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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<size>`.
> Turn on **Sound** for a tone per operation (pitch rises with the value, writes ring
> brighter than compares) and a chime when a lane finishes.
> - **Notes & Chords** (`#/notes`) — pick up to three notes from 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
Expand Down
6 changes: 6 additions & 0 deletions crates/viz-core/src/engine/erased.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 6 additions & 0 deletions crates/viz-core/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ impl Engine {
}
}

/// The active visualization's `readout()` (view state the UI mirrors,
/// such as the notes lab's 4D angle); `undefined` in JS (a JSON 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)
}
Expand Down
51 changes: 40 additions & 11 deletions crates/viz-core/src/rules/notes.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<u8>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<NotesConfig>(v);
Expand All @@ -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"],
Expand Down Expand Up @@ -573,11 +602,11 @@ mod tests {
fn effective_notes_normalizes() {
let eff = |notes: &[u8]| cfg(notes, true).effective_notes();
assert_eq!(eff(&[]), Vec::<u8>::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 ----
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions crates/viz-core/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
Loading
Loading