feat: add Motif Storyboard composition plugin
This commit is contained in:
commit
3281535a04
48 files changed
+10885
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -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"] }
|
||||
@@ -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'))))
|
||||
}
|
||||
@@ -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(¤t);
|
||||
Ok(())
|
||||
}
|
||||
pub fn edit(&self, revision: u64, mut session: Session) -> Result<()> {
|
||||
session.migrate()?;
|
||||
session.validate()?;
|
||||
let mut current = self.session.write().unwrap_or_else(|p| p.into_inner());
|
||||
if self.revision.load(Ordering::Acquire) != revision {
|
||||
return Err(Error(
|
||||
"Session changed while this operation was running. Try again.".into(),
|
||||
));
|
||||
}
|
||||
if current.source != session.source
|
||||
|| current.settings != session.settings
|
||||
|| current.harmony != session.harmony
|
||||
|| current.candidates != session.candidates
|
||||
|| current.performance != session.performance
|
||||
|| current.audition != session.audition
|
||||
{
|
||||
self.stop();
|
||||
}
|
||||
*current = session;
|
||||
self.publish(¤t);
|
||||
Ok(())
|
||||
}
|
||||
fn compile_clip(&self, phrase: &story_core::Phrase) -> Result<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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use story_core::*;
|
||||
|
||||
fn rhythm(p: &Phrase) -> Vec<(u32, u32)> {
|
||||
p.notes.iter().map(|n| (n.start, n.duration)).collect()
|
||||
}
|
||||
#[test]
|
||||
fn alternatives_change_density_and_rhythm_instead_of_only_pitch() {
|
||||
let source = demo_motif();
|
||||
let s = Settings::default();
|
||||
let candidates = generate(&source, &s).unwrap();
|
||||
assert_eq!(candidates.len(), 3);
|
||||
assert!(candidates
|
||||
.iter()
|
||||
.any(|c| c.phrase.notes.len() != source.notes.len() * 4));
|
||||
assert!(candidates
|
||||
.windows(2)
|
||||
.any(|pair| rhythm(&pair[0].phrase) != rhythm(&pair[1].phrase)));
|
||||
for c in &candidates {
|
||||
assert_eq!(
|
||||
&c.phrase.notes[..source.notes.len()],
|
||||
source.notes.as_slice()
|
||||
);
|
||||
}
|
||||
assert_eq!(candidates, generate(&source, &s).unwrap());
|
||||
let mut signatures = std::collections::BTreeSet::new();
|
||||
for seed in 0..12 {
|
||||
for c in generate(&source, &Settings { seed, ..s.clone() }).unwrap() {
|
||||
signatures.insert(rhythm(&c.phrase));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
signatures.len() > 3,
|
||||
"seeds should explore multiple rhythmic decisions"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn fixed_groove_and_separate_note_locks_take_priority() {
|
||||
let mut source = demo_motif();
|
||||
source.notes[1].pitch_locked = true;
|
||||
source.notes[1].pitch = 99;
|
||||
source.notes[3].rhythm_locked = true;
|
||||
for c in generate(&source, &Settings::default()).unwrap() {
|
||||
let pitch: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect();
|
||||
assert_eq!(pitch.len(), 4);
|
||||
assert!(pitch.iter().all(|n| n.pitch == 99));
|
||||
for rep in 0..4 {
|
||||
assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked
|
||||
&& n.start == source.notes[3].start + rep * source.length
|
||||
&& n.duration == source.notes[3].duration));
|
||||
}
|
||||
}
|
||||
let source = demo_motif();
|
||||
let repeated: Vec<_> = (0..4)
|
||||
.flat_map(|rep| {
|
||||
source
|
||||
.notes
|
||||
.iter()
|
||||
.map(move |n| (n.start + rep * SECTION, n.duration))
|
||||
})
|
||||
.collect();
|
||||
for c in generate(
|
||||
&source,
|
||||
&Settings {
|
||||
preserve_rhythm: true,
|
||||
..Settings::default()
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
assert_eq!(rhythm(&c.phrase), repeated);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn impossible_diversity_returns_one_honest_proposal() {
|
||||
let mut source = demo_motif();
|
||||
for n in &mut source.notes {
|
||||
n.locked = true;
|
||||
}
|
||||
let candidates = generate(&source, &Settings::default()).unwrap();
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].phrase.notes.len(), source.notes.len() * 4);
|
||||
}
|
||||
#[test]
|
||||
fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
|
||||
let harmony = Harmony {
|
||||
length: BAR,
|
||||
spans: vec![HarmonySpan {
|
||||
start: 0,
|
||||
duration: BAR,
|
||||
tones: ChordQuality::Major.mask(0),
|
||||
bass: 48,
|
||||
}],
|
||||
};
|
||||
for ending in [Ending::Open, Ending::Resolve] {
|
||||
let s = Settings {
|
||||
preserve_contour: false,
|
||||
ending,
|
||||
..Settings::default()
|
||||
};
|
||||
for c in generate_with_harmony(&demo_motif(), &s, &harmony).unwrap() {
|
||||
let last = c.phrase.notes.last().unwrap();
|
||||
let chord = ChordQuality::Major.mask(0) & (1 << (last.pitch % 12)) != 0;
|
||||
assert_eq!(chord, ending == Ending::Resolve);
|
||||
for n in c.phrase.notes.iter().filter(|n| {
|
||||
n.start >= SECTION && n.start.is_multiple_of(PPQ) && n.start != last.start
|
||||
}) {
|
||||
assert_ne!(
|
||||
harmony.at(n.start).unwrap().tones & (1 << (n.pitch % 12)),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
|
||||
let notes = vec![
|
||||
Note {
|
||||
start: 0,
|
||||
duration: BAR,
|
||||
pitch: 64,
|
||||
velocity: 90,
|
||||
..Default::default()
|
||||
},
|
||||
Note {
|
||||
start: 0,
|
||||
duration: BAR,
|
||||
pitch: 67,
|
||||
velocity: 90,
|
||||
..Default::default()
|
||||
},
|
||||
Note {
|
||||
start: 0,
|
||||
duration: BAR,
|
||||
pitch: 72,
|
||||
velocity: 90,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let h = Harmony::from_midi(¬es, 2 * BAR).unwrap();
|
||||
assert_eq!(h.spans.len(), 1);
|
||||
assert_eq!(h.spans[0].bass, 64);
|
||||
assert_eq!(h.spans[0].tones, ChordQuality::Major.mask(0));
|
||||
assert!(h.at(BAR + 1).is_none());
|
||||
assert_eq!(h.at(2 * BAR).unwrap().bass, 64);
|
||||
}
|
||||
#[test]
|
||||
fn pins_combine_candidates_and_survive_regeneration_exactly() {
|
||||
let mut s = Session {
|
||||
source: Some(demo_motif()),
|
||||
..Default::default()
|
||||
};
|
||||
s.regenerate().unwrap();
|
||||
s.pin_selected(1).unwrap();
|
||||
let saved = s.pins[0].clone();
|
||||
s.selected = 1;
|
||||
let expected: Vec<_> = saved
|
||||
.phrase
|
||||
.notes
|
||||
.iter()
|
||||
.map(|n| Note {
|
||||
start: n.start + SECTION,
|
||||
..*n
|
||||
})
|
||||
.collect();
|
||||
let combined = s.composed_phrase().unwrap();
|
||||
assert_eq!(
|
||||
combined
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| n.start >= SECTION && n.start < 2 * SECTION)
|
||||
.copied()
|
||||
.collect::<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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "story-midi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
story-core.workspace = true
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 658 KiB |
@@ -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.
|
||||
@@ -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 |
@@ -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).
|
||||
@@ -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.
|
||||
Generated
+3000
File diff suppressed because it is too large.
Load diff
@@ -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}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¶ms.revision);
|
||||
setter
|
||||
.set_parameter(¶ms.revision, (params.revision.value() + 1) % 1_000_001);
|
||||
setter.end_set_parameter(¶ms.revision);
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
_: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
_: &mut impl InitContext<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);
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
nih_plug::prelude::nih_export_standalone::<motif_storyboard::MotifStoryboard>();
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
Generated
+521
@@ -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"
|
||||
@@ -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" }
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in new issue
Block a user