/ proof-of-concept / instrawave-compose
Sign in
proof-of-concept / instrawave-compose
instrawave-compose / src / main.ts
1233 lines · 46.2 KBBlameHistoryRaw
1/**
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.
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';
17import { mixInto, peak, resampleLinear } from './render/mix.ts';
18import { wavBytes } from './render/wav.ts';
19import { IwEngine, parseBake, PhaseCancelled, type StimulusEvent } from './render/instrawave.ts';
20import { Viewer, type Camera } from 'instrawave/src/host/view.ts';
21import {
22 clearBulk, clearStore, deleteClipAudio, getBakeBytes, getBakeKey, getClipAudio, getTakeAudio,
23 deleteRender, getRender, loadClassifier, loadIwOverrides, loadTakeHits, loadUi, loadVoices,
24 putBake, putClipAudio,
25 putRender, putTakeAudio,
26 saveClassifier, saveIwOverrides, saveTakeHits, saveUi, saveVoices, trainPool,
27 type TakeHit, type TrainClip, type Voice,
28} from './store.ts';
29import { BUILTIN_CLASSIFIERS, CUSTOM_CLASSIFIER_TEMPLATE, type BuiltinMethod } 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';
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'];
39const MIX_SR = 44100;
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: [] };
48let edit: { voiceId: string; clipId: string; samples: Float32Array; sr: number } | null = null;
49let recTarget: { kind: 'voice'; voiceId: string } | { kind: 'take' } | null = null;
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;
56let renderCache: { sig: string; buf: AudioBuffer } | null = null;
58const iw = new IwEngine();
59iw.setOverrides(loadIwOverrides());
60const saveIw = () => saveIwOverrides(iw.dumpOverrides());
61const ui = loadUi();
62let bakeJob: { name: string; cancel: boolean } | null = null;
63let iwBusy = false;
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 -------------------------------------------------------------
72function playSlice(samples: Float32Array, sr: number, s0: number, s1: number): void {
73 const ctx = audio();
74 const n = Math.min(s1, s0 + Math.floor(0.5 * sr)) - s0;
75 if (n <= 0) return;
76 const buf = ctx.createBuffer(1, n, sr);
77 buf.getChannelData(0).set(samples.subarray(s0, s0 + n));
78 const src = ctx.createBufferSource();
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') {
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();
176 } else {
177 take.clip = clip;
178 void putTakeAudio(clip.samples, clip.sr).catch(() =>
179 status('warning: take audio could not be stored; it will not survive a reload'));
180 processTake();
181 }
184// ---- training clip editor ----------------------------------------------------
186const waveView = new WaveView($('capture-wave') as HTMLCanvasElement);
188function editingClip(): TrainClip | null {
189 if (!edit) return null;
190 return voiceById(edit.voiceId)?.clips.find((c) => c.id === edit!.clipId) ?? null;
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 }
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;
246 const btn = $('capture-exclude') as HTMLButtonElement;
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 : '';
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);
281};
282waveView.onSelectionChange = syncEditorButtons;
284$('capture-exclude').addEventListener('click', toggleExcluded);
286$('capture-sens').addEventListener('input', () => {
287 ui.captureSens = Number(($('capture-sens') as HTMLInputElement).value);
288 saveUi(ui);
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();
298});
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);
312 save();
313 renderVoiceList();
314 if (voice.clips.length) openEditor(voice.id, voice.clips[voice.clips.length - 1].id);
315 else closeEditor();
316});
318function closeEditor(): void {
319 edit = null;
320 $('capture').hidden = true;
322$('capture-done').addEventListener('click', closeEditor);
324// ---- voices ---------------------------------------------------------------
326function bindingValue(v: Voice): string {
327 return v.binding.kind === 'synth' ? `synth:${v.binding.preset}` : `iw:${v.binding.instrument}`;
330function renderVoiceList(): void {
331 const list = $('voice-list');
332 list.textContent = '';
333 bakeProgs = [];
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();
351 if (edit?.voiceId === voice.id) refreshEditor();
352 });
354 const n = trainPool(voice).length;
355 const count = document.createElement('span');
356 count.className = 'count';
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 });
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 });
416 const clear = document.createElement('button');
417 clear.textContent = 'Clear';
418 clear.title = 'forget this voice\'s training';
419 clear.disabled = n === 0;
420 clear.addEventListener('click', () => {
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 = [];
425 save();
426 if (edit?.voiceId === voice.id) closeEditor();
427 renderVoiceList();
428 });
430 const del = document.createElement('button');
431 del.textContent = '✕';
432 del.title = 'delete voice';
433 del.addEventListener('click', () => {
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);
436 voices = voices.filter((v) => v.id !== voice.id);
437 void deleteRender(voice.id);
438 take.hits = take.hits.filter((h) => h.voiceId !== voice.id);
439 saveTakeHits(take.hits);
440 save();
441 if (edit?.voiceId === voice.id) closeEditor();
442 renderVoiceList();
443 refreshTimeline();
444 updateBakeBar();
445 });
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 }
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 });
505 const spacer = document.createElement('span');
506 spacer.className = 'spacer';
507 row.append(swatch, name, count, rec, editBtn, clear, spacer, binding, vol, tune, ...iwControls, del);
508 list.appendChild(row);
510 if (voice.binding.kind === 'iw' && expandedTuning.has(voice.id)) {
511 list.appendChild(tuningPanel(voice, voice.binding.instrument, voice.binding.set));
512 }
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 }
524 }
527function tuningPanel(voice: Voice, instrument: string, set: Record<string, number>): HTMLElement {
528 const panel = document.createElement('div');
529 panel.className = 'tuning-panel';
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);
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;
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],
576 binding: { kind: 'synth', preset: PRESETS[voices.length % PRESETS.length] },
577 volume: 1,
578 clips: [],
579 loose: [],
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;
589 clearStore();
590 void clearBulk();
591 iw.setOverrides({});
592 saveIw();
593 voices = [];
594 closeIwEditor();
595 take = { clip: null, hits: [] };
596 closeEditor();
597 renderVoiceList();
598 refreshTimeline();
599 syncTakeBar();
600 updateBakeBar();
601 status('');
602});
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))];
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. */
616function updateBakeBar(): void {
617 void refreshBakeStates();
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,
661 (done, total) => { for (const prog of bakeProgs) prog.value = done / total; },
662 () => bakeJob?.cancel ?? true,
663 );
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 }
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 }
681// ---- take -----------------------------------------------------------------
683const timeline = new Timeline($('timeline') as HTMLCanvasElement);
685const clf = loadClassifier();
686const activeClfSource = (): string =>
687 clf.method === 'custom' ? (clf.customSrc ?? CUSTOM_CLASSIFIER_TEMPLATE)
688 : clf.method === 'pursuit' ? (clf.pursuitSrc ?? BUILTIN_CLASSIFIERS.pursuit.source)
689 : (clf.builtinSrc ?? BUILTIN_CLASSIFIERS.builtin.source);
691async function processTake(): Promise<void> {
692 if (!take.clip) return;
693 stopPlayback();
694 const { samples, sr } = take.clip;
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();
724 const algoName = clf.method === 'custom'
725 ? 'the custom algorithm' : BUILTIN_CLASSIFIERS[clf.method as BuiltinMethod].name;
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 }
736function processTakeLegacy(samples: Float32Array, sr: number): void {
737 const sens = Number(($('take-sens') as HTMLInputElement).value);
738 const onsets = detectOnsets(samples, sr, sens);
739 const trained = voices.filter((v) => trainPool(v).length > 0);
740 const trainSet: TrainingSet = {
741 voiceIds: trained.flatMap((v) => trainPool(v).map(() => v.id)),
742 vecs: trained.flatMap((v) => trainPool(v).map((h) => h.vec)),
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;
759 saveTakeHits(take.hits);
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'}`);
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();
776function syncTakeBar(): void {
777 const recordingTake = recTarget?.kind === 'take';
778 const btn = $('take-record') as HTMLButtonElement;
779 btn.innerHTML = recordingTake ? '&#9632; Stop' : '&#9679; 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;
784 ($('take-save') as HTMLButtonElement).disabled = take.hits.length === 0;
787function syncPlayButton(): void {
788 ($('take-play') as HTMLButtonElement).textContent = playing ? 'Stop' : 'Play';
789 ($('take-play') as HTMLButtonElement).disabled = take.hits.length === 0 && !playing;
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;
798$('take-record').addEventListener('click', () => {
799 if (recTarget?.kind === 'take') void stopRecording();
800 else void startRecording({ kind: 'take' });
801});
803$('take-redetect').addEventListener('click', () => void processTake());
804$('take-sens').addEventListener('change', () => {
805 ui.takeSens = Number(($('take-sens') as HTMLInputElement).value);
806 saveUi(ui);
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';
820function syncClfEditor(): void {
821 if ($('clf-editor').hidden) return;
822 $('clf-title').textContent = clf.method === 'custom'
823 ? 'custom classifier' : BUILTIN_CLASSIFIERS[clf.method as BuiltinMethod].name;
824 ($('clf-source') as HTMLTextAreaElement).value = activeClfSource();
825 ($('clf-reset') as HTMLButtonElement).hidden = clf.method === 'custom' || clf.method === 'legacy'
826 || (clf.method === 'builtin' ? clf.builtinSrc : clf.pursuitSrc) === null;
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;
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;
848 saveClassifier(clf);
849 syncClfEditor();
850});
851$('clf-reset').addEventListener('click', () => {
852 if (clf.method === 'pursuit') clf.pursuitSrc = null;
853 else clf.builtinSrc = null;
854 saveClassifier(clf);
855 syncClfEditor();
856});
858timeline.onSelect = (id) => {
859 syncHitButtons();
860 const hit = take.hits.find((h) => h.id === id);
861 if (!hit) return;
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);
866};
868async function auditionSynth(hit: TakeHit, preset: SynthPreset): Promise<void> {
869 const buf = await renderSynth([{ t: 0, voiceId: hit.voiceId, gain: hit.gain }], () => preset, 1);
870 void playBuffer(buf);
873timeline.onReassign = (id, voiceId) => {
874 saveTakeHits(take.hits);
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);
883 saveTakeHits(take.hits);
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;
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();
898});
899function deleteSelected(): void {
900 if (!timeline.selected) return;
901 take.hits = take.hits.filter((h) => h.id !== timeline.selected);
902 saveTakeHits(take.hits);
903 timeline.selected = null;
904 refreshTimeline();
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;
912 voice.loose.push({ vec: hit.vec, peakDb: hit.peakDb });
913 save();
914 renderVoiceList();
915 status(`learned this hit as "${voice.name}" (${trainPool(voice).length} trained)`);
916});
918// ---- the mix ----------------------------------------------------------------
920const iwTrackCache = new Map<string, { sig: string; data: Float32Array }>();
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
925 * loaded; from the GPU otherwise. Returned at the mix rate, levelled to
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;
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({
941 inst: name,
942 key: await iw.currentKey(name),
943 secs: seconds,
944 evs: evs.map((e) => [e.t, Object.entries(e.set ?? {}).sort()]),
945 });
946 const mem = iwTrackCache.get(voice.id);
947 if (mem?.sig === sig) return mem.data;
948 const stored = await getRender(voice.id).catch(() => null);
949 if (stored?.sig === sig) {
950 iwTrackCache.set(voice.id, stored);
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) =>
957 status(`rendering "${voice.name}" on ${name} (${evs.length} event${evs.length === 1 ? '' : 's'}): ${Math.round((100 * done) / total)}%`));
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;
964 iwTrackCache.set(voice.id, { sig, data });
965 void putRender(voice.id, sig, data).catch(() => { /* it will re-render next session */ });
966 return data;
969async function renderMix(): Promise<AudioBuffer> {
970 const sig = JSON.stringify({
971 hits: take.hits.map((h) => [h.t, h.voiceId, h.gain]),
972 bindings: voices.map((v) => [v.id, v.binding, v.volume]),
973 iw: iw.dumpOverrides(),
974 });
975 if (renderCache?.sig === sig) return renderCache.buf;
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[]>();
981 for (const h of take.hits) {
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)!;
989 if (v.binding.kind === 'synth') {
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 });
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);
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 });
1008 const seconds = Math.max(1, ...tracks.map((tr) => tr.data.length / MIX_SR));
1009 const out = new Float32Array(Math.ceil(seconds * MIX_SR));
1010 for (const tr of tracks) mixInto(out, tr.data, tr.vol);
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;
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;
1038});
1040$('take-play').addEventListener('click', async () => {
1041 if (playing) { stopPlayback(); return; }
1042 if (take.hits.length === 0) return;
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();
1055});
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))) {
1064 status(`no current ${instrument} bake; precompute it in the voice row`);
1065 return;
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;
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.
1094let iwEdit: { name: string; anchor: string; viewer: Viewer | null } | null = null;
1095const iwEditEl = $('iwedit');
1096const iwEditTitle = $('iwedit-title');
1097const iwEditParams = $('iwedit-params');
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; }
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}`);
1124 })();
1125 }, 180);
1128async function openIwEditor(name: string, anchor: string): Promise<void> {
1129 iwEdit = { name, anchor, viewer: null };
1130 iwEditTitle.textContent = name;
1131 buildIwParams(name);
1132 renderVoiceList(); // inserts the panel under its row and shows it
1133 try {
1134 const device = await iw.ensureDevice();
1135 if (iwEdit?.name !== name || iwEdit.anchor !== anchor) return;
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)`);
1145function buildIwParams(name: string): void {
1146 const box = iwEditParams;
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);
1178function closeIwEditor(): void {
1179 const wasOpen = iwEdit !== null;
1180 iwEdit = null;
1181 iwEditEl.hidden = true;
1182 if (wasOpen) renderVoiceList();
1184$('iwedit-done').addEventListener('click', closeIwEditor);
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 });
1214// ---- boot -----------------------------------------------------------------
1216($('capture-sens') as HTMLInputElement).value = String(ui.captureSens);
1217($('take-sens') as HTMLInputElement).value = String(ui.takeSens);
1218syncClfBar();
1219take.hits = loadTakeHits() ?? [];
1220void getTakeAudio().then((a) => {
1221 if (a) {
1222 take.clip = { samples: a.samples, sr: a.sr };
1223 refreshTimeline();
1224 syncTakeBar();
1226}).catch(() => { /* the hits still play and edit; re-detect needs the audio */ });
1227renderVoiceList();
1228refreshTimeline();
1229syncTakeBar();
1230updateBakeBar();
1231if (voices.length === 0) {
1232 status('start by adding a voice, then record yourself making its sound a handful of times');
↑↓ move↵openescclose