1/**
2 * The page: voices (named, trained by example), a take (recorded once,
3 * segmented, classified onto the voices), a timeline (the take as editable
4 * events), and playback through the renderer seam in render/types.ts. All
5 * analysis is offline on the recorded clip; nothing here is realtime.
6 */
7import type { Clip } from './audio/recorder.ts';
8import { Recorder } from './audio/recorder.ts';
9import { detectOnsets } from './analysis/onsets.ts';
10import { extractHit } from './analysis/features.ts';
11import { classify, type TrainingSet } from './analysis/classify.ts';
12import { renderSynth } from './render/synth.ts';
13import { gainFromPeakDb, type NoteEvent, type SynthPreset } from './render/types.ts';
14import { WaveView, type Marker } from './ui/waveform.ts';
15import { Timeline } from './ui/timeline.ts';
17const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
18const PRESETS: SynthPreset[] = ['kick', 'snare', 'hat', 'tom'];
19const STORE_KEY = 'instrawave-compose/v1';
21interface TrainHit { vec: number[]; peakDb: number; }
22interface Voice { id: string; name: string; color: string; preset: SynthPreset; train: TrainHit[]; }
23interface TakeHit {
24 id: string; t: number; voiceId: string; gain: number;
25 peakDb: number; vec: number[] | null; // null for hand-added hits
26 s0: number; s1: number; // sample bounds in the take clip; -1 if hand-added
27}
29const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
30const uid = () => Math.random().toString(36).slice(2, 10);
32// ---- state ----------------------------------------------------------------
34let voices: Voice[] = loadVoices();
35let take: { clip: Clip | null; hits: TakeHit[] } = { clip: null, hits: [] };
36let pending: { clip: Clip; voiceId: string; onsets: number[]; excluded: Set<number> } | null = null;
37let recTarget: { kind: 'voice'; voiceId: string } | { kind: 'take' } | null = null;
39let actx: AudioContext | null = null;
40const audio = (): AudioContext => (actx ??= new AudioContext());
41let recorder: Recorder | null = null;
42let playing: { src: AudioBufferSourceNode; t0: number; dur: number } | null = null;
44function loadVoices(): Voice[] {
45 try {
46 const raw = localStorage.getItem(STORE_KEY);
47 if (raw) return (JSON.parse(raw) as { voices: Voice[] }).voices;
48 } catch { /* a corrupt store starts fresh */ }
49 return [];
50}
51function save(): void {
52 localStorage.setItem(STORE_KEY, JSON.stringify({ voices }));
53}
54function voiceById(id: string): Voice | undefined {
55 return voices.find((v) => v.id === id);
56}
57function status(msg: string): void {
58 $('status').textContent = msg;
59}
61// ---- audition -------------------------------------------------------------
63function playSlice(clip: Clip, s0: number, s1: number): void {
64 const ctx = audio();
65 const n = Math.min(s1, s0 + Math.floor(0.5 * clip.sr)) - s0;
66 if (n <= 0) return;
67 const buf = ctx.createBuffer(1, n, clip.sr);
68 buf.getChannelData(0).set(clip.samples.subarray(s0, s0 + n));
69 const src = ctx.createBufferSource();
70 src.buffer = buf;
71 src.connect(ctx.destination);
72 src.start();
73}
75async function playBuffer(buf: AudioBuffer, onFrame?: (t: number | null) => void): Promise<void> {
76 const ctx = audio();
77 if (ctx.state === 'suspended') await ctx.resume();
78 const src = ctx.createBufferSource();
79 src.buffer = buf;
80 src.connect(ctx.destination);
81 const t0 = ctx.currentTime;
82 playing = { src, t0, dur: buf.duration };
83 src.onended = () => { if (playing?.src === src) playing = null; onFrame?.(null); syncPlayButton(); };
84 src.start();
85 if (onFrame) {
86 const tick = () => {
87 if (!playing || playing.src !== src) return;
88 onFrame(ctx.currentTime - t0);
89 requestAnimationFrame(tick);
90 };
91 requestAnimationFrame(tick);
92 }
93 syncPlayButton();
94}
96function stopPlayback(): void {
97 playing?.src.stop();
98 playing = null;
99 timeline.playhead(null);
100 syncPlayButton();
101}
103// ---- recording ------------------------------------------------------------
105let meterRaf = 0;
106function meterOn(): void {
107 $('meter').hidden = false;
108 const fill = $('meter').firstElementChild as HTMLElement;
109 const tick = () => {
110 fill.style.width = `${(recorder?.level() ?? 0) * 100}%`;
111 if (recorder?.recording) meterRaf = requestAnimationFrame(tick);
112 };
113 meterRaf = requestAnimationFrame(tick);
114}
115function meterOff(): void {
116 cancelAnimationFrame(meterRaf);
117 $('meter').hidden = true;
118}
120async function startRecording(target: NonNullable<typeof recTarget>): Promise<void> {
121 recorder = new Recorder(audio());
122 try {
123 await recorder.start();
124 } catch (e) {
125 status(`microphone unavailable: ${e instanceof Error ? e.message : e}`);
126 recorder = null;
127 return;
128 }
129 recTarget = target;
130 meterOn();
131 renderVoiceList();
132 syncTakeBar();
133 status(target.kind === 'take'
134 ? 'recording the take; stop when the phrase is done'
135 : `recording examples for "${voiceById(target.voiceId)?.name}"; make the sound several times, then stop`);
136}
138async function stopRecording(): Promise<void> {
139 if (!recorder) return;
140 const target = recTarget!;
141 let clip: Clip;
142 try {
143 clip = await recorder.stop();
144 } finally {
145 recorder = null;
146 recTarget = null;
147 meterOff();
148 }
149 renderVoiceList();
150 syncTakeBar();
151 if (target.kind === 'voice') {
152 pending = { clip, voiceId: target.voiceId, onsets: [], excluded: new Set() };
153 redetectPending();
154 $('capture').hidden = false;
155 } else {
156 take.clip = clip;
157 processTake();
158 }
159}
161// ---- capture review (voice training) ---------------------------------------
163const waveView = new WaveView($('capture-wave') as HTMLCanvasElement);
165function redetectPending(): void {
166 if (!pending) return;
167 const sens = Number(($('capture-sens') as HTMLInputElement).value);
168 pending.onsets = detectOnsets(pending.clip.samples, pending.clip.sr, sens);
169 pending.excluded = new Set();
170 refreshCapture();
171}
173function refreshCapture(): void {
174 if (!pending) return;
175 const voice = voiceById(pending.voiceId)!;
176 const markers: Marker[] = pending.onsets.map((t, i) => ({ t, excluded: pending!.excluded.has(i) }));
177 waveView.set(pending.clip, markers, voice.color);
178 const n = pending.onsets.length - pending.excluded.size;
179 $('capture-title').textContent = `${pending.onsets.length} hit${pending.onsets.length === 1 ? '' : 's'} detected`;
180 ($('capture-commit') as HTMLButtonElement).textContent = `Add ${n} hit${n === 1 ? '' : 's'} to "${voice.name}"`;
181 ($('capture-commit') as HTMLButtonElement).disabled = n === 0;
182 ($('capture-exclude') as HTMLButtonElement).disabled = true;
183}
185waveView.onMarkerClick = (i) => {
186 if (!pending) return;
187 waveView.selected = i;
188 waveView.draw();
189 const s0 = Math.floor(pending.onsets[i] * pending.clip.sr);
190 const s1 = i + 1 < pending.onsets.length ? Math.floor(pending.onsets[i + 1] * pending.clip.sr) : pending.clip.samples.length;
191 playSlice(pending.clip, s0, s1);
192 const btn = $('capture-exclude') as HTMLButtonElement;
193 btn.disabled = false;
194 btn.textContent = pending.excluded.has(i) ? 'Include' : 'Exclude';
195};
197$('capture-exclude').addEventListener('click', () => {
198 const i = waveView.selected;
199 if (!pending || i < 0) return;
200 if (pending.excluded.has(i)) pending.excluded.delete(i);
201 else pending.excluded.add(i);
202 const sel = i;
203 refreshCapture();
204 waveView.selected = sel;
205 waveView.draw();
206 const btn = $('capture-exclude') as HTMLButtonElement;
207 btn.disabled = false;
208 btn.textContent = pending.excluded.has(i) ? 'Include' : 'Exclude';
209});
211$('capture-sens').addEventListener('input', redetectPending);
213$('capture-commit').addEventListener('click', () => {
214 if (!pending) return;
215 const voice = voiceById(pending.voiceId)!;
216 const { samples, sr } = pending.clip;
217 let added = 0;
218 for (let i = 0; i < pending.onsets.length; i++) {
219 if (pending.excluded.has(i)) continue;
220 const s0 = Math.floor(pending.onsets[i] * sr);
221 const s1 = i + 1 < pending.onsets.length ? Math.floor(pending.onsets[i + 1] * sr) : samples.length;
222 const f = extractHit(samples, sr, s0, s1);
223 voice.train.push({ vec: f.vec, peakDb: f.peakDb });
224 added++;
225 }
226 save();
227 pending = null;
228 $('capture').hidden = true;
229 renderVoiceList();
230 status(`added ${added} hit${added === 1 ? '' : 's'} to "${voice.name}" (${voice.train.length} total)`);
231});
233$('capture-discard').addEventListener('click', () => {
234 pending = null;
235 $('capture').hidden = true;
236});
238// ---- voices ---------------------------------------------------------------
240function renderVoiceList(): void {
241 const list = $('voice-list');
242 list.textContent = '';
243 const busy = recorder !== null;
244 for (const voice of voices) {
245 const row = document.createElement('div');
246 row.className = 'voice-row';
248 const swatch = document.createElement('span');
249 swatch.className = 'swatch';
250 swatch.style.background = voice.color;
252 const name = document.createElement('input');
253 name.className = 'name';
254 name.value = voice.name;
255 name.addEventListener('change', () => {
256 voice.name = name.value.trim() || voice.name;
257 name.value = voice.name;
258 save();
259 refreshTimeline();
260 });
262 const count = document.createElement('span');
263 count.className = 'count';
264 count.textContent = `${voice.train.length} trained hit${voice.train.length === 1 ? '' : 's'}`;
266 const preset = document.createElement('select');
267 for (const p of PRESETS) {
268 const o = document.createElement('option');
269 o.value = p; o.textContent = p;
270 preset.appendChild(o);
271 }
272 preset.value = voice.preset;
273 preset.addEventListener('change', () => { voice.preset = preset.value as SynthPreset; save(); });
275 const rec = document.createElement('button');
276 const recordingThis = recTarget?.kind === 'voice' && recTarget.voiceId === voice.id;
277 rec.innerHTML = recordingThis ? '■ Stop' : '● Record';
278 if (recordingThis) rec.className = 'rec';
279 rec.disabled = busy && !recordingThis;
280 rec.addEventListener('click', () => {
281 if (recordingThis) void stopRecording();
282 else void startRecording({ kind: 'voice', voiceId: voice.id });
283 });
285 const clear = document.createElement('button');
286 clear.textContent = 'Clear';
287 clear.title = 'forget this voice\'s trained hits';
288 clear.disabled = voice.train.length === 0;
289 clear.addEventListener('click', () => {
290 voice.train = [];
291 save();
292 renderVoiceList();
293 });
295 const del = document.createElement('button');
296 del.textContent = '✕';
297 del.title = 'delete voice';
298 del.addEventListener('click', () => {
299 if (!confirm(`Delete voice "${voice.name}"? Its trained hits and its timeline events go with it.`)) return;
300 voices = voices.filter((v) => v.id !== voice.id);
301 take.hits = take.hits.filter((h) => h.voiceId !== voice.id);
302 save();
303 renderVoiceList();
304 refreshTimeline();
305 });
307 const spacer = document.createElement('span');
308 spacer.className = 'spacer';
309 row.append(swatch, name, count, rec, clear, spacer, preset, del);
310 list.appendChild(row);
311 }
312}
314$('add-voice').addEventListener('click', () => {
315 const input = $('voice-name') as HTMLInputElement;
316 const name = input.value.trim() || `voice ${voices.length + 1}`;
317 voices.push({
318 id: uid(),
319 name,
320 color: PALETTE[voices.length % PALETTE.length],
321 preset: PRESETS[voices.length % PRESETS.length],
322 train: [],
323 });
324 input.value = '';
325 save();
326 renderVoiceList();
327 refreshTimeline();
328});
330$('reset-all').addEventListener('click', () => {
331 if (!confirm('Forget all voices, training, and the current take?')) return;
332 localStorage.removeItem(STORE_KEY);
333 voices = [];
334 take = { clip: null, hits: [] };
335 pending = null;
336 $('capture').hidden = true;
337 renderVoiceList();
338 refreshTimeline();
339 syncTakeBar();
340 status('');
341});
343// ---- take -----------------------------------------------------------------
345const timeline = new Timeline($('timeline') as HTMLCanvasElement);
347function processTake(): void {
348 if (!take.clip) return;
349 stopPlayback();
350 const { samples, sr } = take.clip;
351 const sens = Number(($('take-sens') as HTMLInputElement).value);
352 const onsets = detectOnsets(samples, sr, sens);
353 const trained = voices.filter((v) => v.train.length > 0);
354 const trainSet: TrainingSet = {
355 voiceIds: trained.flatMap((v) => v.train.map(() => v.id)),
356 vecs: trained.flatMap((v) => v.train.map((h) => h.vec)),
357 };
359 const hits: TakeHit[] = [];
360 let refDb = -160;
361 for (let i = 0; i < onsets.length; i++) {
362 const s0 = Math.floor(onsets[i] * sr);
363 const s1 = i + 1 < onsets.length ? Math.floor(onsets[i + 1] * sr) : samples.length;
364 const f = extractHit(samples, sr, s0, s1);
365 if (f.peakDb > refDb) refDb = f.peakDb;
366 const cls = classify(f.vec, trainSet);
367 const voiceId = cls?.voiceId ?? voices[0]?.id;
368 if (!voiceId) continue;
369 hits.push({ id: uid(), t: onsets[i], voiceId, gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
370 }
371 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
372 take.hits = hits;
373 refreshTimeline();
374 syncTakeBar();
375 if (voices.length === 0) status('no voices defined; add and train a voice, then hit re-detect');
376 else if (trainSet.vecs.length === 0) status(`no trained hits yet; all ${hits.length} events landed on "${voices[0].name}"`);
377 else status(`${hits.length} hits classified across ${trained.length} trained voice${trained.length === 1 ? '' : 's'}`);
378}
380function refreshTimeline(): void {
381 timeline.setData(
382 voices.map((v) => ({ id: v.id, name: v.name, color: v.color })),
383 take.hits,
384 take.clip,
385 );
386 syncHitButtons();
387}
389function syncTakeBar(): void {
390 const recordingTake = recTarget?.kind === 'take';
391 const btn = $('take-record') as HTMLButtonElement;
392 btn.innerHTML = recordingTake ? '■ Stop' : '● Record take';
393 btn.className = recordingTake ? 'rec' : '';
394 btn.disabled = recorder !== null && !recordingTake;
395 ($('take-redetect') as HTMLButtonElement).disabled = take.clip === null;
396 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0;
397}
399function syncPlayButton(): void {
400 ($('take-play') as HTMLButtonElement).textContent = playing ? 'Stop' : 'Play';
401 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0 && !playing;
402}
404function syncHitButtons(): void {
405 const sel = take.hits.find((h) => h.id === timeline.selected);
406 ($('hit-delete') as HTMLButtonElement).disabled = !sel;
407 ($('hit-learn') as HTMLButtonElement).disabled = !sel || sel.vec === null;
408}
410$('take-record').addEventListener('click', () => {
411 if (recTarget?.kind === 'take') void stopRecording();
412 else void startRecording({ kind: 'take' });
413});
415$('take-redetect').addEventListener('click', processTake);
416$('take-sens').addEventListener('change', () => { if (take.clip) processTake(); });
418timeline.onSelect = (id) => {
419 syncHitButtons();
420 const hit = take.hits.find((h) => h.id === id);
421 if (!hit) return;
422 if (hit.s0 >= 0 && take.clip) playSlice(take.clip, hit.s0, hit.s1);
423 else void auditionSynth(hit);
424};
426async function auditionSynth(hit: TakeHit): Promise<void> {
427 const voice = voiceById(hit.voiceId);
428 if (!voice) return;
429 const buf = await renderSynth([{ t: 0, voiceId: hit.voiceId, gain: hit.gain }], () => voice.preset, 1);
430 void playBuffer(buf);
431}
433timeline.onReassign = (id, voiceId) => {
434 const voice = voiceById(voiceId);
435 status(voice ? `hit moved to "${voice.name}"; Learn adds it to that voice's training` : '');
436 syncHitButtons();
437};
439timeline.onAdd = (t, voiceId) => {
440 take.hits.push({ id: uid(), t, voiceId, gain: 0.8, peakDb: 0, vec: null, s0: -1, s1: -1 });
441 take.hits.sort((a, b) => a.t - b.t);
442 refreshTimeline();
443};
445$('hit-delete').addEventListener('click', deleteSelected);
446document.addEventListener('keydown', (e) => {
447 if (e.key !== 'Delete' && e.key !== 'Backspace') return;
448 if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
449 deleteSelected();
450});
451function deleteSelected(): void {
452 if (!timeline.selected) return;
453 take.hits = take.hits.filter((h) => h.id !== timeline.selected);
454 timeline.selected = null;
455 refreshTimeline();
456}
458$('hit-learn').addEventListener('click', () => {
459 const hit = take.hits.find((h) => h.id === timeline.selected);
460 if (!hit || !hit.vec) return;
461 const voice = voiceById(hit.voiceId);
462 if (!voice) return;
463 voice.train.push({ vec: hit.vec, peakDb: hit.peakDb });
464 save();
465 renderVoiceList();
466 status(`learned this hit as "${voice.name}" (${voice.train.length} trained)`);
467});
469$('take-play').addEventListener('click', async () => {
470 if (playing) { stopPlayback(); return; }
471 if (take.hits.length === 0) return;
472 const events: NoteEvent[] = take.hits.map((h) => ({ t: h.t, voiceId: h.voiceId, gain: h.gain }));
473 const seconds = Math.max(...events.map((e) => e.t)) + 1.5;
474 const buf = await renderSynth(events, (id) => voiceById(id)?.preset ?? 'kick', seconds);
475 void playBuffer(buf, (t) => timeline.playhead(t));
476});
478// ---- boot -----------------------------------------------------------------
480renderVoiceList();
481refreshTimeline();
482syncTakeBar();
483if (voices.length === 0) {
484 status('start by adding a voice, then record yourself making its sound a handful of times');
485}