Move instrument bake controls inline into the voice rows
The separate bake bar is gone: bake state, Precompute with progress and
cancel, and the Instrument toggle now sit on each instrawave-bound voice
row, and the geometry editor expands inline under its row like the tuning
panel. Rows sharing an instrument show the same shared state.
3 changed files+101−73
README.mdmodified+9−7View file
@@ -59,13 +59,15 @@ Playback touches the GPU only when at least one bound instrument is
5959 instrawave; a purely synth arrangement never asks for a device.
6060
6161 Each instrument carries one set of geometry and accuracy values, edited
62-in the instrument panel that opens from the bake bar: instrawave's own
63-raymarched scene view alongside the geometry and accuracy sliders, so the
64-staircased sound hole or the microphone position is seen exactly as the
65-solver sees it, and any edit marks the bake stale in the bar. Geometry
66-belongs to the instrument rather than the voice because it is what the
67-shared bake depends on; each instrument therefore has exactly one bake,
68-computed on demand (with progress and a cancel). A Hear button in each
62+in the instrument panel that expands inline under a voice row bound to it:
63+instrawave's own raymarched scene view alongside the geometry and accuracy
64+sliders, so the staircased sound hole or the microphone position is seen
65+exactly as the solver sees it. The bake state, the Precompute button, and
66+its progress sit inline on the same rows. Geometry belongs to the
67+instrument rather than the voice because it is what the shared bake
68+depends on; each instrument therefore has exactly one bake, computed on
69+demand, and rows sharing an instrument show the same state. A Hear button
70+in each
6971 voice's tuning panel renders a single hit at the current tuning from that
7072 bake, so tuning is auditioned without recording anything. All per-voice
7173 character travels as tuning, which is free: every voice bound to the same
index.htmlmodified+1−3View file
@@ -40,7 +40,7 @@
4040 .tuning-panel label { display: flex; justify-content: space-between; }
4141 .tuning-panel label span:last-child { opacity: 0.7; font-variant-numeric: tabular-nums; }
4242 .tuning-panel input[type='range'] { width: 100%; }
43- #bakebar progress { width: 8rem; }
43+ .voice-row progress { width: 6rem; }
4444 #iwedit { border: 1px solid rgba(128,128,128,0.35); border-radius: 8px; padding: 0.6rem 0.8rem; margin: 0.5rem 0; }
4545 .iwedit-grid { display: grid; grid-template-columns: minmax(0, 1fr) 24rem; gap: 0 1.5rem; align-items: start; }
4646 @media (max-width: 56rem) { .iwedit-grid { grid-template-columns: 1fr; } }
@@ -107,8 +107,6 @@
107107 <button id="take-play" disabled>Play</button>
108108 <button id="take-save" disabled>Save .wav</button>
109109 </div>
110- <div class="bar" id="bakebar" hidden></div>
111-
112110 <div id="iwedit" hidden>
113111 <div class="bar">
114112 <strong id="iwedit-title"></strong>
src/main.tsmodified+91−63View file
@@ -55,7 +55,7 @@ const saveIw = () => saveIwOverrides(iw.dumpOverrides());
5555 const ui = loadUi();
5656 let bakeJob: { name: string; cancel: boolean } | null = null;
5757 let iwBusy = false;
58-let bakeProg: HTMLProgressElement | null = null;
58+let bakeProgs: HTMLProgressElement[] = [];
5959
6060 function save(): void { saveVoices(voices); }
6161 function voiceById(id: string): Voice | undefined { return voices.find((v) => v.id === id); }
@@ -324,6 +324,7 @@ function bindingValue(v: Voice): string {
324324 function renderVoiceList(): void {
325325 const list = $('voice-list');
326326 list.textContent = '';
327+ bakeProgs = [];
327328 const busy = recorder !== null;
328329 for (const voice of voices) {
329330 const row = document.createElement('div');
@@ -436,14 +437,66 @@ function renderVoiceList(): void {
436437 updateBakeBar();
437438 });
438439
440+ // The instrument's bake state and its editor live inline on the rows
441+ // of the voices bound to it (the state is per instrument, so rows
442+ // sharing one show the same thing).
443+ const iwControls: HTMLElement[] = [];
444+ if (voice.binding.kind === 'iw') {
445+ const instName = voice.binding.instrument;
446+ const st = bakeStates[instName] ?? 'none';
447+ if (st === 'ready' || st === 'stored') {
448+ const ok = document.createElement('span');
449+ ok.className = 'count';
450+ ok.style.color = '#15803d';
451+ ok.style.minWidth = 'auto';
452+ ok.textContent = `bake ${st}`;
453+ if (st === 'stored') ok.title = 'saved in the browser; loads on first use';
454+ iwControls.push(ok);
455+ } else if (st === 'baking') {
456+ const prog = document.createElement('progress');
457+ prog.max = 1;
458+ bakeProgs.push(prog);
459+ const cancel = document.createElement('button');
460+ cancel.textContent = 'Cancel';
461+ cancel.addEventListener('click', () => { if (bakeJob) bakeJob.cancel = true; });
462+ iwControls.push(prog, cancel);
463+ } else {
464+ const btn = document.createElement('button');
465+ btn.textContent = 'Precompute';
466+ btn.title = 'bake this instrument at its current geometry';
467+ btn.disabled = bakeJob !== null;
468+ btn.addEventListener('click', () => void startBake(instName));
469+ iwControls.push(btn);
470+ }
471+ const instBtn = document.createElement('button');
472+ instBtn.textContent = iwEdit?.anchor === voice.id ? 'Instrument ▾' : 'Instrument ▸';
473+ instBtn.title = 'geometry, accuracy, and the 3D view; shared by every voice bound to this instrument';
474+ instBtn.addEventListener('click', () => {
475+ if (iwEdit?.anchor === voice.id) closeIwEditor();
476+ else void openIwEditor(instName, voice.id);
477+ });
478+ iwControls.push(instBtn);
479+ }
480+
439481 const spacer = document.createElement('span');
440482 spacer.className = 'spacer';
441- row.append(swatch, name, count, rec, editBtn, clear, spacer, binding, tune, del);
483+ row.append(swatch, name, count, rec, editBtn, clear, spacer, binding, tune, ...iwControls, del);
442484 list.appendChild(row);
443485
444486 if (voice.binding.kind === 'iw' && expandedTuning.has(voice.id)) {
445487 list.appendChild(tuningPanel(voice, voice.binding.instrument, voice.binding.set));
446488 }
489+ if (iwEdit?.anchor === voice.id) {
490+ if (voice.binding.kind === 'iw' && voice.binding.instrument === iwEdit.name) {
491+ iwEditEl.hidden = false;
492+ list.appendChild(iwEditEl);
493+ } else {
494+ // The binding moved out from under the open editor; hide it without
495+ // re-rendering (we are mid-render already).
496+ iwEdit = null;
497+ iwEditEl.hidden = true;
498+ }
499+ }
447500 }
448501 }
449502
@@ -530,60 +583,30 @@ function neededInstruments(): string[] {
530583 .map((v) => (v.binding as { instrument: string }).instrument))];
531584 }
532585
586+type BakeState = 'ready' | 'stored' | 'baking' | 'none';
587+let bakeStates: Record<string, BakeState> = {};
588+
589+/** Refresh the per-instrument bake states the voice rows render from;
590+ * rebuild the rows only when something actually changed. */
533591 function updateBakeBar(): void {
534- void refreshBakeBar();
592+ void refreshBakeStates();
535593 }
536594
537-let bakeBarEpoch = 0;
538-async function refreshBakeBar(): Promise<void> {
539- const epoch = ++bakeBarEpoch;
540- const bar = $('bakebar');
541- const needed = neededInstruments();
542- bar.hidden = needed.length === 0;
543- if (needed.length === 0) { bar.textContent = ''; return; }
544- const states = await Promise.all(needed.map(async (name) => {
545- if (await iw.bakeReady(name)) return 'ready' as const;
546- if (bakeJob?.name === name) return 'baking' as const;
547- const stored = await getBakeKey(name).catch(() => null);
548- return stored === (await iw.currentKey(name)) ? ('stored' as const) : ('none' as const);
549- }));
550- if (epoch !== bakeBarEpoch) return; // a newer refresh superseded this one
551- bar.textContent = '';
552- bakeProg = null;
553- needed.forEach((name, i) => {
554- const label = document.createElement('span');
555- label.className = 'hint';
556- label.textContent = `${name} bake:`;
557- bar.appendChild(label);
558- const st = states[i];
559- if (st === 'ready' || st === 'stored') {
560- const ok = document.createElement('span');
561- ok.textContent = st;
562- ok.style.color = '#15803d';
563- if (st === 'stored') ok.title = 'saved in the browser; loads on first use';
564- bar.appendChild(ok);
565- } else if (st === 'baking') {
566- const prog = document.createElement('progress');
567- prog.max = 1;
568- prog.value = 0;
569- bakeProg = prog;
570- const cancel = document.createElement('button');
571- cancel.textContent = 'Cancel';
572- cancel.addEventListener('click', () => { if (bakeJob) bakeJob.cancel = true; });
573- bar.append(prog, cancel);
574- } else {
575- const btn = document.createElement('button');
576- btn.textContent = 'Precompute';
577- btn.disabled = bakeJob !== null;
578- btn.addEventListener('click', () => void startBake(name));
579- bar.appendChild(btn);
580- }
581- const inst = document.createElement('button');
582- inst.textContent = 'Instrument\u2026';
583- inst.title = 'geometry, accuracy, and the 3D view';
584- inst.addEventListener('click', () => void openIwEditor(name));
585- bar.appendChild(inst);
586- });
595+let bakeEpoch = 0;
596+async function refreshBakeStates(): Promise<void> {
597+ const epoch = ++bakeEpoch;
598+ const next: Record<string, BakeState> = {};
599+ for (const name of neededInstruments()) {
600+ if (bakeJob?.name === name) next[name] = 'baking';
601+ else if (await iw.bakeReady(name)) next[name] = 'ready';
602+ else if ((await getBakeKey(name).catch(() => null)) === (await iw.currentKey(name))) next[name] = 'stored';
603+ else next[name] = 'none';
604+ }
605+ if (epoch !== bakeEpoch) return; // a newer refresh superseded this one
606+ if (JSON.stringify(next) !== JSON.stringify(bakeStates)) {
607+ bakeStates = next;
608+ renderVoiceList();
609+ }
587610 }
588611
589612 /** Bring a bake into memory: already there, or stored in the browser from
@@ -610,7 +633,7 @@ async function startBake(name: string): Promise<void> {
610633 try {
611634 await iw.precompute(
612635 name,
613- (done, total) => { if (bakeProg) bakeProg.value = done / total; },
636+ (done, total) => { for (const prog of bakeProgs) prog.value = done / total; },
614637 () => bakeJob?.cancel ?? true,
615638 );
616639 status(`${name} bake ready; storing it in the browser\u2026`);
@@ -919,7 +942,7 @@ async function auditionIw(instrument: string, set: Record<string, number>): Prom
919942 iwBusy = true;
920943 try {
921944 if (!(await ensureBakeLoaded(instrument))) {
922- status(`no current ${instrument} bake; precompute it in the bake bar below`);
945+ status(`no current ${instrument} bake; precompute it in the voice row`);
923946 return;
924947 }
925948 const allowed = new Set(iw.tuning(instrument).map((p) => p.key));
@@ -949,7 +972,10 @@ async function auditionIw(instrument: string, set: Record<string, number>): Prom
949972 // the bake stale there. The scene is instrawave's own viewer, raymarching
950973 // the solver's actual fields, so what it shows is what the bake will see.
951974
952-let iwEdit: { name: string; viewer: Viewer | null } | null = null;
975+let iwEdit: { name: string; anchor: string; viewer: Viewer | null } | null = null;
976+const iwEditEl = $('iwedit');
977+const iwEditTitle = $('iwedit-title');
978+const iwEditParams = $('iwedit-params');
953979 const iwCam: Camera = { yaw: -2.2, pitch: 0.45, dist: 1.6 };
954980 const iwVolume = $('iw-volume') as HTMLCanvasElement;
955981 const iwOverlay = $('iw-overlay') as HTMLCanvasElement;
@@ -980,14 +1006,14 @@ function scheduleIwView(): void {
9801006 }, 180);
9811007 }
9821008
983-async function openIwEditor(name: string): Promise<void> {
984- iwEdit = { name, viewer: null };
985- $('iwedit').hidden = false;
986- $('iwedit-title').textContent = name;
1009+async function openIwEditor(name: string, anchor: string): Promise<void> {
1010+ iwEdit = { name, anchor, viewer: null };
1011+ iwEditTitle.textContent = name;
9871012 buildIwParams(name);
1013+ renderVoiceList(); // inserts the panel under its row and shows it
9881014 try {
9891015 const device = await iw.ensureDevice();
990- if (iwEdit?.name !== name) return;
1016+ if (iwEdit?.name !== name || iwEdit.anchor !== anchor) return;
9911017 sizeSceneCanvas(iwVolume);
9921018 sizeSceneCanvas(iwOverlay);
9931019 iwEdit.viewer = new Viewer(device, iw.file(name), iwVolume, iwOverlay);
@@ -998,7 +1024,7 @@ async function openIwEditor(name: string): Promise<void> {
9981024 }
9991025
10001026 function buildIwParams(name: string): void {
1001- const box = $('iwedit-params');
1027+ const box = iwEditParams;
10021028 box.textContent = '';
10031029 const values = iw.values(name);
10041030 for (const p of iw.geometryParams(name)) {
@@ -1031,8 +1057,10 @@ function buildIwParams(name: string): void {
10311057 }
10321058
10331059 function closeIwEditor(): void {
1060+ const wasOpen = iwEdit !== null;
10341061 iwEdit = null;
1035- $('iwedit').hidden = true;
1062+ iwEditEl.hidden = true;
1063+ if (wasOpen) renderVoiceList();
10361064 }
10371065 $('iwedit-done').addEventListener('click', closeIwEditor);
10381066