From 685c7ff3b2b5971300fc83fe80893cdade6581c7 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Wed, 23 Sep 2026 15:48:06 -0400 Subject: [PATCH 1/5] feat(sorting): summary reports each lane's size and last touched op/value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane::last_touch() returns the kind of the most recent op (compare, or write for writes and swaps) and the value it landed on — the larger of a compared/swapped pair, or the value written. The rule summary carries it as last_kind/last_value (null before the first tick) plus the array size, so the lab can voice each lane without the op traces. --- crates/viz-core/src/rules/sorting/mod.rs | 63 +++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/viz-core/src/rules/sorting/mod.rs b/crates/viz-core/src/rules/sorting/mod.rs index 6bed5a3..c92a658 100644 --- a/crates/viz-core/src/rules/sorting/mod.rs +++ b/crates/viz-core/src/rules/sorting/mod.rs @@ -142,6 +142,19 @@ impl Lane { .and_then(|i| self.ops.get(i).copied()) } + /// What the last op touched, for the lab's sonification: the kind of op + /// (`"compare"` or `"write"` — a swap is a write) and the value it + /// landed on — the larger of a compared/swapped pair, or the value + /// written. `None` before the first tick. + pub fn last_touch(&self) -> Option<(&'static str, u16)> { + let at = |i: u16| self.values.get(usize::from(i)).copied().unwrap_or(0); + self.last_op().map(|op| match op { + Op::Compare(i, j) => ("compare", at(i).max(at(j))), + Op::Swap(i, j) => ("write", at(i).max(at(j))), + Op::Write(_, v) => ("write", v), + }) + } + /// Rewind to the initial array with the counters zeroed. Leaves /// `running` alone — the callers decide that. pub fn reset(&mut self) { @@ -277,13 +290,19 @@ impl Rule for SortingRace { } /// `{ rows, cols, tick, all_done, lanes: [{algorithm, dataset, compares, - /// writes, cursor, total, running, done}, …] }`. The lab reads this every - /// frame, so the op traces deliberately stay out of it. + /// writes, cursor, total, running, done, size, last_kind, last_value}, …] }`. + /// The lab reads this every frame, so the op traces deliberately stay out + /// of it; `last_kind`/`last_value` (null before the first tick) describe + /// only the most recent op, enough for the lab to voice each lane. fn summary(&self, state: &Self::State) -> serde_json::Value { let lanes: Vec = state .lanes .iter() .map(|l| { + let (last_kind, last_value) = match l.last_touch() { + Some((kind, value)) => (Some(kind), Some(value)), + None => (None, None), + }; serde_json::json!({ "algorithm": l.algorithm, "dataset": l.dataset, @@ -293,6 +312,9 @@ impl Rule for SortingRace { "total": l.ops.len(), "running": l.running, "done": l.done(), + "size": l.values.len(), + "last_kind": last_kind, + "last_value": last_value, }) }) .collect(); @@ -804,6 +826,9 @@ mod tests { assert_eq!(lanes[0]["writes"], 0); assert_eq!(lanes[0]["running"], json!(false)); assert_eq!(lanes[0]["done"], json!(false)); + assert_eq!(lanes[0]["size"], st.lanes[0].values.len()); + assert_eq!(lanes[0]["last_kind"], json!(null), "no op yet"); + assert_eq!(lanes[0]["last_value"], json!(null), "no op yet"); assert!(lanes[0].get("ops").is_none(), "traces stay out of summary"); run_all(&rule, &mut st, &c); @@ -813,6 +838,40 @@ mod tests { assert!(s["lanes"][0]["compares"].as_u64().unwrap() > 0); assert_eq!(s["lanes"][0]["running"], json!(false)); assert_eq!(s["lanes"][0]["done"], json!(true)); + assert!( + ["compare", "write"].contains(&s["lanes"][0]["last_kind"].as_str().unwrap()), + "a finished lane still reports its final op" + ); + assert!(s["lanes"][0]["last_value"].is_u64()); + } + + #[test] + fn last_touch_reports_the_value_the_last_op_landed_on() { + let rule = SortingRace; + let c = cfg(); + let mut st = rule.init(&c, 6); + assert!(st.lanes[0].last_touch().is_none(), "nothing applied yet"); + + // Walk lane 0 one op at a time and check every step against its trace. + st.lanes[0].running = true; + let total = st.lanes[0].ops.len(); + for k in 1..=total { + rule.advance_to(&mut st, &c, 6, k as u32); + let lane = &st.lanes[0]; + let op = lane.ops[k - 1]; + let expected = match op { + Op::Compare(i, j) => ( + "compare", + lane.values[i as usize].max(lane.values[j as usize]), + ), + Op::Swap(i, j) => ( + "write", + lane.values[i as usize].max(lane.values[j as usize]), + ), + Op::Write(_, v) => ("write", v), + }; + assert_eq!(lane.last_touch(), Some(expected), "op {k}: {op:?}"); + } } #[test] From d1fb9343b13718bc4719a2674ca1498c0e196775 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Wed, 23 Sep 2026 15:54:22 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(web):=20sorting=20lab=20sound=20?= =?UTF-8?q?=E2=80=94=20per-lane=20tones=20and=20a=20finish=20chime,=20with?= =?UTF-8?q?=20mute=20and=20volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/sorting/audio.ts: planAudio() diffs consecutive summaries into per-lane tone/chime/rest events (pure, tested); SortingAudio voices them on Web Audio with one persistent oscillator per lane (sine for compares, triangle for writes, pitch log-mapped over three octaves from the value touched), a throwaway two-note chime when a lane finishes, and a master gain into a compressor so 28 voices stay listenable. Starts muted; the speaker button's click is the gesture that brings the context up. The controls bar gains a speaker toggle and a volume slider (disabled while muted). readSummary() now requires the engine's size/last_kind/ last_value per lane. The shell's bar wraps instead of widening the page. --- web/src/lib/components/LabShell.svelte | 5 +- .../components/__tests__/SortingLab.test.ts | 26 ++ web/src/lib/components/labs/SortingLab.svelte | 47 +++- web/src/lib/sorting/__tests__/audio.test.ts | 222 +++++++++++++++++ web/src/lib/sorting/__tests__/lanes.test.ts | 3 + web/src/lib/sorting/__tests__/summary.test.ts | 21 ++ web/src/lib/sorting/audio.ts | 230 ++++++++++++++++++ web/src/lib/sorting/summary.ts | 23 +- web/src/lib/test/fakeViz.ts | 3 + 9 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/sorting/__tests__/audio.test.ts create mode 100644 web/src/lib/sorting/audio.ts diff --git a/web/src/lib/components/LabShell.svelte b/web/src/lib/components/LabShell.svelte index 597f7c5..ebf854f 100644 --- a/web/src/lib/components/LabShell.svelte +++ b/web/src/lib/components/LabShell.svelte @@ -469,8 +469,9 @@ border-bottom: 1px solid var(--border); padding: 0.5rem 1rem; display: flex; + flex-wrap: wrap; /* a lab's extra controls wrap rather than widen the page */ align-items: center; - gap: 0.75rem; + gap: 0.5rem 0.75rem; font-size: 0.9rem; } .playback-bar button { @@ -564,9 +565,7 @@ /* Let the playback bar wrap to multiple rows; align center so it balances vertically when items wrap. */ .playback-bar { - flex-wrap: wrap; justify-content: center; - row-gap: 0.5rem; } .speed { margin-left: 0; /* no more push-to-right with wrapping */ diff --git a/web/src/lib/components/__tests__/SortingLab.test.ts b/web/src/lib/components/__tests__/SortingLab.test.ts index 97a9bf1..32e5ff1 100644 --- a/web/src/lib/components/__tests__/SortingLab.test.ts +++ b/web/src/lib/components/__tests__/SortingLab.test.ts @@ -189,6 +189,32 @@ describe('SortingLab.svelte', () => { expect(input.value).toBe('300'); }); + it('starts with sound off, the volume slider disabled, and no AudioContext needed', async () => { + const { getByLabelText } = await renderLab(); + const mute = getByLabelText('Sound off') as HTMLButtonElement; + expect(mute.getAttribute('aria-pressed')).toBe('false'); + expect((getByLabelText('Volume') as HTMLInputElement).disabled).toBe(true); + }); + + it('the speaker button toggles sound on and off and enables the volume slider', async () => { + const { getByLabelText, queryByLabelText } = await renderLab(); + await fireEvent.click(getByLabelText('Sound off')); + const on = getByLabelText('Sound on') as HTMLButtonElement; + expect(on.getAttribute('aria-pressed')).toBe('true'); + expect(on.textContent).toBe('🔊'); + const volume = getByLabelText('Volume') as HTMLInputElement; + expect(volume.disabled).toBe(false); + expect(volume.value).toBe('0.5'); + + await fireEvent.input(volume, { target: { value: '0.8' } }); + expect(volume.value).toBe('0.8'); + + await fireEvent.click(on); + expect(queryByLabelText('Sound on')).toBeNull(); + expect((getByLabelText('Sound off') as HTMLButtonElement).getAttribute('aria-pressed')).toBe('false'); + expect(volume.disabled).toBe(true); + }); + it('rewrites an out-of-range ?n= link to the clamped value', async () => { const { getByLabelText } = await renderLab('n=5'); expect((getByLabelText('Array size') as HTMLInputElement).value).toBe('10'); diff --git a/web/src/lib/components/labs/SortingLab.svelte b/web/src/lib/components/labs/SortingLab.svelte index 0031212..aecf33d 100644 --- a/web/src/lib/components/labs/SortingLab.svelte +++ b/web/src/lib/components/labs/SortingLab.svelte @@ -12,6 +12,7 @@ import { cellRects } from '../../sorting/layout'; import { readSummary, laneIndex, ALGORITHMS, DATASETS, type SortingSummary } from '../../sorting/summary'; import { allLanes, rowLanes, colLanes, laneState, shouldRun, LANE_GLYPH } from '../../sorting/lanes'; + import { SortingAudio } from '../../sorting/audio'; import { route, replaceQuery } from '../../router.svelte'; import { buildQuery } from '../../router'; @@ -33,6 +34,10 @@ let size = $state(clamp(paramsOf(route.query).size ?? DEFAULT_SIZE)); let speed = $state(DEFAULT_SPEED); + /** Sonification: one voice per lane, off until the speaker button is clicked. */ + const audio = new SortingAudio(); + let muted = $state(audio.muted); + let volume = $state(audio.volume); /** The engine's lane grid, re-read every frame (null until the engine is up). */ let summary = $state(null); /** Query we last wrote (or consumed), so our own replaceQuery() doesn't re-trigger a push. */ @@ -62,12 +67,17 @@ }); }); - // The shell replaces `snapshot` every frame; re-read the lane grid off that clock. + // The shell replaces `snapshot` every frame; re-read the lane grid off that + // clock and voice whatever moved. $effect(() => { const a = api; if (!a) return; void a.snapshot; - summary = readSummary(a.readSummary()); + // Hand the audio the raw value, not the `summary` state: reading state + // this effect just wrote would make it re-run on its own write. + const next = readSummary(a.readSummary()); + summary = next; + audio.update(next); }); /** Measure the cell buttons and hand the viz their device-pixel rects. */ @@ -118,6 +128,7 @@ ro?.disconnect(); ro = null; window.removeEventListener('resize', pushCells); + audio.destroy(); }); const laneAt = (i: number) => summary?.lanes[i]; @@ -171,6 +182,17 @@ speed = Number((e.target as HTMLInputElement).value); api?.dispatch(cmd.setSpeed(speed)); } + + /** The click is the user gesture that lets the browser start audio. */ + function toggleMute() { + audio.setMuted(!muted); + muted = audio.muted; + } + + function onVolume(e: Event) { + audio.setVolume(Number((e.target as HTMLInputElement).value)); + volume = audio.volume; + } @@ -218,6 +240,16 @@ {speed} ops/s +
+ + +
{/snippet} {#snippet info()} @@ -263,6 +295,11 @@ Size changes the array length (10–300) and is shareable: it lands in the link as ?n=.

+

+ 🔊 turns on sound: every running panel hums the value it just + touched — low for small, high for large, brighter on a write than on a + compare — and rings a chime when it finishes. The slider sets the volume. +

{/snippet}
@@ -332,9 +369,12 @@ width: 5rem; font-variant-numeric: tabular-nums; } - .size, .speed { display: flex; align-items: center; gap: 0.5rem; color: #bbb; } + .size, .speed, .sound { display: flex; align-items: center; gap: 0.5rem; color: #bbb; } .speed { margin-left: auto; } .speed .value { font-variant-numeric: tabular-nums; width: 5rem; text-align: right; } + .sound input { width: 6rem; } + .sound input:disabled { opacity: 0.4; } + .mute[aria-pressed="true"] { border-color: #6f8fc9; } /* Legend swatches — the shell supplies the base dot; `span` outranks it. */ span.swatch.bar { background: #8c99bf; } @@ -348,5 +388,6 @@ .hdr.row { font-size: 0.62rem; } .hdr small, .badge { display: none; } .speed { margin-left: 0; } + .sound input { width: 5rem; } } diff --git a/web/src/lib/sorting/__tests__/audio.test.ts b/web/src/lib/sorting/__tests__/audio.test.ts new file mode 100644 index 0000000..882af1d --- /dev/null +++ b/web/src/lib/sorting/__tests__/audio.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi } from 'vitest'; +import { pitchOf, planAudio, SortingAudio, BASE_HZ, OCTAVES, TONE_LEVEL, type AudioContextLike } from '../audio'; +import type { LaneSummary, SortingSummary } from '../summary'; + +function lane(patch: Partial = {}): LaneSummary { + return { + algorithm: 'bubble', + dataset: 'random', + compares: 0, + writes: 0, + cursor: 0, + total: 100, + running: false, + done: false, + size: 50, + last_kind: null, + last_value: null, + ...patch, + }; +} + +function grid(lanes: LaneSummary[]): SortingSummary { + return { rows: 1, cols: lanes.length, tick: 0, all_done: lanes.every((l) => l.done), lanes }; +} + +describe('pitchOf', () => { + it('maps the value range onto OCTAVES octaves above BASE_HZ, log-spaced', () => { + expect(pitchOf(0, 50)).toBe(BASE_HZ); + expect(pitchOf(49, 50)).toBeCloseTo(BASE_HZ * 2 ** OCTAVES); + // The midpoint of a 3-octave span is 1.5 octaves up. + expect(pitchOf(49.5, 100)).toBeCloseTo(BASE_HZ * 2 ** 1.5); + }); + + it('clamps out-of-range values and pins the base note for a degenerate size', () => { + expect(pitchOf(-5, 50)).toBe(BASE_HZ); + expect(pitchOf(500, 50)).toBeCloseTo(BASE_HZ * 2 ** OCTAVES); + expect(pitchOf(0, 1)).toBe(BASE_HZ); + expect(pitchOf(3, 0)).toBe(BASE_HZ); + }); +}); + +describe('planAudio', () => { + it('rests every lane when nothing has moved', () => { + const s = grid([lane(), lane()]); + expect(planAudio(s, s)).toEqual([ + { kind: 'rest', lane: 0 }, + { kind: 'rest', lane: 1 }, + ]); + }); + + it('voices a lane whose cursor advanced, at the pitch of the value it touched', () => { + const before = grid([lane({ running: true, cursor: 3, last_kind: 'compare', last_value: 10 })]); + const after = grid([lane({ running: true, cursor: 4, last_kind: 'write', last_value: 49 })]); + expect(planAudio(before, after)).toEqual([ + { kind: 'tone', lane: 0, touch: 'write', freq: pitchOf(49, 50) }, + ]); + }); + + it('treats a missing previous frame as "any progress counts"', () => { + const s = grid([ + lane({ running: true, cursor: 1, last_kind: 'compare', last_value: 0 }), + lane({ running: true, cursor: 0 }), + ]); + expect(planAudio(null, s)).toEqual([ + { kind: 'tone', lane: 0, touch: 'compare', freq: BASE_HZ }, + { kind: 'rest', lane: 1 }, + ]); + }); + + it('stays silent for a lane that reports progress but no last op', () => { + const s = grid([lane({ running: true, cursor: 5 })]); + expect(planAudio(null, s)).toEqual([{ kind: 'rest', lane: 0 }]); + }); + + it('chimes once when a lane flips to done, alongside its final tone', () => { + const before = grid([lane({ running: true, cursor: 99, last_kind: 'compare', last_value: 1 })]); + const after = grid([lane({ running: false, done: true, cursor: 100, last_kind: 'write', last_value: 2 })]); + expect(planAudio(before, after)).toEqual([ + { kind: 'tone', lane: 0, touch: 'write', freq: pitchOf(2, 50) }, + { kind: 'chime', lane: 0 }, + ]); + // Still done next frame: no second chime, and no tone. + expect(planAudio(after, after)).toEqual([{ kind: 'rest', lane: 0 }]); + }); + + it('does not chime for a lane that was already done on the first frame it sees', () => { + const s = grid([lane({ done: true, cursor: 100, last_kind: 'write', last_value: 2 })]); + expect(planAudio(null, s)).toEqual([{ kind: 'tone', lane: 0, touch: 'write', freq: pitchOf(2, 50) }]); + }); + + it('rests every previously known lane when the summary goes away', () => { + const s = grid([lane(), lane(), lane()]); + expect(planAudio(s, null)).toEqual([ + { kind: 'rest', lane: 0 }, + { kind: 'rest', lane: 1 }, + { kind: 'rest', lane: 2 }, + ]); + expect(planAudio(null, null)).toEqual([]); + }); +}); + +// ---- Web Audio wrapper, against a stub context ---------------------------- + +function param(value = 0) { + return { + value, + setValueAtTime: vi.fn(), + setTargetAtTime: vi.fn(), + cancelScheduledValues: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }; +} + +function makeStubContext() { + const oscillators: ReturnType[] = []; + function makeOsc() { + return { type: 'sine', frequency: param(440), connect: vi.fn(), start: vi.fn(), stop: vi.fn() }; + } + const ctx = { + currentTime: 1, + state: 'suspended' as AudioContextState, + destination: {} as AudioDestinationNode, + resume: vi.fn(async () => { ctx.state = 'running'; }), + close: vi.fn(async () => { ctx.state = 'closed'; }), + createOscillator: vi.fn(() => { const o = makeOsc(); oscillators.push(o); return o; }), + createGain: vi.fn(() => ({ gain: param(1), connect: vi.fn() })), + createDynamicsCompressor: vi.fn(() => ({ connect: vi.fn() })), + }; + return { ctx: ctx as unknown as AudioContextLike, raw: ctx, oscillators }; +} + +describe('SortingAudio', () => { + it('starts muted and does not touch the browser until unmuted', () => { + const factory = vi.fn(() => makeStubContext().ctx); + const audio = new SortingAudio(factory); + expect(audio.muted).toBe(true); + expect(audio.supported).toBe(false); + audio.update(grid([lane({ running: true, cursor: 1, last_kind: 'write', last_value: 3 })])); + expect(factory).not.toHaveBeenCalled(); + }); + + it('unmuting creates and resumes the context; muting again silences the voices', () => { + const stub = makeStubContext(); + const audio = new SortingAudio(() => stub.ctx); + audio.setMuted(false); + expect(audio.supported).toBe(true); + expect(stub.raw.resume).toHaveBeenCalledTimes(1); + expect(stub.raw.createDynamicsCompressor).toHaveBeenCalledTimes(1); + + audio.update(grid([lane({ running: true, cursor: 1, last_kind: 'write', last_value: 3 })])); + expect(stub.oscillators).toHaveLength(1); // one voice per lane + const voice = stub.oscillators[0]; + expect(voice.start).toHaveBeenCalledTimes(1); + expect(voice.type).toBe('triangle'); // writes use the brighter wave + expect(voice.frequency.setTargetAtTime).toHaveBeenCalledWith(pitchOf(3, 50), 1, expect.any(Number)); + + audio.setMuted(true); + // The voice's gain was driven to 0 when muting. + const gains = (stub.raw.createGain.mock.results as { value: { gain: ReturnType } }[]).map((r) => r.value.gain); + const voiceGain = gains[1]; // gains[0] is the master + expect(voiceGain.setTargetAtTime).toHaveBeenLastCalledWith(0, 1, expect.any(Number)); + }); + + it('keeps the frame diff going while muted, so unmuting does not replay old progress', () => { + const stub = makeStubContext(); + const audio = new SortingAudio(() => stub.ctx); + const moving = grid([lane({ running: true, cursor: 5, last_kind: 'compare', last_value: 3 })]); + audio.update(moving); // muted: remembered, not voiced + audio.setMuted(false); + audio.update(moving); // same cursor as last frame → a rest, not a tone + expect(stub.oscillators[0].frequency.setTargetAtTime).not.toHaveBeenCalled(); + }); + + it('plays a throwaway chime oscillator when a lane finishes', () => { + const stub = makeStubContext(); + const audio = new SortingAudio(() => stub.ctx); + audio.setMuted(false); + audio.update(grid([lane({ running: true, cursor: 99, last_kind: 'compare', last_value: 1 })])); + expect(stub.oscillators).toHaveLength(1); + audio.update(grid([lane({ done: true, cursor: 100, last_kind: 'write', last_value: 2 })])); + expect(stub.oscillators).toHaveLength(2); + const chime = stub.oscillators[1]; + expect(chime.start).toHaveBeenCalledWith(1); + expect(chime.stop).toHaveBeenCalledWith(1.5); + expect(chime.frequency.setValueAtTime).toHaveBeenCalledWith(880, 1); + }); + + it('applies volume on a squared curve and clamps it', () => { + const stub = makeStubContext(); + const audio = new SortingAudio(() => stub.ctx, 0.5); + audio.setMuted(false); + const master = (stub.raw.createGain.mock.results[0] as { value: { gain: ReturnType } }).value.gain; + expect(master.value).toBeCloseTo(0.25); + audio.setVolume(1.7); + expect(audio.volume).toBe(1); + expect(master.setTargetAtTime).toHaveBeenLastCalledWith(1, 1, expect.any(Number)); + audio.setVolume(NaN); + expect(audio.volume).toBe(0); + }); + + it('reports unsupported (and stays quiet) when the browser has no AudioContext', () => { + const audio = new SortingAudio(() => null); + audio.setMuted(false); + expect(audio.supported).toBe(false); + expect(() => audio.update(grid([lane({ running: true, cursor: 1, last_kind: 'write', last_value: 3 })]))).not.toThrow(); + }); + + it('destroy stops every voice and closes the context', () => { + const stub = makeStubContext(); + const audio = new SortingAudio(() => stub.ctx); + audio.setMuted(false); + audio.update(grid([lane({ running: true, cursor: 1, last_kind: 'write', last_value: 3 }), lane()])); + audio.destroy(); + expect(stub.oscillators.every((o) => o.stop.mock.calls.length === 1)).toBe(true); + expect(stub.raw.close).toHaveBeenCalledTimes(1); + expect(audio.supported).toBe(false); + }); + + it('exposes the per-kind levels the tone envelope uses', () => { + expect(TONE_LEVEL.write).toBeGreaterThan(TONE_LEVEL.compare); + }); +}); diff --git a/web/src/lib/sorting/__tests__/lanes.test.ts b/web/src/lib/sorting/__tests__/lanes.test.ts index f0abc0c..8973983 100644 --- a/web/src/lib/sorting/__tests__/lanes.test.ts +++ b/web/src/lib/sorting/__tests__/lanes.test.ts @@ -12,6 +12,9 @@ function lane(patch: Partial = {}): LaneSummary { total: 10, running: false, done: false, + size: 50, + last_kind: null, + last_value: null, ...patch, }; } diff --git a/web/src/lib/sorting/__tests__/summary.test.ts b/web/src/lib/sorting/__tests__/summary.test.ts index 10111eb..b3a8bec 100644 --- a/web/src/lib/sorting/__tests__/summary.test.ts +++ b/web/src/lib/sorting/__tests__/summary.test.ts @@ -12,6 +12,9 @@ function lane(patch: Partial = {}): LaneSummary { total: 100, running: true, done: false, + size: 50, + last_kind: 'compare', + last_value: 17, ...patch, }; } @@ -68,6 +71,24 @@ describe('readSummary', () => { const { done: _done, ...noDone } = lane(); expect(readSummary({ ...oneLane, lanes: [noDone] })).toBeNull(); }); + + it('accepts a lane before its first tick (no last op) and after (kind + value)', () => { + const idle = lane({ last_kind: null, last_value: null }); + expect(readSummary({ ...oneLane, lanes: [idle] })?.lanes[0]).toEqual(idle); + const written = lane({ last_kind: 'write', last_value: 0 }); + expect(readSummary({ ...oneLane, lanes: [written] })?.lanes[0]).toEqual(written); + }); + + it('rejects a bad size, an unknown touch kind, or a half-set last op', () => { + expect(readSummary({ ...oneLane, lanes: [lane({ size: NaN })] })).toBeNull(); + expect(readSummary({ ...oneLane, lanes: [{ ...lane(), last_kind: 'swap' }] })).toBeNull(); + expect(readSummary({ ...oneLane, lanes: [lane({ last_kind: null, last_value: 3 })] })).toBeNull(); + expect(readSummary({ ...oneLane, lanes: [lane({ last_kind: 'write', last_value: null })] })).toBeNull(); + const { size: _size, ...noSize } = lane(); + expect(readSummary({ ...oneLane, lanes: [noSize] })).toBeNull(); + const { last_kind: _k, last_value: _v, ...noTouch } = lane(); + expect(readSummary({ ...oneLane, lanes: [noTouch] })).toBeNull(); + }); }); describe('laneIndex', () => { diff --git a/web/src/lib/sorting/audio.ts b/web/src/lib/sorting/audio.ts new file mode 100644 index 0000000..b596bc4 --- /dev/null +++ b/web/src/lib/sorting/audio.ts @@ -0,0 +1,230 @@ +// Sonification of the sorting matrix. `planAudio` is the pure part: it diffs +// two consecutive summaries into per-lane events (a tone for a lane that just +// did work, a chime for one that just finished, a rest for one that fell +// silent). `SortingAudio` is the thin Web Audio wrapper that voices those +// events: one persistent oscillator per lane, retriggered every frame the +// lane advanced, behind a master gain and a compressor so 28 voices at once +// stay listenable. +import type { SortingSummary, TouchKind } from './summary'; + +/** Lowest tone (value 0) and how many octaves the value range spans above it. */ +export const BASE_HZ = 110; +export const OCTAVES = 3; + +/** Per-voice peak gain by op kind — writes ring a little louder than compares. */ +export const TONE_LEVEL: Record = { compare: 0.05, write: 0.09 }; + +export type AudioEvent = + | { kind: 'tone'; lane: number; touch: TouchKind; freq: number } + | { kind: 'chime'; lane: number } + | { kind: 'rest'; lane: number }; + +/** + * Value → pitch, log-spaced so every octave covers the same share of the + * array: value 0 is `BASE_HZ`, value `size - 1` is `OCTAVES` above it. + * Values are clamped into range, and a degenerate size pins the base note. + */ +export function pitchOf(value: number, size: number): number { + if (!(size > 1)) return BASE_HZ; + const t = Math.min(Math.max(value, 0), size - 1) / (size - 1); + return BASE_HZ * 2 ** (OCTAVES * t); +} + +/** + * Events to voice for the frame that moved `prev` to `next`. Per lane: + * a `chime` when it just finished, a `tone` when it is running and its cursor + * advanced since `prev` (with `prev === null` meaning "any progress at all"), + * else a `rest`. A lane that finished this frame gets both its final tone + * and the chime. A null `next` rests every lane `prev` had. + */ +export function planAudio(prev: SortingSummary | null, next: SortingSummary | null): AudioEvent[] { + const events: AudioEvent[] = []; + if (next === null) { + prev?.lanes.forEach((_, lane) => events.push({ kind: 'rest', lane })); + return events; + } + next.lanes.forEach((l, lane) => { + const before = prev?.lanes[lane]; + const advanced = l.cursor > (before?.cursor ?? 0); + if (advanced && l.last_kind !== null && l.last_value !== null) { + events.push({ kind: 'tone', lane, touch: l.last_kind, freq: pitchOf(l.last_value, l.size) }); + } else { + events.push({ kind: 'rest', lane }); + } + if (l.done && before !== undefined && !before.done) events.push({ kind: 'chime', lane }); + }); + return events; +} + +/** The slice of AudioContext the wrapper uses — lets tests hand in a stub. */ +export type AudioContextLike = Pick< + AudioContext, + 'currentTime' | 'destination' | 'state' | 'resume' | 'close' | 'createOscillator' | 'createGain' | 'createDynamicsCompressor' +>; + +export type AudioContextFactory = () => AudioContextLike | null; + +/** The browser's AudioContext, or null where there is none (jsdom, old WebKit). */ +export const defaultContextFactory: AudioContextFactory = () => { + const Ctor = + (globalThis as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }).AudioContext ?? + (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + return Ctor ? new Ctor() : null; +}; + +interface Voice { + osc: OscillatorNode; + gain: GainNode; + sounding: boolean; +} + +/** + * Voices the matrix. Starts muted; `setMuted(false)` from a user gesture + * creates (and resumes) the context, which autoplay policy requires. Call + * `update()` once per frame with the latest summary and `destroy()` on + * unmount. + */ +export class SortingAudio { + private ctx: AudioContextLike | null = null; + private master: GainNode | null = null; + private voices: Voice[] = []; + private prev: SortingSummary | null = null; + private _muted = true; + private _volume: number; + + constructor( + private readonly createContext: AudioContextFactory = defaultContextFactory, + volume = 0.5, + ) { + this._volume = clamp01(volume); + } + + get muted(): boolean { + return this._muted; + } + + get volume(): number { + return this._volume; + } + + /** True once the browser has handed us a context — false where audio is unsupported. */ + get supported(): boolean { + return this.ctx !== null; + } + + /** Master volume in [0, 1], applied on a squared curve so the slider feels linear. */ + setVolume(v: number): void { + this._volume = clamp01(v); + if (this.master && this.ctx) this.master.gain.setTargetAtTime(this._volume ** 2, this.ctx.currentTime, 0.02); + } + + /** Unmuting from a click/tap is what brings the context up. */ + setMuted(muted: boolean): void { + this._muted = muted; + if (!muted) { + this.ensureContext(); + if (this.ctx?.state === 'suspended') void this.ctx.resume(); + } else { + this.silenceAll(); + } + } + + /** Voice the frame that moved from the last summary to `summary`. */ + update(summary: SortingSummary | null): void { + const events = planAudio(this.prev, summary); + this.prev = summary; + if (this._muted || !this.ctx || !this.master) return; + if (summary && this.voices.length !== summary.lanes.length) this.buildVoices(summary.lanes.length); + const now = this.ctx.currentTime; + for (const ev of events) { + if (ev.kind === 'tone') this.tone(ev.lane, ev.touch, ev.freq, now); + else if (ev.kind === 'chime') this.chime(now); + else this.rest(ev.lane, now); + } + } + + destroy(): void { + this.silenceAll(); + for (const v of this.voices) v.osc.stop(); + this.voices = []; + void this.ctx?.close(); + this.ctx = null; + this.master = null; + } + + private ensureContext(): void { + if (this.ctx) return; + const ctx = this.createContext(); + if (!ctx) return; + this.ctx = ctx; + const master = ctx.createGain(); + master.gain.value = this._volume ** 2; + const comp = ctx.createDynamicsCompressor(); + master.connect(comp); + comp.connect(ctx.destination); + this.master = master; + } + + private buildVoices(n: number): void { + if (!this.ctx || !this.master) return; + for (const v of this.voices) v.osc.stop(); + this.voices = []; + for (let i = 0; i < n; i++) { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + gain.gain.value = 0; + osc.type = 'sine'; + osc.connect(gain); + gain.connect(this.master); + osc.start(); + this.voices.push({ osc, gain, sounding: false }); + } + } + + /** A short blip: slide to the pitch, snap the gain up, let it fall away. */ + private tone(lane: number, touch: TouchKind, freq: number, now: number): void { + const v = this.voices[lane]; + if (!v) return; + v.osc.type = touch === 'write' ? 'triangle' : 'sine'; + v.osc.frequency.setTargetAtTime(freq, now, 0.004); + v.gain.gain.cancelScheduledValues(now); + v.gain.gain.setTargetAtTime(TONE_LEVEL[touch], now, 0.005); + v.gain.gain.setTargetAtTime(0, now + 0.04, 0.03); + v.sounding = true; + } + + private rest(lane: number, now: number): void { + const v = this.voices[lane]; + if (!v || !v.sounding) return; + v.gain.gain.cancelScheduledValues(now); + v.gain.gain.setTargetAtTime(0, now, 0.01); + v.sounding = false; + } + + /** A finished lane: a rising two-note ding on a throwaway oscillator. */ + private chime(now: number): void { + if (!this.ctx || !this.master) return; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(880, now); + osc.frequency.setValueAtTime(1320, now + 0.09); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.18, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45); + osc.connect(gain); + gain.connect(this.master); + osc.start(now); + osc.stop(now + 0.5); + } + + private silenceAll(): void { + if (!this.ctx) return; + const now = this.ctx.currentTime; + for (let i = 0; i < this.voices.length; i++) this.rest(i, now); + } +} + +function clamp01(v: number): number { + return Number.isFinite(v) ? Math.min(Math.max(v, 0), 1) : 0; +} diff --git a/web/src/lib/sorting/summary.ts b/web/src/lib/sorting/summary.ts index 901bc56..c93934f 100644 --- a/web/src/lib/sorting/summary.ts +++ b/web/src/lib/sorting/summary.ts @@ -2,6 +2,9 @@ // `rule_summary()`, plus the row/column vocabulary the page renders. Pure and // dependency-free so it's trivially testable (same style as lib/fourier/summary.ts). +/** What the last op touched: a compare, or a write (swaps count as writes). */ +export type TouchKind = 'compare' | 'write'; + /** One grid cell: an algorithm running one dataset, with its live counters. */ export interface LaneSummary { algorithm: string; @@ -12,6 +15,12 @@ export interface LaneSummary { total: number; running: boolean; done: boolean; + /** Length of the array under sort — `last_value` is in `[0, size)`. */ + size: number; + /** Kind of the most recent op, or null before the first tick. */ + last_kind: TouchKind | null; + /** The value the most recent op landed on (larger of a compared/swapped pair, or the value written), or null before the first tick. */ + last_value: number | null; } /** The whole grid: `rows` algorithms × `cols` datasets, row-major in `lanes`. */ @@ -61,15 +70,25 @@ function isCount(v: unknown): v is number { return isFiniteNumber(v) && Number.isInteger(v) && v > 0; } +function isTouchKind(v: unknown): v is TouchKind { + return v === 'compare' || v === 'write'; +} + function readLane(raw: unknown): LaneSummary | null { if (typeof raw !== 'object' || raw === null) return null; - const { algorithm, dataset, compares, writes, cursor, total, running, done } = + const { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind, last_value } = raw as Record; if (typeof algorithm !== 'string' || typeof dataset !== 'string') return null; if (!isFiniteNumber(compares) || !isFiniteNumber(writes)) return null; if (!isFiniteNumber(cursor) || !isFiniteNumber(total)) return null; if (typeof running !== 'boolean' || typeof done !== 'boolean') return null; - return { algorithm, dataset, compares, writes, cursor, total, running, done }; + if (!isFiniteNumber(size)) return null; + // Both null before the first tick, both set after it — never one without the other. + if (last_kind === null && last_value === null) { + return { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind, last_value }; + } + if (!isTouchKind(last_kind) || !isFiniteNumber(last_value)) return null; + return { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind, last_value }; } /** diff --git a/web/src/lib/test/fakeViz.ts b/web/src/lib/test/fakeViz.ts index 7ac06f6..9145f99 100644 --- a/web/src/lib/test/fakeViz.ts +++ b/web/src/lib/test/fakeViz.ts @@ -60,6 +60,9 @@ export const sortingSummaryFixture: SortingSummary = { total: 100, running: false, done: false, + size: 50, + last_kind: null, + last_value: null, }), ), ), From d2802e3d8208c9f1c57dd16b0dc25111ec4f9b07 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Wed, 23 Sep 2026 15:57:33 -0400 Subject: [PATCH 3/5] fix(sorting): accept undefined for a lane's untouched last op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasm-bindgen hands the engine's None across as undefined, not null, so the strict null check rejected every lane that had not ticked yet and the whole summary read as null — a single running lane never showed as running and no voices were built until every lane had moved. Accept either and normalize to null. README: describe the sound controls. --- README.md | 3 +++ web/src/lib/sorting/__tests__/summary.test.ts | 7 +++++-- web/src/lib/sorting/summary.ts | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 452b501..7a4b085 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ Math Visualizer is an interactive collection of mathematical visualizations buil > a cell to run/pause/restart it, or use the ▶ on a row/column header to run a whole > group; the toolbar runs or pauses everything, generates new data, and adjusts speed > and array size (10–300, default 50). Share a size with `#/sorting?n=`. +> Click 🔊 for sound: every running panel hums the value it just touched (pitch +> rises with the value, writes ring brighter than compares) and chimes when it +> finishes; the slider beside it sets the volume. > > The midpoint-on-circle and ColorCycle rules remain in the codebase as alternative > examples. See [`docs/superpowers/specs/`](docs/superpowers/specs/) for designs and diff --git a/web/src/lib/sorting/__tests__/summary.test.ts b/web/src/lib/sorting/__tests__/summary.test.ts index b3a8bec..2110b9b 100644 --- a/web/src/lib/sorting/__tests__/summary.test.ts +++ b/web/src/lib/sorting/__tests__/summary.test.ts @@ -75,6 +75,10 @@ describe('readSummary', () => { it('accepts a lane before its first tick (no last op) and after (kind + value)', () => { const idle = lane({ last_kind: null, last_value: null }); expect(readSummary({ ...oneLane, lanes: [idle] })?.lanes[0]).toEqual(idle); + // wasm-bindgen hands the engine's `None` over as `undefined`; it normalizes to null. + const { last_kind: _k, last_value: _v, ...bare } = lane(); + expect(readSummary({ ...oneLane, lanes: [{ ...bare, last_kind: undefined, last_value: undefined }] })?.lanes[0]).toEqual(idle); + expect(readSummary({ ...oneLane, lanes: [bare] })?.lanes[0]).toEqual(idle); const written = lane({ last_kind: 'write', last_value: 0 }); expect(readSummary({ ...oneLane, lanes: [written] })?.lanes[0]).toEqual(written); }); @@ -84,10 +88,9 @@ describe('readSummary', () => { expect(readSummary({ ...oneLane, lanes: [{ ...lane(), last_kind: 'swap' }] })).toBeNull(); expect(readSummary({ ...oneLane, lanes: [lane({ last_kind: null, last_value: 3 })] })).toBeNull(); expect(readSummary({ ...oneLane, lanes: [lane({ last_kind: 'write', last_value: null })] })).toBeNull(); + expect(readSummary({ ...oneLane, lanes: [{ ...lane(), last_kind: undefined }] })).toBeNull(); const { size: _size, ...noSize } = lane(); expect(readSummary({ ...oneLane, lanes: [noSize] })).toBeNull(); - const { last_kind: _k, last_value: _v, ...noTouch } = lane(); - expect(readSummary({ ...oneLane, lanes: [noTouch] })).toBeNull(); }); }); diff --git a/web/src/lib/sorting/summary.ts b/web/src/lib/sorting/summary.ts index c93934f..b3fe181 100644 --- a/web/src/lib/sorting/summary.ts +++ b/web/src/lib/sorting/summary.ts @@ -83,9 +83,11 @@ function readLane(raw: unknown): LaneSummary | null { if (!isFiniteNumber(cursor) || !isFiniteNumber(total)) return null; if (typeof running !== 'boolean' || typeof done !== 'boolean') return null; if (!isFiniteNumber(size)) return null; - // Both null before the first tick, both set after it — never one without the other. - if (last_kind === null && last_value === null) { - return { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind, last_value }; + // Both unset before the first tick, both set after it — never one without + // the other. The engine's `None` crosses wasm-bindgen as `undefined`, not + // `null`, so accept either and normalize to null. + if (last_kind == null && last_value == null) { + return { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind: null, last_value: null }; } if (!isTouchKind(last_kind) || !isFiniteNumber(last_value)) return null; return { algorithm, dataset, compares, writes, cursor, total, running, done, size, last_kind, last_value }; From 83d70892d500b903d926b730682956e7e69ad0fc Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Wed, 23 Sep 2026 15:57:51 -0400 Subject: [PATCH 4/5] docs: refresh testing.md counts and coverage for the sorting sound work --- docs/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 68891f1..69514ab 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,8 @@ npm test npm run coverage ``` -The Rust suite currently contains 177 passing tests. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 24 passing tests and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. +The Rust suite currently contains 178 passing tests. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 24 passing tests and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. -As measured on September 15, 2026, the 105 web tests (across 13 test files) cover 84.42% of statements, 84.98% of branches, 76.11% of functions, and 84.42% of lines. They exercise the single-flight WASM loader and representative Svelte application mount and interaction paths across the Fourier and sorting labs. +As measured on September 23, 2026, the 127 web tests (across 14 test files) cover 86.72% of statements, 86.50% of branches, 79.35% of functions, and 86.72% of lines. They exercise the single-flight WASM loader, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), and representative Svelte application mount and interaction paths across the Fourier and sorting labs. Instrumentation-based Rust source coverage is currently 0% because the Cargo test gate does not configure a Rust coverage reporter. The web report's principal gaps are the Svelte application's rendering and animation paths and the browser entry point. Real WebGL behavior remains outside the DOM-based unit-test environment. From 6d696d691ad350eac5f09b8343d1736022a34772 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Wed, 23 Sep 2026 16:03:00 -0400 Subject: [PATCH 5/5] fix(sorting): review follow-ups for the sound work - pitchOf maps the engine's 1..=size value range (value 1 is the base note, value size the top) instead of 0..size-1, which had pinned the two largest values to the same note and never played the base - planAudio no longer rests a running lane between ops, so a slow lane's blip rings out on its own envelope instead of being cut every frame; rests fire when a lane stops, is rewound, or the summary goes away - the speaker button keeps a fixed accessible name ("Sound") with aria-pressed for state, rather than a changing label plus aria-pressed - the chime's throwaway nodes disconnect themselves once they end - tests for the no-advance, pause, and rewind cases; docs counts --- docs/testing.md | 2 +- .../components/__tests__/SortingLab.test.ts | 21 ++++--- web/src/lib/components/labs/SortingLab.svelte | 2 +- web/src/lib/sorting/__tests__/audio.test.ts | 57 +++++++++++++++---- web/src/lib/sorting/audio.ts | 23 +++++--- 5 files changed, 74 insertions(+), 31 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 69514ab..c0dfb4a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,6 +11,6 @@ npm run coverage The Rust suite currently contains 178 passing tests. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 24 passing tests and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README. -As measured on September 23, 2026, the 127 web tests (across 14 test files) cover 86.72% of statements, 86.50% of branches, 79.35% of functions, and 86.72% of lines. They exercise the single-flight WASM loader, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), and representative Svelte application mount and interaction paths across the Fourier and sorting labs. +As measured on September 23, 2026, the 130 web tests (across 14 test files) cover 86.75% of statements, 86.45% of branches, 79.48% of functions, and 86.75% of lines. They exercise the single-flight WASM loader, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), and representative Svelte application mount and interaction paths across the Fourier and sorting labs. Instrumentation-based Rust source coverage is currently 0% because the Cargo test gate does not configure a Rust coverage reporter. The web report's principal gaps are the Svelte application's rendering and animation paths and the browser entry point. Real WebGL behavior remains outside the DOM-based unit-test environment. diff --git a/web/src/lib/components/__tests__/SortingLab.test.ts b/web/src/lib/components/__tests__/SortingLab.test.ts index 32e5ff1..5ca306f 100644 --- a/web/src/lib/components/__tests__/SortingLab.test.ts +++ b/web/src/lib/components/__tests__/SortingLab.test.ts @@ -191,17 +191,20 @@ describe('SortingLab.svelte', () => { it('starts with sound off, the volume slider disabled, and no AudioContext needed', async () => { const { getByLabelText } = await renderLab(); - const mute = getByLabelText('Sound off') as HTMLButtonElement; + const mute = getByLabelText('Sound') as HTMLButtonElement; expect(mute.getAttribute('aria-pressed')).toBe('false'); + expect(mute.textContent).toBe('🔇'); + expect(mute.title).toBe('Turn sound on'); expect((getByLabelText('Volume') as HTMLInputElement).disabled).toBe(true); }); it('the speaker button toggles sound on and off and enables the volume slider', async () => { - const { getByLabelText, queryByLabelText } = await renderLab(); - await fireEvent.click(getByLabelText('Sound off')); - const on = getByLabelText('Sound on') as HTMLButtonElement; - expect(on.getAttribute('aria-pressed')).toBe('true'); - expect(on.textContent).toBe('🔊'); + const { getByLabelText } = await renderLab(); + const mute = getByLabelText('Sound') as HTMLButtonElement; + await fireEvent.click(mute); + expect(mute.getAttribute('aria-pressed')).toBe('true'); + expect(mute.textContent).toBe('🔊'); + expect(mute.title).toBe('Turn sound off'); const volume = getByLabelText('Volume') as HTMLInputElement; expect(volume.disabled).toBe(false); expect(volume.value).toBe('0.5'); @@ -209,9 +212,9 @@ describe('SortingLab.svelte', () => { await fireEvent.input(volume, { target: { value: '0.8' } }); expect(volume.value).toBe('0.8'); - await fireEvent.click(on); - expect(queryByLabelText('Sound on')).toBeNull(); - expect((getByLabelText('Sound off') as HTMLButtonElement).getAttribute('aria-pressed')).toBe('false'); + await fireEvent.click(mute); + expect(mute.getAttribute('aria-pressed')).toBe('false'); + expect(mute.textContent).toBe('🔇'); expect(volume.disabled).toBe(true); }); diff --git a/web/src/lib/components/labs/SortingLab.svelte b/web/src/lib/components/labs/SortingLab.svelte index aecf33d..6fe0115 100644 --- a/web/src/lib/components/labs/SortingLab.svelte +++ b/web/src/lib/components/labs/SortingLab.svelte @@ -245,7 +245,7 @@ class="mute" onclick={toggleMute} aria-pressed={!muted} - aria-label={muted ? 'Sound off' : 'Sound on'} + aria-label="Sound" title={muted ? 'Turn sound on' : 'Turn sound off'} >{muted ? '🔇' : '🔊'} diff --git a/web/src/lib/sorting/__tests__/audio.test.ts b/web/src/lib/sorting/__tests__/audio.test.ts index 882af1d..4328b32 100644 --- a/web/src/lib/sorting/__tests__/audio.test.ts +++ b/web/src/lib/sorting/__tests__/audio.test.ts @@ -24,23 +24,26 @@ function grid(lanes: LaneSummary[]): SortingSummary { } describe('pitchOf', () => { - it('maps the value range onto OCTAVES octaves above BASE_HZ, log-spaced', () => { - expect(pitchOf(0, 50)).toBe(BASE_HZ); - expect(pitchOf(49, 50)).toBeCloseTo(BASE_HZ * 2 ** OCTAVES); + it('maps the 1..=size value range onto OCTAVES octaves above BASE_HZ, log-spaced', () => { + expect(pitchOf(1, 50)).toBe(BASE_HZ); + expect(pitchOf(50, 50)).toBeCloseTo(BASE_HZ * 2 ** OCTAVES); // The midpoint of a 3-octave span is 1.5 octaves up. - expect(pitchOf(49.5, 100)).toBeCloseTo(BASE_HZ * 2 ** 1.5); + expect(pitchOf(50.5, 100)).toBeCloseTo(BASE_HZ * 2 ** 1.5); + // Adjacent top values are distinct notes, not both clamped to the ceiling. + expect(pitchOf(9, 10)).toBeLessThan(pitchOf(10, 10)); }); it('clamps out-of-range values and pins the base note for a degenerate size', () => { + expect(pitchOf(0, 50)).toBe(BASE_HZ); expect(pitchOf(-5, 50)).toBe(BASE_HZ); expect(pitchOf(500, 50)).toBeCloseTo(BASE_HZ * 2 ** OCTAVES); - expect(pitchOf(0, 1)).toBe(BASE_HZ); + expect(pitchOf(1, 1)).toBe(BASE_HZ); expect(pitchOf(3, 0)).toBe(BASE_HZ); }); }); describe('planAudio', () => { - it('rests every lane when nothing has moved', () => { + it('rests every stopped lane when nothing has moved', () => { const s = grid([lane(), lane()]); expect(planAudio(s, s)).toEqual([ { kind: 'rest', lane: 0 }, @@ -48,6 +51,25 @@ describe('planAudio', () => { ]); }); + it('leaves a running lane alone between ops, so its last blip rings out', () => { + const s = grid([lane({ running: true, cursor: 5, last_kind: 'compare', last_value: 3 })]); + expect(planAudio(s, s)).toEqual([]); + }); + + it('rests a lane the moment it is paused', () => { + const on = grid([lane({ running: true, cursor: 5, last_kind: 'compare', last_value: 3 })]); + const off = grid([lane({ running: false, cursor: 5, last_kind: 'compare', last_value: 3 })]); + expect(planAudio(on, off)).toEqual([{ kind: 'rest', lane: 0 }]); + }); + + it('treats a cursor going backwards (reset, restart, resize) as a rest with no chime', () => { + const late = grid([lane({ running: true, cursor: 80, last_kind: 'write', last_value: 9 })]); + const rewound = grid([lane({ running: false, cursor: 0 })]); + expect(planAudio(late, rewound)).toEqual([{ kind: 'rest', lane: 0 }]); + const finished = grid([lane({ done: true, cursor: 100, last_kind: 'write', last_value: 9 })]); + expect(planAudio(finished, rewound)).toEqual([{ kind: 'rest', lane: 0 }]); + }); + it('voices a lane whose cursor advanced, at the pitch of the value it touched', () => { const before = grid([lane({ running: true, cursor: 3, last_kind: 'compare', last_value: 10 })]); const after = grid([lane({ running: true, cursor: 4, last_kind: 'write', last_value: 49 })]); @@ -58,18 +80,17 @@ describe('planAudio', () => { it('treats a missing previous frame as "any progress counts"', () => { const s = grid([ - lane({ running: true, cursor: 1, last_kind: 'compare', last_value: 0 }), + lane({ running: true, cursor: 1, last_kind: 'compare', last_value: 1 }), lane({ running: true, cursor: 0 }), ]); expect(planAudio(null, s)).toEqual([ { kind: 'tone', lane: 0, touch: 'compare', freq: BASE_HZ }, - { kind: 'rest', lane: 1 }, ]); }); it('stays silent for a lane that reports progress but no last op', () => { const s = grid([lane({ running: true, cursor: 5 })]); - expect(planAudio(null, s)).toEqual([{ kind: 'rest', lane: 0 }]); + expect(planAudio(null, s)).toEqual([]); }); it('chimes once when a lane flips to done, alongside its final tone', () => { @@ -79,7 +100,7 @@ describe('planAudio', () => { { kind: 'tone', lane: 0, touch: 'write', freq: pitchOf(2, 50) }, { kind: 'chime', lane: 0 }, ]); - // Still done next frame: no second chime, and no tone. + // Still done next frame: no second chime, no tone, and the stopped lane rests. expect(planAudio(after, after)).toEqual([{ kind: 'rest', lane: 0 }]); }); @@ -114,7 +135,15 @@ function param(value = 0) { function makeStubContext() { const oscillators: ReturnType[] = []; function makeOsc() { - return { type: 'sine', frequency: param(440), connect: vi.fn(), start: vi.fn(), stop: vi.fn() }; + return { + type: 'sine', + frequency: param(440), + connect: vi.fn(), + disconnect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + onended: null as null | (() => void), + }; } const ctx = { currentTime: 1, @@ -123,7 +152,7 @@ function makeStubContext() { resume: vi.fn(async () => { ctx.state = 'running'; }), close: vi.fn(async () => { ctx.state = 'closed'; }), createOscillator: vi.fn(() => { const o = makeOsc(); oscillators.push(o); return o; }), - createGain: vi.fn(() => ({ gain: param(1), connect: vi.fn() })), + createGain: vi.fn(() => ({ gain: param(1), connect: vi.fn(), disconnect: vi.fn() })), createDynamicsCompressor: vi.fn(() => ({ connect: vi.fn() })), }; return { ctx: ctx as unknown as AudioContextLike, raw: ctx, oscillators }; @@ -183,6 +212,10 @@ describe('SortingAudio', () => { expect(chime.start).toHaveBeenCalledWith(1); expect(chime.stop).toHaveBeenCalledWith(1.5); expect(chime.frequency.setValueAtTime).toHaveBeenCalledWith(880, 1); + // The throwaway nodes unhook themselves once the chime has played out. + expect(chime.onended).toEqual(expect.any(Function)); + chime.onended!(); + expect(chime.disconnect).toHaveBeenCalledTimes(1); }); it('applies volume on a squared curve and clamps it', () => { diff --git a/web/src/lib/sorting/audio.ts b/web/src/lib/sorting/audio.ts index b596bc4..ef7e510 100644 --- a/web/src/lib/sorting/audio.ts +++ b/web/src/lib/sorting/audio.ts @@ -21,21 +21,24 @@ export type AudioEvent = /** * Value → pitch, log-spaced so every octave covers the same share of the - * array: value 0 is `BASE_HZ`, value `size - 1` is `OCTAVES` above it. - * Values are clamped into range, and a degenerate size pins the base note. + * array. The engine's datasets hold values in `1..=size`: value 1 is + * `BASE_HZ`, value `size` is `OCTAVES` above it. Values are clamped into + * range, and a degenerate size pins the base note. */ export function pitchOf(value: number, size: number): number { if (!(size > 1)) return BASE_HZ; - const t = Math.min(Math.max(value, 0), size - 1) / (size - 1); + const t = (Math.min(Math.max(value, 1), size) - 1) / (size - 1); return BASE_HZ * 2 ** (OCTAVES * t); } /** * Events to voice for the frame that moved `prev` to `next`. Per lane: - * a `chime` when it just finished, a `tone` when it is running and its cursor - * advanced since `prev` (with `prev === null` meaning "any progress at all"), - * else a `rest`. A lane that finished this frame gets both its final tone - * and the chime. A null `next` rests every lane `prev` had. + * a `tone` when its cursor advanced since `prev` (with `prev === null` + * meaning "any progress at all"), a `rest` when it is not running, and + * nothing at all for a running lane between ops — its last blip is left to + * ring out on its own envelope rather than being cut every frame. A lane + * that just finished gets its final tone, a `chime`, and (being stopped) a + * rest. A null `next` rests every lane `prev` had. */ export function planAudio(prev: SortingSummary | null, next: SortingSummary | null): AudioEvent[] { const events: AudioEvent[] = []; @@ -48,7 +51,7 @@ export function planAudio(prev: SortingSummary | null, next: SortingSummary | nu const advanced = l.cursor > (before?.cursor ?? 0); if (advanced && l.last_kind !== null && l.last_value !== null) { events.push({ kind: 'tone', lane, touch: l.last_kind, freq: pitchOf(l.last_value, l.size) }); - } else { + } else if (!l.running) { events.push({ kind: 'rest', lane }); } if (l.done && before !== undefined && !before.done) events.push({ kind: 'chime', lane }); @@ -214,6 +217,10 @@ export class SortingAudio { gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45); osc.connect(gain); gain.connect(this.master); + osc.onended = () => { + osc.disconnect(); + gain.disconnect(); + }; osc.start(now); osc.stop(now + 0.5); }