趣味で音楽をつくっているのですが、
それ用の特設サイトをNext.jsでつくったのにあわせてオーディオプレイヤー(スペクトラムアナライザ付き)をReact+WebAudioでつくってみました。
実際の結果
https://romot.studio
その記録です。
事前
- 単純に再生だけなら
<audio>
だけあればWebAudioいらない - そうでなくてもReact用音声プレイヤーライブラリたくさんあるのでその方がいい
- 今回使わなかったが、スペクトラムアナライザだけでなく
- プレイリスト
- 曲やその再生時間にあわせた背景変更
- 曲の再生秒数にあわせた歌詞など表示
などもつけたかったので必要だったという前提
↓こんな感じで一応実装はしていた
※ イラストはゆーはくさま
要素を置く
プリロードなどが面倒なので、audioタグを利用しています。
/pages/index.js
import { useState, useEffect, useRef } from 'react';
export default function Home() {
const audioRef = useRef(null); // ref経由でaudio要素利用
const timePositionRef = useRef(null); // ref経由でタイムスライダー要素利用
const spectrumRef = useRef(null); // ref経由でcanvas要素利用(スペクトラムアナライザ用)
return (
<>
{/* 開始/停止ボタン */}
<button
type="button"
>
停止
</button>
{/* タイムスライダー */}
<input
type="range"
ref={timePositionRef}
>
{/* audio */}
<audio
src="/demo.mp3"
ref={audioRef}
/>
<canvas className="spectrums" ref={spectrumRef} />
</>
);
}
初期化
初期化し、WebAudio経由でも操作できるようにします。
export default function Home() {
...
const audioCtxRef = useRef(null); // ref経由でAudioContext利用
const [source, setSource] = useState(null); // MediaElementSource
const [analyserNode, setAnalyserNode] = useState(null); // アナライザー
// 初期化
useEffect(() => {
// 初期化時にAudioContextを作成
audioCtxRef.current = new AudioContext();
// createMediaElementSource
const elementSource = audioCtxRef.current.createMediaElementSource(audioRef.current);
// スペクトラムアナライザー用node
const analyser = audioCtxRef.current.createAnalyser();
// nodeを接続
elementSource.connect(analyser).connect(audioCtxRef.current.destination);
setSource(elementSource);
setAnalyserNode(analyser);
},[]);
return (...;
プレイヤー機能をつける
再生・停止、スライダーで位置変更など
export default function Home() {
...
const [playState, setPlayState] = useState('stop'); // 再生状態
const [duration, setDuration] = useState(0); // 曲の総時間
const [timePosition, setTimePosition] = useState(0); // タイムスライダーの位置
// 再生/停止ボタンの操作
const handleTogglePlay = () => {
// 初期状態から再生開始
if (audioCtxRef.current.state === 'suspended'){
audioCtxRef.current.resume();
setPlayState('play');
}
// 再開
if (playState === 'stop') {
audioRef.current.play();
setPlayState('play');
// 停止
if (playState === 'play'){
audioRef.current.pause();
setPlayState('stop');
}
};
// スライダーを操作で再生位置変更
const handleTimeUpdate = () => {
setTimePosition(audioRef.current.currentTime);
};
// スライダーを操作で再生位置変更
const handleChangeTimePosition = (e) => {
const position = parseInt(e.target.value);
setTimePosition(position);
audioRef.current.currentTime = position;
};
// 最後まで再生したら最初に戻り、停止
const handleEnded = () => {
setTimePosition(0);
setPlayState('stop');
};
// 音声メタデータ取得時点で総合計時間を設定
const handleLoadedMetadata = () => {
const duration = audioRef.current.duration;
setDuration(duration);
};
return (
<>
{/* 開始/停止ボタン */}
<button
...
onClick={handleTogglePlay}
>
{playState === 'stop' && '開始'}
{playState === 'play' && '停止'}
</button>
{/* タイムスライダー */}
<input
...
min={0}
max={duration}
value={timePosition}
onInput={handleChangeTimePosition}
>
{/* audio */}
<audio
...
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
/>
</>
);
スペクトラムアナライザ
どこかで見たものをほぼそのまま使いましたが、ソースが見つかりませんでした(ごめんなさい)
上の画像だと背景画像がアナライザ的に色が変わっていますが、同じ形でCSSのfilterでざっくりやっています。
export default function Home() {
...
useEffect(() => {
if (source && analyserNode && playState === 'play') {
const canvas = spectrumRef.current;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const canvasCtx = canvas.getContext('2d');
analyserNode.fftSize = 16384; // FFTサイズ
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const barWidth = 1; // バーの横幅
let barHeight;
let x = 0;
function renderFrame() {
requestAnimationFrame(renderFrame);
x = 0;
analyserNode.getByteFrequencyData(dataArray);
canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
let bars = 128; // バーの数
for (let i = 0; i < bars; i++) {
barHeight = (dataArray[i]);
canvasCtx.fillStyle = `rgba(255,255,255,0.8)`;
canvasCtx.fillRect(x, (canvasHeight - barHeight), barWidth, barHeight);
x += barWidth + (canvasWidth / 128);
}
}
renderFrame();
}
},[playState]); // 再生状態からhook
...
return (...
全体
import { useState, useEffect, useRef } from 'react';
export default function Home() {
const [playState, setPlayState] = useState('stop');
const [duration, setDuration] = useState(0);
const [timePosition, setTimePosition] = useState(0);
const [source, setSource] = useState(null);
const [analyserNode, setAnalyserNode] = useState(null);
const audioRef = useRef(null);
const audioCtxRef = useRef(null);
const timePositionRef = useRef(null);
const spectrumRef = useRef(null);
useEffect(() => {
audioCtxRef.current = new AudioContext();
const elementSource = audioCtxRef.current.createMediaElementSource(audioRef.current);
const analyser = audioCtxRef.current.createAnalyser();
elementSource.connect(analyser).connect(audioCtxRef.current.destination);
setSource(elementSource);
setAnalyserNode(analyser);
},[]);
useEffect(() => {
if (source && analyserNode && playState === 'play') {
const canvas = spectrumRef.current;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const canvasCtx = canvas.getContext('2d');
analyserNode.fftSize = 16384;
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const barWidth = 1;
let barHeight;
let x = 0;
function renderFrame() {
requestAnimationFrame(renderFrame);
x = 0;
analyserNode.getByteFrequencyData(dataArray);
canvasCtx.clearRect(0 ,0, canvasWidth, canvasHeight);
let bars = 128;
for (let i = 0; i < bars; i++) {
barHeight = (dataArray[i]);
canvasCtx.fillStyle = `rgba(255,255,255,0.8)`;
canvasCtx.fillRect(x, (canvasHeight - barHeight), barWidth, barHeight);
x += barWidth + (canvasWidth / 128);
}
}
renderFrame();
}
},[playState]);
const handleTogglePlay = () => {
if (audioCtxRef.current.state === 'suspended'){
audioCtxRef.current.resume();
setPlayState('play');
}
if (playState === 'stop') {
audioRef.current.play();
setPlayState('play');
}
if (playState === 'play') {
audioRef.current.pause();
setPlayState('stop');
}
};
const handleTimeUpdate = () => {
setTimePosition(audioRef.current.currentTime);
};
const handleEnded = () => {
setTimePosition(0);
setPlayState('stop');
};
const handleLoadedMetadata = () => {
const duration = audioRef.current.duration;
setDuration(duration);
};
const handleChangeTimePosition = (e) => {
const position = parseInt(e.target.value);
setTimePosition(position);
audioRef.current.currentTime = position;
};
return (
<>
<button
type="button"
onClick={handleTogglePlay}
>
{playState === 'stop' && '開始'}
{playState === 'play' && '停止'}
</button>
<input
type="range"
min={0}
max={duration}
value={timePosition}
onInput={handleChangeTimePosition}
>
<audio
src="/demo.mp3"
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
/>
<canvas className="spectrums" ref={spectrumRef} />
</>
)
}
なにかの参考になれば幸いです。
間違いなどありましたらおしらせください。