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);
}