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

### 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/627085/4b509f65-e16c-da16-a569-cae52e7a5432.png)

#### **実装コード**

```tsx: 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";
```

#### 使い方（抜粋）

```tsx:
    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}
        />
    );
```