Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<size>`.
> 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**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -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`).
Expand Down
16 changes: 11 additions & 5 deletions web/src/lib/components/__tests__/NotesLab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,25 @@ 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();
expect(container.querySelector('.clock')).toBeNull();
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<HTMLButtonElement>('.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 () => {
Expand Down Expand Up @@ -115,7 +120,7 @@ describe('NotesLab.svelte', () => {
expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67] }));

const unpicked = [...container.querySelectorAll<HTMLButtonElement>('.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
Expand Down Expand Up @@ -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 }));
Expand Down
9 changes: 6 additions & 3 deletions web/src/lib/components/labs/NotesLab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -320,8 +320,10 @@
<button class="btn" aria-pressed={!just} onclick={() => setTuning(false)} title="Equal steps, as a piano is tuned: every ratio is rounded a little, so the shape drifts">Piano</button>
</div>
<div class="group picker" role="group" aria-label="Notes">
{#each PICKER_RANGE as midi (midi)}
{#each PICKER_RANGE as midi, i (midi)}
{@const order = notes.indexOf(midi)}
<!-- Each octave starts a row of its own; the top C stays with the octave below it. -->
{#if i > 0 && i < PICKER_RANGE.length - 1 && midi % 12 === 0}<span class="octave-break" aria-hidden="true"></span>{/if}
{@const selected = order !== -1}
<button
class="btn chip"
Expand Down Expand Up @@ -377,7 +379,7 @@
which rounds every ratio off a little, so its shapes never quite sit still.
</p>
<p class="tip">
<em>Pick up to three notes</em> from C4 to C5. The first one you pick is the root: it swings on the left bar, and the
<em>Pick up to three notes</em> from C3 to C5. The first one you pick is the root: it swings on the left bar, and the
speed counts its swings. A third note adds depth, and the curve turns inside a cube; drag the cube to look around it.
</p>
<p class="tip">
Expand Down Expand Up @@ -446,6 +448,7 @@

/* The note picker. A picked chip wears its bar's colour on the border and
on its order badge, a dark pill, so the colour is never text on paper. */
.picker .octave-break { flex-basis: 100%; height: 0; }
.picker .btn.chip { position: relative; min-width: 3.25rem; justify-content: center; padding: 0 10px; }
.btn.chip[aria-pressed="true"] { border-color: var(--note-color); box-shadow: inset 0 0 0 1px var(--note-color); }
/* A full picker marks the other chips unavailable rather than disabling
Expand Down
6 changes: 3 additions & 3 deletions web/src/lib/notes/__tests__/picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('toggleNote', () => {

it('refuses a midi number outside the picker, handing back the very same array', () => {
const one = [60];
expect(toggleNote(one, 59)).toBe(one);
expect(toggleNote(one, 47)).toBe(one);
expect(toggleNote(one, 73)).toBe(one);
expect(toggleNote(one, 61.5)).toBe(one);
expect(toggleNote(one, NaN)).toBe(one);
Expand All @@ -55,7 +55,7 @@ describe('toggleNote', () => {

describe('sanitizeNotes', () => {
it('keeps integers within the picker, drops duplicates (first wins) and stops at three', () => {
expect(sanitizeNotes([60, 60, 67, 999, 61.5, NaN, 72, 64])).toEqual([60, 67, 72]);
expect(sanitizeNotes([60, 60, 48, 999, 61.5, NaN, 72, 64])).toEqual([60, 48, 72]);
});

it('keeps the order it is given', () => {
Expand All @@ -64,7 +64,7 @@ describe('sanitizeNotes', () => {

it('may come back empty', () => {
expect(sanitizeNotes([])).toEqual([]);
expect(sanitizeNotes([999, 59, 73])).toEqual([]);
expect(sanitizeNotes([999, 47, 73])).toEqual([]);
});

it('returns a fresh array', () => {
Expand Down
11 changes: 6 additions & 5 deletions web/src/lib/notes/__tests__/theory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('noteName', () => {

describe('noteLabel', () => {
it('adds the octave, with middle C (midi 60) in octave 4', () => {
expect(noteLabel(48)).toBe('C3');
expect(noteLabel(60)).toBe('C4');
expect(noteLabel(72)).toBe('C5');
expect(noteLabel(69)).toBe('A4');
Expand Down Expand Up @@ -124,11 +125,11 @@ describe('etRatio', () => {
});

describe('PICKER_RANGE', () => {
it('is the thirteen semitones from C4 to C5, midi 60 through 72', () => {
expect(PICKER_RANGE).toHaveLength(13);
expect(PICKER_RANGE[0]).toBe(60);
expect(PICKER_RANGE[12]).toBe(72);
PICKER_RANGE.forEach((m, i) => expect(m).toBe(60 + i));
it('is the twenty-five semitones from C3 to C5, midi 48 through 72', () => {
expect(PICKER_RANGE).toHaveLength(25);
expect(PICKER_RANGE[0]).toBe(48);
expect(PICKER_RANGE[24]).toBe(72);
PICKER_RANGE.forEach((m, i) => expect(m).toBe(48 + i));
});
});

Expand Down
4 changes: 2 additions & 2 deletions web/src/lib/notes/theory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export function etRatio(semitones: number): number {
return 2 ** (semitones / 12);
}

/** The picker's chips: one octave, C4 to C5 (midi 60 through 72). */
export const PICKER_RANGE: readonly number[] = Array.from({ length: 13 }, (_, i) => 60 + i);
/** The picker's chips: two octaves, C3 to C5 (midi 48 through 72). */
export const PICKER_RANGE: readonly number[] = Array.from({ length: 25 }, (_, i) => 48 + i);

/**
* The viz colours as hex, for the legend, chips and overlay labels. The Rust
Expand Down
Loading