Plugin の解説
-
TimelinePlugin
波形の下にタイムライン(時間軸)を表示するプラグインです. -
RegionsPlugin
波形上に「領域(Region)」を追加・管理できるプラグインです.
ポイント:
- useMemoを使ってプラグインのインスタンスをメモ化し,不要な再生成を防ぎます.
- プラグインは,useWavesurfer の
plugins
オプションに渡します. - wavesurfer や各プラグインのイベントリスナーの設定が重要です.
- 例 1:
wavesurfer?.once("ready", () => wavesurfer.setTime(0.2));
- 例 2:
regionsPlugin.on("region-updated", (region) => onUpdatedRegion(region.id));
- 例 1:
-
この実装では,コンポーネントを使用する際に
useRef
で参照を取得し,その参照を通じて操作を行えるようにしています.(必ずしもこの方法に従う必要はありません)
イメージ図
実装コード
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}
/>
);