0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

@wavesurfer/react の TimelinePlugin・RegionsPlugin について

Posted at

Plugin の解説

  1. TimelinePlugin
    波形の下にタイムライン(時間軸)を表示するプラグインです.

  2. RegionsPlugin
    波形上に「領域(Region)」を追加・管理できるプラグインです.

ポイント:

  • useMemoを使ってプラグインのインスタンスをメモ化し,不要な再生成を防ぎます.
  • プラグインは,useWavesurfer の plugins オプションに渡します.
  • wavesurfer や各プラグインのイベントリスナーの設定が重要です.
    • 例 1: wavesurfer?.once("ready", () => wavesurfer.setTime(0.2));
    • 例 2: regionsPlugin.on("region-updated", (region) => onUpdatedRegion(region.id));
  • この実装では,コンポーネントを使用する際にuseRefで参照を取得し,その参照を通じて操作を行えるようにしています.(必ずしもこの方法に従う必要はありません)

イメージ図

image.png

実装コード

AudioAnnotator.tsx
"use client";

import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle } from "react";
import { Box, Button, Slider, Typography, Grid2 } from "@mui/material";
import { Add, PlayArrow, Pause } from "@mui/icons-material";
import { useWavesurfer } from "@wavesurfer/react";
import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline.esm.js";
import RegionsPlugin, { Region } from "wavesurfer.js/dist/plugins/regions.esm.js";

export const random = (min: number, max: number) => Math.random() * (max - min) + min;

export const randomColor = () => `rgba(${random(0, 255)}, ${random(0, 255)}, ${random(0, 255)}, 0.5)`;

type AudioAnnotatorProps = {
    audio: { url: string; name: string; size: number };
    onAddRegion: (regionId: string) => void;
    onUpdatedRegion: (regionId: string) => void;
};

type AudioAnnotatorRef = {
    playPause: () => Promise<void> | undefined;
    playRegion: (regionId: string) => void;
    getRegions: () => Region[];
    addRegion: () => void;
    removeRegion: (regionId: string) => void;
};

export const AudioAnnotator = forwardRef<AudioAnnotatorRef, AudioAnnotatorProps>(
    ({ audio, onAddRegion, onUpdatedRegion }, ref) => {
        const containerRef = useRef<HTMLDivElement>(null);

        const [zoomLevel, setZoomLevel] = useState<number>(100);
        const [regionColor, setRegionColor] = useState<string>(randomColor());

        const timelinePlugin = useMemo(
            () =>
                TimelinePlugin.create({
                    height: 20,
                    timeInterval: audio.size < 200_000 ? 0.5 : undefined, // 200 KB 以下なら小さい目盛りを設定
                    primaryLabelInterval: audio.size < 200_000 ? 1 : undefined, // 同上
                    style: { fontSize: "12px", top: "24px", marginBottom: "20px" },
                }),
            [audio.size]
        );
        const regionsPlugin = useMemo(() => RegionsPlugin.create(), []);

        const { wavesurfer } = useWavesurfer({
            container: containerRef,
            waveColor: "rgb(200, 0, 200)",
            progressColor: "rgb(100, 0, 100)",
            url: audio.url,
            plugins: useMemo(() => [timelinePlugin, regionsPlugin], [timelinePlugin, regionsPlugin]),
        });

        const handlePlayRegion = useCallback(
            (regionId: string) => {
                if (!wavesurfer) return;

                const region = regionsPlugin.getRegions().find((region) => region.id === regionId);
                if (!region) return;

                const handlePlaying = (current: number) => {
                    if (current > region.end) {
                        wavesurfer.pause();
                        wavesurfer.un("timeupdate", handlePlaying);
                    }
                };

                wavesurfer.setTime(region.start);
                wavesurfer.on("timeupdate", handlePlaying);
                wavesurfer.play();
            },
            [wavesurfer, regionsPlugin]
        );

        const handleAddRegion = useCallback(() => {
            const regions = regionsPlugin.getRegions();
            const lastRegionEnd = regions.length > 0 ? regions[regions.length - 1].end : 0;
            const startTime = lastRegionEnd ? 1.05 * lastRegionEnd : 0;

            if (wavesurfer && wavesurfer.getDuration() > startTime) {
                const region = regionsPlugin.addRegion({
                    // content: `${regions.length + 1}`,
                    start: startTime,
                    end: startTime + 1,
                    color: regionColor,
                });
                setRegionColor(randomColor());
                onAddRegion(region.id);
            }
        }, [wavesurfer, regionsPlugin, regionColor, onAddRegion]);

        const handleRemoveRegion = useCallback(
            (regionId: string) => {
                const regions = regionsPlugin.getRegions();
                if (regions.length > 0) {
                    regionsPlugin.clearRegions();
                    regions.filter((region) => region.id !== regionId).forEach((region) => regionsPlugin.addRegion(region));
                }
            },
            [regionsPlugin]
        );

        // 外部から参照できるようにする
        useImperativeHandle(
            ref,
            () => ({
                playPause: () => wavesurfer?.playPause(),
                playRegion: (regionId: string) => handlePlayRegion(regionId),
                getRegions: () => regionsPlugin.getRegions(),
                addRegion: () => handleAddRegion(),
                removeRegion: (regionId: string) => handleRemoveRegion(regionId),
            }),
            [wavesurfer, regionsPlugin, handlePlayRegion, handleAddRegion, handleRemoveRegion]
        );

        useEffect(() => {
            regionsPlugin.on("region-updated", (region) => onUpdatedRegion(region.id));
        }, [wavesurfer, regionsPlugin, onUpdatedRegion]);

        useEffect(() => {
            wavesurfer?.once("ready", () => wavesurfer.setTime(0.2));
        }, [wavesurfer]);

        useEffect(() => {
            wavesurfer?.once("ready", () => wavesurfer.zoom(zoomLevel));
        }, [wavesurfer, zoomLevel]);

        const handleZoom = (event: Event, newValue: number | number[]) => {
            setZoomLevel(newValue as number);
            wavesurfer?.zoom(newValue as number);
        };

        return (
            <Box>
                <Box ref={containerRef} sx={{ mt: 2, mx: 1, pb: 3, backgroundColor: "rgba(0, 0, 0, 0.02)" }} />
                <Grid2 container spacing={2} alignItems="center">
                    <Grid2>
                        <Button variant="contained" color="primary" onClick={() => wavesurfer?.playPause()} sx={{ p: 0.75 }}>
                            <PlayArrow /> / <Pause />
                        </Button>
                    </Grid2>
                    <Grid2>
                        <Button
                            variant="contained"
                            onClick={handleAddRegion}
                            startIcon={<Add />}
                            sx={{ py: 0.75, backgroundColor: regionColor, color: "black" }}
                        >
                            Region
                        </Button>
                    </Grid2>
                    <Grid2 size="grow" sx={{ mr: 5, display: "flex", alignItems: "center" }}>
                        <Typography variant="body2" sx={{ mx: 2 }}>
                            Zoom:
                        </Typography>
                        <Slider value={zoomLevel} onChange={handleZoom} min={1} max={100} step={10} />
                    </Grid2>
                </Grid2>
            </Box>
        );
    }
);

AudioAnnotator.displayName = "AudioAnnotator";

使い方(抜粋)

    const waveformRef = useRef<{
        playPause: () => Promise<void> | undefined;
        playRegion: (regionId: string) => void;
        getRegions: () => Region[];
        addRegion: () => void;
        removeRegion: (regionId: string) => void;
    }>(null);

    const handleAddRegion = () => {
        if (!waveformRef.current) return;

        const regions = waveformRef.current.getRegions().map(({ id, start, end, color }) => ({
            id,
            start: parseFloat(start.toFixed(2)),
            end: parseFloat(end.toFixed(2)),
            color,
        }));
        setAnnotations(regions.map((item) => ({ ...item, label: "" })));
    };

    return (
        <AudioAnnotator
            ref={waveformRef}
            audio={audio}
            onAddRegion={handleAddRegion}
            onUpdatedRegion={handleUpdatedRegion}
        />
    );
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?