From 12175aed1643f8e32366931d87ce83b6dff115ce Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Mon, 12 Oct 2026 20:38:38 -0400 Subject: [PATCH 01/12] docs(spec): four notes and the tesseract for the Notes & Chords lab --- .../2026-10-12-notes-tesseract-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-12-notes-tesseract-design.md diff --git a/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md new file mode 100644 index 0000000..b18e4e8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md @@ -0,0 +1,114 @@ +# Notes & Chords: four notes and the tesseract — Design + +**Date:** 2026-10-12 +**Status:** Draft for review +**Scope:** extend the Notes & Chords lab (`#/notes`) from three notes to four: the Rust rule and visualization in `crates/viz-core`, one small engine/trait addition, the Svelte lab and its helpers, docs. + +## Summary + +Today one note swings a dot on a bar, two draw a Lissajous loop and three draw a curve inside a turning cube. This adds a fourth note. It gets a fourth bar (bottom gutter) and becomes the fourth coordinate, w, of the curve: the cube becomes a tesseract (16 corners, 32 edges, the cube-inside-a-cube picture), turned through the fourth dimension and squashed back to 3D with perspective, then drawn by the existing 3D pipeline. A pure 4 : 5 : 6 : 7 chord (a dominant seventh) closes after four swings of the root. + +Four notes come from single-note mode (up to four picks) and from five new seventh chords in chord mode. + +## Goals + +1. Pick up to four notes in single-note mode; the fourth gets its own colour, bar, badge "4", legend swatch and voice. +2. Chord mode offers, besides the six triads, Dom7, Maj7, Min7, Half-dim7 and Dim7. +3. With four notes the figure is a tesseract frame with the curve inside it, turning through 4D on its own; the 3D spin and drag-to-orbit work as now. +4. A **4D turn** slider shows and sets the 4D angle; a **Turn** toggle starts and stops the automatic turn. +5. One to three notes look and behave exactly as today, apart from the ratio readout's new position. + +## Non-goals + +- Storing the 4D angle or the Turn state in the share link. +- Five or more notes; extended chords (9ths, 11ths), inversions or voicings. +- GPU-side 4D maths (approach B) or orthographic 4D (approach C); see Decisions. +- Any change to the other labs' behaviour. + +## Decisions + +- **Approach A: 4D maths on the CPU in Rust, drawn by the existing 3D pipeline.** At most `max_samples` trail points plus 32 edges per frame, so the cost is negligible; the maths stays in pure, unit-testable functions; no new shader. +- **Axes:** (x, y, z, w) = (note 2, root, note 3, note 4), each displacement × `CUBE_HALF`. Notes 1–3 keep today's axes. +- **The turn:** one angle θ applied as a rotation in the x–w plane and then in the z–w plane, both by θ. Periodic in 360°, so one slider covers it. At θ = 90° the fourth note lies along x (the second note's old axis). +- **Perspective 4D → 3D:** `p = D / (D − w′)` scales x′, y′, z′, with `D = 3 · CUBE_HALF`, then a fixed fit factor brings the result back inside the current cube's bounding sphere (radius `√3 · CUBE_HALF`) for every θ, so the camera framing (`CAMERA_DISTANCE`) is unchanged. The fit factor is a named constant derived from the worst case and pinned by a test. +- **Who owns the angle:** the visualization. `update_viz_config` re-runs `init` and zeroes the next frame's dt, so the page cannot push the angle every frame without freezing the cube's spin. The viz advances the angle in `tick` and the page reads it back through a new readout (below). +- **Fourth colour:** a soft blue, default `#7FA8E8`, final value tuned against the dark stage in the mockup step; Rust's default is the truth and JS `NOTE_COLORS` copies it. +- **Fourth bar:** in the bottom gutter, on its midline, running left → right (mirroring the top bar). +- **Ratio readout:** moves to the strip's top-left corner (the dark pill it already has), for every pick size, because there is no free space below the figure (it fills the stage height in landscape, and the strip sits right under it in portrait). With no strip (a stage too small for the layout), it stays at the figure's bottom-centre. +- **Chord picker:** still one radio group with one shared `name` (arrow keys run through all 12). Row 1: Single notes and the six triads; row 2, after a small "Sevenths" label: the five sevenths. + +## Rust + +### Rule (`crates/viz-core/src/rules/notes.rs`) + +- `MAX_NOTES` 3 → 4; the schema's `maxItems` follows it; `effective_notes()` keeps at most four. +- Ratios, `period_cycles`, the summary and the trail sampling are already per-note: no other change. New tests: four notes are kept and a fifth dropped; `[60, 64, 67, 70]` in pure ratios is 4 : 5 : 6 : 7 with a period of 4 root cycles; the summary lists four notes. + +### Trait and engine (shared) + +- `Visualization` gains `fn readout(&self) -> serde_json::Value`, default `Value::Null`, documented as cheap and read every frame. The erased wrapper forwards it. +- `Engine` exposes `viz_readout() -> JsValue`. +- No other visualization implements it, so the other labs are untouched. + +### Visualization (`crates/viz-core/src/visualizations/notes.rs`) + +- `NotesVizConfig.note_colors: [[f32; 4]; 4]`; the fourth default is the soft blue. Schema and defaults updated. +- New config fields, each `#[serde(default)]`: + - `turn_4d: bool` (default `true`): whether `tick` advances the angle. + - `turn_4d_speed: f32` (radians per second, default `0.35`). + - `angle_4d: f32` (radians, default `0`): taken by `set_config`/`init` **only when `turn_4d` is false**, so the slider can hold an angle and no other config push (layout, resize) snaps a turning figure back. +- `NotesViz` keeps `angle_4d` state; `tick` adds `turn_4d_speed · dt` while turning and wraps it into [0, 2π). +- `readout()` returns `{ "angle_4d": θ }` (radians, in [0, 2π)). +- `Layout.bars: [Bar; 4]`; the fourth bar is on the bottom gutter's midline, spanning the plot left → right. `frame.n_notes` is capped by `layout.bars.len()` (now 4). +- `render`: four notes → `draw_figure_4d`; three → `draw_figure_3d`; one or two → `draw_figure_2d`, as now. +- `draw_figure_4d` mirrors `draw_figure_3d` (same viewport, camera, auto azimuth and drag offset, line and point batches): tesseract edges in `axis_color`, the trail in `trail_color`, the pen with the smear fade, each point through `project_4d(rotate_4d(p, θ))`. +- Pure helpers, all unit-tested: + - `rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4]` + - `project_4d(p: [f32; 4]) -> [f32; 3]` (perspective and the fit factor) + - `tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32]` +- `draw_bars` and `draw_strip` already loop over the notes in play; they pick up the fourth with no change beyond the colour array. + +## Web + +### Helpers (`web/src/lib/notes/`) + +- `theory.ts`: `NOTE_COLORS.bars` gets the fourth colour. +- `picker.ts`: `MAX_NOTES = 4` (toggle lock-out, `sanitizeNotes`, link parsing). +- `chords.ts`: `Chord` gains `group: 'triad' | 'seventh'` and `steps` of length 3 or 4. New entries, in this order after the triads: `dom7` "Dom7" [0, 4, 7, 10], `maj7` "Maj7" [0, 4, 7, 11], `min7` "Min7" [0, 3, 7, 10], `halfdim7` "Half-dim7" [0, 3, 6, 10], `dim7` "Dim7" [0, 3, 6, 9]. `chordOn` and the B5 limit already work for any length (Maj7's highest root is C5). +- `layout.ts`: `labelAnchors(figure, strip)` returns four bar anchors (the fourth at the bottom bar's right end, placed beside it like the top bar's) and the ratio anchor at the strip's top-left corner, or the figure's bottom-centre when the strip is empty. +- `audio.ts`: no change (`VOICE_LEVEL / n`); a four-voice test. + +### Lab API (`web/src/lib/components/labApi.svelte.ts`) + +- `readVizReadout(): unknown` wraps `engine.viz_readout()`, `null` without an engine. The test fake engine (`test/fakeViz.ts`) gains `viz_readout`. + +### Lab page (`components/labs/NotesLab.svelte`) + +- Chord radios in two rows as described under Decisions; behaviour unchanged (a chord re-voices the current root; Single notes keeps the pick; a root change carries the speed). +- 4D controls, rendered only when four notes are picked: + - **4D turn** slider, 0–359 whole degrees, with a mono degree readout. + - **Turn** toggle, `aria-pressed` while turning. + - The per-frame effect reads `api.readVizReadout()` and, while turning, moves the slider to the reported angle. + - Slider input → `patchVizConfig({ turn_4d: false, angle_4d })` and Turn shows off. + - Turn pressed → `patchVizConfig({ turn_4d: true })` (resumes from the held angle); pressed again → `{ turn_4d: false, angle_4d: current }`. + - Reset → turn on, angle 0. +- Overlay: four swing labels; the ratio readout at its new anchor; the legend gets a fourth swatch. +- Tips: four notes, the tesseract and the 4D controls; the link tip mentions `c=dom7`. + +## Docs + +- README Notes & Chords bullet: up to four notes, sevenths, the tesseract. +- `2026-10-04-notes-and-chords-design.md`: point to this spec for the four-note extension. + +## Mockup before building + +A static mockup (headless capture) of the bezel's two chord rows and the 4D controls, and of the stage with four notes (the fourth bar, the tesseract and the ratio pill in the strip's corner), in light and dark. The look is approved before implementation starts. + +## Testing + +- **Rust unit tests:** `rotate_4d` is periodic in 2π and the identity at 0; at θ = π/2 the input's w lands on the x axis (x′ = −w); `project_4d` shrinks points with larger w′; every projected tesseract corner stays within `√3 · CUBE_HALF` for θ sampled over a full turn; 32 edges, each joining corners that differ in one coordinate; the fourth bar's position; config defaults round-trip and the schema lists the new fields; `tick` advances the angle while turning and not while held; `set_config` takes `angle_4d` only when held; `readout()` shape. +- **Rule tests:** as listed under Rule. +- **wasm test:** four notes render without error and `viz_readout()` returns an angle. +- **Web unit tests:** the seventh chord table and limits; four-note picker and link rules; four label anchors; the ratio anchor in the strip corner and its fallback; four-voice audio levels. +- **Lab tests:** 12 radios in one group in the stated order; a seventh built on a press; the 4D controls present only with four notes; slider input and Turn pushing the stated viz configs; the slider following the readout; Reset; links with four notes and with `c=dom7`. +- **Validation:** `cargo fmt --all --check`, `cargo clippy --all-targets --all-features`, `cargo test --all-features`, the wasm tests, `vitest`, `svelte-check`; then a browser check of the deployed site. From f5b127d92f6d53906370c20f4bd57464183dadc4 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Mon, 12 Oct 2026 20:50:14 -0400 Subject: [PATCH 02/12] docs(spec): a pure dominant seventh is 20:25:30:36 in the lab's 5-limit table --- docs/superpowers/specs/2026-10-12-notes-tesseract-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md index b18e4e8..68e6be7 100644 --- a/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md +++ b/docs/superpowers/specs/2026-10-12-notes-tesseract-design.md @@ -6,7 +6,7 @@ ## Summary -Today one note swings a dot on a bar, two draw a Lissajous loop and three draw a curve inside a turning cube. This adds a fourth note. It gets a fourth bar (bottom gutter) and becomes the fourth coordinate, w, of the curve: the cube becomes a tesseract (16 corners, 32 edges, the cube-inside-a-cube picture), turned through the fourth dimension and squashed back to 3D with perspective, then drawn by the existing 3D pipeline. A pure 4 : 5 : 6 : 7 chord (a dominant seventh) closes after four swings of the root. +Today one note swings a dot on a bar, two draw a Lissajous loop and three draw a curve inside a turning cube. This adds a fourth note. It gets a fourth bar (bottom gutter) and becomes the fourth coordinate, w, of the curve: the cube becomes a tesseract (16 corners, 32 edges, the cube-inside-a-cube picture), turned through the fourth dimension and squashed back to 3D with perspective, then drawn by the existing 3D pipeline. In pure ratios a dominant seventh (C, E, G, B♭) is 20 : 25 : 30 : 36, because the lab's 5-limit table makes the minor seventh 9/5; it closes after 20 swings of the root (the trail shows the last 64 at most, as now). Four notes come from single-note mode (up to four picks) and from five new seventh chords in chord mode. @@ -42,7 +42,7 @@ Four notes come from single-note mode (up to four picks) and from five new seven ### Rule (`crates/viz-core/src/rules/notes.rs`) - `MAX_NOTES` 3 → 4; the schema's `maxItems` follows it; `effective_notes()` keeps at most four. -- Ratios, `period_cycles`, the summary and the trail sampling are already per-note: no other change. New tests: four notes are kept and a fifth dropped; `[60, 64, 67, 70]` in pure ratios is 4 : 5 : 6 : 7 with a period of 4 root cycles; the summary lists four notes. +- Ratios, `period_cycles`, the summary and the trail sampling are already per-note: no other change. New tests: four notes are kept and a fifth dropped; `[60, 64, 67, 70]` in pure ratios is 1, 5/4, 3/2, 9/5 (20 : 25 : 30 : 36) with a period of 20 root cycles; the summary lists four notes. ### Trait and engine (shared) From 7761e19b5b603c4bd17ef41608e49b8314a7efe2 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Mon, 12 Oct 2026 20:54:14 -0400 Subject: [PATCH 03/12] docs(plan): four notes and the tesseract --- .../plans/2026-10-12-notes-tesseract.md | 1207 +++++++++++++++++ 1 file changed, 1207 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-12-notes-tesseract.md diff --git a/docs/superpowers/plans/2026-10-12-notes-tesseract.md b/docs/superpowers/plans/2026-10-12-notes-tesseract.md new file mode 100644 index 0000000..93dc2f2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-notes-tesseract.md @@ -0,0 +1,1207 @@ +# Notes & Chords: Four Notes and the Tesseract — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the Notes & Chords lab play four notes (single picks or seventh chords) and draw them as a curve inside a tesseract that turns through the fourth dimension. + +**Architecture:** The Rust rule accepts four notes; the Rust visualization gains a fourth bar, pure 4D helpers (`rotate_4d`, `project_4d`, `tesseract_edges`) and a `draw_figure_4d` that feeds the existing 3D line/point batches and camera. The viz owns the 4D angle (advanced in `tick`) and exposes it through a new `Visualization::readout()` → `Engine::viz_readout()`; the Svelte lab reads it every frame for its slider and pushes `turn_4d` / `angle_4d` through the viz config only on user input. + +**Tech Stack:** Rust (wasm-bindgen, serde, WebGL2 via `web-sys`), Svelte 5 + TypeScript, Vitest + Testing Library, wasm-pack tests in headless Chrome. + +**Spec:** `docs/superpowers/specs/2026-10-12-notes-tesseract-design.md` + +## Global Constraints + +- Branch: `notes-tesseract` (the spec is already committed there). `main` is PR-only; nothing is pushed until the final PR. +- Validation (AGENTS.md): `cargo fmt --all --check`, `cargo clippy --all-targets --all-features`, `cargo test --all-features`; web: `cd web && npx vitest run` and `npx svelte-check`; wasm: `wasm-pack test --chrome --headless --chromedriver= crates/viz-core` (flag **before** the crate path; never concurrently with another cargo build). +- Axes: (x, y, z, w) = (note 2, root, note 3, note 4), each displacement × `CUBE_HALF` (0.5). +- 4D turn: rotate in the x–w plane, then the z–w plane, both by θ. Perspective `p = D / (D − w′)` with `D = 3 · CUBE_HALF`, then × `FIT_4D = 0.645` (just under √(5/12) ≈ 0.6455, the worst case over a full turn), so the projected tesseract stays within radius `√3 · CUBE_HALF`. +- Fourth note colour: `#7FA8E8` = `[0.498, 0.659, 0.910, 1.0]` (Rust default is the truth; JS `NOTE_COLORS` copies it). If the mockup step changes it, change both places. +- Viz config fields: `turn_4d: bool` (default `true`), `turn_4d_speed: f32` (rad/s, default `0.35`), `angle_4d: f32` (rad, default `0`), all `#[serde(default)]`. `angle_4d` is taken only when `turn_4d` is false. +- Readout shape: `{ "angle_4d": }`; every other visualization returns `null`. +- Seventh chord ids, labels and steps, in this order after the triads: `dom7` "Dom7" [0,4,7,10], `maj7` "Maj7" [0,4,7,11], `min7` "Min7" [0,3,7,10], `halfdim7` "Half-dim7" [0,3,6,10], `dim7` "Dim7" [0,3,6,9]. +- Picker range stays C3–B5 (midi 48–83). `MAX_NOTES = 4` on both sides. +- Copy: sentence case, no emoji, ♯/♭ glyphs only in `.ts` files (a repo test scans `.svelte` files). +- Match the surrounding code: doc comments on every pub item in Rust, JSDoc on exported TS, comment density like the neighbours. + +## Review Focus + +1. **A layout or resize push while the 4D figure is turning** must not snap the angle back to the stale `angle_4d` left in the stored viz config → Task 4 tests `apply_turn_config` with `turn_4d: true` and a non-zero `angle_4d`. +2. **Dropping from four notes to three (or Reset) while the turn is held** must leave the 3D cube exactly as before and the controls hidden; picking four again shows the held angle → Task 8 test "hides the 4D controls below four notes and keeps the held angle". +3. **A seventh chord near the top** (Maj7 on C♯5 needs B♯5 = 84) must grey the root out, not build a three-note chord → Task 5 `chordOn(73, 'maj7')` is undefined, and Task 8 checks C♯5 is `aria-disabled` under Maj7. +4. **A link with five notes or `c=dom7` plus three notes** loads the first four notes and the chord mode without re-voicing → Task 5 `parseNotesParam('60,64,67,70,72')` and Task 8 link test. +5. **Interior trail points, not just corners, staying inside the camera view** at every θ → Task 3 samples random interior points as well as corners. + +--- + +## Task 0: Mockup and look approval (controller only, no code) + +**Files:** scratchpad only (nothing committed). + +- [ ] **Step 1:** Build a static HTML mockup in the session scratchpad of (a) the bezel: Pick label, row 1 radios (Single notes … Sus4), a small "Sevenths" label and row 2 (Dom7 … Dim7), and the 4D controls ("4D turn" slider with a `123°` mono readout and a "Turn" toggle) styled like the existing pills; (b) the stage at 1168×650: figure square with four bars (fourth in `#7FA8E8` in the bottom gutter), a tesseract wireframe with a curve, and the ratio pill `20 : 25 : 30 : 36` in the strip's top-left corner. Light and dark. +- [ ] **Step 2:** Capture with the headless capture script used for the redesign and show the user. Record the approved fourth colour; if it differs from `#7FA8E8`, update Global Constraints before dispatching Tasks 4 and 5. + +--- + +## Task 1: Rule accepts four notes + +**Files:** +- Modify: `crates/viz-core/src/rules/notes.rs` (`MAX_NOTES` near line 32; tests module) + +**Interfaces:** +- Produces: `pub const MAX_NOTES: usize = 4;` (schema `maxItems` already reads it). + +- [ ] **Step 1: Write the failing tests** (in the rule's `mod tests`, next to `config_parses_with_missing_fields`) + +```rust + #[test] + fn keeps_four_notes_and_drops_a_fifth() { + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70, 72], + ..NotesConfig::default() + }; + assert_eq!(cfg.effective_notes(), vec![60, 64, 67, 70]); + assert_eq!(NotesConfig::schema()["properties"]["notes"]["maxItems"], 4); + } + + #[test] + fn a_pure_dominant_seventh_closes_after_twenty_root_swings() { + // C E G B♭: 1, 5/4, 3/2, 9/5 in the 5-limit table, so 20 : 25 : 30 : 36. + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70], + ..NotesConfig::default() + }; + let state = Notes.init(&cfg, 0); + assert_eq!(state.notes.len(), 4); + assert_eq!(state.period, 20); + let summary = Notes.summary(&state); + let notes = summary["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 4); + assert_eq!((notes[3]["num"].as_u64(), notes[3]["den"].as_u64()), (Some(9), Some(5))); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib rules::notes -- four_notes dominant_seventh` +Expected: FAIL (`effective_notes` returns three notes; `maxItems` is 3; `state.notes.len()` is 3). + +- [ ] **Step 3: Implement** + +Change `pub const MAX_NOTES: usize = 3;` to `pub const MAX_NOTES: usize = 4;` and update its doc comment to say "one per bar: left, top, right, bottom". Search the file for doc comments that say "three" or "at most 3" about the note count and update them. + +- [ ] **Step 4: Run the rule tests** + +Run: `cargo test -p viz-core --lib rules::notes` +Expected: all PASS. If an existing test asserted a three-note cap (e.g. a fourth note dropped), change it to a fifth note being dropped. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/rules/notes.rs +git commit -m "feat(rule): notes accepts four notes; a pure dominant seventh closes after 20 swings" +``` + +--- + +## Task 2: Visualization readout through the engine + +**Files:** +- Modify: `crates/viz-core/src/traits.rs` (`Visualization` trait, after `set_zoom`) +- Modify: `crates/viz-core/src/engine/erased.rs` (`ErasedVisualization` trait and `TypedViz` impl) +- Modify: `crates/viz-core/src/engine/mod.rs` (next to `rule_summary`, ~line 194) +- Test: `crates/viz-core/src/traits.rs` (new `mod tests`) + +**Interfaces:** +- Produces: `Visualization::readout(&self) -> serde_json::Value` (default `Value::Null`); `ErasedVisualization::readout(&self) -> Value`; `Engine::viz_readout(&self) -> JsValue` (wasm-bindgen, JS name `viz_readout`). + +- [ ] **Step 1: Write the failing test** (the erased wrapper's forwarding is pinned in Task 4, once `NotesViz` has a readout). Add to `traits.rs` tests (create `#[cfg(test)] mod tests` at the end of `traits.rs` if none exists) a minimal visualization: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + struct Bare; + impl Visualization for Bare { + type State = crate::rules::notes::NotesState; + type Config = crate::visualizations::notes::NotesVizConfig; + fn id(&self) -> &'static str { + "bare" + } + fn init(&mut self, _gl: &WebGl2RenderingContext, _cfg: &Self::Config) {} + fn render(&mut self, _gl: &WebGl2RenderingContext, _s: &Self::State, _c: &Self::Config) {} + } + + #[test] + fn readout_defaults_to_null() { + assert_eq!(Bare.readout(), serde_json::Value::Null); + } +} +``` + +(Use the import paths that compile in this crate; `NotesState` / `NotesVizConfig` are just convenient existing types that satisfy the bounds.) + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib readout` +Expected: FAIL to compile (`no method named readout` on `Bare`). + +- [ ] **Step 3: Implement** + +`traits.rs`, inside `pub trait Visualization`, after `set_zoom`: + +```rust + /// Optional read-only snapshot of view state the UI mirrors every frame + /// (e.g. the notes lab's 4D angle, which the viz advances in `tick`). + /// Keep it cheap. Default: `null`. + fn readout(&self) -> serde_json::Value { + serde_json::Value::Null + } +``` + +`erased.rs`: add `fn readout(&self) -> Value;` to `ErasedVisualization` (after `set_zoom`, with a one-line doc pointing at `Visualization::readout`), and in `impl ErasedVisualization for TypedViz`: + +```rust + fn readout(&self) -> Value { + self.viz.readout() + } +``` + +`engine/mod.rs`, after `rule_summary`: + +```rust + /// The active visualization's `readout()` (view state the UI mirrors, + /// such as the notes lab's 4D angle); `null` for vizzes without one. + pub fn viz_readout(&self) -> JsValue { + to_js(&self.viz.readout()) + } +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p viz-core --lib` then `cargo clippy --all-targets --all-features` +Expected: PASS, no new warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/traits.rs crates/viz-core/src/engine/erased.rs crates/viz-core/src/engine/mod.rs +git commit -m "feat(engine): visualizations can expose a readout, read with Engine::viz_readout" +``` + +--- + +## Task 3: Pure 4D helpers + +**Files:** +- Modify: `crates/viz-core/src/visualizations/notes.rs` (new section after `cube_edges`; tests module) + +**Interfaces:** +- Produces (all `pub`, in `visualizations::notes`): + - `pub const FIT_4D: f32 = 0.645;` + - `pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF;` (the `D` above) + - `pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4]` + - `pub fn project_4d(p: [f32; 4]) -> [f32; 3]` + - `pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32]` + +- [ ] **Step 1: Write the failing tests** (in `mod tests`) + +```rust + fn near4(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + fn len3(p: [f32; 3]) -> f32 { + (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt() + } + + #[test] + fn rotate_4d_is_the_identity_at_zero_and_periodic() { + let p = [0.1, -0.2, 0.3, 0.4]; + assert!(near4(rotate_4d(p, 0.0), p)); + assert!(near4(rotate_4d(p, std::f32::consts::TAU), p)); + assert!(near4(rotate_4d(p, 1.0 + std::f32::consts::TAU), rotate_4d(p, 1.0))); + } + + #[test] + fn rotate_4d_keeps_y_and_brings_w_onto_x_at_a_quarter_turn() { + let q = rotate_4d([0.0, 0.25, 0.0, 0.5], std::f32::consts::FRAC_PI_2); + assert!((q[0] + 0.5).abs() < 1e-5, "x′ = −w, got {q:?}"); + assert!((q[1] - 0.25).abs() < 1e-6, "y is untouched"); + } + + #[test] + fn rotate_4d_preserves_length() { + let p = [0.3, -0.1, 0.2, -0.4]; + let l = |v: [f32; 4]| v.iter().map(|c| c * c).sum::(); + assert!((l(rotate_4d(p, 0.7)) - l(p)).abs() < 1e-5); + } + + #[test] + fn project_4d_shrinks_points_further_along_w() { + let near = project_4d([0.3, 0.3, 0.3, -0.4]); + let far = project_4d([0.3, 0.3, 0.3, 0.4]); + // w toward the viewer (larger w) scales up; away scales down. + assert!(len3(far) > len3(near)); + assert!((project_4d([0.0; 4])[0]).abs() < 1e-7); + } + + #[test] + fn the_turning_tesseract_stays_inside_the_cube_bounds() { + let limit = 3f32.sqrt() * CUBE_HALF + 1e-4; + let mut worst = 0f32; + for k in 0u32..720 { + let theta = std::f32::consts::TAU * k as f32 / 720.0; + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + // Interior points (a trail lives inside the hypercube) too. + for i in 0u32..16 { + let f = |j: u32| ((i * 7 + j * 13 + k) % 17) as f32 / 16.0 * 2.0 - 1.0; + let p = [f(1), f(2), f(3), f(4)].map(|c| c * CUBE_HALF); + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + assert!(worst <= limit, "worst radius {worst} > {limit}"); + assert!(worst > 0.97 * limit, "FIT_4D wastes the frame: {worst}"); + } + + #[test] + fn tesseract_has_32_edges_each_along_one_axis() { + let edges = tesseract_edges(0.5); + assert_eq!(edges.len(), 32); + for [a, b] in edges { + let diff = (0..4).filter(|&i| (a[i] - b[i]).abs() > 1e-6).count(); + assert_eq!(diff, 1, "{a:?} → {b:?}"); + assert!(a.iter().chain(&b).all(|c| (c.abs() - 0.5).abs() < 1e-6)); + } + // No edge appears twice. + let mut keys: Vec = edges.iter().map(|e| format!("{e:?}")).collect(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), 32); + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib visualizations::notes -- rotate_4d project_4d tesseract` +Expected: FAIL to compile (functions missing). + +- [ ] **Step 3: Implement** (after `cube_edges`) + +```rust +// ---- The fourth dimension ---- + +/// The 4D eye's distance along w: perspective scales a point by +/// `D / (D − w)`, so the far cube of a tesseract shrinks inside the near one. +pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF; +/// Brings the perspective-projected tesseract back inside the 3D cube's +/// bounding sphere (radius `√3 · CUBE_HALF`) at every turn, so the camera +/// framing is unchanged. The worst case over a full turn is √(5/12) ≈ 0.6455; +/// pinned by `the_turning_tesseract_stays_inside_the_cube_bounds`. +pub const FIT_4D: f32 = 0.645; + +/// Turns `p` = (x, y, z, w) by `theta` in the x–w plane and then in the z–w +/// plane. Periodic in 2π, so one slider covers every view; y (the root) is +/// never touched. At a quarter turn the fourth note lies along x. +pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4] { + let (s, c) = theta.sin_cos(); + let [x, y, z, w] = p; + let (x, w) = (x * c - w * s, x * s + w * c); + let (z, w) = (z * c - w * s, z * s + w * c); + [x, y, z, w] +} + +/// Perspective from 4D to 3D along w, then `FIT_4D`: larger w (nearer the 4D +/// eye) draws larger. +pub fn project_4d(p: [f32; 4]) -> [f32; 3] { + let scale = FIT_4D * PERSPECTIVE_4D / (PERSPECTIVE_4D - p[3]); + [p[0] * scale, p[1] * scale, p[2] * scale] +} + +/// The tesseract `[-half, half]⁴` as its 32 edges: each joins two corners +/// that differ in exactly one coordinate. +pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32] { + let corner = |bits: u32| -> [f32; 4] { + std::array::from_fn(|axis| if bits & (1 << axis) != 0 { half } else { -half }) + }; + let mut out = [[[0.0; 4]; 2]; 32]; + let mut i = 0; + for bits in 0..16u32 { + for axis in 0..4 { + let other = bits ^ (1 << axis); + if other > bits { + out[i] = [corner(bits), corner(other)]; + i += 1; + } + } + } + out +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p viz-core --lib visualizations::notes` +Expected: PASS. If `worst > 0.97 * limit` fails only because the sampled worst is a hair lower, report the measured value rather than loosening silently. + +- [ ] **Step 5: Commit** + +```bash +git add crates/viz-core/src/visualizations/notes.rs +git commit -m "feat(viz): pure 4D helpers for the notes tesseract (rotate, project, edges)" +``` + +--- + +## Task 4: The fourth bar, the turn and `draw_figure_4d` + +**Files:** +- Modify: `crates/viz-core/src/visualizations/notes.rs` (config, schema, `BarSide`, `layout`, `glow_quad`, `draw_bars`, `NotesViz` fields/`new`/`init`/`tick`/`render`, new `draw_figure_4d`, `readout`; tests) +- Modify: `crates/viz-core/tests/wasm.rs` (extend `notes_renders_one_two_and_three_notes`) + +**Interfaces:** +- Consumes: Task 2 `Visualization::readout`; Task 3 `rotate_4d`, `project_4d`, `tesseract_edges`; Task 1 four-note rule. +- Produces: viz config fields `turn_4d`, `turn_4d_speed`, `angle_4d`; `note_colors: [[f32; 4]; 4]`; `BarSide::Bottom`; `Layout.bars: [Bar; 4]`; `NotesViz::apply_turn_config(&mut self, cfg: &NotesVizConfig)`; readout `{ "angle_4d": θ }`. + +- [ ] **Step 1: Write the failing unit tests** (in `mod tests`) + +```rust + #[test] + fn the_fourth_bar_runs_along_the_bottom_gutter_left_to_right() { + let l = layout([1000, 800], [100.0, 50.0, 500.0, 500.0], [0.0; 4]); + let gutter = GUTTER_FRAC * 500.0; + let bottom = l.bars[3]; + assert_eq!(bottom.side, BarSide::Bottom); + let y = 50.0 + 500.0 - gutter * 0.5; + assert_point_near(bottom.from, [l.plot.x, y], "bottom bar from"); + assert_point_near(bottom.to, [l.plot.x + l.plot.w, y], "bottom bar to"); + } + + #[test] + fn turn_config_defaults_and_schema() { + let d = NotesVizConfig::default(); + assert!(d.turn_4d); + assert_eq!(d.turn_4d_speed, 0.35); + assert_eq!(d.angle_4d, 0.0); + assert_eq!(d.note_colors.len(), 4); + assert_eq!(d.note_colors[3], [0.498, 0.659, 0.910, 1.0]); + let schema = NotesVizConfig::schema(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + assert!(schema["properties"][key].is_object(), "{key} in schema"); + } + assert_eq!(schema["properties"]["note_colors"]["maxItems"], 4); + // Older pushes without the new fields still parse, with the defaults. + let mut older = NotesVizConfig::defaults(); + for key in ["turn_4d", "turn_4d_speed", "angle_4d"] { + older.as_object_mut().unwrap().remove(key); + } + let parsed: NotesVizConfig = serde_json::from_value(older).expect("parses without the 4D fields"); + assert!(parsed.turn_4d); + assert_eq!(parsed.turn_4d_speed, 0.35); + assert_eq!(parsed.angle_4d, 0.0); + } + + #[test] + fn tick_turns_while_turning_and_holds_while_held() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(0.5); + assert!((viz.angle_4d - 0.5).abs() < 1e-6); + viz.apply_turn_config(&NotesVizConfig { turn_4d: false, angle_4d: 2.0, ..NotesVizConfig::default() }); + assert!((viz.angle_4d - 2.0).abs() < 1e-6, "a held config sets the angle"); + viz.tick(1.0); + assert!((viz.angle_4d - 2.0).abs() < 1e-6, "held: tick leaves it"); + } + + #[test] + fn a_turning_config_never_snaps_the_angle_back() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(1.2); + // A layout push carries a stale angle_4d with turn_4d still on. + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, angle_4d: 0.1, ..NotesVizConfig::default() }); + assert!((viz.angle_4d - 1.2).abs() < 1e-6); + } + + #[test] + fn the_angle_wraps_and_the_readout_reports_it() { + let mut viz = NotesViz::new(); + viz.apply_turn_config(&NotesVizConfig { turn_4d_speed: 1.0, ..NotesVizConfig::default() }); + viz.tick(std::f32::consts::TAU + 0.25); + assert!((viz.angle_4d - 0.25).abs() < 1e-4); + let r = viz.readout(); + assert!((r["angle_4d"].as_f64().unwrap() - 0.25).abs() < 1e-4); + // Through the erased wrapper too. + let erased = crate::engine::erased::TypedViz::new(NotesViz::new()); + use crate::engine::erased::ErasedVisualization; + assert_eq!(erased.readout()["angle_4d"].as_f64(), Some(0.0)); + } +``` + +Also update the existing layout test that lists `[BarSide::Left, BarSide::Top, BarSide::Right]` (~line 1279) to include `BarSide::Bottom`, and any test that indexes `cfg.note_colors` as a 3-array. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cargo test -p viz-core --lib visualizations::notes` +Expected: FAIL to compile (`BarSide::Bottom`, `turn_4d`, `apply_turn_config`, `angle_4d` missing). + +- [ ] **Step 3: Config and schema** + +In `NotesVizConfig`: change `note_colors` to `[[f32; 4]; 4]` and its doc to "note 0 (left), 1 (top), 2 (right), 3 (bottom)". Add, after `auto_rotate_speed`: + +```rust + /// Whether `tick` turns the four-note figure through the fourth + /// dimension. Off holds it at `angle_4d`. + #[serde(default = "default_true")] + pub turn_4d: bool, + /// Radians per second of the 4D turn. + #[serde(default = "default_turn_4d_speed")] + pub turn_4d_speed: f32, + /// The 4D angle in radians, taken only while `turn_4d` is off (the lab's + /// slider holds it there); a turning figure ignores it, so a layout push + /// carrying a stale value never snaps it back. + #[serde(default)] + pub angle_4d: f32, +``` + +with + +```rust +fn default_true() -> bool { + true +} +const DEFAULT_TURN_4D_SPEED: f32 = 0.35; +fn default_turn_4d_speed() -> f32 { + DEFAULT_TURN_4D_SPEED +} +``` + +In `Default`: add `[0.498, 0.659, 0.910, 1.0], // soft blue #7FA8E8` as the fourth colour, and `turn_4d: true, turn_4d_speed: DEFAULT_TURN_4D_SPEED, angle_4d: 0.0,`. + +In `schema()`: `note_colors` title "Note colors (4)", `minItems`/`maxItems` 4, add `color_property("Note 4 (bottom bar)", d.note_colors[3])` to `prefixItems` and change the comment to "four distinct bars; items=false forbids a fifth". Add properties: + +```rust + "turn_4d": { "type": "boolean", "title": "Turn through 4D (four notes)", "default": true }, + "turn_4d_speed": number_property(NumberOpts { + label: "4D turn speed (rad/s)", + default: DEFAULT_TURN_4D_SPEED as f64, min: 0.0, max: 3.0, step: 0.05, + integer: false, cosmetic: true, widget: None, + }), + "angle_4d": number_property(NumberOpts { + label: "4D angle when held (rad)", + default: 0.0, min: 0.0, max: std::f64::consts::TAU, step: 0.01, + integer: false, cosmetic: true, widget: None, + }), +``` + +Do **not** add the three new keys to `required` (they are serde-defaulted; older pushes omit them). + +- [ ] **Step 4: Layout and bars** + +Add `Bottom` to `BarSide` (doc: "the fourth note's bar"). In `Bar`'s doc, say the bottom bar also runs left → right. `Layout.bars: [Bar; 4]`, doc "note 3 bottom". In `layout()` add `let bottom_y = figure.y + figure.h - midline;` and a fourth entry: + +```rust + Bar { + side: BarSide::Bottom, + from: [plot.x, bottom_y], + to: [plot.x + plot.w, bottom_y], + }, +``` + +In `glow_quad` and `draw_bars`, change `BarSide::Top =>` to `BarSide::Top | BarSide::Bottom =>`. + +- [ ] **Step 5: Turn state, `apply_turn_config`, `tick`, `readout`** + +`NotesViz` fields (with docs): `angle_4d: f32` ("the 4D turn, radians in [0, 2π)") and `cached_turn_4d: (bool, f32)` ("`turn_4d` and its speed from the last `init`, for `tick`"). Initialise to `0.0` and `(true, DEFAULT_TURN_4D_SPEED)` in `new()`. + +```rust + /// The turn part of `init`, separate so it is testable without GL: + /// caches whether and how fast `tick` turns, and while held takes the + /// config's angle. + fn apply_turn_config(&mut self, cfg: &NotesVizConfig) { + self.cached_turn_4d = (cfg.turn_4d, cfg.turn_4d_speed); + if !cfg.turn_4d { + self.angle_4d = cfg.angle_4d.rem_euclid(std::f32::consts::TAU); + } + } +``` + +Call `self.apply_turn_config(cfg);` in `init` and extend `init`'s doc ("…and the 4D turn's settings; a held angle is taken from the config"). In `tick`, after the azimuth line: + +```rust + let (turning, speed) = self.cached_turn_4d; + if turning { + self.angle_4d = (self.angle_4d + speed * dt).rem_euclid(std::f32::consts::TAU); + } +``` + +In `impl Visualization for NotesViz`: + +```rust + /// The 4D angle, so the lab's slider can follow the turn. + fn readout(&self) -> serde_json::Value { + serde_json::json!({ "angle_4d": self.angle_4d }) + } +``` + +- [ ] **Step 6: `draw_figure_4d` and `render`** + +Add after `draw_figure_3d` (same shape, 4D points): + +```rust + /// Four notes: the curve inside a tesseract, turned through the fourth + /// dimension by `angle_4d` (see `rotate_4d`), projected to 3D + /// (`project_4d`) and drawn with the cube's camera, batches and pen. + fn draw_figure_4d( + &mut self, + gl: &Gl, + state: &NotesState, + cfg: &NotesVizConfig, + frame: &FrameCtx, + ) { + if self.ensure_3d_resources(gl).is_err() { + return; + } + let [n0, n1, n2, n3] = [&state.notes[0], &state.notes[1], &state.notes[2], &state.notes[3]]; + let just = state.just_intonation; + let theta = self.angle_4d; + // x = note 1 (top), y = note 0 (left), z = note 2 (right), w = note 3 (bottom). + let point_at = |t: f64| { + project_4d(rotate_4d( + [ + displacement_at(n1, just, t) * CUBE_HALF, + displacement_at(n0, just, t) * CUBE_HALF, + displacement_at(n2, just, t) * CUBE_HALF, + displacement_at(n3, just, t) * CUBE_HALF, + ], + theta, + )) + }; + + let [x, y, w, h] = gl_viewport_rect(frame.canvas[1], &frame.layout.plot); + let plot_px = [w.max(0) as u32, h.max(0) as u32]; + gl.viewport(x, y, w, h); + self.camera.resize(plot_px[0], plot_px[1]); + self.camera.azimuth = self.auto_azimuth + self.azimuth_offset; + self.camera.elevation = self.elevation; + let view_proj = self.camera.view_projection(); + + sample_trail(state, cfg, trail_window(state.period), &mut self.time_scratch); + self.line_3d_scratch.clear(); + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + self.line_3d_scratch.push(LineVertex3D { + position: project_4d(rotate_4d(p, theta)), + color: cfg.axis_color, + }); + } + } + push_polyline( + &mut self.line_3d_scratch, + self.time_scratch.iter().map(|&t| LineVertex3D { + position: point_at(t), + color: cfg.trail_color, + }), + ); + let lines_3d = self.lines_3d.as_mut().expect("ensure_3d_resources ran"); + lines_3d.upload(gl, &self.line_3d_scratch); + lines_3d.draw(gl, &view_proj); + + let pen = PointInstance3D { + position: point_at(state.phase), + color: with_alpha(PEN_COLOR, 1.0 - frame.smear), + radius_px: cfg.pen_size_px * 0.5, + }; + let points_3d = self.points_3d.as_mut().expect("ensure_3d_resources ran"); + points_3d.upload(gl, &[pen]); + points_3d.draw(gl, &view_proj, plot_px); + + gl.viewport(0, 0, frame.canvas[0] as i32, frame.canvas[1] as i32); + } +``` + +In `render`, replace the 3/else branch with: + +```rust + match frame.n_notes { + 4 => self.draw_figure_4d(gl, state, cfg, &frame), + 3 => self.draw_figure_3d(gl, state, cfg, &frame), + _ => self.draw_figure_2d(gl, state, cfg, &frame), + } +``` + +Check the type of `cube_edges`/`LineVertex3D.position` (`[f32; 3]`) matches `project_4d`'s return. Update the struct/`ensure_3d_resources` docs that say "three notes" to "three or four notes". + +- [ ] **Step 7: Run unit tests and clippy** + +Run: `cargo test -p viz-core --lib && cargo clippy --all-targets --all-features && cargo fmt --all` +Expected: PASS, no new warnings. + +- [ ] **Step 8: Extend the wasm test** (`crates/viz-core/tests/wasm.rs`, end of `notes_renders_one_two_and_three_notes`; rename it `notes_renders_one_to_four_notes`) + +```rust + // C, E, G and B♭ (20:25:30:36) draw the tesseract, and the viz reports its 4D angle. + engine + .update_rule_config(cmd(r#"{"notes":[60,64,67,70]}"#)) + .expect("four notes"); + engine.frame(48.0); + assert_eq!(summary_field(&engine, "period").as_f64(), Some(20.0)); + assert_eq!(js_sys::Array::from(&summary_field(&engine, "notes")).length(), 4); + let angle = js_sys::Reflect::get(&engine.viz_readout(), &JsValue::from_str("angle_4d")) + .expect("readout has angle_4d"); + assert!(angle.as_f64().is_some()); + assert_no_gl_error(&gl_of(&canvas)); +``` + +Run (alone, nothing else building): `wasm-pack test --chrome --headless --chromedriver= crates/viz-core` +Expected: PASS. (Driver recipe: the README's Chrome for Testing section; download into the session scratchpad.) + +- [ ] **Step 9: Commit** + +```bash +git add crates/viz-core/src/visualizations/notes.rs crates/viz-core/tests/wasm.rs +git commit -m "feat(viz): four notes draw a curve in a tesseract that turns through 4D, with a fourth bar" +``` + +--- + +## Task 5: Web helpers — fourth colour, four-note picker, seventh chords, four voices + +**Files:** +- Modify: `web/src/lib/notes/theory.ts` (`NOTE_COLORS`) +- Modify: `web/src/lib/notes/picker.ts` (`MAX_NOTES`, header comment) +- Modify: `web/src/lib/notes/chords.ts` +- Test: `web/src/lib/notes/__tests__/theory.test.ts`, `picker.test.ts`, `chords.test.ts`, `audio.test.ts` + +**Interfaces:** +- Produces: `NOTE_COLORS.bars: readonly [string, string, string, string]`; `MAX_NOTES = 4`; `ChordId` adds `'dom7' | 'maj7' | 'min7' | 'halfdim7' | 'dim7'`; `Chord = { id; label; group: 'triad' | 'seventh'; steps: readonly number[] }`; `CHORDS` in the Global Constraints order; `chordOn(root, id): number[] | undefined` unchanged in signature. + +- [ ] **Step 1: Write the failing tests** + +`theory.test.ts` (in the `NOTE_COLORS` describe, or a new one): + +```ts + it('has four bar colours, the fourth the soft blue of the bottom bar', () => { + expect(NOTE_COLORS.bars).toHaveLength(4); + expect(NOTE_COLORS.bars[3]).toBe('#7FA8E8'); + }); +``` + +`picker.test.ts`: change `'allows up to three notes…'` to four (`expect(MAX_NOTES).toBe(4)`); in the toggle lock-out test use four picks (`[60, 64, 67, 70]` refuses `72`); change `'…stops at three'` to `'…stops at four'` with input `[60, 60, 48, 999, 61.5, NaN, 72, 64, 67]` → `[60, 48, 72, 64]`; and add: + +```ts + it('reads a link with five notes as its first four', () => { + expect(parseNotesParam('60,64,67,70,72')).toEqual([60, 64, 67, 70]); + }); +``` + +`chords.test.ts`: replace the first two tests' expectations with: + +```ts + it('lists the six triads, then the five sevenths', () => { + expect(CHORDS.map((c) => c.id)).toEqual([ + 'major', 'minor', 'diminished', 'augmented', 'sus2', 'sus4', + 'dom7', 'maj7', 'min7', 'halfdim7', 'dim7', + ]); + expect(CHORDS.map((c) => c.label)).toEqual([ + 'Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4', + 'Dom7', 'Maj7', 'Min7', 'Half-dim7', 'Dim7', + ]); + for (const c of CHORDS) { + expect(c.steps[0]).toBe(0); + expect(c.steps).toHaveLength(c.group === 'triad' ? 3 : 4); + } + expect(CHORDS.filter((c) => c.group === 'seventh')).toHaveLength(5); + }); + + it('spells each seventh in semitones above its root', () => { + expect(chordById('dom7').steps).toEqual([0, 4, 7, 10]); + expect(chordById('maj7').steps).toEqual([0, 4, 7, 11]); + expect(chordById('min7').steps).toEqual([0, 3, 7, 10]); + expect(chordById('halfdim7').steps).toEqual([0, 3, 6, 10]); + expect(chordById('dim7').steps).toEqual([0, 3, 6, 9]); + }); +``` + +(keep the existing triad-spelling test) and in `describe('chordOn')`: + +```ts + it('builds a seventh as four notes, and refuses one that would pass the top', () => { + expect(chordOn(60, 'dom7')).toEqual([60, 64, 67, 70]); + expect(chordOn(72, 'maj7')).toEqual([72, 76, 79, 83]); // C5 Maj7 ends on B5 + expect(chordOn(73, 'maj7')).toBeUndefined(); + }); +``` + +and in `isChordId`'s bad list replace `'dom7'` with `'dom9'`. + +`audio.test.ts`, in `'splits the level so any number of notes totals the same loudness'`, add `[1, 1.25, 1.5, 1.8]` to the list of ratio sets. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/notes` +Expected: FAIL (three colours, `MAX_NOTES` 3, no sevenths). + +- [ ] **Step 3: Implement** + +`theory.ts`: `bars: ['#F2B23C', '#F0665C', '#6CC68E', '#7FA8E8'],` and widen the type to `readonly [string, string, string, string]`. + +`picker.ts`: `export const MAX_NOTES = 4;`; header comment "up to four notes"; `toggleNote` doc "a fifth note". + +`chords.ts`: + +```ts +export type ChordId = + | 'major' | 'minor' | 'diminished' | 'augmented' | 'sus2' | 'sus4' + | 'dom7' | 'maj7' | 'min7' | 'halfdim7' | 'dim7'; + +/** A chord: its share-link id, its name in the picker, its row, and its notes in semitones above the root. */ +export type Chord = { id: ChordId; label: string; group: 'triad' | 'seventh'; steps: readonly number[] }; + +/** Triads fill three bars and sevenths all four: root left, then top, right and bottom. */ +export const CHORDS: readonly Chord[] = [ + { id: 'major', label: 'Major', group: 'triad', steps: [0, 4, 7] }, + { id: 'minor', label: 'Minor', group: 'triad', steps: [0, 3, 7] }, + { id: 'diminished', label: 'Diminished', group: 'triad', steps: [0, 3, 6] }, + { id: 'augmented', label: 'Augmented', group: 'triad', steps: [0, 4, 8] }, + { id: 'sus2', label: 'Sus2', group: 'triad', steps: [0, 2, 7] }, + { id: 'sus4', label: 'Sus4', group: 'triad', steps: [0, 5, 7] }, + { id: 'dom7', label: 'Dom7', group: 'seventh', steps: [0, 4, 7, 10] }, + { id: 'maj7', label: 'Maj7', group: 'seventh', steps: [0, 4, 7, 11] }, + { id: 'min7', label: 'Min7', group: 'seventh', steps: [0, 3, 7, 10] }, + { id: 'halfdim7', label: 'Half-dim7', group: 'seventh', steps: [0, 3, 6, 10] }, + { id: 'dim7', label: 'Dim7', group: 'seventh', steps: [0, 3, 6, 9] }, +]; +``` + +Update the file's header comment ("the six triads and five sevenths"). + +- [ ] **Step 4: Run tests** + +Run: `cd web && npx vitest run src/lib/notes && npx svelte-check` +Expected: PASS; `svelte-check` may flag `NotesLab.svelte` places that assumed three colours — leave those for Task 8 only if they are type errors there, otherwise fix the helper. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/notes +git commit -m "feat(web): notes helpers take four notes and five seventh chords" +``` + +--- + +## Task 6: Label anchors for four bars and the ratio in the strip corner + +**Files:** +- Modify: `web/src/lib/notes/layout.ts` (`labelAnchors`) +- Test: `web/src/lib/notes/__tests__/layout.test.ts` + +**Interfaces:** +- Produces: `labelAnchors(figure: Rect, strip: Rect): { bars: [Point, Point, Point, Point]; ratio: Point; ratioInCorner: boolean }`. `ratio` is the strip's top-left corner when the strip has area (`ratioInCorner: true`), else the figure's bottom-centre (`false`). + +- [ ] **Step 1: Write the failing tests** (replace the `labelAnchors` describe) + +```ts +describe('labelAnchors', () => { + const STRIP = { x: 140, y: 30, w: 300, h: 45 }; + + it('puts each bar label at its bar end, the bottom bar at its right end', () => { + const { bars } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); + expectPoint(bars[0], 16, 108); // left bar: bottom end, 6% in + expectPoint(bars[1], 98, 26); // top bar: right end, 6% down + expectPoint(bars[2], 104, 108); // right bar: bottom end, 94% across + expectPoint(bars[3], 98, 114); // bottom bar: right end, 6% up from the bottom + }); + + it('hangs the ratio in the strip top-left corner', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); + expectPoint(ratio, 140, 30); + expect(ratioInCorner).toBe(true); + }); + + it('keeps the ratio at the figure bottom-centre when there is no strip', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, ZERO); + expectPoint(ratio, 60, 114); + expect(ratioInCorner).toBe(false); + }); + + it('scales with the square', () => { + const { bars } = labelAnchors({ x: 0, y: 0, w: 200, h: 200 }, ZERO); + expectPoint(bars[0], 12, 176); + expectPoint(bars[1], 176, 12); + expectPoint(bars[2], 188, 176); + expectPoint(bars[3], 176, 188); + }); + + it('keeps every bar anchor inside the figure and the ratio on the strip', () => { + const { figure, strip } = notesLayout(1168, 650); + const { bars, ratio } = labelAnchors(figure, strip); + for (const p of bars) { + expect(p.x).toBeGreaterThan(figure.x); + expect(p.x).toBeLessThan(figure.x + figure.w); + expect(p.y).toBeGreaterThan(figure.y); + expect(p.y).toBeLessThan(figure.y + figure.h); + } + expectPoint(ratio, strip.x, strip.y); + }); +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/notes/__tests__/layout.test.ts` +Expected: FAIL (three bars; ratio at figure bottom-centre; no `ratioInCorner`). + +- [ ] **Step 3: Implement** + +```ts +/** + * Where the overlay hangs its text, for a figure of side `figure.w`: one label + * at each bar's end (left bar at the bottom, top bar at the right, right bar + * at the bottom, bottom bar at the right) and the ratio readout. The bars lie + * on their gutters' midlines, half of `BAR_INSET` in from the figure's edge, + * and span the plot, which ends `BAR_INSET` short of the opposite edge. The + * ratio hangs from the strip's top-left corner (the bottom gutter now holds + * the fourth bar), or sits at the figure's bottom-centre when there is no + * strip; `ratioInCorner` says which, so the page can align the pill. + */ +export function labelAnchors( + figure: Rect, + strip: Rect, +): { bars: [Point, Point, Point, Point]; ratio: Point; ratioInCorner: boolean } { + const s = figure.w; + const mid = BAR_INSET / 2; + const far = 1 - BAR_INSET; + const ratioInCorner = strip.w > 0 && strip.h > 0; + return { + bars: [ + { x: figure.x + mid * s, y: figure.y + far * s }, + { x: figure.x + far * s, y: figure.y + mid * s }, + { x: figure.x + (1 - mid) * s, y: figure.y + far * s }, + { x: figure.x + far * s, y: figure.y + (1 - mid) * s }, + ], + ratio: ratioInCorner ? { x: strip.x, y: strip.y } : { x: figure.x + 0.5 * s, y: figure.y + (1 - mid) * s }, + ratioInCorner, + }; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd web && npx vitest run src/lib/notes/__tests__/layout.test.ts` +Expected: PASS. (`NotesLab.svelte` still calls `labelAnchors(layout.figure)`; Task 8 updates it. `svelte-check` will flag it until then — expected.) + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/notes/layout.ts web/src/lib/notes/__tests__/layout.test.ts +git commit -m "feat(web): label anchors for a fourth bar; the ratio hangs from the strip corner" +``` + +--- + +## Task 7: `LabApi.readVizReadout` and the fake engine + +**Files:** +- Modify: `web/src/lib/components/labApi.svelte.ts` (after `readSummary`) +- Modify: `web/src/lib/test/fakeViz.ts` (FakeEngine, exported fixture) +- Test: `web/src/lib/components/__tests__/labApi.test.ts` if it exists, else add the case to `NotesLab.test.ts` in Task 8 + +**Interfaces:** +- Consumes: Task 2 `Engine.viz_readout()` (generated wasm-bindgen typing appears after a wasm build; the fake supplies it in tests). +- Produces: `LabApi.readVizReadout(): unknown`; `export const notesVizReadout: { angle_4d: number }` in `fakeViz.ts` (mutable; the fake returns it on the notes lab, `null` elsewhere). + +- [ ] **Step 1: Write the failing test** + +If `web/src/lib/components/__tests__/labApi.test.ts` exists, add (following its setup for building a `LabApi` around a fake engine): + +```ts + it('reads the viz readout, or null without an engine', () => { + expect(new LabApi().readVizReadout()).toBeNull(); + }); +``` + +Otherwise skip this step; Task 8's lab tests exercise it. + +- [ ] **Step 2: Implement** + +`labApi.svelte.ts`: + +```ts + /** Read the visualization's readout (view state it advances itself, e.g. the notes lab's 4D angle), or null. */ + readVizReadout(): unknown { + return this.engine?.viz_readout() ?? null; + } +``` + +If `svelte-check` reports `viz_readout` missing on the generated `Engine` type, rebuild the wasm package (`npm --prefix web run build:wasm` or the README's wasm-pack build command) so the `.d.ts` picks up Task 2's method; do not cast around it. + +`fakeViz.ts`: add near the notes fixture + +```ts +/** What the fake's `viz_readout()` returns on the notes lab (null elsewhere). Mutable so a test can move the 4D angle. */ +export const notesVizReadout = { angle_4d: 0 }; +``` + +and in `FakeEngine`: + +```ts + viz_readout() { return this._lab === 'notes' ? notesVizReadout : null; } +``` + +- [ ] **Step 3: Run tests** + +Run: `cd web && npx vitest run && npx svelte-check` +Expected: vitest PASS; `svelte-check` clean apart from the known `labelAnchors` call (Task 8). + +- [ ] **Step 4: Commit** + +```bash +git add web/src/lib/components/labApi.svelte.ts web/src/lib/test/fakeViz.ts +git commit -m "feat(web): LabApi reads the visualization's readout" +``` + +--- + +## Task 8: The lab page — sevenths row, 4D controls, overlay, legend, docs + +**Files:** +- Modify: `web/src/lib/components/labs/NotesLab.svelte` +- Test: `web/src/lib/components/__tests__/NotesLab.test.ts` +- Modify: `README.md` (Notes & Chords bullet), `docs/superpowers/specs/2026-10-04-notes-and-chords-design.md` (one pointer line) + +**Interfaces:** +- Consumes: Task 5 `CHORDS` (with `group`), `MAX_NOTES = 4`, `NOTE_COLORS.bars[3]`; Task 6 `labelAnchors(figure, strip)` → `{ bars, ratio, ratioInCorner }`; Task 7 `api.readVizReadout()`, `notesVizReadout`; viz config keys `turn_4d`, `angle_4d` (radians). + +- [ ] **Step 1: Write the failing tests** (import `notesVizReadout` from `../../test/fakeViz`; add `const lastViz = () => updateVizConfigSpy.mock.lastCall?.[0] as Record;`) + +Update existing tests: +- `'stops at three notes…'` → four: pick `64, 67, 70` after C4 (so `[60, 64, 67, 70]`), `unpicked` length `32`, title `'Pick up to four notes'`, the pressed set `[60, 64, 67, 70]`. +- chord mode `'starts on single notes and offers the six triads as radio buttons'` → twelve labels in order: Single notes, Major, Minor, Diminished, Augmented, Sus2, Sus4, Dom7, Maj7, Min7, Half-dim7, Dim7; still one shared `name`; and `getByText('Sevenths')` exists. +- `'drops a chord id it does not know'` → use `c=dom9`. + +New tests (in `describe('chord mode')`): + +```ts + it('a seventh builds four notes on the pressed root', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Dom7'); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); + expect(badge(chipFor(getByRole, 70))).toBe('4'); + await fireEvent.click(chipFor(getByRole, 62)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [62, 66, 69, 72] })); + expect(location.hash).toBe('#/notes?n=62,66,69,72&c=dom7'); + }); + + it('greys out a root whose seventh would pass B5', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Maj7'); + expect(chipFor(getByRole, 72).getAttribute('aria-disabled')).not.toBe('true'); + expect(chipFor(getByRole, 73).getAttribute('aria-disabled')).toBe('true'); + }); +``` + +New `describe('4D turn')`: + +```ts + describe('4D turn', () => { + const turnButton = (getByRole: (r: string, o: { name: string }) => HTMLElement) => + getByRole('button', { name: 'Turn' }); + + it('shows its controls only with four notes', async () => { + const { queryByLabelText, getByRole } = await renderLab('n=60,64,67'); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).not.toBeNull(); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('the slider follows the viz while turning', async () => { + const { getByLabelText, container } = await renderLab('n=60,64,67,70'); + notesVizReadout.angle_4d = Math.PI / 2; + try { + await vi.waitFor(() => expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('90')); + expect(container.querySelector('.turn-4d .value')?.textContent).toBe('90°'); + } finally { + notesVizReadout.angle_4d = 0; + } + }); + + it('grabbing the slider holds the turn at that angle; Turn resumes it', async () => { + const { getByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + updateVizConfigSpy.mockClear(); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '45' } }); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: false })); + expect(lastViz().angle_4d as number).toBeCloseTo(Math.PI / 4); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('false'); + await fireEvent.click(turnButton(getByRole)); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: true })); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('hides the 4D controls below four notes and keeps the held angle', async () => { + const { getByLabelText, queryByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '30' } }); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('30'); + }); + + it('Reset turns it on again from 0', async () => { + const { getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.click(turnButton(getByRole)); // hold + updateVizConfigSpy.mockClear(); + await fireEvent.click(getByRole('button', { name: 'Reset' })); + const pushes = updateVizConfigSpy.mock.calls.map(([c]) => c as Record); + expect(pushes).toContainEqual(expect.objectContaining({ turn_4d: false, angle_4d: 0 })); + expect(pushes.at(-1)).toEqual(expect.objectContaining({ turn_4d: true })); + }); + }); +``` + +Overlay/legend (in `describe('layout and overlay')` / `'legend and story'`): + +```ts + it('labels four bars and hangs the ratio in the strip corner', async () => { + // The fixture summary has two notes; give it four for this read. + const saved = notesSummaryFixture.notes; + notesSummaryFixture.notes = [...saved, saved[0], saved[1]].slice(0, 4); + try { + const { container } = await renderLab('n=60,64,67,70'); + await vi.waitFor(() => expect(container.querySelectorAll('.note-label')).toHaveLength(4)); + expect(container.querySelectorAll('.note-label.beside')).toHaveLength(2); + } finally { + notesSummaryFixture.notes = saved; + } + }); +``` + +and change the legend swatch count from `5` to `6`. Add to the share-link `it.each`: `['n=60,64,67,70,72', '#/notes?n=60,64,67,70', [60, 64, 67, 70]]`, and a case `renderLab('n=60,64,67&c=dom7')` keeps notes `[60, 64, 67]` and `c=dom7` in the hash (links are not re-voiced). + +If the overlay test's fixture shape differs (check `notesSummaryFixture` in `fakeViz.ts`), build four notes with the fixture's own note objects; the assertion is four `.note-label`s, two of them `.beside`. + +- [ ] **Step 2: Run to verify they fail** + +Run: `cd web && npx vitest run src/lib/components/__tests__/NotesLab.test.ts` +Expected: the new and updated tests FAIL. + +- [ ] **Step 3: Implement — chord rows** + +Replace the `{#each CHORDS …}` inside the Pick radiogroup with two rows; keep one `role="radiogroup"` and the shared `name="notes-pick"`: + +```svelte + {#each CHORDS.filter((c) => c.group === 'triad') as c (c.id)} + + {/each} + + Sevenths + {#each CHORDS.filter((c) => c.group === 'seventh') as c (c.id)} + + {/each} +``` + +CSS: `.pick-mode .row-break { flex-basis: 100%; height: 0; }`. + +- [ ] **Step 4: Implement — 4D state and controls** + +Script: + +```ts + /** The 4D turn (four notes): whether the viz turns it, and the angle in radians the slider shows. */ + let turning = $state(true); + let angle4d = $state(0); + const angleDeg = $derived(Math.round((angle4d * 180) / Math.PI) % 360); + + /** Hold the turn at the slider's angle. */ + function on4dInput(e: Event) { + angle4d = (Number((e.target as HTMLInputElement).value) * Math.PI) / 180; + turning = false; + api?.patchVizConfig({ turn_4d: false, angle_4d: angle4d }); + } + + /** Start or stop the turn; stopping holds it where the slider shows. */ + function toggleTurn() { + turning = !turning; + api?.patchVizConfig(turning ? { turn_4d: true } : { turn_4d: false, angle_4d: angle4d }); + } +``` + +In the per-frame `$effect` (the one that reads `a.readSummary()`), after `summary = next;`: + +```ts + // While turning the viz owns the 4D angle; mirror it for the slider. + const r = a.readVizReadout() as { angle_4d?: unknown } | null; + if (untrack(() => turning) && typeof r?.angle_4d === 'number') angle4d = r.angle_4d; +``` + +(`untrack` keeps the effect from re-running on `turning`; it is already imported in this file — check.) In `onReset`, before `applyConfig()`: + +```ts + // A held angle is only taken while held, so set it held at 0, then turn. + angle4d = 0; + turning = true; + api?.patchVizConfig({ turn_4d: false, angle_4d: 0 }); + api?.patchVizConfig({ turn_4d: true }); +``` + +Update Reset's `title` to "…half a swing a second, the 4D turn back on, playing". + +Markup, after the picker group: + +```svelte + {#if notes.length === 4} +
+ + +
+ {/if} +``` + +CSS: `.turn-4d .btn[aria-pressed="true"]` styled like `.tuning .btn[aria-pressed="true"]`; `.turn-4d .value { width: 4ch; text-align: right; color: var(--ink); }`. + +- [ ] **Step 5: Implement — overlay, legend, copy** + +- `const anchors = $derived(labelAnchors(layout.figure, layout.strip));` +- Note labels: `class:beside={i === 1 || i === 3}`. +- Ratio span: add `class:corner={anchors.ratioInCorner}`; CSS `.ratio.corner { transform: translate(6px, 6px); }`. +- Legend: add `Note 4 (bottom bar)` after note 3. +- `chipBlocked`: `'Pick up to four notes'`. +- Tips: "Pick up to four notes … A third note adds depth, and the curve turns inside a cube; a fourth turns that cube into a tesseract, a four-dimensional cube, and the curve folds through it. The **4D turn** slider holds it at an angle and **Turn** sets it going again; drag to look around it." Chord tip: mention the Sevenths row (Dom7, Maj7, Min7, Half-dim7, Dim7) build four notes. Link tip: `&c=dom7`. +- Any remaining "three" about the note count in this file's comments → "four" where it is about the cap. + +- [ ] **Step 6: Run the web suite** + +Run: `cd web && npx vitest run && npx svelte-check` +Expected: all PASS, 0 errors. + +- [ ] **Step 7: Docs** + +README Notes & Chords bullet: "pick up to four notes from C3 to B5, or set **Pick** to a chord (six triads, or a seventh: Dom7, Maj7, Min7, Half-dim7, Dim7) and press its root; … three draw a 3D curve in a turning cube and four a curve in a tesseract that turns through the fourth dimension (**4D turn** holds it, **Turn** restarts it) …". In `2026-10-04-notes-and-chords-design.md`, under the header add: `**Extended by:** \`2026-10-12-notes-tesseract-design.md\` (four notes, sevenths, the tesseract).` + +- [ ] **Step 8: Commit** + +```bash +git add web/src/lib/components/labs/NotesLab.svelte web/src/lib/components/__tests__/NotesLab.test.ts README.md docs/superpowers/specs/2026-10-04-notes-and-chords-design.md +git commit -m "feat(web): sevenths and four notes in the notes lab, with 4D turn controls" +``` + +--- + +## Task 9: Whole-branch validation, PR, deploy check (controller) + +- [ ] **Step 1:** From the repo root: `cargo fmt --all --check && cargo clippy --all-targets --all-features && cargo test --all-features`; `cd web && npx vitest run && npx svelte-check`; the wasm tests alone. All pass; report counts. +- [ ] **Step 2:** Browser check on the dev server (port 5173, or 5174 if taken): `#/notes?n=60,64,67,70` shows four bars, the tesseract turning, the slider moving; grab the slider (holds), press Turn (resumes), drop a note (cube back, controls hidden); Dom7 on D4 builds D/F♯/A/C; no console errors. Screenshot as proof. +- [ ] **Step 3:** Push `notes-tesseract`, open the PR with summary + validation, bind it, and report. Merge only on the user's say-so. From 2bff274cf023de33a1286b59f32c7faf69dd340d Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:38:17 -0400 Subject: [PATCH 04/12] feat(rule): notes accepts four notes; a pure dominant seventh closes after 20 swings --- crates/viz-core/src/rules/notes.rs | 51 +++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/viz-core/src/rules/notes.rs b/crates/viz-core/src/rules/notes.rs index b759cea..6f1634e 100644 --- a/crates/viz-core/src/rules/notes.rs +++ b/crates/viz-core/src/rules/notes.rs @@ -1,5 +1,5 @@ //! Notes & Chords: the oscillator model behind the lab. Each note is a sine -//! that swings a dot back and forth along one bar; two or three notes +//! that swings a dot back and forth along one bar; two to four notes //! together draw a Lissajous figure. //! //! **One iteration is one full swing of the root note** (the first note in @@ -28,8 +28,8 @@ use crate::traits::{Capabilities, Rule, SceneState}; /// MIDI note 60 (C4): the lab's default note and the fallback for an empty /// note list. pub const DEFAULT_NOTE: u8 = 60; -/// The lab draws at most three notes: the left, top and right bar. -pub const MAX_NOTES: usize = 3; +/// The lab draws at most four notes, one per bar: left, top, right, bottom. +pub const MAX_NOTES: usize = 4; /// Highest MIDI note number. const MAX_MIDI: u8 = 127; @@ -113,8 +113,8 @@ fn default_max_iter() -> u32 { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotesConfig { /// MIDI notes in bar order: `[0]` is the root (left bar, drives y), `[1]` - /// the top bar (x), `[2]` the right bar (depth). May be empty (nothing - /// picked). Use `effective_notes` + /// the top bar (x), `[2]` the right bar (depth), `[3]` the bottom bar. May + /// be empty (nothing picked). Use `effective_notes` /// for the normalised list the model actually runs on. #[serde(default = "default_notes")] pub notes: Vec, @@ -252,7 +252,7 @@ pub fn displacement_at(note: &NoteState, just_intonation: bool, phase: f64) -> f } /// Builds the whole state for iteration `n`, `sub` of the way into the next -/// one. The model is at most three notes, so it is derived from the config +/// one. The model is at most four notes, so it is derived from the config /// every time rather than cached; that is what keeps `advance_to` and /// `substep` pure. fn recompute(cfg: &NotesConfig, n: u32, sub: f32) -> NotesState { @@ -511,6 +511,35 @@ mod tests { assert_eq!(parse(json!({"max_iterations": 7})).max_iterations, 7); } + #[test] + fn keeps_four_notes_and_drops_a_fifth() { + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70, 72], + ..NotesConfig::default() + }; + assert_eq!(cfg.effective_notes(), vec![60, 64, 67, 70]); + assert_eq!(NotesConfig::schema()["properties"]["notes"]["maxItems"], 4); + } + + #[test] + fn a_pure_dominant_seventh_closes_after_twenty_root_swings() { + // C E G B♭: 1, 5/4, 3/2, 9/5 in the 5-limit table, so 20 : 25 : 30 : 36. + let cfg = NotesConfig { + notes: vec![60, 64, 67, 70], + ..NotesConfig::default() + }; + let state = Notes.init(&cfg, 0); + assert_eq!(state.notes.len(), 4); + assert_eq!(state.period, 20); + let summary = Notes.summary(&state); + let notes = summary["notes"].as_array().unwrap(); + assert_eq!(notes.len(), 4); + assert_eq!( + (notes[3]["num"].as_u64(), notes[3]["den"].as_u64()), + (Some(9), Some(5)) + ); + } + #[test] fn config_rejects_bad_notes() { let parse = |v: serde_json::Value| serde_json::from_value::(v); @@ -533,7 +562,7 @@ mod tests { } let notes = &schema["properties"]["notes"]; assert_eq!(notes["minItems"], 0); - assert_eq!(notes["maxItems"], 3); + assert_eq!(notes["maxItems"], 4); assert_eq!(notes["items"]["maximum"], 127); assert_eq!( schema["properties"]["max_iterations"]["default"], @@ -573,11 +602,11 @@ mod tests { fn effective_notes_normalizes() { let eff = |notes: &[u8]| cfg(notes, true).effective_notes(); assert_eq!(eff(&[]), Vec::::new()); - assert_eq!(eff(&[60, 62, 64, 65, 67]), vec![60, 62, 64]); + assert_eq!(eff(&[60, 62, 64, 65, 67]), vec![60, 62, 64, 65]); assert_eq!(eff(&[200]), vec![127]); // Order is bar order, so it must survive; every kept note is clamped. assert_eq!(eff(&[67, 60, 64]), vec![67, 60, 64]); - assert_eq!(eff(&[200, 10, 255, 30]), vec![127, 10, 127]); + assert_eq!(eff(&[200, 10, 255, 30, 40]), vec![127, 10, 127, 30]); } // ---- state and rule ---- @@ -638,8 +667,8 @@ mod tests { assert_eq!(empty.period, 1); assert!(!empty.closed); - let crowded = Notes.init(&cfg(&[60, 64, 67, 72], true), 0); - assert_eq!(crowded.notes.len(), 3); + let crowded = Notes.init(&cfg(&[60, 64, 67, 72, 76], true), 0); + assert_eq!(crowded.notes.len(), 4); let loud = Notes.init(&cfg(&[200], true), 0); assert_eq!(loud.notes[0].midi, 127); From 8a2db58868f3adc338054919ee49a4ac9b35458f Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:38:50 -0400 Subject: [PATCH 05/12] feat(web): notes helpers take four notes and five seventh chords --- web/src/lib/notes/__tests__/audio.test.ts | 2 +- web/src/lib/notes/__tests__/chords.test.ts | 33 +++++++++++++++++---- web/src/lib/notes/__tests__/picker.test.ts | 22 +++++++++----- web/src/lib/notes/__tests__/summary.test.ts | 6 ++-- web/src/lib/notes/__tests__/theory.test.ts | 9 ++++-- web/src/lib/notes/chords.ts | 29 +++++++++++------- web/src/lib/notes/picker.ts | 8 ++--- web/src/lib/notes/theory.ts | 4 +-- 8 files changed, 76 insertions(+), 37 deletions(-) diff --git a/web/src/lib/notes/__tests__/audio.test.ts b/web/src/lib/notes/__tests__/audio.test.ts index 18dab10..04bcfa5 100644 --- a/web/src/lib/notes/__tests__/audio.test.ts +++ b/web/src/lib/notes/__tests__/audio.test.ts @@ -41,7 +41,7 @@ describe('voicePlan', () => { }); it('splits the level so any number of notes totals the same loudness', () => { - for (const ratios of [[1], [1, 1.5], [1, 1.25, 1.5]]) { + for (const ratios of [[1], [1, 1.5], [1, 1.25, 1.5], [1, 1.25, 1.5, 1.8]]) { const plan = voicePlan(440, ratios, true, false); expect(plan).toHaveLength(ratios.length); expect(plan.reduce((total, v) => total + v.gain, 0)).toBeCloseTo(VOICE_LEVEL, 12); diff --git a/web/src/lib/notes/__tests__/chords.test.ts b/web/src/lib/notes/__tests__/chords.test.ts index 6783626..2a56620 100644 --- a/web/src/lib/notes/__tests__/chords.test.ts +++ b/web/src/lib/notes/__tests__/chords.test.ts @@ -3,13 +3,20 @@ import { CHORDS, chordOn, chordById, isChordId } from '../chords'; import { PICKER_RANGE } from '../theory'; describe('CHORDS', () => { - it('lists the six triads in picker order, each three notes from the root up', () => { - expect(CHORDS.map((c) => c.id)).toEqual(['major', 'minor', 'diminished', 'augmented', 'sus2', 'sus4']); - expect(CHORDS.map((c) => c.label)).toEqual(['Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4']); + it('lists the six triads, then the five sevenths', () => { + expect(CHORDS.map((c) => c.id)).toEqual([ + 'major', 'minor', 'diminished', 'augmented', 'sus2', 'sus4', + 'dom7', 'maj7', 'min7', 'halfdim7', 'dim7', + ]); + expect(CHORDS.map((c) => c.label)).toEqual([ + 'Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4', + 'Dom7', 'Maj7', 'Min7', 'Half-dim7', 'Dim7', + ]); for (const c of CHORDS) { - expect(c.steps).toHaveLength(3); expect(c.steps[0]).toBe(0); + expect(c.steps).toHaveLength(c.group === 'triad' ? 3 : 4); } + expect(CHORDS.filter((c) => c.group === 'seventh')).toHaveLength(5); }); it('spells each triad in semitones above its root', () => { @@ -20,12 +27,20 @@ describe('CHORDS', () => { expect(chordById('sus2').steps).toEqual([0, 2, 7]); expect(chordById('sus4').steps).toEqual([0, 5, 7]); }); + + it('spells each seventh in semitones above its root', () => { + expect(chordById('dom7').steps).toEqual([0, 4, 7, 10]); + expect(chordById('maj7').steps).toEqual([0, 4, 7, 11]); + expect(chordById('min7').steps).toEqual([0, 3, 7, 10]); + expect(chordById('halfdim7').steps).toEqual([0, 3, 6, 10]); + expect(chordById('dim7').steps).toEqual([0, 3, 6, 9]); + }); }); describe('isChordId', () => { - it('accepts the six ids and nothing else', () => { + it('accepts the eleven ids and nothing else', () => { for (const c of CHORDS) expect(isChordId(c.id)).toBe(true); - for (const bad of ['', 'Major', 'dom7', 'notes', null]) expect(isChordId(bad)).toBe(false); + for (const bad of ['', 'Major', 'dom9', 'notes', null]) expect(isChordId(bad)).toBe(false); }); }); @@ -36,6 +51,12 @@ describe('chordOn', () => { expect(chordOn(48, 'augmented')).toEqual([48, 52, 56]); }); + it('builds a seventh as four notes, and refuses one that would pass the top', () => { + expect(chordOn(60, 'dom7')).toEqual([60, 64, 67, 70]); + expect(chordOn(72, 'maj7')).toEqual([72, 76, 79, 83]); // C5 Maj7 ends on B5 + expect(chordOn(73, 'maj7')).toBeUndefined(); + }); + it('refuses a chord that would run past the top of the picker', () => { const top = PICKER_RANGE[PICKER_RANGE.length - 1]; expect(chordOn(top - 7, 'major')).toEqual([top - 7, top - 3, top]); // the highest major chord ends on the top note diff --git a/web/src/lib/notes/__tests__/picker.test.ts b/web/src/lib/notes/__tests__/picker.test.ts index 14fffde..b270563 100644 --- a/web/src/lib/notes/__tests__/picker.test.ts +++ b/web/src/lib/notes/__tests__/picker.test.ts @@ -9,9 +9,15 @@ import { } from '../picker'; describe('limits', () => { - it('allows up to three notes, an empty pick included', () => { + it('allows up to four notes, an empty pick included', () => { expect(MIN_NOTES).toBe(0); - expect(MAX_NOTES).toBe(3); + expect(MAX_NOTES).toBe(4); + }); +}); + +describe('parseNotesParam (limit)', () => { + it('reads a link with five notes as its first four', () => { + expect(parseNotesParam('60,64,67,70,72')).toEqual([60, 64, 67, 70]); }); }); @@ -27,9 +33,9 @@ describe('toggleNote', () => { expect(toggleNote([60, 64], 60)).toEqual([64]); }); - it('refuses a fourth note, handing back the very same array', () => { - const three = [60, 64, 67]; - expect(toggleNote(three, 69)).toBe(three); + it('refuses a fifth note, handing back the very same array', () => { + const four = [60, 64, 67, 70]; + expect(toggleNote(four, 72)).toBe(four); }); it('removes the only note too, leaving an empty pick', () => { @@ -54,8 +60,8 @@ describe('toggleNote', () => { }); describe('sanitizeNotes', () => { - it('keeps integers within the picker, drops duplicates (first wins) and stops at three', () => { - expect(sanitizeNotes([60, 60, 48, 999, 61.5, NaN, 72, 64])).toEqual([60, 48, 72]); + it('keeps integers within the picker, drops duplicates (first wins) and stops at four', () => { + expect(sanitizeNotes([60, 60, 48, 999, 61.5, NaN, 72, 64, 67])).toEqual([60, 48, 72, 64]); }); it('keeps the order it is given', () => { @@ -83,7 +89,7 @@ describe('parseNotesParam', () => { it('drops the numbers the picker does not offer and keeps the rest', () => { expect(parseNotesParam('69,85,90')).toEqual([69]); expect(parseNotesParam('60,abc,64')).toEqual([60, 64]); - expect(parseNotesParam('60,61,62,63')).toEqual([60, 61, 62]); + expect(parseNotesParam('60,61,62,63,64')).toEqual([60, 61, 62, 63]); expect(parseNotesParam('64,64,60')).toEqual([64, 60]); }); diff --git a/web/src/lib/notes/__tests__/summary.test.ts b/web/src/lib/notes/__tests__/summary.test.ts index 0027a69..7da4747 100644 --- a/web/src/lib/notes/__tests__/summary.test.ts +++ b/web/src/lib/notes/__tests__/summary.test.ts @@ -46,11 +46,11 @@ describe('readSummary', () => { expect(s).not.toHaveProperty('extra'); }); - it('accepts up to three notes, an empty pick included, and no more', () => { - for (const notes of [[], [C], [C, G], [C, E, G]]) { + it('accepts up to four notes, an empty pick included, and no more', () => { + for (const notes of [[], [C], [C, G], [C, E, G], [C, E, G, note({ midi: 70, ratio: 1.8, num: 9, den: 5 })]]) { expect(readSummary({ ...notesSummaryFixture, notes })?.notes).toHaveLength(notes.length); } - expect(readSummary({ ...notesSummaryFixture, notes: [C, E, G, note({ midi: 72, ratio: 2, num: 2, den: 1 })] })).toBeNull(); + expect(readSummary({ ...notesSummaryFixture, notes: [C, E, G, note({ midi: 70, ratio: 1.8, num: 9, den: 5 }), note({ midi: 72, ratio: 2, num: 2, den: 1 })] })).toBeNull(); }); it('rejects null, undefined and non-objects', () => { diff --git a/web/src/lib/notes/__tests__/theory.test.ts b/web/src/lib/notes/__tests__/theory.test.ts index a5f7e8f..110b4a0 100644 --- a/web/src/lib/notes/__tests__/theory.test.ts +++ b/web/src/lib/notes/__tests__/theory.test.ts @@ -135,12 +135,17 @@ describe('PICKER_RANGE', () => { }); describe('NOTE_COLORS', () => { - it('copies the Rust viz defaults: amber, coral and mint bars, warm-white trail, cool-white sum', () => { - expect(NOTE_COLORS.bars).toEqual(['#F2B23C', '#F0665C', '#6CC68E']); + it('copies the Rust viz defaults: amber, coral and mint top bars, warm-white trail, cool-white sum', () => { + expect(NOTE_COLORS.bars.slice(0, 3)).toEqual(['#F2B23C', '#F0665C', '#6CC68E']); expect(NOTE_COLORS.trail).toBe('#F5E6CC'); expect(NOTE_COLORS.sum).toBe('#E6E9EE'); }); + it('has four bar colours, the fourth the soft blue of the bottom bar', () => { + expect(NOTE_COLORS.bars).toHaveLength(4); + expect(NOTE_COLORS.bars[3]).toBe('#7FA8E8'); + }); + it('are all #rrggbb', () => { for (const c of [...NOTE_COLORS.bars, NOTE_COLORS.trail, NOTE_COLORS.sum]) expect(c).toMatch(/^#[0-9A-F]{6}$/); }); diff --git a/web/src/lib/notes/chords.ts b/web/src/lib/notes/chords.ts index 4c964e0..7cff43c 100644 --- a/web/src/lib/notes/chords.ts +++ b/web/src/lib/notes/chords.ts @@ -1,21 +1,28 @@ -// Chord mode for the note picker: the six triads, and the one rule that turns +// Chord mode for the note picker: the six triads and five sevenths, and the one rule that turns // a pressed chip into a whole chord. Pure, so the lab page only wires the // select and the chips to it. import { PICKER_RANGE } from './theory'; -export type ChordId = 'major' | 'minor' | 'diminished' | 'augmented' | 'sus2' | 'sus4'; +export type ChordId = + | 'major' | 'minor' | 'diminished' | 'augmented' | 'sus2' | 'sus4' + | 'dom7' | 'maj7' | 'min7' | 'halfdim7' | 'dim7'; -/** A triad: its share-link id, its name in the picker, and its notes in semitones above the root. */ -export type Chord = { id: ChordId; label: string; steps: readonly [number, number, number] }; +/** A chord: its share-link id, its name in the picker, its row, and its notes in semitones above the root. */ +export type Chord = { id: ChordId; label: string; group: 'triad' | 'seventh'; steps: readonly number[] }; -/** Three notes each, so a chord fills the lab's three bars: root left, then top, then right. */ +/** Triads fill three bars and sevenths all four: root left, then top, right and bottom. */ export const CHORDS: readonly Chord[] = [ - { id: 'major', label: 'Major', steps: [0, 4, 7] }, - { id: 'minor', label: 'Minor', steps: [0, 3, 7] }, - { id: 'diminished', label: 'Diminished', steps: [0, 3, 6] }, - { id: 'augmented', label: 'Augmented', steps: [0, 4, 8] }, - { id: 'sus2', label: 'Sus2', steps: [0, 2, 7] }, - { id: 'sus4', label: 'Sus4', steps: [0, 5, 7] }, + { id: 'major', label: 'Major', group: 'triad', steps: [0, 4, 7] }, + { id: 'minor', label: 'Minor', group: 'triad', steps: [0, 3, 7] }, + { id: 'diminished', label: 'Diminished', group: 'triad', steps: [0, 3, 6] }, + { id: 'augmented', label: 'Augmented', group: 'triad', steps: [0, 4, 8] }, + { id: 'sus2', label: 'Sus2', group: 'triad', steps: [0, 2, 7] }, + { id: 'sus4', label: 'Sus4', group: 'triad', steps: [0, 5, 7] }, + { id: 'dom7', label: 'Dom7', group: 'seventh', steps: [0, 4, 7, 10] }, + { id: 'maj7', label: 'Maj7', group: 'seventh', steps: [0, 4, 7, 11] }, + { id: 'min7', label: 'Min7', group: 'seventh', steps: [0, 3, 7, 10] }, + { id: 'halfdim7', label: 'Half-dim7', group: 'seventh', steps: [0, 3, 6, 10] }, + { id: 'dim7', label: 'Dim7', group: 'seventh', steps: [0, 3, 6, 9] }, ]; export function isChordId(value: unknown): value is ChordId { diff --git a/web/src/lib/notes/picker.ts b/web/src/lib/notes/picker.ts index a1ca74d..b4cc399 100644 --- a/web/src/lib/notes/picker.ts +++ b/web/src/lib/notes/picker.ts @@ -1,14 +1,14 @@ -// The note picker's rules: up to three notes from the picker's octave, the +// The note picker's rules: up to four notes from the picker's octave, the // first one picked being the root, plus the share-link spelling of a pick // (`#/notes?n=60,67`). Pure, so the lab page only wires clicks to it. import { PICKER_RANGE } from './theory'; export const MIN_NOTES = 0; -export const MAX_NOTES = 3; +export const MAX_NOTES = 4; /** * Click a chip: append a note that is not picked yet, remove one that is (the - * last one too: an empty pick is allowed). A refusal (a fourth note, a midi + * last one too: an empty pick is allowed). A refusal (a fifth note, a midi * number the picker does not offer) returns the same array it was given, so * the caller can tell "nothing changed" with `===` and skip the engine push. */ @@ -20,7 +20,7 @@ export function toggleNote(notes: readonly number[], midi: number): readonly num /** * Whatever the URL (or anyone) hands us, cut down to a legal pick: integers - * within the picker, each at most once (first occurrence wins), at most three. + * within the picker, each at most once (first occurrence wins), at most four. * May be empty; the caller supplies the default. */ export function sanitizeNotes(candidates: readonly number[]): number[] { diff --git a/web/src/lib/notes/theory.ts b/web/src/lib/notes/theory.ts index dae699b..55169cb 100644 --- a/web/src/lib/notes/theory.ts +++ b/web/src/lib/notes/theory.ts @@ -78,8 +78,8 @@ export const PICKER_RANGE: readonly number[] = Array.from({ length: 36 }, (_, i) * The viz colours as hex, for the legend, chips and overlay labels. The Rust * visualization's defaults are the source of truth; these copy them. */ -export const NOTE_COLORS: { bars: readonly [string, string, string]; trail: string; sum: string } = { - bars: ['#F2B23C', '#F0665C', '#6CC68E'], +export const NOTE_COLORS: { bars: readonly [string, string, string, string]; trail: string; sum: string } = { + bars: ['#F2B23C', '#F0665C', '#6CC68E', '#7FA8E8'], trail: '#F5E6CC', sum: '#E6E9EE', }; From a3ad01511758ccf9010b36413bea55f854fd0ac8 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:39:14 -0400 Subject: [PATCH 06/12] feat(engine): visualizations can expose a readout, read with Engine::viz_readout --- crates/viz-core/src/engine/erased.rs | 6 ++++++ crates/viz-core/src/engine/mod.rs | 6 ++++++ crates/viz-core/src/traits.rs | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/crates/viz-core/src/engine/erased.rs b/crates/viz-core/src/engine/erased.rs index dc62a5b..832a262 100644 --- a/crates/viz-core/src/engine/erased.rs +++ b/crates/viz-core/src/engine/erased.rs @@ -191,6 +191,8 @@ pub trait ErasedVisualization { fn handle_input(&mut self, ev: &InputEvent); fn tick(&mut self, dt: f32); fn set_zoom(&mut self, zoom: f32); + /// Forwards `Visualization::readout`. + fn readout(&self) -> Value; } /// A concrete `Visualization` together with its deserialized config. @@ -255,6 +257,10 @@ where fn set_zoom(&mut self, zoom: f32) { self.viz.set_zoom(zoom); } + + fn readout(&self) -> Value { + self.viz.readout() + } } #[cfg(test)] diff --git a/crates/viz-core/src/engine/mod.rs b/crates/viz-core/src/engine/mod.rs index e08136d..518412d 100644 --- a/crates/viz-core/src/engine/mod.rs +++ b/crates/viz-core/src/engine/mod.rs @@ -201,6 +201,12 @@ impl Engine { } } + /// The active visualization's `readout()` (view state the UI mirrors, + /// such as the notes lab's 4D angle); `null` for vizzes without one. + pub fn viz_readout(&self) -> JsValue { + to_js(&self.viz.readout()) + } + pub fn viz_config(&self) -> JsValue { to_js(&self.viz_cfg) } diff --git a/crates/viz-core/src/traits.rs b/crates/viz-core/src/traits.rs index f6a1788..72c196c 100644 --- a/crates/viz-core/src/traits.rs +++ b/crates/viz-core/src/traits.rs @@ -112,6 +112,13 @@ pub trait Visualization { /// default framing); >1 zooms in, <1 zooms out. Visualizations that /// support zoom override this; default is no-op. fn set_zoom(&mut self, _zoom: f32) {} + + /// Optional read-only snapshot of view state the UI mirrors every frame + /// (e.g. the notes lab's 4D angle, which the viz advances in `tick`). + /// Keep it cheap. Default: `null`. + fn readout(&self) -> serde_json::Value { + serde_json::Value::Null + } } /// Pointer / keyboard input forwarded from the canvas to the active viz. @@ -189,4 +196,20 @@ mod tests { other => panic!("wrong variant: {other:?}"), } } + + struct Bare; + impl Visualization for Bare { + type State = crate::rules::notes::NotesState; + type Config = crate::visualizations::notes::NotesVizConfig; + fn id(&self) -> &'static str { + "bare" + } + fn init(&mut self, _gl: &WebGl2RenderingContext, _cfg: &Self::Config) {} + fn render(&mut self, _gl: &WebGl2RenderingContext, _s: &Self::State, _c: &Self::Config) {} + } + + #[test] + fn readout_defaults_to_null() { + assert_eq!(Bare.readout(), serde_json::Value::Null); + } } From c247e9e27aff632e21245e4898665e7369d99af7 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:40:01 -0400 Subject: [PATCH 07/12] feat(web): label anchors for a fourth bar; the ratio hangs from the strip corner --- web/src/lib/notes/__tests__/layout.test.ts | 31 ++++++++++++++++------ web/src/lib/notes/layout.ts | 19 +++++++++---- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/web/src/lib/notes/__tests__/layout.test.ts b/web/src/lib/notes/__tests__/layout.test.ts index 8c23470..37c52f7 100644 --- a/web/src/lib/notes/__tests__/layout.test.ts +++ b/web/src/lib/notes/__tests__/layout.test.ts @@ -176,30 +176,45 @@ describe('toDevice', () => { }); describe('labelAnchors', () => { - it('puts each bar label at its bar end and the ratio at the square bottom-centre', () => { - const { bars, ratio } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }); + const STRIP = { x: 140, y: 30, w: 300, h: 45 }; + + it('puts each bar label at its bar end, the bottom bar at its right end', () => { + const { bars } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); expectPoint(bars[0], 16, 108); // left bar: bottom end, 6% in expectPoint(bars[1], 98, 26); // top bar: right end, 6% down expectPoint(bars[2], 104, 108); // right bar: bottom end, 94% across + expectPoint(bars[3], 98, 114); // bottom bar: right end, 6% up from the bottom + }); + + it('hangs the ratio in the strip top-left corner', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, STRIP); + expectPoint(ratio, 140, 30); + expect(ratioInCorner).toBe(true); + }); + + it('keeps the ratio at the figure bottom-centre when there is no strip', () => { + const { ratio, ratioInCorner } = labelAnchors({ x: 10, y: 20, w: 100, h: 100 }, ZERO); expectPoint(ratio, 60, 114); + expect(ratioInCorner).toBe(false); }); it('scales with the square', () => { - const { bars, ratio } = labelAnchors({ x: 0, y: 0, w: 200, h: 200 }); + const { bars } = labelAnchors({ x: 0, y: 0, w: 200, h: 200 }, ZERO); expectPoint(bars[0], 12, 176); expectPoint(bars[1], 176, 12); expectPoint(bars[2], 188, 176); - expectPoint(ratio, 100, 188); + expectPoint(bars[3], 176, 188); }); - it('keeps every anchor inside the figure', () => { - const { figure } = notesLayout(1168, 650); - const { bars, ratio } = labelAnchors(figure); - for (const p of [...bars, ratio]) { + it('keeps every bar anchor inside the figure and the ratio on the strip', () => { + const { figure, strip } = notesLayout(1168, 650); + const { bars, ratio } = labelAnchors(figure, strip); + for (const p of bars) { expect(p.x).toBeGreaterThan(figure.x); expect(p.x).toBeLessThan(figure.x + figure.w); expect(p.y).toBeGreaterThan(figure.y); expect(p.y).toBeLessThan(figure.y + figure.h); } + expectPoint(ratio, strip.x, strip.y); }); }); diff --git a/web/src/lib/notes/layout.ts b/web/src/lib/notes/layout.ts index 299bd1b..f43bcbb 100644 --- a/web/src/lib/notes/layout.ts +++ b/web/src/lib/notes/layout.ts @@ -85,20 +85,29 @@ export function toDevice(r: Rect, dpr: number): DeviceRect { /** * Where the overlay hangs its text, for a figure of side `figure.w`: one label * at each bar's end (left bar at the bottom, top bar at the right, right bar - * at the bottom) and the ratio readout at the bottom-centre. The bars lie on - * their gutters' midlines, half of `BAR_INSET` in from the figure's edge, and - * span the plot, which ends `BAR_INSET` short of the opposite edge. + * at the bottom, bottom bar at the right) and the ratio readout. The bars lie + * on their gutters' midlines, half of `BAR_INSET` in from the figure's edge, + * and span the plot, which ends `BAR_INSET` short of the opposite edge. The + * ratio hangs from the strip's top-left corner (the bottom gutter now holds + * the fourth bar), or sits at the figure's bottom-centre when there is no + * strip; `ratioInCorner` says which, so the page can align the pill. */ -export function labelAnchors(figure: Rect): { bars: [Point, Point, Point]; ratio: Point } { +export function labelAnchors( + figure: Rect, + strip: Rect, +): { bars: [Point, Point, Point, Point]; ratio: Point; ratioInCorner: boolean } { const s = figure.w; const mid = BAR_INSET / 2; const far = 1 - BAR_INSET; + const ratioInCorner = strip.w > 0 && strip.h > 0; return { bars: [ { x: figure.x + mid * s, y: figure.y + far * s }, { x: figure.x + far * s, y: figure.y + mid * s }, { x: figure.x + (1 - mid) * s, y: figure.y + far * s }, + { x: figure.x + far * s, y: figure.y + (1 - mid) * s }, ], - ratio: { x: figure.x + 0.5 * s, y: figure.y + (1 - mid) * s }, + ratio: ratioInCorner ? { x: strip.x, y: strip.y } : { x: figure.x + 0.5 * s, y: figure.y + (1 - mid) * s }, + ratioInCorner, }; } From f46d7629d02b34322a01deac085cf3e5197e0a97 Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:40:44 -0400 Subject: [PATCH 08/12] feat(web): LabApi reads the visualization's readout --- web/src/lib/components/labApi.svelte.ts | 5 +++++ web/src/lib/test/fakeViz.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/web/src/lib/components/labApi.svelte.ts b/web/src/lib/components/labApi.svelte.ts index cc6e5bd..bd9e8dc 100644 --- a/web/src/lib/components/labApi.svelte.ts +++ b/web/src/lib/components/labApi.svelte.ts @@ -72,4 +72,9 @@ export class LabApi { readSummary(): unknown { return this.engine?.rule_summary() ?? null; } + + /** Read the visualization's readout (view state it advances itself, e.g. the notes lab's 4D angle), or null. */ + readVizReadout(): unknown { + return this.engine?.viz_readout() ?? null; + } } diff --git a/web/src/lib/test/fakeViz.ts b/web/src/lib/test/fakeViz.ts index 5f4dd02..ff73b9a 100644 --- a/web/src/lib/test/fakeViz.ts +++ b/web/src/lib/test/fakeViz.ts @@ -85,6 +85,9 @@ export const notesSummaryFixture: NotesSummary = { ], }; +/** What the fake's `viz_readout()` returns on the notes lab (null elsewhere). Mutable so a test can move the 4D angle. */ +export const notesVizReadout = { angle_4d: 0 }; + export class FakeEngine { private readonly _lab: string | null | undefined; @@ -114,6 +117,7 @@ export class FakeEngine { if (this._lab === 'notes') return notesSummaryFixture; return null; } + viz_readout() { return this._lab === 'notes' ? notesVizReadout : null; } viz_config() { return {}; } update_rule_config(cfg: unknown) { updateRuleConfigSpy(cfg); } update_rule_config_with_path(cfg: unknown, xy: Float32Array, pen: Uint8Array) { updateRuleConfigWithPathSpy(cfg, xy, pen); } From a346bcf2debc8e13b0eba37da1654939d4d6367d Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:40:56 -0400 Subject: [PATCH 09/12] feat(viz): pure 4D helpers for the notes tesseract (rotate, project, edges) --- crates/viz-core/src/visualizations/notes.rs | 130 ++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/crates/viz-core/src/visualizations/notes.rs b/crates/viz-core/src/visualizations/notes.rs index af6c80d..2ba732f 100644 --- a/crates/viz-core/src/visualizations/notes.rs +++ b/crates/viz-core/src/visualizations/notes.rs @@ -577,6 +577,55 @@ pub fn cube_edges(half: f32) -> [[f32; 3]; 24] { out } +// ---- The fourth dimension ---- + +/// The 4D eye's distance along w: perspective scales a point by +/// `D / (D − w)`, so the far cube of a tesseract shrinks inside the near one. +pub const PERSPECTIVE_4D: f32 = 3.0 * CUBE_HALF; +/// Brings the perspective-projected tesseract back inside the 3D cube's +/// bounding sphere (radius `√3 · CUBE_HALF`) at every turn, so the camera +/// framing is unchanged. The worst case over a full turn is √(5/12) ≈ 0.6455; +/// pinned by `the_turning_tesseract_stays_inside_the_cube_bounds`. +pub const FIT_4D: f32 = 0.645; + +/// Turns `p` = (x, y, z, w) by `theta` in the x–w plane and then in the z–w +/// plane. Periodic in 2π, so one slider covers every view; y (the root) is +/// never touched. At a quarter turn the fourth note lies along x. +pub fn rotate_4d(p: [f32; 4], theta: f32) -> [f32; 4] { + let (s, c) = theta.sin_cos(); + let [x, y, z, w] = p; + let (x, w) = (x * c - w * s, x * s + w * c); + let (z, w) = (z * c - w * s, z * s + w * c); + [x, y, z, w] +} + +/// Perspective from 4D to 3D along w, then `FIT_4D`: larger w (nearer the 4D +/// eye) draws larger. +pub fn project_4d(p: [f32; 4]) -> [f32; 3] { + let scale = FIT_4D * PERSPECTIVE_4D / (PERSPECTIVE_4D - p[3]); + [p[0] * scale, p[1] * scale, p[2] * scale] +} + +/// The tesseract `[-half, half]⁴` as its 32 edges: each joins two corners +/// that differ in exactly one coordinate. +pub fn tesseract_edges(half: f32) -> [[[f32; 4]; 2]; 32] { + let corner = |bits: u32| -> [f32; 4] { + std::array::from_fn(|axis| if bits & (1 << axis) != 0 { half } else { -half }) + }; + let mut out = [[[0.0; 4]; 2]; 32]; + let mut i = 0; + for bits in 0..16u32 { + for axis in 0..4 { + let other = bits ^ (1 << axis); + if other > bits { + out[i] = [corner(bits), corner(other)]; + i += 1; + } + } + } + out +} + /// `c` with its alpha scaled by `scale`. pub fn with_alpha(c: [f32; 4], scale: f32) -> [f32; 4] { [c[0], c[1], c[2], c[3] * scale] @@ -1829,4 +1878,85 @@ mod tests { } assert_eq!(prev, 0.0); } + + // ---- the fourth dimension ---- + + fn near4(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) + } + fn len3(p: [f32; 3]) -> f32 { + (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt() + } + + #[test] + fn rotate_4d_is_the_identity_at_zero_and_periodic() { + let p = [0.1, -0.2, 0.3, 0.4]; + assert!(near4(rotate_4d(p, 0.0), p)); + assert!(near4(rotate_4d(p, std::f32::consts::TAU), p)); + assert!(near4( + rotate_4d(p, 1.0 + std::f32::consts::TAU), + rotate_4d(p, 1.0) + )); + } + + #[test] + fn rotate_4d_keeps_y_and_brings_w_onto_x_at_a_quarter_turn() { + let q = rotate_4d([0.0, 0.25, 0.0, 0.5], std::f32::consts::FRAC_PI_2); + assert!((q[0] + 0.5).abs() < 1e-5, "x′ = −w, got {q:?}"); + assert!((q[1] - 0.25).abs() < 1e-6, "y is untouched"); + } + + #[test] + fn rotate_4d_preserves_length() { + let p = [0.3, -0.1, 0.2, -0.4]; + let l = |v: [f32; 4]| v.iter().map(|c| c * c).sum::(); + assert!((l(rotate_4d(p, 0.7)) - l(p)).abs() < 1e-5); + } + + #[test] + fn project_4d_shrinks_points_further_along_w() { + let near = project_4d([0.3, 0.3, 0.3, -0.4]); + let far = project_4d([0.3, 0.3, 0.3, 0.4]); + // w toward the viewer (larger w) scales up; away scales down. + assert!(len3(far) > len3(near)); + assert!((project_4d([0.0; 4])[0]).abs() < 1e-7); + } + + #[test] + fn the_turning_tesseract_stays_inside_the_cube_bounds() { + let limit = 3f32.sqrt() * CUBE_HALF + 1e-4; + let mut worst = 0f32; + for k in 0u32..720 { + let theta = std::f32::consts::TAU * k as f32 / 720.0; + for [a, b] in tesseract_edges(CUBE_HALF) { + for p in [a, b] { + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + // Interior points (a trail lives inside the hypercube) too. + for i in 0u32..16 { + let f = |j: u32| ((i * 7 + j * 13 + k) % 17) as f32 / 16.0 * 2.0 - 1.0; + let p = [f(1), f(2), f(3), f(4)].map(|c| c * CUBE_HALF); + worst = worst.max(len3(project_4d(rotate_4d(p, theta)))); + } + } + assert!(worst <= limit, "worst radius {worst} > {limit}"); + assert!(worst > 0.97 * limit, "FIT_4D wastes the frame: {worst}"); + } + + #[test] + fn tesseract_has_32_edges_each_along_one_axis() { + let edges = tesseract_edges(0.5); + assert_eq!(edges.len(), 32); + for [a, b] in edges { + let diff = (0..4).filter(|&i| (a[i] - b[i]).abs() > 1e-6).count(); + assert_eq!(diff, 1, "{a:?} → {b:?}"); + assert!(a.iter().chain(&b).all(|c| (c.abs() - 0.5).abs() < 1e-6)); + } + // No edge appears twice. + let mut keys: Vec = edges.iter().map(|e| format!("{e:?}")).collect(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), 32); + } } From 182beddca01f8d106a65b376b3855d5de088081d Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Tue, 13 Oct 2026 09:44:54 -0400 Subject: [PATCH 10/12] feat(web): sevenths and four notes in the notes lab, with 4D turn controls --- README.md | 12 +- .../2026-10-04-notes-and-chords-design.md | 1 + .../lib/components/__tests__/NotesLab.test.ts | 129 +++++++++++++++--- web/src/lib/components/labs/NotesLab.svelte | 98 ++++++++++--- web/src/lib/notes/summary.ts | 2 +- 5 files changed, 198 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d14594f..71426f3 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,18 @@ 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 C3 to B5, or set -> **Pick** to a chord (Major, Minor, Diminished, Augmented, Sus2, Sus4) and press its -> root; each note 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 +> - **Notes & Chords** (`#/notes`) — pick up to four notes from C3 to B5, or set +> **Pick** to a chord (six triads, or a seventh: Dom7, Maj7, Min7, Half-dim7, Dim7) and +> press its root; each note 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), three draw a 3D curve in a turning cube and four a +> curve in a tesseract that turns through the fourth dimension (**4D turn** holds it, +> **Turn** restarts it). The > speed slider is the first note's swings per second (0.25–1000 Hz); **Real pitch** > ramps it to that note's true frequency (a new root carries the speed along by the step > between the roots, so the chord moves in pitch, not just in name) and **Sound** voices the notes once past about > 20 swings per second. **Pure ratios** closes the loop; **Piano** tuning never quite > does. Share a pick with `#/notes?n=60,67` (`&t=equal` for piano tuning, -> `&c=major` for chord mode). +> `&c=major` or `&c=dom7` for chord mode). > > Every lab page has the same shape: a labelled control bezel, the canvas mounted as a > framed dark stage (a thin terracotta clock line along its top edge fills with the 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 40288cc..fa69678 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 @@ -3,6 +3,7 @@ **Date:** 2026-10-04 **Status:** Approved for implementation **Scope:** a fourth lab, `#/notes`: Rust rule + WebGL2 visualization in `crates/viz-core`, a Svelte lab page, a home tile, docs. +**Extended by:** `2026-10-12-notes-tesseract-design.md` (four notes, sevenths, the tesseract). ## Summary diff --git a/web/src/lib/components/__tests__/NotesLab.test.ts b/web/src/lib/components/__tests__/NotesLab.test.ts index 08e7e93..b28c128 100644 --- a/web/src/lib/components/__tests__/NotesLab.test.ts +++ b/web/src/lib/components/__tests__/NotesLab.test.ts @@ -6,6 +6,7 @@ import { updateRuleConfigSpy, updateVizConfigSpy, notesSummaryFixture, + notesVizReadout, } from '../../test/fakeViz'; import { navigate } from '../../router.svelte'; import { PICKER_RANGE, midiToHz, noteLabel } from '../../notes/theory'; @@ -46,6 +47,8 @@ type RuleConfig = { notes: number[]; just_intonation: boolean }; const kinds = () => dispatchSpy.mock.calls.map(([c]) => (c as Dispatched).kind); /** The last rule config the lab pushed. */ const lastPush = () => updateRuleConfigSpy.mock.lastCall?.[0] as RuleConfig; +/** The last viz config the lab pushed. */ +const lastViz = () => updateVizConfigSpy.mock.lastCall?.[0] as Record; /** Every speed dispatched (SetSpeed values), oldest first. */ const speeds = () => dispatchSpy.mock.calls.map(([c]) => c as Dispatched).filter((c) => c.kind === 'SetSpeed').map((c) => c.value); @@ -63,7 +66,7 @@ describe('NotesLab.svelte', () => { function chipFor(getByRole: (role: string, opts: { name: string }) => HTMLElement, midi: number) { return getByRole('button', { name: noteLabel(midi) }) as HTMLButtonElement; } - /** The order badge on a chip (1, 2 or 3), or undefined when the chip is not picked. */ + /** The order badge on a chip (1 to 4), or undefined when the chip is not picked. */ const badge = (chip: HTMLElement) => chip.querySelector('.badge')?.textContent; describe('shell', () => { @@ -114,20 +117,19 @@ describe('NotesLab.svelte', () => { expect(badge(chipFor(getByRole, 64))).toBe('2'); }); - it('stops at three notes: every other chip is marked unavailable with a hint but stays focusable, and a click on one pushes nothing', async () => { + it('stops at four notes: every other chip is marked unavailable with a hint but stays focusable, and a click on one pushes nothing', async () => { const { container, getByRole } = await renderLab(); - await fireEvent.click(chipFor(getByRole, 64)); - await fireEvent.click(chipFor(getByRole, 67)); - expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67] })); + for (const midi of [64, 67, 70]) await fireEvent.click(chipFor(getByRole, midi)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); const unpicked = [...container.querySelectorAll('.chip[aria-pressed="false"]')]; - expect(unpicked).toHaveLength(33); + expect(unpicked).toHaveLength(32); 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 - expect(chip.title).toBe('Pick up to three notes'); + expect(chip.title).toBe('Pick up to four notes'); } - for (const midi of [60, 64, 67]) expect(chipFor(getByRole, midi).getAttribute('aria-disabled')).not.toBe('true'); + for (const midi of [60, 64, 67, 70]) expect(chipFor(getByRole, midi).getAttribute('aria-disabled')).not.toBe('true'); updateRuleConfigSpy.mockClear(); dispatchSpy.mockClear(); @@ -476,7 +478,7 @@ describe('NotesLab.svelte', () => { expect(updateVizConfigSpy).toHaveBeenLastCalledWith( expect.objectContaining({ figure: toDevice(figure, 1), strip: toDevice(strip, 1) }), ); - const anchors = labelAnchors(figure); + const anchors = labelAnchors(figure, strip); const px = (el: Element | null, side: 'left' | 'top') => parseFloat((el as HTMLElement).style[side]); container.querySelectorAll('.note-label').forEach((label, i) => { expect(px(label, 'left')).toBeCloseTo(anchors.bars[i].x); @@ -484,6 +486,7 @@ describe('NotesLab.svelte', () => { }); expect(px(container.querySelector('.ratio'), 'left')).toBeCloseTo(anchors.ratio.x); expect(px(container.querySelector('.ratio'), 'top')).toBeCloseTo(anchors.ratio.y); + expect(container.querySelector('.ratio')?.classList.contains('corner')).toBe(anchors.ratioInCorner); } finally { width.mockRestore(); height.mockRestore(); @@ -509,6 +512,19 @@ describe('NotesLab.svelte', () => { notesSummaryFixture.notes = original; } }); + + it('labels four bars and hangs the ratio in the strip corner', async () => { + // The fixture summary has two notes; give it four for this read. + const saved = notesSummaryFixture.notes; + notesSummaryFixture.notes = [...saved, saved[0], saved[1]].slice(0, 4); + try { + const { container } = await renderLab('n=60,64,67,70'); + await vi.waitFor(() => expect(container.querySelectorAll('.note-label')).toHaveLength(4)); + expect(container.querySelectorAll('.note-label.beside')).toHaveLength(2); + } finally { + notesSummaryFixture.notes = saved; + } + }); }); describe('share link', () => { @@ -530,7 +546,8 @@ describe('NotesLab.svelte', () => { it.each([ ['n=abc', '#/notes', [60]], ['n=none', '#/notes?n=none', []], - ['n=60,61,62,63', '#/notes?n=60,61,62', [60, 61, 62]], + ['n=60,61,62,63', '#/notes?n=60,61,62,63', [60, 61, 62, 63]], + ['n=60,64,67,70,72', '#/notes?n=60,64,67,70', [60, 64, 67, 70]], ['n=69,85,90', '#/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) => { @@ -567,25 +584,50 @@ describe('NotesLab.svelte', () => { expect(badge(chipFor(getByRole, 69))).toBe('2'); expect(getByRole('button', { name: 'Piano' }).getAttribute('aria-pressed')).toBe('true'); }); + + it('keeps a linked chord mode and its notes as given: links are not re-voiced', async () => { + await renderLab('n=60,64,67&c=dom7'); + expect(updateRuleConfigSpy).toHaveBeenCalledWith(expect.objectContaining({ notes: [60, 64, 67] })); + expect(location.hash).toBe('#/notes?n=60,64,67&c=dom7'); + }); }); describe('chord mode', () => { type Get = (role: string, opts: { name: string }) => HTMLElement; - /** The Pick radio group's choices: Single notes, then the six chords. */ + /** The Pick radio group's choices: Single notes, then the six triads and the five sevenths. */ const radios = (getByRole: Get) => [...getByRole('radiogroup', { name: 'Pick' }).querySelectorAll('input[type="radio"]')]; /** The label of the choice that is on. */ const picked = (getByRole: Get) => radios(getByRole).find((r) => r.checked)?.closest('label')?.textContent?.trim(); const choose = (getByRole: Get, name: string) => fireEvent.click(getByRole('radio', { name })); - it('starts on single notes and offers the six triads as radio buttons', async () => { - const { getByRole } = await renderLab(); + it('starts on single notes and offers the six triads and five sevenths as radio buttons', async () => { + const { getByRole, getByText } = await renderLab(); expect(picked(getByRole)).toBe('Single notes'); expect(radios(getByRole).map((r) => r.closest('label')?.textContent?.trim())).toEqual([ 'Single notes', 'Major', 'Minor', 'Diminished', 'Augmented', 'Sus2', 'Sus4', + 'Dom7', 'Maj7', 'Min7', 'Half-dim7', 'Dim7', ]); // One group: a shared name, so the arrow keys move between them. expect(new Set(radios(getByRole).map((r) => r.name)).size).toBe(1); + expect(getByText('Sevenths')).toBeTruthy(); + }); + + it('a seventh builds four notes on the pressed root', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Dom7'); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [60, 64, 67, 70] })); + expect(badge(chipFor(getByRole, 70))).toBe('4'); + await fireEvent.click(chipFor(getByRole, 62)); + expect(lastPush()).toEqual(expect.objectContaining({ notes: [62, 66, 69, 72] })); + expect(location.hash).toBe('#/notes?n=62,66,69,72&c=dom7'); + }); + + it('greys out a root whose seventh would pass B5', async () => { + const { getByRole } = await renderLab(); + await choose(getByRole, 'Maj7'); + expect(chipFor(getByRole, 72).getAttribute('aria-disabled')).not.toBe('true'); + expect(chipFor(getByRole, 73).getAttribute('aria-disabled')).toBe('true'); }); it('choosing a chord builds it on the current root, then plays', async () => { @@ -605,7 +647,7 @@ describe('NotesLab.svelte', () => { await choose(getByRole, 'Minor'); await fireEvent.click(chipFor(getByRole, 57)); // A3 minor expect(lastPush()).toEqual(expect.objectContaining({ notes: [57, 60, 64] })); - // No three-note lock-out in chord mode: every chip stays available. + // No full-pick lock-out in chord mode: every chip stays available. expect(chipFor(getByRole, 50).getAttribute('aria-disabled')).not.toBe('true'); await fireEvent.click(chipFor(getByRole, 50)); // D3 minor expect(lastPush()).toEqual(expect.objectContaining({ notes: [50, 53, 57] })); @@ -657,7 +699,7 @@ describe('NotesLab.svelte', () => { }); it('drops a chord id it does not know', async () => { - const { getByRole } = await renderLab('n=60&c=dom7'); + const { getByRole } = await renderLab('n=60&c=dom9'); expect(picked(getByRole)).toBe('Single notes'); expect(location.hash).toBe('#/notes'); }); @@ -669,10 +711,65 @@ describe('NotesLab.svelte', () => { }); }); + describe('4D turn', () => { + const turnButton = (getByRole: (r: string, o: { name: string }) => HTMLElement) => + getByRole('button', { name: 'Turn' }); + + it('shows its controls only with four notes', async () => { + const { queryByLabelText, getByRole } = await renderLab('n=60,64,67'); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).not.toBeNull(); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('the slider follows the viz while turning', async () => { + const { getByLabelText, container } = await renderLab('n=60,64,67,70'); + notesVizReadout.angle_4d = Math.PI / 2; + try { + await vi.waitFor(() => expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('90')); + expect(container.querySelector('.turn-4d .value')?.textContent).toBe('90°'); + } finally { + notesVizReadout.angle_4d = 0; + } + }); + + it('grabbing the slider holds the turn at that angle; Turn resumes it', async () => { + const { getByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + updateVizConfigSpy.mockClear(); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '45' } }); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: false })); + expect(lastViz().angle_4d as number).toBeCloseTo(Math.PI / 4); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('false'); + await fireEvent.click(turnButton(getByRole)); + expect(lastViz()).toEqual(expect.objectContaining({ turn_4d: true })); + expect(turnButton(getByRole).getAttribute('aria-pressed')).toBe('true'); + }); + + it('hides the 4D controls below four notes and keeps the held angle', async () => { + const { getByLabelText, queryByLabelText, getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.input(getByLabelText('4D turn'), { target: { value: '30' } }); + await fireEvent.click(chipFor(getByRole, 70)); + expect(queryByLabelText('4D turn')).toBeNull(); + await fireEvent.click(chipFor(getByRole, 70)); + expect((getByLabelText('4D turn') as HTMLInputElement).value).toBe('30'); + }); + + it('Reset turns it on again from 0', async () => { + const { getByRole } = await renderLab('n=60,64,67,70'); + await fireEvent.click(turnButton(getByRole)); // hold + updateVizConfigSpy.mockClear(); + await fireEvent.click(getByRole('button', { name: 'Reset' })); + const pushes = updateVizConfigSpy.mock.calls.map(([c]) => c as Record); + expect(pushes).toContainEqual(expect.objectContaining({ turn_4d: false, angle_4d: 0 })); + expect(pushes.at(-1)).toEqual(expect.objectContaining({ turn_4d: true })); + }); + }); + describe('legend and story', () => { it('has a swatch for each note, the trail and the sum, and a story with tips', async () => { const { container, getByRole } = await renderLab(); - expect(container.querySelectorAll('.legend .swatch')).toHaveLength(5); + expect(container.querySelectorAll('.legend .swatch')).toHaveLength(6); expect(getByRole('heading', { level: 2, name: 'How it works' })).toBeTruthy(); expect(container.querySelectorAll('.story .tip').length).toBeGreaterThan(0); }); diff --git a/web/src/lib/components/labs/NotesLab.svelte b/web/src/lib/components/labs/NotesLab.svelte index f4a3c84..c96acd8 100644 --- a/web/src/lib/components/labs/NotesLab.svelte +++ b/web/src/lib/components/labs/NotesLab.svelte @@ -1,7 +1,8 @@