diff --git a/README.md b/README.md index 78cb510..a412e24 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Math Visualizer is an interactive collection of mathematical visualizations buil > speed and array size (10–300, default 50). Share a size with `#/sorting?n=`. > Turn on **Sound** for a tone per operation (pitch rises with the value, writes ring > brighter than compares) and a chime when a lane finishes. -> - **Notes & Chords** (`#/notes`) — pick up to three notes from C4 to C5; each swings a +> - **Notes & Chords** (`#/notes`) — pick up to three notes from C3 to C5; each swings a > dot on its own bar. Two notes draw the Lissajous loop of their interval (a 3:2 closes > after two swings of the first note) and three draw a 3D curve in a turning cube. The > speed slider is the first note's swings per second (0.25–1000 Hz); **Real pitch** diff --git a/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md b/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md index 22905eb..bdab522 100644 --- a/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md +++ b/docs/superpowers/specs/2026-10-04-notes-and-chords-design.md @@ -12,7 +12,7 @@ The lab follows the existing pattern exactly: a Rust `Rule` (the oscillator mode ## Goals -1. Pick up to three notes (C4…C5) and watch each one swing a dot on its own bar: first note on the left bar (drives y), second on the top bar (drives x), third on the right bar (drives depth). +1. Pick up to three notes (C3…C5) and watch each one swing a dot on its own bar: first note on the left bar (drives y), second on the top bar (drives x), third on the right bar (drives depth). 2. Two notes draw the Lissajous figure with dotted guides from each bar's dot to the pen; three notes draw a 3D curve inside a slowly turning wireframe cube (drag to orbit). 3. A waveform strip under/beside the figure plots each note's sine and their sum with a moving playhead. 4. A "swings per second" slider from 0.25 to 1000 Hz, a "Real pitch" ramp to the first note's true frequency, and sound that fades in once the rate passes about 20 swings per second. @@ -27,7 +27,7 @@ The lab follows the existing pattern exactly: a Rust `Rule` (the oscillator mode ## Decisions -- Default note **C4**: the picker is one octave C4…C5 (13 chips), so starting at C makes every interval reachable upward and "Real pitch" lands on 262 Hz. +- Default note **C4**: the picker is two octaves C3…C5 (25 chips), so starting at the middle C makes every interval within an octave reachable up or down and "Real pitch" lands on 262 Hz. - The **root** (ratio 1, the note the speed slider counts) is the **first note picked** = the left bar, on both the Rust and JS sides. Notes picked below it get ratios < 1. - The ratio readout lists notes in **ascending pitch** ("2 : 3", "4 : 5 : 6", "4 : 5.04 : 5.99"); the per-bar labels carry each note's swing count ("C ×2", "G ×3"). - The home grid becomes 2×2 for four tiles (one column at ≤900px). @@ -56,7 +56,7 @@ The engine clock counts iterations; `speed` = iterations per second (`engine/pla ## Web (`web/src/lib/notes/*`, `components/labs/NotesLab.svelte`) -- `LabShell labId="notes" title="Notes & Chords" playback={false} zoom={false}`; bezel controls in order: Play/Pause (primary) + Reset (back to C4, pure ratios and 0.5 Hz, from the first swing, playing); "Swings per second" **log** slider (`sliderToHz(t) = 0.25·4000^t`) with mono Hz readout; "Real pitch" button ramping exponentially from the current speed to `midiToHz(notes[0])` over 2.5 s (rAF; cancelled by slider input, any transport click, root change, destroy); "Pure ratios" / "Piano" buttons (aria-pressed); the note picker = 13 chip buttons C4…C5 (`noteLabel` like "C4"; names with real ♯/♭ glyphs live in `theory.ts` — the no-emoji test scans `.svelte` files only), selected chips tinted via `--note-color` on the border/badge (never as text on paper) and numbered 1/2/3, unselected chips `aria-disabled` (dimmed, still focusable) with a title hint once 3 are picked, the pick may be empty (link `n=none`; the stage then shows a prompt and Real pitch is disabled), default C4; Sound button + volume (sorting pattern). Default speed 0.5 Hz dispatched in `onReady`; the rule config `{ notes, just_intonation }` is pushed on ready unconditionally. +- `LabShell labId="notes" title="Notes & Chords" playback={false} zoom={false}`; bezel controls in order: Play/Pause (primary) + Reset (back to C4, pure ratios and 0.5 Hz, from the first swing, playing); "Swings per second" **log** slider (`sliderToHz(t) = 0.25·4000^t`) with mono Hz readout; "Real pitch" button ramping exponentially from the current speed to `midiToHz(notes[0])` over 2.5 s (rAF; cancelled by slider input, any transport click, root change, destroy); "Pure ratios" / "Piano" buttons (aria-pressed); the note picker = 25 chip buttons C3…C5 (`noteLabel` like "C4"; names with real ♯/♭ glyphs live in `theory.ts` — the no-emoji test scans `.svelte` files only), selected chips tinted via `--note-color` on the border/badge (never as text on paper) and numbered 1/2/3, unselected chips `aria-disabled` (dimmed, still focusable) with a title hint once 3 are picked, the pick may be empty (link `n=none`; the stage then shows a prompt and Real pitch is disabled), default C4; Sound button + volume (sorting pattern). Default speed 0.5 Hz dispatched in `onReady`; the rule config `{ notes, just_intonation }` is pushed on ready unconditionally. - Overlay (pointer-events none): one `.note-label` per note at its bar end — left bar bottom `(x + 0.06s, y + 0.88s)` below, top bar right `(x + 0.88s, y + 0.06s)` to the right, right bar bottom `(x + 0.94s, y + 0.88s)` below — reading `swingLabel` ("C ×2"; piano: "G ×3.00" = period × ratio); `.ratio` at the square's bottom-centre `(x + 0.5s, y + 0.94s)` reading `ratioLabel` (ascending pitch; hidden for one note). Positions come from `notes/layout.ts` (`notesLayout(cssW, cssH)`: landscape → square at the left, strip to its right; portrait → square on top, strip below; 16 px margins; degenerate sizes → all zeros), pushed to the viz via `patchVizConfig({ figure, strip })` in device pixels from a ResizeObserver on the overlay with a window-resize fallback and once in `onReady`, exactly like the sorting lab's `pushCells`. - Audio `notes/audio.ts` (`NotesAudio`, modelled on `SortingAudio`; the shared `AudioContextLike`/`AudioContextFactory`/`defaultContextFactory`/`clamp01` move to `web/src/lib/audio/context.ts`, re-exported from `sorting/audio.ts`; the stub context helpers move to `web/src/lib/test/stubAudio.ts`): one persistent sine oscillator per note behind per-voice gains → master gain → compressor; `voicePlan(speedHz, ratios, playing, muted)`: freq = speedHz × ratio, gain = playing && !muted ? 0.18/n × clamp01((speedHz × ratio − 20)/10) : 0 (each voice gated by its own pitch); `update()` only touches an AudioParam whose target changed; `setTargetAtTime` ramps (freq 0.01 s, gain 0.02 s). Driven by a per-frame `$effect` on `api.snapshot` (speed/playing from the snapshot, ratios from the parsed summary). - URL params `#/notes?n=60,67&t=equal` (`n` sanitised to the picker range, deduped, max 3; `t=equal` only in piano mode; canonical rewrite on load). `buildQuery` keeps raw commas (`URLSearchParams` would emit `%2C`). diff --git a/web/src/lib/components/__tests__/NotesLab.test.ts b/web/src/lib/components/__tests__/NotesLab.test.ts index 10e51fe..30362bf 100644 --- a/web/src/lib/components/__tests__/NotesLab.test.ts +++ b/web/src/lib/components/__tests__/NotesLab.test.ts @@ -67,7 +67,7 @@ describe('NotesLab.svelte', () => { const badge = (chip: HTMLElement) => chip.querySelector('.badge')?.textContent; describe('shell', () => { - it('mounts with its own transport and a 13-note picker with C4 picked, and no shell playback or zoom', async () => { + it('mounts with its own transport and a 25-note picker with C4 picked, and no shell playback or zoom', async () => { const { container, getByRole } = await renderLab(); expect(getByRole('heading', { level: 1 }).textContent).toBe('Notes & Chords'); expect(container.querySelector('.readout')).toBeNull(); @@ -75,12 +75,17 @@ describe('NotesLab.svelte', () => { expect(container.querySelector('.zoom')).toBeNull(); for (const name of ['Play', 'Reset']) expect(getByRole('button', { name }).querySelector('svg')).toBeTruthy(); - // One chip per note, C4 to C5 in order, each named by its note. + // One chip per note, C3 to C5 in order, each named by its note. const chips = [...container.querySelectorAll('.chip')]; - expect(chips).toHaveLength(13); + expect(chips).toHaveLength(25); expect(PICKER_RANGE.map((midi) => chips.indexOf(chipFor(getByRole, midi)))).toEqual(PICKER_RANGE.map((_, i) => i)); expect(chipFor(getByRole, 60).getAttribute('aria-pressed')).toBe('true'); - expect(chips.filter((c) => c.getAttribute('aria-pressed') === 'false')).toHaveLength(12); + expect(chips.filter((c) => c.getAttribute('aria-pressed') === 'false')).toHaveLength(24); + + // The picker breaks its rows at the octave: C3 to B3, then C4 to C5. + const breaks = container.querySelectorAll('.picker .octave-break'); + expect(breaks).toHaveLength(1); + expect(breaks[0].nextElementSibling).toBe(chipFor(getByRole, 60)); }); it('on ready sets 0.5 swings per second and pushes C4 in pure ratios, then plays', async () => { @@ -115,7 +120,7 @@ describe('NotesLab.svelte', () => { expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67] })); const unpicked = [...container.querySelectorAll('.chip[aria-pressed="false"]')]; - expect(unpicked).toHaveLength(10); + expect(unpicked).toHaveLength(22); for (const chip of unpicked) { expect(chip.getAttribute('aria-disabled')).toBe('true'); expect(chip.disabled).toBe(false); // still in the tab order, so the hint can be reached @@ -480,6 +485,7 @@ describe('NotesLab.svelte', () => { ['n=none', '#/notes?n=none', []], ['n=60,61,62,63', '#/notes?n=60,61,62', [60, 61, 62]], ['n=69,76,81', '#/notes?n=69', [69]], + ['n=47,48,60', '#/notes?n=48,60', [48, 60]], ])('rewrites ?%s to the pick that loaded (%s)', async (query, hash, notes) => { await renderLab(query); expect(updateRuleConfigSpy).toHaveBeenCalledWith(expect.objectContaining({ notes })); diff --git a/web/src/lib/components/labs/NotesLab.svelte b/web/src/lib/components/labs/NotesLab.svelte index b45a650..16d0f61 100644 --- a/web/src/lib/components/labs/NotesLab.svelte +++ b/web/src/lib/components/labs/NotesLab.svelte @@ -21,7 +21,7 @@ import { route, replaceQuery } from '../../router.svelte'; import { buildQuery } from '../../router'; - /** C4, middle C: from here every interval in the picker's octave is reachable upward. */ + /** C4, middle C: the middle of the picker, so every interval in an octave is reachable up or down. */ const DEFAULT_NOTE = 60; /** @@ -320,8 +320,10 @@
- {#each PICKER_RANGE as midi (midi)} + {#each PICKER_RANGE as midi, i (midi)} {@const order = notes.indexOf(midi)} + + {#if i > 0 && i < PICKER_RANGE.length - 1 && midi % 12 === 0}{/if} {@const selected = order !== -1}