diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 00000000..7d2320b5 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,51 @@ +name: web-playground + +# Builds the WASM playground (web/) and deploys it to GitHub Pages. +# Enable once under repo Settings → Pages → Source: "GitHub Actions". + +on: + push: + branches: [main] + paths: ['web/**', 'core/**', '.github/workflows/web.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time; let an in-progress run finish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install wasm target + run: rustup target add wasm32-unknown-unknown + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + web/target + key: web-wasm-${{ hashFiles('web/Cargo.toml', 'core/Cargo.toml') }} + - name: Build playground + run: ./web/build.sh + - uses: actions/upload-pages-artifact@v3 + with: + path: web/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/Cargo.toml b/Cargo.toml index 6579656a..000c3faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,8 @@ members = ["core", "cli", "plugin", "preview"] # `fuzz/` is an isolated nightly cargo-fuzz crate (ADR-0010); it is # deliberately not a workspace member so stable `--workspace` builds, -# clippy, and tests never touch it. -exclude = ["fuzz"] +# clippy, and tests never touch it. `web/` is likewise wasm32-only (ADR-0024). +exclude = ["fuzz", "web"] resolver = "2" [workspace.package] diff --git a/core/Cargo.toml b/core/Cargo.toml index 59910b15..998fd17f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,9 +13,16 @@ categories.workspace = true [dependencies] midly = { workspace = true } thiserror = { workspace = true } -guitarpro = { version = "0.3", default-features = false } +guitarpro = { version = "0.3", default-features = false, optional = true } serde = { workspace = true } +[features] +default = ["gp"] +# Guitar Pro import. Pulls `guitarpro`/`zip` (and a `wasm-bindgen` subtree via +# `time`/`getrandom`); disable it (`default-features = false`) for lean, +# import-free wasm builds that only need MIDI — see ADR-0024. +gp = ["dep:guitarpro"] + [dev-dependencies] serde_json = { workspace = true } proptest = { workspace = true } diff --git a/core/src/import.rs b/core/src/import.rs index 5ee3ef31..04ca08f9 100644 --- a/core/src/import.rs +++ b/core/src/import.rs @@ -4,8 +4,9 @@ //! content, so the product accepts either a `.gp3/.gp4/.gp5/.gpx` tab or a //! `.mid` file through one entry point. +#[cfg(feature = "gp")] +use crate::gp::{self, GpImportError}; use crate::{ - gp::{self, GpImportError}, midi::{self, MidiError}, score::Score, }; @@ -14,6 +15,7 @@ use crate::{ #[derive(Debug, thiserror::Error)] pub enum ImportError { /// The bytes looked like Guitar Pro, but the Guitar Pro adapter failed. + #[cfg(feature = "gp")] #[error("Guitar Pro import failed: {0}")] Gp(#[from] GpImportError), /// The bytes were not Guitar Pro, and the MIDI adapter failed. @@ -26,13 +28,15 @@ pub enum ImportError { /// Guitar Pro is tried first (it has a recognisable header); anything its /// detector rejects falls through to the MIDI importer. A Guitar Pro *parse* /// failure surfaces as [`ImportError::Gp`] rather than being masked by the MIDI -/// fallback. +/// fallback. Without the `gp` feature only MIDI is recognised. pub fn import_score_auto(data: &[u8]) -> Result { - match gp::import_gp_score(data) { - Ok(score) => Ok(score), - Err(GpImportError::UnsupportedFormat) => { - midi::import_score(data).map_err(ImportError::Midi) + #[cfg(feature = "gp")] + { + match gp::import_gp_score(data) { + Ok(score) => return Ok(score), + Err(GpImportError::UnsupportedFormat) => {} // fall through to MIDI + Err(other) => return Err(ImportError::Gp(other)), } - Err(other) => Err(ImportError::Gp(other)), } + midi::import_score(data).map_err(ImportError::Midi) } diff --git a/core/src/lib.rs b/core/src/lib.rs index 79fdb69b..947b11b6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -15,6 +15,7 @@ pub mod feature; pub mod fretboard; pub mod generate; pub mod gesture; +#[cfg(feature = "gp")] pub mod gp; pub mod import; pub mod midi; diff --git a/docs/adr/0024-web-wasm-frontend-for-mobile.md b/docs/adr/0024-web-wasm-frontend-for-mobile.md new file mode 100644 index 00000000..3130f31e --- /dev/null +++ b/docs/adr/0024-web-wasm-frontend-for-mobile.md @@ -0,0 +1,100 @@ +# ADR 0024: Ship the egui frontend to the browser (WASM) for mobile testing + +Date: 2026-06-16 +Status: Proposed + +## Context + +griff today runs only as a desktop CLI and a `ratatui` terminal preview (S8) — +both tied to a computer terminal. The primary author/tester works almost +entirely from a phone, so iterating on generation (tweaking seed / mode / +variation and *hearing* the result) is impractical: every test means getting to +a desktop. + +The pieces for a browser build are already in place: + +- **`griff-core` is WASM-clean.** It is pure compute — no filesystem, threads, + wall-clock, or the `rand` crate (the seeded PRNG is a hand-rolled `SplitMix64` + finalizer); all file I/O lives in the CLI. It compiles to + `wasm32-unknown-unknown` today (verified 2026-06-16), `serde` and collections + included. +- **ADR-0016 already chose `egui`** as the GUI renderer over a shared UI core + (view-model → interaction core → scene → renderers), and **`eframe` builds the + *same* egui app to native desktop and web/WASM**. A browser build is therefore + not a new frontend — it is the planned egui renderer targeting the browser. +- **S8 already lists** `eframe/egui window` and `MIDI playback` as its remaining + items; the pure `PianoRollView` / `Analysis` projections exist. + +What the browser changes versus the native plan is narrow: (1) audio — S8 planned +`midir`, which has no web backend; (2) input — no filesystem; (3) build/hosting. + +## Decision + +1. **The canonical web frontend is the `eframe`/`egui` app compiled to + `wasm32-unknown-unknown`** — the same renderer ADR-0016 specifies. Native and + web share one codebase; the browser is a *build target*, not a fork. That is + the M2 target. + +2. **The M1 MVP is a thin, throwaway front, not egui** — an *import-free* + `cdylib` (`web/`, no `wasm-bindgen`, no framework) that exports three C-ABI + functions, plus a static `index.html` + `app.js` that loads the `.wasm` with + `WebAssembly.instantiate(bytes, {})` and marshals a small JSON result through + linear memory. This unlocks phone testing now without the egui/Trunk/ + wasm-bindgen toolchain. It is disposable, not a second canonical renderer, so + it carries no ADR-0016 divergence debt; egui replaces it at M2. + +3. **`griff-core` gains a default-on `gp` feature** so the wasm build can drop + the Guitar Pro importer (`guitarpro`/`zip` → `time`/`getrandom` → + `wasm-bindgen`/`js-sys`). With `default-features = false` the module is + genuinely import-free and ~90 KiB; the CLI and tests keep `gp` on and are + unchanged. + +4. **Audio on web is WebAudio**, not the Web MIDI API (absent on iOS Safari, + patchy on mobile) and not `midir` (no web backend). The MVP uses a placeholder + oscillator synth fed note events from core; a bundled SoundFont (guitar tone) + is a follow-up. The playback *driver* is the one per-target seam. + +5. **Input is a fixed in-code sample** for the MVP (a file picker / drag-drop + later); the CLI keeps path-based I/O. + +6. **Build and host: `cargo build --target wasm32` → copy the `.wasm` beside the + static files → GitHub Pages** (`web/build.sh`, `.github/workflows/web.yml`). + No Trunk or `wasm-bindgen` for the MVP. A URL, no install. + +7. **Determinism is unaffected** (SPEC §6): the same controls yield the same + output in the browser too; the engine's seeded PRNG never touches wall-clock + or OS randomness. + +## Consequences + +- The maintainer can run complement — and the `VariationControl` knob — on a + phone via a URL. That is the actual ask. +- The import-free `cdylib` needs no build tooling beyond the stock wasm target: + `cargo build --target wasm32-unknown-unknown` then static hosting. Tiny payload + (~90 KiB, ~35 KiB gzipped). +- The `gp` feature gate also benefits any future wasm/plugin target that only + needs MIDI; it is a clean, default-on split. +- At M2 the per-target surface becomes: one egui codebase for desktop + web, with + the playback driver (`midir` native / WebAudio web) and input (fs vs picker) + behind seams; a SoundFont (license-checked) lands for a real tone. +- Accepted: the MVP synth is a placeholder (sawtooth + envelope), the roll is a + throwaway canvas painter, and the sample part A is fixed — all replaced as M2/M3 + land. +- Accepted: mobile browsers require a user gesture before audio starts (a tap to + unlock the `AudioContext`); SoundFont licensing/bundling is a real chore. +- Accepted: the MVP roll is throwaway; the canonical piano-roll still needs the + ADR-0016 Scene/Viewport work (S8). +- Out of scope for the MVP: offline PWA install, and corpus curation / + persistence on web (the `preview/design/` curation dock — later). + +## Roadmap + +Extends ADR-0016 and advances the S8 "egui window + playback" items toward a web +target. If it grows beyond a playground it earns its own appended stage +(append-only, per the stage-label audit). + +## See also + +- [`0016-shared-ui-core-across-frontends.md`](0016-shared-ui-core-across-frontends.md) +- [`0007-clap-first-plugin-target.md`](0007-clap-first-plugin-target.md) +- [`../stages/S8-preview-app.md`](../stages/S8-preview-app.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 02019720..2d8ce98f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -29,6 +29,7 @@ immutable; supersede it with a new one. New ADRs: copy | [0021](0021-property-invariants-over-canonical-score.md) | Property-based invariants over the canonical Score | Proposed | | [0022](0022-repeat-unfolding-as-projection.md) | Repeat unfolding is a projection, not a model rewrite | Proposed | | [0023](0023-variation-control-for-complement.md) | Control pitch/contour spread of complementary parts | Proposed | +| [0024](0024-web-wasm-frontend-for-mobile.md) | Ship the egui frontend to the browser (WASM) for mobile testing | Proposed | See also: [`../SPEC.md`](../SPEC.md), [`../glossary.md`](../glossary.md), [`../decisions.log.md`](../decisions.log.md). diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..66a23867 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +/target +/dist +Cargo.lock diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 00000000..50ef81a4 --- /dev/null +++ b/web/Cargo.toml @@ -0,0 +1,31 @@ +# Excluded from the root workspace (see root Cargo.toml `exclude`): this crate +# only builds for `wasm32-unknown-unknown`, like `fuzz/` only builds on nightly. +[package] +name = "griff-web" +version = "0.0.0" +edition = "2021" +publish = false +description = "Browser playground (WASM) for the griff engine — see docs/adr/0024." + +[lib] +# `rlib` alongside `cdylib` lets the host-side unit tests link the crate; the +# wasm build still emits the cdylib. +crate-type = ["cdylib", "rlib"] + +# `default-features = false` drops the GP importer (guitarpro/zip/time/getrandom +# → wasm-bindgen), leaving a lean, import-free wasm module. +[dependencies] +griff-core = { path = "../core", default-features = false } + +# A pure-compute WASM module: no unwinding (smaller, no import surface), squeezed +# for mobile download. +[profile.release] +panic = "abort" +opt-level = "z" +lto = true +codegen-units = 1 +strip = true + +# Own workspace root (like `fuzz/`): isolates the wasm-only target and profile +# from the stable host workspace. +[workspace] diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..c352aa16 --- /dev/null +++ b/web/README.md @@ -0,0 +1,55 @@ +# griff web playground (WASM) + +A browser front for the complement arranger — built so the engine can be driven +(and *heard*) from a phone, no install. See +[`docs/adr/0024-web-wasm-frontend-for-mobile.md`](../docs/adr/0024-web-wasm-frontend-for-mobile.md). + +This is the **MVP** (ADR-0024 §2): a deliberately thin, throwaway front — no +`wasm-bindgen`, no framework. `griff-web` is an *import-free* `cdylib` that +exports three C-ABI functions; the page (`static/`) loads the `.wasm` with +`WebAssembly.instantiate(bytes, {})` and marshals a small JSON result through +linear memory. The canonical `egui` frontend (ADR-0016) replaces it at M2. + +## What it does + +Builds a fixed sample lead (part A) and a generated complement (part B) entirely +in the browser, with live controls for **mode**, **seed**, **register offset**, +and **pitch spread** (the ADR-0023 `VariationControl`, audible on the grid-locked +modes). Deterministic: the same controls always produce the same result. + +## Build & run locally + +```sh +./web/build.sh # → web/dist/ (wasm + static) +python3 -m http.server -d web/dist 8080 # open http://localhost:8080 +``` + +The crate is wasm32-only and excluded from the root workspace (like `fuzz/`), so +stable `--workspace` builds/clippy/tests never touch it. It depends on +`griff-core` with `default-features = false`, dropping the Guitar Pro importer +(`guitarpro`/`zip`/`time`/`getrandom` → `wasm-bindgen`) — that is what keeps the +module import-free and ~90 KiB. + +## ABI + +| export | signature | meaning | +| --- | --- | --- | +| `arrange` | `(mode:u32, seed:u32, offset:i32, variation:f32) -> *const u8` | arrange; returns a pointer to JSON in linear memory | +| `arrange_len` | `() -> usize` | byte length of the last result | +| `memory` | — | the linear memory JS reads the JSON from | + +`mode`: 0 `rhythm_lock`, 1 `register_contrast`, 2 `call_response`, +3 `support_layer`, 4 `octave_double`, 5 `counter_melody`. + +Result JSON: `{ppqn, tempo, realized_spread, error, tracks:[{name, role, notes:[{p,s,d,v}]}]}`. + +## Deploy + +`.github/workflows/web.yml` builds `web/dist` and publishes it to GitHub Pages on +pushes to the default branch (enable Pages → "GitHub Actions" in repo settings). + +## Notes / next + +- Audio is a placeholder WebAudio synth (sawtooth + envelope, A left / B right). + A real SoundFont (guitar tone) is a follow-up. +- Input is a fixed in-code sample; a file picker / drag-drop comes later. diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 00000000..503125e2 --- /dev/null +++ b/web/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Build the griff web playground into web/dist/ (static, deployable anywhere). +# +# ./web/build.sh # release build → web/dist +# python3 -m http.server -d web/dist 8080 # then open http://localhost:8080 +# +# No wasm-bindgen / Trunk: griff-web is an import-free cdylib (ADR-0024), so the +# .wasm is copied next to the static files and loaded with WebAssembly.instantiate. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +out="$here/dist" + +rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true +( cd "$here" && cargo build --release --target wasm32-unknown-unknown ) + +rm -rf "$out" +mkdir -p "$out" +cp "$here"/static/* "$out"/ +cp "$here"/target/wasm32-unknown-unknown/release/griff_web.wasm "$out"/ + +size=$(wc -c < "$out/griff_web.wasm") +echo "built web/dist ($((size / 1024)) KiB wasm) — serve it with:" +echo " python3 -m http.server -d \"$out\" 8080" diff --git a/web/src/lib.rs b/web/src/lib.rs new file mode 100644 index 00000000..73568119 --- /dev/null +++ b/web/src/lib.rs @@ -0,0 +1,264 @@ +//! Browser playground (WASM) for the griff complement arranger — ADR-0024. +//! +//! A deliberately thin, throwaway front (ADR-0024 §5): no `wasm-bindgen`, no +//! framework. It exports three C-ABI functions and marshals a JSON result +//! through linear memory, so the build is just `cargo build --target +//! wasm32-unknown-unknown` and the page is static files. The canonical `egui` +//! frontend (ADR-0016) replaces this at M2. +//! +//! `arrange(mode, seed, offset, variation)` builds a fixed sample part A, runs +//! [`arrange_complement_varied`], and writes `{ppqn, tempo, realized_spread, +//! error, tracks:[A, B]}` into a thread-local buffer; JS reads it via +//! `arrange()` (pointer) + `arrange_len()` (length). + +use std::cell::RefCell; +use std::fmt::Write as _; + +use griff_core::complement::{ + arrange_complement_varied, ComplementSpec, RelationMode, VariationControl, +}; +use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; +use griff_core::generate::GenerationSeed; +use griff_core::score::{ + AtomEvent, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, Track, Voice, +}; +use griff_core::slice::TickRange; + +const PPQN: u16 = 480; +const BAR: u32 = 1920; // 4/4 at 480 PPQN +const EIGHTH: u32 = 240; +const TEMPO: f64 = 120.0; +const BARS: usize = 4; + +/// A fixed, uniform-4/4 part A spanning ~two octaves of C natural minor, so the +/// ladder modes' `pitch_spread` knob is audible and `counter_melody` has room. +fn sample_part_a() -> Score { + // C natural minor across two octaves. + const SCALE: [u8; 15] = [48, 50, 51, 53, 55, 56, 58, 60, 62, 63, 65, 67, 68, 70, 72]; + let span = 2 * (SCALE.len() - 1); // triangle-wave period over the scale + + let master_bars = (0..BARS) + .map(|i| { + let start = u32::try_from(i).unwrap_or(0) * BAR; + MasterBar { + index: i, + tick_range: TickRange::new(Ticks(start), Ticks(start + BAR)) + .expect("ordered bar range"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(TEMPO).expect("valid tempo"), + repeat: RepeatMarker::default(), + } + }) + .collect(); + + let mut groups = Vec::new(); + let per_bar = (BAR / EIGHTH) as usize; // 8 eighth notes per bar + for bar in 0..BARS { + let bar_start = u32::try_from(bar).unwrap_or(0) * BAR; + for j in 0..per_bar { + let i = bar * per_bar + j; + // Triangle contour over the scale: up then down, repeating. + let phase = i % span; + let idx = if phase < SCALE.len() { + phase + } else { + span - phase + }; + let pitch = SCALE[idx]; + let onset = bar_start + u32::try_from(j).unwrap_or(0) * EIGHTH; + groups.push(EventGroup { + kind: EventGroupKind::Single, + atoms: vec![AtomEvent::Note(griff_core::score::AtomNote { + absolute_start: Ticks(onset), + duration: Ticks(EIGHTH), + pitch: Pitch::new(pitch).unwrap_or(Pitch(48)), + velocity: Velocity::new(90).unwrap_or(Velocity(90)), + marks: NoteMarks::empty(), + position: None, + })], + technique_spans: Vec::new(), + }); + } + } + + Score { + ticks_per_quarter: PPQN, + master_bars, + tracks: vec![Track { + name: Some("A".to_string()), + channel: 0, + voices: vec![Voice { + id: 0, + event_groups: groups, + }], + tuning: Tuning::standard_e(), + }], + source_meta: None, + loss: LossReport::new(), + } +} + +fn relation_mode(mode: u32) -> RelationMode { + match mode { + 1 => RelationMode::RegisterContrast, + 2 => RelationMode::CallResponse, + 3 => RelationMode::SupportLayer, + 4 => RelationMode::OctaveDouble, + 5 => RelationMode::CounterMelody, + _ => RelationMode::RhythmLock, + } +} + +/// Appends a track's primary-voice notes as a JSON array of `{p,s,d,v}`. +fn push_notes(json: &mut String, track: &Track) { + json.push('['); + let mut first = true; + if let Some(voice) = track.voices.first() { + for group in &voice.event_groups { + for atom in &group.atoms { + if let AtomEvent::Note(n) = atom { + if !first { + json.push(','); + } + first = false; + let _ = write!( + json, + "{{\"p\":{},\"s\":{},\"d\":{},\"v\":{}}}", + n.pitch.0, n.absolute_start.0, n.duration.0, n.velocity.0 + ); + } + } + } + } + json.push(']'); +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Builds the result JSON for one arrangement request. +fn build_json(mode: u32, seed: u64, offset: i32, variation: f32) -> String { + let score = sample_part_a(); + let spec = ComplementSpec { + mode: relation_mode(mode), + register_offset: offset.clamp(-48, 48) as i8, + }; + let control = VariationControl { + pitch_spread: f64::from(variation).clamp(0.0, 1.0), + }; + + let mut json = String::with_capacity(2048); + let _ = write!(json, "{{\"ppqn\":{PPQN},\"tempo\":{TEMPO},"); + + match arrange_complement_varied(&score, 0, spec, GenerationSeed(seed), control) { + Ok(varied) => { + let combined = &varied.complement.score; + let b_index = varied.complement.part_b_index; + let b_name = combined + .tracks + .get(b_index) + .and_then(|t| t.name.clone()) + .unwrap_or_else(|| "B".to_string()); + let _ = write!( + json, + "\"realized_spread\":{:.3},\"error\":null,\"tracks\":[", + varied.realized_spread + ); + json.push_str("{\"name\":\"A\",\"role\":\"a\",\"notes\":"); + push_notes(&mut json, &score.tracks[0]); + json.push('}'); + if let Some(b_track) = combined.tracks.get(b_index) { + let _ = write!( + json, + ",{{\"name\":\"{}\",\"role\":\"b\",\"notes\":", + json_escape(&b_name) + ); + push_notes(&mut json, b_track); + json.push('}'); + } + json.push(']'); + } + Err(e) => { + // Surface the typed error; still return A so the page can draw it. + let _ = write!( + json, + "\"realized_spread\":0,\"error\":\"{:?}\",\"tracks\":[", + e + ); + json.push_str("{\"name\":\"A\",\"role\":\"a\",\"notes\":"); + push_notes(&mut json, &score.tracks[0]); + json.push_str("}]"); + } + } + json.push('}'); + json +} + +thread_local! { + static OUT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Arranges a complement and stores the JSON result; returns a pointer into +/// WASM linear memory. Read `arrange_len()` bytes from it (valid until the next +/// `arrange` call). +#[no_mangle] +pub extern "C" fn arrange(mode: u32, seed: u32, offset: i32, variation: f32) -> *const u8 { + let json = build_json(mode, u64::from(seed), offset, variation); + OUT.with(|o| { + *o.borrow_mut() = json.into_bytes(); + o.borrow().as_ptr() + }) +} + +/// Length in bytes of the JSON stored by the last [`arrange`] call. +#[no_mangle] +pub extern "C" fn arrange_len() -> usize { + OUT.with(|o| o.borrow().len()) +} + +#[cfg(test)] +mod tests { + use super::build_json; + + #[test] + fn every_mode_emits_part_a_and_well_formed_header() { + for mode in 0..6 { + let j = build_json(mode, 5, 0, 1.0); + assert!( + j.starts_with("{\"ppqn\":480,\"tempo\":120"), + "mode {mode}: {j:.60}" + ); + assert!(j.contains("\"tracks\":["), "mode {mode}: has tracks"); + assert!(j.contains("\"role\":\"a\""), "mode {mode}: has part A"); + assert!(j.ends_with('}'), "mode {mode}: closed object"); + } + } + + #[test] + fn counter_melody_succeeds_on_the_uniform_sample() { + // mode 5 = counter_melody; the sample is uniform 4/4, so no NonUniformTimeline. + let j = build_json(5, 0, 0, 1.0); + assert!(j.contains("\"error\":null"), "expected success: {j:.120}"); + assert!(j.contains("\"role\":\"b\""), "counter_melody emits part B"); + } + + #[test] + fn pitch_spread_changes_rhythm_lock_output() { + // mode 0 = rhythm_lock: the knob must move B's pitches. + let locked = build_json(0, 5, 0, 0.0); + let full = build_json(0, 5, 0, 1.0); + assert_ne!( + locked, full, + "pitch_spread must change a grid-locked complement" + ); + } + + #[test] + fn deterministic_for_identical_args() { + assert_eq!(build_json(5, 7, -12, 0.5), build_json(5, 7, -12, 0.5)); + } +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 00000000..f2cf1a09 --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,196 @@ +(() => { + 'use strict'; + const $ = (id) => document.getElementById(id); + const els = { + mode: $('mode'), seed: $('seed'), seedOut: $('seedOut'), + offset: $('offset'), offsetOut: $('offsetOut'), + variation: $('variation'), varOut: $('varOut'), + gen: $('gen'), play: $('play'), stop: $('stop'), + roll: $('roll'), status: $('status'), + }; + + const PPQN_FALLBACK = 480; + let wasm = null; // wasm exports + let current = null; // last arrange() result (parsed JSON) + let audio = null; // AudioContext + let voices = []; // scheduled oscillators + let playStartT = 0, playSpan = 0, raf = 0; + + // ---- engine ---- + async function initWasm() { + const resp = await fetch('./griff_web.wasm'); + const bytes = await resp.arrayBuffer(); + // Import-free module (ADR-0024): no import object needed. + const { instance } = await WebAssembly.instantiate(bytes, {}); + wasm = instance.exports; + } + + function arrange() { + if (!wasm) return; + const mode = +els.mode.value; + const seed = +els.seed.value; + const offset = +els.offset.value; + const variation = (+els.variation.value) / 100; + const ptr = wasm.arrange(mode, seed, offset, variation); + const len = wasm.arrange_len(); + // Read AFTER the call: arrange() may have grown linear memory. + const view = new Uint8Array(wasm.memory.buffer, ptr, len); + current = JSON.parse(new TextDecoder('utf-8').decode(view)); + draw(); + showStatus(); + } + + function showStatus() { + if (!current) return; + const a = current.tracks.find((t) => t.role === 'a'); + const b = current.tracks.find((t) => t.role === 'b'); + els.status.classList.toggle('error', !!current.error); + if (current.error) { + els.status.textContent = + `error: ${current.error} — try another offset/mode (A still shown)`; + return; + } + els.status.textContent = + `A: ${a ? a.notes.length : 0} notes · B: ${b ? b.notes.length : 0} notes` + + ` · realized spread ${Number(current.realized_spread).toFixed(2)}`; + } + + // ---- drawing ---- + function allNotes() { + if (!current) return []; + return current.tracks.flatMap((t) => t.notes.map((n) => ({ ...n, role: t.role }))); + } + + function draw(playheadTick) { + const c = els.roll, dpr = window.devicePixelRatio || 1; + const w = c.clientWidth, h = c.clientHeight; + c.width = Math.floor(w * dpr); c.height = Math.floor(h * dpr); + const ctx = c.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const notes = allNotes(); + if (notes.length === 0) return; + + const ppqn = current.ppqn || PPQN_FALLBACK; + const endTick = Math.max(...notes.map((n) => n.s + n.d), 1); + const loP = Math.min(...notes.map((n) => n.p)) - 1; + const hiP = Math.max(...notes.map((n) => n.p)) + 1; + const pad = 6, plotW = w - pad * 2, plotH = h - pad * 2; + const x = (t) => pad + (t / endTick) * plotW; + const y = (p) => pad + (1 - (p - loP) / Math.max(1, hiP - loP)) * plotH; + const rowH = plotH / Math.max(1, hiP - loP); + + // bar gridlines (4/4: bar = 4*ppqn) + ctx.strokeStyle = '#1c2230'; ctx.lineWidth = 1; + for (let t = 0; t <= endTick; t += 4 * ppqn) { + ctx.beginPath(); ctx.moveTo(x(t), pad); ctx.lineTo(x(t), h - pad); ctx.stroke(); + } + + for (const n of notes) { + const nx = x(n.s), nw = Math.max(2, x(n.s + n.d) - nx); + const ny = y(n.p) - rowH / 2, nh = Math.max(3, rowH * 0.8); + ctx.fillStyle = n.role === 'a' ? '#4aa3ff' : '#ffb24a'; + ctx.globalAlpha = 0.35 + 0.55 * (n.v / 127); + ctx.fillRect(nx, ny, nw, nh); + } + ctx.globalAlpha = 1; + + if (playheadTick != null) { + ctx.strokeStyle = '#5ad19a'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x(playheadTick), pad); ctx.lineTo(x(playheadTick), h - pad); ctx.stroke(); + } + } + + // ---- playback (placeholder synth; a real SoundFont comes later) ---- + const tickToSec = (tick, ppqn, tempo) => (tick / ppqn) * (60 / tempo); + + function play() { + if (!current || allNotes().length === 0) return; + stop(); + audio = audio || new (window.AudioContext || window.webkitAudioContext)(); + if (audio.state === 'suspended') audio.resume(); // unlock on the tap gesture + + const ppqn = current.ppqn || PPQN_FALLBACK; + const tempo = current.tempo || 120; + const t0 = audio.currentTime + 0.08; + const master = audio.createGain(); + master.gain.value = 0.25; + master.connect(audio.destination); + + let maxEnd = 0; + for (const n of allNotes()) { + const start = t0 + tickToSec(n.s, ppqn, tempo); + const dur = Math.max(0.05, tickToSec(n.d, ppqn, tempo)); + maxEnd = Math.max(maxEnd, tickToSec(n.s + n.d, ppqn, tempo)); + + const osc = audio.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = 440 * Math.pow(2, (n.p - 69) / 12); + const g = audio.createGain(); + const peak = 0.18 + 0.5 * (n.v / 127); + g.gain.setValueAtTime(0.0001, start); + g.gain.exponentialRampToValueAtTime(peak, start + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, start + dur); + osc.connect(g); + if (audio.createStereoPanner) { + const pan = audio.createStereoPanner(); + pan.pan.value = n.role === 'a' ? -0.4 : 0.4; + g.connect(pan); pan.connect(master); + } else { + g.connect(master); + } + osc.start(start); osc.stop(start + dur + 0.02); + voices.push(osc); + } + + playStartT = t0; playSpan = maxEnd; + animatePlayhead(); + } + + function animatePlayhead() { + cancelAnimationFrame(raf); + const step = () => { + if (!audio) return; + const el = audio.currentTime - playStartT; + if (el > playSpan + 0.1) { draw(); return; } + if (el >= 0) { + const ppqn = current.ppqn || PPQN_FALLBACK, tempo = current.tempo || 120; + draw((el / (60 / tempo)) * ppqn); + } + raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + } + + function stop() { + cancelAnimationFrame(raf); + for (const v of voices) { try { v.stop(); } catch (_) { /* already stopped */ } } + voices = []; + draw(); + } + + // ---- wiring ---- + function bind() { + els.seed.addEventListener('input', () => { els.seedOut.textContent = els.seed.value; arrange(); }); + els.offset.addEventListener('input', () => { els.offsetOut.textContent = els.offset.value; arrange(); }); + els.variation.addEventListener('input', () => { + els.varOut.textContent = (els.variation.value / 100).toFixed(2); arrange(); + }); + els.mode.addEventListener('change', arrange); + els.gen.addEventListener('click', arrange); + els.play.addEventListener('click', play); + els.stop.addEventListener('click', stop); + window.addEventListener('resize', () => draw()); + } + + initWasm().then(() => { + bind(); + arrange(); + els.status.textContent = 'ready — drag a slider, then ▶ Play'; + }).catch((err) => { + els.status.classList.add('error'); + els.status.textContent = 'failed to load engine: ' + err; + }); +})(); diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 00000000..35e02bd5 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,64 @@ + + + + + + + griff · complement playground + + + +
+

griff · complement playground

+

+ A fixed sample lead (part A) + a generated complement (part B), all in the + browser. Deterministic: same controls → same result. (ADR-0024, MVP) +

+ +
+ + + + + + + +
+ +
+ + + +
+ + + +

loading engine…

+

+ Part A = blue · Part B = amber. Built from griff-core compiled + to WASM; audio is a placeholder synth (a real SoundFont comes later). +

+
+ + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 00000000..aa078c77 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,97 @@ +:root { + --bg: #10131a; + --panel: #181c26; + --ink: #e7ebf3; + --muted: #8b93a7; + --a: #4aa3ff; + --b: #ffb24a; + --accent: #5ad19a; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +main { + max-width: 760px; + margin: 0 auto; + padding: max(16px, env(safe-area-inset-top)) 16px 48px; +} + +h1 { font-size: 1.25rem; margin: 0.3rem 0 0.2rem; } +.muted { color: var(--muted); } +.small { font-size: 0.85rem; } +code { color: var(--accent); } + +.controls { + display: grid; + gap: 14px; + background: var(--panel); + border: 1px solid #232838; + border-radius: 14px; + padding: 16px; + margin: 16px 0; +} + +label { + display: grid; + gap: 6px; + font-size: 0.9rem; + color: var(--muted); +} +label output { color: var(--ink); font-weight: 600; } +.hint { color: var(--muted); font-size: 0.72rem; } + +select, input[type="range"] { + width: 100%; + accent-color: var(--accent); +} +select { + appearance: none; + background: #0d1017; + color: var(--ink); + border: 1px solid #2a3146; + border-radius: 10px; + padding: 12px; + font-size: 1rem; +} +/* Big touch targets for phones. */ +input[type="range"] { height: 40px; } + +.transport { + display: flex; + gap: 10px; + margin-bottom: 16px; +} +button { + flex: 1; + padding: 14px 10px; + font-size: 1rem; + font-weight: 600; + color: var(--ink); + background: #222838; + border: 1px solid #2e3650; + border-radius: 12px; + cursor: pointer; +} +button:active { transform: translateY(1px); } +button.primary { background: var(--accent); color: #06120c; border-color: var(--accent); } + +canvas { + width: 100%; + height: 340px; + display: block; + background: #0c0f16; + border: 1px solid #232838; + border-radius: 12px; + touch-action: none; +} + +.status { margin: 12px 2px 4px; min-height: 1.2em; } +.status.error { color: #ff7a7a; }