/ simulated_instruments / woodblock
Sign in
simulated_instruments / woodblock
woodblock / webapp / src / App.tsx
193 lines · 6.8 KBBlameHistoryRaw
1import { useCallback, useEffect, useRef, useState } from 'react';
2import { DEFAULTS, loadParams, saveParams, type WoodblockParams } from './params';
3import { useRenderer } from './hooks/useRenderer';
4import { encodeWav } from './lib/wav';
5import ParamPanel from './components/ParamPanel';
6import Scene3D from './components/Scene3D';
7import DiagnosticsPanel from './components/DiagnosticsPanel';
8import WaveformPlot from './components/plots/WaveformPlot';
9import SpectrumPlot from './components/plots/SpectrumPlot';
10import SpectrogramPlot from './components/plots/SpectrogramPlot';
11import ContactForcePlot from './components/plots/ContactForcePlot';
12import ModeShapes from './components/plots/ModeShapes';
14export default function App() {
15 const [params, setParams] = useState<WoodblockParams>(() => loadParams());
16 const { result, busy, stage, progress, renderFull } = useRenderer(params);
17 const audioCtxRef = useRef<AudioContext | null>(null);
19 useEffect(() => saveParams(params), [params]);
21 const onChange = useCallback((key: keyof WoodblockParams, value: number | boolean) => {
22 setParams((p) => ({ ...p, [key]: value }));
23 }, []);
25 const reset = useCallback(() => setParams({ ...DEFAULTS }), []);
27 const [looping, setLooping] = useState(false);
28 const loopSrcRef = useRef<AudioBufferSourceNode | null>(null);
30 const getAudioCtx = useCallback((sampleRate: number) => {
31 let ctx = audioCtxRef.current;
32 if (!ctx || ctx.state === 'closed') {
33 ctx = new AudioContext({ sampleRate });
34 audioCtxRef.current = ctx;
35 }
36 void ctx.resume();
37 return ctx;
38 }, []);
40 const makeBuffer = useCallback((ctx: AudioContext, r: NonNullable<typeof result>) => {
41 const buf = ctx.createBuffer(1, r.y.length, r.sampleRate);
42 buf.copyToChannel(new Float32Array(r.y), 0);
43 return buf;
44 }, []);
46 const play = useCallback(() => {
47 if (!result) return;
48 const ctx = getAudioCtx(result.sampleRate);
49 const src = ctx.createBufferSource();
50 src.buffer = makeBuffer(ctx, result);
51 src.connect(ctx.destination);
52 src.start();
53 }, [result, getAudioCtx, makeBuffer]);
55 // Loop mode: repeat the strike continuously, swapping in each new render as
56 // parameters change so edits are audible without pressing Play.
57 useEffect(() => {
58 if (!looping || !result) return;
59 const ctx = getAudioCtx(result.sampleRate);
60 const src = ctx.createBufferSource();
61 src.buffer = makeBuffer(ctx, result);
62 src.loop = true;
63 src.connect(ctx.destination);
64 src.start();
65 loopSrcRef.current = src;
66 return () => {
67 loopSrcRef.current = null;
68 try {
69 src.stop();
70 } catch {
71 // already stopped
72 }
73 src.disconnect();
74 };
75 }, [looping, result, getAudioCtx, makeBuffer]);
77 const download = useCallback(() => {
78 if (!result) return;
79 const blob = encodeWav(result.y, result.sampleRate, params.bit_depth === 16 ? 16 : 24);
80 const url = URL.createObjectURL(blob);
81 const a = document.createElement('a');
82 a.href = url;
83 a.download = result.quality === 'full' ? 'woodblock.wav' : 'woodblock_preview.wav';
84 a.click();
85 setTimeout(() => URL.revokeObjectURL(url), 5000);
86 }, [result, params.bit_depth]);
88 return (
89 <div className="app">
90 <div className="topbar">
91 <h1>Woodblock Simulator</h1>
92 <button className="primary" onClick={renderFull} disabled={busy}>
93 Render
94 </button>
95 <button
96 className={`play ${result?.quality === 'preview' ? 'preview' : ''}`}
97 onClick={play}
98 disabled={!result}
99 title={
100 result?.quality === 'preview'
101 ? 'Playing the fast preview render; press Render for full quality'
102 : 'Play the rendered strike'
103 }
104 >
105 ▶ Play{result?.quality === 'preview' ? ' (preview)' : ''}
106 </button>
107 <button
108 className={`loop ${looping ? 'active' : ''}`}
109 onClick={() => setLooping((v) => !v)}
110 disabled={!result && !looping}
111 title="Repeat the strike continuously so parameter changes are audible immediately"
112 >
113 ⟳ Loop{looping ? ' on' : ''}
114 </button>
115 <button onClick={download} disabled={!result}>
116 Download .wav{result?.quality === 'preview' ? ' (preview)' : ''}
117 </button>
118 <button onClick={reset}>Reset to defaults</button>
119 <div className="status">
120 {result && (
121 <span className={`badge ${result.quality}`}>
122 {result.quality === 'full' ? 'full quality' : 'preview quality — press Render'}
123 </span>
124 )}
125 {busy ? (
126 <>
127 <span>{stage}…</span>
128 <div className="progressbar">
129 <div style={{ width: `${Math.round(progress * 100)}%` }} />
130 </div>
131 </>
132 ) : (
133 <span>ready</span>
134 )}
135 </div>
136 </div>
137 <div className="main">
138 <div className="sidebar">
139 <ParamPanel params={params} onChange={onChange} />
140 </div>
141 <div className="content">
142 <div className="card wide">
143 <h2>Geometry — block, strike, slit, microphone</h2>
144 <Scene3D params={params} />
145 </div>
146 {result && (
147 <>
148 <div className="card">
149 <h2>Diagnostics</h2>
150 <DiagnosticsPanel result={result} />
151 </div>
152 <div className="card">
153 <h2>Contact force</h2>
154 <ContactForcePlot
155 force={result.force}
156 forceFsHz={result.forceFsHz}
157 peakForceN={result.diag.peakForceN}
158 contactMs={result.diag.contactMs}
159 />
160 </div>
161 <div className="card">
162 <h2>Waveform</h2>
163 <WaveformPlot y={result.y} sampleRate={result.sampleRate} />
164 </div>
165 <div className="card">
166 <h2>Spectrum</h2>
167 <SpectrumPlot
168 y={result.y}
169 sampleRate={result.sampleRate}
170 partialsHz={result.diag.partialsHz}
171 helmholtzHz={result.diag.helmholtzHz}
172 />
173 </div>
174 <div className="card">
175 <h2>Spectrogram</h2>
176 <SpectrogramPlot y={result.y} sampleRate={result.sampleRate} />
177 </div>
178 <div className="card">
179 <h2>Mode shapes</h2>
180 <ModeShapes
181 modeShapes={result.modeShapes}
182 shapePts={result.shapePts}
183 partialsHz={result.diag.partialsHz}
184 strikePosition={params.strike_position}
185 />
186 </div>
187 </>
188 )}
189 </div>
190 </div>
191 </div>
192 );
↑↓ move↵openescclose