fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1/**
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 2 * The page: voices (named, trained by example, each bound to a synth
3 * preset or an instrawave instrument), a take (recorded once, segmented,
4 * classified onto the voices), a timeline (the take as editable events),
5 * and playback through the renderer seam in render/types.ts. Training
6 * clips persist (audio in IndexedDB, detections and features in
7 * localStorage) and reopen in the same editor they were made in. The GPU
8 * is touched only when at least one bound instrument is instrawave.
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 9 */
10import type { Clip } from './audio/recorder.ts';
11import { Recorder } from './audio/recorder.ts';
12import { detectOnsets } from './analysis/onsets.ts';
13import { extractHit } from './analysis/features.ts';
14import { classify, type TrainingSet } from './analysis/classify.ts';
15import { renderSynth } from './render/synth.ts';
16import { gainFromPeakDb, type NoteEvent, type SynthPreset } from './render/types.ts';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 17import { mixInto, peak, resampleLinear } from './render/mix.ts';
18import { IwEngine, PhaseCancelled, type StimulusEvent } from './render/instrawave.ts';
19import {
20 clearClipAudio, clearStore, deleteClipAudio, getClipAudio, loadVoices, putClipAudio,
21 saveVoices, trainPool, type TrainClip, type Voice,
22} from './store.ts';
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 23import { WaveView, type Marker } from './ui/waveform.ts';
24import { Timeline } from './ui/timeline.ts';
26const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
27const PRESETS: SynthPreset[] = ['kick', 'snare', 'hat', 'tom'];
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 28const MIX_SR = 44100;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 29
30interface TakeHit {
31 id: string; t: number; voiceId: string; gain: number;
32 peakDb: number; vec: number[] | null; // null for hand-added hits
33 s0: number; s1: number; // sample bounds in the take clip; -1 if hand-added
34}
36const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
37const uid = () => Math.random().toString(36).slice(2, 10);
39// ---- state ----------------------------------------------------------------
41let voices: Voice[] = loadVoices();
42let take: { clip: Clip | null; hits: TakeHit[] } = { clip: null, hits: [] };
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 43let edit: { voiceId: string; clipId: string; samples: Float32Array; sr: number } | null = null;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 44let recTarget: { kind: 'voice'; voiceId: string } | { kind: 'take' } | null = null;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 45const expandedTuning = new Set<string>();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 46
47let actx: AudioContext | null = null;
48const audio = (): AudioContext => (actx ??= new AudioContext());
49let recorder: Recorder | null = null;
50let playing: { src: AudioBufferSourceNode; t0: number; dur: number } | null = null;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 51let renderCache: { sig: string; buf: AudioBuffer } | null = null;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 52
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 53const iw = new IwEngine();
54let bakeJob: { name: string; cancel: boolean } | null = null;
55let bakeProg: HTMLProgressElement | null = null;
57function save(): void { saveVoices(voices); }
58function voiceById(id: string): Voice | undefined { return voices.find((v) => v.id === id); }
59function status(msg: string): void { $('status').textContent = msg; }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 60
61// ---- audition -------------------------------------------------------------
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 63function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void {
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 64 const ctx = audio();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 65 const n = Math.min(s1, s0 + Math.floor(0.5 * sr)) - s0;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 66 if (n <= 0) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 67 const buf = ctx.createBuffer(1, n, sr);
68 buf.getChannelData(0).set(samples.subarray(s0, s0 + n));
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 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') {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 152 const voice = voiceById(target.voiceId);
153 if (!voice) return;
154 const sens = Number(($('capture-sens') as HTMLInputElement).value);
155 const tclip: TrainClip = {
156 id: uid(), sr: clip.sr, sens,
157 onsets: detectOnsets(clip.samples, clip.sr, sens),
158 excluded: [], hits: [],
159 };
160 recomputeHits(tclip, clip.samples);
161 voice.clips.push(tclip);
162 save();
163 void putClipAudio(tclip.id, clip.samples, clip.sr).catch(() =>
164 status('warning: clip audio could not be stored; this clip will not be re-editable after a reload'));
165 openEditor(voice.id, tclip.id, { samples: clip.samples, sr: clip.sr });
166 renderVoiceList();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 167 } else {
168 take.clip = clip;
169 processTake();
170 }
171}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 173// ---- training clip editor ----------------------------------------------------
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 174
175const waveView = new WaveView($('capture-wave') as HTMLCanvasElement);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 177function editingClip(): TrainClip | null {
178 if (!edit) return null;
179 return voiceById(edit.voiceId)?.clips.find((c) => c.id === edit!.clipId) ?? null;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 180}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 182function recomputeHits(clip: TrainClip, samples: Float32Array): void {
183 const ex = new Set(clip.excluded);
184 clip.hits = [];
185 for (let i = 0; i < clip.onsets.length; i++) {
186 if (ex.has(i)) continue;
187 const s0 = Math.floor(clip.onsets[i] * clip.sr);
188 const s1 = i + 1 < clip.onsets.length ? Math.floor(clip.onsets[i + 1] * clip.sr) : samples.length;
189 const f = extractHit(samples, clip.sr, s0, s1);
190 clip.hits.push({ vec: f.vec, peakDb: f.peakDb });
191 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 192}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 194function openEditor(voiceId: string, clipId: string, audioData?: { samples: Float32Array; sr: number }): void {
195 const voice = voiceById(voiceId);
196 const clip = voice?.clips.find((c) => c.id === clipId);
197 if (!voice || !clip) return;
198 const open = (samples: Float32Array, sr: number) => {
199 edit = { voiceId, clipId, samples, sr };
200 ($('capture-sens') as HTMLInputElement).value = String(clip.sens);
201 $('capture').hidden = false;
202 refreshEditor();
203 };
204 if (audioData) open(audioData.samples, audioData.sr);
205 else {
206 void getClipAudio(clipId).then((a) => {
207 if (a) open(a.samples, a.sr);
208 else status('this clip\'s audio is missing (stored before audio persistence, or cleared); its hits still classify');
209 });
210 }
211}
213function refreshEditor(): void {
214 const voice = edit && voiceById(edit.voiceId);
215 const clip = editingClip();
216 if (!edit || !voice || !clip) return;
217 $('capture-title').textContent = `"${voice.name}"`;
218 const sel = $('capture-clip') as HTMLSelectElement;
219 sel.textContent = '';
220 voice.clips.forEach((c, i) => {
221 const o = document.createElement('option');
222 o.value = c.id;
223 o.textContent = `clip ${i + 1} · ${c.hits.length} hit${c.hits.length === 1 ? '' : 's'}`;
224 sel.appendChild(o);
225 });
226 sel.value = clip.id;
227 const markers: Marker[] = clip.onsets.map((t, i) => ({ t, excluded: clip.excluded.includes(i) }));
228 waveView.set({ samples: edit.samples, sr: edit.sr }, markers, voice.color);
229 syncEditorButtons();
230}
232function syncEditorButtons(): void {
233 const clip = editingClip();
234 const n = waveView.selection.size;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 235 const btn = $('capture-exclude') as HTMLButtonElement;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 236 btn.disabled = n === 0;
237 if (clip && n > 0) {
238 const allExcluded = [...waveView.selection].every((i) => clip.excluded.includes(i));
239 btn.textContent = allExcluded ? `Include (${n})` : `Exclude (${n})`;
240 } else btn.textContent = 'Exclude';
241 $('capture-count').textContent = clip
242 ? `${clip.hits.length} of ${clip.onsets.length} hits included${n ? ` · ${n} selected` : ''}`
243 : '';
244}
246function toggleExcluded(): void {
247 const clip = editingClip();
248 if (!edit || !clip || waveView.selection.size === 0) return;
249 const sel = [...waveView.selection];
250 const allExcluded = sel.every((i) => clip.excluded.includes(i));
251 const ex = new Set(clip.excluded);
252 for (const i of sel) {
253 if (allExcluded) ex.delete(i);
254 else ex.add(i);
255 }
256 clip.excluded = [...ex].sort((a, b) => a - b);
257 recomputeHits(clip, edit.samples);
258 save();
259 waveView.updateMarkers(clip.onsets.map((t, i) => ({ t, excluded: ex.has(i) })));
260 syncEditorButtons();
261 renderVoiceList();
262}
264waveView.onAudition = (i) => {
265 const clip = editingClip();
266 if (!edit || !clip) return;
267 const s0 = Math.floor(clip.onsets[i] * edit.sr);
268 const s1 = i + 1 < clip.onsets.length ? Math.floor(clip.onsets[i + 1] * edit.sr) : edit.samples.length;
269 playSlice(edit.samples, edit.sr, s0, s1);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 270};
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 271waveView.onSelectionChange = syncEditorButtons;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 272
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 273$('capture-exclude').addEventListener('click', toggleExcluded);
275$('capture-sens').addEventListener('input', () => {
276 const clip = editingClip();
277 if (!edit || !clip) return;
278 clip.sens = Number(($('capture-sens') as HTMLInputElement).value);
279 clip.onsets = detectOnsets(edit.samples, edit.sr, clip.sens);
280 clip.excluded = [];
281 recomputeHits(clip, edit.samples);
282 save();
283 refreshEditor();
284 renderVoiceList();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 285});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 287$('capture-clip').addEventListener('change', () => {
288 if (!edit) return;
289 openEditor(edit.voiceId, ($('capture-clip') as HTMLSelectElement).value);
290});
292$('capture-delete').addEventListener('click', () => {
293 const voice = edit && voiceById(edit.voiceId);
294 const clip = editingClip();
295 if (!edit || !voice || !clip) return;
296 if (!confirm(`Delete this clip and its ${clip.hits.length} trained hits?`)) return;
297 void deleteClipAudio(clip.id);
298 voice.clips = voice.clips.filter((c) => c.id !== clip.id);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 299 save();
300 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 301 if (voice.clips.length) openEditor(voice.id, voice.clips[voice.clips.length - 1].id);
302 else closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 303});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 305function closeEditor(): void {
306 edit = null;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 307 $('capture').hidden = true;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 308}
309$('capture-done').addEventListener('click', closeEditor);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 310
311// ---- voices ---------------------------------------------------------------
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 313function bindingValue(v: Voice): string {
314 return v.binding.kind === 'synth' ? `synth:${v.binding.preset}` : `iw:${v.binding.instrument}`;
315}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 317function renderVoiceList(): void {
318 const list = $('voice-list');
319 list.textContent = '';
320 const busy = recorder !== null;
321 for (const voice of voices) {
322 const row = document.createElement('div');
323 row.className = 'voice-row';
325 const swatch = document.createElement('span');
326 swatch.className = 'swatch';
327 swatch.style.background = voice.color;
329 const name = document.createElement('input');
330 name.className = 'name';
331 name.value = voice.name;
332 name.addEventListener('change', () => {
333 voice.name = name.value.trim() || voice.name;
334 name.value = voice.name;
335 save();
336 refreshTimeline();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 337 if (edit?.voiceId === voice.id) refreshEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 338 });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 340 const n = trainPool(voice).length;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 341 const count = document.createElement('span');
342 count.className = 'count';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 343 count.textContent = `${voice.clips.length} clip${voice.clips.length === 1 ? '' : 's'} · ${n} hit${n === 1 ? '' : 's'}`;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 344
345 const rec = document.createElement('button');
346 const recordingThis = recTarget?.kind === 'voice' && recTarget.voiceId === voice.id;
347 rec.innerHTML = recordingThis ? '■ Stop' : '● Record';
348 if (recordingThis) rec.className = 'rec';
349 rec.disabled = busy && !recordingThis;
350 rec.addEventListener('click', () => {
351 if (recordingThis) void stopRecording();
352 else void startRecording({ kind: 'voice', voiceId: voice.id });
353 });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 355 const editBtn = document.createElement('button');
356 editBtn.textContent = 'Edit';
357 editBtn.title = 'reopen this voice\'s training clips';
358 editBtn.disabled = voice.clips.length === 0;
359 editBtn.addEventListener('click', () => {
360 openEditor(voice.id, voice.clips[voice.clips.length - 1].id);
361 });
363 const binding = document.createElement('select');
364 const gSynth = document.createElement('optgroup');
365 gSynth.label = 'synth';
366 for (const p of PRESETS) {
367 const o = document.createElement('option');
368 o.value = `synth:${p}`; o.textContent = p;
369 gSynth.appendChild(o);
370 }
371 const gIw = document.createElement('optgroup');
372 gIw.label = 'instrawave';
373 for (const nm of iw.names()) {
374 const o = document.createElement('option');
375 o.value = `iw:${nm}`; o.textContent = nm;
376 gIw.appendChild(o);
377 }
378 binding.append(gSynth, gIw);
379 binding.value = bindingValue(voice);
380 binding.addEventListener('change', () => {
381 const [kind, which] = binding.value.split(':');
382 if (kind === 'synth') voice.binding = { kind: 'synth', preset: which as SynthPreset };
383 else {
384 const old = voice.binding;
385 const set = old.kind === 'iw' && old.instrument === which ? old.set : {};
386 voice.binding = { kind: 'iw', instrument: which, set };
387 }
388 save();
389 renderVoiceList();
390 updateBakeBar();
391 });
393 const tune = document.createElement('button');
394 tune.textContent = expandedTuning.has(voice.id) ? 'Tune ▾' : 'Tune ▸';
395 tune.disabled = voice.binding.kind !== 'iw';
396 tune.addEventListener('click', () => {
397 if (expandedTuning.has(voice.id)) expandedTuning.delete(voice.id);
398 else expandedTuning.add(voice.id);
399 renderVoiceList();
400 });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 402 const clear = document.createElement('button');
403 clear.textContent = 'Clear';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 404 clear.title = 'forget this voice\'s training';
405 clear.disabled = n === 0;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 406 clear.addEventListener('click', () => {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 407 if (!confirm(`Forget all ${n} trained hits of "${voice.name}"?`)) return;
408 for (const c of voice.clips) void deleteClipAudio(c.id);
409 voice.clips = [];
410 voice.loose = [];
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 411 save();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 412 if (edit?.voiceId === voice.id) closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 413 renderVoiceList();
414 });
416 const del = document.createElement('button');
417 del.textContent = '✕';
418 del.title = 'delete voice';
419 del.addEventListener('click', () => {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 420 if (!confirm(`Delete voice "${voice.name}"? Its training and its timeline events go with it.`)) return;
421 for (const c of voice.clips) void deleteClipAudio(c.id);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 422 voices = voices.filter((v) => v.id !== voice.id);
423 take.hits = take.hits.filter((h) => h.voiceId !== voice.id);
424 save();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 425 if (edit?.voiceId === voice.id) closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 426 renderVoiceList();
427 refreshTimeline();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 428 updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 429 });
431 const spacer = document.createElement('span');
432 spacer.className = 'spacer';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 433 row.append(swatch, name, count, rec, editBtn, clear, spacer, binding, tune, del);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 434 list.appendChild(row);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 435
436 if (voice.binding.kind === 'iw' && expandedTuning.has(voice.id)) {
437 list.appendChild(tuningPanel(voice, voice.binding.instrument, voice.binding.set));
438 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 439 }
440}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 442function tuningPanel(voice: Voice, instrument: string, set: Record<string, number>): HTMLElement {
443 const panel = document.createElement('div');
444 panel.className = 'tuning-panel';
445 for (const p of iw.tuning(instrument)) {
446 const div = document.createElement('div');
447 div.className = 'param';
448 const label = document.createElement('label');
449 const nameSpan = document.createElement('span');
450 nameSpan.textContent = `${p.label} (${p.unit})`;
451 const valSpan = document.createElement('span');
452 const input = document.createElement('input');
453 input.type = 'range';
454 input.min = String(p.min);
455 input.max = String(p.max);
456 input.step = String(p.step ?? (p.max - p.min) / 200);
457 input.value = String(set[p.key] ?? p.value);
458 const show = () => { valSpan.textContent = Number(input.value).toPrecision(4); };
459 show();
460 input.addEventListener('input', () => {
461 set[p.key] = Number(input.value);
462 show();
463 save();
464 });
465 label.append(nameSpan, valSpan);
466 div.append(label, input);
467 panel.appendChild(div);
468 }
469 return panel;
470}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 472$('add-voice').addEventListener('click', () => {
473 const input = $('voice-name') as HTMLInputElement;
474 const name = input.value.trim() || `voice ${voices.length + 1}`;
475 voices.push({
476 id: uid(),
477 name,
478 color: PALETTE[voices.length % PALETTE.length],
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 479 binding: { kind: 'synth', preset: PRESETS[voices.length % PRESETS.length] },
480 clips: [],
481 loose: [],
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 482 });
483 input.value = '';
484 save();
485 renderVoiceList();
486 refreshTimeline();
487});
489$('reset-all').addEventListener('click', () => {
490 if (!confirm('Forget all voices, training, and the current take?')) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 491 clearStore();
492 void clearClipAudio();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 493 voices = [];
494 take = { clip: null, hits: [] };
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 495 closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 496 renderVoiceList();
497 refreshTimeline();
498 syncTakeBar();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 499 updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 500 status('');
501});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 503// ---- bakes ------------------------------------------------------------------
505function neededInstruments(): string[] {
506 return [...new Set(voices.filter((v) => v.binding.kind === 'iw')
507 .map((v) => (v.binding as { instrument: string }).instrument))];
508}
510function updateBakeBar(): void {
511 const bar = $('bakebar');
512 const needed = neededInstruments();
513 bar.hidden = needed.length === 0;
514 bar.textContent = '';
515 bakeProg = null;
516 for (const name of needed) {
517 const label = document.createElement('span');
518 label.className = 'hint';
519 label.textContent = `${name} bake:`;
520 bar.appendChild(label);
521 if (iw.hasBake(name)) {
522 const ok = document.createElement('span');
523 ok.textContent = 'ready';
524 ok.style.color = '#15803d';
525 bar.appendChild(ok);
526 } else if (bakeJob?.name === name) {
527 const prog = document.createElement('progress');
528 prog.max = 1;
529 prog.value = 0;
530 bakeProg = prog;
531 const cancel = document.createElement('button');
532 cancel.textContent = 'Cancel';
533 cancel.addEventListener('click', () => { if (bakeJob) bakeJob.cancel = true; });
534 bar.append(prog, cancel);
535 } else {
536 const btn = document.createElement('button');
537 btn.textContent = 'Precompute';
538 btn.disabled = bakeJob !== null;
539 btn.addEventListener('click', () => void startBake(name));
540 bar.appendChild(btn);
541 }
542 }
543}
545async function startBake(name: string): Promise<void> {
546 if (bakeJob) return;
547 bakeJob = { name, cancel: false };
548 updateBakeBar();
549 try {
550 await iw.precompute(
551 name,
552 (done, total) => { if (bakeProg) bakeProg.value = done / total; },
553 () => bakeJob?.cancel ?? true,
554 );
555 status(`${name} bake ready`);
556 } catch (e) {
557 status(e instanceof PhaseCancelled ? `${name} precompute cancelled`
558 : `${name} precompute failed: ${e instanceof Error ? e.message : e}`);
559 } finally {
560 bakeJob = null;
561 updateBakeBar();
562 }
563}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 565// ---- take -----------------------------------------------------------------
567const timeline = new Timeline($('timeline') as HTMLCanvasElement);
569function processTake(): void {
570 if (!take.clip) return;
571 stopPlayback();
572 const { samples, sr } = take.clip;
573 const sens = Number(($('take-sens') as HTMLInputElement).value);
574 const onsets = detectOnsets(samples, sr, sens);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 575 const trained = voices.filter((v) => trainPool(v).length > 0);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 576 const trainSet: TrainingSet = {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 577 voiceIds: trained.flatMap((v) => trainPool(v).map(() => v.id)),
578 vecs: trained.flatMap((v) => trainPool(v).map((h) => h.vec)),
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 579 };
581 const hits: TakeHit[] = [];
582 let refDb = -160;
583 for (let i = 0; i < onsets.length; i++) {
584 const s0 = Math.floor(onsets[i] * sr);
585 const s1 = i + 1 < onsets.length ? Math.floor(onsets[i + 1] * sr) : samples.length;
586 const f = extractHit(samples, sr, s0, s1);
587 if (f.peakDb > refDb) refDb = f.peakDb;
588 const cls = classify(f.vec, trainSet);
589 const voiceId = cls?.voiceId ?? voices[0]?.id;
590 if (!voiceId) continue;
591 hits.push({ id: uid(), t: onsets[i], voiceId, gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
592 }
593 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
594 take.hits = hits;
595 refreshTimeline();
596 syncTakeBar();
597 if (voices.length === 0) status('no voices defined; add and train a voice, then hit re-detect');
598 else if (trainSet.vecs.length === 0) status(`no trained hits yet; all ${hits.length} events landed on "${voices[0].name}"`);
599 else status(`${hits.length} hits classified across ${trained.length} trained voice${trained.length === 1 ? '' : 's'}`);
600}
602function refreshTimeline(): void {
603 timeline.setData(
604 voices.map((v) => ({ id: v.id, name: v.name, color: v.color })),
605 take.hits,
606 take.clip,
607 );
608 syncHitButtons();
609}
611function syncTakeBar(): void {
612 const recordingTake = recTarget?.kind === 'take';
613 const btn = $('take-record') as HTMLButtonElement;
614 btn.innerHTML = recordingTake ? '■ Stop' : '● Record take';
615 btn.className = recordingTake ? 'rec' : '';
616 btn.disabled = recorder !== null && !recordingTake;
617 ($('take-redetect') as HTMLButtonElement).disabled = take.clip === null;
618 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0;
619}
621function syncPlayButton(): void {
622 ($('take-play') as HTMLButtonElement).textContent = playing ? 'Stop' : 'Play';
623 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0 && !playing;
624}
626function syncHitButtons(): void {
627 const sel = take.hits.find((h) => h.id === timeline.selected);
628 ($('hit-delete') as HTMLButtonElement).disabled = !sel;
629 ($('hit-learn') as HTMLButtonElement).disabled = !sel || sel.vec === null;
630}
632$('take-record').addEventListener('click', () => {
633 if (recTarget?.kind === 'take') void stopRecording();
634 else void startRecording({ kind: 'take' });
635});
637$('take-redetect').addEventListener('click', processTake);
638$('take-sens').addEventListener('change', () => { if (take.clip) processTake(); });
640timeline.onSelect = (id) => {
641 syncHitButtons();
642 const hit = take.hits.find((h) => h.id === id);
643 if (!hit) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 644 const voice = voiceById(hit.voiceId);
645 status(`hit at ${hit.t.toFixed(3)} s · "${voice?.name ?? '?'}" · strength ${hit.gain.toFixed(2)}`);
646 if (hit.s0 >= 0 && take.clip) playSlice(take.clip.samples, take.clip.sr, hit.s0, hit.s1);
647 else if (voice?.binding.kind === 'synth') void auditionSynth(hit, voice.binding.preset);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 648};
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 650async function auditionSynth(hit: TakeHit, preset: SynthPreset): Promise<void> {
651 const buf = await renderSynth([{ t: 0, voiceId: hit.voiceId, gain: hit.gain }], () => preset, 1);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 652 void playBuffer(buf);
653}
655timeline.onReassign = (id, voiceId) => {
656 const voice = voiceById(voiceId);
657 status(voice ? `hit moved to "${voice.name}"; Learn adds it to that voice's training` : '');
658 syncHitButtons();
659};
661timeline.onAdd = (t, voiceId) => {
662 take.hits.push({ id: uid(), t, voiceId, gain: 0.8, peakDb: 0, vec: null, s0: -1, s1: -1 });
663 take.hits.sort((a, b) => a.t - b.t);
664 refreshTimeline();
665};
667$('hit-delete').addEventListener('click', deleteSelected);
668document.addEventListener('keydown', (e) => {
669 if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 670 if (!$('capture').hidden && edit) {
671 if (e.key === 'x' || e.key === 'X' || e.key === 'Delete' || e.key === 'Backspace') {
672 e.preventDefault();
673 toggleExcluded();
674 }
675 return;
676 }
677 if (e.key === 'Delete' || e.key === 'Backspace') deleteSelected();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 678});
679function deleteSelected(): void {
680 if (!timeline.selected) return;
681 take.hits = take.hits.filter((h) => h.id !== timeline.selected);
682 timeline.selected = null;
683 refreshTimeline();
684}
686$('hit-learn').addEventListener('click', () => {
687 const hit = take.hits.find((h) => h.id === timeline.selected);
688 if (!hit || !hit.vec) return;
689 const voice = voiceById(hit.voiceId);
690 if (!voice) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 691 voice.loose.push({ vec: hit.vec, peakDb: hit.peakDb });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 692 save();
693 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 694 status(`learned this hit as "${voice.name}" (${trainPool(voice).length} trained)`);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 695});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 697// ---- the mix ----------------------------------------------------------------
699async function renderMix(): Promise<AudioBuffer> {
700 const seconds = Math.max(...take.hits.map((h) => h.t)) + 1.5;
701 const sig = JSON.stringify({
702 hits: take.hits.map((h) => [h.t, h.voiceId, h.gain]),
703 bindings: voices.map((v) => [v.id, v.binding]),
704 });
705 if (renderCache?.sig === sig) return renderCache.buf;
707 const synthEvents: NoteEvent[] = [];
708 const iwEvents = new Map<string, StimulusEvent[]>();
709 for (const h of take.hits) {
710 const v = voiceById(h.voiceId);
711 if (!v) continue;
712 if (v.binding.kind === 'synth') {
713 synthEvents.push({ t: h.t, voiceId: h.voiceId, gain: h.gain });
714 } else {
715 const b = v.binding;
716 const allowed = new Set(iw.tuning(b.instrument).map((p) => p.key));
717 const set: Record<string, number> = {};
718 for (const [k, val] of Object.entries(b.set)) if (allowed.has(k)) set[k] = val;
719 const strength = iw.strengthParam(b.instrument);
720 set[strength] = (b.set[strength] ?? iw.defaultValue(b.instrument, strength)) * h.gain;
721 let list = iwEvents.get(b.instrument);
722 if (!list) iwEvents.set(b.instrument, (list = []));
723 list.push({ t: h.t, set });
724 }
725 }
726 const missing = [...iwEvents.keys()].filter((n) => !iw.hasBake(n));
727 if (missing.length) {
728 throw new Error(`precompute the ${missing.join(' and ')} bake${missing.length > 1 ? 's' : ''} first`);
729 }
731 const out = new Float32Array(Math.ceil(seconds * MIX_SR));
732 if (synthEvents.length) {
733 const buf = await renderSynth(
734 synthEvents,
735 (id) => {
736 const v = voiceById(id);
737 return v?.binding.kind === 'synth' ? v.binding.preset : 'kick';
738 },
739 seconds,
740 );
741 mixInto(out, buf.getChannelData(0));
742 }
743 for (const [name, evs] of iwEvents) {
744 evs.sort((a, b) => a.t - b.t);
745 const { trace, dt } = await iw.render(name, seconds, evs, (done, total) =>
746 status(`rendering ${name} (${evs.length} events): ${Math.round((100 * done) / total)}%`));
747 // Physical pressure amplitudes carry no absolute scale worth keeping
748 // across engines; each instrument's trace is levelled to sit alongside
749 // the synth part, while relative volumes WITHIN it stay physical.
750 const res = resampleLinear(trace, 1 / dt, MIX_SR);
751 const p = peak(res);
752 if (p > 0) mixInto(out, res, 0.8 / p);
753 }
754 const pAll = peak(out);
755 if (pAll > 0.98) for (let i = 0; i < out.length; i++) out[i] *= 0.95 / pAll;
756 const buf = audio().createBuffer(1, out.length, MIX_SR);
757 buf.getChannelData(0).set(out);
758 renderCache = { sig, buf };
759 return buf;
760}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 762$('take-play').addEventListener('click', async () => {
763 if (playing) { stopPlayback(); return; }
764 if (take.hits.length === 0) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 765 const btn = $('take-play') as HTMLButtonElement;
766 btn.disabled = true;
767 try {
768 const buf = await renderMix();
769 status('');
770 void playBuffer(buf, (t) => timeline.playhead(t));
771 } catch (e) {
772 status(e instanceof Error ? e.message : String(e));
773 } finally {
774 btn.disabled = false;
775 syncPlayButton();
776 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 777});
779// ---- boot -----------------------------------------------------------------
781renderVoiceList();
782refreshTimeline();
783syncTakeBar();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 784updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 785if (voices.length === 0) {
786 status('start by adding a voice, then record yourself making its sound a handful of times');
787}