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';
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';
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 21import {
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 22 clearBulk, clearStore, deleteClipAudio, getBakeBytes, getBakeKey, getClipAudio, getTakeAudio,
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 23 deleteRender, getRender, loadClassifier, loadIwOverrides, loadTakeHits, loadUi, loadVoices,
24 putBake, putClipAudio,
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 25 putRender, putTakeAudio,
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 26 saveClassifier, saveIwOverrides, saveTakeHits, saveUi, saveVoices, trainPool,
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 27 type TakeHit, type TrainClip, type Voice,
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 28} from './store.ts';
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 29import { BUILTIN_CLASSIFIERS, CUSTOM_CLASSIFIER_TEMPLATE, type BuiltinMethod } from './numbl/index.ts';
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 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'];
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 39const MIX_SR = 44100;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 40
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>();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 51
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;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 57
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 58const iw = new IwEngine();
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;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 63let iwBusy = false;
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 64let bakeProgs: HTMLProgressElement[] = [];
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 65
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; }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 69
70// ---- audition -------------------------------------------------------------
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 72function 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 73 const ctx = audio();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 74 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 75 if (n <= 0) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 76 const buf = ctx.createBuffer(1, n, sr);
77 buf.getChannelData(0).set(samples.subarray(s0, s0 + n));
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 78 const src = ctx.createBufferSource();
79 src.buffer = buf;
80 src.connect(ctx.destination);
81 src.start();
82}
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();
103}
105function stopPlayback(): void {
106 playing?.src.stop();
107 playing = null;
108 timeline.playhead(null);
109 syncPlayButton();
110}
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);
123}
124function meterOff(): void {
125 cancelAnimationFrame(meterRaf);
126 $('meter').hidden = true;
127}
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`);
145}
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') {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 161 const voice = voiceById(target.voiceId);
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();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 176 } else {
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'));
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 180 processTake();
181 }
182}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 184// ---- training clip editor ----------------------------------------------------
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 185
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;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 191}
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 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 203}
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 }
222}
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();
241}
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;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 247 btn.disabled = n === 0;
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 : '';
255}
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();
273}
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);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 281};
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 282waveView.onSelectionChange = syncEditorButtons;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 283
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);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 289 const clip = editingClip();
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();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 298});
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);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 312 save();
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();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 316});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 318function closeEditor(): void {
319 edit = null;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 320 $('capture').hidden = true;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 321}
322$('capture-done').addEventListener('click', closeEditor);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 323
324// ---- voices ---------------------------------------------------------------
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 326function bindingValue(v: Voice): string {
327 return v.binding.kind === 'synth' ? `synth:${v.binding.preset}` : `iw:${v.binding.instrument}`;
328}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 330function renderVoiceList(): void {
331 const list = $('voice-list');
332 list.textContent = '';
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 334 const busy = recorder !== null;
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 352 });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 354 const n = trainPool(voice).length;
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'}`;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 358
359 const rec = document.createElement('button');
360 const recordingThis = recTarget?.kind === 'voice' && recTarget.voiceId === voice.id;
361 rec.innerHTML = recordingThis ? '■ Stop' : '● 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;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 420 clear.addEventListener('click', () => {
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 = [];
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 425 save();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 426 if (edit?.voiceId === voice.id) closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 427 renderVoiceList();
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);
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);
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 439 saveTakeHits(take.hits);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 440 save();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 441 if (edit?.voiceId === voice.id) closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 442 renderVoiceList();
443 refreshTimeline();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 444 updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 445 });
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);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 508 list.appendChild(row);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 509
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 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 524 }
525}
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);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 542 for (const p of iw.tuning(instrument)) {
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;
567}
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] },
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 578 clips: [],
579 loose: [],
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 580 });
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;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 589 clearStore();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 590 void clearBulk();
591 iw.setOverrides({});
592 saveIw();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 593 voices = [];
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 594 closeIwEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 595 take = { clip: null, hits: [] };
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 596 closeEditor();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 597 renderVoiceList();
598 refreshTimeline();
599 syncTakeBar();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 600 updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 601 status('');
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))];
609}
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. */
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 616function updateBakeBar(): void {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 617 void refreshBakeStates();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 618}
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 }
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 635}
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;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 652}
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; },
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 662 () => bakeJob?.cancel ?? true,
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 }
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 672 } catch (e) {
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 }
679}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 681// ---- take -----------------------------------------------------------------
683const timeline = new Timeline($('timeline') as HTMLCanvasElement);
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 685const clf = loadClassifier();
686const activeClfSource = (): string =>
687 clf.method === 'custom' ? (clf.customSrc ?? CUSTOM_CLASSIFIER_TEMPLATE)
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 688 : clf.method === 'pursuit' ? (clf.pursuitSrc ?? BUILTIN_CLASSIFIERS.pursuit.source)
689 : (clf.builtinSrc ?? BUILTIN_CLASSIFIERS.builtin.source);
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 690
691async function processTake(): Promise<void> {
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 692 if (!take.clip) return;
693 stopPlayback();
694 const { samples, sr } = take.clip;
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 695 if (clf.method === 'legacy') { processTakeLegacy(samples, sr); return; }
697 const btn = $('take-redetect') as HTMLButtonElement;
698 btn.disabled = true;
699 status('classifying take with numbl...');
700 try {
701 const { training, voiceIds, skipped } = await buildVoiceTraining(voices, getClipAudio);
702 if (voiceIds.length === 0) {
703 status('no voice has training clips with audio; record examples first, or switch the classifier to the built-in feature kNN');
704 return;
705 }
706 const x16 = downsampleBox(samples, sr, CLF_FS);
707 const { detections } = await classifyInWorker(activeClfSource(), x16, CLF_FS, training);
709 const hits: TakeHit[] = [];
710 let refDb = -160;
711 for (let i = 0; i < detections.length; i++) {
712 const det = detections[i];
713 if (det.typeIndex < 0 || det.typeIndex >= voiceIds.length) continue;
714 const s0 = Math.max(0, Math.min(samples.length - 1, Math.floor(det.t * sr)));
715 const s1 = i + 1 < detections.length ? Math.floor(detections[i + 1].t * sr) : samples.length;
716 const f = extractHit(samples, sr, s0, Math.max(s1, s0 + 1));
717 if (f.peakDb > refDb) refDb = f.peakDb;
718 hits.push({ id: uid(), t: det.t, voiceId: voiceIds[det.typeIndex], gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
719 }
720 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
721 take.hits = hits;
722 saveTakeHits(take.hits);
723 refreshTimeline();
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 724 const algoName = clf.method === 'custom'
725 ? 'the custom algorithm' : BUILTIN_CLASSIFIERS[clf.method as BuiltinMethod].name;
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 726 const skippedNote = skipped.length ? `; no training audio for ${skipped.join(', ')}` : '';
727 status(`${hits.length} hits classified by ${algoName} across ${voiceIds.length} trained voice${voiceIds.length === 1 ? '' : 's'}${skippedNote}`);
728 } catch (err) {
729 status(`classifier failed: ${err instanceof Error ? err.message : err}; previous hits kept`);
730 } finally {
731 btn.disabled = take.clip === null;
732 syncTakeBar();
733 }
734}
736function processTakeLegacy(samples: Float32Array, sr: number): void {
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 737 const sens = Number(($('take-sens') as HTMLInputElement).value);
738 const onsets = detectOnsets(samples, sr, sens);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 739 const trained = voices.filter((v) => trainPool(v).length > 0);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 740 const trainSet: TrainingSet = {
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 741 voiceIds: trained.flatMap((v) => trainPool(v).map(() => v.id)),
742 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 743 };
745 const hits: TakeHit[] = [];
746 let refDb = -160;
747 for (let i = 0; i < onsets.length; i++) {
748 const s0 = Math.floor(onsets[i] * sr);
749 const s1 = i + 1 < onsets.length ? Math.floor(onsets[i + 1] * sr) : samples.length;
750 const f = extractHit(samples, sr, s0, s1);
751 if (f.peakDb > refDb) refDb = f.peakDb;
752 const cls = classify(f.vec, trainSet);
753 const voiceId = cls?.voiceId ?? voices[0]?.id;
754 if (!voiceId) continue;
755 hits.push({ id: uid(), t: onsets[i], voiceId, gain: 0, peakDb: f.peakDb, vec: f.vec, s0, s1 });
756 }
757 for (const h of hits) h.gain = gainFromPeakDb(h.peakDb, refDb);
758 take.hits = hits;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 759 saveTakeHits(take.hits);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 760 refreshTimeline();
761 syncTakeBar();
762 if (voices.length === 0) status('no voices defined; add and train a voice, then hit re-detect');
763 else if (trainSet.vecs.length === 0) status(`no trained hits yet; all ${hits.length} events landed on "${voices[0].name}"`);
764 else status(`${hits.length} hits classified across ${trained.length} trained voice${trained.length === 1 ? '' : 's'}`);
765}
767function refreshTimeline(): void {
768 timeline.setData(
769 voices.map((v) => ({ id: v.id, name: v.name, color: v.color })),
770 take.hits,
771 take.clip,
772 );
773 syncHitButtons();
774}
776function syncTakeBar(): void {
777 const recordingTake = recTarget?.kind === 'take';
778 const btn = $('take-record') as HTMLButtonElement;
779 btn.innerHTML = recordingTake ? '■ Stop' : '● Record take';
780 btn.className = recordingTake ? 'rec' : '';
781 btn.disabled = recorder !== null && !recordingTake;
782 ($('take-redetect') as HTMLButtonElement).disabled = take.clip === null;
783 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0;
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 784 ($('take-save') as HTMLButtonElement).disabled = take.hits.length === 0;
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 785}
787function syncPlayButton(): void {
788 ($('take-play') as HTMLButtonElement).textContent = playing ? 'Stop' : 'Play';
789 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0 && !playing;
790}
792function syncHitButtons(): void {
793 const sel = take.hits.find((h) => h.id === timeline.selected);
794 ($('hit-delete') as HTMLButtonElement).disabled = !sel;
795 ($('hit-learn') as HTMLButtonElement).disabled = !sel || sel.vec === null;
796}
798$('take-record').addEventListener('click', () => {
799 if (recTarget?.kind === 'take') void stopRecording();
800 else void startRecording({ kind: 'take' });
801});
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 803$('take-redetect').addEventListener('click', () => void processTake());
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 804$('take-sens').addEventListener('change', () => {
805 ui.takeSens = Number(($('take-sens') as HTMLInputElement).value);
806 saveUi(ui);
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 807 if (take.clip) void processTake();
808});
810// ---- classifier selection and editor ---------------------------------------
812const clfMethodSel = $('clf-method') as HTMLSelectElement;
814function syncClfBar(): void {
815 clfMethodSel.value = clf.method;
816 ($('take-sens-wrap') as HTMLElement).hidden = clf.method !== 'legacy';
817 ($('clf-edit') as HTMLButtonElement).disabled = clf.method === 'legacy';
818}
820function syncClfEditor(): void {
821 if ($('clf-editor').hidden) return;
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 822 $('clf-title').textContent = clf.method === 'custom'
823 ? 'custom classifier' : BUILTIN_CLASSIFIERS[clf.method as BuiltinMethod].name;
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 824 ($('clf-source') as HTMLTextAreaElement).value = activeClfSource();
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 825 ($('clf-reset') as HTMLButtonElement).hidden = clf.method === 'custom' || clf.method === 'legacy'
826 || (clf.method === 'builtin' ? clf.builtinSrc : clf.pursuitSrc) === null;
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 827}
829clfMethodSel.addEventListener('change', () => {
830 clf.method = clfMethodSel.value as typeof clf.method;
831 saveClassifier(clf);
832 syncClfBar();
833 if (clf.method === 'legacy') $('clf-editor').hidden = true;
834 else syncClfEditor();
835 if (take.clip) void processTake();
836});
838$('clf-edit').addEventListener('click', () => {
839 $('clf-editor').hidden = false;
840 syncClfEditor();
841});
842$('clf-close').addEventListener('click', () => { $('clf-editor').hidden = true; });
843($('clf-source') as HTMLTextAreaElement).addEventListener('change', () => {
844 const src = ($('clf-source') as HTMLTextAreaElement).value;
845 if (clf.method === 'custom') clf.customSrc = src;
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 846 else if (clf.method === 'pursuit') clf.pursuitSrc = src === BUILTIN_CLASSIFIERS.pursuit.source ? null : src;
847 else clf.builtinSrc = src === BUILTIN_CLASSIFIERS.builtin.source ? null : src;
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 848 saveClassifier(clf);
849 syncClfEditor();
850});
851$('clf-reset').addEventListener('click', () => {
7d7cc56Add overlap pursuit classifier and update composerJeremy Magland 852 if (clf.method === 'pursuit') clf.pursuitSrc = null;
853 else clf.builtinSrc = null;
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 854 saveClassifier(clf);
855 syncClfEditor();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 856});
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 857
858timeline.onSelect = (id) => {
859 syncHitButtons();
860 const hit = take.hits.find((h) => h.id === id);
861 if (!hit) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 862 const voice = voiceById(hit.voiceId);
863 status(`hit at ${hit.t.toFixed(3)} s · "${voice?.name ?? '?'}" · strength ${hit.gain.toFixed(2)}`);
864 if (hit.s0 >= 0 && take.clip) playSlice(take.clip.samples, take.clip.sr, hit.s0, hit.s1);
865 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 866};
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 868async function auditionSynth(hit: TakeHit, preset: SynthPreset): Promise<void> {
869 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 870 void playBuffer(buf);
871}
873timeline.onReassign = (id, voiceId) => {
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 874 saveTakeHits(take.hits);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 875 const voice = voiceById(voiceId);
876 status(voice ? `hit moved to "${voice.name}"; Learn adds it to that voice's training` : '');
877 syncHitButtons();
878};
880timeline.onAdd = (t, voiceId) => {
881 take.hits.push({ id: uid(), t, voiceId, gain: 0.8, peakDb: 0, vec: null, s0: -1, s1: -1 });
882 take.hits.sort((a, b) => a.t - b.t);
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 883 saveTakeHits(take.hits);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 884 refreshTimeline();
885};
887$('hit-delete').addEventListener('click', deleteSelected);
888document.addEventListener('keydown', (e) => {
889 if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 890 if (!$('capture').hidden && edit) {
891 if (e.key === 'x' || e.key === 'X' || e.key === 'Delete' || e.key === 'Backspace') {
892 e.preventDefault();
893 toggleExcluded();
894 }
895 return;
896 }
897 if (e.key === 'Delete' || e.key === 'Backspace') deleteSelected();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 898});
899function deleteSelected(): void {
900 if (!timeline.selected) return;
901 take.hits = take.hits.filter((h) => h.id !== timeline.selected);
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 902 saveTakeHits(take.hits);
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 903 timeline.selected = null;
904 refreshTimeline();
905}
907$('hit-learn').addEventListener('click', () => {
908 const hit = take.hits.find((h) => h.id === timeline.selected);
909 if (!hit || !hit.vec) return;
910 const voice = voiceById(hit.voiceId);
911 if (!voice) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 912 voice.loose.push({ vec: hit.vec, peakDb: hit.peakDb });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 913 save();
914 renderVoiceList();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 915 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 916});
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 918// ---- the mix ----------------------------------------------------------------
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 920const iwTrackCache = new Map<string, { sig: string; data: Float32Array }>();
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 922/** The rendered track for one instrawave voice's events: from the caches
923 * when the signature (events, tuning, gains, bake key) matches, memory
924 * first, then IndexedDB, where a stored track plays without its bake even
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 925 * loaded; from the GPU otherwise. Returned at the mix rate, levelled to
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 926 * 0.8 peak. Playback volume is deliberately absent from the signature. */
927async function iwTrack(voice: Voice, evs: StimulusEvent[]): Promise<Float32Array> {
928 if (voice.binding.kind !== 'iw') throw new Error(`voice "${voice.name}" is not bound to an instrument`);
929 const name = voice.binding.instrument;
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 930 evs.sort((a, b) => a.t - b.t);
931 // The track's own length: its last event plus its longest decay, so an
932 // edit on another lane cannot change this group's signature.
933 let tail = 1.5;
934 const hasT60 = iw.tuning(name).some((p) => p.key === 't60');
935 for (const e of evs) {
936 const t60 = e.set?.t60 ?? (hasT60 ? iw.defaultValue(name, 't60') : 1.5);
937 tail = Math.max(tail, Math.min(6, t60));
938 }
939 const seconds = evs[evs.length - 1].t + tail;
940 const sig = JSON.stringify({
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 942 key: await iw.currentKey(name),
943 secs: seconds,
944 evs: evs.map((e) => [e.t, Object.entries(e.set ?? {}).sort()]),
945 });
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 946 const mem = iwTrackCache.get(voice.id);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 947 if (mem?.sig === sig) return mem.data;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 948 const stored = await getRender(voice.id).catch(() => null);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 949 if (stored?.sig === sig) {
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 950 iwTrackCache.set(voice.id, stored);
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 951 return stored.data;
952 }
953 if (!(await ensureBakeLoaded(name))) {
954 throw new Error(`precompute the ${name} bake first`);
955 }
956 const { trace, dt } = await iw.render(name, seconds, evs, (done, total) =>
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 957 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 958 // Physical pressure amplitudes carry no absolute scale worth keeping
959 // across engines; each instrument's track is levelled to sit alongside
960 // the synth part, while relative volumes WITHIN it stay physical.
961 const data = resampleLinear(trace, 1 / dt, MIX_SR);
962 const p = peak(data);
963 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 964 iwTrackCache.set(voice.id, { sig, data });
965 void putRender(voice.id, sig, data).catch(() => { /* it will re-render next session */ });
967}
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 969async function renderMix(): Promise<AudioBuffer> {
970 const sig = JSON.stringify({
971 hits: take.hits.map((h) => [h.t, h.voiceId, h.gain]),
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 972 bindings: voices.map((v) => [v.id, v.binding, v.volume]),
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 973 iw: iw.dumpOverrides(),
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 974 });
975 if (renderCache?.sig === sig) return renderCache.buf;
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 977 // One track per voice, mixed at that voice's playback volume. The
978 // volume scales a finished track, so moving a slider re-mixes cached
979 // audio and renders nothing.
980 const byVoice = new Map<string, TakeHit[]>();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 981 for (const h of take.hits) {
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 982 const list = byVoice.get(h.voiceId);
983 if (list) list.push(h);
984 else if (voiceById(h.voiceId)) byVoice.set(h.voiceId, [h]);
985 }
986 const tracks: { data: Float32Array; vol: number }[] = [];
987 for (const [voiceId, hits] of byVoice) {
988 const v = voiceById(voiceId)!;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 989 if (v.binding.kind === 'synth') {
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 990 const preset = v.binding.preset;
991 const events: NoteEvent[] = hits.map((h) => ({ t: h.t, voiceId, gain: h.gain }));
992 const secs = Math.max(...events.map((e) => e.t)) + 1.5;
993 const buf = await renderSynth(events, () => preset, secs);
994 tracks.push({ data: buf.getChannelData(0), vol: v.volume });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 995 } else {
996 const b = v.binding;
997 const allowed = new Set(iw.tuning(b.instrument).map((p) => p.key));
998 const strength = iw.strengthParam(b.instrument);
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 999 const events: StimulusEvent[] = hits.map((h) => {
1000 const set: Record<string, number> = {};
1001 for (const [k, val] of Object.entries(b.set)) if (allowed.has(k)) set[k] = val;
1002 set[strength] = (b.set[strength] ?? iw.defaultValue(b.instrument, strength)) * h.gain;
1003 return { t: h.t, set };
1004 });
1005 tracks.push({ data: await iwTrack(v, events), vol: v.volume });
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1006 }
1007 }
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 1008 const seconds = Math.max(1, ...tracks.map((tr) => tr.data.length / MIX_SR));
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1009 const out = new Float32Array(Math.ceil(seconds * MIX_SR));
2d56c5ePer-voice playback volume applied at mix timeJeremy Magland 1010 for (const tr of tracks) mixInto(out, tr.data, tr.vol);
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1011 const pAll = peak(out);
1012 if (pAll > 0.98) for (let i = 0; i < out.length; i++) out[i] *= 0.95 / pAll;
1013 const buf = audio().createBuffer(1, out.length, MIX_SR);
1014 buf.getChannelData(0).set(out);
1015 renderCache = { sig, buf };
1016 return buf;
1017}
838af43Per-instrument render cache in IndexedDB and .wav downloadJeremy Magland 1019$('take-save').addEventListener('click', async () => {
1020 if (take.hits.length === 0) return;
1021 const btn = $('take-save') as HTMLButtonElement;
1022 btn.disabled = true;
1023 try {
1024 const buf = await renderMix();
1025 const bytes = wavBytes(buf.getChannelData(0), MIX_SR);
1026 const url = URL.createObjectURL(new Blob([bytes as Uint8Array<ArrayBuffer>], { type: 'audio/wav' }));
1027 const a = document.createElement('a');
1028 a.href = url;
1029 a.download = 'instrawave-compose-take.wav';
1030 a.click();
1031 URL.revokeObjectURL(url);
1032 status(`saved ${(buf.duration).toFixed(1)} s of audio`);
1033 } catch (e) {
1034 status(e instanceof Error ? e.message : String(e));
1035 } finally {
1036 btn.disabled = take.hits.length === 0;
1037 }
1038});
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1040$('take-play').addEventListener('click', async () => {
1041 if (playing) { stopPlayback(); return; }
1042 if (take.hits.length === 0) return;
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1043 const btn = $('take-play') as HTMLButtonElement;
1044 btn.disabled = true;
1045 try {
1046 const buf = await renderMix();
1047 status('');
1048 void playBuffer(buf, (t) => timeline.playhead(t));
1049 } catch (e) {
1050 status(e instanceof Error ? e.message : String(e));
1051 } finally {
1052 btn.disabled = false;
1053 syncPlayButton();
1054 }
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1055});
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1057// ---- tuning audition ----------------------------------------------------------
1059async function auditionIw(instrument: string, set: Record<string, number>): Promise<void> {
1060 if (iwBusy || bakeJob) { status('the GPU is busy; try again in a moment'); return; }
1061 iwBusy = true;
1062 try {
1063 if (!(await ensureBakeLoaded(instrument))) {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1064 status(`no current ${instrument} bake; precompute it in the voice row`);
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1065 return;
1066 }
1067 const allowed = new Set(iw.tuning(instrument).map((p) => p.key));
1068 const ev: Record<string, number> = {};
1069 for (const [k, v] of Object.entries(set)) if (allowed.has(k)) ev[k] = v;
1070 const t60 = set.t60 ?? (allowed.has('t60') ? iw.defaultValue(instrument, 't60') : 2);
1071 const seconds = Math.min(5, Math.max(1.2, t60 * 0.6 + 0.5));
1072 const { trace, dt } = await iw.render(instrument, seconds, [{ t: 0, set: ev }], (done, total) =>
1073 status(`rendering ${instrument}: ${Math.round((100 * done) / total)}%`));
1074 const res = resampleLinear(trace, 1 / dt, MIX_SR);
1075 const pk = peak(res);
1076 if (pk > 0) for (let i = 0; i < res.length; i++) res[i] *= 0.8 / pk;
1077 const buf = audio().createBuffer(1, res.length, MIX_SR);
1078 buf.getChannelData(0).set(res);
1079 status('');
1080 void playBuffer(buf);
1081 } catch (e) {
1082 status(e instanceof Error ? e.message : String(e));
1083 } finally {
1084 iwBusy = false;
1085 }
1086}
1088// ---- instrument editor (geometry, accuracy, the 3D view) ----------------------
1089// Geometry belongs to the instrument, not the voice: it is what the shared
1090// bake depends on, so the editor opens from the bake bar and an edit marks
1091// the bake stale there. The scene is instrawave's own viewer, raymarching
1092// 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 1094let iwEdit: { name: string; anchor: string; viewer: Viewer | null } | null = null;
1095const iwEditEl = $('iwedit');
1096const iwEditTitle = $('iwedit-title');
1097const iwEditParams = $('iwedit-params');
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1098const iwCam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 };
1099const iwVolume = $('iw-volume') as HTMLCanvasElement;
1100const iwOverlay = $('iw-overlay') as HTMLCanvasElement;
1102function sizeSceneCanvas(c: HTMLCanvasElement): void {
1103 const r = c.getBoundingClientRect();
1104 const dpr = Math.min(devicePixelRatio || 1, 2);
1105 const w = Math.max(1, Math.round(r.width * dpr));
1106 const h = Math.max(1, Math.round(r.height * dpr));
1107 if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
1108}
1110let iwViewTimer: number | null = null;
1111function scheduleIwView(): void {
1112 if (iwViewTimer !== null) clearTimeout(iwViewTimer);
1113 iwViewTimer = window.setTimeout(() => {
1114 iwViewTimer = null;
1115 const e = iwEdit;
1116 if (!e?.viewer) return;
1117 void (async () => {
1118 try {
1119 await e.viewer!.update(iw.scope(e.name));
1120 e.viewer!.draw(iwCam);
1121 } catch (err) {
1122 status(`scene view: ${err instanceof Error ? err.message : err}`);
1123 }
1124 })();
1125 }, 180);
1126}
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1128async function openIwEditor(name: string, anchor: string): Promise<void> {
1129 iwEdit = { name, anchor, viewer: null };
1130 iwEditTitle.textContent = name;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1131 buildIwParams(name);
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1132 renderVoiceList(); // inserts the panel under its row and shows it
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1133 try {
1134 const device = await iw.ensureDevice();
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1135 if (iwEdit?.name !== name || iwEdit.anchor !== anchor) return;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1136 sizeSceneCanvas(iwVolume);
1137 sizeSceneCanvas(iwOverlay);
1138 iwEdit.viewer = new Viewer(device, iw.file(name), iwVolume, iwOverlay);
1139 scheduleIwView();
1140 } catch (e) {
1141 status(`scene view unavailable: ${e instanceof Error ? e.message : e} (the sliders still work)`);
1142 }
1143}
1145function buildIwParams(name: string): void {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1146 const box = iwEditParams;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1147 box.textContent = '';
1148 const values = iw.values(name);
1149 for (const p of iw.geometryParams(name)) {
1150 const div = document.createElement('div');
1151 div.className = 'param';
1152 const label = document.createElement('label');
1153 const nameSpan = document.createElement('span');
1154 nameSpan.textContent = `${p.label} (${p.unit})`;
1155 if (p.hint) nameSpan.title = p.hint;
1156 const valSpan = document.createElement('span');
1157 const input = document.createElement('input');
1158 input.type = 'range';
1159 input.min = String(p.min);
1160 input.max = String(p.max);
1161 input.step = String(p.step ?? (p.max - p.min) / 200);
1162 input.value = String(values[p.key]);
1163 const show = () => { valSpan.textContent = Number(input.value).toPrecision(4); };
1164 show();
1165 input.addEventListener('input', () => {
1166 iw.setOverride(name, p.key, Number(input.value));
1167 show();
1168 saveIw();
1169 scheduleIwView();
1170 updateBakeBar(); // the bake just went stale (or came back)
1171 });
1172 label.append(nameSpan, valSpan);
1173 div.append(label, input);
1174 box.appendChild(div);
1175 }
1176}
1178function closeIwEditor(): void {
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1179 const wasOpen = iwEdit !== null;
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1180 iwEdit = null;
30039cdMove instrument bake controls inline into the voice rowsJeremy Magland 1181 iwEditEl.hidden = true;
1182 if (wasOpen) renderVoiceList();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1183}
1184$('iwedit-done').addEventListener('click', closeIwEditor);
1186{
1187 let dragging = false, px = 0, py = 0;
1188 iwOverlay.addEventListener('pointerdown', (e) => {
1189 dragging = true; px = e.clientX; py = e.clientY;
1190 iwOverlay.setPointerCapture(e.pointerId);
1191 });
1192 iwOverlay.addEventListener('pointermove', (e) => {
1193 if (!dragging || !iwEdit?.viewer) return;
1194 iwCam.yaw -= (e.clientX - px) * 0.008;
1195 iwCam.pitch = Math.min(1.45, Math.max(-1.45, iwCam.pitch + (e.clientY - py) * 0.008));
1196 px = e.clientX; py = e.clientY;
1197 iwEdit.viewer.draw(iwCam);
1198 });
1199 iwOverlay.addEventListener('pointerup', () => { dragging = false; });
1200 iwOverlay.addEventListener('wheel', (e) => {
1201 if (!iwEdit?.viewer) return;
1202 e.preventDefault();
1203 iwCam.dist = Math.min(6, Math.max(0.3, iwCam.dist * Math.exp(e.deltaY * 0.001)));
1204 iwEdit.viewer.draw(iwCam);
1205 }, { passive: false });
1206 window.addEventListener('resize', () => {
1207 if (!iwEdit?.viewer) return;
1208 sizeSceneCanvas(iwVolume);
1209 sizeSceneCanvas(iwOverlay);
1210 scheduleIwView();
1211 });
1212}
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1214// ---- boot -----------------------------------------------------------------
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1216($('capture-sens') as HTMLInputElement).value = String(ui.captureSens);
1217($('take-sens') as HTMLInputElement).value = String(ui.takeSens);
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 1218syncClfBar();
9f20d5bTuning audition, instrument geometry editor with 3D view, full browser persistenceJeremy Magland 1219take.hits = loadTakeHits() ?? [];
1220void getTakeAudio().then((a) => {
1221 if (a) {
1222 take.clip = { samples: a.samples, sr: a.sr };
1223 refreshTimeline();
1224 syncTakeBar();
1225 }
1226}).catch(() => { /* the hits still play and edit; re-detect needs the audio */ });
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1227renderVoiceList();
1228refreshTimeline();
1229syncTakeBar();
bd6f329Editable persistent trainings, multi-select exclusion, playback pulses, instrawave bindingsJeremy Magland 1230updateBakeBar();
fef783aVoice-driven rhythm capture: voices trained by example, takes classified onto them, synth playbackJeremy Magland 1231if (voices.length === 0) {
1232 status('start by adding a voice, then record yourself making its sound a handful of times');
1233}