feat: add Motif Storyboard composition plugin
Check and build / core (push) Successful in 51s
Check and build / plugin (ubuntu-latest) (push) Failing after 32s
Check and build / plugin (windows-latest) (push) Canceled after 0s

This commit is contained in:
nvrl committed 2026-11-05 22:39:36 +01:00
commit 3281535a04
48 files changed
+10885

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"
+38
View File
@@ -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/
+35
View File
@@ -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
Generated
+162
View File
@@ -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"
+19
View File
@@ -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"
+15
View File
@@ -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.
+165
View File
@@ -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/<target>/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.
+13
View File
@@ -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"] }
+72
View File
@@ -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<Option<PathBuf>> {
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use windows_sys::Win32::UI::Controls::Dialogs::*;
let mut buffer = [0u16; 32768];
let filter: Vec<u16> = if session {
"Storyboard session\0*.json\0\0"
} else {
"MIDI file\0*.mid;*.midi\0\0"
}
.encode_utf16()
.collect();
let extension: Vec<u16> = if session { "json\0" } else { "mid\0" }
.encode_utf16()
.collect();
let mut options: OPENFILENAMEW = unsafe { std::mem::zeroed() };
options.lStructSize = std::mem::size_of::<OPENFILENAMEW>() 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<Option<PathBuf>> {
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'))))
}
+474
View File
@@ -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<Session>,
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<PlayRequest>,
pub busy: AtomicBool,
results: Mutex<Vec<Completion>>,
}
pub enum Output {
Imported(story_midi::MidiFile),
Generated(Vec<Candidate>),
Exported(PathBuf),
Saved(PathBuf),
Loaded(Box<Session>),
LibraryLoaded(Vec<story_core::Gesture>),
Canceled,
}
pub struct Completion {
pub revision: u64,
pub result: Result<Output>,
}
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(&current);
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(&current);
Ok(())
}
fn compile_clip(&self, phrase: &story_core::Phrase) -> Result<PerformanceClip> {
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<Self>,
revision: u64,
job: impl FnOnce() -> Result<Output> + 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<Completion> {
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<Vec<u8>> {
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<u8>; 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<u8>,
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));
}
}
+124
View File
@@ -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());
}
+84
View File
@@ -0,0 +1,84 @@
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
use story_app::{AudioEngine, Shared};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=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());
}
+117
View File
@@ -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
);
}
+9
View File
@@ -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
+407
View File
@@ -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<Phrase>,
pub settings: Settings,
pub harmony: Harmony,
pub phrase: Option<Phrase>,
pub pins: Vec<PinnedSection>,
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<u64>,
pub name: String,
pub idea: IdeaSnapshot,
}
#[derive(Serialize, Deserialize)]
struct LibraryFile {
version: u32,
gestures: Vec<Gesture>,
}
pub fn read_gesture_library(bytes: &[u8]) -> Result<Vec<Gesture>> {
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<Vec<u8>> {
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<Gesture>) -> Result<usize> {
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<u64> {
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<usize>) -> Result<u64> {
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<u64> {
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<usize>) -> 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(())
}
}
+170
View File
@@ -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<HarmonySpan>,
}
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<Self> {
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<HarmonySpan> = 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(),
}
}
}
+657
View File
@@ -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<T> = std::result::Result<T, Error>;
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<Note>,
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<Vec<Candidate>> {
generate_with_harmony(source, settings, &Harmony::default())
}
pub fn generate_with_harmony(
source: &Phrase,
settings: &Settings,
harmony: &Harmony,
) -> Result<Vec<Candidate>> {
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<Phrase>,
pub settings: Settings,
pub candidates: Vec<Candidate>,
pub selected: usize,
pub accepted: Option<Phrase>,
pub previous_accepted: Option<Phrase>,
pub tempo: f64,
pub follow_host: bool,
#[serde(default)]
pub harmony: Harmony,
#[serde(default)]
pub pins: Vec<PinnedSection>,
#[serde(default)]
pub audition: Audition,
#[serde(default)]
pub looping: bool,
#[serde(default)]
pub performance: PerformanceSettings,
#[serde(default)]
pub gestures: Vec<Gesture>,
#[serde(default)]
pub branches: Vec<Branch>,
#[serde(default)]
pub active_branch: Option<u64>,
#[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<Phrase> {
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<Self> {
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<Vec<u8>> {
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());
}
}
+622
View File
@@ -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<CurvePoint>,
}
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<TimedMessage>,
}
impl Performance {
pub fn compile(
phrase: &Phrase,
settings: &PerformanceSettings,
bpm: f64,
compensate: bool,
) -> Result<Self> {
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<PerformanceClip> {
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<MidiMessage>; 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<MidiMessage>; 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)
}
}
+202
View File
@@ -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<Self> {
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<Note> {
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<f32> {
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);
}
}
+427
View File
@@ -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<Vec<Candidate>> {
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<Candidate> = 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<Candidate> = 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);
}
}
}
}
}
+138
View File
@@ -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);
}
+228
View File
@@ -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(&notes, 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::<Vec<_>>(),
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());
}
+152
View File
@@ -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());
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "story-midi"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
story-core.workspace = true
+546
View File
@@ -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<String>,
pub channels: Vec<u8>,
}
#[derive(Debug)]
pub struct MidiFile {
pub tracks: Vec<MidiTrack>,
pub tempo: Option<f64>,
}
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<u8> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
fn u32(&mut self) -> Result<u32> {
let b = self.take(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
fn vlq(&mut self) -> Result<u32> {
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<u8> {
let b = self.byte()?;
if b > 127 {
Err(error("Invalid MIDI data byte."))
} else {
Ok(b)
}
}
}
fn ticks(value: u32, division: u16) -> Result<u32> {
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<MidiFile> {
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<u8>) {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
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::<usize>(),
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);
}
}
+9
View File
@@ -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
+101
View File
@@ -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<dyn std::error::Error>> {
let args: Vec<_> = env::args().skip(1).collect();
if args.is_empty() || args[0] == "--help" {
println!("Motif Storyboard preview\n story-preview demo <output-directory>\n story-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n story-preview session <session.json> <output-directory>\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(())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

+53
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

+114
View File
@@ -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.
@@ -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.
+100
View File
@@ -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
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

+95
View File
@@ -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).
+54
View File
@@ -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.
+3000
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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"
@@ -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<String>) {
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<Shared>,
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}); }
}
}
}
});
}
});
}
+812
View File
@@ -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<story_midi::MidiFile>,
track: usize,
preview_lane: Option<usize>,
pending_lane: Option<usize>,
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<Shared>,
state: &mut EditorState,
job: impl FnOnce() -> Result<Output> + Send + 'static,
) {
start_at(shared, state, shared.revision.load(Ordering::Acquire), job);
}
fn start_at(
shared: &Arc<Shared>,
state: &mut EditorState,
revision: u64,
job: impl FnOnce() -> Result<Output> + 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<Shared>, state: &mut EditorState, path: Option<PathBuf>, 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<Shared>,
state: &mut EditorState,
phrase: Option<Phrase>,
tempo: f64,
path: Option<PathBuf>,
) {
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<Shared>, 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<Shared>, 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<Shared>, 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<Shared>,
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<<shift)|(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<f64>,
editable: bool,
height: f32,
) -> Option<usize> {
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
}
+192
View File
@@ -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<Shared>);
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<F, R>(&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<EguiState>,
#[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<StoryParams>,
shared: Arc<Shared>,
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<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
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(&params.revision);
setter
.set_parameter(&params.revision, (params.revision.value() + 1) % 1_000_001);
setter.end_set_parameter(&params.revision);
}
},
)
}
fn initialize(
&mut self,
_: &AudioIOLayout,
config: &BufferConfig,
_: &mut impl InitContext<Self>,
) -> 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<Self>,
) -> 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);
+3
View File
@@ -0,0 +1,3 @@
fn main() {
nih_plug::prelude::nih_export_standalone::<motif_storyboard::MotifStoryboard>();
}
+186
View File
@@ -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<u8>) };
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<Vec<u8>>) };
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<u8> 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<Vec<u8>> 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()();
}
}
+80
View File
@@ -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='<f4')
sr = 11025
hop = 256
size = 2048
frames = np.lib.stride_tricks.sliding_window_view(y, size)[::hop]
window = np.hanning(size)
spectra = []
rms = []
for start in range(0, len(frames), 256):
f = frames[start:start + 256]
spectra.append(np.abs(np.fft.rfft(f * window, axis=1)).astype(np.float32))
rms.extend(np.sqrt(np.mean(f * f, axis=1)))
mag = np.concatenate(spectra)
freq = np.fft.rfftfreq(size, 1 / sr)
times = np.arange(len(mag)) * hop / sr
log = np.log1p(mag)
flux = np.maximum(np.diff(log, axis=0, prepend=log[:1]), 0).sum(axis=1)
one = np.arange(0, len(y) / sr, 1.0)
def seconds(values):
return np.array([np.mean(values[(times >= 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))
+35
View File
@@ -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}")
+521
View File
@@ -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"
+14
View File
@@ -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" }
+165
View File
@@ -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}");
}