はじめに
前回の記事では、音に反応して3Dビジュアルを生成する Audio Reactive 3D Visualizer を作った話を書きました。
今回はそのReact編です。
このアプリは、Three.js / Web Audio API / WebCodecs などを使っていますが、React側でやっていることは単に画面を並べるだけではありません。
音源の読み込み、解析状態、再生状態、表示モード、プリセット、エフェクト、Live / VJ Mode、MP4書き出しまで、アプリ全体の体験をReactでつないでいます。
この記事では、実際のソースをもとに、
- Reactをどこに使ったか
- Canvas / Three.js とReactをどう分離したか
- Zustandでどの状態を共有したか
- 音源アップロードから解析までの流れ
- 3D / Wave / Image FXの表示モード切り替え
- MP4書き出しをReact側からどう呼び出したか
- 作っていて難しかったReact設計
について書きます。
リポジトリはこちらです。
https://github.com/7g3n/phase-viz
作ったもの
Audio Reactive 3D Visualizer は、ブラウザ上で音源を読み込み、その音に反応するビジュアルを生成するWebアプリです。
主な機能は以下です。
- 音源ファイルの読み込み
- 音声解析
- 3D Visualizer
- Wave Visualizer
- Image FX
- Live / VJ Mode
- 1920x1080 / 30fps のMP4書き出し
React編として見ると、このアプリは大きく以下のような構成になっています。
src/
App.tsx
main.tsx
store.ts
theme.ts
ui/
Uploader.tsx
Controls.tsx
Timeline.tsx
VisualizerCanvas.tsx
WaveVisualizer.tsx
ImageFXVisualizer.tsx
LiveVJHelp.tsx
audio/
analyze.ts
bpm.ts
fft.ts
visual/
scene.ts
particles.ts
presets.ts
export/
recorder.ts
webcodecs.ts
ffmpeg.ts
download.ts
Reactコンポーネントは、主に src/ui/ に集めています。
ただし、3D描画そのものをReactのJSXで全部書いているわけではありません。
このアプリでは、ReactはUIと状態管理を担当し、描画の重い部分はCanvas / Three.js側に寄せています。
Reactの役割
このプロジェクトでReactに持たせた役割は、大きく分けると以下です。
React
├─ 画面レイアウト
├─ 音源・画像アップロードUI
├─ 表示モード切り替え
├─ プリセット・エフェクト操作
├─ 再生タイムライン
├─ Live / VJ ModeのUI
├─ MP4書き出し操作
└─ 状態管理の入口
一方で、以下はReactに持たせすぎないようにしました。
Reactに持たせないもの
├─ 毎フレームの3D描画処理
├─ 大量のパーティクル更新
├─ Canvas 2Dの描画命令
├─ WebGLの低レベルな状態
└─ エクスポート時のフレーム描画ループ
理由はシンプルで、音声反応ビジュアルは毎フレーム動くからです。
Reactのstate更新で毎フレーム描画しようとすると、コンポーネントの再レンダリングが増えすぎます。
そのため、Reactは「現在どのモードか」「どのプリセットか」「再生中か」「エクスポート中か」などのアプリ状態を持ち、実際の描画はCanvas側で回す構成にしました。
エントリーポイント
エントリーポイントはかなりシンプルです。
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
main.tsx では、App をReact rootに描画するだけです。
MUI用にRobotoフォントもここで読み込んでいます。
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
アプリ本体のほとんどの責務は App.tsx と store.ts、そして ui/ 以下のコンポーネントに分けています。
App.tsxはアプリの司令塔
App.tsx は、このアプリ全体のレイアウトと大きな操作をまとめています。
主にやっていることは以下です。
- MUI ThemeProviderの適用
- ヘッダー、左パネル、中央Canvas、右コントロール、タイムラインの配置
- 表示モードに応じたVisualizerの切り替え
- Live / VJ Modeのショートカット管理
- フルスクリーン制御
- MP4エクスポート処理
- エクスポート中断処理
特に重要なのは、Visualizer本体を遅延読み込みしているところです。
const VisualizerCanvas = React.lazy(() => import('./ui/VisualizerCanvas'));
const WaveVisualizer = React.lazy(() => import('./ui/WaveVisualizer'));
const ImageFXVisualizer = React.lazy(() => import('./ui/ImageFXVisualizer'));
このアプリには3つの表示モードがあります。
visualizer3d
wave
imageFx
それぞれ処理が重めなので、最初から全部読むより、必要になったタイミングで読み込むようにしています。
表示切り替えはざっくりこんな形です。
<Suspense fallback={<div style={{ width: '100%', height: '100%' }} />}>
{displayMode === 'imageFx' ? (
<ImageFXVisualizer exportRendererRef={exportRendererRef} />
) : displayMode === 'wave' ? (
<WaveVisualizer exportRendererRef={exportRendererRef} />
) : (
<VisualizerCanvas
recorderRef={recorderRef}
exportRendererRef={exportRendererRef}
/>
)}
</Suspense>
React側では「どのVisualizerを表示するか」だけを決めます。
3D描画やCanvas描画の中身は、それぞれのコンポーネント内に閉じ込めています。
Zustandでアプリ状態をまとめる
状態管理にはZustandを使っています。
このアプリでは共有したい状態がかなり多くあります。
たとえば以下です。
export type DisplayMode = 'visualizer3d' | 'wave' | 'imageFx';
export type PresetId = 'minimal' | 'neon' | 'glitch' | 'organic';
export type WaveVisualizerType = 'horizontal' | 'circular' | 'bars';
export type ImageFxPreset = 'clean' | 'glitch' | 'dreamy' | 'dark' | 'vhs';
音源・解析結果・再生状態もstoreに持たせています。
export interface AppState {
audioFile: File | null;
audioBuffer: AudioBuffer | null;
analysis: AudioAnalysis | null;
isAnalyzing: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
displayMode: DisplayMode;
preset: PresetId;
isExporting: boolean;
exportProgress: number;
exportError: string | null;
exportStatus: string | null;
fps: number;
isFullscreen: boolean;
}
音声解析の結果は AudioAnalysis としてまとめています。
export interface AudioAnalysis {
bpm: number;
loudness: number;
waveform: Float32Array;
spectrum: Float32Array[];
transientMap: number[];
stereoWidth: number;
mood: MoodId;
energy: EnergyLevel;
duration: number;
}
この設計にした理由は、複数のコンポーネントが同じ情報を必要とするからです。
たとえば、
-
Uploaderは音源を読み込んでaudioBufferとanalysisを保存する -
TimelineはisPlaying,currentTime,durationを使う -
VisualizerCanvasはanalysis,audioBuffer,preset,effectsを使う -
Controlsはプリセット、エフェクト、表示モード、エクスポート状態を操作する -
Appはエクスポート全体を開始・中断する
という関係になっています。
propsで全部バケツリレーするとかなりつらいので、Zustandでまとめました。
音源アップロード
音源アップロードは Uploader.tsx で行っています。
流れは以下です。
Fileを受け取る
↓
拡張子をチェック
↓
ArrayBufferに変換
↓
AudioContext.decodeAudioData()
↓
AudioBufferをstoreに保存
↓
analyzeAudio()で解析
↓
解析結果をstoreに保存
実装イメージはこんな感じです。
const handleAudioFile = useCallback(
async (file: File) => {
if (!file.name.match(/\.(wav|mp3|flac|ogg|aac)$/i)) return;
setAudioFile(file);
setIsAnalyzing(true);
try {
const arrayBuffer = await file.arrayBuffer();
const ctx = new AudioContext();
const buffer = await ctx.decodeAudioData(arrayBuffer);
setAudioBuffer(buffer);
setDuration(buffer.duration);
const analysis = await analyzeAudio(buffer);
setAnalysis(analysis);
} catch (e) {
console.error('Analysis failed:', e);
} finally {
setIsAnalyzing(false);
}
},
[setAudioFile, setAudioBuffer, setAnalysis, setIsAnalyzing, setDuration],
);
ここで重要なのは、音源をサーバーへ送らないことです。
ローカルファイルをブラウザ内で読み込み、ブラウザ内で解析します。
音楽制作者向けのツールとして考えた時、制作中の音源を外部にアップロードせずに使えることはかなり大事でした。
画像も同じようにブラウザ内で扱います。
const handleImageFile = useCallback(
(file: File) => {
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/)) return;
const url = URL.createObjectURL(file);
setBackgroundImageUrl(url);
},
[setBackgroundImageUrl],
);
背景画像は URL.createObjectURL() で一時URLにして、Visualizer側で読み込みます。
削除時には URL.revokeObjectURL() も呼ぶようにしています。
Controls.tsxで操作系をまとめる
Controls.tsx は、右側の操作パネルです。
ここでは以下を操作できます。
- Display Mode
- Live / VJ Mode
- Analysis表示
- 3Dプリセット
- Camera / Morph
- Particle設定
- Effects
- Layer Order
- Wave Visualizer設定
- Image FX設定
- MP4 Export
Display Modeは ToggleButtonGroup で切り替えています。
<ToggleButtonGroup
value={displayMode}
exclusive
onChange={(_, value) => value && setDisplayMode(value as DisplayMode)}
orientation="vertical"
fullWidth
size="small"
disabled={isExporting}
>
<ToggleButton value="visualizer3d">
3D Visualizer Mode
</ToggleButton>
<ToggleButton value="wave">
Wave Visualizer Mode
</ToggleButton>
<ToggleButton value="imageFx">
Image FX Mode
</ToggleButton>
</ToggleButtonGroup>
表示モードの値はZustandに保存されます。
その値を App.tsx が見て、中央に表示するVisualizerを切り替えます。
つまり、Controlsは直接Canvasを触りません。
Controls
↓ setDisplayMode()
store
↓ displayMode
App
↓
VisualizerCanvas / WaveVisualizer / ImageFXVisualizer
この一方向の流れにしたことで、操作パネルと描画側の依存を減らせました。
Timelineで再生状態を管理する
Timeline.tsx は、再生・停止・シーク・FPS表示を担当しています。
const {
isPlaying,
currentTime,
duration,
fps,
setIsPlaying,
setCurrentTime,
analysis,
} = useStore();
再生ボタンは、解析結果がない場合は押せないようにしています。
const handlePlayPause = () => {
if (!analysis) return;
setIsPlaying(!isPlaying);
};
停止時は再生状態をfalseにして、現在時刻を0に戻します。
const handleStop = () => {
setIsPlaying(false);
setCurrentTime(0);
};
シークバーは currentTime を直接更新します。
<Slider
value={currentTime}
min={0}
max={duration || 1}
step={0.01}
onChange={(_, v) => setCurrentTime(v as number)}
disabled={!analysis}
/>
ここも、Timelineが音声を直接鳴らすわけではありません。
Timelineはstoreの再生状態を変えるだけです。
実際にAudioContextを作って音を再生するのはVisualizer側です。
VisualizerCanvasでReactとThree.jsをつなぐ
3D表示は VisualizerCanvas.tsx が担当しています。
ここではReactの useRef をかなり使っています。
const canvasRef = useRef<HTMLCanvasElement>(null);
const sceneRef = useRef<VisualizerScene | null>(null);
const rafRef = useRef<number>(0);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
useState ではなく useRef を使っている理由は、毎フレーム変わる値でReactの再レンダリングを起こしたくないからです。
たとえば、以下のようなものはReact stateにしませんでした。
- requestAnimationFrameのID
- AudioContext
- AnalyserNode
- AudioBufferSourceNode
- Three.jsのScene管理インスタンス
- 周波数データ配列
- 時間領域データ配列
これらはUIの表示というより、描画ループ内部の状態です。
Reactに乗せるより、refで保持した方が自然でした。
Three.jsシーンの初期化
Canvasがマウントされたら、VisualizerScene を作ります。
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const scene = new VisualizerScene(canvas);
sceneRef.current = scene;
const onResize = () => {
if (useStore.getState().isExporting) return;
const el = canvas.parentElement;
if (!el) return;
const w = el.clientWidth;
const h = el.clientHeight;
scene.resize(w, h);
};
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(rafRef.current);
scene.dispose();
};
}, []);
Reactコンポーネントがmountされた時にThree.jsの世界を作り、unmount時に破棄します。
この境界をはっきりさせると、ReactとThree.jsの責務が分かれます。
React
└─ Canvasを置く
Three.js
└─ Canvasの中身を描く
ReactはCanvasの親であり、Three.jsはCanvasの中で動く描画エンジンです。
再生中の音声解析
再生中は AudioContext と AnalyserNode を作ります。
useEffect(() => {
if (!audioBuffer || !isPlaying) return;
const ctx = new AudioContext();
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.75;
analyser.connect(ctx.destination);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(analyser);
source.start(0, useStore.getState().currentTime);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
sourceRef.current = source;
return () => {
source.disconnect();
ctx.close();
audioCtxRef.current = null;
analyserRef.current = null;
sourceRef.current = null;
};
}, [audioBuffer, isPlaying]);
isPlaying がtrueになったタイミングで再生を開始し、falseになったりコンポーネントが破棄されたりしたらAudioContextを閉じます。
ここで注意したのは、Reactの再レンダリングと音声再生のライフサイクルを混ぜすぎないことです。
音声再生はブラウザAPIの世界なので、React stateだけで無理に管理しようとすると複雑になります。
React側は「再生中かどうか」を持つ。
実際の再生ノードはrefで持つ。
この分け方にしました。
requestAnimationFrameの描画ループ
3D描画は requestAnimationFrame で回しています。
ただし、無制限に描画すると重くなるので、30fps相当に制限しています。
const RENDER_FPS_LIMIT = 30;
const RENDER_FRAME_INTERVAL_MS = 1000 / RENDER_FPS_LIMIT;
描画ループでは、まず前回描画から十分な時間が経ったかを見ます。
const elapsedSinceRender = timestamp - lastRenderTimeRef.current;
if (elapsedSinceRender < RENDER_FRAME_INTERVAL_MS) {
return;
}
そのうえで、AnalyserNodeから周波数データを取得します。
analyser.getByteFrequencyData(freqData);
const binCount = freqData.length;
const bassEnd = Math.max(1, Math.floor(binCount * 0.05));
const midEnd = Math.max(bassEnd + 1, Math.floor(binCount * 0.35));
低域・中域・高域をざっくり分けて、0〜1の値にします。
for (let i = 0; i < bassEnd; i++) bass += freqData[i];
bass = bass / bassEnd / 255;
for (let i = bassEnd; i < midEnd; i++) mid += freqData[i];
mid = mid / (midEnd - bassEnd) / 255;
for (let i = midEnd; i < binCount; i++) high += freqData[i];
high = high / (binCount - midEnd) / 255;
そして、その値をThree.js側に渡します。
scene.update(
dt,
analysis?.bpm ?? 120,
bass,
mid,
high,
transient,
waveformL,
waveformR,
effects,
bloomStrength,
);
scene.render();
React側で毎フレームJSXを更新するのではなく、Canvas内部の描画エンジンに数値を渡して更新します。
ここがこのアプリのReact設計でかなり重要なところです。
表示モードごとにCanvas実装を分ける
このアプリには3つの表示モードがあります。
3D Visualizer
Wave Visualizer
Image FX
最初は全部1つのCanvasコンポーネントにまとめることも考えました。
でも、やっていることがかなり違います。
3D Visualizer
└─ Three.js / WebGL
Wave Visualizer
└─ Canvas 2Dで波形・円形波形・バー表示
Image FX
└─ Canvas 2Dで画像加工・グロー・RGB Shift・ノイズ
そのため、以下のように分けました。
VisualizerCanvas.tsx
WaveVisualizer.tsx
ImageFXVisualizer.tsx
共通しているのは、
audioBufferanalysisisPlayingcurrentTimebackgroundImageUrlisExportingexportRendererRef
などをstoreから読むことです。
一方、描画方法はそれぞれのコンポーネントに閉じています。
これにより、3D側を触ってもWave側に影響しづらくなります。
MP4書き出しをReactから呼ぶ
このアプリでは、現在表示しているモードをそのままMP4として書き出せます。
React側で面白いのは、エクスポート処理を exportRendererRef 経由で受け取っているところです。
const exportRendererRef = useRef<ExportFrameRenderer | null>(null);
ExportFrameRenderer は以下のような型です。
export interface ExportRenderOptions {
duration: number;
fps: number;
onProgress: (progress: number) => void;
onStatus?: (status: string) => void;
signal?: AbortSignal;
}
export type ExportFrameRenderer = (
options: ExportRenderOptions
) => Promise<Blob>;
各Visualizerは、自分の描画方法に応じたエクスポート関数を exportRendererRef.current に登録します。
useEffect(() => {
exportRendererRef.current = renderExportFrames;
return () => {
if (exportRendererRef.current === renderExportFrames) {
exportRendererRef.current = null;
}
};
}, [exportRendererRef, renderExportFrames]);
App.tsx 側は、現在のVisualizerが3DなのかWaveなのかImage FXなのかを意識しません。
ただ exportRendererRef.current() を呼ぶだけです。
const blob = await exportRendererRef.current({
duration: analysis.duration,
fps,
signal: abortController.signal,
onProgress: reportProgress,
onStatus: setExportStatus,
});
この設計にしたことで、エクスポート開始ボタンは1つで済みます。
App
↓ exportRendererRef.current()
現在表示中のVisualizer
↓
各モード専用のMP4書き出し
モードごとの差分はVisualizer側に閉じ込め、App側は共通インターフェースだけを見ます。
これはかなり扱いやすかったです。
AbortControllerで書き出しを中断できるようにする
MP4書き出しは重い処理です。
長い音源や重いビジュアルでは、途中で止めたくなることがあります。
そのため、React側では AbortController を使っています。
const exportAbortRef = useRef<AbortController | null>(null);
エクスポート開始時に作成します。
const abortController = new AbortController();
exportAbortRef.current = abortController;
エクスポート関数には signal を渡します。
const blob = await exportRendererRef.current({
duration: analysis.duration,
fps,
signal: abortController.signal,
onProgress: reportProgress,
onStatus: setExportStatus,
});
キャンセル時は abort() を呼びます。
const handleCancelExport = () => {
exportAbortRef.current?.abort();
};
React側は「中断の意思」を渡すだけで、実際にどこで止めるかはエクスポート処理側で見ます。
この分離も、UIと処理を分けるうえで大事でした。
Live / VJ Mode
Live / VJ Modeでは、UIを隠してフルスクリーンで表示できます。
React側では、主に以下を管理しています。
isLiveMode: boolean;
liveUiVisible: boolean;
liveHelpOpen: boolean;
liveIntensity: number;
liveBoost: boolean;
Live Mode中は、キーボードショートカットも使えます。
たとえば、
H UI表示/非表示
F フルスクリーン
Space 一時的なBoost
↑↓ Intensity変更
←→ プリセット切り替え
1〜5 プリセット直接選択
? ヘルプ表示
Esc ヘルプ/フルスクリーン終了
キーボードイベントは App.tsx の useEffect で登録しています。
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const state = useStore.getState();
if (!state.isLiveMode || state.isExporting || isEditableTarget(event.target)) {
return;
}
// shortcut handling...
};
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, []);
ここでは useStore.getState() を使っています。
Reactのレンダー時点の値ではなく、ショートカットが押された瞬間の最新stateを読みたいからです。
Live / VJ Modeは通常のUI操作とは少し違って、リアルタイム性が大事です。
そのため、必要なところではZustandのstoreを直接読みに行くようにしました。
MUI Themeで見た目を統一する
UIにはMaterial UIを使っています。
テーマは theme.ts にまとめています。
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: cyan[400],
light: cyan[200],
dark: cyan[700],
},
secondary: {
main: teal[400],
light: teal[200],
dark: teal[700],
},
background: {
default: '#050508',
paper: '#0d0d14',
},
},
});
このアプリは音楽制作用のツールなので、一般的な管理画面っぽさよりも、少し暗くて映像ツールっぽい見た目に寄せています。
ただ、派手にしすぎるとビジュアル本体の邪魔になります。
そのため、UIは暗めにして、アクセントカラーだけを使うようにしました。
作っていて難しかったところ
1. React stateにするものとrefにするものの分離
一番大事だったのは、何をReact stateに乗せるかです。
毎フレーム変わる値を全部stateに入れると、再レンダリングが増えすぎます。
そのため、以下はstoreやstateに入れました。
store/stateに入れるもの
├─ 再生中か
├─ 現在時刻
├─ 表示モード
├─ プリセット
├─ エフェクト設定
├─ エクスポート状態
└─ UIに表示したいFPS
一方、以下はrefにしました。
refに入れるもの
├─ AudioContext
├─ AnalyserNode
├─ requestAnimationFrame ID
├─ Three.js Scene
├─ 周波数データ配列
├─ 時間領域データ配列
└─ エクスポート関数
UIとして表示したいものはstate。
描画ループ内部だけで使うものはref。
この分け方をかなり意識しました。
2. ReactとCanvasの境界
ReactでCanvasを扱う時、Reactに描画を任せるのか、Canvas側に任せるのかで設計が変わります。
このアプリでは、Canvas側に寄せました。
理由は、音声反応ビジュアルはリアルタイムに動き続けるからです。
ReactはCanvasを配置する。
Canvas内部はThree.jsやCanvas 2Dが描く。
この境界を決めたことで、パフォーマンスも考えやすくなりました。
3. 表示モードごとの責務分離
3D、Wave、Image FXは、見た目は同じアプリ内のモードですが、実装はかなり違います。
最初から分けておかないと、1つの巨大なVisualizerになってしまいます。
そのため、
VisualizerCanvas.tsx
WaveVisualizer.tsx
ImageFXVisualizer.tsx
に分離しました。
それぞれが、
- リアルタイム描画
- エクスポート用描画
- Canvasサイズ管理
- AudioBufferからのフレーム取得
を持っています。
共通化しすぎるより、モードごとの違いを許容した方が実装しやすかったです。
4. エクスポート処理の共通化
表示モードごとに描画方法は違います。
でも、UI側から見ると「MP4を書き出す」という操作は1つです。
そこで、ExportFrameRenderer という共通の型を用意しました。
export type ExportFrameRenderer = (
options: ExportRenderOptions
) => Promise<Blob>;
各Visualizerがこの型に合う関数を登録すれば、App側は中身を知らなくて済みます。
この設計は、あとからモードを増やす時にも使えます。
たとえば将来的に、
Lyrics Visualizer
Particle Only Mode
Album Cover Motion Mode
のようなモードを追加しても、同じインターフェースでエクスポートできます。
改善したいところ
React側で今後改善したいところもあります。
- コンポーネントが大きくなっている部分を分割したい
-
Controls.tsxは設定項目が多いのでさらに整理したい - Live / VJ Modeのショートカット定義を外に出したい
- Visualizerごとの共通処理をhook化したい
- エクスポート状態管理をもう少し専用hookに寄せたい
- モバイル・レスポンシブ対応を強化したい
- アクセシビリティを改善したい
- プリセット保存機能を追加したい
特に Controls.tsx は、機能が増えるほど肥大化しやすい部分です。
今後は、
DisplayModeControls
PresetControls
ParticleControls
EffectControls
ExportControls
LiveModeControls
LayerOrderControls
のように分けても良さそうです。
まとめ
今回は、Audio Reactive 3D Visualizer のReact実装について書きました。
このアプリでReactが担当しているのは、単なるUIではありません。
音源を読み込み、解析結果をstoreに置き、表示モードを切り替え、Canvas描画とWeb Audio APIをつなぎ、MP4書き出しまで操作できるようにしています。
ただし、すべてをReactでやろうとはしていません。
毎フレーム動くもの、Canvas内部で完結するもの、AudioContextのようなブラウザAPIのインスタンスは、React stateではなくrefや専用クラスに逃がしています。
自分の中では、このプロジェクトのReact設計は以下のような感覚です。
Reactは描画エンジンではなく、
音楽・映像・UI・書き出しをつなぐ司令塔。
Three.jsやWeb Audio APIのような低レベル寄りの処理と、ReactのUIをどう共存させるかは、クリエイティブコーディング系のWebアプリではかなり重要だと思います。
この実装が、音声反応ビジュアルやブラウザ上の制作ツールを作りたい人の参考になれば嬉しいです。