commit 3281535a04912533fdc977c84bdc631caf3755f2 Author: Nils Pukropp Date: Thu Nov 5 22:39:36 2026 +0100 feat: add Motif Storyboard composition plugin diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5ed6338 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..394e7b4 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,38 @@ +name: Check and build +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --all --check + - run: cargo test --workspace --locked + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + plugin: + strategy: + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Linux GUI and audio headers + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libgl1-mesa-dev libx11-dev libxcursor-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxkbcommon-x11-dev + - run: cargo fmt --manifest-path plugins/motif-storyboard/Cargo.toml --check + - run: cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --locked + - name: Windows CLAP + if: runner.os == 'Windows' + run: python scripts/build-plugin.py + - name: Linux CLAP and standalone + if: runner.os == 'Linux' + run: python3 scripts/build-plugin.py --standalone + - uses: actions/upload-artifact@v4 + with: + name: motif-storyboard-${{ runner.os }} + path: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74c1cb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Rust build output (all workspaces); keep Cargo.lock files. +target/ +/dist/ +*.clap + +# Python analysis/build caches and local virtual environments. +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Local listening references remain outside source distribution. +*.wav +*.flac +*.mp3 +*.m4a +*.aif +*.aiff +*.ogg + +# Local credentials and environment overrides. +.env +.env.* +!.env.example +/.aws/ +/.codex/ + +# Editor and operating-system files. +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b5b000b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,162 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "story-app" +version = "0.3.0" +dependencies = [ + "crossbeam-queue", + "story-core", + "story-midi", + "windows-sys", +] + +[[package]] +name = "story-core" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "story-midi" +version = "0.3.0" +dependencies = [ + "story-core", +] + +[[package]] +name = "story-preview" +version = "0.3.0" +dependencies = [ + "story-core", + "story-midi", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8c6207c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = ["crates/story-core", "crates/story-midi", "crates/story-preview", "crates/story-app"] +exclude = ["plugins/motif-storyboard", "tools/editor-check"] + +[workspace.package] +version = "0.3.0" +edition = "2021" +license = "ISC" + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +story-core = { path = "crates/story-core" } +story-midi = { path = "crates/story-midi" } + +[profile.release] +lto = "thin" +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..766e332 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Motif Storyboard contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c195a29 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Motif Storyboard + +A Rust composition companion for an experienced producer developing their own +melodic ideas. Version 0.3 explores rhythm, space, contour, harmonic arrivals, and +returns, with reusable gestures, branching ideas, and two sound roles. + +![Motif Storyboard editor](docs/editor.png) + +## Write with it + +1. Import a monophonic MIDI motif of up to two bars in 4/4, or load the demo. +2. Choose a task: **Develop the motif**, **Write an answer**, **Make room**, or + **Find a return**. Choose a settled or open ending and a four/eight-bar length. +3. Decide what defines the idea. **Keep groove** fixes note positions and lengths; + **Keep contour** protects the direction of movement, allowing flattened steps + at register limits. Click source notes to lock the whole note, pitch only, or + rhythm only. Clicking uses the currently selected lock mode. Locks + and identity constraints take precedence over the proposed transformations. +4. Optionally open **Harmony** and import a chord/bass MIDI track (up to eight + bars), or create and edit a four-chord progression. Imported overlapping notes + become pitch-class regions with the actual lowest held pitch; the tool does + not guess chord roots or chord names. Empty regions remain gaps, and short + contexts repeat. Harmony guides strong notes and arrivals; the preview plays + the melody only. Supplied chord tones may extend beyond the selected scale. +5. Explore alternatives. A deterministic pool of eight transformation families + supplies up to three distinct proposals, selected for the task and differences + in rhythm, density, duration, and pitch. Fully constrained motifs may yield + fewer than three; cosmetic velocity differences do not count as new ideas. +6. Select a proposal and pin its two-bar sections. Select another to fill the + remaining sections, or explore another take. Preview and export the combined + phrase from the fixed bottom toolbar. Keep a combined result to retain it + independently of new proposals; undo/redo switches the kept result and its + previous value. Individual proposals and kept results can also be exported. + +The opening quotation stays intact. Later sections use displaced entries, +repeated pickups, fragments, subtraction, short/long answers, changed endings, +register contrast, and returns. These are compositional suggestions, not promises +of an emotion or of commercial originality. Different seeds choose structured +alternatives; they do not independently randomize every pitch. + +Pins survive task/seed/harmony changes and regeneration. Changing the motif, +its locks, or the phrase length clears pins. Pinned sections override newly +proposed material. Unsafe boundaries in legacy candidates are rejected instead +of truncating sustained notes. Changing generation inputs clears current +proposals while retaining kept work. + +## Audition and commit + +**Internal voice** provides two simple reference voices. **MIDI output** sends +notes and optional CC curves on the configured channels to routed external synths +and mutes the internal voice. Solo defaults to channel 1. +Looping is optional and enabled for new sessions. When audition is already +running, another selection waits until the next two-bar boundary or current +phrase end. Note-offs precede repeated same-pitch note-ons; stop, replacement, +loop boundaries, reset, restoration, and mode changes release active output notes +on the processing thread. Queued auditions are canceled by Stop. + +**Host tempo** follows tempo, with the local BPM as fallback. It does not align +the initial audition to the DAW's song position: start it at the desired point +in the project loop. Host start never initiates audition; host stop silences it. +Exported MIDI is in musical ticks and carries the local BPM as its tempo event. +Native OS MIDI drag-out is not implemented; export files to commit a phrase. +See [FL Studio setup and acceptance checks](docs/fl-studio.md). + +Open **Gestures, moves, and branches** to save a source or selected two-bar +section with its performance choices. Export/import a gesture library between +projects. Save a branch before experimenting; restore any branch and make a new +child with subtraction, a delayed entry, longer gates, or transposition. +Moves respect pins and locks, reject unsafe overlaps, and report when constraints +leave nothing to change. Libraries and histories each hold up to 16 entries; +remove a gesture or a leaf branch to make room. + +Open **Sound roles and expressive gestures** to pass existing notes between two +patches: alternate events, alternate question/answer bars, or divide strong beats +from replies. These modes distribute a monophonic phrase rather than generate +a second independent melody. Assign separate MIDI channels and explicitly map +each optional CC lane to a synth macro. Curves are normalized over the whole +phrase, with editable points and rise/fall/arch/pulse starting shapes. +Names describe your intent; the plugin does not infer feelings. + +Patch profiles set register offset, gate, velocity, and estimated attack/release. +MIDI audition uses a short warmup for early triggers. Re-audition after a tempo +change to recompile millisecond offsets. Compensation estimates trigger timing; +it does not measure patch latency or model its sound. Default export retains +musical positions; compensated export adds one bar of lead-in. Role tracks carry +names, channels, and CC curves. Stop resets mapped controllers to their chosen +stop values. Internal audition does not reproduce mapped timbre. +See [the theory and producer workflow](docs/theory/README.md). + +Sessions save inside the host project and can be saved/loaded as JSON. Version-1/2 +sessions and the original host field identifier remain supported; older sessions +retain one-shot audition. New sessions persist harmony, task, lock modes on notes, +pins, proposals, kept history, gestures, branches, patch profiles, CC curves, and +audition settings. JSON state is bounded at 16 MiB; gesture libraries at 2 MiB. File export/save refuses to +silently overwrite an existing file. + +## Build and verify + +The plugin is CLAP-only, using NIH-plug pinned to +`de421011f41a6d10fc8c7a6084e4f4dee0143683` with egui 0.31. VST3 is disabled. +The independent plugin workspace keeps Git/GUI downloads separate from engine +and MIDI tests. Stable Rust and Python are required for packaging. + +```sh +cargo test --workspace --offline +cargo clippy --workspace --all-targets --offline -- -D warnings +cargo run -p story-preview --offline -- demo /tmp/motif-demo +``` + +The headless demo writes source/candidate MIDI, a session JSON, and a WAV using +the same engine and voice as the plugin. To use existing MIDI, note-track indices +exclude conductor-only tracks and start at zero. Optional harmony arguments +accept a separate polyphonic MIDI file and track index. + +```sh +cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase +cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase-with-chords chords.mid 0 +cargo run -p story-preview -- session saved-session.json /tmp/rendered-session +``` + +Windows needs the Rust MSVC build tools; Linux needs X11/OpenGL development +headers, plus ALSA/JACK headers for standalone. The CI workflow lists Ubuntu +packages. A MinGW cross-build needs the `x86_64-pc-windows-gnu` Rust target and +`x86_64-w64-mingw32-gcc`. + +```sh +python scripts/build-plugin.py # native CLAP +python3 scripts/build-plugin.py --standalone # Linux CLAP + app +python3 scripts/build-plugin.py --target x86_64-pc-windows-gnu +cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --offline +cargo clippy --manifest-path plugins/motif-storyboard/Cargo.toml --all-targets --offline -- -D warnings +cargo run --manifest-path tools/editor-check/Cargo.toml --offline -- /tmp/editor.png +``` + +Packages are written to `dist//Motif Storyboard.clap`; builds do not +install into a DAW. The visual QA harness compiles the shared editor against the +same egui version and software-renders it without a display. Interactive window +embedding, FL Studio MIDI routing, and audible artistic evaluation still need +manual testing. The native Linux window cannot access X11 in this environment. +CI is configured for Windows CLAP and Linux CLAP/standalone, but has not been +triggered remotely from this workspace. + +## Design and references + +[Theory findings and implemented design](docs/theory/README.md) connect the research +to practical controls. [Implementation plan](docs/variation-plan.md) records the musical and technical +choices. [Devath reference measurements](docs/devath-reference-notes.md) document +analysis of the seven supplied FLACs, with reproducible plots and explicit limits. +Original reference audio is excluded from source distribution. + +- `story-core`: musical model, harmonic context, structured proposals, section + composition, session migration, and bounded playback clips. +- `story-midi`: bounded SMF 0/1 import/export. Rejects SMPTE, unsupported meters, + incomplete notes, and timing not exactly representable at 960 PPQ. Reports + ignored controllers/pitch bends. Polyphonic tracks work as harmonic context; + motif import requires monophony. +- `story-app`: worker jobs, revision handling, bounded queues, sample-accurate + preview/MIDI scheduling, and Windows/Zenity dialogs. +- `motif-storyboard`: CLAP wrapper, host persistence, editor, and standalone. + +Tests cover musical constraints/diversity, harmony, legacy state, section seams, +MIDI round trips, sample timing, looping, queued switches, note releases, and +zero allocation/deallocation during rendering and queued clip replacement. The +CLAP ABI test verifies discovery, note-output port exposure, state restoration, +and silent startup at several sample rates and buffer sizes. diff --git a/crates/story-app/Cargo.toml b/crates/story-app/Cargo.toml new file mode 100644 index 0000000..6e2254a --- /dev/null +++ b/crates/story-app/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "story-app" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +story-core.workspace = true +story-midi.workspace = true +crossbeam-queue = "0.3" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_UI_Controls_Dialogs", "Win32_Foundation"] } diff --git a/crates/story-app/src/dialog.rs b/crates/story-app/src/dialog.rs new file mode 100644 index 0000000..815b322 --- /dev/null +++ b/crates/story-app/src/dialog.rs @@ -0,0 +1,72 @@ +//! Dialogs execute only on the file worker. A path field remains available in the editor. +use std::path::PathBuf; +use story_core::{Error, Result}; + +#[cfg(windows)] +pub fn choose(save: bool, session: bool) -> Result> { + use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + use windows_sys::Win32::UI::Controls::Dialogs::*; + let mut buffer = [0u16; 32768]; + let filter: Vec = if session { + "Storyboard session\0*.json\0\0" + } else { + "MIDI file\0*.mid;*.midi\0\0" + } + .encode_utf16() + .collect(); + let extension: Vec = if session { "json\0" } else { "mid\0" } + .encode_utf16() + .collect(); + let mut options: OPENFILENAMEW = unsafe { std::mem::zeroed() }; + options.lStructSize = std::mem::size_of::() as u32; + options.lpstrFile = buffer.as_mut_ptr(); + options.nMaxFile = buffer.len() as u32; + options.lpstrFilter = filter.as_ptr(); + options.lpstrDefExt = extension.as_ptr(); + options.Flags = OFN_EXPLORER + | OFN_NOCHANGEDIR + | OFN_PATHMUSTEXIST + | if save { 0 } else { OFN_FILEMUSTEXIST }; + // SAFETY: Every pointer references live UTF-16 storage for the duration of the modal call. + let success = unsafe { + if save { + GetSaveFileNameW(&mut options) + } else { + GetOpenFileNameW(&mut options) + } + }; + if success == 0 { + let code = unsafe { CommDlgExtendedError() }; + return if code == 0 { + Ok(None) + } else { + Err(Error(format!( + "File dialog failed ({code}). Enter a path instead." + ))) + }; + } + let len = buffer.iter().position(|&c| c == 0).unwrap_or(buffer.len()); + Ok(Some(PathBuf::from(OsString::from_wide(&buffer[..len])))) +} + +#[cfg(not(windows))] +pub fn choose(save: bool, session: bool) -> Result> { + let mut cmd = std::process::Command::new("zenity"); + cmd.arg("--file-selection").arg(if session { + "--file-filter=Session | *.json" + } else { + "--file-filter=MIDI | *.mid *.midi" + }); + if save { + cmd.arg("--save"); + } + let output = cmd.output().map_err(|_| { + Error("File dialog unavailable. Enter a path in the editor instead.".into()) + })?; + if !output.status.success() { + return Ok(None); + } + let path = String::from_utf8(output.stdout) + .map_err(|_| Error("File path is not UTF-8. Enter it manually.".into()))?; + Ok(Some(PathBuf::from(path.trim_end_matches('\n')))) +} diff --git a/crates/story-app/src/lib.rs b/crates/story-app/src/lib.rs new file mode 100644 index 0000000..e0c4dd7 --- /dev/null +++ b/crates/story-app/src/lib.rs @@ -0,0 +1,474 @@ +//! Shared application state and bounded control messages. The audio callback never locks a session. +use crossbeam_queue::ArrayQueue; +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, RwLock, + }, +}; +use story_core::{ + Audition, Candidate, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip, + PerformancePlayer, Result, Session, SECTION, +}; +pub mod dialog; + +#[derive(Clone, Copy)] +pub struct PlayRequest { + pub epoch: u64, + pub clip: PerformanceClip, + pub quantized: bool, +} + +pub struct Shared { + pub session: RwLock, + pub epoch: AtomicU64, + pub revision: AtomicU64, + pub tempo: AtomicU64, + pub audition_tempo: AtomicU64, + pub follow_host: AtomicBool, + pub midi: AtomicBool, + pub looping: AtomicBool, + pub queued: AtomicBool, + pub playing: AtomicBool, + pub playhead: AtomicU64, + pub commands: ArrayQueue, + pub busy: AtomicBool, + results: Mutex>, +} +pub enum Output { + Imported(story_midi::MidiFile), + Generated(Vec), + Exported(PathBuf), + Saved(PathBuf), + Loaded(Box), + LibraryLoaded(Vec), + Canceled, +} +pub struct Completion { + pub revision: u64, + pub result: Result, +} +impl Default for Shared { + fn default() -> Self { + Self { + session: RwLock::new(Session::default()), + epoch: AtomicU64::new(0), + revision: AtomicU64::new(0), + tempo: AtomicU64::new(140f64.to_bits()), + audition_tempo: AtomicU64::new(f64::NAN.to_bits()), + follow_host: AtomicBool::new(false), + midi: AtomicBool::new(false), + looping: AtomicBool::new(true), + queued: AtomicBool::new(false), + playing: AtomicBool::new(false), + playhead: AtomicU64::new(0), + commands: ArrayQueue::new(2), + busy: AtomicBool::new(false), + results: Mutex::new(Vec::new()), + } + } +} +impl Shared { + pub fn snapshot(&self) -> (u64, Session) { + let state = self.session.read().unwrap_or_else(|p| p.into_inner()); + (self.revision.load(Ordering::Acquire), state.clone()) + } + pub fn stop(&self) { + self.epoch.fetch_add(1, Ordering::AcqRel); + self.playing.store(false, Ordering::Release); + self.queued.store(false, Ordering::Release); + } + fn publish(&self, s: &Session) { + self.tempo.store(s.tempo.to_bits(), Ordering::Release); + self.follow_host.store(s.follow_host, Ordering::Release); + self.midi + .store(s.audition == Audition::Midi, Ordering::Release); + self.looping.store(s.looping, Ordering::Release); + self.revision.fetch_add(1, Ordering::AcqRel); + } + pub fn replace(&self, mut session: Session) -> Result<()> { + session.migrate()?; + session.validate()?; + self.stop(); + let mut current = self.session.write().unwrap_or_else(|p| p.into_inner()); + *current = session; + self.publish(¤t); + Ok(()) + } + pub fn edit(&self, revision: u64, mut session: Session) -> Result<()> { + session.migrate()?; + session.validate()?; + let mut current = self.session.write().unwrap_or_else(|p| p.into_inner()); + if self.revision.load(Ordering::Acquire) != revision { + return Err(Error( + "Session changed while this operation was running. Try again.".into(), + )); + } + if current.source != session.source + || current.settings != session.settings + || current.harmony != session.harmony + || current.candidates != session.candidates + || current.performance != session.performance + || current.audition != session.audition + { + self.stop(); + } + *current = session; + self.publish(¤t); + Ok(()) + } + fn compile_clip(&self, phrase: &story_core::Phrase) -> Result { + let state = self.session.read().unwrap_or_else(|p| p.into_inner()); + let observed = f64::from_bits(self.audition_tempo.load(Ordering::Acquire)); + let tempo = if state.follow_host && observed.is_finite() { + observed + } else { + state.tempo + }; + let settings = state.performance.clone(); + let compensate = state.audition == Audition::Midi; + drop(state); + Performance::compile(phrase, &settings, tempo, compensate)?.clip() + } + pub fn preview(&self, phrase: &story_core::Phrase) -> Result<()> { + let epoch = self.epoch.load(Ordering::Acquire); + let clip = self.compile_clip(phrase)?; + let next = epoch.wrapping_add(1); + self.epoch + .compare_exchange(epoch, next, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| Error("Session changed while preparing audition. Try again.".into()))?; + self.playing.store(false, Ordering::Release); + self.queued.store(false, Ordering::Release); + self.commands.force_push(PlayRequest { + epoch: next, + clip, + quantized: false, + }); + Ok(()) + } + pub fn audition(&self, phrase: &story_core::Phrase) -> Result<()> { + if !self.playing.load(Ordering::Acquire) { + return self.preview(phrase); + } + let epoch = self.epoch.load(Ordering::Acquire); + let clip = self.compile_clip(phrase)?; + if self.epoch.load(Ordering::Acquire) != epoch { + return Err(Error( + "Session changed while preparing audition. Try again.".into(), + )); + } + self.queued.store(true, Ordering::Release); + self.commands.force_push(PlayRequest { + epoch, + clip, + quantized: true, + }); + Ok(()) + } + /// All jobs run off the audio/UI thread; one outstanding job per instance bounds resource use. + pub fn work( + self: &Arc, + revision: u64, + job: impl FnOnce() -> Result + Send + 'static, + ) -> Result<()> { + self.busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| Error("Wait for the current operation to finish.".into()))?; + let shared = self.clone(); + let result = std::thread::Builder::new() + .name("motif-worker".into()) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)) + .unwrap_or_else(|_| Err(Error("The operation failed unexpectedly.".into()))); + shared + .results + .lock() + .unwrap_or_else(|p| p.into_inner()) + .push(Completion { revision, result }); + // Busy is cleared when the GUI consumes the result, so completions cannot accumulate. + }); + if let Err(e) = result { + self.busy.store(false, Ordering::Release); + return Err(Error(e.to_string())); + } + Ok(()) + } + pub fn take_completion(&self) -> Option { + let result = self.results.lock().unwrap_or_else(|p| p.into_inner()).pop(); + if result.is_some() { + self.busy.store(false, Ordering::Release); + } + result + } +} + +pub fn read_limited(path: &Path, limit: usize) -> Result> { + let file = + File::open(path).map_err(|e| Error(format!("Cannot open {}: {e}", path.display())))?; + let mut bytes = Vec::new(); + file.take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| Error(e.to_string()))?; + if bytes.len() > limit { + return Err(Error(format!( + "File exceeds the {} MiB limit.", + limit / 1024 / 1024 + ))); + } + Ok(bytes) +} +/// Never silently overwrite a user's existing MIDI/session file. +pub fn write_new(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = File::options() + .write(true) + .create_new(true) + .open(path) + .map_err(|e| { + Error(format!( + "Cannot create {}: {e}. Choose a new filename if it already exists.", + path.display() + )) + })?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| Error(e.to_string())) +} + +/// Fixed-size scheduling and MIDI state; no session locks or heap work on this path. +#[derive(Default)] +pub struct AudioEngine { + pub player: PerformancePlayer, + epoch: u64, + pending: Option<(f64, PerformanceClip)>, + active: [Option; 16], + controls: [Option<(u8, u8)>; 16], + clear: bool, + was_midi: bool, +} +pub struct AudioFrame { + pub sample: f32, + pub events: EventBatch, + /// Channel-one summaries retained for simple monophonic callers. + pub off: Option, + pub on: Option<(u8, u8)>, +} +impl AudioEngine { + pub fn stop(&mut self) { + self.player.stop(); + self.pending = None; + self.clear = true; + } + pub fn begin_block(&mut self, shared: &Shared) { + let epoch = shared.epoch.load(Ordering::Acquire); + if self.epoch != epoch { + self.stop(); + self.epoch = epoch; + } + self.player + .set_looping(shared.looping.load(Ordering::Acquire)); + for _ in 0..2 { + let Some(request) = shared.commands.pop() else { + break; + }; + if request.epoch != epoch { + continue; + } + if request.quantized && self.player.is_playing() { + let now = self.player.absolute_tick(); + let lead = request.clip.lead_in as f64; + let boundary = ((now + lead) / SECTION as f64) + .floor() + .mul_add(SECTION as f64, SECTION as f64); + self.pending = Some((boundary - lead, request.clip)); + } else { + self.player.play(request.clip); + self.pending = None; + self.clear = true; + shared.queued.store(false, Ordering::Release); + } + } + } + fn release(&mut self, batch: &mut EventBatch) { + for channel in 0..16 { + if let Some(key) = self.active[channel].take() { + batch.push(MidiMessage { + kind: MessageKind::Off, + channel: channel as u8, + key, + value: 0, + rest: 0, + }); + } + if let Some((key, rest)) = self.controls[channel].take() { + batch.push(MidiMessage { + kind: MessageKind::Control, + channel: channel as u8, + key, + value: rest, + rest, + }); + } + } + } + fn forward(&mut self, message: MidiMessage, batch: &mut EventBatch) { + let channel = message.channel as usize; + match message.kind { + MessageKind::On => self.active[channel] = Some(message.key), + MessageKind::Off => { + if self.active[channel] == Some(message.key) { + self.active[channel] = None; + } + } + MessageKind::Control => self.controls[channel] = Some((message.key, message.rest)), + } + batch.push(message); + } + pub fn sample(&mut self, bpm: f64) -> f32 { + self.frame(bpm, false).sample + } + pub fn frame(&mut self, bpm: f64, midi: bool) -> AudioFrame { + if self.pending.as_ref().is_some_and(|(boundary, _)| { + self.player.absolute_tick() >= *boundary || !self.player.is_playing() + }) { + let (_, clip) = self.pending.take().unwrap(); + self.player.play(clip); + self.clear = true; + } + let mut events = EventBatch::default(); + if self.clear || (!midi && self.was_midi) { + self.release(&mut events); + self.clear = false; + } + let (sample, raw) = self.player.next_frame(bpm); + if midi { + if !self.was_midi { + let current = self.player.current_messages(); + for message in current.events[..current.len].iter().flatten().copied() { + self.forward(message, &mut events); + } + } else { + for message in raw.events[..raw.len].iter().flatten().copied() { + self.forward(message, &mut events); + } + } + if !self.player.is_playing() { + self.release(&mut events); + } + } + self.was_midi = midi; + let off = events + .events + .iter() + .flatten() + .find(|m| m.channel == 0 && m.kind == MessageKind::Off) + .map(|m| m.key); + let on = events + .events + .iter() + .flatten() + .find(|m| m.channel == 0 && m.kind == MessageKind::On) + .map(|m| (m.key, m.value)); + AudioFrame { + sample, + events, + off, + on, + } + } + pub fn end_block(&self, shared: &Shared) { + shared.queued.store( + self.pending.is_some() || !shared.commands.is_empty(), + Ordering::Release, + ); + shared + .playing + .store(self.player.is_playing(), Ordering::Release); + shared + .playhead + .store(self.player.tick().to_bits(), Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn stop_cancels_pending_and_active_preview() { + let shared = Shared::default(); + let motif = story_core::demo_motif(); + let mut audio = AudioEngine::default(); + shared.preview(&motif).unwrap(); + shared.stop(); + audio.begin_block(&shared); + assert!(!audio.player.is_playing()); + shared.preview(&motif).unwrap(); + audio.begin_block(&shared); + assert!(audio.player.is_playing()); + shared.replace(Session::default()).unwrap(); + audio.begin_block(&shared); + assert!(!audio.player.is_playing()); + } + #[test] + fn stale_edits_do_not_replace_new_session() { + let s = Shared::default(); + let (revision, old) = s.snapshot(); + let mut new = old.clone(); + new.tempo = 160.; + s.replace(new).unwrap(); + assert!(s.edit(revision, old).is_err()); + assert_eq!(s.snapshot().1.tempo, 160.); + } + #[test] + fn bounded_preview_queue_keeps_latest_request() { + let s = Shared::default(); + for _ in 0..8 { + s.preview(&story_core::demo_motif()).unwrap(); + } + assert_eq!(s.commands.len(), 2); + let mut a = AudioEngine::default(); + a.begin_block(&s); + assert!(a.player.is_playing()); + } + #[test] + fn replacing_session_publishes_audio_controls() { + let s = Shared::default(); + s.replace(Session { + tempo: 172., + follow_host: true, + ..Session::default() + }) + .unwrap(); + assert_eq!(f64::from_bits(s.tempo.load(Ordering::Acquire)), 172.); + assert!(s.follow_host.load(Ordering::Acquire)); + } + + #[test] + fn worker_completion_keeps_the_revision_of_its_input() { + let shared = Arc::new(Shared::default()); + let revision = shared.revision.load(Ordering::Acquire); + let (release, wait) = std::sync::mpsc::channel(); + shared + .work(revision, move || { + wait.recv().unwrap(); + Ok(Output::Canceled) + }) + .unwrap(); + shared.replace(Session::default()).unwrap(); + release.send(()).unwrap(); + let completion = (0..1000) + .find_map(|_| { + let completion = shared.take_completion(); + if completion.is_none() { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + completion + }) + .expect("worker should complete"); + assert_eq!(completion.revision, revision); + assert_ne!(completion.revision, shared.revision.load(Ordering::Acquire)); + assert!(!shared.busy.load(Ordering::Acquire)); + } +} diff --git a/crates/story-app/tests/midi.rs b/crates/story-app/tests/midi.rs new file mode 100644 index 0000000..d2f54d7 --- /dev/null +++ b/crates/story-app/tests/midi.rs @@ -0,0 +1,124 @@ +use story_app::{AudioEngine, Shared}; +use story_core::{Note, Phrase, PPQ, SECTION}; + +fn clip(pitch: u8, length: u32) -> Phrase { + Phrase { + length, + notes: vec![Note { + start: 0, + duration: length, + pitch, + velocity: 90, + ..Default::default() + }], + } +} +#[test] +fn looping_and_mode_changes_release_and_retrigger_notes() { + let shared = Shared::default(); + let mut a = AudioEngine::default(); + a.player.set_sample_rate(8000.).unwrap(); + shared.preview(&clip(60, PPQ)).unwrap(); + a.begin_block(&shared); + let first = a.frame(120., true); + assert_eq!(first.on, Some((60, 90))); + assert_eq!(first.off, None); + let mut repeated = false; + for _ in 0..4001 { + let frame = a.frame(120., true); + if frame.off.is_some() { + assert_eq!(frame.off, Some(60)); + assert_eq!(frame.on, Some((60, 90))); + repeated = true; + break; + } + } + assert!(repeated); + let muted = a.frame(120., false); + assert_eq!(muted.off, Some(60)); + assert_eq!(muted.on, None); + assert_eq!(a.frame(120., true).on, Some((60, 90))); + shared.stop(); + a.begin_block(&shared); + let stopped = a.frame(120., true); + assert_eq!(stopped.off, Some(60)); + assert_eq!(stopped.on, None); +} +#[test] +fn adjacent_same_pitch_notes_have_separate_sample_accurate_attacks() { + let shared = Shared::default(); + shared + .looping + .store(false, std::sync::atomic::Ordering::Release); + let mut a = AudioEngine::default(); + a.player.set_sample_rate(8000.).unwrap(); + let p = Phrase { + length: PPQ, + notes: vec![ + Note { + start: 0, + duration: PPQ / 2, + pitch: 60, + velocity: 90, + ..Default::default() + }, + Note { + start: PPQ / 2, + duration: PPQ / 2, + pitch: 60, + velocity: 70, + ..Default::default() + }, + ], + }; + shared.preview(&p).unwrap(); + a.begin_block(&shared); + let mut events = Vec::new(); + for frame in 0i32..4003 { + let e = a.frame(120., true); + if e.off.is_some() || e.on.is_some() { + events.push((frame, e.off, e.on)); + } + } + assert_eq!(events.len(), 3); + assert_eq!(events[0], (0, None, Some((60, 90)))); + assert!((events[1].0 - 2000).abs() <= 1); + assert_eq!(events[1].1, Some(60)); + assert_eq!(events[1].2, Some((60, 70))); + assert!((events[2].0 - 4000).abs() <= 1); + assert_eq!(events[2].1, Some(60)); + assert_eq!(events[2].2, None); +} +#[test] +fn queued_audition_switches_at_section_boundary_and_stop_cancels_it() { + let shared = Shared::default(); + let mut a = AudioEngine::default(); + a.player.set_sample_rate(8000.).unwrap(); + shared.preview(&clip(60, 2 * SECTION)).unwrap(); + a.begin_block(&shared); + for _ in 0..100 { + a.frame(120., true); + } + a.end_block(&shared); + shared.audition(&clip(67, 2 * SECTION)).unwrap(); + a.begin_block(&shared); + let mut switched = None; + for index in 100i32..32003 { + let f = a.frame(120., true); + if f.on == Some((67, 90)) { + assert_eq!(f.off, Some(60)); + switched = Some(index); + break; + } + } + assert!((switched.unwrap() - 32000).abs() <= 1); + a.end_block(&shared); + shared.audition(&clip(72, SECTION)).unwrap(); + a.begin_block(&shared); + shared.stop(); + a.begin_block(&shared); + let f = a.frame(120., true); + assert_eq!(f.off, Some(67)); + assert_eq!(f.on, None); + assert!(!a.player.is_playing()); +} diff --git a/crates/story-app/tests/realtime.rs b/crates/story-app/tests/realtime.rs new file mode 100644 index 0000000..189f9ea --- /dev/null +++ b/crates/story-app/tests/realtime.rs @@ -0,0 +1,84 @@ +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, +}; +use story_app::{AudioEngine, Shared}; + +struct CheckedAllocator; +thread_local! { + static CHECK:Cell=const {Cell::new(false)}; + static OPERATIONS:Cell=const {Cell::new(0)}; +} +fn record() { + if CHECK.try_with(Cell::get).unwrap_or(false) { + let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1)); + } +} +unsafe impl GlobalAlloc for CheckedAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + record(); + unsafe { System.dealloc(pointer, layout) } + } + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record(); + unsafe { System.realloc(pointer, layout, size) } + } +} +#[global_allocator] +static ALLOCATOR: CheckedAllocator = CheckedAllocator; + +#[test] +fn playback_control_and_rendering_do_not_allocate_or_free() { + let shared = Shared::default(); + let mut settings = story_core::PerformanceSettings { + mode: story_core::RoleMode::Alternating, + ..Default::default() + }; + for (i, voice) in settings.voices.iter_mut().enumerate() { + voice.attack_ms = 100; + voice.release_ms = 20; + voice.controller.enabled = true; + voice.controller.cc = 20 + i as u8; + } + shared + .replace(story_core::Session { + performance: settings, + audition: story_core::Audition::Midi, + tempo: 300., + ..Default::default() + }) + .unwrap(); + let mut audio = AudioEngine::default(); + let motif = story_core::demo_motif(); + audio.player.set_sample_rate(8000.).unwrap(); + shared.preview(&motif).unwrap(); + CHECK.with(|flag| flag.set(true)); + for _ in 0..4 { + audio.begin_block(&shared); + for _ in 0..10000 { + std::hint::black_box(audio.frame(300., true)); + } + audio.end_block(&shared); + } + CHECK.with(|flag| flag.set(false)); + shared.audition(&motif).unwrap(); + CHECK.with(|flag| flag.set(true)); + audio.begin_block(&shared); + for _ in 0..30000 { + std::hint::black_box(audio.frame(300., true)); + } + audio.end_block(&shared); + CHECK.with(|flag| flag.set(false)); + shared.stop(); + CHECK.with(|flag| flag.set(true)); + audio.begin_block(&shared); + std::hint::black_box(audio.frame(300., true)); + audio.end_block(&shared); + CHECK.with(|flag| flag.set(false)); + assert_eq!(OPERATIONS.with(Cell::get), 0); + assert!(!audio.player.is_playing()); +} diff --git a/crates/story-app/tests/roles.rs b/crates/story-app/tests/roles.rs new file mode 100644 index 0000000..1246449 --- /dev/null +++ b/crates/story-app/tests/roles.rs @@ -0,0 +1,117 @@ +use story_app::{AudioEngine, Shared}; +use story_core::*; +fn fixture() -> (Shared, AudioEngine, Phrase) { + let mut settings = PerformanceSettings { + mode: RoleMode::Alternating, + ..Default::default() + }; + for (i, v) in settings.voices.iter_mut().enumerate() { + v.controller.enabled = true; + v.controller.cc = 20 + i as u8; + v.controller.rest = 5 + i as u8; + } + let shared = Shared::default(); + shared + .replace(Session { + performance: settings, + audition: Audition::Midi, + tempo: 120., + ..Default::default() + }) + .unwrap(); + let mut audio = AudioEngine::default(); + audio.player.set_sample_rate(8000.).unwrap(); + let phrase = Phrase { + length: PPQ, + notes: vec![ + Note { + start: 0, + duration: PPQ / 2, + pitch: 60, + velocity: 90, + ..Default::default() + }, + Note { + start: PPQ / 2, + duration: PPQ / 2, + pitch: 67, + velocity: 80, + ..Default::default() + }, + ], + }; + (shared, audio, phrase) +} +#[test] +fn stop_and_profile_replacement_release_old_mappings() { + let (shared, mut audio, phrase) = fixture(); + shared.preview(&phrase).unwrap(); + audio.begin_block(&shared); + let first = audio.frame(120., true); + assert!(first + .events + .events + .iter() + .flatten() + .any(|e| e.kind == MessageKind::On && e.channel == 0)); + for _ in 0..2100 { + audio.frame(120., true); + } + let (revision, mut state) = shared.snapshot(); + state.performance.voices[0].controller.cc = 30; + shared.edit(revision, state).unwrap(); + audio.begin_block(&shared); + let released = audio.frame(120., true); + for (channel, key, value) in [(0, 20, 5), (1, 21, 6)] { + assert!(released + .events + .events + .iter() + .flatten() + .any(|e| e.kind == MessageKind::Control + && e.channel == channel + && e.key == key + && e.value == value)); + } + assert!(released + .events + .events + .iter() + .flatten() + .any(|e| e.kind == MessageKind::Off && e.channel == 1 && e.key == 67)); + assert!(!released + .events + .events + .iter() + .flatten() + .any(|e| e.kind == MessageKind::On)); +} +#[test] +fn muting_midi_resets_both_controllers_and_reenabling_restores_current_state() { + let (shared, mut audio, phrase) = fixture(); + shared.preview(&phrase).unwrap(); + audio.begin_block(&shared); + audio.frame(120., true); + let off = audio.frame(120., false); + assert_eq!( + off.events + .events + .iter() + .flatten() + .filter(|e| e.kind == MessageKind::Control) + .count(), + 2 + ); + assert_eq!(off.off, Some(60)); + let on = audio.frame(120., true); + assert_eq!(on.on, Some((60, 90))); + assert_eq!( + on.events + .events + .iter() + .flatten() + .filter(|e| e.kind == MessageKind::Control) + .count(), + 2 + ); +} diff --git a/crates/story-core/Cargo.toml b/crates/story-core/Cargo.toml new file mode 100644 index 0000000..289ba45 --- /dev/null +++ b/crates/story-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "story-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/story-core/src/creative.rs b/crates/story-core/src/creative.rs new file mode 100644 index 0000000..223e37f --- /dev/null +++ b/crates/story-core/src/creative.rs @@ -0,0 +1,407 @@ +use crate::*; +use serde::{Deserialize, Serialize}; +pub const MAX_GESTURES: usize = 16; +pub const MAX_BRANCHES: usize = 16; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Gesture { + pub id: u64, + pub name: String, + pub phrase: Phrase, + pub performance: PerformanceSettings, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IdeaSnapshot { + pub source: Option, + pub settings: Settings, + pub harmony: Harmony, + pub phrase: Option, + pub pins: Vec, + pub performance: PerformanceSettings, +} +impl IdeaSnapshot { + fn validate(&self) -> Result<()> { + self.settings.validate()?; + self.harmony.validate()?; + self.performance.validate()?; + if let Some(p) = &self.source { + p.validate_source()?; + } + if let Some(p) = &self.phrase { + p.validate(MAX_PLAYBACK_NOTES)?; + } + let mut seen = 0u8; + for pin in &self.pins { + if pin.index >= self.settings.bars as usize / 2 + || pin.phrase.length != SECTION + || seen & (1 << pin.index) != 0 + { + return Err(err("Invalid branch pins.")); + } + seen |= 1 << pin.index; + if !pin.phrase.notes.is_empty() { + pin.phrase.validate(MAX_PLAYBACK_NOTES)?; + } + } + Ok(()) + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Branch { + pub id: u64, + pub parent: Option, + pub name: String, + pub idea: IdeaSnapshot, +} +#[derive(Serialize, Deserialize)] +struct LibraryFile { + version: u32, + gestures: Vec, +} +pub fn read_gesture_library(bytes: &[u8]) -> Result> { + if bytes.len() > 2 * 1024 * 1024 { + return Err(err("Gesture library exceeds 2 MiB.")); + } + let file: LibraryFile = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?; + if file.version != 1 || file.gestures.len() > MAX_GESTURES { + return Err(err("Unsupported or oversized gesture library.")); + } + for g in &file.gestures { + if !name_valid(&g.name) { + return Err(err("Invalid gesture name.")); + } + g.phrase.validate_source()?; + g.performance.validate()?; + } + Ok(file.gestures) +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreativeMove { + Thin, + Delay, + Lengthen, + Transpose(i8), +} +impl CreativeMove { + pub fn name(self) -> String { + match self { + Self::Thin => "Remove connecting events".into(), + Self::Delay => "Delay the gesture".into(), + Self::Lengthen => "Lengthen arrivals".into(), + Self::Transpose(n) => format!("Transpose {n:+} semitones"), + } + } +} +fn name_valid(name: &str) -> bool { + !name.trim().is_empty() && name.chars().count() <= 64 +} +impl Session { + pub fn export_gesture_library(&self) -> Result> { + self.validate_creative()?; + serde_json::to_vec(&LibraryFile { + version: 1, + gestures: self.gestures.clone(), + }) + .map_err(|e| Error(e.to_string())) + } + pub fn import_gesture_library(&mut self, gestures: Vec) -> Result { + if self.gestures.len() + gestures.len() > MAX_GESTURES { + return Err(err("The combined gesture library would exceed 16 entries.")); + } + let mut next = self.clone(); + let count = gestures.len(); + for mut g in gestures { + g.id = next.allocate_id()?; + next.gestures.push(g); + } + next.validate()?; + *self = next; + Ok(count) + } + fn allocate_id(&mut self) -> Result { + let highest = self + .gestures + .iter() + .map(|g| g.id) + .chain(self.branches.iter().map(|b| b.id)) + .max() + .unwrap_or(0); + let id = self.next_id.max(highest.saturating_add(1)); + self.next_id = id + .checked_add(1) + .ok_or_else(|| err("Idea identifier limit reached."))?; + Ok(id) + } + pub(crate) fn validate_creative(&self) -> Result<()> { + if self.gestures.len() > MAX_GESTURES || self.branches.len() > MAX_BRANCHES { + return Err(err( + "At most 16 gestures and 16 branches can be stored per session.", + )); + } + let mut ids = std::collections::BTreeSet::new(); + for g in &self.gestures { + if g.id == 0 || !ids.insert(g.id) || !name_valid(&g.name) { + return Err(err("Invalid gesture name or identifier.")); + } + g.phrase.validate_source()?; + g.performance.validate()?; + } + for b in &self.branches { + if b.id == 0 || !ids.insert(b.id) || !name_valid(&b.name) { + return Err(err("Invalid branch name or identifier.")); + } + if b.parent.is_some_and(|parent| { + parent >= b.id || !self.branches.iter().any(|p| p.id == parent) + }) { + return Err(err("Invalid branch ancestry.")); + } + b.idea.validate()?; + } + if self + .active_branch + .is_some_and(|id| !self.branches.iter().any(|b| b.id == id)) + { + return Err(err("Active branch does not exist.")); + } + Ok(()) + } + pub fn save_gesture(&mut self, name: &str, section: Option) -> Result { + self.performance.validate()?; + if !name_valid(name) { + return Err(err("Name the gesture using 1–64 characters.")); + } + if self.gestures.len() >= MAX_GESTURES { + return Err(err( + "Gesture library is full. Remove an unused gesture or save another session.", + )); + } + let phrase = if let Some(index) = section { + let p = self.composed_phrase()?; + let start = index as u32 * SECTION; + let end = start + SECTION; + if index >= self.settings.bars as usize / 2 + || p.notes.iter().any(|n| { + (n.start < start && n.end() > start) || (n.start < end && n.end() > end) + }) + { + return Err(err("Choose a two-bar section without crossing notes.")); + } + Phrase { + length: SECTION, + notes: p + .notes + .iter() + .filter(|n| n.start >= start && n.start < end) + .map(|n| Note { + start: n.start - start, + ..*n + }) + .collect(), + } + } else { + self.source + .clone() + .ok_or_else(|| err("Import a motif first."))? + }; + phrase.validate_source()?; + let id = self.allocate_id()?; + self.gestures.push(Gesture { + id, + name: name.trim().into(), + phrase, + performance: self.performance.clone(), + }); + Ok(id) + } + pub fn recall_gesture(&mut self, id: u64) -> Result<()> { + let gesture = self + .gestures + .iter() + .find(|g| g.id == id) + .cloned() + .ok_or_else(|| err("Gesture does not exist."))?; + self.source = Some(gesture.phrase); + self.performance = gesture.performance; + self.pins.clear(); + self.candidates.clear(); + self.selected = 0; + self.active_branch = None; + Ok(()) + } + pub fn save_branch(&mut self, name: &str) -> Result { + if !name_valid(name) { + return Err(err("Name the branch using 1–64 characters.")); + } + if self.branches.len() >= MAX_BRANCHES { + return Err(err( + "Branch history is full. Remove an unused leaf branch or save another session.", + )); + } + let idea = IdeaSnapshot { + source: self.source.clone(), + settings: self.settings.clone(), + harmony: self.harmony.clone(), + phrase: if self.candidates.is_empty() { + None + } else { + Some(self.composed_phrase()?) + }, + pins: self.pins.clone(), + performance: self.performance.clone(), + }; + idea.validate()?; + let id = self.allocate_id()?; + self.branches.push(Branch { + id, + parent: self.active_branch, + name: name.trim().into(), + idea, + }); + self.active_branch = Some(id); + Ok(id) + } + pub fn restore_branch(&mut self, id: u64) -> Result<()> { + let b = self + .branches + .iter() + .find(|b| b.id == id) + .cloned() + .ok_or_else(|| err("Branch does not exist."))?; + b.idea.validate()?; + self.source = b.idea.source; + self.settings = b.idea.settings; + self.harmony = b.idea.harmony; + self.pins = b.idea.pins; + self.performance = b.idea.performance; + self.candidates = b + .idea + .phrase + .into_iter() + .map(|phrase| Candidate { + strategy: Strategy::SavedIdea, + explanation: format!("Restored branch: {}", b.name), + phrase, + }) + .collect(); + self.selected = 0; + self.active_branch = Some(id); + Ok(()) + } + pub fn remove_branch(&mut self, id: u64) -> Result<()> { + if self.branches.iter().any(|b| b.parent == Some(id)) { + return Err(err("Remove child branches first.")); + } + let index = self + .branches + .iter() + .position(|b| b.id == id) + .ok_or_else(|| err("Branch does not exist."))?; + if self.active_branch == Some(id) { + self.active_branch = self.branches[index].parent; + } + self.branches.remove(index); + Ok(()) + } + /// Moves operate on heard selections, respect pinned material/locks, and preserve their parent. + pub fn apply_move(&mut self, operation: CreativeMove, section: Option) -> Result<()> { + if section.is_some_and(|i| i >= self.settings.bars as usize / 2) { + return Err(err("Section is outside the phrase.")); + } + if self.settings.preserve_rhythm && !matches!(operation, CreativeMove::Transpose(_)) { + return Err(err( + "Release Keep groove before changing rhythm or articulation.", + )); + } + let old = self.composed_phrase()?; + let mut phrase = old.clone(); + let in_selection = |n: &Note| { + section.is_none_or(|i| n.start / SECTION == i as u32) + && !self + .pins + .iter() + .any(|p| n.start / SECTION == p.index as u32) + }; + match operation { + CreativeMove::Thin => { + let mut index = 0; + phrase.notes.retain(|n| { + if !in_selection(n) { + return true; + } + let keep = index % 2 == 0 || n.is_anchor(); + index += 1; + keep + }); + } + CreativeMove::Transpose(steps) => { + for n in phrase + .notes + .iter_mut() + .filter(|n| in_selection(n) && !n.keeps_pitch()) + { + let pitch = n.pitch as i16 + steps as i16; + if pitch < self.settings.low as i16 || pitch > self.settings.high as i16 { + return Err(err("The transposition exceeds the chosen register. Widen it or choose a smaller move.")); + } + n.pitch = pitch as u8; + } + } + CreativeMove::Delay => { + for n in phrase + .notes + .iter_mut() + .filter(|n| in_selection(n) && !n.keeps_rhythm()) + { + let boundary = ((n.start / SECTION) + 1) * SECTION; + if n.end() + PPQ / 4 > boundary.min(phrase.length) { + return Err(err( + "The delayed gesture would cross the selected section boundary.", + )); + } + n.start += PPQ / 4; + } + phrase.notes.sort_by_key(|n| n.start); + phrase.validate(MAX_PLAYBACK_NOTES).map_err(|_| { + err("The delayed gesture would overlap protected or unselected material.") + })?; + } + CreativeMove::Lengthen => { + for i in 0..phrase.notes.len() { + let boundary = ((phrase.notes[i].start / SECTION) + 1) * SECTION; + let next = phrase + .notes + .get(i + 1) + .map_or(phrase.length, |n| n.start) + .min(boundary); + let n = &mut phrase.notes[i]; + if !in_selection(n) || n.keeps_rhythm() { + continue; + } + n.duration = (n.duration + n.duration / 2).min(next - n.start); + } + } + } + + phrase.validate(MAX_PLAYBACK_NOTES)?; + if phrase == old { + return Err(err( + "The chosen move is blocked by locks, pins, or available space.", + )); + } + let mut next = self.clone(); + if next.active_branch.is_none() { + next.save_branch("Starting idea")?; + } + let label = operation.name(); + next.candidates = vec![Candidate { + strategy: Strategy::SavedIdea, + explanation: label.clone(), + phrase, + }]; + next.selected = 0; + next.save_branch(&label)?; + next.validate()?; + *self = next; + Ok(()) + } +} diff --git a/crates/story-core/src/harmony.rs b/crates/story-core/src/harmony.rs new file mode 100644 index 0000000..670beec --- /dev/null +++ b/crates/story-core/src/harmony.rs @@ -0,0 +1,170 @@ +use crate::{err, Note, Result, Scale, BAR, MAX_PLAYBACK_NOTES}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ChordQuality { + Major, + Minor, + Major7, + Minor7, + Sus2, + Sus4, +} +impl ChordQuality { + pub const ALL: [Self; 6] = [ + Self::Major, + Self::Minor, + Self::Major7, + Self::Minor7, + Self::Sus2, + Self::Sus4, + ]; + pub fn name(self) -> &'static str { + match self { + Self::Major => "Major", + Self::Minor => "Minor", + Self::Major7 => "Major 7", + Self::Minor7 => "Minor 7", + Self::Sus2 => "Sus 2", + Self::Sus4 => "Sus 4", + } + } + pub fn mask(self, root: u8) -> u16 { + let intervals: &[u8] = match self { + Self::Major => &[0, 4, 7], + Self::Minor => &[0, 3, 7], + Self::Major7 => &[0, 4, 7, 11], + Self::Minor7 => &[0, 3, 7, 10], + Self::Sus2 => &[0, 2, 7], + Self::Sus4 => &[0, 5, 7], + }; + intervals + .iter() + .fold(0, |mask, i| mask | 1 << ((root + i) % 12)) + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HarmonySpan { + pub start: u32, + pub duration: u32, + pub tones: u16, + pub bass: u8, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Harmony { + pub length: u32, + pub spans: Vec, +} +impl Harmony { + pub fn validate(&self) -> Result<()> { + if self.spans.is_empty() { + return if self.length == 0 { + Ok(()) + } else { + Err(err("Empty harmony must have zero length.")) + }; + } + if self.length == 0 || self.length > 8 * BAR || self.spans.len() > 256 { + return Err(err("Harmony must fit eight bars and at most 256 changes.")); + } + let mut end = 0; + for span in &self.spans { + if span.duration == 0 + || span.start < end + || span.start.saturating_add(span.duration) > self.length + || span.tones == 0 + || span.tones > 0xfff + || span.bass > 127 + { + return Err(err("Invalid or overlapping harmonic regions.")); + } + end = span.start + span.duration; + } + Ok(()) + } + pub fn at(&self, tick: u32) -> Option<&HarmonySpan> { + if self.length == 0 { + return None; + } + let tick = tick % self.length; + self.spans + .iter() + .find(|s| tick >= s.start && tick < s.start + s.duration) + } + pub fn from_midi(notes: &[Note], length: u32) -> Result { + if notes.is_empty() || notes.len() > MAX_PLAYBACK_NOTES || length == 0 || length > 8 * BAR { + return Err(err("Import a harmony track of up to eight bars.")); + } + let mut edges = std::collections::BTreeSet::new(); + for n in notes { + if n.pitch > 127 || n.duration == 0 || n.end() > length { + return Err(err("Invalid harmony note.")); + } + edges.insert(n.start); + edges.insert(n.end()); + } + let edges: Vec<_> = edges.into_iter().collect(); + let mut spans: Vec = Vec::new(); + for pair in edges.windows(2) { + let active: Vec<_> = notes + .iter() + .filter(|n| n.start <= pair[0] && n.end() > pair[0]) + .collect(); + if active.is_empty() { + continue; + } + let tones = active.iter().fold(0, |mask, n| mask | 1 << (n.pitch % 12)); + let bass = active.iter().map(|n| n.pitch).min().unwrap(); + if let Some(last) = spans.last_mut() { + if last.start + last.duration == pair[0] && last.tones == tones && last.bass == bass + { + last.duration += pair[1] - pair[0]; + continue; + } + } + spans.push(HarmonySpan { + start: pair[0], + duration: pair[1] - pair[0], + tones, + bass, + }); + } + let context = Self { length, spans }; + context.validate()?; + Ok(context) + } + pub fn progression(root: u8, scale: Scale) -> Self { + let minor = scale != Scale::Major; + let chords = if minor { + [ + (0, ChordQuality::Minor), + (8, ChordQuality::Major), + (3, ChordQuality::Major), + (10, ChordQuality::Major), + ] + } else { + [ + (0, ChordQuality::Major), + (9, ChordQuality::Minor), + (5, ChordQuality::Major), + (7, ChordQuality::Major), + ] + }; + Self { + length: 4 * BAR, + spans: chords + .into_iter() + .enumerate() + .map(|(i, (step, quality))| { + let root = (root + step) % 12; + HarmonySpan { + start: i as u32 * BAR, + duration: BAR, + tones: quality.mask(root), + bass: 48 + root, + } + }) + .collect(), + } + } +} diff --git a/crates/story-core/src/lib.rs b/crates/story-core/src/lib.rs new file mode 100644 index 0000000..e25acb8 --- /dev/null +++ b/crates/story-core/src/lib.rs @@ -0,0 +1,657 @@ +//! Musical time and deterministic motif development, independent of the host and UI. +mod creative; +mod harmony; +mod performance; +mod playback; +mod variation; +pub use creative::{ + read_gesture_library, Branch, CreativeMove, Gesture, IdeaSnapshot, MAX_BRANCHES, MAX_GESTURES, +}; +pub use harmony::{ChordQuality, Harmony, HarmonySpan}; +pub use performance::*; +pub use playback::{PlaybackClip, PreviewPlayer, MAX_PLAYBACK_NOTES}; + +use serde::{Deserialize, Serialize}; +use std::fmt; + +pub const PPQ: u32 = 960; +pub const BAR: u32 = PPQ * 4; +pub const MAX_SOURCE_NOTES: usize = 256; +pub const STATE_VERSION: u32 = 3; +pub const MAX_STATE_BYTES: usize = 16 * 1024 * 1024; +pub const SECTION: u32 = 2 * BAR; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(pub String); +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; +fn err(s: &str) -> Error { + Error(s.into()) +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Note { + pub start: u32, + pub duration: u32, + pub pitch: u8, + pub velocity: u8, + pub locked: bool, + #[serde(default)] + pub pitch_locked: bool, + #[serde(default)] + pub rhythm_locked: bool, +} +impl Note { + pub fn end(&self) -> u32 { + self.start.saturating_add(self.duration) + } + pub fn keeps_pitch(&self) -> bool { + self.locked || self.pitch_locked + } + pub fn keeps_rhythm(&self) -> bool { + self.locked || self.rhythm_locked + } + pub fn is_anchor(&self) -> bool { + self.locked || self.pitch_locked || self.rhythm_locked + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Phrase { + pub notes: Vec, + pub length: u32, +} +impl Phrase { + pub fn validate(&self, max_notes: usize) -> Result<()> { + if self.notes.is_empty() || self.notes.len() > max_notes { + return Err(err( + "Choose a phrase containing between 1 and the supported maximum number of notes.", + )); + } + if self.length == 0 || self.length > 8 * BAR { + return Err(err( + "Phrase length must be positive and at most eight bars.", + )); + } + let mut end = 0; + for n in &self.notes { + if n.duration == 0 + || n.pitch > 127 + || n.velocity == 0 + || n.velocity > 127 + || n.end() > self.length + { + return Err(err("Notes must have positive duration and velocity, valid MIDI values, and fit inside the phrase.")); + } + if n.start < end { + return Err(err( + "Overlapping notes are unsupported. Select a monophonic MIDI track.", + )); + } + end = n.end(); + } + Ok(()) + } + pub fn validate_source(&self) -> Result<()> { + self.validate(MAX_SOURCE_NOTES)?; + if self.length > 2 * BAR { + return Err(err("Select a motif of at most two bars in 4/4.")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum Scale { + Minor, + Major, + Dorian, + Pentatonic, +} +impl Scale { + pub const ALL: [Self; 4] = [Self::Minor, Self::Major, Self::Dorian, Self::Pentatonic]; + pub fn name(self) -> &'static str { + match self { + Self::Minor => "Natural minor", + Self::Major => "Major", + Self::Dorian => "Dorian", + Self::Pentatonic => "Minor pentatonic", + } + } + pub fn intervals(self) -> &'static [u8] { + match self { + Self::Minor => &[0, 2, 3, 5, 7, 8, 10], + Self::Major => &[0, 2, 4, 5, 7, 9, 11], + Self::Dorian => &[0, 2, 3, 5, 7, 9, 10], + Self::Pentatonic => &[0, 3, 5, 7, 10], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Settings { + pub bars: u8, + pub root: u8, + pub scale: Scale, + pub variation: u8, + pub preserve_rhythm: bool, + pub preserve_contour: bool, + pub task: Task, + pub ending: Ending, + pub low: u8, + pub high: u8, + pub seed: u64, +} +impl Default for Settings { + fn default() -> Self { + Self { + bars: 8, + root: 0, + scale: Scale::Minor, + variation: 55, + preserve_rhythm: false, + preserve_contour: true, + task: Task::Develop, + ending: Ending::Resolve, + low: 48, + high: 84, + seed: 42, + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum Task { + #[default] + Develop, + Answer, + MakeSpace, + Return, +} +impl Task { + pub const ALL: [Self; 4] = [Self::Develop, Self::Answer, Self::MakeSpace, Self::Return]; + pub fn name(self) -> &'static str { + match self { + Self::Develop => "Develop the motif", + Self::Answer => "Write an answer", + Self::MakeSpace => "Make room", + Self::Return => "Find a return", + } + } +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum Ending { + #[default] + Resolve, + Open, +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum Audition { + #[default] + Internal, + Midi, +} +impl Settings { + pub fn validate(&self) -> Result<()> { + if ![4, 8].contains(&self.bars) + || self.root > 11 + || self.variation > 100 + || self.low > self.high + || self.high > 127 + { + return Err(err("Use four or eight bars, a valid key, variation 0–100, and an ordered MIDI register.")); + } + if !(self.low..=self.high).any(|p| self.contains(p)) { + return Err(err( + "The register must contain at least one note in the selected scale.", + )); + } + Ok(()) + } + pub fn contains(&self, p: u8) -> bool { + self.scale + .intervals() + .contains(&((p + 12 - self.root) % 12)) + } + fn degree(&self, p: u8, steps: i16) -> u8 { + let choices: Vec<_> = (self.low..=self.high) + .filter(|&n| self.contains(n)) + .collect(); + let index = choices + .iter() + .enumerate() + .min_by_key(|(_, n)| (**n as i16 - p as i16).abs()) + .map(|(i, _)| i) + .unwrap_or(0) as i16; + choices[(index + steps).clamp(0, choices.len() as i16 - 1) as usize] + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum Strategy { + ChangedEnding, + LaterPeak, + DelayedResolution, + NewRhythm, + FragmentAnswer, + NegativeSpace, + CallResponse, + HarmonicReturn, + SavedIdea, +} +impl Strategy { + pub const ALL: [Self; 8] = [ + Self::NewRhythm, + Self::FragmentAnswer, + Self::NegativeSpace, + Self::CallResponse, + Self::HarmonicReturn, + Self::ChangedEnding, + Self::LaterPeak, + Self::DelayedResolution, + ]; + pub fn name(self) -> &'static str { + match self { + Self::ChangedEnding => "Changed ending", + Self::LaterPeak => "Later peak", + Self::DelayedResolution => "Delayed resolution", + Self::NewRhythm => "Displaced groove", + Self::FragmentAnswer => "Fragment and answer", + Self::NegativeSpace => "Room to breathe", + Self::CallResponse => "Short question, long answer", + Self::HarmonicReturn => "Familiar return", + Self::SavedIdea => "Working gesture", + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Candidate { + pub strategy: Strategy, + pub explanation: String, + pub phrase: Phrase, +} + +/// Generate context-free proposals. Use `generate_with_harmony` when harmony is available. +pub fn generate(source: &Phrase, settings: &Settings) -> Result> { + generate_with_harmony(source, settings, &Harmony::default()) +} +pub fn generate_with_harmony( + source: &Phrase, + settings: &Settings, + harmony: &Harmony, +) -> Result> { + variation::generate(source, settings, harmony) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PinnedSection { + pub index: usize, + pub phrase: Phrase, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Session { + pub version: u32, + pub source: Option, + pub settings: Settings, + pub candidates: Vec, + pub selected: usize, + pub accepted: Option, + pub previous_accepted: Option, + pub tempo: f64, + pub follow_host: bool, + #[serde(default)] + pub harmony: Harmony, + #[serde(default)] + pub pins: Vec, + #[serde(default)] + pub audition: Audition, + #[serde(default)] + pub looping: bool, + #[serde(default)] + pub performance: PerformanceSettings, + #[serde(default)] + pub gestures: Vec, + #[serde(default)] + pub branches: Vec, + #[serde(default)] + pub active_branch: Option, + #[serde(default)] + pub next_id: u64, +} +impl Default for Session { + fn default() -> Self { + Self { + version: STATE_VERSION, + source: None, + settings: Settings::default(), + candidates: Vec::new(), + selected: 0, + accepted: None, + previous_accepted: None, + tempo: 140.0, + follow_host: false, + harmony: Harmony::default(), + pins: Vec::new(), + audition: Audition::Internal, + looping: true, + performance: PerformanceSettings::default(), + gestures: Vec::new(), + branches: Vec::new(), + active_branch: None, + next_id: 1, + } + } +} +impl Session { + pub fn migrate(&mut self) -> Result<()> { + match self.version { + 1 | 2 => self.version = STATE_VERSION, + STATE_VERSION => {} + _ => return Err(err("Unsupported session version.")), + } + Ok(()) + } + fn validate_pins(&self) -> Result<()> { + let mut seen = std::collections::BTreeSet::new(); + for pin in &self.pins { + if pin.index >= self.settings.bars as usize / 2 + || !seen.insert(pin.index) + || pin.phrase.length != SECTION + { + return Err(err("Invalid pinned section.")); + } + if !pin.phrase.notes.is_empty() { + pin.phrase.validate(MAX_PLAYBACK_NOTES)?; + } + } + Ok(()) + } + pub fn pin_selected(&mut self, index: usize) -> Result<()> { + if index >= self.settings.bars as usize / 2 { + return Err(err("Section is outside the phrase.")); + } + let source = self + .selected_phrase() + .ok_or_else(|| err("Generate a candidate first."))?; + let start = index as u32 * SECTION; + let end = start + SECTION; + if source + .notes + .iter() + .any(|n| (n.start < start && n.end() > start) || (n.start < end && n.end() > end)) + { + return Err(err( + "A note crosses this section boundary. Edit the seam before pinning.", + )); + } + let phrase = Phrase { + length: SECTION, + notes: source + .notes + .iter() + .filter(|n| n.start >= start && n.start < end) + .map(|n| Note { + start: n.start - start, + ..*n + }) + .collect(), + }; + self.pins.retain(|pin| pin.index != index); + self.pins.push(PinnedSection { index, phrase }); + self.pins.sort_by_key(|p| p.index); + Ok(()) + } + pub fn composed_phrase(&self) -> Result { + self.validate_pins()?; + let mut phrase = self + .selected_phrase() + .cloned() + .ok_or_else(|| err("Generate a candidate first."))?; + for pin in &self.pins { + let start = pin.index as u32 * SECTION; + let end = start + SECTION; + if phrase + .notes + .iter() + .any(|n| (n.start < start && n.end() > start) || (n.start < end && n.end() > end)) + { + return Err(err("A candidate note crosses a pinned section boundary.")); + } + phrase.notes.retain(|n| n.start < start || n.start >= end); + phrase.notes.extend(pin.phrase.notes.iter().map(|n| Note { + start: n.start + start, + ..*n + })); + } + phrase.notes.sort_by_key(|n| n.start); + phrase.validate(MAX_PLAYBACK_NOTES)?; + Ok(phrase) + } + pub fn accept_composed(&mut self) -> Result<()> { + let phrase = self.composed_phrase()?; + self.previous_accepted = self.accepted.replace(phrase); + Ok(()) + } + pub fn validate(&self) -> Result<()> { + if self.version != STATE_VERSION { + return Err(err("Unsupported session version.")); + } + self.settings.validate()?; + self.performance.validate()?; + self.validate_creative()?; + self.harmony.validate()?; + self.validate_pins()?; + if !self.tempo.is_finite() || !(30.0..=300.0).contains(&self.tempo) { + return Err(err("Tempo must be between 30 and 300 BPM.")); + } + if let Some(p) = &self.source { + p.validate_source()?; + } + if self.candidates.len() > 3 + || (!self.candidates.is_empty() && self.selected >= self.candidates.len()) + { + return Err(err("Invalid candidate selection.")); + } + for p in self + .candidates + .iter() + .map(|c| &c.phrase) + .chain(self.accepted.iter()) + .chain(self.previous_accepted.iter()) + { + p.validate(MAX_PLAYBACK_NOTES)?; + } + Ok(()) + } + pub fn from_json(bytes: &[u8]) -> Result { + if bytes.len() > MAX_STATE_BYTES { + return Err(err("Session exceeds the 16 MiB limit.")); + } + let mut session: Self = serde_json::from_slice(bytes).map_err(|e| Error(e.to_string()))?; + session.migrate()?; + session.validate()?; + Ok(session) + } + pub fn to_json(&self) -> Result> { + self.validate()?; + let bytes = serde_json::to_vec(self).map_err(|e| Error(e.to_string()))?; + if bytes.len() > MAX_STATE_BYTES { + return Err(err("Session exceeds the 16 MiB limit.")); + } + Ok(bytes) + } + pub fn regenerate(&mut self) -> Result<()> { + let source = self + .source + .as_ref() + .ok_or_else(|| err("Import a MIDI motif first."))?; + self.candidates = generate_with_harmony(source, &self.settings, &self.harmony)?; + self.selected = 0; + Ok(()) + } + pub fn selected_phrase(&self) -> Option<&Phrase> { + self.candidates.get(self.selected).map(|c| &c.phrase) + } + pub fn accept(&mut self) { + if let Some(p) = self.selected_phrase().cloned() { + self.previous_accepted = self.accepted.replace(p); + } + } + pub fn undo_accept(&mut self) { + std::mem::swap(&mut self.accepted, &mut self.previous_accepted); + } +} + +pub fn demo_motif() -> Phrase { + Phrase { + length: 2 * BAR, + notes: [ + (0, 60), + (720, 67), + (1440, 63), + (2880, 62), + (4320, 67), + (5760, 65), + (6720, 63), + ] + .into_iter() + .map(|(start, pitch)| Note { + start, + pitch, + duration: 480, + velocity: 90, + locked: false, + ..Note::default() + }) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reproducible_distinct_and_bounded() { + let source = demo_motif(); + let s = Settings::default(); + let a = generate(&source, &s).unwrap(); + assert_eq!(a, generate(&source, &s).unwrap()); + assert_ne!(a[0].phrase, a[1].phrase); + assert_ne!(a[1].phrase, a[2].phrase); + for c in a { + c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap(); + assert_eq!(c.phrase.length, 8 * BAR); + assert_eq!( + &c.phrase.notes[..source.notes.len()], + source.notes.as_slice() + ); + for n in c.phrase.notes.iter().filter(|n| n.start >= source.length) { + assert!((s.low..=s.high).contains(&n.pitch)); + assert!(s.contains(n.pitch)); + } + } + } + #[test] + fn locks_preserve_all_attributes_even_outside_scale_and_register() { + let mut source = demo_motif(); + source.notes[2].locked = true; + source.notes[2].pitch = 99; + for c in generate( + &source, + &Settings { + preserve_rhythm: false, + ..Settings::default() + }, + ) + .unwrap() + { + for rep in 0..4 { + let mut expected = source.notes[2]; + expected.start += rep * source.length; + assert!(c.phrase.notes.contains(&expected)); + } + } + } + #[test] + fn all_keys_register_edges_and_seeds() { + for root in 0..12 { + for scale in Scale::ALL { + for seed in 0..12 { + for bars in [4, 8] { + let s = Settings { + root, + scale, + seed, + bars, + low: 60, + high: 72, + preserve_rhythm: false, + ..Settings::default() + }; + for c in generate(&demo_motif(), &s).unwrap() { + c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap(); + } + } + } + } + } + } + #[test] + fn session_round_trip_and_accept_history() { + let mut s = Session { + source: Some(demo_motif()), + ..Session::default() + }; + s.regenerate().unwrap(); + s.accept(); + let first = s.accepted.clone(); + s.selected = 1; + s.accept(); + s.undo_accept(); + assert_eq!(s.accepted, first); + s.settings.seed += 1; + s.regenerate().unwrap(); + assert_eq!(s.accepted, first); + assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s); + s.version = 99; + assert!(s.to_json().is_err()); + } + #[test] + fn invalid_inputs_and_non_divisible_lengths() { + let mut source = demo_motif(); + source.notes[1].start = 1; + assert!(generate(&source, &Settings::default()).is_err()); + let source = Phrase { + notes: vec![Note { + start: 0, + duration: 300, + pitch: 60, + velocity: 80, + locked: true, + ..Note::default() + }], + length: 1000, + }; + for c in generate(&source, &Settings::default()).unwrap() { + c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap(); + } + assert!(generate( + &source, + &Settings { + low: 61, + high: 61, + root: 0, + scale: Scale::Major, + ..Settings::default() + } + ) + .is_err()); + } +} diff --git a/crates/story-core/src/performance.rs b/crates/story-core/src/performance.rs new file mode 100644 index 0000000..3dc01ef --- /dev/null +++ b/crates/story-core/src/performance.rs @@ -0,0 +1,622 @@ +//! Explicit sound roles and controller gestures, compiled away from the audio thread. +use crate::{err, Note, Phrase, Result, BAR, MAX_PLAYBACK_NOTES, PPQ, SECTION}; +use serde::{Deserialize, Serialize}; +use std::f64::consts::TAU; + +pub const MAX_PERFORMANCE_EVENTS: usize = 5120; +pub const MAX_FRAME_EVENTS: usize = 64; +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum RoleMode { + #[default] + Solo, + Alternating, + CallResponse, + Interlock, +} +impl RoleMode { + pub const ALL: [Self; 4] = [ + Self::Solo, + Self::Alternating, + Self::CallResponse, + Self::Interlock, + ]; + pub fn name(self) -> &'static str { + match self { + Self::Solo => "One voice", + Self::Alternating => "Alternate events", + Self::CallResponse => "Question and answer", + Self::Interlock => "Strong beats and replies", + } + } + pub fn role(self, index: usize, n: &Note) -> usize { + match self { + Self::Solo => 0, + Self::Alternating => index % 2, + Self::CallResponse => usize::from(n.start % SECTION >= BAR), + Self::Interlock => usize::from(!n.start.is_multiple_of(PPQ)), + } + } +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct CurvePoint { + pub position: u16, + pub value: u8, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ControllerCurve { + pub enabled: bool, + pub cc: u8, + pub rest: u8, + pub points: Vec, +} +impl Default for ControllerCurve { + fn default() -> Self { + Self { + enabled: false, + cc: 1, + rest: 0, + points: vec![ + CurvePoint { + position: 0, + value: 0, + }, + CurvePoint { + position: 1000, + value: 127, + }, + ], + } + } +} +impl ControllerCurve { + pub fn validate(&self) -> Result<()> { + if self.cc == 0 || self.cc >= 120 || [32, 64, 66, 69].contains(&self.cc) || self.rest > 127 + { + return Err(err("Choose a continuous controller in 1–119 (excluding bank/hold pedals) and a valid rest value.")); + } + if !(2..=16).contains(&self.points.len()) + || self.points.first().unwrap().position != 0 + || self.points.last().unwrap().position != 1000 + || self.points.iter().any(|p| p.value > 127) + || self + .points + .windows(2) + .any(|p| p[0].position >= p[1].position) + { + return Err(err("A controller curve needs 2–16 ordered points from 0 to 1000, with MIDI values 0–127.")); + } + Ok(()) + } + pub fn value_at(&self, position: u16) -> u8 { + for pair in self.points.windows(2) { + if position <= pair[1].position { + let p = position.saturating_sub(pair[0].position) as f64 + / (pair[1].position - pair[0].position) as f64; + return (pair[0].value as f64 + (pair[1].value as f64 - pair[0].value as f64) * p) + .round() + .clamp(0., 127.) as u8; + } + } + self.points.last().map_or(0, |p| p.value) + } + pub fn shape(&mut self, preset: usize) { + self.points = match preset { + 1 => vec![(0, 127), (1000, 0)], + 2 => vec![(0, 0), (500, 127), (1000, 0)], + 3 => vec![ + (0, 0), + (125, 127), + (250, 0), + (375, 127), + (500, 0), + (625, 127), + (750, 0), + (875, 127), + (1000, 0), + ], + _ => vec![(0, 0), (1000, 127)], + } + .into_iter() + .map(|(position, value)| CurvePoint { position, value }) + .collect(); + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VoiceProfile { + pub name: String, + pub channel: u8, + pub transpose: i8, + pub gate: u16, + pub velocity: u16, + pub attack_ms: u16, + pub release_ms: u16, + pub controller: ControllerCurve, +} +impl Default for VoiceProfile { + fn default() -> Self { + Self { + name: "Primary".into(), + channel: 0, + transpose: 0, + gate: 100, + velocity: 100, + attack_ms: 0, + release_ms: 0, + controller: ControllerCurve::default(), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PerformanceSettings { + pub mode: RoleMode, + pub voices: [VoiceProfile; 2], + pub compensated_export: bool, +} +impl Default for PerformanceSettings { + fn default() -> Self { + Self { + mode: RoleMode::Solo, + voices: [ + VoiceProfile::default(), + VoiceProfile { + name: "Answer".into(), + channel: 1, + ..VoiceProfile::default() + }, + ], + compensated_export: false, + } + } +} +impl PerformanceSettings { + pub fn validate(&self) -> Result<()> { + if self.mode != RoleMode::Solo && self.voices[0].channel == self.voices[1].channel { + return Err(err("Use separate MIDI channels for the two sound roles.")); + } + for v in &self.voices { + if v.name.chars().count() > 48 + || v.channel > 15 + || !(-24..=24).contains(&v.transpose) + || !(10..=200).contains(&v.gate) + || !(1..=200).contains(&v.velocity) + || v.attack_ms > 500 + || v.release_ms > 2000 + { + return Err(err("Invalid patch profile: check channel, register offset, gate, velocity, and timing limits.")); + } + v.controller.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum MessageKind { + #[default] + Off, + Control, + On, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MidiMessage { + pub kind: MessageKind, + pub channel: u8, + pub key: u8, + pub value: u8, + pub rest: u8, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TimedMessage { + pub tick: i32, + pub role: u8, + pub message: MidiMessage, + pub next_cycle: bool, +} +#[derive(Debug, Clone)] +pub struct Performance { + pub length: u32, + pub lead_in: u32, + pub events: Vec, +} +impl Performance { + pub fn compile( + phrase: &Phrase, + settings: &PerformanceSettings, + bpm: f64, + compensate: bool, + ) -> Result { + phrase.validate(MAX_PLAYBACK_NOTES)?; + settings.validate()?; + if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) { + return Err(err("Tempo must be 30–300 BPM.")); + } + let to_ticks = |ms: u16| (ms as f64 * bpm * PPQ as f64 / 60000.).round() as i32; + let mut events = Vec::new(); + let mut lead_in = 0; + for role in 0..2 { + if role == 1 && settings.mode == RoleMode::Solo { + continue; + } + let profile = &settings.voices[role]; + let attack = if compensate { + to_ticks(profile.attack_ms) + } else { + 0 + }; + let release = if compensate { + to_ticks(profile.release_ms) + } else { + 0 + }; + let routed: Vec<_> = phrase + .notes + .iter() + .enumerate() + .filter(|(i, n)| settings.mode.role(*i, n) == role) + .map(|(_, n)| n) + .collect(); + if routed.is_empty() && !profile.controller.enabled { + continue; + } + lead_in = lead_in.max(attack as u32); + for (index, n) in routed.iter().enumerate() { + let pitch = if n.keeps_pitch() { + n.pitch as i16 + } else { + n.pitch as i16 + profile.transpose as i16 + }; + if !(0..=127).contains(&pitch) { + return Err(err( + "A sound-role register offset exceeds MIDI pitch limits.", + )); + } + let on = n.start as i32 - attack; + let duration = if n.keeps_rhythm() { + n.duration + } else { + (n.duration as u64 * profile.gate as u64 / 100).max(1) as u32 + }; + let wrap = routed.first().map_or(phrase.length as i32, |first| { + phrase.length as i32 + (first.start as i32 - attack).min(0) + }); + let next = routed + .get(index + 1) + .map_or(wrap, |n| n.start as i32 - attack); + let off = (n.start as i32 + duration as i32 - release) + .min(next) + .min(phrase.length as i32) + .max(on + 1); + let velocity = if n.locked { + n.velocity + } else { + (n.velocity as u32 * profile.velocity as u32 / 100).clamp(1, 127) as u8 + }; + events.push(TimedMessage { + tick: on, + role: role as u8, + message: MidiMessage { + kind: MessageKind::On, + channel: profile.channel, + key: pitch as u8, + value: velocity, + rest: 0, + }, + next_cycle: false, + }); + events.push(TimedMessage { + tick: off, + role: role as u8, + message: MidiMessage { + kind: MessageKind::Off, + channel: profile.channel, + key: pitch as u8, + value: 0, + rest: 0, + }, + next_cycle: false, + }); + } + if profile.controller.enabled { + let curve = &profile.controller; + let mut points = std::collections::BTreeMap::new(); + for tick in (0..=phrase.length).step_by((PPQ / 8) as usize) { + points.insert( + tick as i32, + curve.value_at((tick as u64 * 1000 / phrase.length as u64) as u16), + ); + } + for p in &curve.points { + points.insert( + (p.position as u64 * phrase.length as u64 / 1000) as i32, + p.value, + ); + } + if attack > 0 { + points.insert(-attack, curve.value_at(0)); + } + for (tick, value) in points { + events.push(TimedMessage { + tick, + role: role as u8, + message: MidiMessage { + kind: MessageKind::Control, + channel: profile.channel, + key: curve.cc, + value, + rest: curve.rest, + }, + next_cycle: false, + }); + } + } + } + // Negative early triggers are played once during warm-up, and before each subsequent wrap. + let wrapped: Vec<_> = events + .iter() + .filter(|e| e.tick < 0) + .map(|e| TimedMessage { + tick: e.tick + phrase.length as i32, + next_cycle: true, + ..*e + }) + .collect(); + // Very short synthetic clips cannot represent their own warm-up as one loop. + if lead_in >= phrase.length { + return Err(err("Attack compensation exceeds this phrase's length. Reduce it or use a longer phrase.")); + } + events.extend(wrapped); + events.sort_by_key(|e| (e.tick, e.message.kind, e.message.channel, e.message.key)); + if events.len() > MAX_PERFORMANCE_EVENTS { + return Err(err("Performance exceeds the bounded event limit. Simplify the phrase or controller curve.")); + } + Ok(Self { + length: phrase.length, + lead_in, + events, + }) + } + pub fn clip(&self) -> Result { + if self.events.len() > MAX_PERFORMANCE_EVENTS + || self.length == 0 + || self.length > 8 * BAR + || self.lead_in >= self.length + { + return Err(err("Invalid performance clip.")); + } + let mut per_tick = std::collections::BTreeMap::new(); + for event in &self.events { + if event.role > 1 + || event.message.channel > 15 + || event.message.key > 127 + || event.message.value > 127 + || event.message.rest > 127 + || event.message.kind == MessageKind::On && event.message.value == 0 + || event.tick < -(self.lead_in as i32) + || event.tick > self.length as i32 + || event.next_cycle && event.tick < 0 + { + return Err(err("Invalid scheduled MIDI event.")); + } + *per_tick.entry(event.tick).or_insert(0) += 1; + } + if per_tick.values().any(|&count| count > 16) + || self.events.windows(2).any(|p| p[0].tick > p[1].tick) + { + return Err(err("Unsorted or excessively dense performance events.")); + } + let mut clip = PerformanceClip { + events: [TimedMessage::default(); MAX_PERFORMANCE_EVENTS], + count: self.events.len(), + length: self.length, + lead_in: self.lead_in, + }; + clip.events[..clip.count].copy_from_slice(&self.events); + Ok(clip) + } +} +#[derive(Clone, Copy)] +pub struct PerformanceClip { + events: [TimedMessage; MAX_PERFORMANCE_EVENTS], + count: usize, + pub length: u32, + pub lead_in: u32, +} +pub struct EventBatch { + pub events: [Option; MAX_FRAME_EVENTS], + pub len: usize, +} +impl Default for EventBatch { + fn default() -> Self { + Self { + events: [None; MAX_FRAME_EVENTS], + len: 0, + } + } +} +impl EventBatch { + pub fn push(&mut self, message: MidiMessage) { + assert!(self.len < MAX_FRAME_EVENTS, "bounded MIDI batch overflow"); + self.events[self.len] = Some(message); + self.len += 1; + } +} +#[derive(Clone, Copy, Default)] +struct Voice { + held: Option<(u8, u8)>, + frequency: f64, + phase: f64, + gain: f64, + level: f64, +} +impl Voice { + fn sample(&mut self, rate: f64, role: usize) -> f32 { + let target = if self.held.is_some() { 1. } else { 0. }; + let speed = if target > self.gain { + 1. / (0.005 * rate) + } else { + 1. / (0.012 * rate) + }; + self.gain = if target > self.gain { + (self.gain + speed).min(target) + } else { + (self.gain - speed).max(target) + }; + let sine = self.phase.sin(); + let triangle = 2. / std::f64::consts::PI * sine.asin(); + let wave = if role == 0 { + sine * 0.8 + triangle * 0.2 + } else { + triangle * 0.65 + (self.phase * 2.).sin() * 0.35 + }; + self.phase = (self.phase + TAU * self.frequency / rate) % TAU; + (wave * self.gain * 0.1 * self.level) as f32 + } +} +pub struct PerformancePlayer { + clip: PerformanceClip, + tick: f64, + index: usize, + cycle: u64, + playing: bool, + looping: bool, + sample_rate: f64, + voices: [Voice; 2], + controls: [Option; 2], +} +impl Default for PerformancePlayer { + fn default() -> Self { + Self { + clip: PerformanceClip { + events: [TimedMessage::default(); MAX_PERFORMANCE_EVENTS], + count: 0, + length: 0, + lead_in: 0, + }, + tick: 0., + index: 0, + cycle: 0, + playing: false, + looping: false, + sample_rate: 44100., + voices: [Voice::default(); 2], + controls: [None; 2], + } + } +} +impl PerformancePlayer { + pub fn set_sample_rate(&mut self, rate: f64) -> Result<()> { + if !rate.is_finite() || !(8000.0..=384000.0).contains(&rate) { + return Err(err("Unsupported sample rate.")); + } + self.sample_rate = rate; + self.stop(); + Ok(()) + } + pub fn set_looping(&mut self, value: bool) { + self.looping = value; + } + pub fn is_playing(&self) -> bool { + self.playing + } + pub fn length(&self) -> u32 { + self.clip.length + } + pub fn tick(&self) -> f64 { + self.tick + } + pub fn absolute_tick(&self) -> f64 { + self.cycle as f64 * self.clip.length as f64 + self.tick + } + pub fn play(&mut self, clip: PerformanceClip) { + self.stop(); + self.clip = clip; + self.tick = -(clip.lead_in as f64); + self.playing = true; + } + pub fn stop(&mut self) { + self.playing = false; + self.tick = 0.; + self.index = 0; + self.cycle = 0; + self.voices = [Voice::default(); 2]; + self.controls = [None; 2]; + } + fn dispatch_until(&mut self, tick: f64, batch: &mut EventBatch) { + while self.index < self.clip.count && self.clip.events[self.index].tick as f64 <= tick { + let event = self.clip.events[self.index]; + self.index += 1; + if event.next_cycle && !self.looping { + continue; + } + let voice = &mut self.voices[event.role as usize]; + match event.message.kind { + MessageKind::On => { + voice.held = Some((event.message.key, event.message.value)); + voice.level = event.message.value as f64 / 127.; + voice.frequency = 440. * 2.0_f64.powf((event.message.key as f64 - 69.) / 12.); + voice.phase = 0.; + } + MessageKind::Off => { + if voice.held.is_some_and(|n| n.0 == event.message.key) { + voice.held = None; + } + } + MessageKind::Control => self.controls[event.role as usize] = Some(event.message), + } + batch.push(event.message); + } + } + pub fn current_messages(&self) -> EventBatch { + let mut batch = EventBatch::default(); + for role in 0..2 { + if let Some(message) = self.controls[role] { + batch.push(message); + } + if let Some((key, value)) = self.voices[role].held { + let channel = self.clip.events[..self.clip.count] + .iter() + .find(|e| e.role as usize == role && e.message.kind == MessageKind::On) + .map_or(role as u8, |e| e.message.channel); + batch.push(MidiMessage { + kind: MessageKind::On, + channel, + key, + value, + rest: 0, + }); + } + } + batch + } + pub fn next_frame(&mut self, bpm: f64) -> (f32, EventBatch) { + let mut batch = EventBatch::default(); + if !self.playing { + return (0., batch); + } + if self.tick >= self.clip.length as f64 { + self.dispatch_until(self.clip.length as f64, &mut batch); + if self.looping { + self.tick -= self.clip.length as f64; + self.cycle = self.cycle.wrapping_add(1); + self.index = self.clip.events[..self.clip.count].partition_point(|e| e.tick < 0); + } else { + self.playing = false; + return (0., batch); + } + } + self.dispatch_until(self.tick, &mut batch); + let sample = self + .voices + .iter_mut() + .enumerate() + .map(|(role, v)| v.sample(self.sample_rate, role)) + .sum(); + self.tick += (if bpm.is_finite() { + bpm.clamp(30., 300.) + } else { + 140. + }) * PPQ as f64 + / (60. * self.sample_rate); + (sample, batch) + } +} diff --git a/crates/story-core/src/playback.rs b/crates/story-core/src/playback.rs new file mode 100644 index 0000000..56cbda9 --- /dev/null +++ b/crates/story-core/src/playback.rs @@ -0,0 +1,202 @@ +use crate::{Error, Note, Phrase, Result, PPQ}; +use std::f64::consts::TAU; + +pub const MAX_PLAYBACK_NOTES: usize = 2048; + +/// Fixed-size, trivially dropped data: safe to move through a bounded RT queue. +#[derive(Clone, Copy)] +pub struct PlaybackClip { + notes: [Note; MAX_PLAYBACK_NOTES], + count: usize, + length: u32, +} +impl PlaybackClip { + pub fn new(phrase: &Phrase) -> Result { + phrase.validate(MAX_PLAYBACK_NOTES)?; + let mut clip = Self { + notes: [Note::default(); MAX_PLAYBACK_NOTES], + count: phrase.notes.len(), + length: phrase.length, + }; + clip.notes[..clip.count].copy_from_slice(&phrase.notes); + Ok(clip) + } +} + +pub struct PreviewPlayer { + clip: PlaybackClip, + playing: bool, + tick: f64, + index: usize, + phase: f64, + sample_rate: f64, + looping: bool, + cycle: u64, +} +impl Default for PreviewPlayer { + fn default() -> Self { + Self { + clip: PlaybackClip { + notes: [Note::default(); MAX_PLAYBACK_NOTES], + count: 0, + length: 0, + }, + playing: false, + tick: 0., + index: 0, + phase: 0., + sample_rate: 44100., + looping: false, + cycle: 0, + } + } +} +impl PreviewPlayer { + pub fn set_sample_rate(&mut self, sample_rate: f64) -> Result<()> { + if !sample_rate.is_finite() || !(8000.0..=384000.0).contains(&sample_rate) { + return Err(Error("Unsupported sample rate.".into())); + } + self.sample_rate = sample_rate; + self.stop(); + Ok(()) + } + pub fn play(&mut self, clip: PlaybackClip) { + self.stop(); + self.clip = clip; + self.playing = true; + } + pub fn stop(&mut self) { + self.playing = false; + self.tick = 0.; + self.index = 0; + self.phase = 0.; + self.cycle = 0; + } + pub fn is_playing(&self) -> bool { + self.playing + } + pub fn tick(&self) -> f64 { + self.tick + } + pub fn set_looping(&mut self, looping: bool) { + self.looping = looping; + } + pub fn cycle(&self) -> u64 { + self.cycle + } + pub fn prepare_sample(&mut self) { + if self.playing && self.tick >= self.clip.length as f64 { + if self.looping { + self.cycle = self + .cycle + .wrapping_add((self.tick / self.clip.length as f64) as u64); + self.tick %= self.clip.length as f64; + self.index = 0; + self.phase = 0.; + } else { + self.stop(); + } + } + } + pub fn current_note(&self) -> Option { + if !self.playing { + return None; + } + self.clip.notes[self.index..self.clip.count] + .iter() + .find(|n| self.tick < n.end() as f64) + .filter(|n| self.tick >= n.start as f64) + .copied() + } + pub fn length(&self) -> u32 { + self.clip.length + } + pub fn next_sample(&mut self, bpm: f64) -> f32 { + self.prepare_sample(); + if !self.playing { + return 0.; + } + let bpm = if bpm.is_finite() { + bpm.clamp(30., 300.) + } else { + 140. + }; + while self.index < self.clip.count && self.tick >= self.clip.notes[self.index].end() as f64 + { + self.index += 1; + self.phase = 0.; + } + let mut value = 0.; + if let Some(n) = self.clip.notes[..self.clip.count].get(self.index) { + if self.tick >= n.start as f64 { + let age = (self.tick - n.start as f64) * 60. / (bpm * PPQ as f64); + let remaining = (n.end() as f64 - self.tick) * 60. / (bpm * PPQ as f64); + let env = (age / 0.005).min(remaining / 0.012).clamp(0., 1.); + let sine = self.phase.sin(); + let triangle = 2. / std::f64::consts::PI * sine.asin(); + value = ((sine * 0.8 + triangle * 0.2) * env * (n.velocity as f64 / 127.) * 0.15) + as f32; + let frequency = 440. * 2.0_f64.powf((n.pitch as f64 - 69.) / 12.); + self.phase = (self.phase + TAU * frequency / self.sample_rate) % TAU; + } + } + self.tick += bpm * PPQ as f64 / (60. * self.sample_rate); + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::demo_motif; + fn render(rate: f64, block: usize) -> Vec { + let mut p = PreviewPlayer::default(); + p.set_sample_rate(rate).unwrap(); + p.play(PlaybackClip::new(&demo_motif()).unwrap()); + let total = (rate * 4.0) as usize; + let mut out = Vec::new(); + while out.len() < total { + for _ in 0..block.min(total - out.len()) { + out.push(p.next_sample(140.)); + } + } + out + } + #[test] + fn independent_of_block_size() { + for rate in [44100., 48000., 96000.] { + assert_eq!(render(rate, 1), render(rate, 511)); + } + } + #[test] + fn silent_until_started_and_stops_on_replace() { + let mut p = PreviewPlayer::default(); + assert_eq!(p.next_sample(140.), 0.); + let c = PlaybackClip::new(&demo_motif()).unwrap(); + p.play(c); + for _ in 0..1000 { + p.next_sample(140.); + } + p.play(c); + assert_eq!(p.tick(), 0.); + p.stop(); + assert_eq!(p.next_sample(140.), 0.); + p.play(c); + p.set_sample_rate(48000.).unwrap(); + assert!(!p.is_playing()); + } + #[test] + fn duration_and_finite_output() { + let mut p = PreviewPlayer::default(); + p.set_sample_rate(8000.).unwrap(); + p.play(PlaybackClip::new(&demo_motif()).unwrap()); + let mut count: i32 = 0; + while p.is_playing() { + let s = p.next_sample(120.); + assert!(s.is_finite() && s.abs() <= 0.15); + count += 1; + assert!(count < 40000); + } + assert!((count - 32000).abs() <= 2); + } +} diff --git a/crates/story-core/src/variation.rs b/crates/story-core/src/variation.rs new file mode 100644 index 0000000..1c54c5c --- /dev/null +++ b/crates/story-core/src/variation.rs @@ -0,0 +1,427 @@ +//! Phrase-level proposals. Randomness chooses compositional alternatives, not independent pitches. +use crate::{ + Candidate, Ending, Harmony, Note, Phrase, Result, Settings, Strategy, Task, BAR, + MAX_PLAYBACK_NOTES, PPQ, SECTION, +}; + +const FAMILIES: [Strategy; 8] = Strategy::ALL; +struct Random(u64); +impl Random { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); + z ^ (z >> 31) + } +} +fn same_music(a: &Phrase, b: &Phrase) -> bool { + a.length == b.length + && a.notes.len() == b.notes.len() + && a.notes + .iter() + .zip(&b.notes) + .all(|(a, b)| (a.start, a.duration, a.pitch) == (b.start, b.duration, b.pitch)) +} +/// Distance deliberately values timing/density differences above small pitch substitutions. +pub fn distance(a: &Phrase, b: &Phrase) -> u32 { + let a: std::collections::BTreeMap<_, _> = a.notes.iter().map(|n| (n.start, n)).collect(); + let b: std::collections::BTreeMap<_, _> = b.notes.iter().map(|n| (n.start, n)).collect(); + let mut total = 0; + for (start, n) in &a { + total += if let Some(other) = b.get(start) { + (n.pitch.abs_diff(other.pitch) as u32).min(12) * 3 + + (n.duration.abs_diff(other.duration) / 60).min(24) + } else { + 100 + }; + } + total += b.keys().filter(|start| !a.contains_key(start)).count() as u32 * 100; + total * 10 / (a.len().max(b.len()).max(1) as u32) +} +fn fit(task: Task, family: Strategy) -> u32 { + let preferred = match task { + Task::Develop => [ + Strategy::NewRhythm, + Strategy::FragmentAnswer, + Strategy::LaterPeak, + ], + Task::Answer => [ + Strategy::CallResponse, + Strategy::ChangedEnding, + Strategy::DelayedResolution, + ], + Task::MakeSpace => [ + Strategy::NegativeSpace, + Strategy::FragmentAnswer, + Strategy::CallResponse, + ], + Task::Return => [ + Strategy::HarmonicReturn, + Strategy::LaterPeak, + Strategy::ChangedEnding, + ], + }; + if family == preferred[0] { + 300 + } else if preferred.contains(&family) { + 240 + } else { + 0 + } +} +pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result> { + source.validate_source()?; + s.validate()?; + harmony.validate()?; + if source.notes.len() as u64 * (s.bars as u64 * BAR as u64).div_ceil(source.length as u64) + > MAX_PLAYBACK_NOTES as u64 + { + return Err(crate::Error("This motif is too dense to develop within the 2048-note limit. Shorten or simplify it.".into())); + } + let mut pool: Vec = Vec::new(); + for (family_index, family) in FAMILIES.into_iter().enumerate() { + for variant in 0..3 { + let mut rng = Random( + s.seed + .wrapping_add(family_index as u64 * 7919 + variant * 104729), + ); + let phrase = develop(source, s, harmony, family, &mut rng); + if phrase.notes.len() > MAX_PLAYBACK_NOTES { + continue; + } + phrase.validate(MAX_PLAYBACK_NOTES)?; + let description = match family { + Strategy::SavedIdea => "Saved working gesture.", + Strategy::NewRhythm => { + "Recognizable contour; displaced entries and repeated pickups." + } + Strategy::FragmentAnswer => "A short fragment leaves space before a fuller return.", + Strategy::NegativeSpace => { + "Connecting notes fall away; the signature anchors remain." + } + Strategy::CallResponse => "A shorter question is answered with longer arrivals.", + Strategy::HarmonicReturn => { + "The opening gesture returns after a contrasting middle." + } + Strategy::LaterPeak => { + "Register opens in the middle and returns before the ending." + } + Strategy::ChangedEnding => { + "The groove stays recognizable while answer endings change." + } + Strategy::DelayedResolution => { + "Earlier answers stay open; the last arrival follows your ending choice." + } + }; + let note = if harmony.spans.is_empty() { + " Scale guides pitches; no chord context supplied." + } else { + " Strong notes and arrivals follow the supplied harmony where locks and contour allow." + }; + let constraints = if s.preserve_rhythm { + " Groove is fixed." + } else { + "" + }; + let candidate = Candidate { + strategy: family, + explanation: format!("{description}{note}{constraints}"), + phrase, + }; + if let Some(index) = pool + .iter() + .position(|c| same_music(&c.phrase, &candidate.phrase)) + { + if fit(s.task, family) > fit(s.task, pool[index].strategy) { + pool[index] = candidate; + } + } else { + pool.push(candidate); + } + } + } + let mut selected: Vec = Vec::new(); + while selected.len() < 3 && !pool.is_empty() { + let index = pool + .iter() + .enumerate() + .max_by_key(|(i, c)| { + let diversity = selected + .iter() + .map(|chosen| distance(&c.phrase, &chosen.phrase)) + .min() + .unwrap_or(0); + let new_family = if selected.iter().all(|chosen| chosen.strategy != c.strategy) { + 120 + } else { + 0 + }; + ( + fit(s.task, c.strategy) > 0, + new_family > 0, + fit(s.task, c.strategy) + diversity, + usize::MAX - *i, + ) + }) + .map(|(i, _)| i) + .unwrap(); + selected.push(pool.remove(index)); + } + Ok(selected) +} + +fn develop( + source: &Phrase, + s: &Settings, + harmony: &Harmony, + family: Strategy, + rng: &mut Random, +) -> Phrase { + let sections = s.bars as u32 / 2; + let mut result = Vec::new(); + for section in 0..sections { + let offset = section * SECTION; + let mut notes = Vec::new(); + for repeat in 0..SECTION.div_ceil(source.length) { + for original in &source.notes { + let n = Note { + start: original.start + repeat * source.length, + ..*original + }; + if n.end() <= SECTION { + notes.push(n); + } + } + } + if section == 0 { + result.extend(notes.into_iter().map(|n| Note { + start: n.start + offset, + ..n + })); + continue; + } + let returning = section + 1 == sections; + let departure = sections == 4 && section == 2; + let pattern = (rng.next() % 3) as usize; + let original_count = notes.len(); + if !s.preserve_rhythm { + let mut i = 0; + notes.retain(|n| { + let index = i; + i += 1; + if n.is_anchor() { + return true; + } + match family { + Strategy::NegativeSpace => { + index == 0 + || index + 1 == original_count + || (index + pattern).is_multiple_of(3) + } + Strategy::FragmentAnswer if !returning => index < original_count.div_ceil(2), + Strategy::CallResponse if !returning => n.start < BAR, + _ => true, + } + }); + } + for (i, n) in notes.iter_mut().enumerate() { + if !n.keeps_pitch() { + let steps = match family { + Strategy::LaterPeak if departure => 3 + s.variation as i16 / 25, + Strategy::LaterPeak if sections == 2 && i < original_count / 2 => 2, + Strategy::HarmonicReturn if departure => 2, + Strategy::ChangedEnding if i >= original_count / 2 => -1 - pattern as i16, + Strategy::DelayedResolution if !returning => 1, + _ => 0, + }; + n.pitch = s.degree(n.pitch, steps); + } + if !n.locked { + let accent = if n.start.is_multiple_of(PPQ) { 5 } else { -5 }; + let arc = if departure { + 6 + } else if returning { + -3 + } else { + 0 + }; + n.velocity = (n.velocity as i16 + accent + arc).clamp(1, 127) as u8; + } + } + if !s.preserve_rhythm { + for i in 0..notes.len() { + if notes[i].keeps_rhythm() { + continue; + } + let previous = if i == 0 { 0 } else { notes[i - 1].end() }; + let next = notes.get(i + 1).map_or(SECTION, |n| n.start); + let n = &mut notes[i]; + let shift = PPQ / 4 + (s.variation as u32 * PPQ / 400); + if matches!( + family, + Strategy::NewRhythm | Strategy::FragmentAnswer | Strategy::CallResponse + ) { + let wanted = if (i + pattern).is_multiple_of(2) { + n.start.saturating_add(shift) + } else { + n.start.saturating_sub(shift) + }; + n.start = wanted.clamp(previous, next.saturating_sub(1).max(previous)); + } + let desired = match family { + Strategy::NegativeSpace | Strategy::FragmentAnswer => n.duration.min(PPQ / 2), + Strategy::CallResponse if returning => n.duration.saturating_add(PPQ / 2), + Strategy::NewRhythm if (i + pattern) % 2 == 1 => n.duration / 2, + _ => n.duration, + }; + n.duration = desired.max(1).min(next - n.start); + } + if family == Strategy::NewRhythm { + let mut extra = Vec::new(); + for (i, n) in notes.iter().enumerate() { + let next = notes.get(i + 1).map_or(SECTION, |n| n.start); + if !n.is_anchor() && next.saturating_sub(n.end()) >= PPQ && i % 2 == pattern % 2 + { + extra.push(Note { + start: n.end() + PPQ / 4, + duration: PPQ / 4, + pitch: n.pitch, + velocity: n.velocity.saturating_sub(12).max(1), + ..Note::default() + }); + } + } + notes.extend(extra); + notes.sort_by_key(|n| n.start); + } + } + // Reference relationships constrain harmonic adjustments, preserving the chosen contour. + let reference: Vec<_> = notes.iter().map(|n| n.pitch).collect(); + for i in 0..notes.len() { + if notes[i].keeps_pitch() { + continue; + } + let last = i + 1 == notes.len(); + let global = notes[i].start + offset; + let strong = global.is_multiple_of(PPQ); + let wants_open = last + && (family == Strategy::DelayedResolution && !returning + || returning && s.ending == Ending::Open); + let wants_closed = last && returning && s.ending == Ending::Resolve; + let region = harmony.at(global); + let mask = region.map(|h| h.tones); + if !strong && !last { + continue; + } + let allowed = |pitch: u8| { + if !s.preserve_contour { + return true; + } + let previous = i.checked_sub(1).map(|p| (reference[p], notes[p].pitch)); + let next = reference.get(i + 1).copied(); + previous.is_none_or(|(old, new)| match reference[i].cmp(&old) { + std::cmp::Ordering::Greater => pitch >= new, + std::cmp::Ordering::Less => pitch <= new, + std::cmp::Ordering::Equal => pitch == new, + }) && next.is_none_or(|next| match reference[i].cmp(&next) { + std::cmp::Ordering::Greater => pitch >= next, + std::cmp::Ordering::Less => pitch <= next, + std::cmp::Ordering::Equal => pitch == next, + }) + }; + let target = if wants_closed && family == Strategy::HarmonicReturn { + source.notes.last().unwrap().pitch + } else { + notes[i].pitch + }; + let chosen = (s.low..=s.high) + .filter(|&pitch| { + if !allowed(pitch) { + return false; + } + match mask { + Some(mask) if wants_open => { + s.contains(pitch) && mask & (1 << (pitch % 12)) == 0 + } + Some(mask) => mask & (1 << (pitch % 12)) != 0, + None if wants_open => s.contains(pitch) && pitch % 12 != s.root, + None if wants_closed => pitch % 12 == s.root, + None => false, + } + }) + .min_by_key(|&pitch| pitch.abs_diff(target)); + if let Some(pitch) = chosen { + notes[i].pitch = pitch; + } + } + result.extend(notes.into_iter().map(|n| Note { + start: n.start + offset, + ..n + })); + } + Phrase { + notes: result, + length: s.bars as u32 * BAR, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn later_peak_returns_before_the_final_note() { + let source = crate::demo_motif(); + let settings = Settings { + preserve_rhythm: true, + ..Settings::default() + }; + let phrase = develop( + &source, + &settings, + &Harmony::default(), + Strategy::LaterPeak, + &mut Random(42), + ); + let peak = phrase + .notes + .iter() + .filter(|n| n.start >= 4 * BAR && n.start < 6 * BAR) + .map(|n| n.pitch) + .max() + .unwrap(); + let returned = phrase + .notes + .iter() + .filter(|n| n.start >= 6 * BAR) + .map(|n| n.pitch) + .max() + .unwrap(); + assert!(peak > returned); + } + #[test] + fn preserved_contour_has_no_direction_reversals_with_a_fixed_groove() { + let source = crate::demo_motif(); + let settings = Settings { + preserve_rhythm: true, + preserve_contour: true, + ..Settings::default() + }; + for family in FAMILIES { + let phrase = develop( + &source, + &settings, + &Harmony::default(), + family, + &mut Random(42), + ); + for section in phrase.notes.chunks_exact(source.notes.len()) { + for (original, new) in source.notes.windows(2).zip(section.windows(2)) { + let a = original[1].pitch as i16 - original[0].pitch as i16; + let b = new[1].pitch as i16 - new[0].pitch as i16; + assert!(a * b >= 0); + } + } + } + } +} diff --git a/crates/story-core/tests/creative.rs b/crates/story-core/tests/creative.rs new file mode 100644 index 0000000..fdf2f07 --- /dev/null +++ b/crates/story-core/tests/creative.rs @@ -0,0 +1,138 @@ +use story_core::*; + +fn session() -> Session { + let mut s = Session { + source: Some(demo_motif()), + ..Default::default() + }; + s.regenerate().unwrap(); + s +} +#[test] +fn moves_fork_and_restore_context_without_losing_siblings() { + let mut s = session(); + s.pin_selected(0).unwrap(); + let root = s.save_branch("Original").unwrap(); + let original = s.composed_phrase().unwrap(); + s.apply_move(CreativeMove::Thin, Some(1)).unwrap(); + let thin = s.active_branch.unwrap(); + let thinned = s.composed_phrase().unwrap(); + assert_ne!(thinned, original); + assert_eq!( + &thinned.notes[..demo_motif().notes.len()], + demo_motif().notes.as_slice() + ); + s.restore_branch(root).unwrap(); + assert_eq!(s.composed_phrase().unwrap(), original); + assert_eq!(s.pins.len(), 1); + s.apply_move(CreativeMove::Lengthen, Some(2)).unwrap(); + let long = s.active_branch.unwrap(); + assert_ne!(thin, long); + assert_eq!(s.branches.len(), 3); + assert_eq!( + s.branches.iter().find(|b| b.id == thin).unwrap().parent, + Some(root) + ); + assert_eq!( + s.branches.iter().find(|b| b.id == long).unwrap().parent, + Some(root) + ); + s.restore_branch(thin).unwrap(); + assert_eq!(s.composed_phrase().unwrap(), thinned); + assert!(s.remove_branch(root).is_err()); + assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s); +} +#[test] +fn gesture_recall_and_cross_project_library_preserve_performance() { + let mut s = session(); + s.performance.mode = RoleMode::CallResponse; + s.performance.voices[1].controller.enabled = true; + s.performance.voices[1].controller.cc = 20; + s.performance.voices[1].controller.shape(2); + let id = s.save_gesture("Floating answer", Some(1)).unwrap(); + let saved = s.gestures[0].clone(); + let bytes = s.export_gesture_library().unwrap(); + let mut other = Session { + source: Some(demo_motif()), + harmony: Harmony::progression(2, Scale::Major), + ..Default::default() + }; + other.save_gesture("Existing", None).unwrap(); + let context = other.harmony.clone(); + other + .import_gesture_library(read_gesture_library(&bytes).unwrap()) + .unwrap(); + assert_eq!(other.gestures.len(), 2); + assert_ne!(other.gestures[0].id, other.gestures[1].id); + let imported = other.gestures[1].id; + other.recall_gesture(imported).unwrap(); + assert_eq!(other.source, Some(saved.phrase)); + assert_eq!(other.performance, saved.performance); + assert_eq!(other.harmony, context); + assert!(other.candidates.is_empty()); + assert!(other.pins.is_empty()); + assert!(s.gestures.iter().any(|g| g.id == id)); +} +#[test] +fn blocked_moves_and_full_histories_are_atomic() { + let mut s = session(); + s.settings.preserve_rhythm = true; + let before = s.clone(); + assert!(s.apply_move(CreativeMove::Thin, None).is_err()); + assert_eq!(s, before); + s.settings.preserve_rhythm = false; + for i in 0..MAX_BRANCHES { + s.save_branch(&format!("Idea {i}")).unwrap(); + } + let before = s.clone(); + assert!(s.apply_move(CreativeMove::Thin, Some(1)).is_err()); + assert_eq!(s, before); + let leaf = s.active_branch.unwrap(); + s.remove_branch(leaf).unwrap(); + assert_eq!(s.branches.len(), MAX_BRANCHES - 1); +} +#[test] +fn malformed_ancestry_libraries_and_version_two_state_are_handled() { + let mut s = session(); + let id = s.save_branch("Root").unwrap(); + s.branches[0].parent = Some(id); + assert!(s.validate().is_err()); + assert!(read_gesture_library(br#"{"version":99,"gestures":[]}"#).is_err()); + let old = session(); + let mut value = serde_json::to_value(&old).unwrap(); + value["version"] = 2.into(); + for key in [ + "performance", + "gestures", + "branches", + "active_branch", + "next_id", + ] { + value.as_object_mut().unwrap().remove(key); + } + let restored = Session::from_json(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(restored.version, 3); + assert_eq!(restored.source, old.source); + assert_eq!(restored.performance, PerformanceSettings::default()); +} + +#[test] +fn delay_moves_adjacent_notes_together_and_rejects_protected_collisions() { + let mut s = session(); + let make_note = |start| Note { + start, + duration: PPQ, + pitch: 60, + velocity: 90, + ..Default::default() + }; + s.candidates[s.selected].phrase.notes = vec![make_note(SECTION), make_note(SECTION + PPQ)]; + s.apply_move(CreativeMove::Delay, Some(1)).unwrap(); + let moved = s.composed_phrase().unwrap(); + assert_eq!(moved.notes[0].start, SECTION + PPQ / 4); + assert_eq!(moved.notes[1].start, SECTION + PPQ + PPQ / 4); + s.candidates[0].phrase.notes[1].rhythm_locked = true; + let before = s.clone(); + assert!(s.apply_move(CreativeMove::Delay, Some(1)).is_err()); + assert_eq!(s, before); +} diff --git a/crates/story-core/tests/musical.rs b/crates/story-core/tests/musical.rs new file mode 100644 index 0000000..3da1104 --- /dev/null +++ b/crates/story-core/tests/musical.rs @@ -0,0 +1,228 @@ +use story_core::*; + +fn rhythm(p: &Phrase) -> Vec<(u32, u32)> { + p.notes.iter().map(|n| (n.start, n.duration)).collect() +} +#[test] +fn alternatives_change_density_and_rhythm_instead_of_only_pitch() { + let source = demo_motif(); + let s = Settings::default(); + let candidates = generate(&source, &s).unwrap(); + assert_eq!(candidates.len(), 3); + assert!(candidates + .iter() + .any(|c| c.phrase.notes.len() != source.notes.len() * 4)); + assert!(candidates + .windows(2) + .any(|pair| rhythm(&pair[0].phrase) != rhythm(&pair[1].phrase))); + for c in &candidates { + assert_eq!( + &c.phrase.notes[..source.notes.len()], + source.notes.as_slice() + ); + } + assert_eq!(candidates, generate(&source, &s).unwrap()); + let mut signatures = std::collections::BTreeSet::new(); + for seed in 0..12 { + for c in generate(&source, &Settings { seed, ..s.clone() }).unwrap() { + signatures.insert(rhythm(&c.phrase)); + } + } + assert!( + signatures.len() > 3, + "seeds should explore multiple rhythmic decisions" + ); +} +#[test] +fn fixed_groove_and_separate_note_locks_take_priority() { + let mut source = demo_motif(); + source.notes[1].pitch_locked = true; + source.notes[1].pitch = 99; + source.notes[3].rhythm_locked = true; + for c in generate(&source, &Settings::default()).unwrap() { + let pitch: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect(); + assert_eq!(pitch.len(), 4); + assert!(pitch.iter().all(|n| n.pitch == 99)); + for rep in 0..4 { + assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked + && n.start == source.notes[3].start + rep * source.length + && n.duration == source.notes[3].duration)); + } + } + let source = demo_motif(); + let repeated: Vec<_> = (0..4) + .flat_map(|rep| { + source + .notes + .iter() + .map(move |n| (n.start + rep * SECTION, n.duration)) + }) + .collect(); + for c in generate( + &source, + &Settings { + preserve_rhythm: true, + ..Settings::default() + }, + ) + .unwrap() + { + assert_eq!(rhythm(&c.phrase), repeated); + } +} +#[test] +fn impossible_diversity_returns_one_honest_proposal() { + let mut source = demo_motif(); + for n in &mut source.notes { + n.locked = true; + } + let candidates = generate(&source, &Settings::default()).unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].phrase.notes.len(), source.notes.len() * 4); +} +#[test] +fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() { + let harmony = Harmony { + length: BAR, + spans: vec![HarmonySpan { + start: 0, + duration: BAR, + tones: ChordQuality::Major.mask(0), + bass: 48, + }], + }; + for ending in [Ending::Open, Ending::Resolve] { + let s = Settings { + preserve_contour: false, + ending, + ..Settings::default() + }; + for c in generate_with_harmony(&demo_motif(), &s, &harmony).unwrap() { + let last = c.phrase.notes.last().unwrap(); + let chord = ChordQuality::Major.mask(0) & (1 << (last.pitch % 12)) != 0; + assert_eq!(chord, ending == Ending::Resolve); + for n in c.phrase.notes.iter().filter(|n| { + n.start >= SECTION && n.start.is_multiple_of(PPQ) && n.start != last.start + }) { + assert_ne!( + harmony.at(n.start).unwrap().tones & (1 << (n.pitch % 12)), + 0 + ); + } + } + } +} +#[test] +fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() { + let notes = vec![ + Note { + start: 0, + duration: BAR, + pitch: 64, + velocity: 90, + ..Default::default() + }, + Note { + start: 0, + duration: BAR, + pitch: 67, + velocity: 90, + ..Default::default() + }, + Note { + start: 0, + duration: BAR, + pitch: 72, + velocity: 90, + ..Default::default() + }, + ]; + let h = Harmony::from_midi(¬es, 2 * BAR).unwrap(); + assert_eq!(h.spans.len(), 1); + assert_eq!(h.spans[0].bass, 64); + assert_eq!(h.spans[0].tones, ChordQuality::Major.mask(0)); + assert!(h.at(BAR + 1).is_none()); + assert_eq!(h.at(2 * BAR).unwrap().bass, 64); +} +#[test] +fn pins_combine_candidates_and_survive_regeneration_exactly() { + let mut s = Session { + source: Some(demo_motif()), + ..Default::default() + }; + s.regenerate().unwrap(); + s.pin_selected(1).unwrap(); + let saved = s.pins[0].clone(); + s.selected = 1; + let expected: Vec<_> = saved + .phrase + .notes + .iter() + .map(|n| Note { + start: n.start + SECTION, + ..*n + }) + .collect(); + let combined = s.composed_phrase().unwrap(); + assert_eq!( + combined + .notes + .iter() + .filter(|n| n.start >= SECTION && n.start < 2 * SECTION) + .copied() + .collect::>(), + expected + ); + s.settings.seed += 7; + s.regenerate().unwrap(); + assert_eq!(s.pins[0], saved); + s.accept_composed().unwrap(); + assert_eq!(s.accepted, Some(s.composed_phrase().unwrap())); + assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s); +} +#[test] +fn version_one_sessions_migrate_without_losing_existing_notes() { + let s = Session { + source: Some(demo_motif()), + ..Default::default() + }; + let mut value = serde_json::to_value(&s).unwrap(); + value["version"] = 1.into(); + let object = value.as_object_mut().unwrap(); + for key in ["harmony", "pins", "audition", "looping"] { + object.remove(key); + } + let settings = object.get_mut("settings").unwrap().as_object_mut().unwrap(); + for key in ["preserve_contour", "task", "ending"] { + settings.remove(key); + } + for n in object.get_mut("source").unwrap()["notes"] + .as_array_mut() + .unwrap() + { + n.as_object_mut().unwrap().remove("pitch_locked"); + n.as_object_mut().unwrap().remove("rhythm_locked"); + } + let migrated = Session::from_json(&serde_json::to_vec(&value).unwrap()).unwrap(); + assert_eq!(migrated.version, STATE_VERSION); + assert_eq!(migrated.source, s.source); + assert!(!migrated.looping); + assert!(migrated.harmony.spans.is_empty()); +} +#[test] +fn invalid_pins_and_unsafe_legacy_seams_are_rejected() { + let mut s = Session { + source: Some(demo_motif()), + ..Default::default() + }; + s.regenerate().unwrap(); + s.candidates[0].phrase.notes = vec![Note { + start: SECTION - 100, + duration: 200, + pitch: 60, + velocity: 80, + ..Default::default() + }]; + assert!(s.pin_selected(1).is_err()); + assert!(s.pin_selected(9).is_err()); +} diff --git a/crates/story-core/tests/performance.rs b/crates/story-core/tests/performance.rs new file mode 100644 index 0000000..c1cda2a --- /dev/null +++ b/crates/story-core/tests/performance.rs @@ -0,0 +1,152 @@ +use story_core::*; +fn held() -> Phrase { + Phrase { + length: PPQ, + notes: vec![ + Note { + start: 0, + duration: PPQ / 2, + pitch: 60, + velocity: 90, + ..Default::default() + }, + Note { + start: PPQ / 2, + duration: PPQ / 2, + pitch: 60, + velocity: 70, + ..Default::default() + }, + ], + } +} +#[test] +fn role_handoffs_preserve_event_identity_and_respect_locks() { + let mut source = demo_motif(); + source.notes[1].pitch_locked = true; + source.notes[0].locked = true; + for mode in RoleMode::ALL { + let mut s = PerformanceSettings { + mode, + ..Default::default() + }; + s.voices[0].transpose = -12; + s.voices[1].transpose = 12; + s.voices[0].velocity = 50; + s.voices[1].velocity = 50; + let p = Performance::compile(&source, &s, 140., false).unwrap(); + let on: Vec<_> = p + .events + .iter() + .filter(|e| e.message.kind == MessageKind::On && !e.next_cycle) + .collect(); + assert_eq!(on.len(), source.notes.len()); + for (i, n) in source.notes.iter().enumerate() { + let role = mode.role(i, n); + let actual = on.iter().find(|e| e.tick == n.start as i32).unwrap(); + assert_eq!(actual.message.channel, s.voices[role].channel); + assert_eq!( + actual.message.key, + if n.keeps_pitch() { + n.pitch + } else { + (n.pitch as i16 + s.voices[role].transpose as i16) as u8 + } + ); + } + assert_eq!(on[0].message.value, 90); + } +} +#[test] +fn controller_interpolation_and_validation_are_explicit() { + let mut curve = ControllerCurve::default(); + assert_eq!(curve.value_at(500), 64); + curve.shape(2); + assert_eq!(curve.value_at(500), 127); + assert_eq!(curve.value_at(1000), 0); + curve.cc = 64; + assert!(curve.validate().is_err()); + let mut s = PerformanceSettings::default(); + s.voices[0].controller.enabled = true; + s.voices[0].controller.cc = 20; + let p = Performance::compile(&held(), &s, 120., false).unwrap(); + assert!(p + .events + .iter() + .any(|e| e.message.kind == MessageKind::Control + && e.message.key == 20 + && e.message.value == 127)); + s.voices[0].controller.points[1].position = 0; + assert!(s.validate().is_err()); +} +#[test] +fn attack_compensation_warms_up_once_and_wraps_without_cutting_the_next_note() { + let mut s = PerformanceSettings::default(); + s.voices[0].attack_ms = 100; + let performance = Performance::compile(&held(), &s, 120., true).unwrap(); + assert_eq!(performance.lead_in, 192); + assert!(performance + .events + .iter() + .any(|e| e.tick == -192 && e.message.kind == MessageKind::On && !e.next_cycle)); + let mut player = PerformancePlayer::default(); + player.set_sample_rate(8000.).unwrap(); + player.set_looping(true); + player.play(performance.clip().unwrap()); + let mut ons = Vec::new(); + for frame in 0..4802 { + let (_, events) = player.next_frame(120.); + for e in events.events[..events.len].iter().flatten() { + if e.kind == MessageKind::On { + ons.push(frame); + } + } + } + assert_eq!(ons.len(), 3); + assert_eq!(ons[0], 0); + assert!((ons[2] as i32 - 4000).abs() <= 1); + assert!(player + .current_messages() + .events + .iter() + .flatten() + .any(|e| e.kind == MessageKind::On && e.key == 60)); + player.set_looping(false); + player.play(performance.clip().unwrap()); + let mut count = 0; + while player.is_playing() { + let (_, events) = player.next_frame(120.); + count += events + .events + .iter() + .flatten() + .filter(|e| e.kind == MessageKind::On) + .count(); + } + assert_eq!(count, 2); +} +#[test] +fn public_clip_validation_rejects_bad_roles_and_dense_events() { + let mut p = + Performance::compile(&held(), &PerformanceSettings::default(), 120., false).unwrap(); + p.events[0].role = 9; + assert!(p.clip().is_err()); + let event = TimedMessage { + tick: 0, + role: 0, + message: MidiMessage { + kind: MessageKind::On, + channel: 0, + key: 60, + value: 90, + rest: 0, + }, + next_cycle: false, + }; + let p = Performance { + length: PPQ, + lead_in: 0, + events: vec![event; 17], + }; + assert!(p.clip().is_err()); +} diff --git a/crates/story-midi/Cargo.toml b/crates/story-midi/Cargo.toml new file mode 100644 index 0000000..19704d2 --- /dev/null +++ b/crates/story-midi/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "story-midi" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +story-core.workspace = true diff --git a/crates/story-midi/src/lib.rs b/crates/story-midi/src/lib.rs new file mode 100644 index 0000000..57e1d5f --- /dev/null +++ b/crates/story-midi/src/lib.rs @@ -0,0 +1,546 @@ +//! Strict Standard MIDI File import/export. Unsupported data is reported, never flattened. +use story_core::{ + Error, MessageKind, Note, Performance, PerformanceSettings, Phrase, Result, BAR, + MAX_PLAYBACK_NOTES, PPQ, +}; + +pub const MAX_FILE_BYTES: usize = 4 * 1024 * 1024; +#[derive(Debug)] +pub struct MidiTrack { + pub name: String, + pub phrase: Phrase, + pub warnings: Vec, + pub channels: Vec, +} +#[derive(Debug)] +pub struct MidiFile { + pub tracks: Vec, + pub tempo: Option, +} +fn error(s: &str) -> Error { + Error(s.into()) +} + +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} +impl<'a> Reader<'a> { + fn take(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(len) + .ok_or_else(|| error("MIDI length overflow."))?; + let out = self + .bytes + .get(self.pos..end) + .ok_or_else(|| error("Truncated MIDI file."))?; + self.pos = end; + Ok(out) + } + fn byte(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + fn vlq(&mut self) -> Result { + let mut v = 0; + for _ in 0..4 { + let b = self.byte()?; + v = (v << 7) | (b & 127) as u32; + if b & 128 == 0 { + return Ok(v); + } + } + Err(error("Invalid MIDI variable-length integer.")) + } + fn data(&mut self) -> Result { + let b = self.byte()?; + if b > 127 { + Err(error("Invalid MIDI data byte.")) + } else { + Ok(b) + } + } +} +fn ticks(value: u32, division: u16) -> Result { + let numerator = value as u64 * PPQ as u64; + if !numerator.is_multiple_of(division as u64) { + return Err(error("This MIDI timing cannot be represented exactly at 960 PPQ. Re-export at 96, 480, or 960 PPQ.")); + } + u32::try_from(numerator / division as u64).map_err(|_| error("MIDI timeline is too long.")) +} + +pub fn read(bytes: &[u8]) -> Result { + if bytes.len() > MAX_FILE_BYTES { + return Err(error("MIDI file exceeds 4 MiB.")); + } + let mut r = Reader { bytes, pos: 0 }; + if r.take(4)? != b"MThd" { + return Err(error("Select a standard .mid MIDI file.")); + } + let header_len = r.u32()? as usize; + if header_len < 6 { + return Err(error("Invalid MIDI header.")); + } + let format = r.u16()?; + let count = r.u16()?; + let division = r.u16()?; + if format > 1 { + return Err(error( + "Asynchronous format-2 MIDI is unsupported. Export format 0 or 1.", + )); + } + if count == 0 || count > 128 || (format == 0 && count != 1) { + return Err(error("Invalid MIDI track count (maximum 128).")); + } + if division == 0 || division & 0x8000 != 0 { + return Err(error( + "SMPTE timing is unsupported. Export beat-based MIDI.", + )); + } + r.take(header_len - 6)?; + let mut tracks = Vec::new(); + let mut tempo = None; + for track_index in 0..count { + if r.take(4)? != b"MTrk" { + return Err(error("Missing MIDI track chunk.")); + } + let len = r.u32()? as usize; + let data = r.take(len)?; + let mut t = Reader { + bytes: data, + pos: 0, + }; + let mut clock = 0u32; + let mut running = 0u8; + let mut active = [[None; 128]; 16]; + let mut notes = Vec::new(); + let mut name = format!("Track {}", track_index + 1); + let mut ignored = false; + let mut channels = 0u16; + let mut ended = false; + let mut events = 0; + while t.pos < data.len() { + events += 1; + if events > 100_000 { + return Err(error("MIDI track exceeds 100,000 events.")); + } + clock = clock + .checked_add(t.vlq()?) + .ok_or_else(|| error("MIDI timeline overflow."))?; + let first = t.byte()?; + let status = if first < 128 { + if running == 0 { + return Err(error("Invalid MIDI running status.")); + } + t.pos -= 1; + running + } else { + first + }; + match status { + 0xff => { + running = 0; + let kind = t.byte()?; + let len = t.vlq()? as usize; + let body = t.take(len)?; + match kind { + 0x03 => name = String::from_utf8_lossy(body).chars().take(120).collect(), + 0x2f => { + if !body.is_empty() { + return Err(error("Invalid end-of-track event.")); + } + ended = true; + break; + } + 0x51 => { + if body.len() != 3 { + return Err(error("Invalid tempo event.")); + } + let us = + ((body[0] as u32) << 16) | ((body[1] as u32) << 8) | body[2] as u32; + if us == 0 { + return Err(error("Invalid zero MIDI tempo.")); + } + if tempo.is_none() { + tempo = Some(60_000_000. / us as f64); + } else { + ignored = true; + } + } + 0x58 => { + if body.len() != 4 { + return Err(error("Invalid time signature.")); + } + if body[0] != 4 || body[1] != 2 { + return Err(error( + "Only 4/4 motifs are supported in this version.", + )); + } + } + _ => {} + } + } + 0xf0 | 0xf7 => { + running = 0; + let len = t.vlq()? as usize; + t.take(len)?; + ignored = true; + } + 0x80..=0xef => { + running = status; + let a = t.data()?; + let b = if status & 0xf0 == 0xc0 || status & 0xf0 == 0xd0 { + 0 + } else { + t.data()? + }; + let ch = (status & 15) as usize; + match (status & 0xf0, b) { + (0x90, 1..=127) => { + channels |= 1 << ch; + if active[ch][a as usize].is_some() { + return Err(error( + "Repeated note-on before note-off is unsupported.", + )); + } + active[ch][a as usize] = Some((clock, b)); + } + (0x80, _) | (0x90, 0) => { + let (start, velocity) = + active[ch][a as usize].take().ok_or_else(|| { + error("MIDI contains a note-off without a note-on.") + })?; + let start = ticks(start, division)?; + let end = ticks(clock, division)?; + if end <= start { + return Err(error("MIDI contains a zero-length note.")); + } + if notes.len() >= MAX_PLAYBACK_NOTES { + return Err(error("MIDI track has too many notes.")); + } + notes.push(Note { + start, + duration: end - start, + pitch: a, + velocity, + locked: false, + ..Note::default() + }); + } + _ => ignored = true, + } + } + _ => return Err(error("Unsupported MIDI event status.")), + } + } + if !ended { + return Err(error("MIDI track is missing its end-of-track event.")); + } + if active.iter().flatten().any(Option::is_some) { + return Err(error("MIDI contains notes without note-offs.")); + } + if !notes.is_empty() { + notes.sort_by_key(|n| (n.start, n.pitch)); + let end = ticks(clock, division)?.max(notes.iter().map(Note::end).max().unwrap_or(0)); + let length = end + .checked_add(BAR - 1) + .ok_or_else(|| error("MIDI timeline overflow."))? + / BAR + * BAR; + let warnings = if ignored { + vec!["Controllers, pitch bend, program changes, SysEx, or later tempo events are not imported. Preview uses a single tempo.".into()] + } else { + vec![] + }; + tracks.push(MidiTrack { + name, + phrase: Phrase { notes, length }, + warnings, + channels: (0..16).filter(|c| channels & (1 << c) != 0).collect(), + }); + } + } + if tracks.is_empty() { + return Err(error("This MIDI file has no note tracks.")); + } + Ok(MidiFile { tracks, tempo }) +} + +fn vlq(mut value: u32, out: &mut Vec) { + let mut b = [0u8; 5]; + let mut i = 4; + b[i] = (value & 127) as u8; + value >>= 7; + while value > 0 { + i -= 1; + b[i] = ((value & 127) as u8) | 128; + value >>= 7; + } + out.extend_from_slice(&b[i..]); +} + +pub fn write(phrase: &Phrase, bpm: f64) -> Result> { + phrase.validate(MAX_PLAYBACK_NOTES)?; + if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) { + return Err(error("Tempo must be 30–300 BPM.")); + } + let us = (60_000_000. / bpm).round() as u32; + let mut track = vec![ + 0, + 0xff, + 0x51, + 3, + (us >> 16) as u8, + (us >> 8) as u8, + us as u8, + 0, + 0xff, + 0x58, + 4, + 4, + 2, + 24, + 8, + ]; + let mut events = Vec::new(); + for n in &phrase.notes { + events.push((n.start, 1, n.pitch, n.velocity)); + events.push((n.end(), 0, n.pitch, 0)); + } + events.sort_unstable(); + let mut previous = 0; + for (time, on, pitch, velocity) in events { + vlq(time - previous, &mut track); + track.extend_from_slice(&[if on == 1 { 0x90 } else { 0x80 }, pitch, velocity]); + previous = time; + } + vlq(phrase.length - previous, &mut track); + track.extend_from_slice(&[0xff, 0x2f, 0]); + let mut out = b"MThd\0\0\0\x06\0\0\0\x01".to_vec(); + out.extend_from_slice(&(PPQ as u16).to_be_bytes()); + out.extend_from_slice(b"MTrk"); + out.extend_from_slice(&(track.len() as u32).to_be_bytes()); + out.extend_from_slice(&track); + Ok(out) +} + +/// Two explicit sound roles, named tracks, channel routing, and mapped expression. +/// Compensated export adds a full bar before the intended musical start. +pub fn write_performance( + phrase: &Phrase, + settings: &PerformanceSettings, + bpm: f64, +) -> Result> { + let p = Performance::compile(phrase, settings, bpm, settings.compensated_export)?; + let shift = if settings.compensated_export && p.lead_in > 0 { + BAR + } else { + 0 + }; + let us = (60_000_000. / bpm).round() as u32; + let mut conductor = vec![ + 0, + 0xff, + 0x51, + 3, + (us >> 16) as u8, + (us >> 8) as u8, + us as u8, + 0, + 0xff, + 0x58, + 4, + 4, + 2, + 24, + 8, + ]; + vlq(phrase.length + shift, &mut conductor); + conductor.extend_from_slice(&[0xff, 0x2f, 0]); + let mut tracks = vec![conductor]; + for role in 0..2 { + let events: Vec<_> = p + .events + .iter() + .filter(|e| e.role as usize == role && !e.next_cycle) + .collect(); + if events.is_empty() { + continue; + } + let mut track = vec![0, 0xff, 3]; + let name = format!( + "{} (channel {})", + settings.voices[role].name, + settings.voices[role].channel + 1 + ); + vlq(name.len() as u32, &mut track); + track.extend_from_slice(name.as_bytes()); + let mut previous = 0; + for e in events { + let tick = (e.tick + shift as i32) as u32; + vlq(tick - previous, &mut track); + let kind = match e.message.kind { + MessageKind::Off => 0x80, + MessageKind::On => 0x90, + MessageKind::Control => 0xb0, + }; + track.extend_from_slice(&[kind | e.message.channel, e.message.key, e.message.value]); + previous = tick; + } + let end = phrase.length + shift; + if settings.voices[role].controller.enabled { + vlq(end - previous, &mut track); + track.extend_from_slice(&[ + 0xb0 | settings.voices[role].channel, + settings.voices[role].controller.cc, + settings.voices[role].controller.rest, + ]); + previous = end; + } + vlq(end - previous, &mut track); + track.extend_from_slice(&[0xff, 0x2f, 0]); + tracks.push(track); + } + let mut out = b"MThd\0\0\0\x06\0\x01".to_vec(); + out.extend_from_slice(&(tracks.len() as u16).to_be_bytes()); + out.extend_from_slice(&(PPQ as u16).to_be_bytes()); + for track in tracks { + out.extend_from_slice(b"MTrk"); + out.extend_from_slice(&(track.len() as u32).to_be_bytes()); + out.extend_from_slice(&track); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + fn file(track: &[u8]) -> Vec { + let mut b = b"MThd\0\0\0\x06\0\0\0\x01\x03\xc0MTrk".to_vec(); + b.extend_from_slice(&(track.len() as u32).to_be_bytes()); + b.extend_from_slice(track); + b + } + #[test] + fn round_trip_source_and_generated_phrase() { + let source = story_core::demo_motif(); + let bytes = write(&source, 140.).unwrap(); + let parsed = read(&bytes).unwrap(); + assert_eq!(parsed.tracks[0].phrase, source); + assert!((parsed.tempo.unwrap() - 140.).abs() < 0.001); + for c in story_core::generate(&source, &story_core::Settings::default()).unwrap() { + assert_eq!( + read(&write(&c.phrase, 140.).unwrap()).unwrap().tracks[0].phrase, + c.phrase + ); + } + } + #[test] + fn running_status_and_zero_velocity_off() { + let parsed = read(&file(&[ + 0, 0x90, 60, 90, 0x83, 0x60, 60, 0, 0, 0xff, 0x2f, 0, + ])) + .unwrap(); + assert_eq!(parsed.tracks[0].phrase.notes[0].duration, 480); + } + #[test] + fn malformed_and_unsupported_files() { + let good = write(&story_core::demo_motif(), 140.).unwrap(); + for n in 0..good.len() { + assert!(read(&good[..n]).is_err()); + } + let mut smpte = good.clone(); + smpte[12] = 0x80; + assert!(read(&smpte).unwrap_err().0.contains("SMPTE")); + assert!(read(&file(&[0, 0x90, 60, 80, 0, 0xff, 0x2f, 0])).is_err()); + assert!( + read(&file(&[0, 0xff, 0x58, 4, 3, 2, 24, 8, 0, 0xff, 0x2f, 0])) + .unwrap_err() + .0 + .contains("4/4") + ); + assert!(read(&file(&[0x81, 0x81, 0x81, 0x81, 0, 0xff, 0x2f, 0])).is_err()); + } + #[test] + fn overlap_is_available_for_selection_but_rejected_as_motif() { + let p = read(&file(&[ + 0, 0x90, 60, 90, 0, 0x90, 67, 80, 10, 0x80, 60, 0, 0, 0x80, 67, 0, 0, 0xff, 0x2f, 0, + ])) + .unwrap(); + assert!(p.tracks[0] + .phrase + .validate_source() + .unwrap_err() + .0 + .contains("Overlapping")); + } + #[test] + fn separate_tracks_and_timing_resolution() { + let a = write(&story_core::demo_motif(), 140.).unwrap(); + let mut two = a[..14].to_vec(); + two[9] = 1; + two[11] = 2; + two.extend_from_slice(&a[14..]); + two.extend_from_slice(&a[14..]); + assert_eq!(read(&two).unwrap().tracks.len(), 2); + let mut b = file(&[1, 0x90, 60, 80, 1, 0x80, 60, 0, 0, 0xff, 0x2f, 0]); + b[12..14].copy_from_slice(&1000u16.to_be_bytes()); + assert!(read(&b).unwrap_err().0.contains("exactly")); + } + + #[test] + fn performance_export_has_named_channel_tracks_and_controller_resets() { + let mut settings = PerformanceSettings { + mode: story_core::RoleMode::Alternating, + ..Default::default() + }; + for (role, v) in settings.voices.iter_mut().enumerate() { + v.controller.enabled = true; + v.controller.cc = 20 + role as u8; + v.controller.rest = 5 + role as u8; + } + let source = story_core::demo_motif(); + let bytes = write_performance(&source, &settings, 120.).unwrap(); + let file = read(&bytes).unwrap(); + assert_eq!(file.tracks.len(), 2); + assert_eq!(file.tracks[0].channels, vec![0]); + assert_eq!(file.tracks[1].channels, vec![1]); + assert!(file.tracks[0].name.contains("Primary")); + assert!(file.tracks[1].name.contains("Answer")); + assert_eq!( + file.tracks + .iter() + .map(|t| t.phrase.notes.len()) + .sum::(), + source.notes.len() + ); + assert!(file.tracks.iter().all(|t| !t.warnings.is_empty())); + assert!(bytes + .windows(7) + .any(|p| p == [0xb0, 20, 5, 0, 0xff, 0x2f, 0])); + assert!(bytes + .windows(7) + .any(|p| p == [0xb1, 21, 6, 0, 0xff, 0x2f, 0])); + } + + #[test] + fn compensated_export_adds_lead_in_while_default_export_keeps_the_grid() { + let source = story_core::demo_motif(); + let mut settings = PerformanceSettings::default(); + settings.voices[0].attack_ms = 100; + let direct = read(&write_performance(&source, &settings, 120.).unwrap()).unwrap(); + assert_eq!(direct.tracks[0].phrase.notes[0].start, 0); + settings.compensated_export = true; + let compensated = read(&write_performance(&source, &settings, 120.).unwrap()).unwrap(); + assert_eq!(compensated.tracks[0].phrase.notes[0].start, BAR - 192); + assert_eq!(compensated.tracks[0].phrase.length, source.length + BAR); + } +} diff --git a/crates/story-preview/Cargo.toml b/crates/story-preview/Cargo.toml new file mode 100644 index 0000000..7603e2f --- /dev/null +++ b/crates/story-preview/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "story-preview" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +story-core.workspace = true +story-midi.workspace = true diff --git a/crates/story-preview/src/main.rs b/crates/story-preview/src/main.rs new file mode 100644 index 0000000..542378d --- /dev/null +++ b/crates/story-preview/src/main.rs @@ -0,0 +1,101 @@ +//! Headless end-to-end development harness; uses exactly the plugin's composition and voice. +use std::{env, fs, io::Write, path::PathBuf}; +use story_core::{demo_motif, Harmony, Performance, PerformancePlayer, Session}; + +fn main() { + if let Err(e) = run() { + eprintln!("{e}"); + std::process::exit(1); + } +} +fn run() -> Result<(), Box> { + let args: Vec<_> = env::args().skip(1).collect(); + if args.is_empty() || args[0] == "--help" { + println!("Motif Storyboard preview\n story-preview demo \n story-preview develop [harmony.mid harmony-track-index]\n story-preview session \nWrites source.mid, up to three distinct candidates, session.json, and selected.wav. Track indices start at 0."); + return Ok(()); + } + let mut session = Session::default(); + let output = match args[0].as_str() { + "demo" if args.len() == 2 => { + session.source = Some(demo_motif()); + PathBuf::from(&args[1]) + } + "session" if args.len() == 3 => { + session = Session::from_json(&fs::read(&args[1])?)?; + PathBuf::from(&args[2]) + } + "develop" if args.len() == 4 || args.len() == 6 => { + let bytes = fs::read(&args[1])?; + let file = story_midi::read(&bytes)?; + let index: usize = args[2].parse()?; + let track = file.tracks.get(index).ok_or("Track index out of range")?; + track.phrase.validate_source()?; + for w in &track.warnings { + eprintln!("{w}"); + } + session.source = Some(track.phrase.clone()); + PathBuf::from(&args[3]) + } + _ => return Err("Use --help for usage.".into()), + }; + if args.len() == 6 { + let file = story_midi::read(&fs::read(&args[4])?)?; + let index: usize = args[5].parse()?; + let track = file + .tracks + .get(index) + .ok_or("Harmony track index out of range")?; + session.harmony = Harmony::from_midi(&track.phrase.notes, track.phrase.length)?; + } + if args[0] != "session" { + session.regenerate()?; + session.accept(); + } + let selected = session.composed_phrase()?; + fs::create_dir_all(&output)?; + if let Some(source) = &session.source { + fs::write( + output.join("source.mid"), + story_midi::write(source, session.tempo)?, + )?; + } + for (i, c) in session.candidates.iter().enumerate() { + fs::write( + output.join(format!("candidate-{}.mid", i + 1)), + story_midi::write_performance(&c.phrase, &session.performance, session.tempo)?, + )?; + println!("{}: {}", c.strategy.name(), c.explanation); + } + fs::write(output.join("session.json"), session.to_json()?)?; + fs::write( + output.join("selected.mid"), + story_midi::write_performance(&selected, &session.performance, session.tempo)?, + )?; + let mut player = PerformancePlayer::default(); + player.set_sample_rate(44100.)?; + player + .play(Performance::compile(&selected, &session.performance, session.tempo, false)?.clip()?); + let mut samples = Vec::new(); + while player.is_playing() { + samples.push((player.next_frame(session.tempo).0 * i16::MAX as f32) as i16); + } + let len = (samples.len() * 2) as u32; + let mut wav = fs::File::create(output.join("selected.wav"))?; + wav.write_all(b"RIFF")?; + wav.write_all(&(36 + len).to_le_bytes())?; + wav.write_all(b"WAVEfmt ")?; + wav.write_all(&16u32.to_le_bytes())?; + wav.write_all(&1u16.to_le_bytes())?; + wav.write_all(&1u16.to_le_bytes())?; + wav.write_all(&44100u32.to_le_bytes())?; + wav.write_all(&88200u32.to_le_bytes())?; + wav.write_all(&2u16.to_le_bytes())?; + wav.write_all(&16u16.to_le_bytes())?; + wav.write_all(b"data")?; + wav.write_all(&len.to_le_bytes())?; + for s in samples { + wav.write_all(&s.to_le_bytes())?; + } + println!("Saved {}", output.display()); + Ok(()) +} diff --git a/docs/creative-editor.png b/docs/creative-editor.png new file mode 100644 index 0000000..556981e Binary files /dev/null and b/docs/creative-editor.png differ diff --git a/docs/devath-reference-notes.md b/docs/devath-reference-notes.md new file mode 100644 index 0000000..401039b --- /dev/null +++ b/docs/devath-reference-notes.md @@ -0,0 +1,53 @@ +# Devath reference measurements + +Analyzed the seven user-supplied FLACs in this workspace using their actual audio +samples. This is signal analysis of the rendered mixes, not a listening-based +transcription of their melodies or an account of the artist's intent. + +![Relative level and spectral attack timelines](reference-analysis/reference-timelines.png) + +## Observable contrasts + +Approximate reference points from the one-second summaries: + +| Recording | Measured pattern | Useful compositional question | +|---|---|---| +| Henka | A marked reduction in level/attacks around 2:03–2:35, followed by a strong return; another reduction near 3:57. | What can be withheld so the recognizable idea matters when it returns? | +| Phantasy | Lower, interrupted activity around 1:27–2:23, contrasting with the stronger regions before and after. | Can a fragment carry continuity across a less active passage? | +| Blumengarten | An interrupted middle region around 2:02–3:02, then renewed sustained activity; marked reduction near 4:19. | Can the same identity support different densities and articulations? | +| quiet wish | A broad reduction around 1:53–2:32 before activity returns. | Does a long breath make the return more effective than adding notes? | +| Light Aspect | A smaller reduction around 1:33–1:49 and a later reduction around 3:04. | Can a shorter contrast work without rebuilding the entire phrase? | +| Petals | More consistent relative activity through much of the first four minutes, with short dips and a lower closing region after roughly 4:11. | Can development happen with small changes while the groove stays continuous? | +| VERDICT. remix | Stronger sustained regions roughly 1:01–1:48 and 3:02–3:48, separated by a less active region. | What should remain recognizable across contrasting presentations? | + +These times are prompts for human listening, not asserted verse/drop boundaries. +RMS is affected by arrangement and mastering. Spectral attacks include drums and +sound-design transients, so neither curve measures melodic note density. + +The design inference is to support both subtraction/return and continuous-groove +writing, rather than force every generated phrase through the same climb and +resolution. The implementation includes optional fixed groove, fragments, note +subtraction, different articulations, register return, and section composition. +This does **not** establish that Devath used those exact note-level operations. +No source notes or audio were copied into presets or used to train a model. + +## Reproduce + +```sh +python scripts/analyze-references.py . --output docs/reference-analysis +``` + +Requires `ffmpeg`, NumPy, and Matplotlib. The script decodes mono audio at 11,025 +Hz and uses a 2,048-sample Hann window with a 256-sample hop. It summarizes RMS +and positive log-spectral flux per second, normalizing each curve to its own +95th percentile for visibility. Candidate texture changes compare standardized +low/mid/high-band energy over eight-second neighborhoods, with separated peaks. +[Raw summaries](reference-analysis/measurements.json) include track durations and +within-track 80th/20th percentile ratios. Automatic tempo and chord estimates are +intentionally omitted: coarse onset periodicity and spectral peaks are not +reliable transcriptions of these full mixes. + +A detailed melodic study still needs a listening pass with annotated motifs, +harmonic rhythm, bass/lead relationships, and returns, ideally checked against +stems or MIDI. These measurements alone cannot identify exact chord progressions, +voice leading, emotional meaning, or which sound carries the melody. diff --git a/docs/editor.png b/docs/editor.png new file mode 100644 index 0000000..18efa0f Binary files /dev/null and b/docs/editor.png differ diff --git a/docs/fl-studio.md b/docs/fl-studio.md new file mode 100644 index 0000000..eb7e641 --- /dev/null +++ b/docs/fl-studio.md @@ -0,0 +1,114 @@ +# FL Studio acceptance test (Windows) + +Record the FL Studio version, Windows version, plugin build revision, sample +rate, and audio buffer size with each test. These steps have not yet been run +against FL Studio in this environment. + +## Install + +1. Build with `python scripts/build-plugin.py` on Windows, or obtain a matching + Windows artifact from the configured CI workflow. +2. Copy `Motif Storyboard.clap` to + `C:\Program Files\Common Files\CLAP`, or a CLAP location scanned by your + FL Studio version. Close existing instances before replacing the binary. +3. In FL Studio's Plugin Manager, rescan plugins and locate Motif Storyboard. + Use an FL Studio version with CLAP support. Insert it as a generator. + +## Creative workflow + +- Open and close the editor repeatedly; verify keyboard input and controls work + at 100%, 150%, and 200% Windows display scaling, with no clipped content. +- Load the demo, generate three continuations, and audition each. The opening + must match the source; candidates should be audibly related and differ later. +- Lock a source note, regenerate, and verify pitch, velocity, start, and duration + are unchanged at each repeated position. Locks may override register/scale. +- Try four/eight bars, all scales, different seeds, and rhythm preservation. +- Accept candidate A; accept B; undo/redo. Regenerate with a different seed and + confirm accepted work remains available. +- Export selected and accepted MIDI separately. Import each into the piano roll + of an existing synth and compare note timing, duration, velocity, and length. + +## Import and files + +- Import a monophonic MIDI file exported from FL Studio at 96/480/960 PPQ. +- Import multitrack MIDI and select the intended note track. +- Verify useful errors for chords/overlaps, motifs longer than two bars, + unsupported meters, malformed files, and inaccessible paths. +- Cancel native dialogs. Try Unicode paths and the manual file-path controls. +- Export to an existing filename; confirm it is refused and the file is intact. + +## Playback and state + +- Confirm opening the editor and loading a project are silent until Preview. +- Preview, press Stop, and switch phrases rapidly; no stuck or overlapping voice. +- Preview while FL Studio's transport runs, then stop the host; audition stops. +- Enable host tempo following and change project tempo. Disable it and verify + local tempo controls audition speed. Host start must never trigger audition. +- At 44.1/48/96 kHz and several buffer sizes, confirm stable timing and no clicks + at ordinary note boundaries, dropouts, or non-finite output. +- Save a project containing locks, candidates, an accepted result, and history. + Reopen it, confirm all state is restored and preview remains stopped. +- Save/load a JSON session; reject unsupported versions without changing the + current session. Test multiple plugin instances for independent state. + +Use MIDI output mode to audition production synths, or export MIDI into the +piano roll. Native MIDI drag-out is outside this release. + +## Version 0.2: contextual audition and composition + +- Select MIDI output in Motif Storyboard. Where FL Studio exposes wrapper MIDI + ports, set its Output port to an unused port and the target synth's Input port + to the same value; receive notes on channel 1. These are FL Studio's documented + [wrapper input/output port controls](https://www.image-line.com/fl-studio-learning/fl-studio-online-manual/html/plugins/wrapper.htm). + This specific CLAP routing path is still unverified here; check it in the + installed FL Studio version. Keep the MIDI-file export path as fallback. +- Audition through the actual production patch with the surrounding project loop. + Host tempo follows BPM only; start audition at the desired point manually. + Switching a running audition waits for the next local two-bar boundary or end. +- Test Loop on/off, same-pitch repeated notes, changing Internal/MIDI mode while + playing, rapid queued selections, Stop before a queued switch, host transport + stop, and project reload. There must be no hanging external notes. +- Import polyphonic harmony, verify gaps/changes/repetition, then compare open + and settled endings. Context is a pitch guide and does not itself emit chords. +- Test whole-note, pitch-only, and rhythm-only locks. Keep groove must retain + onset/duration; keep contour must prevent direction reversals except that a + register limit can flatten a step. Locks override other targets. +- Pin bars 3–4 from proposal A, select B, and preview/export the combined phrase. + Regenerate with another seed: pinned notes must remain exact. Change the motif + or phrase length: pins clear. Restore a version-1 session and a version-2 + project with harmony, pins, and kept history. +- Evaluate artistic usefulness through the real patch: can the producer choose + a meaningful alternative, retain just its useful section, and make it his own? + Automated diversity metrics do not replace that evaluation. + +## Version 0.3: gestures, branches, roles, and controllers + +- Save a named source gesture and a developed two-bar gesture. Export the library + and import it into another instance: notes, locks, and performance choices + should survive; recalling keeps the destination harmony. +- Save a branch, apply Subtract to bars 3–4, restore the parent, and try Longer + gates instead. Both children should remain available. Restore each after + saving/reopening the host project. Remove leaves without losing their parent. +- Try each role mode. Route channels 1 and 2 to separate patch parts (or choose + other distinct channels). Verify every original note belongs to one role. + A host configuration must preserve channels; MIDI file export provides named + role tracks when live routing cannot do this. +- Map CC20/21 explicitly to suitable macros in the target synths. Enable each + curve, edit points, compare Rise/Arch/Pulse, and verify the actual mapped + sound follows it. A curve describes controller values, not a guaranteed + timbral or emotional result. Imported MIDI controller automation is not read. +- While MIDI plays, stop, replace the profile/controller mapping, toggle audition + mode, and reload the project. Old notes must release and old controllers must + return to their specified stop values. Opening a project remains silent. +- Estimate patch attack/release by ear. Compare 0 ms and 40–100 ms attack, + including a same-pitch loop seam and a queued selection. Warmup occurs before + the local first beat. Re-audition after BPM changes. +- Export normally and confirm notes retain musical onset positions. Enable + compensated export: confirm one bar of lead-in, early MIDI triggers, separate + named tracks, and controller reset events. Compare through the same patch. +- Locks protect musical note attributes; attack/release compensation adjusts + MIDI trigger scheduling for the patch. Judge the audible onset against the + surrounding groove rather than expecting every trigger to stay on the grid. +- Evaluate whether a saved gesture still feels like the same idea with another + patch, and whether branching makes it easy to retain useful exceptions. + The internal voices and the automated checks cannot establish artistic quality. diff --git a/docs/psychoacoustics-and-creative-direction.md b/docs/psychoacoustics-and-creative-direction.md new file mode 100644 index 0000000..87f7b2b --- /dev/null +++ b/docs/psychoacoustics-and-creative-direction.md @@ -0,0 +1,217 @@ +# Psychoacoustics, expectation, and creative direction + +## The product target + +Treat a heard musical gesture as the unit of composition: its trigger timing, +pitch movement, articulation, timbral trajectory, placement against other parts, +and relationship to what the listener has already heard. MIDI notes describe +only some of those properties. A held note can contain a rising resonance, +several rhythmic pulses, a change in texture, and a long answering tail. + +The design proposals below are hypotheses for a creative tool. The cited +experiments establish particular perceptual effects under their study conditions; +they do not establish universal rules for riddim or Devath's intentions. + +## Research-grounded mechanisms + +### Expectation depends on context + +Cheung et al. found an interaction between uncertainty and surprise in ratings of +Western tonal chord sequences. Both an unexpected event in a predictable context +and an expected event in an uncertain context could receive high pleasure ratings. +This supports exploring relationships between recognition and deviation, while +leaving genre, listener experience, and musical context explicit. + +[Cheung et al., 2019, original study abstract](https://cms.mus.cam.ac.uk/publications/cheung-uncertainty-surprise/) + +Design inference: preserve an identifiable anchor while changing another +property, and offer confirmation after a passage of ambiguity. A novelty score +alone cannot select a meaningful continuation. + +### Groove involves a stable frame and rhythmic challenge + +Witek et al.'s drum-break study found greatest pleasure and desire to move at +intermediate syncopation levels. This was a result for their stimuli and listeners, +not a genre-independent target number for a sequencer. + +[Witek et al., 2014](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0094446) + +Design inference: distinguish the pulse-bearing parts from a freely moving +foreground. A surprising bass or lead event can be easier to follow when drums, +phrase length, or a recurring attack still provide orientation. Intentional +extremes remain valid creative choices. + +### Timbre contributes its own tension + +Farbood and Price found evidence linking greater roughness, inharmonicity, and +spectral flatness to greater judged tension in their sound comparisons. Evidence +for spectral centroid as a tension predictor was ambiguous. Brightness, roughness, +loudness, harmonic function, and emotional meaning should remain separate concepts. + +[Farbood and Price, 2017](https://bpb-us-e1.wpmucdn.com/wp.nyu.edu/dist/f/11865/files/2020/08/Farbood_Price_2017.pdf) + +Design inference: a melodic arrival and a timbral release can happen together, +independently, or in opposition. A line can keep its pitches while its texture +becomes less abrasive. A quiet sound may remain harmonically unresolved. + +### Grouping determines which sounds form a phrase + +Elhilali et al.'s human psychophysics experiments showed that relative timing can +strongly affect whether tones are heard as a coherent stream. Their findings +challenge an account based solely on frequency separation. + +[Elhilali et al., 2009](https://pmc.ncbi.nlm.nih.gov/articles/PMC2673083/) + +Design inference: a motif distributed between sounds needs perceptual connections. +Shared timing, related articulation, recurring contour, and deliberate handoffs +are candidates to test. A MIDI line split across unrelated patches may be heard +as several parts rather than one melody. + +### Preferences are learned as well as sensory + +McDermott et al. found substantial cultural variation in consonance preferences, +including indifference to consonance versus dissonance in a population with little +Western musical exposure. This argues against presenting Western consonance +preferences as a universal measure of musical worth. + +[McDermott et al., 2016](https://www.nature.com/articles/nature18635) + +Design inference: genre fluency and the producer's taste matter. Scale adherence, +chord-tone density, and estimated roughness can describe an option; they should +not become an automatic good/bad ranking. + +## Reading the supplied Devath references responsibly + +The [local measurements](devath-reference-notes.md) establish changes in mix +level, attack activity, and broad spectral texture. For example, HENKA has a +measured reduction around 2:03–2:35 and Phantasy around 1:27–2:23. Petals maintains +more consistent relative activity across much of its earlier duration. These +contrasts support studying both large departures/returns and development within +an ongoing groove. + +Those measurements do not establish what the melody is, how the harmony moves, +which sound is foreground, or what emotional meaning a listener assigns. Exact +note-level claims require an annotated listening pass, preferably with stems or +MIDI. The existing report deliberately leaves those questions open. + +For each reference passage, annotate the following rather than only naming a key: + +1. **Anchor:** what remains recognizable—attack pattern, interval, texture, bass + pulse, or a small melodic shape? +2. **Foreground:** which part attracts attention, and when does that change? +3. **Motion:** does movement come from pitch, amplitude pulses, spectral movement, + articulation, spatial effects, or several at once? +4. **Expectation:** what seems about to happen, and what is confirmed or withheld? +5. **Space:** which event is absent, shorter, farther away, or easier to hear? +6. **Memory:** when something returns, what has changed around it? + +These are listening questions, not findings already established about each file. +Reference tracks supply examples of possibilities; they do not define the bounds +of the producer's own style. + +## What the tool should let a producer do + +### Work on relationships + +Useful requests are specific and relational: + +- Keep this bass groove and let a smaller melodic answer occupy its gaps. +- Preserve the pickup; make the destination later and more exposed. +- Keep the notes but contrast short, dry articulations with a sustained answer. +- Let one timbre finish a phrase that another timbre starts. +- Keep harmonic ambiguity while reducing rhythmic activity. +- Return to a familiar gesture with softer texture and different accompaniment. +- Turn the ending into a pickup for the next loop, rather than closing it. + +The original motif can survive any of these transformations. The producer chooses +which relationship matters and which dimensions are free. + +### Separate useful descriptors + +Avoid a single emotional-intensity control. Explore these dimensions separately: + +| Dimension | Example experiment | +|---|---| +| Pulse clarity | Keep drums fixed while moving foreground attacks. | +| Anticipation | Bring the pickup forward; delay the destination. | +| Activity | Remove connective notes while retaining signature anchors. | +| Articulation | Keep onset locations and compare different note lengths/tails. | +| Harmonic stability | Hold melody fixed over different bass/chord contexts. | +| Timbral tension | Keep pitches fixed while changing an explicitly mapped patch macro. | +| Continuity | Pass the motif between related and contrasting voices. | +| Familiarity | Reintroduce a recognizable fragment after a departure. | + +These descriptors help propose experiments. They do not measure longing, sadness, +euphoria, or artistic merit. Several can point in different directions at once. + +### Capture how the actual sound behaves + +The same MIDI onset can produce different heard attacks through different patches. +A slower attack, rhythmic modulation, portamento, or a long release can change the +musical role of a note. Accurate evaluation therefore needs the production sound +and surrounding loop, with repeated audition rather than only an isolated tone. + +A future sound profile could record an approximate attack delay, release tail, +comfortable register, and user-mapped expressive controls. Audio returned from the +synth could help measure those responses. A MIDI-only generator cannot infer the +behavior of an arbitrary patch or automatically control its macros without an +explicit routing/mapping arrangement. + +### Make creative moves composable + +The current eight families are useful initial experiments. Further freedom comes +from chaining smaller operations with a visible history: + +- select and preserve an anchor; +- fragment, displace, stretch, repeat, or subtract an event group; +- choose a destination relative to the surrounding harmony; +- hand a gesture to another voice; +- change its articulation or a mapped timbral trajectory; +- bring a remembered fragment back in a new context. + +A producer should be able to branch an idea, pin a particular decision, compare +branches in context, and reuse a personal gesture. Multiple operations should +compose without forcing every phrase into a statement/climax/resolution arc. +Circular, suspended, sparse, and continuously evolving structures are valid. + +### Preserve learning and authorship + +Three proposals should illustrate different musical hypotheses. For example: + +1. same pitches, more space, a later arrival; +2. same groove, a different harmonic destination; +3. a fragment handed between two explicitly assigned voices. + +Keep the surrounding loop and listening level comparable, so the producer can +judge the changed relationship. Store the chosen material and the specific +choices he wants to preserve. Optional explicit favorites can become a personal +vocabulary; generic musical rules should remain adjustable. + +## Practical next research steps + +1. **Annotated reference sessions.** Select short passages from the supplied files + and mark anchors, foreground roles, handoffs, withheld events, and returns. + Check claims against repeated listening instead of treating a waveform as a + melodic transcription. +2. **Controlled comparisons using the friend's own patches.** Keep notes fixed + while changing articulation; keep articulation fixed while changing rhythm; + compare the same line under two harmonic settings. Check the whole loop as + well as isolated examples. Avoid making the louder example automatically seem + more successful. +3. **A reusable gesture and branch history.** Extend the current note/pin model + toward saved groups with independent timing, pitch, articulation, voice, and + mapped modulation choices. +4. **Two-role experiments.** Explore interlocking bass and answer lines before + expanding into a full arranger or arbitrary multi-instrument generator. +5. **Producer evaluation.** Ask whether a suggestion leads to a usable section, + whether he can retain its useful moment, and whether the tool keeps him in the + writing flow. Test over repeated sessions; mathematical novelty is insufficient. + +## Current implementation boundary + +Version 0.2 already covers structured note/rhythm proposals, harmony context, +separate note locks, pinned sections, and external MIDI audition. It does not yet +represent patch-dependent audible gestures, mapped timbral motion, multiple sound +roles, reusable branch histories, or verified perceptual/emotional judgments. +The next design work should make those relationships explicit while keeping the +producer's listening decisions central. diff --git a/docs/reference-analysis/measurements.json b/docs/reference-analysis/measurements.json new file mode 100644 index 0000000..e846b05 --- /dev/null +++ b/docs/reference-analysis/measurements.json @@ -0,0 +1,100 @@ +[ + { + "track": "Blumengarten - Devath", + "duration_seconds": 296.0, + "candidate_texture_changes_seconds": [ + 61, + 122, + 138, + 182, + 259, + 280 + ], + "rms_20th_to_80th_ratio": 2.71, + "spectral_attack_20th_to_80th_ratio": 1.8 + }, + { + "track": "Henka - Devath", + "duration_seconds": 280.14, + "candidate_texture_changes_seconds": [ + 16, + 123, + 139, + 155, + 237, + 255 + ], + "rms_20th_to_80th_ratio": 3.67, + "spectral_attack_20th_to_80th_ratio": 4.01 + }, + { + "track": "Light Aspect - Devath", + "duration_seconds": 214.52, + "candidate_texture_changes_seconds": [ + 16, + 52, + 77, + 93, + 109, + 184 + ], + "rms_20th_to_80th_ratio": 2.49, + "spectral_attack_20th_to_80th_ratio": 1.93 + }, + { + "track": "Petals - Devath", + "duration_seconds": 301.69, + "candidate_texture_changes_seconds": [ + 28, + 54, + 116, + 192, + 251, + 279 + ], + "rms_20th_to_80th_ratio": 1.62, + "spectral_attack_20th_to_80th_ratio": 1.44 + }, + { + "track": "Phantasy - Devath", + "duration_seconds": 219.04, + "candidate_texture_changes_seconds": [ + 35, + 87, + 114, + 143, + 168, + 197 + ], + "rms_20th_to_80th_ratio": 2.87, + "spectral_attack_20th_to_80th_ratio": 2.1 + }, + { + "track": "VERDICT. - Devath Remix - Aphonic, Devath", + "duration_seconds": 258.32, + "candidate_texture_changes_seconds": [ + 16, + 61, + 108, + 133, + 182, + 228 + ], + "rms_20th_to_80th_ratio": 4.2, + "spectral_attack_20th_to_80th_ratio": 2.3 + }, + { + "track": "quiet wish - Devath", + "duration_seconds": 231.43, + "candidate_texture_changes_seconds": [ + 16, + 39, + 113, + 136, + 152, + 214 + ], + "rms_20th_to_80th_ratio": 2.48, + "spectral_attack_20th_to_80th_ratio": 1.74 + } +] \ No newline at end of file diff --git a/docs/reference-analysis/reference-timelines.png b/docs/reference-analysis/reference-timelines.png new file mode 100644 index 0000000..b636cf9 Binary files /dev/null and b/docs/reference-analysis/reference-timelines.png differ diff --git a/docs/theory/README.md b/docs/theory/README.md new file mode 100644 index 0000000..04409f6 --- /dev/null +++ b/docs/theory/README.md @@ -0,0 +1,95 @@ +# Theory: perceptual identity as a compositional resource + +The research and local reference work are consolidated in +[psychoacoustics and creative direction](../psychoacoustics-and-creative-direction.md) +and [Devath measurements](../devath-reference-notes.md). This document connects +those findings to implementation choices and their practical limits. + +## Findings and their interpretation + +- Expectation is contextual. Recognition and deviation can both be rewarding; + a mathematically novel note sequence is not automatically useful music. +- Groove depends on how a pattern is heard against an orienting frame. Intermediate + syncopation was preferred in particular experiments, not established as a + universal genre-independent optimum. +- Timbre can carry motion and contribute tension independently of MIDI pitch. + Brightness, roughness, noisiness, loudness, and harmonic stability are distinct. +- Relative timing helps determine which events form one perceived stream. Passing + a gesture between sound roles should preserve chosen connections. +- Preference depends on experience and culture as well as sensory mechanisms. + Musical rules should inform experiments rather than rank artistic worth. + +Primary research: [Cheung et al.](https://cms.mus.cam.ac.uk/publications/cheung-uncertainty-surprise/), +[Witek et al.](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0094446), +[Farbood and Price](https://bpb-us-e1.wpmucdn.com/wp.nyu.edu/dist/f/11865/files/2020/08/Farbood_Price_2017.pdf), +[Elhilali et al.](https://pmc.ncbi.nlm.nih.gov/articles/PMC2673083/), and +[McDermott et al.](https://www.nature.com/articles/nature18635). +The genre-specific design connections are hypotheses, not reported findings from +those studies or claims about Devath's intent. + +## What the references establish + +The seven local FLACs have measured differences in level, attacks, and broad +spectral texture. HENKA and Phantasy contain substantial reductions followed by +returns; Petals retains more consistent activity through much of its early span. +These observations motivate exploring both contrast and continuous development. +They do not identify exact notes, chords, emotional meaning, or melodic roles. + +## Implemented creative model (0.3) + +- A reusable gesture stores a short musical phrase and its explicit performance + choices. A name records the producer's interpretation; it is not inferred emotion. +- A bounded branching history stores working ideas, ancestry, context, and pinned + decisions. Composable moves create children rather than overwrite their parent. +- Two sound roles can share or answer a motif through explicit routing. Each role + has a separate MIDI channel, gate, velocity response, octave offset, and patch + timing profile. The roles are user-assigned, not separated from mixed audio. +- Each role can carry an optional, explicitly mapped MIDI CC trajectory. The tool + sends controller data; the producer binds it to a synth macro. An internal voice + does not simulate an arbitrary synth's timbre. +- Patch attack compensation is scheduled using a short lead-in, and estimated + release is accounted for when setting note-off times. These are editable timing + estimates, not an automatic model of the target patch. Re-audition after tempo + changes because millisecond offsets are compiled at the audition tempo. +- Musical export stays on its original grid by default. Optional compensated export + adds one bar of lead-in so early triggers have valid nonnegative MIDI times. + +## Producer workflow and evaluation + +Import or recall a gesture -> choose context and what to preserve -> explore a +proposal -> save a branch -> apply a move -> distribute sound roles -> map an +expressive controller -> audition through real patches -> keep or branch again. +Compare alternatives against the same surrounding loop and listening level. +The useful outcome is a section the producer can own and develop further. + +All mappings and compensation are explicit. Original reference audio remains +local. The model does not claim source separation, automatic emotion recognition, +universal quality scoring, or a verified melodic transcription of Devath's tracks. + +## Concrete limits and implementation + +A gesture is at most two bars; a working phrase is four or eight bars. +Libraries and branch histories each retain up to sixteen entries. A branch +stores musical settings, source, harmony, the composed phrase, pinned sections, +and performance choices without recursively copying the history. Importing a +library merges validated entries with new identifiers. State versions 1 and 2 +migrate to version 3; controller output is disabled by default. + +Role assignment partitions existing notes, so it preserves a readable motif +before sound design. It is not source separation or independent counterpoint. +Controller curves span the phrase, interpolate at 32nd-note intervals and exact +control points, and reset to explicit stop values. Their effect depends entirely +on the synth mapping. Gates are capped at subsequent triggers in the same role; +estimated release cannot guarantee that a patch becomes silent at that point. +Negative attack triggers receive a warmup and a separate repeated-loop schedule. + +Composition and performance compilation run away from the audio callback. +The callback uses fixed event storage and bounded control queues. Regression +checks cover history forks, migration, locks, two-role event identity, MIDI +channels/controller export, warmup/loop timing, mapping resets, and allocation-free +rendering. Actual patch perception, FL Studio routing, and producer usefulness +still require listening in the target workflow. + +For implementation, see [creative history](../../crates/story-core/src/creative.rs), +[performance scheduling](../../crates/story-core/src/performance.rs), and +[FL Studio acceptance checks](../fl-studio.md). diff --git a/docs/variation-plan.md b/docs/variation-plan.md new file mode 100644 index 0000000..4ef6df9 --- /dev/null +++ b/docs/variation-plan.md @@ -0,0 +1,54 @@ +# Motif Storyboard 0.2 implementation plan + +## Musical intent +Develop a producer's own motif through audible choices about recognition, space, +contrast, and return. Offer three distinct compositional proposals rather than +three randomly shifted versions. Emotional interpretation stays with the producer. + +## Engine +- Keep an exact opening quotation. Build subsequent two-bar sections as statement, + answer, departure, and return (four-bar phrases use statement and return). +- Add tasks Develop, Answer, Make space, and Return. Generate a deterministic pool + using rhythmic displacement, fragmentation, call/response, sparse subtraction, + a later register peak, changed endings, and harmonic returns; remove duplicates + and choose three candidates by task fit and musical distance. Return fewer if + locks make three distinct outcomes impossible. +- Preserve groove and contour independently. Per-note locks protect pitch, rhythm, + or every attribute. Locked events survive subtraction; rhythm locks keep exact + timing and duration. Global constraints and locks take precedence over transforms. +- Optional harmony consists of bounded time regions with pitch-class masks and + bass pitch. Accept polyphonic MIDI without claiming chord-root detection. Also + provide editable per-bar chord progressions. Guide metrically strong notes and + ending targets using the actual harmony at that time. Open endings prefer an + available scale tone outside the ending chord; closed endings use chord tones. +- Candidate generation changes note count, spacing, duration, and articulation + when constraints allow. Preserve recognizable fragments; avoid overlaps and + clipping MIDI values. Keep generated notes within two-bar section boundaries so + sections can be combined safely. + +## Producer workflow +Import motif -> choose identity/locks -> optionally import or enter harmony -> +choose task -> audition alternatives -> pin two-bar sections -> export combined +MIDI or accept a complete candidate. Retain accepted work during regeneration. +Pins survive changes to task and seed, and clear when source or phrase length +changes. Pinned material overrides regenerated sections. Reject unsafe seams +rather than silently truncate a sustained note. + +## Audition +Keep the internal voice and add a MIDI output mode. Preview is explicitly started, +optionally loops, and switches pending selections at the next two-bar boundary or +current clip end. Stop, replacement, mode changes, reset, and state restoration +release emitted notes. Host transport start never auto-starts audition. All clips, +queues, scheduling, and MIDI event emission remain bounded and allocation-free +on the audio thread. Validate FL Studio routing manually; retain MIDI file export +as the reliable commit path. Native OS MIDI drag-out is outside this release. + +## Compatibility and verification +Write state version 2; migrate version-1 JSON and host fields with serde defaults, +retain legacy strategy names for old candidates, and keep the persisted host field +identifier. New controls/harmony/pins/audition choices persist. Test timing, locks, +contour, harmonic targets, candidate diversity, section composition, migration, +loop boundaries, sample-accurate note events, queued switches, and RT allocation. +Run existing core/ABI tests and Clippy, render the updated editor, and package +Windows CLAP plus Linux CLAP/standalone. Do not claim Devath-derived algorithms or +FL Studio validation without direct evidence. diff --git a/plugins/motif-storyboard/Cargo.lock b/plugins/motif-storyboard/Cargo.lock new file mode 100644 index 0000000..1ab6628 --- /dev/null +++ b/plugins/motif-storyboard/Cargo.lock @@ -0,0 +1,3000 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "addr2line" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2562ad8dcf0f789f65c6fdaad8a8a9708ed6b488e649da28c01656ad66b8b47" +dependencies = [ + "alsa-sys", + "bitflags 1.3.2", + "libc", + "nix 0.24.3", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atomic_float" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62af46d040ba9df09edc6528dae9d8e49f5f3e82f55b7d2ec31a733c38dbc49d" + +[[package]] +name = "atomic_refcell" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e4227379beff4205943696e6c3e0cd809bacdf3f0edd6e3dd153e2269571a4" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "baseview" +version = "0.1.0" +source = "git+https://github.com/RustAudio/baseview.git?rev=579130ecb4f9f315ae52190af42f0ea46aeaa4a2#579130ecb4f9f315ae52190af42f0ea46aeaa4a2" +dependencies = [ + "cocoa", + "core-foundation", + "keyboard-types", + "nix 0.22.3", + "objc", + "raw-window-handle 0.5.2", + "uuid", + "winapi", + "x11", + "x11rb", +] + +[[package]] +name = "baseview" +version = "0.1.0" +source = "git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0#9a0b42c09d712777b2edb4c5e0cb6baf21e988f0" +dependencies = [ + "cocoa", + "core-foundation", + "keyboard-types", + "nix 0.22.3", + "objc", + "raw-window-handle 0.5.2", + "uuid", + "winapi", + "x11", + "x11rb", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cache-padded" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981520c98f422fcc584dc1a95c334e6953900b9106bc47a9839b81790009eb21" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-sys" +version = "0.5.0" +source = "git+https://github.com/micahrj/clap-sys.git?rev=25d7f53fdb6363ad63fbd80049cb7a42a97ac156#25d7f53fdb6363ad63fbd80049cb7a42a97ac156" + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "coremidi" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7847ca018a67204508b77cb9e6de670125075f7464fff5f673023378fa34f5" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "coremidi-sys", +] + +[[package]] +name = "coremidi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2b8c2cefa9a8f712213c5a1383ffe428efc8f1a1fd1e2f757be94daf7e256a" +dependencies = [ + "core-foundation-sys", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa 0.9.1", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "nohash-hasher", + "profiling", +] + +[[package]] +name = "egui-baseview" +version = "0.5.0" +source = "git+https://github.com/BillyDM/egui-baseview.git?rev=ec70c3fe6b2f070dcacbc22924431edbe24bd1c0#ec70c3fe6b2f070dcacbc22924431edbe24bd1c0" +dependencies = [ + "baseview 0.1.0 (git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0)", + "copypasta", + "egui", + "egui_glow", + "keyboard-types", + "log", + "open", + "raw-window-handle 0.5.2", + "thiserror 2.0.21", +] + +[[package]] +name = "egui_glow" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "910906e3f042ea6d2378ec12a6fd07698e14ddae68aed2d819ffe944a73aab9e" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset 0.9.1", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gimli" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jack" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5a18a3c2aefb354fb77111ade228b20267bdc779de84e7a4ccf7ea96b9a6cd" +dependencies = [ + "bitflags 1.3.2", + "jack-sys", + "lazy_static", + "libc", + "log", +] + +[[package]] +name = "jack-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "libc", + "libloading 0.7.4", + "log", + "pkg-config", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7668b7cff6a51fe61cdde64cd27c8a220786f399501b57ebe36f7d8112fd68" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "midi-consts" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2dd5c7f8aaf48a76e389068ab25ed80bdbc226b887f9013844c415698c9952" + +[[package]] +name = "midir" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a456444d83e7ead06ae6a5c0a215ed70282947ff3897fb45fcb052b757284731" +dependencies = [ + "alsa 0.7.1", + "bitflags 1.3.2", + "coremidi", + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", + "windows 0.43.0", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "motif-storyboard" +version = "0.3.0" +dependencies = [ + "clap-sys", + "nih_plug", + "nih_plug_egui", + "serde_json", + "story-app", + "story-core", + "story-midi", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nih_log" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0cdb52ef79af48ae110401c883bdb9c15e0306a99ab6ecf18bc52068b668e54" +dependencies = [ + "atty", + "log", + "once_cell", + "termcolor", + "time", + "windows 0.44.0", +] + +[[package]] +name = "nih_plug" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "anyhow", + "anymap3", + "atomic_float", + "atomic_refcell", + "backtrace", + "baseview 0.1.0 (git+https://github.com/RustAudio/baseview.git?rev=579130ecb4f9f315ae52190af42f0ea46aeaa4a2)", + "bitflags 1.3.2", + "cfg-if", + "clap", + "clap-sys", + "core-foundation", + "cpal", + "crossbeam", + "jack", + "libc", + "log", + "midi-consts", + "midir", + "nih_log", + "nih_plug_derive", + "objc", + "parking_lot", + "raw-window-handle 0.5.2", + "rtrb", + "serde", + "serde_json", + "widestring", + "windows 0.44.0", +] + +[[package]] +name = "nih_plug_derive" +version = "0.1.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nih_plug_egui" +version = "0.0.0" +source = "git+https://github.com/robbert-vdh/nih-plug.git?rev=de421011f41a6d10fc8c7a6084e4f4dee0143683#de421011f41a6d10fc8c7a6084e4f4dee0143683" +dependencies = [ + "baseview 0.1.0 (git+https://github.com/RustAudio/baseview.git?rev=9a0b42c09d712777b2edb4c5e0cb6baf21e988f0)", + "crossbeam", + "egui-baseview", + "nih_plug", + "parking_lot", + "raw-window-handle 0.5.2", + "serde", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtrb" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e704dd104faf2326a320140f70f0b736d607c1caa1b1748a6c568a79819109" +dependencies = [ + "cache-padded", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "story-app" +version = "0.3.0" +dependencies = [ + "crossbeam-queue", + "story-core", + "story-midi", + "windows-sys 0.61.2", +] + +[[package]] +name = "story-core" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "story-midi" +version = "0.3.0" +dependencies = [ + "story-core", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04662ed0e3e5630dfa9b26e4cb823b817f1a9addda855d973a9458c236556244" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics 0.23.2", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk 0.9.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle 0.6.2", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/motif-storyboard/Cargo.toml b/plugins/motif-storyboard/Cargo.toml new file mode 100644 index 0000000..1738f15 --- /dev/null +++ b/plugins/motif-storyboard/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "motif-storyboard" +version = "0.3.0" +edition = "2021" +license = "ISC" + +# Kept independent so core/MIDI tests also run without plugin SDK downloads. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "motif-storyboard" +path = "src/main.rs" +required-features = ["standalone"] + +[features] +standalone = ["nih_plug/standalone"] + +[dependencies] +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false } +nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" } +story-core = { path = "../../crates/story-core" } +story-midi = { path = "../../crates/story-midi" } +story-app = { path = "../../crates/story-app" } + +[dev-dependencies] +clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" } +serde_json = "1.0" + +[profile.release] +lto = "thin" +strip = "symbols" diff --git a/plugins/motif-storyboard/src/creative_editor.rs b/plugins/motif-storyboard/src/creative_editor.rs new file mode 100644 index 0000000..8ef1bd5 --- /dev/null +++ b/plugins/motif-storyboard/src/creative_editor.rs @@ -0,0 +1,214 @@ +use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE}; +use std::sync::Arc; +use story_app::{Output, Shared}; +use story_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session}; + +pub struct CreativeUi { + gesture_name: String, + branch_name: String, + scope: usize, + save_source: bool, +} +impl Default for CreativeUi { + fn default() -> Self { + Self { + gesture_name: "New gesture".into(), + branch_name: "New idea".into(), + scope: 0, + save_source: true, + } + } +} +fn feedback(state: &mut EditorState, result: Result) { + match result { + Ok(message) => { + state.status = message; + state.error = false; + } + Err(error) => report(state, Err(error)), + } +} + +pub fn ideas( + ui: &mut egui::Ui, + shared: &Arc, + state: &mut EditorState, + session: &mut Session, +) -> bool { + let mut restored = false; + let busy = shared.busy.load(std::sync::atomic::Ordering::Acquire); + egui::CollapsingHeader::new("Gestures, moves, and branches") + .id_salt("creative-history") + .default_open(!session.gestures.is_empty() || !session.branches.is_empty()) + .show(ui, |ui| { + ui.add_enabled_ui(!busy, |ui| { + ui.horizontal_wrapped(|ui| { + ui.label("Scope"); + state.creative.scope = state.creative.scope.min(session.settings.bars as usize / 2); + let selected = if state.creative.scope == 0 { "Whole phrase".into() } else { format!("Bars {}–{}", state.creative.scope * 2 - 1, state.creative.scope * 2) }; + egui::ComboBox::from_id_salt("move-scope").selected_text(selected).show_ui(ui, |ui| { + ui.selectable_value(&mut state.creative.scope, 0, "Whole phrase"); + for i in 1..=session.settings.bars as usize / 2 { + ui.selectable_value(&mut state.creative.scope, i, format!("Bars {}–{}", i * 2 - 1, i * 2)); + } + }); + let scope = state.creative.scope.checked_sub(1); + for (label, operation) in [ + ("Subtract", CreativeMove::Thin), ("Delay 1/16", CreativeMove::Delay), + ("Longer gates", CreativeMove::Lengthen), ("Up semitone", CreativeMove::Transpose(1)), + ("Down semitone", CreativeMove::Transpose(-1)), + ] { + if ui.add_enabled(session.selected_phrase().is_some(), egui::Button::new(label)).clicked() { + feedback(state, session.apply_move(operation, scope).map(|_| "New branch created. Its parent and pinned material are preserved.".into())); + } + } + }); + ui.label(egui::RichText::new("Moves respect pins and note locks. Release Keep groove to change timing or articulation.").color(MUTED)); + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut state.creative.save_source, "Save source motif"); + ui.add(egui::TextEdit::singleline(&mut state.creative.gesture_name).char_limit(64).desired_width(170.)); + let section = if state.creative.save_source { None } else { state.creative.scope.checked_sub(1) }; + let enabled = session.source.is_some() && (state.creative.save_source || section.is_some() && session.selected_phrase().is_some()); + if ui.add_enabled(enabled, egui::Button::new("Save gesture")).clicked() { + let name = state.creative.gesture_name.clone(); + feedback(state, session.save_gesture(&name, section).map(|_| "Gesture saved with its sound-role and controller choices.".into())); + } + if ui.add_enabled(!session.gestures.is_empty(), egui::Button::new("Export gestures…")).clicked() { + let saved = session.export_gesture_library(); + start(shared, state, move || { + let bytes = saved?; + let Some(mut path) = story_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); }; + if path.extension().is_none() { path.set_extension("json"); } + story_app::write_new(&path, &bytes)?; Ok(Output::Saved(path)) + }); + } + if ui.button("Import gestures…").clicked() { + start(shared, state, move || { + let Some(path) = story_app::dialog::choose(false, true)? else { return Ok(Output::Canceled); }; + let bytes = story_app::read_limited(&path, 2 * 1024 * 1024)?; + Ok(Output::LibraryLoaded(story_core::read_gesture_library(&bytes)?)) + }); + } + }); + if !state.creative.save_source && state.creative.scope == 0 { + ui.label(egui::RichText::new("Choose a two-bar scope to save a developed section.").color(MUTED)); + } + let gestures = session.gestures.clone(); + for gesture in gestures { + ui.horizontal(|ui| { + ui.label(&gesture.name); + ui.label(egui::RichText::new(format!("{} notes", gesture.phrase.notes.len())).color(MUTED)); + if ui.button("Recall as motif").clicked() { + let result = session.recall_gesture(gesture.id); + restored |= result.is_ok(); + feedback(state, result.map(|_| "Gesture recalled with its performance choices. Current harmony is retained; preview is stopped.".into())); + } + if ui.small_button("Remove").clicked() { session.gestures.retain(|g| g.id != gesture.id); } + }); + } + ui.separator(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut state.creative.branch_name).char_limit(64).desired_width(190.)); + if ui.button("Save branch").clicked() { + let name = state.creative.branch_name.clone(); + feedback(state, session.save_branch(&name).map(|_| "Working idea saved as a branch. Future moves can fork from here.".into())); + } + }); + let branches = session.branches.clone(); + egui::ScrollArea::vertical().id_salt("branch-list").max_height(170.).show(ui, |ui| { + for branch in &branches { + let mut depth = 0; let mut parent = branch.parent; + while let Some(id) = parent { + depth += 1; + parent = branches.iter().find(|b| b.id == id).and_then(|b| b.parent); + if depth >= 16 { break; } + } + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 12.); + ui.label(egui::RichText::new(&branch.name).color(if session.active_branch == Some(branch.id) { LOCK } else { NOTE })); + if ui.button("Restore").clicked() { + let result = session.restore_branch(branch.id); + restored |= result.is_ok(); + feedback(state, result.map(|_| "Branch restored. Other branches and gestures remain available.".into())); + } + let leaf = !branches.iter().any(|b| b.parent == Some(branch.id)); + if ui.add_enabled(leaf, egui::Button::new("Remove leaf")).clicked() { + feedback(state, session.remove_branch(branch.id).map(|_| "Leaf branch removed; working material is retained.".into())); + } + }); + } + }); + }); + }); + restored +} + +pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) { + egui::CollapsingHeader::new("Sound roles and expressive gestures").id_salt("performance-settings").default_open(settings.mode != RoleMode::Solo).show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + egui::ComboBox::from_id_salt("role-mode").selected_text(settings.mode.name()).show_ui(ui, |ui| { + for mode in RoleMode::ALL { ui.selectable_value(&mut settings.mode, mode, mode.name()); } + }); + ui.checkbox(&mut settings.compensated_export, "Export compensation with a one-bar lead-in"); + }); + ui.label(egui::RichText::new("Assign the channels to separate synth parts. CC curves require explicit macro mapping. Internal preview uses simple reference voices.").color(MUTED)); + for role in 0..if settings.mode == RoleMode::Solo { 1 } else { 2 } { + let voice = &mut settings.voices[role]; + ui.push_id(role, |ui| { + ui.separator(); + ui.horizontal_wrapped(|ui| { + ui.label(if role == 0 { "Primary" } else { "Answer" }); + ui.add(egui::TextEdit::singleline(&mut voice.name).char_limit(48).desired_width(130.)); + let mut channel = voice.channel as u16 + 1; + ui.label("Channel"); ui.add(egui::DragValue::new(&mut channel).range(1..=16)); voice.channel = (channel - 1) as u8; + ui.label("Transpose"); ui.add(egui::DragValue::new(&mut voice.transpose).range(-24..=24)); + ui.label("Gate"); ui.add(egui::DragValue::new(&mut voice.gate).range(10..=200).suffix("%")); + ui.label("Velocity"); ui.add(egui::DragValue::new(&mut voice.velocity).range(1..=200).suffix("%")); + }); + ui.horizontal_wrapped(|ui| { + ui.label("Estimated attack"); ui.add(egui::DragValue::new(&mut voice.attack_ms).range(0..=500).suffix(" ms")); + ui.label("Estimated release"); ui.add(egui::DragValue::new(&mut voice.release_ms).range(0..=2000).suffix(" ms")); + ui.label(egui::RichText::new("MIDI audition warms up before beat 1. Re-audition after tempo changes.").color(MUTED)); + }); + let curve = &mut voice.controller; + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut curve.enabled, "Send controller gesture"); + ui.label("CC"); ui.add(egui::DragValue::new(&mut curve.cc).range(1..=119)); + ui.label("Stop value"); ui.add(egui::DragValue::new(&mut curve.rest).range(0..=127)); + for (index, label) in ["Rise", "Fall", "Arch", "Pulse"].iter().enumerate() { + if ui.button(*label).clicked() { curve.shape(index); } + } + }); + if curve.enabled { + let (rect, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 58.), egui::Sense::hover()); + let painter = ui.painter(); painter.rect_filled(rect, 3., egui::Color32::from_rgb(30,43,63)); + let points: Vec<_> = curve.points.iter().map(|p| egui::pos2(rect.left() + p.position as f32 / 1000. * rect.width(), rect.bottom() - p.value as f32 / 127. * rect.height())).collect(); + for pair in points.windows(2) { painter.line_segment([pair[0],pair[1]], egui::Stroke::new(1.5_f32, if role == 0 { NOTE } else { egui::Color32::from_rgb(107,201,194) })); } + egui::ScrollArea::vertical().id_salt("curve-points").max_height(115.).show(ui, |ui| { + let mut remove = None; + for index in 0..curve.points.len() { + let low = if index == 0 { 0 } else { curve.points[index - 1].position + 1 }; + let high = curve.points.get(index + 1).map_or(1000, |p| p.position - 1); + let interior = index != 0 && index + 1 != curve.points.len(); + let point = &mut curve.points[index]; + ui.horizontal(|ui| { + let mut percent = point.position as f64 / 10.; + ui.add_enabled(interior, egui::DragValue::new(&mut percent).range(low as f64/10. ..=high as f64/10.).speed(0.1).suffix("%")); + point.position = (percent * 10.).round() as u16; + ui.label("Value"); ui.add(egui::DragValue::new(&mut point.value).range(0..=127)); + if ui.add_enabled(interior, egui::Button::new("Remove point")).clicked() { remove = Some(index); } + }); + } + if let Some(index) = remove { curve.points.remove(index); } + }); + if ui.add_enabled(curve.points.len()<16, egui::Button::new("Add point in largest gap")).clicked() { + if let Some((index,pair)) = curve.points.windows(2).enumerate().max_by_key(|(_,p)|p[1].position-p[0].position) { + let position = (pair[0].position+pair[1].position)/2; + if position > pair[0].position { let value = curve.value_at(position); curve.points.insert(index+1,story_core::CurvePoint {position,value}); } + } + } + } + }); + } + }); +} diff --git a/plugins/motif-storyboard/src/editor.rs b/plugins/motif-storyboard/src/editor.rs new file mode 100644 index 0000000..2455fd4 --- /dev/null +++ b/plugins/motif-storyboard/src/editor.rs @@ -0,0 +1,812 @@ +//! Shared egui view. Also compiled by the offline visual QA harness against cached egui. +use crate::egui::{self, Align2, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; +use std::{ + path::PathBuf, + sync::{atomic::Ordering, Arc}, + time::Duration, +}; +use story_app::{Output, Shared}; +#[path = "creative_editor.rs"] +mod creative_editor; +use story_core::{ + Audition, ChordQuality, Ending, Error, Harmony, Phrase, Result, Scale, Session, Task, BAR, +}; +const INK: Color32 = Color32::from_rgb(225, 231, 243); +const MUTED: Color32 = Color32::from_rgb(154, 172, 196); +const NOTE: Color32 = Color32::from_rgb(178, 164, 237); +const LOCK: Color32 = Color32::from_rgb(241, 192, 109); +const SURFACE: Color32 = Color32::from_rgb(30, 43, 63); +const GRID: Color32 = Color32::from_rgb(56, 72, 95); +const KEYS: [&str; 12] = [ + "C", "C♯", "D", "E♭", "E", "F", "F♯", "G", "A♭", "A", "B♭", "B", +]; +pub struct EditorState { + status: String, + error: bool, + import_path: String, + export_path: String, + pending: Option, + track: usize, + preview_lane: Option, + pending_lane: Option, + harmony_import: bool, + lock_mode: u8, + initialized: bool, + creative: creative_editor::CreativeUi, +} +impl Default for EditorState { + fn default() -> Self { + Self { + status: "Import a short MIDI motif, or load the demo to explore.".into(), + error: false, + import_path: String::new(), + export_path: String::new(), + pending: None, + track: 0, + preview_lane: None, + pending_lane: None, + harmony_import: false, + lock_mode: 0, + initialized: false, + creative: creative_editor::CreativeUi::default(), + } + } +} +pub fn style(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.panel_fill = Color32::from_rgb(23, 34, 51); + visuals.window_fill = Color32::from_rgb(30, 43, 63); + visuals.override_text_color = Some(INK); + visuals.selection.bg_fill = Color32::from_rgb(82, 72, 125); + visuals.widgets.inactive.bg_fill = Color32::from_rgb(48, 64, 87); + visuals.widgets.hovered.bg_fill = Color32::from_rgb(68, 83, 108); + visuals.widgets.active.bg_fill = Color32::from_rgb(91, 78, 135); + ctx.set_visuals(visuals); + let mut style = (*ctx.style_of(egui::Theme::Dark)).clone(); + style.spacing.item_spacing = Vec2::new(10., 8.); + style.spacing.button_padding = Vec2::new(12., 7.); + style + .text_styles + .insert(egui::TextStyle::Body, FontId::proportional(14.)); + style + .text_styles + .insert(egui::TextStyle::Button, FontId::proportional(14.)); + ctx.set_style_of(egui::Theme::Dark, style); +} +fn report(state: &mut EditorState, result: Result<()>) { + if let Err(e) = result { + state.status = e.to_string(); + state.error = true; + } +} +fn start( + shared: &Arc, + state: &mut EditorState, + job: impl FnOnce() -> Result + Send + 'static, +) { + start_at(shared, state, shared.revision.load(Ordering::Acquire), job); +} +fn start_at( + shared: &Arc, + state: &mut EditorState, + revision: u64, + job: impl FnOnce() -> Result + Send + 'static, +) { + match shared.work(revision, job) { + Ok(()) => { + state.status = "Working…".into(); + state.error = false; + } + Err(e) => { + state.status = e.to_string(); + state.error = true; + } + } +} +fn import(shared: &Arc, state: &mut EditorState, path: Option, harmony: bool) { + state.harmony_import = harmony; + state.pending = None; + start(shared, state, move || { + let path = match path { + Some(p) => Some(p), + None => story_app::dialog::choose(false, false)?, + }; + let Some(path) = path else { + return Ok(Output::Canceled); + }; + let bytes = story_app::read_limited(&path, story_midi::MAX_FILE_BYTES)?; + Ok(Output::Imported(story_midi::read(&bytes)?)) + }); +} +fn export( + shared: &Arc, + state: &mut EditorState, + phrase: Option, + tempo: f64, + path: Option, +) { + let Some(phrase) = phrase else { + report(state, Err(Error("Select a phrase to export.".into()))); + return; + }; + let performance = shared + .session + .read() + .unwrap_or_else(|p| p.into_inner()) + .performance + .clone(); + start(shared, state, move || { + let path = match path { + Some(p) => Some(p), + None => story_app::dialog::choose(true, false)?, + }; + let Some(mut path) = path else { + return Ok(Output::Canceled); + }; + if path.extension().is_none() { + path.set_extension("mid"); + } + story_app::write_new( + &path, + &story_midi::write_performance(&phrase, &performance, tempo)?, + )?; + Ok(Output::Exported(path)) + }); +} +fn apply_track(shared: &Arc, state: &mut EditorState, index: usize) { + let Some(file) = state.pending.as_ref() else { + return; + }; + let Some(track) = file.tracks.get(index) else { + return; + }; + let (revision, mut session) = shared.snapshot(); + if state.harmony_import { + match Harmony::from_midi(&track.phrase.notes, track.phrase.length) { + Ok(harmony) => session.harmony = harmony, + Err(e) => { + report(state, Err(e)); + return; + } + } + } else { + if let Err(e) = track.phrase.validate_source() { + report(state, Err(e)); + return; + } + session.source = Some(track.phrase.clone()); + session.pins.clear(); + } + session.candidates.clear(); + session.selected = 0; + let message = if state.harmony_import { + format!( + "Imported harmony from {}. Explore alternatives to use this context. {}", + track.name, + track.warnings.join(" ") + ) + } else if track.warnings.is_empty() { + format!( + "Imported {}. Click notes to lock them, then generate continuations.", + track.name + ) + } else { + track.warnings.join(" ") + }; + match shared.edit(revision, session) { + Ok(()) => { + state.pending = None; + state.status = message; + state.error = false; + state.preview_lane = None; + } + Err(e) => report(state, Err(e)), + } +} +fn completions(shared: &Arc, state: &mut EditorState) { + let Some(completion) = shared.take_completion() else { + return; + }; + state.error = false; + match completion.result { + Err(e) => report(state, Err(e)), + Ok(Output::Canceled) => state.status = "Canceled.".into(), + Ok(Output::Exported(path)) => state.status = format!("Exported {}", path.display()), + Ok(Output::Saved(path)) => state.status = format!("Saved {}", path.display()), + Ok(Output::Loaded(session)) => { + shared.stop(); + match shared.edit(completion.revision, *session) { + Ok(()) => { + state.pending = None; + state.preview_lane = None; + state.status = "Session restored. Preview is stopped.".into(); + } + Err(e) => report(state, Err(e)), + } + } + Ok(Output::LibraryLoaded(gestures)) => { + let (_, mut session) = shared.snapshot(); + match session.import_gesture_library(gestures) { + Ok(count) => match shared.edit(completion.revision, session) { + Ok(()) => { + state.status = format!("Imported {count} gestures."); + state.error = false; + } + Err(e) => report(state, Err(e)), + }, + Err(e) => report(state, Err(e)), + } + } + Ok(Output::Imported(file)) => { + if shared.revision.load(Ordering::Acquire) != completion.revision { + report( + state, + Err(Error("Session changed during import. Import again.".into())), + ); + return; + } + let single = file.tracks.len() == 1; + state.pending = Some(file); + state.track = 0; + state.status = "Select the MIDI note track to import.".into(); + if single { + apply_track(shared, state, 0); + } + } + Ok(Output::Generated(candidates)) => { + let (_, mut session) = shared.snapshot(); + session.candidates = candidates; + let count = session.candidates.len(); + session.selected = 0; + match shared.edit(completion.revision, session) { + Ok(()) => { + state.preview_lane = None; + state.status=format!("{} distinct proposals ready. Pin sections to combine them; locks may limit variety.",count); + } + Err(e) => report(state, Err(e)), + } + } + } +} +pub fn show(ui: &mut egui::Ui, shared: &Arc, state: &mut EditorState) { + let ctx = ui.ctx().clone(); + completions(shared, state); + if state.pending_lane.is_some() && !shared.queued.load(Ordering::Acquire) { + state.preview_lane = state.pending_lane.take(); + } + let busy = shared.busy.load(Ordering::Acquire); + let (revision, mut session) = shared.snapshot(); + if !state.initialized { + if session.source.is_some() { + state.status = "Audition proposals, then pin the sections you want to keep.".into(); + } + state.initialized = true; + } + let before = session.clone(); + let playing = shared.playing.load(Ordering::Acquire); + let tick = f64::from_bits(shared.playhead.load(Ordering::Relaxed)); + let mut audition: Option<(usize, Phrase)> = None; + let mut generate = false; + let mut restored = false; + egui::TopBottomPanel::bottom("commit-and-audition").show_inside(ui, |ui| { + ui.horizontal_wrapped(|ui| { + if ui.button("Stop").clicked() { + shared.stop(); + state.preview_lane = None; + state.pending_lane = None; + } + let combined = session.composed_phrase().ok(); + if ui + .add_enabled( + !busy && combined.is_some(), + egui::Button::new("Preview combined"), + ) + .clicked() + { + audition = combined.clone().map(|p| (4, p)); + } + if ui + .add_enabled( + !busy && combined.is_some(), + egui::Button::new("Export combined MIDI…"), + ) + .clicked() + { + export(shared, state, combined, session.tempo, None); + } + if ui + .add_enabled( + !busy && session.selected_phrase().is_some(), + egui::Button::new("Keep combined"), + ) + .clicked() + { + report(state, session.accept_composed()); + } + if ui + .add_enabled( + !busy && session.accepted.is_some(), + egui::Button::new("Preview kept"), + ) + .clicked() + { + audition = session.accepted.clone().map(|p| (5, p)); + } + if ui + .add_enabled( + !busy && (session.accepted.is_some() || session.previous_accepted.is_some()), + egui::Button::new("Undo / redo keep"), + ) + .clicked() + { + session.undo_accept(); + } + if busy { + ui.spinner(); + } + }); + ui.label(RichText::new(&state.status).color(if state.error { LOCK } else { MUTED })); + }); + egui::ScrollArea::vertical().show(ui,|ui|{ + ui.horizontal(|ui|{ + ui.label(RichText::new("Motif Storyboard").size(28.).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center),|ui|{ + if ui.add_enabled(!busy,egui::Button::new("Import motif…")).clicked(){ + import(shared,state,None,false); + } + if ui.add_enabled(!busy,egui::Button::new("Load demo")).clicked(){ + session.source=Some(story_core::demo_motif()); + session.pins.clear(); + state.status="Choose what to preserve, then explore a musical task.".into(); + state.error=false; + } + }); + }); + ui.label(RichText::new("Recognition. Space. Contrast. Return.").color(MUTED)); + ui.add_enabled_ui(!busy,|ui|{ + ui.horizontal_wrapped(|ui|{ + egui::ComboBox::from_id_salt("task").selected_text(session.settings.task.name()).show_ui(ui,|ui|{ + for task in Task::ALL{ + ui.selectable_value(&mut session.settings.task,task,task.name()); + } + }); + ui.label("Ending"); + ui.selectable_value(&mut session.settings.ending,Ending::Resolve,"Settled"); + ui.selectable_value(&mut session.settings.ending,Ending::Open,"Open"); + ui.label("Phrase"); + ui.selectable_value(&mut session.settings.bars,4,"4 bars"); + ui.selectable_value(&mut session.settings.bars,8,"8 bars"); + if ui.add_enabled(session.source.is_some(),egui::Button::new("Explore alternatives").fill(Color32::from_rgb(83,70,127))).clicked(){ + generate=true; + } + }); + ui.horizontal_wrapped(|ui|{ + egui::ComboBox::from_id_salt("root").selected_text(KEYS[session.settings.root as usize]).width(45.).show_ui(ui,|ui|{ + for (i,key) in KEYS.iter().enumerate(){ + ui.selectable_value(&mut session.settings.root,i as u8,*key); + } + }); + egui::ComboBox::from_id_salt("scale").selected_text(session.settings.scale.name()).show_ui(ui,|ui|{ + for scale in Scale::ALL{ + ui.selectable_value(&mut session.settings.scale,scale,scale.name()); + } + }); + ui.checkbox(&mut session.settings.preserve_rhythm,"Keep groove"); + ui.checkbox(&mut session.settings.preserve_contour,"Keep contour"); + ui.add(egui::DragValue::new(&mut session.tempo).range(30.0..=300.0).speed(1.).suffix(" BPM")); + }); + ui.horizontal_wrapped(|ui|{ + ui.label("Audition"); + ui.selectable_value(&mut session.audition,Audition::Internal,"Internal voice"); + ui.selectable_value(&mut session.audition,Audition::Midi,"MIDI output"); + ui.checkbox(&mut session.looping,"Loop"); + ui.checkbox(&mut session.follow_host,"Host tempo"); + if ui.add_enabled(session.source.is_some(),egui::Button::new("Preview source")).clicked(){ + audition=session.source.clone().map(|p|(0,p)); + } + }); + if session.audition==Audition::Midi{ + ui.label(RichText::new("Route each sound role to its configured MIDI channel. Map enabled CC lanes to synth macros. The internal voice is muted.").color(MUTED)); + } + ui.collapsing("Variation and register",|ui|{ + ui.horizontal_wrapped(|ui|{ + ui.add(egui::Slider::new(&mut session.settings.variation,0..=100).text("Reach")); + ui.label("Register"); + ui.add(egui::DragValue::new(&mut session.settings.low).range(0..=127)); + ui.label("to"); + ui.add(egui::DragValue::new(&mut session.settings.high).range(0..=127)); + ui.label("Seed"); + ui.add(egui::DragValue::new(&mut session.settings.seed)); + if ui.button("Another take").clicked(){ + session.settings.seed=session.settings.seed.wrapping_add(1); + generate=session.source.is_some(); + } + }); + }); + harmony_editor(ui,shared,state,&mut session); + creative_editor::performance(ui,&mut session.performance); + }); + if let Some(file)=&state.pending{ + let names:Vec<_>=file.tracks.iter().map(|t|format!("{} ({} notes)",t.name,t.phrase.notes.len())).collect(); + let mut apply=false; + ui.horizontal(|ui|{ + ui.label(if state.harmony_import{ + "Harmony track" + } else{ + "Motif track" + }); + egui::ComboBox::from_id_salt("track").selected_text(&names[state.track]).show_ui(ui,|ui|{ + for (i,name) in names.iter().enumerate(){ + ui.selectable_value(&mut state.track,i,name); + } + }); + if ui.button("Use track").clicked(){ + apply=true; + } + if ui.button("Cancel").clicked(){ + state.pending=None; + } + }); + if apply{ + apply_track(shared,state,state.track); + return; + } + } + let mut low=session.settings.low.min(session.settings.high); + let mut high=session.settings.high.max(session.settings.low); + for n in session.source.iter().flat_map(|p|&p.notes).chain(session.candidates.iter().flat_map(|c|&c.phrase.notes)).chain(session.pins.iter().flat_map(|p|&p.phrase.notes)){ + low=low.min(n.pitch); + high=high.max(n.pitch); + } + let span=session.settings.bars as u32*BAR; + let roll_style = RollStyle { register: (low, high), roles: session.performance.mode }; + ui.separator(); + ui.horizontal(|ui|{ + ui.label(RichText::new("Source motif").strong().size(17.)); + ui.label("Click to lock"); + egui::ComboBox::from_id_salt("lock-mode").selected_text(["Whole note","Pitch only","Rhythm only"][state.lock_mode as usize]).show_ui(ui,|ui|{ + for (i,name) in ["Whole note","Pitch only","Rhythm only"].iter().enumerate(){ + ui.selectable_value(&mut state.lock_mode,i as u8,*name); + } + }); + if ui.add_enabled(!busy&&session.source.is_some(),egui::Button::new("Clear locks")).clicked(){ + if let Some(p)=session.source.as_mut(){ + for n in &mut p.notes{ + n.locked=false; + n.pitch_locked=false; + n.rhythm_locked=false; + } + } + } + }); + if let Some(source)=session.source.as_mut(){ + let cursor=(playing&&state.preview_lane==Some(0)).then_some(tick); + if let Some(i)=roll(ui,source,span,roll_style,cursor,!busy,85.){ + let n=&mut source.notes[i]; + match state.lock_mode{ + 1=>n.pitch_locked= !n.pitch_locked,2=>n.rhythm_locked= !n.rhythm_locked,_=>n.locked= !n.locked + } + } + } else{ + ui.add_space(18.); + ui.label("Import a monophonic motif of up to two bars, or load the demo."); + ui.add_space(18.); + } + restored |= creative_editor::ideas(ui,shared,state,&mut session); + if session.selected_phrase().is_some(){ + ui.horizontal_wrapped(|ui|{ + ui.label(RichText::new("Your phrase").strong().size(17.)); + ui.label("Pin from the selected proposal:"); + for section in 0..session.settings.bars as usize/2{ + let pinned=session.pins.iter().any(|p|p.index==section); + if ui.add_enabled(!busy,egui::Button::new(format!("{} {}–{}",if pinned{ + "Release" + } else{ + "Pin" + },section*2+1,section*2+2)).fill(if pinned{ + Color32::from_rgb(99,77,45) + } else{ + SURFACE + } + )).clicked(){ + if pinned{ + session.pins.retain(|p|p.index!=section); + } else{ + report(state,session.pin_selected(section)); + } + } + } + }); + if let Ok(combined)=session.composed_phrase(){ + roll(ui,&combined,span,roll_style,(playing&&state.preview_lane==Some(4)).then_some(tick),false,65.); + } + ui.label(RichText::new("Pinned sections survive new takes. Select another proposal to fill the unpinned sections.").color(MUTED)); + } + for i in 0..session.candidates.len(){ + let candidate=&session.candidates[i]; + let phrase=candidate.phrase.clone(); + let name=candidate.strategy.name(); + let explanation=candidate.explanation.clone(); + ui.horizontal(|ui|{ + ui.selectable_value(&mut session.selected,i,RichText::new(name).size(17.)); + if ui.add_enabled(!busy,egui::Button::new("Audition")).clicked(){ + session.selected=i; + audition=Some((i+1,phrase.clone())); + } + if ui.add_enabled(!busy,egui::Button::new("Export proposal…")).clicked(){ + export(shared,state,Some(phrase.clone()),session.tempo,None); + } + }); + ui.label(RichText::new(explanation).color(MUTED)); + roll(ui,&phrase,span,roll_style,(playing&&state.preview_lane==Some(i+1)).then_some(tick),false,70.); + } + ui.separator(); + ui.horizontal_wrapped(|ui|{ + if ui.add_enabled(!busy&&session.accepted.is_some(),egui::Button::new("Export kept MIDI…")).clicked(){ + export(shared,state,session.accepted.clone(),session.tempo,None); + } + if ui.add_enabled(!busy,egui::Button::new("Save session…")).clicked(){ + let saved=session.clone(); + start(shared,state,move||{ + let Some(mut path)=story_app::dialog::choose(true,true)? else{ + return Ok(Output::Canceled); + }; + if path.extension().is_none(){ + path.set_extension("json"); + } + story_app::write_new(&path,&saved.to_json()?)?; + Ok(Output::Saved(path)) + }); + } + if ui.add_enabled(!busy,egui::Button::new("Load session…")).clicked(){ + start(shared,state,move||{ + let Some(path)=story_app::dialog::choose(false,true)? else{ + return Ok(Output::Canceled); + }; + Ok(Output::Loaded(Box::new(Session::from_json(&story_app::read_limited(&path,story_core::MAX_STATE_BYTES)?)?))) + }); + } + }); + ui.collapsing("File paths",|ui|{ + ui.add_enabled_ui(!busy,|ui|{ + ui.horizontal(|ui|{ + ui.label("Import"); + ui.text_edit_singleline(&mut state.import_path); + if ui.button("Motif").clicked(){ + import(shared,state,Some(PathBuf::from(&state.import_path)),false); + } + if ui.button("Harmony").clicked(){ + import(shared,state,Some(PathBuf::from(&state.import_path)),true); + } + }); + ui.horizontal(|ui|{ + ui.label("Export combined"); + ui.text_edit_singleline(&mut state.export_path); + if ui.add_enabled(session.selected_phrase().is_some(),egui::Button::new("Export path")).clicked(){ + export(shared,state,session.composed_phrase().ok(),session.tempo,Some(PathBuf::from(&state.export_path))); + } + }); + }); + }); + }); + if !restored + && (session.source != before.source || session.settings.bars != before.settings.bars) + { + session.pins.clear(); + } + if !restored + && (session.settings != before.settings + || session.source != before.source + || session.harmony != before.harmony) + { + session.candidates.clear(); + session.selected = 0; + } + let mut generation_revision = revision; + if session != before { + if let Err(e) = shared.edit(revision, session.clone()) { + report(state, Err(e)); + return; + } + generation_revision = revision.wrapping_add(1); + } + if restored { + shared.stop(); + state.preview_lane = None; + state.pending_lane = None; + } + if generate { + if let Some(source) = session.source.clone() { + let settings = session.settings.clone(); + let harmony = session.harmony.clone(); + start_at(shared, state, generation_revision, move || { + Ok(Output::Generated(story_core::generate_with_harmony( + &source, &settings, &harmony, + )?)) + }); + } + } + if let Some((lane, phrase)) = audition { + match shared.audition(&phrase) { + Ok(()) => { + if shared.queued.load(Ordering::Acquire) { + state.pending_lane = Some(lane); + state.status = "Queued for the next two-bar boundary or phrase end.".into(); + } else { + state.preview_lane = Some(lane); + state.pending_lane = None; + state.status = + "Auditioning. Change proposals to compare at a phrase boundary.".into(); + } + state.error = false; + } + Err(e) => report(state, Err(e)), + } + } + ctx.request_repaint_after(Duration::from_millis(if playing || busy { + 33 + } else { + 150 + })); +} +fn harmony_editor( + ui: &mut egui::Ui, + shared: &Arc, + state: &mut EditorState, + session: &mut Session, +) { + let label = if session.harmony.spans.is_empty() { + "Harmony: scale only".to_string() + } else { + format!( + "Harmony: {} regions, repeats every {} bars", + session.harmony.spans.len(), + session.harmony.length / BAR + ) + }; + egui::CollapsingHeader::new(label).id_salt("harmony-context").show(ui,|ui|{ + ui.horizontal_wrapped(|ui|{ + if ui.button("Import harmony MIDI…").clicked(){ + import(shared,state,None,true); + } + if ui.button("Editable progression").clicked(){ + session.harmony=Harmony::progression(session.settings.root,session.settings.scale); + } + if ui.button("Clear harmony").clicked(){ + session.harmony=Harmony::default(); + } + }); + ui.label(RichText::new("Harmony guides pitches; audition remains melody only. Imported voicings retain their pitch classes, without guessed chord names.").color(MUTED)); + egui::ScrollArea::vertical().id_salt("harmony-list").max_height(170.).show(ui,|ui|{ + for (index,span) in session.harmony.spans.iter_mut().enumerate(){ + ui.push_id(index,|ui|{ + ui.horizontal(|ui|{ + ui.label(format!("Beat {:.2}",span.start as f32/960.+1.)); + let mut root=span.bass%12; + let old=root; + egui::ComboBox::from_id_salt("chord-root").selected_text(KEYS[root as usize]).show_ui(ui,|ui|{ + for (i,key) in KEYS.iter().enumerate(){ + ui.selectable_value(&mut root,i as u8,*key); + } + }); + if root!=old{ + let shift=(root+12-old)%12; + span.tones=((span.tones<>(12-shift)))&0xfff; + span.bass=48+root; + } + let mut quality=ChordQuality::ALL.into_iter().find(|q|q.mask(root)==span.tones); + egui::ComboBox::from_id_salt("chord-quality").selected_text(quality.map_or("Custom pitches",ChordQuality::name)).show_ui(ui,|ui|{ + for q in ChordQuality::ALL{ + ui.selectable_value(&mut quality,Some(q),q.name()); + } + }); + if let Some(q)=quality{ + span.tones=q.mask(root); + } + }); + }); + } + }); + }); +} +#[derive(Clone, Copy)] +struct RollStyle { + register: (u8, u8), + roles: story_core::RoleMode, +} +fn roll( + ui: &mut egui::Ui, + phrase: &Phrase, + span: u32, + style: RollStyle, + cursor: Option, + editable: bool, + height: f32, +) -> Option { + let (rect, response) = ui.allocate_exact_size( + Vec2::new(ui.available_width(), height), + if editable { + Sense::click() + } else { + Sense::hover() + }, + ); + let painter = ui.painter(); + painter.rect_filled(rect, 4., SURFACE); + let plot = Rect::from_min_max( + Pos2::new(rect.left() + 32., rect.top() + 17.), + Pos2::new(rect.right() - 5., rect.bottom() - 5.), + ); + let low = style.register.0.saturating_sub(1); + let high = style.register.1.saturating_add(1).min(127); + let rows = (high - low + 1) as f32; + for pitch in low..=high { + let y = plot.bottom() - (pitch - low) as f32 / rows * plot.height(); + if pitch % 12 == 0 { + painter.line_segment( + [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)], + Stroke::new(0.5_f32, GRID), + ); + painter.text( + Pos2::new(rect.left() + 3., y - 2.), + Align2::LEFT_BOTTOM, + format!("C{}", pitch as i16 / 12 - 1), + FontId::proportional(9.), + MUTED, + ); + } + } + for bar in 0..=span / BAR { + let x = plot.left() + bar as f32 * BAR as f32 / span as f32 * plot.width(); + painter.line_segment( + [Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())], + Stroke::new(1_f32, GRID), + ); + if bar < span / BAR { + painter.text( + Pos2::new(x + 4., rect.top() + 2.), + Align2::LEFT_TOP, + (bar + 1).to_string(), + FontId::proportional(10.), + MUTED, + ); + } + } + let click = if response.clicked() { + response.interact_pointer_pos() + } else { + None + }; + let mut clicked = None; + for (i, n) in phrase.notes.iter().enumerate() { + let x = plot.left() + n.start as f32 / span as f32 * plot.width(); + let right = plot.left() + n.end() as f32 / span as f32 * plot.width(); + let y = plot.bottom() - (n.pitch - low) as f32 / rows * plot.height(); + let note_rect = Rect::from_min_max( + Pos2::new(x, y - (plot.height() / rows).max(4.)), + Pos2::new(right.max(x + 3.), y), + ); + painter.rect_filled( + note_rect, + 1., + if n.is_anchor() { + LOCK + } else if style.roles.role(i, n) == 1 { + Color32::from_rgb(107, 201, 194) + } else { + NOTE + }, + ); + if editable && click.is_some_and(|pos| note_rect.expand(2.).contains(pos)) { + clicked = Some(i); + } + } + if let Some(tick) = cursor.filter(|t| *t >= 0.) { + let x = plot.left() + (tick / span as f64) as f32 * plot.width(); + painter.line_segment( + [Pos2::new(x, plot.top()), Pos2::new(x, plot.bottom())], + Stroke::new(1.5_f32, INK), + ); + } + clicked +} diff --git a/plugins/motif-storyboard/src/lib.rs b/plugins/motif-storyboard/src/lib.rs new file mode 100644 index 0000000..ccf7923 --- /dev/null +++ b/plugins/motif-storyboard/src/lib.rs @@ -0,0 +1,192 @@ +use nih_plug::{params::persist::PersistentField, prelude::*}; +pub use nih_plug_egui::egui; +use nih_plug_egui::{create_egui_editor, EguiState}; +use std::sync::{atomic::Ordering, Arc}; +use story_app::{AudioEngine, Shared}; +use story_core::Session; +mod editor; + +pub struct SessionField(Arc); +impl<'a> PersistentField<'a, Session> for SessionField { + fn set(&self, session: Session) { + // State restoration always stops preview, including invalid/future state. + self.0.stop(); + if self.0.replace(session).is_err() { + let _ = self.0.replace(Session::default()); + } + } + fn map(&self, f: F) -> R + where + F: Fn(&Session) -> R, + { + f(&self.0.session.read().unwrap_or_else(|p| p.into_inner())) + } +} + +#[derive(Params)] +struct StoryParams { + #[persist = "editor"] + editor: Arc, + #[persist = "story-session-v1"] + session: SessionField, + // Notifies the host of edits to non-parameter session data, including lock changes. + #[id = "session-revision"] + revision: IntParam, +} + +pub struct MotifStoryboard { + params: Arc, + shared: Arc, + audio: AudioEngine, + host_was_playing: bool, +} +impl Default for MotifStoryboard { + fn default() -> Self { + let shared = Arc::new(Shared::default()); + Self { + params: Arc::new(StoryParams { + editor: EguiState::from_size(1100, 850), + session: SessionField(shared.clone()), + revision: IntParam::new( + "Session revision", + 0, + IntRange::Linear { + min: 0, + max: 1_000_000, + }, + ) + .hide() + .non_automatable(), + }), + shared, + audio: AudioEngine::default(), + host_was_playing: false, + } + } +} +impl Plugin for MotifStoryboard { + const NAME: &'static str = "Motif Storyboard"; + const VENDOR: &'static str = "Storyboard"; + const URL: &'static str = ""; + const EMAIL: &'static str = ""; + const VERSION: &'static str = env!("CARGO_PKG_VERSION"); + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout { + main_input_channels: None, + main_output_channels: NonZeroU32::new(2), + ..AudioIOLayout::const_default() + }]; + const MIDI_OUTPUT: MidiConfig = MidiConfig::MidiCCs; + type SysExMessage = (); + type BackgroundTask = (); + fn params(&self) -> Arc { + self.params.clone() + } + fn editor(&mut self, _: AsyncExecutor) -> Option> { + let shared = self.shared.clone(); + let params = self.params.clone(); + create_egui_editor( + self.params.editor.clone(), + editor::EditorState::default(), + |ctx, _| editor::style(ctx), + move |ctx, setter, state| { + let before = shared.revision.load(Ordering::Acquire); + egui::CentralPanel::default().show(ctx, |ui| editor::show(ui, &shared, state)); + if shared.revision.load(Ordering::Acquire) != before { + setter.begin_set_parameter(¶ms.revision); + setter + .set_parameter(¶ms.revision, (params.revision.value() + 1) % 1_000_001); + setter.end_set_parameter(¶ms.revision); + } + }, + ) + } + fn initialize( + &mut self, + _: &AudioIOLayout, + config: &BufferConfig, + _: &mut impl InitContext, + ) -> bool { + self.audio + .player + .set_sample_rate(config.sample_rate as f64) + .is_ok() + } + fn reset(&mut self) { + self.shared.stop(); + self.audio.stop(); + self.host_was_playing = false; + } + fn process( + &mut self, + buffer: &mut Buffer, + _: &mut AuxiliaryBuffers, + context: &mut impl ProcessContext, + ) -> ProcessStatus { + self.audio.begin_block(&self.shared); + let transport = context.transport(); + // A host stop silences a running audition; host start never initiates playback. + if self.host_was_playing && !transport.playing { + self.shared.stop(); + self.audio.stop(); + } + self.host_was_playing = transport.playing; + let local = f64::from_bits(self.shared.tempo.load(Ordering::Acquire)); + let bpm = if self.shared.follow_host.load(Ordering::Acquire) { + transport.tempo.unwrap_or(local) + } else { + local + }; + self.shared + .audition_tempo + .store(bpm.to_bits(), Ordering::Release); + let midi = self.shared.midi.load(Ordering::Acquire); + for (index, channels) in buffer.iter_samples().enumerate() { + let frame = self.audio.frame(bpm, midi); + for message in frame.events.events[..frame.events.len].iter().flatten() { + match message.kind { + story_core::MessageKind::Off => context.send_event(NoteEvent::NoteOff { + timing: index as u32, + voice_id: None, + channel: message.channel, + note: message.key, + velocity: 0., + }), + story_core::MessageKind::On => context.send_event(NoteEvent::NoteOn { + timing: index as u32, + voice_id: None, + channel: message.channel, + note: message.key, + velocity: message.value as f32 / 127., + }), + story_core::MessageKind::Control => context.send_event(NoteEvent::MidiCC { + timing: index as u32, + channel: message.channel, + cc: message.key, + value: message.value as f32 / 127., + }), + } + } + let sample = if midi { 0. } else { frame.sample }; + for output in channels { + *output = sample; + } + } + self.audio.end_block(&self.shared); + // Keep the host processing so a GUI Preview command can wake the voice. + ProcessStatus::KeepAlive + } +} +impl ClapPlugin for MotifStoryboard { + const CLAP_ID: &'static str = "io.storyboard.motif"; + const CLAP_DESCRIPTION: Option<&'static str> = + Some("Develop a MIDI motif into a connected phrase"); + const CLAP_MANUAL_URL: Option<&'static str> = None; + const CLAP_SUPPORT_URL: Option<&'static str> = None; + const CLAP_FEATURES: &'static [ClapFeature] = &[ + ClapFeature::Instrument, + ClapFeature::Synthesizer, + ClapFeature::Stereo, + ClapFeature::Utility, + ]; +} +nih_export_clap!(MotifStoryboard); diff --git a/plugins/motif-storyboard/src/main.rs b/plugins/motif-storyboard/src/main.rs new file mode 100644 index 0000000..529a709 --- /dev/null +++ b/plugins/motif-storyboard/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + nih_plug::prelude::nih_export_standalone::(); +} diff --git a/plugins/motif-storyboard/tests/host.rs b/plugins/motif-storyboard/tests/host.rs new file mode 100644 index 0000000..e25ed94 --- /dev/null +++ b/plugins/motif-storyboard/tests/host.rs @@ -0,0 +1,186 @@ +//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW. +use clap_sys::{ + audio_buffer::*, + events::*, + ext::{note_ports::*, state::*}, + factory::plugin_factory::*, + host::*, + process::*, + stream::*, + version::*, +}; +use std::{ + ffi::{c_char, c_void, CStr}, + io::{Cursor, Read}, + ptr, +}; + +unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void { + ptr::null() +} +unsafe extern "C" fn request(_: *const clap_host) {} +unsafe extern "C" fn count(_: *const clap_input_events) -> u32 { + 0 +} +unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header { + ptr::null() +} +unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool { + true +} +unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 { + // SAFETY: The test owns the Vec for the entire synchronous save call. + let bytes = unsafe { &mut *((*stream).ctx as *mut Vec) }; + bytes.extend_from_slice(unsafe { + std::slice::from_raw_parts(buffer as *const u8, size as usize) + }); + size as i64 +} +unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 { + // SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage. + let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor>) }; + cursor + .read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) }) + .unwrap() as i64 +} + +#[test] +fn exported_factory_state_and_silent_audio_lifecycle() { + // SAFETY: All callbacks, strings, streams, buffers, and the host outlive their + // synchronous ABI calls. Activation/processing/destruction follow CLAP order. + unsafe { + let entry = &motif_storyboard::clap_entry; + assert!(entry.init.unwrap()(c"motif-storyboard.clap".as_ptr())); + let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr()) + as *const clap_plugin_factory; + assert!(!factory.is_null()); + let factory = &*factory; + assert_eq!(factory.get_plugin_count.unwrap()(factory), 1); + let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0); + assert!(!descriptor.is_null()); + assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.motif"); + assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null()); + let host = clap_host { + clap_version: CLAP_VERSION, + host_data: ptr::null_mut(), + name: c"Storyboard test host".as_ptr(), + vendor: c"Tests".as_ptr(), + url: c"".as_ptr(), + version: c"1.0".as_ptr(), + get_extension: Some(extension), + request_restart: Some(request), + request_process: Some(request), + request_callback: Some(request), + }; + let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id); + assert!(!plugin.is_null()); + let p = &*plugin; + assert!(p.init.unwrap()(plugin)); + let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_NOTE_PORTS.as_ptr()) + as *const clap_plugin_note_ports; + assert!(!ports.is_null()); + assert_eq!((*ports).count.unwrap()(plugin, false), 1); + assert_eq!((*ports).count.unwrap()(plugin, true), 0); + let state = + p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state; + assert!(!state.is_null()); + let state = &*state; + let save = || { + let mut bytes = Vec::new(); + let stream = clap_ostream { + ctx: &mut bytes as *mut Vec as *mut c_void, + write: Some(write), + }; + assert!(state.save.unwrap()(plugin, &stream)); + bytes + }; + let mut envelope: nih_plug::prelude::PluginState = + serde_json::from_slice(&save()[8..]).unwrap(); + let mut session = story_core::Session { + source: Some(story_core::demo_motif()), + tempo: 150., + ..Default::default() + }; + session.source.as_mut().unwrap().notes[1].locked = true; + session.harmony = story_core::Harmony::progression(0, story_core::Scale::Minor); + session.audition = story_core::Audition::Midi; + session.regenerate().unwrap(); + session.pin_selected(1).unwrap(); + session.accept(); + session.selected = 2; + session.accept(); + session.performance.mode = story_core::RoleMode::CallResponse; + session.performance.voices[1].controller.enabled = true; + session.performance.voices[1].controller.cc = 20; + session.performance.voices[1].attack_ms = 75; + session.save_gesture("Airy question", None).unwrap(); + session.save_branch("Original call").unwrap(); + session + .apply_move(story_core::CreativeMove::Thin, Some(0)) + .unwrap(); + envelope.fields.insert( + "story-session-v1".into(), + String::from_utf8(session.to_json().unwrap()).unwrap(), + ); + let encoded = serde_json::to_vec(&envelope).unwrap(); + let mut framed = (encoded.len() as u64).to_le_bytes().to_vec(); + framed.extend_from_slice(&encoded); + let mut bytes = Cursor::new(framed); + let stream = clap_istream { + ctx: &mut bytes as *mut Cursor> as *mut c_void, + read: Some(read), + }; + assert!(state.load.unwrap()(plugin, &stream)); + let restored: nih_plug::prelude::PluginState = + serde_json::from_slice(&save()[8..]).unwrap(); + assert_eq!( + story_core::Session::from_json(restored.fields["story-session-v1"].as_bytes()).unwrap(), + session + ); + for rate in [44100., 48000., 96000.] { + assert!(p.activate.unwrap()(plugin, rate, 16, 512)); + assert!(p.start_processing.unwrap()(plugin)); + for frames in [16, 127, 512] { + let mut left = [1f32; 512]; + let mut right = [1f32; 512]; + let mut channels = [left.as_mut_ptr(), right.as_mut_ptr()]; + let mut output = clap_audio_buffer { + data32: channels.as_mut_ptr(), + data64: ptr::null_mut(), + channel_count: 2, + latency: 0, + constant_mask: 0, + }; + let input = clap_input_events { + ctx: ptr::null_mut(), + size: Some(count), + get: Some(event), + }; + let outgoing = clap_output_events { + ctx: ptr::null_mut(), + try_push: Some(push), + }; + let process = clap_process { + steady_time: -1, + frames_count: frames, + transport: ptr::null(), + audio_inputs: ptr::null(), + audio_outputs: &mut output, + audio_inputs_count: 0, + audio_outputs_count: 1, + in_events: &input, + out_events: &outgoing, + }; + assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR); + assert!(left[..frames as usize] + .iter() + .chain(&right[..frames as usize]) + .all(|&s| s == 0.)); + } + p.stop_processing.unwrap()(plugin); + p.deactivate.unwrap()(plugin); + } + p.destroy.unwrap()(plugin); + entry.deinit.unwrap()(); + } +} diff --git a/scripts/analyze-references.py b/scripts/analyze-references.py new file mode 100644 index 0000000..b078209 --- /dev/null +++ b/scripts/analyze-references.py @@ -0,0 +1,80 @@ +"""Measure rendered-mix level, attacks, and texture changes in local FLACs. + +Requires ffmpeg, numpy, matplotlib. This does not transcribe melodies or chords. +Positive spectral flux includes percussion; RMS is influenced by mastering. +""" +import os +os.environ['MPLCONFIGDIR'] = '/tmp/motif-matplotlib' +import subprocess, json, argparse +from pathlib import Path +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +parser = argparse.ArgumentParser(description='Measure relative level and attack/texture changes in local FLAC references. Requires ffmpeg, numpy, and matplotlib. Does not transcribe notes or infer emotion.') +parser.add_argument('folder', nargs='?', default='.') +parser.add_argument('--output', default='docs/reference-analysis') +args = parser.parse_args() +root = Path(args.folder) +out = Path(args.output) +out.mkdir(parents=True, exist_ok=True) +files = sorted(root.glob('*.flac')) +if not files: + parser.error('No FLAC files in the selected folder.') +fig, axes = plt.subplots(len(files), 1, squeeze=False, figsize=(15, 2.0 * len(files)), constrained_layout=True) +summary = [] +axes = axes[:, 0] +for ax, path in zip(axes, files): + raw = subprocess.check_output(['ffmpeg', '-v', 'error', '-i', str(path), '-f', 'f32le', '-ac', '1', '-ar', '11025', 'pipe:1']) + y = np.frombuffer(raw, dtype='= t) & (times < t + 1)]) if np.any((times >= t) & (times < t + 1)) else 0 for t in one]) + energy = seconds(np.asarray(rms)) + attack = seconds(flux) + bands = np.stack([seconds(mag[:, (freq >= lo) & (freq < hi)].sum(axis=1)) for lo, hi in [(40, 180), (180, 1000), (1000, 5000)]], axis=1) + features = np.log1p(bands) + features = (features - features.mean(0)) / (features.std(0) + 1e-08) + novelty = np.zeros(len(one)) + for t in range(8, len(one) - 8): + novelty[t] = np.linalg.norm(features[t - 8:t].mean(0) - features[t:t + 8].mean(0)) + peaks = [] + for idx in np.argsort(novelty)[::-1]: + if idx < 16 or idx > len(one) - 16: + continue + if all((abs(int(idx) - p) >= 16 for p in peaks)): + peaks.append(int(idx)) + if len(peaks) == 6: + break + peaks.sort() + ax.plot(one, energy / (np.percentile(energy, 95) + 1e-08), label='Relative RMS', color='#8b77cf', linewidth=1) + ax.plot(one, attack / (np.percentile(attack, 95) + 1e-08), label='Spectral attacks', color='#3f8d9e', alpha=0.65, linewidth=0.8) + for p in peaks: + ax.axvline(p, color='#b7823c', alpha=0.45, linewidth=0.7) + ax.text(p, 1.65, f'{p // 60}:{p % 60:02}', fontsize=7, rotation=90) + ax.set(title=path.stem, xlim=(0, len(y) / sr), ylim=(0, 2), ylabel='Relative level') + ax.grid(alpha=0.15) + item = {'track': path.stem, 'duration_seconds': round(len(y) / sr, 2), 'candidate_texture_changes_seconds': peaks, 'rms_20th_to_80th_ratio': round(float(np.percentile(energy, 80) / (np.percentile(energy, 20) + 1e-08)), 2), 'spectral_attack_20th_to_80th_ratio': round(float(np.percentile(attack, 80) / (np.percentile(attack, 20) + 1e-08)), 2)} + summary.append(item) + print(json.dumps(item), flush=True) +axes[0].legend(loc='upper right') +axes[-1].set_xlabel('Seconds') +fig.savefig(out / 'reference-timelines.png', dpi=130) +(out / 'measurements.json').write_text(json.dumps(summary, indent=2)) diff --git a/scripts/build-plugin.py b/scripts/build-plugin.py new file mode 100644 index 0000000..1ec54b1 --- /dev/null +++ b/scripts/build-plugin.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Build and package CLAP without modifying a DAW's plugin directories.""" +import argparse +from pathlib import Path +import shutil +import subprocess + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--target", help="Rust target triple; defaults to rustc's host") +parser.add_argument("--standalone", action="store_true", help="Also build the standalone preview app") +args = parser.parse_args() +target = args.target +if target is None: + info = subprocess.check_output(["rustc", "-vV"], text=True) + target = next(line.removeprefix("host: ") for line in info.splitlines() if line.startswith("host: ")) +if "windows" not in target and "linux" not in target: + parser.error("This milestone packages Windows and Linux only.") +manifest = root / "plugins/motif-storyboard/Cargo.toml" +cmd = ["cargo", "build", "--manifest-path", str(manifest), "--release", "--locked", "--target", target, "--lib"] +if args.standalone: + cmd += ["--features", "standalone", "--bin", "motif-storyboard"] +subprocess.run(cmd, cwd=root, check=True) +build = root / "plugins/motif-storyboard/target" / target / "release" +destination = root / "dist" / target +destination.mkdir(parents=True, exist_ok=True) +library = build / ("motif_storyboard.dll" if "windows" in target else "libmotif_storyboard.so") +plugin = destination / "Motif Storyboard.clap" +shutil.copy2(library, plugin) +shutil.copy2(root / "LICENSE", destination) +shutil.copy2(root / "docs/fl-studio.md", destination / "FL-Studio-setup.md") +if args.standalone: + exe = "motif-storyboard.exe" if "windows" in target else "motif-storyboard" + shutil.copy2(build / exe, destination) +print(f"Packaged {plugin}") diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock new file mode 100644 index 0000000..9e0d88b --- /dev/null +++ b/tools/editor-check/Cargo.lock @@ -0,0 +1,521 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "ecolor" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1" +dependencies = [ + "emath", +] + +[[package]] +name = "editor-check" +version = "0.1.0" +dependencies = [ + "egui", + "image", + "story-app", + "story-core", + "story-midi", +] + +[[package]] +name = "egui" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3" +dependencies = [ + "ahash", + "bitflags", + "emath", + "epaint", + "nohash-hasher", + "profiling", +] + +[[package]] +name = "emath" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b" + +[[package]] +name = "epaint" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562" +dependencies = [ + "ab_glyph", + "ahash", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "story-app" +version = "0.3.0" +dependencies = [ + "crossbeam-queue", + "story-core", + "story-midi", + "windows-sys", +] + +[[package]] +name = "story-core" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "story-midi" +version = "0.3.0" +dependencies = [ + "story-core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/editor-check/Cargo.toml b/tools/editor-check/Cargo.toml new file mode 100644 index 0000000..6c09a6f --- /dev/null +++ b/tools/editor-check/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "editor-check" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +egui = "0.31.1" +image = { version = "0.25", default-features = false, features = ["png"] } +story-app = { path = "../../crates/story-app" } +story-core = { path = "../../crates/story-core" } +story-midi = { path = "../../crates/story-midi" } diff --git a/tools/editor-check/src/main.rs b/tools/editor-check/src/main.rs new file mode 100644 index 0000000..b6ef00a --- /dev/null +++ b/tools/editor-check/src/main.rs @@ -0,0 +1,165 @@ +//! Offline compilation and software-rendered visual QA for the shared editor. +pub use egui; +#[path = "../../../plugins/motif-storyboard/src/editor.rs"] +mod editor; +use std::sync::Arc; +fn main() { + let shared = Arc::new(story_app::Shared::default()); + let mut session = story_core::Session { + source: Some(story_core::demo_motif()), + ..Default::default() + }; + session.source.as_mut().unwrap().notes[0].locked = true; + session.regenerate().unwrap(); + session.accept(); + if std::env::args().any(|arg| arg == "--creative") { + session.performance.mode = story_core::RoleMode::CallResponse; + session.performance.voices[0].name = "Glass question".into(); + session.performance.voices[1].name = "Warm answer".into(); + for (i, voice) in session.performance.voices.iter_mut().enumerate() { + voice.controller.enabled = true; + voice.controller.cc = 20 + i as u8; + voice.controller.shape(2); + voice.attack_ms = 40; + } + session.save_gesture("Hover, then resolve", None).unwrap(); + session.save_branch("Opening thought").unwrap(); + session + .apply_move(story_core::CreativeMove::Thin, Some(1)) + .unwrap(); + } + shared.replace(session).unwrap(); + let ctx = egui::Context::default(); + editor::style(&ctx); + let mut state = editor::EditorState::default(); + let mut textures = std::collections::HashMap::new(); + let mut output = None; + for _ in 0..3 { + let frame = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1100., 850.), + )), + ..Default::default() + }, + |ui| { + egui::CentralPanel::default().show(ui, |ui| editor::show(ui, &shared, &mut state)); + }, + ); + for (id, delta) in &frame.textures_delta.set { + let image = match &delta.image { + egui::ImageData::Color(image) => image.as_ref().clone(), + egui::ImageData::Font(image) => egui::ColorImage { + size: image.size, + pixels: image.srgba_pixels(None).collect(), + }, + }; + if let Some([x, y]) = delta.pos { + let full = textures.get_mut(id).unwrap(); + let full: &mut egui::ColorImage = full; + for row in 0..image.size[1] { + for col in 0..image.size[0] { + full.pixels[(y + row) * full.size[0] + x + col] = + image.pixels[row * image.size[0] + col]; + } + } + } else { + textures.insert(*id, image.clone()); + } + } + output = Some(frame); + } + let output = output.unwrap(); + let meshes = ctx.tessellate(output.shapes, 1.); + let mut image = image::RgbaImage::from_pixel(1100, 850, image::Rgba([23, 34, 51, 255])); + for clipped in meshes { + let egui::epaint::Primitive::Mesh(mesh) = clipped.primitive else { + continue; + }; + let Some(texture) = textures.get(&mesh.texture_id) else { + continue; + }; + for tri in mesh.indices.chunks_exact(3) { + let [a, b, c] = [ + mesh.vertices[tri[0] as usize], + mesh.vertices[tri[1] as usize], + mesh.vertices[tri[2] as usize], + ]; + let min_x = a + .pos + .x + .min(b.pos.x) + .min(c.pos.x) + .max(clipped.clip_rect.left()) + .max(0.) as u32; + let min_y = a + .pos + .y + .min(b.pos.y) + .min(c.pos.y) + .max(clipped.clip_rect.top()) + .max(0.) as u32; + let max_x = a + .pos + .x + .max(b.pos.x) + .max(c.pos.x) + .min(clipped.clip_rect.right()) + .min(1099.) as u32; + let max_y = a + .pos + .y + .max(b.pos.y) + .max(c.pos.y) + .min(clipped.clip_rect.bottom()) + .min(849.) as u32; + let cross = |u: egui::Vec2, v: egui::Vec2| u.x * v.y - u.y * v.x; + let area = cross(b.pos - a.pos, c.pos - a.pos); + if area.abs() < 0.0001 { + continue; + } + for y in min_y..=max_y { + for x in min_x..=max_x { + let p = egui::pos2(x as f32 + 0.5, y as f32 + 0.5); + let wa = cross(b.pos - p, c.pos - p) / area; + let wb = cross(c.pos - p, a.pos - p) / area; + let wc = 1. - wa - wb; + if wa < 0. || wb < 0. || wc < 0. { + continue; + } + let uv = a.uv.to_vec2() * wa + b.uv.to_vec2() * wb + c.uv.to_vec2() * wc; + let tx = (uv.x * texture.size[0] as f32) + .floor() + .clamp(0., texture.size[0] as f32 - 1.) + as usize; + let ty = (uv.y * texture.size[1] as f32) + .floor() + .clamp(0., texture.size[1] as f32 - 1.) + as usize; + let tex = texture.pixels[ty * texture.size[0] + tx].to_array(); + let ac = a.color.to_array(); + let bc = b.color.to_array(); + let cc = c.color.to_array(); + let mut color = [0.; 4]; + for i in 0..4 { + color[i] = (ac[i] as f32 * wa + bc[i] as f32 * wb + cc[i] as f32 * wc) + / 255. + * tex[i] as f32; + } + let pixel = image.get_pixel_mut(x, y); + let alpha = color[3] / 255.; + for i in 0..3 { + pixel[i] = + (color[i] + pixel[i] as f32 * (1. - alpha)).clamp(0., 255.) as u8; + } + } + } + } + } + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "/tmp/motif-storyboard-editor.png".into()); + image.save(&path).unwrap(); + println!("Rendered {path}"); +}