/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / src / main.ts
1227 lines · 45.8 KBCodeBlameHistory
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.
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';
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 18import { wavBytes } from './render/wav.ts';
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 19import { IwEngine, parseBake, PhaseCancelled, type StimulusEvent } from './render/instrawave.ts';
20import { Viewer, type Camera } from 'instrawave/src/host/view.ts';
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 22 clearBulk, clearStore, deleteClipAudio, getBakeBytes, getBakeKey, getClipAudio, getTakeAudio,
24 putBake, putClipAudio,
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 25 putRender, putTakeAudio,
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 27 type TakeHit, type TrainClip, type Voice,
b11b069Classify takes with numbl algorithms by default (silence_gap_knn from audio-spike-sorting, noise-floor adapted), with a custom-algorithm editor and the feature kNN kept as an optionJeremy Magland 29import { CUSTOM_CLASSIFIER_TEMPLATE, DEFAULT_CLASSIFIER_NAME, DEFAULT_CLASSIFIER_SOURCE } from './numbl/index.ts';
30import { buildVoiceTraining } from './numbl/snippets.ts';
31import { classifyInWorker } from './numbl/client.ts';
32import { downsampleBox } from './numbl/dsp.ts';
33import { CLF_FS } from './numbl/types.ts';
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 34import { WaveView, type Marker } from './ui/waveform.ts';
35import { Timeline } from './ui/timeline.ts';
37const PALETTE = ['#e4573d', '#3d7de4', '#3dbb6e', '#d9a520', '#9061d9', '#d95fa8', '#4fb8c9', '#96a13a'];
38const PRESETS: SynthPreset[] = ['kick', 'snare', 'hat', 'tom'];
41const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
42const uid = () => Math.random().toString(36).slice(2, 10);
44// ---- state ----------------------------------------------------------------
46let voices: Voice[] = loadVoices();
47let take: { clip: Clip | null; hits: TakeHit[] } = { clip: null, hits: [] };
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 48let 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 49let recTarget: { kind: 'voice'; voiceId: string } | { kind: 'take' } | null = null;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 50const expandedTuning = new Set<string>();
52let actx: AudioContext | null = null;
53const audio = (): AudioContext => (actx ??= new AudioContext());
54let recorder: Recorder | null = null;
55let playing: { src: AudioBufferSourceNode; t0: number; dur: number } | null = null;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 56let renderCache: { sig: string; buf: AudioBuffer } | null = null;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 59iw.setOverrides(loadIwOverrides());
60const saveIw = () => saveIwOverrides(iw.dumpOverrides());
61const ui = loadUi();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 62let bakeJob: { name: string; cancel: boolean } | null = null;
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 64let bakeProgs: HTMLProgressElement[] = [];
66function save(): void { saveVoices(voices); }
67function voiceById(id: string): Voice | undefined { return voices.find((v) => v.id === id); }
68function status(msg: string): void { $('status').textContent = msg; }
70// ---- audition -------------------------------------------------------------
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 72function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 74 const n = Math.min(s1, s0 + Math.floor(0.5 * sr)) - s0;
77 buf.getChannelData(0).set(samples.subarray(s0, s0 + n));
79 src.buffer = buf;
80 src.connect(ctx.destination);
81 src.start();
84async function playBuffer(buf: AudioBuffer, onFrame?: (t: number | null) => void): Promise<void> {
85 const ctx = audio();
86 if (ctx.state === 'suspended') await ctx.resume();
87 const src = ctx.createBufferSource();
88 src.buffer = buf;
89 src.connect(ctx.destination);
90 const t0 = ctx.currentTime;
91 playing = { src, t0, dur: buf.duration };
92 src.onended = () => { if (playing?.src === src) playing = null; onFrame?.(null); syncPlayButton(); };
93 src.start();
94 if (onFrame) {
95 const tick = () => {
96 if (!playing || playing.src !== src) return;
97 onFrame(ctx.currentTime - t0);
98 requestAnimationFrame(tick);
99 };
100 requestAnimationFrame(tick);
101 }
102 syncPlayButton();
105function stopPlayback(): void {
106 playing?.src.stop();
107 playing = null;
108 timeline.playhead(null);
109 syncPlayButton();
112// ---- recording ------------------------------------------------------------
114let meterRaf = 0;
115function meterOn(): void {
116 $('meter').hidden = false;
117 const fill = $('meter').firstElementChild as HTMLElement;
118 const tick = () => {
119 fill.style.width = `${(recorder?.level() ?? 0) * 100}%`;
120 if (recorder?.recording) meterRaf = requestAnimationFrame(tick);
121 };
122 meterRaf = requestAnimationFrame(tick);
124function meterOff(): void {
125 cancelAnimationFrame(meterRaf);
126 $('meter').hidden = true;
129async function startRecording(target: NonNullable<typeof recTarget>): Promise<void> {
130 recorder = new Recorder(audio());
131 try {
132 await recorder.start();
133 } catch (e) {
134 status(`microphone unavailable: ${e instanceof Error ? e.message : e}`);
135 recorder = null;
136 return;
137 }
138 recTarget = target;
139 meterOn();
140 renderVoiceList();
141 syncTakeBar();
142 status(target.kind === 'take'
143 ? 'recording the take; stop when the phrase is done'
144 : `recording examples for "${voiceById(target.voiceId)?.name}"; make the sound several times, then stop`);
147async function stopRecording(): Promise<void> {
148 if (!recorder) return;
149 const target = recTarget!;
150 let clip: Clip;
151 try {
152 clip = await recorder.stop();
153 } finally {
154 recorder = null;
155 recTarget = null;
156 meterOff();
157 }
158 renderVoiceList();
159 syncTakeBar();
160 if (target.kind === 'voice') {
162 if (!voice) return;
163 const sens = Number(($('capture-sens') as HTMLInputElement).value);
164 const tclip: TrainClip = {
165 id: uid(), sr: clip.sr, sens,
166 onsets: detectOnsets(clip.samples, clip.sr, sens),
167 excluded: [], hits: [],
168 };
169 recomputeHits(tclip, clip.samples);
170 voice.clips.push(tclip);
171 save();
172 void putClipAudio(tclip.id, clip.samples, clip.sr).catch(() =>
173 status('warning: clip audio could not be stored; this clip will not be re-editable after a reload'));
174 openEditor(voice.id, tclip.id, { samples: clip.samples, sr: clip.sr });
175 renderVoiceList();
177 take.clip = clip;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 178 void putTakeAudio(clip.samples, clip.sr).catch(() =>
179 status('warning: take audio could not be stored; it will not survive a reload'));
181 }
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 184// ---- training clip editor ----------------------------------------------------
186const waveView = new WaveView($('capture-wave') as HTMLCanvasElement);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 188function editingClip(): TrainClip | null {
189 if (!edit) return null;
190 return voiceById(edit.voiceId)?.clips.find((c) => c.id === edit!.clipId) ?? null;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 193function recomputeHits(clip: TrainClip, samples: Float32Array): void {
194 const ex = new Set(clip.excluded);
195 clip.hits = [];
196 for (let i = 0; i < clip.onsets.length; i++) {
197 if (ex.has(i)) continue;
198 const s0 = Math.floor(clip.onsets[i] * clip.sr);
199 const s1 = i + 1 < clip.onsets.length ? Math.floor(clip.onsets[i + 1] * clip.sr) : samples.length;
200 const f = extractHit(samples, clip.sr, s0, s1);
201 clip.hits.push({ vec: f.vec, peakDb: f.peakDb });
202 }
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 205function openEditor(voiceId: string, clipId: string, audioData?: { samples: Float32Array; sr: number }): void {
206 const voice = voiceById(voiceId);
207 const clip = voice?.clips.find((c) => c.id === clipId);
208 if (!voice || !clip) return;
209 const open = (samples: Float32Array, sr: number) => {
210 edit = { voiceId, clipId, samples, sr };
211 ($('capture-sens') as HTMLInputElement).value = String(clip.sens);
212 $('capture').hidden = false;
213 refreshEditor();
214 };
215 if (audioData) open(audioData.samples, audioData.sr);
216 else {
217 void getClipAudio(clipId).then((a) => {
218 if (a) open(a.samples, a.sr);
219 else status('this clip\'s audio is missing (stored before audio persistence, or cleared); its hits still classify');
220 });
221 }
224function refreshEditor(): void {
225 const voice = edit && voiceById(edit.voiceId);
226 const clip = editingClip();
227 if (!edit || !voice || !clip) return;
228 $('capture-title').textContent = `"${voice.name}"`;
229 const sel = $('capture-clip') as HTMLSelectElement;
230 sel.textContent = '';
231 voice.clips.forEach((c, i) => {
232 const o = document.createElement('option');
233 o.value = c.id;
234 o.textContent = `clip ${i + 1} · ${c.hits.length} hit${c.hits.length === 1 ? '' : 's'}`;
235 sel.appendChild(o);
236 });
237 sel.value = clip.id;
238 const markers: Marker[] = clip.onsets.map((t, i) => ({ t, excluded: clip.excluded.includes(i) }));
239 waveView.set({ samples: edit.samples, sr: edit.sr }, markers, voice.color);
240 syncEditorButtons();
243function syncEditorButtons(): void {
244 const clip = editingClip();
245 const n = waveView.selection.size;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 246 const btn = $('capture-exclude') as HTMLButtonElement;
248 if (clip && n > 0) {
249 const allExcluded = [...waveView.selection].every((i) => clip.excluded.includes(i));
250 btn.textContent = allExcluded ? `Include (${n})` : `Exclude (${n})`;
251 } else btn.textContent = 'Exclude';
252 $('capture-count').textContent = clip
253 ? `${clip.hits.length} of ${clip.onsets.length} hits included${n ? ` · ${n} selected` : ''}`
254 : '';
257function toggleExcluded(): void {
258 const clip = editingClip();
259 if (!edit || !clip || waveView.selection.size === 0) return;
260 const sel = [...waveView.selection];
261 const allExcluded = sel.every((i) => clip.excluded.includes(i));
262 const ex = new Set(clip.excluded);
263 for (const i of sel) {
264 if (allExcluded) ex.delete(i);
265 else ex.add(i);
266 }
267 clip.excluded = [...ex].sort((a, b) => a - b);
268 recomputeHits(clip, edit.samples);
269 save();
270 waveView.updateMarkers(clip.onsets.map((t, i) => ({ t, excluded: ex.has(i) })));
271 syncEditorButtons();
272 renderVoiceList();
275waveView.onAudition = (i) => {
276 const clip = editingClip();
277 if (!edit || !clip) return;
278 const s0 = Math.floor(clip.onsets[i] * edit.sr);
279 const s1 = i + 1 < clip.onsets.length ? Math.floor(clip.onsets[i + 1] * edit.sr) : edit.samples.length;
280 playSlice(edit.samples, edit.sr, s0, s1);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 282waveView.onSelectionChange = syncEditorButtons;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 284$('capture-exclude').addEventListener('click', toggleExcluded);
286$('capture-sens').addEventListener('input', () => {
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 287 ui.captureSens = Number(($('capture-sens') as HTMLInputElement).value);
288 saveUi(ui);
290 if (!edit || !clip) return;
291 clip.sens = Number(($('capture-sens') as HTMLInputElement).value);
292 clip.onsets = detectOnsets(edit.samples, edit.sr, clip.sens);
293 clip.excluded = [];
294 recomputeHits(clip, edit.samples);
295 save();
296 refreshEditor();
297 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 300$('capture-clip').addEventListener('change', () => {
301 if (!edit) return;
302 openEditor(edit.voiceId, ($('capture-clip') as HTMLSelectElement).value);
303});
305$('capture-delete').addEventListener('click', () => {
306 const voice = edit && voiceById(edit.voiceId);
307 const clip = editingClip();
308 if (!edit || !voice || !clip) return;
309 if (!confirm(`Delete this clip and its ${clip.hits.length} trained hits?`)) return;
310 void deleteClipAudio(clip.id);
311 voice.clips = voice.clips.filter((c) => c.id !== clip.id);
313 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 314 if (voice.clips.length) openEditor(voice.id, voice.clips[voice.clips.length - 1].id);
315 else closeEditor();
319 edit = null;
322$('capture-done').addEventListener('click', closeEditor);
324// ---- voices ---------------------------------------------------------------
327 return v.binding.kind === 'synth' ? `synth:${v.binding.preset}` : `iw:${v.binding.instrument}`;
331 const list = $('voice-list');
332 list.textContent = '';
335 for (const voice of voices) {
336 const row = document.createElement('div');
337 row.className = 'voice-row';
339 const swatch = document.createElement('span');
340 swatch.className = 'swatch';
341 swatch.style.background = voice.color;
343 const name = document.createElement('input');
344 name.className = 'name';
345 name.value = voice.name;
346 name.addEventListener('change', () => {
347 voice.name = name.value.trim() || voice.name;
348 name.value = voice.name;
349 save();
350 refreshTimeline();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 351 if (edit?.voiceId === voice.id) refreshEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 355 const count = document.createElement('span');
356 count.className = 'count';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 357 count.textContent = `${voice.clips.length} clip${voice.clips.length === 1 ? '' : 's'} · ${n} hit${n === 1 ? '' : 's'}`;
359 const rec = document.createElement('button');
360 const recordingThis = recTarget?.kind === 'voice' && recTarget.voiceId === voice.id;
361 rec.innerHTML = recordingThis ? '&#9632; Stop' : '&#9679; Record';
362 if (recordingThis) rec.className = 'rec';
363 rec.disabled = busy && !recordingThis;
364 rec.addEventListener('click', () => {
365 if (recordingThis) void stopRecording();
366 else void startRecording({ kind: 'voice', voiceId: voice.id });
367 });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 369 const editBtn = document.createElement('button');
370 editBtn.textContent = 'Edit';
371 editBtn.title = 'reopen this voice\'s training clips';
372 editBtn.disabled = voice.clips.length === 0;
373 editBtn.addEventListener('click', () => {
374 openEditor(voice.id, voice.clips[voice.clips.length - 1].id);
375 });
377 const binding = document.createElement('select');
378 const gSynth = document.createElement('optgroup');
379 gSynth.label = 'synth';
380 for (const p of PRESETS) {
381 const o = document.createElement('option');
382 o.value = `synth:${p}`; o.textContent = p;
383 gSynth.appendChild(o);
384 }
385 const gIw = document.createElement('optgroup');
386 gIw.label = 'instrawave';
387 for (const nm of iw.names()) {
388 const o = document.createElement('option');
389 o.value = `iw:${nm}`; o.textContent = nm;
390 gIw.appendChild(o);
391 }
392 binding.append(gSynth, gIw);
393 binding.value = bindingValue(voice);
394 binding.addEventListener('change', () => {
395 const [kind, which] = binding.value.split(':');
396 if (kind === 'synth') voice.binding = { kind: 'synth', preset: which as SynthPreset };
397 else {
398 const old = voice.binding;
399 const set = old.kind === 'iw' && old.instrument === which ? old.set : {};
400 voice.binding = { kind: 'iw', instrument: which, set };
401 }
402 save();
403 renderVoiceList();
404 updateBakeBar();
405 });
407 const tune = document.createElement('button');
408 tune.textContent = expandedTuning.has(voice.id) ? 'Tune ▾' : 'Tune ▸';
409 tune.disabled = voice.binding.kind !== 'iw';
410 tune.addEventListener('click', () => {
411 if (expandedTuning.has(voice.id)) expandedTuning.delete(voice.id);
412 else expandedTuning.add(voice.id);
413 renderVoiceList();
414 });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 416 const clear = document.createElement('button');
417 clear.textContent = 'Clear';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 418 clear.title = 'forget this voice\'s training';
419 clear.disabled = n === 0;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 421 if (!confirm(`Forget all ${n} trained hits of "${voice.name}"?`)) return;
422 for (const c of voice.clips) void deleteClipAudio(c.id);
423 voice.clips = [];
424 voice.loose = [];
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 426 if (edit?.voiceId === voice.id) closeEditor();
428 });
430 const del = document.createElement('button');
431 del.textContent = '✕';
432 del.title = 'delete voice';
433 del.addEventListener('click', () => {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 434 if (!confirm(`Delete voice "${voice.name}"? Its training and its timeline events go with it.`)) return;
435 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 436 voices = voices.filter((v) => v.id !== voice.id);
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 437 void deleteRender(voice.id);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 438 take.hits = take.hits.filter((h) => h.voiceId !== voice.id);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 441 if (edit?.voiceId === voice.id) closeEditor();
443 refreshTimeline();
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 447 // The instrument's bake state and its editor live inline on the rows
448 // of the voices bound to it (the state is per instrument, so rows
449 // sharing one show the same thing).
450 const iwControls: HTMLElement[] = [];
451 if (voice.binding.kind === 'iw') {
452 const instName = voice.binding.instrument;
453 const st = bakeStates[instName] ?? 'none';
454 if (st === 'ready' || st === 'stored') {
455 const ok = document.createElement('span');
456 ok.className = 'count';
457 ok.style.color = '#15803d';
458 ok.style.minWidth = 'auto';
459 ok.textContent = `bake ${st}`;
460 if (st === 'stored') ok.title = 'saved in the browser; loads on first use';
461 iwControls.push(ok);
462 } else if (st === 'baking') {
463 const prog = document.createElement('progress');
464 prog.max = 1;
465 bakeProgs.push(prog);
466 const cancel = document.createElement('button');
467 cancel.textContent = 'Cancel';
468 cancel.addEventListener('click', () => { if (bakeJob) bakeJob.cancel = true; });
469 iwControls.push(prog, cancel);
470 } else {
471 const btn = document.createElement('button');
472 btn.textContent = 'Precompute';
473 btn.title = 'bake this instrument at its current geometry';
474 btn.disabled = bakeJob !== null;
475 btn.addEventListener('click', () => void startBake(instName));
476 iwControls.push(btn);
477 }
478 const instBtn = document.createElement('button');
479 instBtn.textContent = iwEdit?.anchor === voice.id ? 'Instrument ▾' : 'Instrument ▸';
480 instBtn.title = 'geometry, accuracy, and the 3D view; shared by every voice bound to this instrument';
481 instBtn.addEventListener('click', () => {
482 if (iwEdit?.anchor === voice.id) closeIwEditor();
483 else void openIwEditor(instName, voice.id);
484 });
485 iwControls.push(instBtn);
486 }
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 488 // Playback volume: applied when tracks are mixed, so it never causes
489 // a render; the cached track is simply scaled.
490 const vol = document.createElement('input');
491 vol.type = 'range';
492 vol.min = '0';
493 vol.max = '1.5';
494 vol.step = '0.01';
495 vol.className = 'vol';
496 vol.value = String(voice.volume);
497 const volTitle = () => { vol.title = `playback volume ${Math.round(voice.volume * 100)}%`; };
498 volTitle();
499 vol.addEventListener('input', () => {
500 voice.volume = Number(vol.value);
501 volTitle();
502 save();
503 });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 505 const spacer = document.createElement('span');
506 spacer.className = 'spacer';
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 507 row.append(swatch, name, count, rec, editBtn, clear, spacer, binding, vol, tune, ...iwControls, del);
510 if (voice.binding.kind === 'iw' && expandedTuning.has(voice.id)) {
511 list.appendChild(tuningPanel(voice, voice.binding.instrument, voice.binding.set));
512 }
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 513 if (iwEdit?.anchor === voice.id) {
514 if (voice.binding.kind === 'iw' && voice.binding.instrument === iwEdit.name) {
515 iwEditEl.hidden = false;
516 list.appendChild(iwEditEl);
517 } else {
518 // The binding moved out from under the open editor; hide it without
519 // re-rendering (we are mid-render already).
520 iwEdit = null;
521 iwEditEl.hidden = true;
522 }
523 }
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 527function tuningPanel(voice: Voice, instrument: string, set: Record<string, number>): HTMLElement {
528 const panel = document.createElement('div');
529 panel.className = 'tuning-panel';
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 530 const bar = document.createElement('div');
531 bar.className = 'bar';
532 bar.style.gridColumn = '1 / -1';
533 bar.style.margin = '0';
534 const hear = document.createElement('button');
535 hear.textContent = 'Hear';
536 hear.addEventListener('click', () => void auditionIw(instrument, set));
537 const hint = document.createElement('span');
538 hint.className = 'hint';
539 hint.textContent = 'one hit at these settings, from the current bake';
540 bar.append(hear, hint);
541 panel.appendChild(bar);
543 const div = document.createElement('div');
544 div.className = 'param';
545 const label = document.createElement('label');
546 const nameSpan = document.createElement('span');
547 nameSpan.textContent = `${p.label} (${p.unit})`;
548 const valSpan = document.createElement('span');
549 const input = document.createElement('input');
550 input.type = 'range';
551 input.min = String(p.min);
552 input.max = String(p.max);
553 input.step = String(p.step ?? (p.max - p.min) / 200);
554 input.value = String(set[p.key] ?? p.value);
555 const show = () => { valSpan.textContent = Number(input.value).toPrecision(4); };
556 show();
557 input.addEventListener('input', () => {
558 set[p.key] = Number(input.value);
559 show();
560 save();
561 });
562 label.append(nameSpan, valSpan);
563 div.append(label, input);
564 panel.appendChild(div);
565 }
566 return panel;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 569$('add-voice').addEventListener('click', () => {
570 const input = $('voice-name') as HTMLInputElement;
571 const name = input.value.trim() || `voice ${voices.length + 1}`;
572 voices.push({
573 id: uid(),
574 name,
575 color: PALETTE[voices.length % PALETTE.length],
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 576 binding: { kind: 'synth', preset: PRESETS[voices.length % PRESETS.length] },
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 577 volume: 1,
579 loose: [],
581 input.value = '';
582 save();
583 renderVoiceList();
584 refreshTimeline();
585});
587$('reset-all').addEventListener('click', () => {
588 if (!confirm('Forget all voices, training, and the current take?')) return;
591 iw.setOverrides({});
592 saveIw();
598 refreshTimeline();
599 syncTakeBar();
602});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 604// ---- bakes ------------------------------------------------------------------
606function neededInstruments(): string[] {
607 return [...new Set(voices.filter((v) => v.binding.kind === 'iw')
608 .map((v) => (v.binding as { instrument: string }).instrument))];
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 611type BakeState = 'ready' | 'stored' | 'baking' | 'none';
612let bakeStates: Record<string, BakeState> = {};
614/** Refresh the per-instrument bake states the voice rows render from;
615 * rebuild the rows only when something actually changed. */
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 617 void refreshBakeStates();
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 620let bakeEpoch = 0;
621async function refreshBakeStates(): Promise<void> {
622 const epoch = ++bakeEpoch;
623 const next: Record<string, BakeState> = {};
624 for (const name of neededInstruments()) {
625 if (bakeJob?.name === name) next[name] = 'baking';
626 else if (await iw.bakeReady(name)) next[name] = 'ready';
627 else if ((await getBakeKey(name).catch(() => null)) === (await iw.currentKey(name))) next[name] = 'stored';
628 else next[name] = 'none';
629 }
630 if (epoch !== bakeEpoch) return; // a newer refresh superseded this one
631 if (JSON.stringify(next) !== JSON.stringify(bakeStates)) {
632 bakeStates = next;
633 renderVoiceList();
634 }
637/** Bring a bake into memory: already there, or stored in the browser from
638 * an earlier session under the same geometry. */
639async function ensureBakeLoaded(name: string): Promise<boolean> {
640 if (await iw.bakeReady(name)) return true;
641 const key = await iw.currentKey(name);
642 if ((await getBakeKey(name).catch(() => null)) !== key) return false;
643 status(`loading the stored ${name} bake\u2026`);
644 const bytes = await getBakeBytes(name).catch(() => null);
645 if (!bytes) { status(''); return false; }
646 const bake = parseBake(bytes);
647 if (bake.key !== key) return false;
648 iw.adoptBake(name, bake);
649 status('');
650 updateBakeBar();
651 return true;
654async function startBake(name: string): Promise<void> {
655 if (bakeJob) return;
656 bakeJob = { name, cancel: false };
657 updateBakeBar();
658 try {
659 await iw.precompute(
660 name,
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 661 (done, total) => { for (const prog of bakeProgs) prog.value = done / total; },
663 );
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 664 status(`${name} bake ready; storing it in the browser\u2026`);
665 const ser = iw.serializedBake(name);
666 if (ser) {
667 await putBake(name, ser.key, ser.bytes).then(
668 () => status(`${name} bake ready and stored (${(ser.bytes.length / 1e6).toFixed(0)} MB)`),
669 () => status(`${name} bake ready, but storing it failed; it will be recomputed next session`),
670 );
671 }
673 status(e instanceof PhaseCancelled ? `${name} precompute cancelled`
674 : `${name} precompute failed: ${e instanceof Error ? e.message : e}`);
675 } finally {
676 bakeJob = null;
677 updateBakeBar();
678 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 681// ---- take -----------------------------------------------------------------
683const timeline = new Timeline($('timeline') as HTMLCanvasElement);
686const activeClfSource = (): string =>
687 clf.method === 'custom' ? (clf.customSrc ?? CUSTOM_CLASSIFIER_TEMPLATE)
688 : (clf.builtinSrc ?? DEFAULT_CLASSIFIER_SOURCE);
690async function processTake(): Promise<void> {
692 stopPlayback();
693 const { samples, sr } = take.clip;
696 const btn = $('take-redetect') as HTMLButtonElement;
697 btn.disabled = true;
698 status('classifying take with numbl...');
699 try {
700 const { training, voiceIds, skipped } = await buildVoiceTraining(voices, getClipAudio);
701 if (voiceIds.length === 0) {
702 status('no voice has training clips with audio; record examples first, or switch the classifier to the built-in feature kNN');
703 return;
704 }
705 const x16 = downsampleBox(samples, sr, CLF_FS);
706 const { detections } = await classifyInWorker(activeClfSource(), x16, CLF_FS, training);
708 const hits: TakeHit[] = [];
709 let refDb = -160;
710 for (let i = 0; i < detections.length; i++) {
711 const det = detections[i];
712 if (det.typeIndex < 0 || det.typeIndex >= voiceIds.length) continue;
713 const s0 = Math.max(0, Math.min(samples.length - 1, Math.floor(det.t * sr)));
714 const s1 = i + 1 < detections.length ? Math.floor(detections[i + 1].t * sr) : samples.length;
715 const f = extractHit(samples, sr, s0, Math.max(s1, s0 + 1));
716 if (f.peakDb > refDb) refDb = f.peakDb;
717 hits.push({ id: uid(), t: det.t, voiceId: voiceIds[det.typeIndex], gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
718 }
719 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
720 take.hits = hits;
721 saveTakeHits(take.hits);
722 refreshTimeline();
723 const algoName = clf.method === 'builtin' ? DEFAULT_CLASSIFIER_NAME : 'the custom algorithm';
724 const skippedNote = skipped.length ? `; no training audio for ${skipped.join(', ')}` : '';
725 status(`${hits.length} hits classified by ${algoName} across ${voiceIds.length} trained voice${voiceIds.length === 1 ? '' : 's'}${skippedNote}`);
726 } catch (err) {
727 status(`classifier failed: ${err instanceof Error ? err.message : err}; previous hits kept`);
728 } finally {
729 btn.disabled = take.clip === null;
730 syncTakeBar();
731 }
734function processTakeLegacy(samples: Float32Array, sr: number): void {
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 735 const sens = Number(($('take-sens') as HTMLInputElement).value);
736 const onsets = detectOnsets(samples, sr, sens);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 737 const trained = voices.filter((v) => trainPool(v).length > 0);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 739 voiceIds: trained.flatMap((v) => trainPool(v).map(() => v.id)),
740 vecs: trained.flatMap((v) => trainPool(v).map((h) => h.vec)),
743 const hits: TakeHit[] = [];
744 let refDb = -160;
745 for (let i = 0; i < onsets.length; i++) {
746 const s0 = Math.floor(onsets[i] * sr);
747 const s1 = i + 1 < onsets.length ? Math.floor(onsets[i + 1] * sr) : samples.length;
748 const f = extractHit(samples, sr, s0, s1);
749 if (f.peakDb > refDb) refDb = f.peakDb;
750 const cls = classify(f.vec, trainSet);
751 const voiceId = cls?.voiceId ?? voices[0]?.id;
752 if (!voiceId) continue;
753 hits.push({ id: uid(), t: onsets[i], voiceId, gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
754 }
755 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
756 take.hits = hits;
759 syncTakeBar();
760 if (voices.length === 0) status('no voices defined; add and train a voice, then hit re-detect');
761 else if (trainSet.vecs.length === 0) status(`no trained hits yet; all ${hits.length} events landed on "${voices[0].name}"`);
762 else status(`${hits.length} hits classified across ${trained.length} trained voice${trained.length === 1 ? '' : 's'}`);
765function refreshTimeline(): void {
766 timeline.setData(
767 voices.map((v) => ({ id: v.id, name: v.name, color: v.color })),
768 take.hits,
769 take.clip,
770 );
771 syncHitButtons();
774function syncTakeBar(): void {
775 const recordingTake = recTarget?.kind === 'take';
776 const btn = $('take-record') as HTMLButtonElement;
777 btn.innerHTML = recordingTake ? '&#9632; Stop' : '&#9679; Record take';
778 btn.className = recordingTake ? 'rec' : '';
779 btn.disabled = recorder !== null && !recordingTake;
780 ($('take-redetect') as HTMLButtonElement).disabled = take.clip === null;
781 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0;
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 782 ($('take-save') as HTMLButtonElement).disabled = take.hits.length === 0;
785function syncPlayButton(): void {
786 ($('take-play') as HTMLButtonElement).textContent = playing ? 'Stop' : 'Play';
787 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0 && !playing;
790function syncHitButtons(): void {
791 const sel = take.hits.find((h) => h.id === timeline.selected);
792 ($('hit-delete') as HTMLButtonElement).disabled = !sel;
793 ($('hit-learn') as HTMLButtonElement).disabled = !sel || sel.vec === null;
796$('take-record').addEventListener('click', () => {
797 if (recTarget?.kind === 'take') void stopRecording();
798 else void startRecording({ kind: 'take' });
799});
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 802$('take-sens').addEventListener('change', () => {
803 ui.takeSens = Number(($('take-sens') as HTMLInputElement).value);
804 saveUi(ui);
806});
808// ---- classifier selection and editor ---------------------------------------
810const clfMethodSel = $('clf-method') as HTMLSelectElement;
812function syncClfBar(): void {
813 clfMethodSel.value = clf.method;
814 ($('take-sens-wrap') as HTMLElement).hidden = clf.method !== 'legacy';
815 ($('clf-edit') as HTMLButtonElement).disabled = clf.method === 'legacy';
818function syncClfEditor(): void {
819 if ($('clf-editor').hidden) return;
820 $('clf-title').textContent = clf.method === 'custom' ? 'custom classifier' : DEFAULT_CLASSIFIER_NAME;
821 ($('clf-source') as HTMLTextAreaElement).value = activeClfSource();
822 ($('clf-reset') as HTMLButtonElement).hidden = clf.method !== 'builtin' || clf.builtinSrc === null;
825clfMethodSel.addEventListener('change', () => {
826 clf.method = clfMethodSel.value as typeof clf.method;
827 saveClassifier(clf);
828 syncClfBar();
829 if (clf.method === 'legacy') $('clf-editor').hidden = true;
830 else syncClfEditor();
831 if (take.clip) void processTake();
832});
834$('clf-edit').addEventListener('click', () => {
835 $('clf-editor').hidden = false;
836 syncClfEditor();
837});
838$('clf-close').addEventListener('click', () => { $('clf-editor').hidden = true; });
839($('clf-source') as HTMLTextAreaElement).addEventListener('change', () => {
840 const src = ($('clf-source') as HTMLTextAreaElement).value;
841 if (clf.method === 'custom') clf.customSrc = src;
842 else clf.builtinSrc = src === DEFAULT_CLASSIFIER_SOURCE ? null : src;
843 saveClassifier(clf);
844 syncClfEditor();
845});
846$('clf-reset').addEventListener('click', () => {
847 clf.builtinSrc = null;
848 saveClassifier(clf);
849 syncClfEditor();
852timeline.onSelect = (id) => {
853 syncHitButtons();
854 const hit = take.hits.find((h) => h.id === id);
855 if (!hit) return;
857 status(`hit at ${hit.t.toFixed(3)} s · "${voice?.name ?? '?'}" · strength ${hit.gain.toFixed(2)}`);
858 if (hit.s0 >= 0 && take.clip) playSlice(take.clip.samples, take.clip.sr, hit.s0, hit.s1);
859 else if (voice?.binding.kind === 'synth') void auditionSynth(hit, voice.binding.preset);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 862async function auditionSynth(hit: TakeHit, preset: SynthPreset): Promise<void> {
863 const buf = await renderSynth([{ t: 0, voiceId: hit.voiceId, gain: hit.gain }], () => preset, 1);
867timeline.onReassign = (id, voiceId) => {
870 status(voice ? `hit moved to "${voice.name}"; Learn adds it to that voice's training` : '');
871 syncHitButtons();
872};
874timeline.onAdd = (t, voiceId) => {
875 take.hits.push({ id: uid(), t, voiceId, gain: 0.8, peakDb: 0, vec: null, s0: -1, s1: -1 });
876 take.hits.sort((a, b) => a.t - b.t);
879};
881$('hit-delete').addEventListener('click', deleteSelected);
882document.addEventListener('keydown', (e) => {
883 if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
885 if (e.key === 'x' || e.key === 'X' || e.key === 'Delete' || e.key === 'Backspace') {
886 e.preventDefault();
887 toggleExcluded();
888 }
889 return;
890 }
891 if (e.key === 'Delete' || e.key === 'Backspace') deleteSelected();
893function deleteSelected(): void {
894 if (!timeline.selected) return;
895 take.hits = take.hits.filter((h) => h.id !== timeline.selected);
898 refreshTimeline();
901$('hit-learn').addEventListener('click', () => {
902 const hit = take.hits.find((h) => h.id === timeline.selected);
903 if (!hit || !hit.vec) return;
904 const voice = voiceById(hit.voiceId);
905 if (!voice) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 906 voice.loose.push({ vec: hit.vec, peakDb: hit.peakDb });
908 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 909 status(`learned this hit as "${voice.name}" (${trainPool(voice).length} trained)`);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 912// ---- the mix ----------------------------------------------------------------
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 914const iwTrackCache = new Map<string, { sig: string; data: Float32Array }>();
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 916/** The rendered track for one instrawave voice's events: from the caches
917 * when the signature (events, tuning, gains, bake key) matches, memory
918 * first, then IndexedDB, where a stored track plays without its bake even
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 919 * loaded; from the GPU otherwise. Returned at the mix rate, levelled to
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 920 * 0.8 peak. Playback volume is deliberately absent from the signature. */
921async function iwTrack(voice: Voice, evs: StimulusEvent[]): Promise<Float32Array> {
922 if (voice.binding.kind !== 'iw') throw new Error(`voice "${voice.name}" is not bound to an instrument`);
923 const name = voice.binding.instrument;
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 924 evs.sort((a, b) => a.t - b.t);
925 // The track's own length: its last event plus its longest decay, so an
926 // edit on another lane cannot change this group's signature.
927 let tail = 1.5;
928 const hasT60 = iw.tuning(name).some((p) => p.key === 't60');
929 for (const e of evs) {
930 const t60 = e.set?.t60 ?? (hasT60 ? iw.defaultValue(name, 't60') : 1.5);
931 tail = Math.max(tail, Math.min(6, t60));
932 }
933 const seconds = evs[evs.length - 1].t + tail;
934 const sig = JSON.stringify({
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 935 inst: name,
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 936 key: await iw.currentKey(name),
937 secs: seconds,
938 evs: evs.map((e) => [e.t, Object.entries(e.set ?? {}).sort()]),
939 });
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 940 const mem = iwTrackCache.get(voice.id);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 941 if (mem?.sig === sig) return mem.data;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 942 const stored = await getRender(voice.id).catch(() => null);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 943 if (stored?.sig === sig) {
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 944 iwTrackCache.set(voice.id, stored);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 945 return stored.data;
946 }
947 if (!(await ensureBakeLoaded(name))) {
948 throw new Error(`precompute the ${name} bake first`);
949 }
950 const { trace, dt } = await iw.render(name, seconds, evs, (done, total) =>
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 951 status(`rendering "${voice.name}" on ${name} (${evs.length} event${evs.length === 1 ? '' : 's'}): ${Math.round((100 * done) / total)}%`));
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 952 // Physical pressure amplitudes carry no absolute scale worth keeping
953 // across engines; each instrument's track is levelled to sit alongside
954 // the synth part, while relative volumes WITHIN it stay physical.
955 const data = resampleLinear(trace, 1 / dt, MIX_SR);
956 const p = peak(data);
957 if (p > 0) for (let i = 0; i < data.length; i++) data[i] *= 0.8 / p;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 958 iwTrackCache.set(voice.id, { sig, data });
959 void putRender(voice.id, sig, data).catch(() => { /* it will re-render next session */ });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 963async function renderMix(): Promise<AudioBuffer> {
964 const sig = JSON.stringify({
965 hits: take.hits.map((h) => [h.t, h.voiceId, h.gain]),
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 966 bindings: voices.map((v) => [v.id, v.binding, v.volume]),
969 if (renderCache?.sig === sig) return renderCache.buf;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 971 // One track per voice, mixed at that voice's playback volume. The
972 // volume scales a finished track, so moving a slider re-mixes cached
973 // audio and renders nothing.
974 const byVoice = new Map<string, TakeHit[]>();
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 976 const list = byVoice.get(h.voiceId);
977 if (list) list.push(h);
978 else if (voiceById(h.voiceId)) byVoice.set(h.voiceId, [h]);
979 }
980 const tracks: { data: Float32Array; vol: number }[] = [];
981 for (const [voiceId, hits] of byVoice) {
982 const v = voiceById(voiceId)!;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 984 const preset = v.binding.preset;
985 const events: NoteEvent[] = hits.map((h) => ({ t: h.t, voiceId, gain: h.gain }));
986 const secs = Math.max(...events.map((e) => e.t)) + 1.5;
987 const buf = await renderSynth(events, () => preset, secs);
988 tracks.push({ data: buf.getChannelData(0), vol: v.volume });
990 const b = v.binding;
991 const allowed = new Set(iw.tuning(b.instrument).map((p) => p.key));
992 const strength = iw.strengthParam(b.instrument);
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 993 const events: StimulusEvent[] = hits.map((h) => {
994 const set: Record<string, number> = {};
995 for (const [k, val] of Object.entries(b.set)) if (allowed.has(k)) set[k] = val;
996 set[strength] = (b.set[strength] ?? iw.defaultValue(b.instrument, strength)) * h.gain;
997 return { t: h.t, set };
998 });
999 tracks.push({ data: await iwTrack(v, events), vol: v.volume });
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 1002 const seconds = Math.max(1, ...tracks.map((tr) => tr.data.length / MIX_SR));
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1003 const out = new Float32Array(Math.ceil(seconds * MIX_SR));
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 1004 for (const tr of tracks) mixInto(out, tr.data, tr.vol);
1006 if (pAll > 0.98) for (let i = 0; i < out.length; i++) out[i] *= 0.95 / pAll;
1007 const buf = audio().createBuffer(1, out.length, MIX_SR);
1008 buf.getChannelData(0).set(out);
1009 renderCache = { sig, buf };
1010 return buf;
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 1013$('take-save').addEventListener('click', async () => {
1014 if (take.hits.length === 0) return;
1015 const btn = $('take-save') as HTMLButtonElement;
1016 btn.disabled = true;
1017 try {
1018 const buf = await renderMix();
1019 const bytes = wavBytes(buf.getChannelData(0), MIX_SR);
1020 const url = URL.createObjectURL(new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'audio/wav' }));
1021 const a = document.createElement('a');
1022 a.href = url;
1023 a.download = 'instrawave-compose-take.wav';
1024 a.click();
1025 URL.revokeObjectURL(url);
1026 status(`saved ${(buf.duration).toFixed(1)} s of audio`);
1027 } catch (e) {
1028 status(e instanceof Error ? e.message : String(e));
1029 } finally {
1030 btn.disabled = take.hits.length === 0;
1032});
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1034$('take-play').addEventListener('click', async () => {
1035 if (playing) { stopPlayback(); return; }
1036 if (take.hits.length === 0) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1037 const btn = $('take-play') as HTMLButtonElement;
1038 btn.disabled = true;
1039 try {
1040 const buf = await renderMix();
1041 status('');
1042 void playBuffer(buf, (t) => timeline.playhead(t));
1043 } catch (e) {
1044 status(e instanceof Error ? e.message : String(e));
1045 } finally {
1046 btn.disabled = false;
1047 syncPlayButton();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1051// ---- tuning audition ----------------------------------------------------------
1053async function auditionIw(instrument: string, set: Record<string, number>): Promise<void> {
1054 if (iwBusy || bakeJob) { status('the GPU is busy; try again in a moment'); return; }
1055 iwBusy = true;
1056 try {
1057 if (!(await ensureBakeLoaded(instrument))) {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1058 status(`no current ${instrument} bake; precompute it in the voice row`);
1061 const allowed = new Set(iw.tuning(instrument).map((p) => p.key));
1062 const ev: Record<string, number> = {};
1063 for (const [k, v] of Object.entries(set)) if (allowed.has(k)) ev[k] = v;
1064 const t60 = set.t60 ?? (allowed.has('t60') ? iw.defaultValue(instrument, 't60') : 2);
1065 const seconds = Math.min(5, Math.max(1.2, t60 * 0.6 + 0.5));
1066 const { trace, dt } = await iw.render(instrument, seconds, [{ t: 0, set: ev }], (done, total) =>
1067 status(`rendering ${instrument}: ${Math.round((100 * done) / total)}%`));
1068 const res = resampleLinear(trace, 1 / dt, MIX_SR);
1069 const pk = peak(res);
1070 if (pk > 0) for (let i = 0; i < res.length; i++) res[i] *= 0.8 / pk;
1071 const buf = audio().createBuffer(1, res.length, MIX_SR);
1072 buf.getChannelData(0).set(res);
1073 status('');
1074 void playBuffer(buf);
1075 } catch (e) {
1076 status(e instanceof Error ? e.message : String(e));
1077 } finally {
1078 iwBusy = false;
1082// ---- instrument editor (geometry, accuracy, the 3D view) ----------------------
1083// Geometry belongs to the instrument, not the voice: it is what the shared
1084// bake depends on, so the editor opens from the bake bar and an edit marks
1085// the bake stale there. The scene is instrawave's own viewer, raymarching
1086// the solver's actual fields, so what it shows is what the bake will see.
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1088let iwEdit: { name: string; anchor: string; viewer: Viewer | null } | null = null;
1089const iwEditEl = $('iwedit');
1090const iwEditTitle = $('iwedit-title');
1091const iwEditParams = $('iwedit-params');
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1092const iwCam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 };
1093const iwVolume = $('iw-volume') as HTMLCanvasElement;
1094const iwOverlay = $('iw-overlay') as HTMLCanvasElement;
1096function sizeSceneCanvas(c: HTMLCanvasElement): void {
1097 const r = c.getBoundingClientRect();
1098 const dpr = Math.min(devicePixelRatio || 1, 2);
1099 const w = Math.max(1, Math.round(r.width * dpr));
1100 const h = Math.max(1, Math.round(r.height * dpr));
1101 if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
1104let iwViewTimer: number | null = null;
1105function scheduleIwView(): void {
1106 if (iwViewTimer !== null) clearTimeout(iwViewTimer);
1107 iwViewTimer = window.setTimeout(() => {
1108 iwViewTimer = null;
1109 const e = iwEdit;
1110 if (!e?.viewer) return;
1111 void (async () => {
1112 try {
1113 await e.viewer!.update(iw.scope(e.name));
1114 e.viewer!.draw(iwCam);
1115 } catch (err) {
1116 status(`scene view: ${err instanceof Error ? err.message : err}`);
1118 })();
1119 }, 180);
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1122async function openIwEditor(name: string, anchor: string): Promise<void> {
1123 iwEdit = { name, anchor, viewer: null };
1124 iwEditTitle.textContent = name;
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1126 renderVoiceList(); // inserts the panel under its row and shows it
1128 const device = await iw.ensureDevice();
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1129 if (iwEdit?.name !== name || iwEdit.anchor !== anchor) return;
1131 sizeSceneCanvas(iwOverlay);
1132 iwEdit.viewer = new Viewer(device, iw.file(name), iwVolume, iwOverlay);
1133 scheduleIwView();
1134 } catch (e) {
1135 status(`scene view unavailable: ${e instanceof Error ? e.message : e} (the sliders still work)`);
1139function buildIwParams(name: string): void {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1140 const box = iwEditParams;
1142 const values = iw.values(name);
1143 for (const p of iw.geometryParams(name)) {
1144 const div = document.createElement('div');
1145 div.className = 'param';
1146 const label = document.createElement('label');
1147 const nameSpan = document.createElement('span');
1148 nameSpan.textContent = `${p.label} (${p.unit})`;
1149 if (p.hint) nameSpan.title = p.hint;
1150 const valSpan = document.createElement('span');
1151 const input = document.createElement('input');
1152 input.type = 'range';
1153 input.min = String(p.min);
1154 input.max = String(p.max);
1155 input.step = String(p.step ?? (p.max - p.min) / 200);
1156 input.value = String(values[p.key]);
1157 const show = () => { valSpan.textContent = Number(input.value).toPrecision(4); };
1158 show();
1159 input.addEventListener('input', () => {
1160 iw.setOverride(name, p.key, Number(input.value));
1161 show();
1162 saveIw();
1163 scheduleIwView();
1164 updateBakeBar(); // the bake just went stale (or came back)
1165 });
1166 label.append(nameSpan, valSpan);
1167 div.append(label, input);
1168 box.appendChild(div);
1172function closeIwEditor(): void {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1173 const wasOpen = iwEdit !== null;
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1175 iwEditEl.hidden = true;
1176 if (wasOpen) renderVoiceList();
1178$('iwedit-done').addEventListener('click', closeIwEditor);
1181 let dragging = false, px = 0, py = 0;
1182 iwOverlay.addEventListener('pointerdown', (e) => {
1183 dragging = true; px = e.clientX; py = e.clientY;
1184 iwOverlay.setPointerCapture(e.pointerId);
1185 });
1186 iwOverlay.addEventListener('pointermove', (e) => {
1187 if (!dragging || !iwEdit?.viewer) return;
1188 iwCam.yaw -= (e.clientX - px) * 0.008;
1189 iwCam.pitch = Math.min(1.45, Math.max(-1.45, iwCam.pitch + (e.clientY - py) * 0.008));
1190 px = e.clientX; py = e.clientY;
1191 iwEdit.viewer.draw(iwCam);
1192 });
1193 iwOverlay.addEventListener('pointerup', () => { dragging = false; });
1194 iwOverlay.addEventListener('wheel', (e) => {
1195 if (!iwEdit?.viewer) return;
1196 e.preventDefault();
1197 iwCam.dist = Math.min(6, Math.max(0.3, iwCam.dist * Math.exp(e.deltaY * 0.001)));
1198 iwEdit.viewer.draw(iwCam);
1199 }, { passive: false });
1200 window.addEventListener('resize', () => {
1201 if (!iwEdit?.viewer) return;
1202 sizeSceneCanvas(iwVolume);
1203 sizeSceneCanvas(iwOverlay);
1204 scheduleIwView();
1205 });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1208// ---- boot -----------------------------------------------------------------
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1210($('capture-sens') as HTMLInputElement).value = String(ui.captureSens);
1211($('take-sens') as HTMLInputElement).value = String(ui.takeSens);
1214void getTakeAudio().then((a) => {
1215 if (a) {
1216 take.clip = { samples: a.samples, sr: a.sr };
1217 refreshTimeline();
1218 syncTakeBar();
1220}).catch(() => { /* the hits still play and edit; re-detect needs the audio */ });
1222refreshTimeline();
1223syncTakeBar();
1226 status('start by adding a voice, then record yourself making its sound a handful of times');
↑↓ move↵openescclose