Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36ff0780c | ||
|
|
f1366280ff | ||
|
|
abd37fd4fe | ||
|
|
17c6fe3ab9 |
No files matched your search
@@ -1,27 +1,69 @@
|
||||
name: Rust plugin toolchain
|
||||
description: Native checks and optional Windows GNU cross-compilation in Node's Debian container
|
||||
description: >-
|
||||
Native checks and optional Windows GNU cross-compilation in Node's Debian
|
||||
container. Restores the Rust toolchain, Cargo registry, and the caller's
|
||||
build directories from the runner cache, and saves them after the job.
|
||||
inputs:
|
||||
windows:
|
||||
description: Install the Windows cross compiler
|
||||
default: "false"
|
||||
cache-name:
|
||||
description: Distinguishes build-directory caches of different jobs
|
||||
required: true
|
||||
target-paths:
|
||||
description: Build directories to cache, one per line, relative to the workspace
|
||||
default: target
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Cache keys
|
||||
id: keys
|
||||
shell: bash
|
||||
run: |
|
||||
# A new ISO week starts fresh caches, so stale build output cannot grow forever
|
||||
# and the stable toolchain is refreshed regularly.
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore Rust toolchain and Cargo registry
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.cargo-ci/bin
|
||||
.cargo-ci/registry/index
|
||||
.cargo-ci/registry/cache
|
||||
.cargo-ci/git/db
|
||||
.rustup-ci
|
||||
key: rust-${{ inputs.windows }}-${{ steps.keys.outputs.week }}-${{ hashFiles('Cargo.lock', 'plugins/*/plugin/Cargo.lock', 'tools/editor-check/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ inputs.windows }}-${{ steps.keys.outputs.week }}-
|
||||
- name: Restore build directories
|
||||
uses: https://github.com/actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.target-paths }}
|
||||
# One entry per job, week, and dependency set: compiled dependencies are
|
||||
# reused, and only this repository's crates rebuild on each push.
|
||||
key: build-${{ inputs.cache-name }}-${{ steps.keys.outputs.week }}-${{ hashFiles('Cargo.lock', 'plugins/*/plugin/Cargo.lock', 'tools/editor-check/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
build-${{ inputs.cache-name }}-${{ steps.keys.outputs.week }}-
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3 curl ca-certificates pkg-config libgl1-mesa-dev libx11-dev libxcursor-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxkbcommon-x11-dev
|
||||
if [ "${{ inputs.windows }}" = "true" ]; then
|
||||
apt-get install -y --no-install-recommends gcc-mingw-w64-x86-64
|
||||
fi
|
||||
- name: Install Rust
|
||||
shell: bash
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh
|
||||
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain stable
|
||||
if [ ! -x "$CARGO_HOME/bin/rustup" ]; then
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh
|
||||
sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain none --no-modify-path
|
||||
fi
|
||||
echo "$CARGO_HOME/bin" >> "$GITHUB_PATH"
|
||||
"$CARGO_HOME/bin/rustup" component add rustfmt clippy
|
||||
- name: Install Windows target
|
||||
if: inputs.windows == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
apt-get install -y --no-install-recommends gcc-mingw-w64-x86-64
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
# No-op when the restored toolchain is already current.
|
||||
"$CARGO_HOME/bin/rustup" toolchain install stable --profile minimal -c rustfmt -c clippy
|
||||
"$CARGO_HOME/bin/rustup" default stable
|
||||
if [ "${{ inputs.windows }}" = "true" ]; then
|
||||
"$CARGO_HOME/bin/rustup" target add x86_64-pc-windows-gnu
|
||||
fi
|
||||
"$CARGO_HOME/bin/rustc" --version
|
||||
@@ -10,7 +10,10 @@ permissions:
|
||||
|
||||
env:
|
||||
CARGO_HOME: ${{ gitea.workspace }}/.cargo-ci
|
||||
RUSTUP_HOME: ${{ gitea.workspace }}/.rustup-ci
|
||||
CARGO_TERM_COLOR: always
|
||||
# Incremental data is not reused between CI runs; it only inflates the cache.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -19,13 +22,18 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
plugin: [rivet, patina]
|
||||
plugin: [rivet, patina, anvil, crucible]
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
persist-credentials: false
|
||||
- uses: ./.gitea/actions/setup
|
||||
with:
|
||||
cache-name: check-${{ matrix.plugin }}
|
||||
target-paths: |
|
||||
target
|
||||
plugins/${{ matrix.plugin }}/plugin/target
|
||||
- run: python3 -m unittest discover -s scripts/tests -v
|
||||
- run: python3 scripts/check-plugin.py --plugin "${{ matrix.plugin }}"
|
||||
|
||||
@@ -38,6 +46,9 @@ jobs:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
persist-credentials: false
|
||||
- uses: ./.gitea/actions/setup
|
||||
with:
|
||||
cache-name: previews
|
||||
target-paths: tools/editor-check/target
|
||||
- run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo fmt --manifest-path tools/editor-check/Cargo.toml -- --check
|
||||
@@ -45,6 +56,8 @@ jobs:
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/rivet.png --plugin rivet
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina.png --plugin patina
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina-compact.png --plugin patina --compact
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/anvil.png --plugin anvil
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/crucible.png --plugin crucible
|
||||
- uses: https://github.com/actions/upload-artifact@v3-node20
|
||||
with:
|
||||
name: editor-previews
|
||||
@@ -65,9 +78,13 @@ jobs:
|
||||
- uses: ./.gitea/actions/setup
|
||||
with:
|
||||
windows: "true"
|
||||
cache-name: windows-preview
|
||||
target-paths: target/windows
|
||||
- run: |
|
||||
python3 scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python3 scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python3 scripts/package-windows.py
|
||||
- uses: https://github.com/actions/upload-artifact@v3-node20
|
||||
with:
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
name: Independent plugin release
|
||||
on:
|
||||
push:
|
||||
tags: ["rivet-v*", "patina-v*"]
|
||||
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_HOME: ${{ gitea.workspace }}/.cargo-ci
|
||||
RUSTUP_HOME: ${{ gitea.workspace }}/.rustup-ci
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RELEASE_REF: ${{ gitea.ref }}
|
||||
|
||||
jobs:
|
||||
@@ -44,6 +46,10 @@ jobs:
|
||||
- uses: ./.gitea/actions/setup
|
||||
with:
|
||||
windows: "true"
|
||||
# CARGO_TARGET_DIR holds both the native checks and the Windows build.
|
||||
# Kept apart from branch builds; a release never reuses preview output.
|
||||
cache-name: release-${{ needs.plan.outputs.plugin }}
|
||||
target-paths: target/windows
|
||||
- run: |
|
||||
python3 -m unittest discover -s scripts/tests -v
|
||||
python3 scripts/check-plugin.py --plugin "$PLUGIN"
|
||||
|
||||
@@ -30,6 +30,25 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anvil-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anvil-core",
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -48,6 +67,17 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -63,6 +93,26 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crucible-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"crossbeam-queue",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crucible-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"common-dsp",
|
||||
"common-ui",
|
||||
"crucible-core",
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.31.1"
|
||||
@@ -214,7 +264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina-core"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
"serde",
|
||||
@@ -223,7 +273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina-render"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"patina-core",
|
||||
"serde_json",
|
||||
@@ -231,8 +281,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-ui",
|
||||
"egui",
|
||||
"patina-core",
|
||||
]
|
||||
@@ -281,7 +332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-app"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"crossbeam-queue",
|
||||
"rivet-core",
|
||||
@@ -291,7 +342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -299,14 +350,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-midi"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"rivet-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rivet-preview"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"rivet-core",
|
||||
"rivet-midi",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
|
||||
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin"]
|
||||
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
|
||||
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Rivet & Patina
|
||||
|
||||
Two Rust CLAP plugins for writing and refining bass music.
|
||||
Four Rust CLAP plugins for writing, designing and mixing bass music.
|
||||
|
||||
| Plugin | Purpose | Guide |
|
||||
| --- | --- | --- |
|
||||
| **Rivet** | Join motifs into connected phrases, preserve identity, and explore branches | [Rivet](docs/rivet/README.md) |
|
||||
| **Rivet** | Tell a MIDI motif as a story on a storyboard of roles, with joined seams | [Rivet](docs/rivet/README.md) |
|
||||
| **Patina** | Smooth harsh resonances while retaining tone and transient character | [Patina](docs/patina/README.md) |
|
||||
| **Anvil** | Give every bass note the same clean sub: one level, one start phase | [Anvil](docs/anvil/README.md) |
|
||||
| **Crucible** | Capture the last bars, slice them and export clean, key-labelled samples | [Crucible](docs/crucible/README.md) |
|
||||
|
||||
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
|
||||
surface while leaving its character. They share a Rust foundation and keep
|
||||
surface while leaving its character; an **anvil** is the solid base everything is
|
||||
struck on; a **crucible** melts material down to be cast again. They share a Rust foundation and keep
|
||||
their own musical purpose, code, documentation, and version.
|
||||
|
||||
## Install
|
||||
|
||||
Download the desired plugin's Windows ZIP from its Gitea release. Extract it
|
||||
and copy `Rivet.clap` or `Patina.clap` into:
|
||||
and copy its `.clap` file (`Rivet.clap`, `Patina.clap`, `Anvil.clap` or
|
||||
`Crucible.clap`) into:
|
||||
|
||||
```text
|
||||
C:\Program Files\Common Files\CLAP\
|
||||
@@ -22,11 +26,11 @@ C:\Program Files\Common Files\CLAP\
|
||||
|
||||
In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
|
||||
**Verify plugins**, and **Find installed plugins**. Load Rivet in the Channel Rack;
|
||||
load Patina in a mixer effect slot.
|
||||
load Patina, Anvil and Crucible in mixer effect slots.
|
||||
|
||||
Normal branch/PR pipelines also provide a combined Windows preview ZIP in the
|
||||
**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.1.0`
|
||||
publish independently. See [release workflow](docs/releases.md).
|
||||
**windows** job. Releases are separate: tags such as `rivet-v0.4.0`,
|
||||
`patina-v0.2.0`, `anvil-v0.1.0` and `crucible-v0.1.0` publish independently. See [release workflow](docs/releases.md).
|
||||
|
||||
These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs
|
||||
and saved-state identifiers are preserved; replace old scanned copies and rescan.
|
||||
@@ -44,13 +48,23 @@ plugins/
|
||||
plugin/ CLAP wrapper, editor, optional standalone
|
||||
patina/
|
||||
core/ Spectral detector and attenuation engine
|
||||
ui/ Shared native editor and embedded fonts
|
||||
ui/ Patina's native editor
|
||||
render/ Offline WAV evaluation
|
||||
plugin/ CLAP audio-effect wrapper
|
||||
tools/editor-check/ Headless visual QA for both editors
|
||||
anvil/
|
||||
core/ Pitch tracking, crossover and sub oscillator
|
||||
ui/ Anvil's native editor
|
||||
plugin/ CLAP audio-effect wrapper with MIDI input
|
||||
crucible/
|
||||
core/ Input history, capture, slicing, cleanup and export
|
||||
ui/ Crucible's native editor
|
||||
plugin/ CLAP audio-effect wrapper
|
||||
common/
|
||||
ui/ Shared look: palette, embedded fonts, knobs and panels
|
||||
dsp/ Shared filters, YIN pitch detection and WAV files
|
||||
tools/editor-check/ Headless visual QA for all editors
|
||||
scripts/ Shared build, packaging, release, and analysis tools
|
||||
docs/rivet/ Rivet guide, theory, research, and editor previews
|
||||
docs/patina/ Patina guide, research, and editor preview
|
||||
docs/<plugin>/ Guide, changelog and editor preview per plugin
|
||||
references/ Local reference audio; excluded from distribution
|
||||
```
|
||||
|
||||
@@ -62,6 +76,8 @@ separate. [Development and testing](docs/development.md) covers the commands.
|
||||
cargo test --workspace --locked
|
||||
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python scripts/package-windows.py
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Anvil changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Introduce Anvil, a sub anchor for bass music.
|
||||
- Replace the band below Split with a clean mono sine. Every note gets the same
|
||||
peak level and starts at the same phase, so switch-ups and kick hits line up.
|
||||
- Detect pitch and note starts from the audio with 80 ms lookahead, or follow
|
||||
MIDI notes sent to the effect.
|
||||
- Controls: Sub level, Split, Dynamics, Drive, Octave, Glide, Phase, Gate, Keep,
|
||||
Output, Retrigger, Snap to semitone, Bypass. Solo sub monitors only the new sub.
|
||||
- Note readout, level meters and an oscilloscope of the original low band against
|
||||
the new sub.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Anvil
|
||||
|
||||
Anvil gives every bass note the same solid sub. Current version **0.1.0**.
|
||||
|
||||
In riddim and dubstep, each switch-up patch brings its own sub: a different level,
|
||||
a different phase, sometimes two layers that cancel. The drop's weight jumps from
|
||||
note to note and the kick lands differently on each one. Anvil removes the
|
||||
original band below **Split** and puts a clean mono sine in its place. The sine
|
||||
follows the bass pitch, has the same peak level on every note, and starts at the
|
||||
same phase on every note.
|
||||
|
||||

|
||||
|
||||
## Install on Windows
|
||||
|
||||
Copy `Anvil.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1
|
||||
or newer, open Options → Manage plugins, enable Verify plugins, and Find installed
|
||||
plugins. Load Anvil in the mixer slot of the bass bus. Keep the font license with
|
||||
redistributed packages.
|
||||
|
||||
## Work with it
|
||||
|
||||
1. Put Anvil on the bass bus, after the sound design and before the bus
|
||||
compression or clipping.
|
||||
2. Set **Split** just above the sub. 80–100 Hz suits most riddim basses.
|
||||
3. Set **Sub level** once. Every patch on the bus now gets this level.
|
||||
4. Use **Phase** to line the start of each note up with your kick. With
|
||||
**Retrigger** on, every note starts at this phase.
|
||||
5. Turn on **Solo sub** to hear only the new sub; the scope shows the original
|
||||
low band (grey) against the new sub (accent).
|
||||
|
||||
Other controls:
|
||||
|
||||
- **Dynamics** at 0 % holds the level for the whole note. Raise it to let the sub
|
||||
follow the original's volume shape, for example a wobble.
|
||||
- **Drive** adds upper harmonics so the sub reads on phones and laptops.
|
||||
- **Octave** moves the sub against the detected note. Use −1 for a bass played
|
||||
one octave up.
|
||||
- **Glide** slides between overlapping notes; 0 jumps.
|
||||
- **Gate** is the input level that counts as a note. Raise it if tails or reverb
|
||||
start new notes.
|
||||
- **Keep** blends some of the original low band back under the new sub.
|
||||
- **Snap to semitone** rounds detected pitch. Turn it off for pitch bends and slides.
|
||||
|
||||
### Pitch from MIDI
|
||||
|
||||
Detection follows the audio by default. For heavy formant or FM basses where the
|
||||
fundamental is unclear, select **MIDI** and route the bass's MIDI notes to Anvil.
|
||||
In FL Studio, set the bass channel's MIDI output port and Anvil's input port to the
|
||||
same number, or duplicate the notes into a pattern on a channel that feeds Anvil.
|
||||
|
||||
## Timing and limits
|
||||
|
||||
Detection uses 80 ms of lookahead, reported to the host for delay compensation,
|
||||
so each note's pitch is known before its attack leaves the plugin. Pitch range is
|
||||
25–250 Hz, one voice: chords and layered notes in the sub band are not separated.
|
||||
Notes shorter than about 15 ms may not be recognised. A pitch move of more than
|
||||
0.7 semitone that holds for two analysis hops (10 ms) starts a new note.
|
||||
|
||||
The crossover is a 4th-order Linkwitz–Riley pair, so with Keep at 100 % and no
|
||||
note playing, the signal passes with flat magnitude. Stereo above the split is
|
||||
kept; the new sub is mono.
|
||||
|
After Width: | Height: | Size: 243 KiB |
@@ -0,0 +1,13 @@
|
||||
# Crucible changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Introduce Crucible, a resampler that always records its input.
|
||||
- Capture the newest 1–16 complete bars on the host's bar grid, or the newest
|
||||
audio when the transport is stopped. Tempo changes inside a take are followed.
|
||||
- Slice on a straight or triplet grid, or at transients. Add and remove cuts by
|
||||
hand, include or exclude slices, and audition them in place.
|
||||
- Clean every slice: trim leading silence, fade both ends, normalise to −0.3 dBFS.
|
||||
The root note of each pitched slice is detected and written into the file.
|
||||
- Export the loop, every slice, and optionally a 2048-sample wavetable for Serum
|
||||
and Vital into a dated folder.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Crucible
|
||||
|
||||
Crucible melts the bars you just played into ready-to-use samples. Current version
|
||||
**0.1.0**.
|
||||
|
||||
Resampling is the core loop of riddim sound design: bounce, open in Edison, chop,
|
||||
trim, declick, normalise, find the key, name, save, load into a sampler. Crucible
|
||||
does all of it from one button. It always records its input, so the take you
|
||||
liked is already there when you press **Capture**.
|
||||
|
||||

|
||||
|
||||
## Install on Windows
|
||||
|
||||
Copy `Crucible.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio
|
||||
2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find
|
||||
installed plugins. Load Crucible in a mixer slot at the end of the chain you want
|
||||
to resample. Keep the font license with redistributed packages.
|
||||
|
||||
## Work with it
|
||||
|
||||
1. Play the pattern or song section.
|
||||
2. Choose **Bars** (1–16) and press **Capture**. Crucible takes the newest complete
|
||||
bars on FL's bar grid. With the transport stopped, it takes the newest audio at
|
||||
the last known tempo instead.
|
||||
3. Choose how to slice: **Grid** (1/4, 1/8, 1/16, 1/8T, 1/16T) or **Transients**
|
||||
with a Sensitivity. Double-click the waveform to add a cut, right-click to remove
|
||||
the nearest one.
|
||||
4. Click a slice to hear it. Untick **Export** to leave it out.
|
||||
5. Press **Export**.
|
||||
|
||||
Each export creates a folder named after the date, time and tempo, holding:
|
||||
|
||||
- `loop.wav`: the whole take, for Slicex or Edison;
|
||||
- `s01_F1.wav`, `s02_G#1.wav`, …: one file per slice, named by its detected root
|
||||
note. The root is also stored in the file's sampler chunk;
|
||||
- `wavetable.wav` when **Wavetable** is on: up to 256 single-cycle frames of 2048
|
||||
samples from the selected slice. Each frame starts on a rising zero crossing, so
|
||||
frames morph without phase jumps. Serum reads the frame size from the file; in
|
||||
Vital, import it as a wavetable with 2048-sample frames.
|
||||
|
||||
Every slice is cleaned on export: **Trim silence** removes leading silence below
|
||||
−60 dBFS, **Declick** fades both ends (default 2 ms), **Normalize** peaks it at
|
||||
−0.3 dBFS. Files are 32-bit float WAV at the session rate.
|
||||
|
||||
Add the export folder to FL's browser once (Options → File settings → Browser extra
|
||||
search folders). New exports then appear there and can be dragged into channels,
|
||||
the playlist or Edison. **Open folder** shows the latest export in Explorer.
|
||||
|
||||
## Timing and limits
|
||||
|
||||
Crucible adds no latency and passes audio through untouched. Auditioned slices are
|
||||
mixed into its output only while they play. The history holds 72 s, enough for
|
||||
16 bars at 60 BPM. The newest eight takes stay in memory until the plugin is
|
||||
removed; they are not saved with the project, so export what you want to keep.
|
||||
|
||||
Bar alignment follows the host's song position and time signature. Root notes are
|
||||
detected between 35 Hz and 1 kHz. Unpitched slices, such as drums or noise, get
|
||||
no note in their name. Transient slicing finds hits that rise out of quieter
|
||||
audio; for legato basses, slice on the grid.
|
||||
|
After Width: | Height: | Size: 220 KiB |
@@ -12,7 +12,9 @@ licenses. Shared build/check/release tools consume it. Each wrapper's Cargo
|
||||
package version is the authoritative release version.
|
||||
|
||||
The root workspace contains Rivet's core, MIDI, application, and offline preview
|
||||
crates, plus Patina's core, editor, and renderer. Wrappers under
|
||||
crates; Patina's core, editor, and renderer; Anvil's and Crucible's core and
|
||||
editor crates; and the shared `common-ui` (palette, fonts, knobs, panels) and
|
||||
`common-dsp` (filters, YIN pitch detection, WAV files) crates. Wrappers under
|
||||
`plugins/<product>/plugin/` remain independent workspaces because they depend on
|
||||
a pinned NIH-plug Git revision. Their host identities and persistence stay separate.
|
||||
|
||||
@@ -23,6 +25,8 @@ cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
python scripts/check-plugin.py --plugin rivet
|
||||
python scripts/check-plugin.py --plugin patina
|
||||
python scripts/check-plugin.py --plugin anvil
|
||||
python scripts/check-plugin.py --plugin crucible
|
||||
python -m unittest discover -s scripts/tests -v
|
||||
```
|
||||
|
||||
@@ -43,12 +47,14 @@ install the Rust `x86_64-pc-windows-gnu` target and MinGW's
|
||||
```sh
|
||||
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin anvil --target x86_64-pc-windows-gnu
|
||||
python scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
|
||||
python scripts/package-windows.py
|
||||
python scripts/package-windows.py --plugin rivet
|
||||
python scripts/package-windows.py --plugin patina
|
||||
```
|
||||
|
||||
Build outputs use `dist/<target>/Rivet.clap` and `Patina.clap`, with docs and
|
||||
Build outputs use `dist/<target>/<Name>.clap`, with docs and
|
||||
licenses retaining their directory structure. `CARGO_TARGET_DIR` is supported
|
||||
for sharing compilation between wrappers. MSVC packaging accepts
|
||||
`--target x86_64-pc-windows-msvc`.
|
||||
@@ -69,6 +75,8 @@ cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet.png --plug
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet-creative.png --plugin rivet --creative
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina.png --plugin patina
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina-compact.png --plugin patina --compact
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/anvil.png --plugin anvil
|
||||
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/crucible.png --plugin crucible
|
||||
```
|
||||
|
||||
Patina also accepts `--silent`, `--delta`, and `--bypass` as render fixtures.
|
||||
@@ -82,6 +90,8 @@ artistic quality. Use the product guides for FL Studio acceptance checks.
|
||||
| --- | --- | --- |
|
||||
| Rivet | Motif Storyboard | `io.storyboard.motif` |
|
||||
| Patina | Resonance Compressor | `io.storyboard.resonance-compressor` |
|
||||
| Anvil | (new) | `io.storyboard.sub-anchor` |
|
||||
| Crucible | (new) | `io.storyboard.resampler` |
|
||||
|
||||
Rivet retains `story-session-v1` and its session migration support; Patina retains
|
||||
all parameter IDs. No JSON/state migration is introduced by the rename. These
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Patina changelog
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Rebuild the detector so it finds resonant bumps instead of cutting individual
|
||||
harmonics: band-averaged power, temporal smoothing, a harmonic-top envelope,
|
||||
and a robust tilt-aware reference.
|
||||
- Replace six overlapping controls with Depth, Sharpness, and Selectivity.
|
||||
Focus points now read the same way everywhere: up cuts more, down protects.
|
||||
- Halve the analysis window to 2048 samples at 44.1/48 kHz (about 43 ms delay).
|
||||
- Show the detector envelope and reference on the plot.
|
||||
- New dark metallic look with a single accent colour, shared with Rivet.
|
||||
- Breaking: projects saved with 0.1 load the new controls at their defaults.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Introduce Patina, formerly Resonance Compressor, as an independent mixing effect.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Patina
|
||||
|
||||
Patina smooths a sound’s rough surface without erasing its character. Formerly
|
||||
**Resonance Compressor**; current version **0.1.0**.
|
||||
**Resonance Compressor**; current version **0.2.0**.
|
||||
|
||||
A mixing effect for controlling local spectral peaks while retaining the intended
|
||||
sound. Independently designed in Rust; no claim of matching Soothe or Quance.
|
||||
A mixing effect that finds harsh, ringing regions as they happen and pulls them
|
||||
down, while plain harmonic tone passes. Independently designed in Rust; no claim
|
||||
of matching Soothe or Quance.
|
||||
|
||||

|
||||
|
||||
@@ -19,40 +20,60 @@ redistributed packages.
|
||||
|
||||
## Work with it
|
||||
|
||||
Loop a troublesome passage in context. Restrict Low/High frequency, then adjust
|
||||
Amount and Max cut. Sensitivity is a prominence threshold: lower catches more.
|
||||
Neighborhood defines the spectral reference; Cut width smooths the attenuation.
|
||||
Attack and Release smooth gain changes; Transient guard briefly relaxes cuts.
|
||||
Three controls decide what happens:
|
||||
|
||||
Double-click the plot to add one of four focus points. Positive sensitivity
|
||||
makes the detector more eager; negative values protect the region by raising
|
||||
the threshold. Drag a point, wheel to change width, or use its numeric fields.
|
||||
Range boundary handles constrain where processing may act. Focus is detector
|
||||
weighting, not a static EQ boost and not an absolute protection guarantee.
|
||||
- **Depth** is the largest cut, in dB. 0 dB does nothing.
|
||||
- **Sharpness** sets what counts as a resonance. Low values follow broad,
|
||||
smooth bumps (honk, boxiness). High values target narrow rings and whistles.
|
||||
- **Selectivity** sets how far a bump must stand out before it is cut. Low
|
||||
values treat any bump; high values treat only the worst.
|
||||
|
||||
Start with Depth around 6 dB. Raise Selectivity until only the harsh parts show
|
||||
cuts, then use Delta to hear what is removed. Restrict Low/High frequency to keep
|
||||
the source’s body out of reach. Attack and Release set how quickly cuts grow and
|
||||
recover; Transient guard briefly relaxes cuts on attacks to keep punch.
|
||||
|
||||
Double-click the plot to add one of four focus points. Drag a point up to cut
|
||||
more in that region, down to protect it. Wheel changes its width. Range handles
|
||||
on the plot drag the processing limits.
|
||||
|
||||
On the plot, the grey fill is the measured input. The bright line is what the
|
||||
detector hears (its smoothed spectral envelope); the dim line is what it expects
|
||||
there. Accent-coloured cuts appear where the bright line rises above the dim one
|
||||
by more than Selectivity allows. The blue curve is the focus/protect shape.
|
||||
|
||||
Cyan shows spectral input energy, coral the attenuation, violet detector focus.
|
||||
The left scale is calibrated per-bin spectral power; the right scale is cut dB.
|
||||
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
|
||||
then return to normal monitoring to decide whether the music improved.
|
||||
Mix is a linear blend of aligned signals. Bypass keeps the same latency.
|
||||
Use Output for matched-level comparison. Spectral attenuation is not a limiter:
|
||||
sample peaks can increase even when frequency gains never exceed unity.
|
||||
|
||||
Four starting presets and A/B comparison help explore alternatives. All controls
|
||||
are visible. The instrument-style sidebar holds Amount, Mix, and Output; the
|
||||
spectrum includes filled energy traces, cut overlays, meter rails, and range
|
||||
handles, using the plugin’s cyan/coral/violet palette. Host parameters and window
|
||||
size persist; Delta does not. The editor supports numeric entry, keyboard focus, and direct graph editing.
|
||||
Four presets and A/B comparison help explore alternatives. Host parameters and
|
||||
window size persist; Delta does not.
|
||||
|
||||
## How detection works
|
||||
|
||||
1. Each analysis frame is grouped into 512 log-spaced bands. Each band holds the
|
||||
mean power of the FFT bins it covers, smoothed over time (about 5 ms rise,
|
||||
40 ms fall) so cuts hold on a stable ring instead of flickering.
|
||||
2. Neighbouring peaks are joined in dB, so the gaps between harmonics do not count
|
||||
as the expected level. A plain harmonic tone reads as a smooth envelope.
|
||||
3. The envelope is smoothed across frequency by the Sharpness width.
|
||||
4. The reference is a robust local straight-line fit over 2–3 octaves: spectral
|
||||
tilt is expected, bumps cannot raise their own reference, and empty regions
|
||||
(below a fundamental, beyond a filter cutoff) are ignored.
|
||||
5. Envelope minus reference, above the Selectivity threshold, becomes the cut,
|
||||
limited by Depth, smoothed with Attack/Release, and linked across stereo.
|
||||
|
||||
## Timing and limits
|
||||
|
||||
Mixing only: 4096-sample analysis windows at 44.1/48 kHz, 8192 at 88.2/96 kHz,
|
||||
16384 at 176.4/192 kHz, with eight overlaps. Delay is one window, about 85–93 ms.
|
||||
Mixing only: 2048-sample analysis windows at 44.1/48 kHz, 4096 at 88.2/96 kHz,
|
||||
8192 at 176.4/192 kHz, with eight overlaps. Delay is one window, about 43–46 ms.
|
||||
The plugin reports it for host compensation. Supported layouts are mono and
|
||||
linked stereo; supported rates are those six standard rates.
|
||||
|
||||
The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstruction,
|
||||
a log-frequency prominence detector, bounded soft-knee cuts, and temporal/frequency
|
||||
the envelope/reference detector above, bounded soft-knee cuts, and temporal
|
||||
smoothing. Original bin phases are retained. This is not a minimum-phase filter
|
||||
bank, a live monitoring effect, a limiter, or an automatic separation of tone and
|
||||
unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1.
|
||||
@@ -88,15 +109,17 @@ Judge through real patches with matched listening levels: preserve vowel motion,
|
||||
snare punch, bass body, and stereo placement. Synthetic tests do not establish
|
||||
artistic usefulness.
|
||||
|
||||
Local benchmark: AMD Ryzen 7 PRO 3700U, optimized build, stereo 48 kHz, FFT
|
||||
4096/hop 512: five seconds processed in 0.233 seconds (about 4.7% of one
|
||||
core in that run). This is not a worst-case multi-instance guarantee.
|
||||
Local benchmark, optimized build, stereo 48 kHz, FFT 2048/hop 256: five seconds
|
||||
processed in about 0.35–0.45 seconds (7–9% of one core on a laptop CPU). This is
|
||||
not a worst-case multi-instance guarantee.
|
||||
|
||||
|
||||
## Existing projects and source layout
|
||||
|
||||
The CLAP ID `io.storyboard.resonance-compressor`, parameter IDs, and state
|
||||
format are unchanged. Replace the former plugin file with `Patina.clap` and
|
||||
The CLAP ID `io.storyboard.resonance-compressor` is unchanged. Version 0.2
|
||||
replaces Amount, Sensitivity, Max cut, Neighborhood, Cut width, and Knee with
|
||||
Depth, Sharpness, and Selectivity, so projects saved with 0.1 load those
|
||||
controls at their defaults. Replace the former plugin file with `Patina.clap` and
|
||||
rescan; avoid two scanned copies with the same ID. Host project recall still
|
||||
needs verification in FL Studio.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 415 KiB |
@@ -64,6 +64,24 @@ Node runtime, and a local composite action installs Rust and build dependencies.
|
||||
Windows binaries are cross-compiled with MinGW; no Windows runner or Linux release
|
||||
binary is required.
|
||||
|
||||
### Build cache
|
||||
|
||||
Every Rust job restores two caches through the runner's cache server
|
||||
(`actions/cache@v4`, run from the setup composite action):
|
||||
|
||||
- **Toolchain:** rustup's stable toolchain, Cargo's binaries, and the registry and
|
||||
git sources, keyed by ISO week, Windows target, and all three `Cargo.lock` files.
|
||||
- **Build directories:** each job's target directories (check per plugin,
|
||||
previews, Windows preview, release per plugin), keyed the same way.
|
||||
|
||||
A new week or a changed lockfile starts a fresh entry, so stale build output does
|
||||
not accumulate and the stable toolchain refreshes weekly. Within a week, pushes
|
||||
reuse compiled dependencies and rebuild only this repository's crates. Release
|
||||
jobs use their own entries and never reuse branch build output. A cache miss only
|
||||
costs time. The runner's cache server must be enabled (`cache.enabled` in
|
||||
`act_runner`'s config, the default) and must support post steps of actions
|
||||
inside composite actions (current `act_runner` releases do).
|
||||
|
||||
The workflows use explicit action URLs and the v3-compatible artifact protocol.
|
||||
Only the publish job requests `contents: write`. It authenticates against the
|
||||
same Gitea instance using the automatic `secrets.GITEA_TOKEN`; no personal access
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Rivet changelog
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Storyboard: a timeline of blocks with roles (statement, answer, departure,
|
||||
return, rest). Rivet writes each block from a motif and joins the seams with a
|
||||
cut, breath, pickup or approach. Templates start a storyboard in one click.
|
||||
- Flexible lengths: motifs of 1–8 bars, storyboards up to 32 bars, and the
|
||||
meters 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 and 12/8 for import, writing and export.
|
||||
- A motif bin holds up to 16 motifs; locks live on each motif's notes.
|
||||
- Per-block options with Another take and Keep replace proposals and pins.
|
||||
- Undo/redo for every edit (32 steps) replaces kept history and branches.
|
||||
- New editor: motif bin, timeline, one large piano roll, inspector, and tabs
|
||||
for generation, harmony and sound roles. Dark metallic look shared with Patina.
|
||||
- Three more scales: Phrygian, harmonic minor and Mixolydian.
|
||||
- Queued auditions switch at the next bar line of the session's meter.
|
||||
- Sessions from 0.1–0.3 open as a storyboard built from their source motif.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Introduce the Rivet name and a dedicated product source/documentation tree.
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
# Rivet
|
||||
|
||||
Rivet joins short motifs into connected musical phrases, like a fastener joining
|
||||
individual pieces. Formerly **Motif Storyboard**; current version **0.3.0**.
|
||||
individual pieces. Formerly **Motif Storyboard**; current version **0.4.0**.
|
||||
|
||||
|
||||
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.
|
||||
A Rust composition companion for a producer developing their own melodic ideas.
|
||||
Version 0.4 is built around a **storyboard**: a timeline of blocks that tell one
|
||||
short idea as a story. Each block has a role (statement, answer, departure,
|
||||
return, rest) and a length; Rivet writes each block from your motif and joins the
|
||||
seams so the result plays as one phrase.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
1. **Bring in a motif.** Import a monophonic MIDI clip of one to eight bars, or
|
||||
press **Demo**. The bin on the left holds up to 16 motifs. When the bin is
|
||||
empty, the file's time signature and tempo become the session's.
|
||||
2. **Lock what defines it.** Select the motif; the big roll shows it. Click notes
|
||||
to lock the whole note, its pitch only, or its rhythm only. Locked notes
|
||||
survive into every block.
|
||||
3. **Lay out the story.** A new motif starts as an arc: statement, answer,
|
||||
departure, return. **Build from this motif** applies other templates
|
||||
(question and answer, breathe with rests, build with two departures).
|
||||
**Add block** inserts any role after the selected block. Blocks are 1–8 bars;
|
||||
a storyboard holds up to 32 bars in 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 or 12/8.
|
||||
4. **Shape each block.** Click a block in the timeline or the roll. The inspector
|
||||
on the right sets its role, motif, length and how it hands over to the next
|
||||
block. It also lists up to three options written for that role; click one to
|
||||
use it, or **Audition** it alone. **Another take** writes three new options
|
||||
for that block only. **Keep** freezes a block so later changes to key, reach
|
||||
or harmony leave it alone.
|
||||
5. **Join the seams.** Click the small circle between two blocks to cycle its
|
||||
join: **Cut** (as written), **Breath** (the last note ends early to leave
|
||||
air), **Pickup** (two short notes lead into the next block) or **Approach**
|
||||
(the last note steps next to the next block's first note).
|
||||
6. **Play and export.** **Play** (or Space) loops the whole storyboard. While it
|
||||
plays, edits are heard from the next bar line. **Export MIDI…** writes the
|
||||
storyboard to drag into FL Studio. **Undo**/**Redo** (Ctrl+Z, Ctrl+Shift+Z)
|
||||
step back through the last 32 edits.
|
||||
|
||||
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.
|
||||
The tabs under the roll hold the less frequent settings:
|
||||
|
||||
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.
|
||||
- **Generation**: reach (how far options move from the motif), keep groove,
|
||||
keep contour, a settled or open ending for the last block, the register, the
|
||||
seed, and the audition output.
|
||||
- **Harmony**: import a chord or bass MIDI track, or start from an editable
|
||||
progression. Harmony guides strong notes and arrivals; audition plays the
|
||||
melody only. Imported voicings keep their pitch classes; Rivet does not guess
|
||||
chord names.
|
||||
- **Sound roles**: split the phrase between two synth parts (see below).
|
||||
|
||||
Options come from eight transformation families: displaced groove, fragment,
|
||||
room to breathe, short call and long answer, familiar return, changed ending,
|
||||
higher peak and held back. Each role prefers the families that fit it, and the
|
||||
statement always offers the motif as written. Options are deterministic for a
|
||||
given seed, take and position; they are suggestions, not promises of an emotion.
|
||||
|
||||
## Audition and commit
|
||||
|
||||
@@ -54,8 +63,7 @@ proposals while retaining kept work.
|
||||
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,
|
||||
running, another selection waits until the next bar line. 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.
|
||||
|
||||
@@ -66,15 +74,7 @@ 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](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
|
||||
Open **Sound roles** 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
|
||||
@@ -91,12 +91,12 @@ 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](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.
|
||||
Sessions save inside the host project and can be saved/loaded as JSON. Sessions
|
||||
from versions 0.1–0.3 open as a storyboard: the old source motif becomes the
|
||||
first motif in the bin, a kept phrase and saved gestures follow it, and an
|
||||
eight-bar phrase becomes an arc (a four-bar phrase becomes statement and return).
|
||||
Branch history is not carried over; undo replaces it. JSON state is bounded at
|
||||
16 MiB. File export/save refuses to silently overwrite an existing file.
|
||||
|
||||
## Build and offline preview
|
||||
|
||||
@@ -110,8 +110,9 @@ cargo run -p rivet-preview -- session saved-session.json /tmp/rendered-session
|
||||
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
The preview writes source/candidate MIDI, a session JSON, and WAV using the same
|
||||
engine as the plugin. Track indices exclude conductor-only tracks and start at
|
||||
The preview builds an arc storyboard from the motif and writes the motif and
|
||||
storyboard as MIDI, a session JSON, a WAV render using the same engine as the
|
||||
plugin, and a table of blocks with their chosen options. Track indices exclude conductor-only tracks and start at
|
||||
zero. Optional harmony arguments accept a separate polyphonic MIDI file.
|
||||
See [development](../development.md) for checks, dependencies, architecture, and
|
||||
[releases](../releases.md) for independent release tags.
|
||||
@@ -124,8 +125,9 @@ choices. [Devath reference measurements](research/devath.md) document
|
||||
analysis of the seven supplied FLACs, with reproducible plots and explicit limits.
|
||||
Original reference audio is excluded from source distribution.
|
||||
|
||||
- `rivet-core`: musical model, harmonic context, structured proposals, section
|
||||
composition, session migration, and bounded playback clips.
|
||||
- `rivet-core`: meters, motifs, the storyboard (blocks, roles, joins, templates),
|
||||
role-aware option writing, harmonic context, session migration, and bounded
|
||||
playback clips.
|
||||
- `rivet-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;
|
||||
@@ -134,7 +136,8 @@ Original reference audio is excluded from source distribution.
|
||||
preview/MIDI scheduling, and Windows/Zenity dialogs.
|
||||
- `rivet`: CLAP wrapper, host persistence, editor, and standalone.
|
||||
|
||||
Tests cover musical constraints/diversity, harmony, legacy state, section seams,
|
||||
Tests cover musical constraints/diversity, harmony, every meter, storyboard
|
||||
limits, joins, kept blocks, legacy state migration,
|
||||
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,
|
||||
|
||||
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 727 KiB |
|
Before Width: | Height: | Size: 654 KiB After Width: | Height: | Size: 715 KiB |
@@ -18,21 +18,23 @@ against FL Studio in this environment.
|
||||
|
||||
- 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.
|
||||
- Load the demo. The storyboard must show statement, answer, departure and
|
||||
return; Play must loop all eight bars. The statement should quote the motif.
|
||||
- Lock a motif note and confirm it appears unchanged (pitch, start, length) in
|
||||
every block that develops the motif. Locks may override register/scale.
|
||||
- Change a block's role, length (1–8 bars) and motif; try Another take and Keep.
|
||||
Change the key: kept blocks must not change, the others must.
|
||||
- Cycle each seam through Cut, Breath, Pickup and Approach and listen at the seam.
|
||||
- Try 3/4, 6/8 and 12/8, all scales, and storyboards up to 32 bars.
|
||||
- Undo and redo a series of edits, including a template rebuild and a meter change.
|
||||
- Export the storyboard. Import it into the piano roll of an existing synth and
|
||||
compare note timing, duration, velocity, length and time signature.
|
||||
|
||||
## 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,
|
||||
- Verify useful errors for chords/overlaps, motifs longer than eight bars, a full bin,
|
||||
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.
|
||||
@@ -46,7 +48,7 @@ against FL Studio in this environment.
|
||||
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.
|
||||
- Save a project containing locks, several motifs, kept blocks and joins.
|
||||
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.
|
||||
@@ -64,7 +66,7 @@ piano roll. Native MIDI drag-out is outside this release.
|
||||
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.
|
||||
Switching a running audition waits for the next bar line.
|
||||
- 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.
|
||||
@@ -73,22 +75,14 @@ piano roll. Native MIDI drag-out is outside this release.
|
||||
- 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.
|
||||
- Restore a version-1 session and a version-2/3 project with harmony, pins and
|
||||
kept history: each must open as a storyboard built from its source motif.
|
||||
- Evaluate artistic usefulness through the real patch: can the producer choose
|
||||
a meaningful alternative, retain just its useful section, and make it his own?
|
||||
a meaningful option per block, keep just the useful ones, and make it their own?
|
||||
Automated diversity metrics do not replace that evaluation.
|
||||
|
||||
## Version 0.3: gestures, branches, roles, and controllers
|
||||
## Version 0.3: sound 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
|
||||
@@ -109,6 +103,6 @@ piano roll. Native MIDI drag-out is outside this release.
|
||||
- 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.
|
||||
- Evaluate whether the storyboard still feels like the same idea with another
|
||||
patch.
|
||||
The internal voices and the automated checks cannot establish artistic quality.
|
||||
@@ -1,54 +1,64 @@
|
||||
# Rivet 0.2 implementation plan
|
||||
# Rivet 0.4 implementation notes
|
||||
|
||||
## 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.
|
||||
Tell a producer's own motif as a story. The unit of work is a storyboard of
|
||||
blocks; each block has a role that says what it does for the story, and Rivet
|
||||
writes the notes for that role from the motif. 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.
|
||||
## Model
|
||||
- **Meter.** One time signature per session: 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 or
|
||||
12/8. Bar length is `PPQ * 4 * beats / unit`. Compound meters (6/8, 12/8) pulse
|
||||
in dotted quarters; strong positions, pickups and breaths use that pulse.
|
||||
- **Motif.** A monophonic phrase of 1–8 whole bars with per-note locks (whole
|
||||
note, pitch, rhythm). The bin holds 16 motifs.
|
||||
- **Block.** Motif, role, 1–8 bars, the join into the next block, a take number,
|
||||
up to three realized options, the chosen option, and a keep flag. A storyboard
|
||||
holds 32 blocks and 32 bars.
|
||||
- **Roles.** Statement, Answer, Departure, Return and Rest. Each role ranks the
|
||||
transformation families that suit it (statement: quote, displaced groove,
|
||||
changed ending; answer: call/response, changed ending, held back; departure:
|
||||
higher peak, displaced groove, fragment; return: familiar return, quote, room
|
||||
to breathe). The quote is always in the pool, so a statement can play the
|
||||
motif as written. Rest yields one silent option.
|
||||
- **Realizing a block.** The motif is tiled to the block length (notes crossing
|
||||
the end are shortened, or dropped when rhythm-locked), then developed. The seed
|
||||
mixes the session seed, the block's take and its position, so options are
|
||||
deterministic and differ between blocks. Harmony is read at the block's
|
||||
absolute position. The last sounding block follows the ending setting; other
|
||||
statements prefer an open last note.
|
||||
- **Joins.** Applied when the storyboard is composed, never stored in options.
|
||||
Breath ends the last note at least half a beat before the seam. Approach moves
|
||||
the last note one scale step next to the following first note. Pickup writes
|
||||
two half-beat notes in the last beat that move toward the following first note;
|
||||
it is skipped when anchors occupy that beat. Defaults follow the roles: into a
|
||||
departure a pickup, into a return an approach, around rests a cut, otherwise a
|
||||
breath.
|
||||
- **Keep.** Kept blocks are not rewritten when settings, harmony or neighbours
|
||||
change, unless their stored options no longer fit (for example after a meter
|
||||
change).
|
||||
|
||||
## 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.
|
||||
## Editor
|
||||
Header (key, scale, meter, tempo, play, loop, undo/redo, export), motif bin,
|
||||
timeline with role-coloured blocks and clickable seams, one large roll (the
|
||||
storyboard, or the selected motif for lock editing), inspector (block or motif),
|
||||
and tabs for generation, harmony and sound roles. Edits are synchronous; file
|
||||
dialogs run on a worker. Undo keeps the last 32 session states in the editor;
|
||||
continuous drags and typing collapse into one step. While the storyboard plays,
|
||||
an edit queues the new composition for the next bar line.
|
||||
|
||||
## 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
|
||||
State version 4. Versions 1–3 are read leniently and upgraded: the source motif,
|
||||
a kept phrase and saved gestures enter the bin; an eight-bar phrase becomes an
|
||||
arc, a four-bar phrase statement and return. Branch history is dropped. Future
|
||||
versions are rejected without changing the current session. The CLAP ID and the
|
||||
`story-session-v1` host field are unchanged.
|
||||
|
||||
## 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.
|
||||
## Verification
|
||||
Core tests cover role options and diversity, locks, a fully locked motif,
|
||||
harmony and endings, all meters, keys and lengths, templates, limits, joins,
|
||||
kept blocks, compound meters, motif removal, and v1/v3 migration. MIDI tests
|
||||
cover time signatures and compound-meter round trips. App tests cover queued
|
||||
switches at the meter's bar line. The CLAP ABI test restores a 6/8 storyboard
|
||||
session through host state. FL Studio routing and musical usefulness still need
|
||||
checks in the host (see [FL Studio](fl-studio.md)).
|
||||
@@ -35,12 +35,14 @@ 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)
|
||||
## Implemented creative model (0.4)
|
||||
|
||||
- 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.
|
||||
- A storyboard tells one motif through roles: statement (recognition), answer
|
||||
(reply), departure (contrast: register, rhythm, fragments), return (arrival),
|
||||
and rest (space). This puts the recognition/space/contrast/return findings
|
||||
directly on the timeline instead of hiding them in a task menu.
|
||||
- Seams between blocks carry explicit joins (breath, pickup, approach), so
|
||||
connection is a visible decision rather than an accident of concatenation.
|
||||
- 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.
|
||||
@@ -56,9 +58,10 @@ They do not identify exact notes, chords, emotional meaning, or melodic roles.
|
||||
|
||||
## 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.
|
||||
Import a motif -> lock what defines it -> lay out roles on the storyboard ->
|
||||
pick an option per block and set the joins -> keep what works, take again where
|
||||
it does not -> distribute sound roles and map controllers -> audition through
|
||||
real patches -> export.
|
||||
Compare alternatives against the same surrounding loop and listening level.
|
||||
The useful outcome is a section the producer can own and develop further.
|
||||
|
||||
@@ -68,12 +71,10 @@ universal quality scoring, or a verified melodic transcription of Devath's track
|
||||
|
||||
## 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.
|
||||
A motif is 1–8 bars; a storyboard is up to 32 blocks and 32 bars in one of
|
||||
seven meters. The bin holds 16 motifs. Each block stores up to three options.
|
||||
Sessions from versions 1–3 migrate to version 4; controller output stays
|
||||
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.
|
||||
@@ -85,7 +86,7 @@ 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
|
||||
checks cover storyboard limits and joins, 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.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "anvil-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,596 @@
|
||||
//! Sub anchor: replaces the band below the split with a clean mono sine that follows the
|
||||
//! bass pitch, holds one level for every note and restarts at the same phase on each note.
|
||||
//!
|
||||
//! The audio path is delayed by a fixed lookahead. Detection runs on the undelayed input, so
|
||||
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
|
||||
//! exactly on that attack.
|
||||
mod settings;
|
||||
use common_dsp::{
|
||||
coefficient, db_to_gain, gain_to_db, hz_to_midi, midi_to_hz, Biquad, Crossover, Yin,
|
||||
};
|
||||
pub use settings::{Settings, Source};
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Lowest detectable fundamental. Sets the lookahead (two periods).
|
||||
pub const MIN_HZ: f32 = 25.;
|
||||
pub const MAX_HZ: f32 = 250.;
|
||||
pub const SCOPE: usize = 256;
|
||||
const EVENTS: usize = 64;
|
||||
const DETECT_RATE: f32 = 3000.;
|
||||
const HOP_MS: f32 = 5.;
|
||||
const ATTACK_MS: f32 = 2.;
|
||||
const RELEASE_MS: f32 = 15.;
|
||||
const CROSSFADE_MS: f32 = 3.;
|
||||
const GATE_HOLD_MS: f32 = 20.;
|
||||
const GATE_HYSTERESIS_DB: f32 = 6.;
|
||||
/// A pitch move this large (semitones) counts as a new note.
|
||||
const JUMP: f32 = 0.7;
|
||||
const CLARITY: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Monitor {
|
||||
/// Hear only the generated sub.
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AnalyzerFrame {
|
||||
/// Current sub frequency in Hz, 0 when silent.
|
||||
pub hz: f32,
|
||||
/// MIDI note of the current sub, rounded.
|
||||
pub note: Option<u8>,
|
||||
pub low_db: f32,
|
||||
pub sub_db: f32,
|
||||
/// Original low band and new sub, triggered on a rising zero crossing of the sub.
|
||||
pub scope_low: [f32; SCOPE],
|
||||
pub scope_sub: [f32; SCOPE],
|
||||
/// Duration of the scope window in milliseconds.
|
||||
pub scope_ms: f32,
|
||||
pub latency: u32,
|
||||
pub sample_rate: f32,
|
||||
}
|
||||
impl Default for AnalyzerFrame {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hz: 0.,
|
||||
note: None,
|
||||
low_db: -120.,
|
||||
sub_db: -120.,
|
||||
scope_low: [0.; SCOPE],
|
||||
scope_sub: [0.; SCOPE],
|
||||
scope_ms: 60.,
|
||||
latency: 0,
|
||||
sample_rate: 48000.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Onset,
|
||||
Release,
|
||||
NoteOn(u8),
|
||||
NoteOff(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Event {
|
||||
time: u64,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Voice {
|
||||
phase: f32,
|
||||
hz: f32,
|
||||
target: f32,
|
||||
amp: f32,
|
||||
releasing: bool,
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
rate: f32,
|
||||
channels: usize,
|
||||
settings: Settings,
|
||||
latency: usize,
|
||||
delay: Vec<[f32; 2]>,
|
||||
delay_at: usize,
|
||||
crossover: [Crossover; 2],
|
||||
detect: [Biquad; 2],
|
||||
decimate: usize,
|
||||
decimate_count: usize,
|
||||
detect_rate: f32,
|
||||
ring: Vec<f32>,
|
||||
ring_at: usize,
|
||||
scratch: Vec<f32>,
|
||||
yin: Yin,
|
||||
hop: usize,
|
||||
hop_count: usize,
|
||||
gate_env: f32,
|
||||
gate_release: f32,
|
||||
gate_open: bool,
|
||||
gate_hold: usize,
|
||||
clock: u64,
|
||||
events: [Event; EVENTS],
|
||||
event_head: usize,
|
||||
event_len: usize,
|
||||
/// Output-time note state.
|
||||
sounding: bool,
|
||||
pending: bool,
|
||||
tracked: f32,
|
||||
candidate: Option<(f32, u8)>,
|
||||
voice: Voice,
|
||||
old: Voice,
|
||||
old_gain: f32,
|
||||
glide: f32,
|
||||
held: [u8; 16],
|
||||
held_len: usize,
|
||||
low_env: f32,
|
||||
note_peak: f32,
|
||||
env_coef: f32,
|
||||
smooth: f32,
|
||||
level: f32,
|
||||
keep: f32,
|
||||
output: f32,
|
||||
bypass: f32,
|
||||
meter_coef: f32,
|
||||
low_power: f32,
|
||||
sub_power: f32,
|
||||
scope_low: [f32; 2 * SCOPE],
|
||||
scope_sub: [f32; 2 * SCOPE],
|
||||
scope_at: usize,
|
||||
scope_stride: usize,
|
||||
scope_count: usize,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
|
||||
if !(8000.0..=768000.).contains(&rate) {
|
||||
return Err("unsupported sample rate");
|
||||
}
|
||||
if !(1..=2).contains(&channels) {
|
||||
return Err("unsupported channel count");
|
||||
}
|
||||
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
|
||||
let detect_rate = rate / decimate as f32;
|
||||
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
|
||||
// YIN needs one comparison window of at least the longest period after the lags.
|
||||
let analysis = 2 * max_lag + 3;
|
||||
let latency = analysis * decimate;
|
||||
let settings = Settings::default();
|
||||
let mut e = Self {
|
||||
rate,
|
||||
channels,
|
||||
settings,
|
||||
latency,
|
||||
delay: vec![[0.; 2]; latency + 1],
|
||||
delay_at: 0,
|
||||
crossover: [Crossover::new(rate, settings.split); 2],
|
||||
detect: [Biquad::lowpass(rate, 400., std::f32::consts::FRAC_1_SQRT_2); 2],
|
||||
decimate,
|
||||
decimate_count: 0,
|
||||
detect_rate,
|
||||
ring: vec![0.; analysis],
|
||||
ring_at: 0,
|
||||
scratch: vec![0.; analysis],
|
||||
yin: Yin::new(max_lag + 2),
|
||||
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
|
||||
hop_count: 0,
|
||||
gate_env: 0.,
|
||||
gate_release: 1. - coefficient(rate, 30.),
|
||||
gate_open: false,
|
||||
gate_hold: 0,
|
||||
clock: 0,
|
||||
events: [Event {
|
||||
time: 0,
|
||||
kind: Kind::Release,
|
||||
}; EVENTS],
|
||||
event_head: 0,
|
||||
event_len: 0,
|
||||
sounding: false,
|
||||
pending: false,
|
||||
tracked: 0.,
|
||||
candidate: None,
|
||||
voice: Voice::default(),
|
||||
old: Voice::default(),
|
||||
old_gain: 0.,
|
||||
glide: 1.,
|
||||
held: [0; 16],
|
||||
held_len: 0,
|
||||
low_env: 0.,
|
||||
note_peak: 0.,
|
||||
env_coef: coefficient(rate, 10.),
|
||||
smooth: coefficient(rate, 10.),
|
||||
level: db_to_gain(settings.level),
|
||||
keep: 0.,
|
||||
output: 1.,
|
||||
bypass: 0.,
|
||||
meter_coef: coefficient(rate, 150.),
|
||||
low_power: 0.,
|
||||
sub_power: 0.,
|
||||
scope_low: [0.; 2 * SCOPE],
|
||||
scope_sub: [0.; 2 * SCOPE],
|
||||
scope_at: 0,
|
||||
scope_stride: ((rate * 0.06) / SCOPE as f32).round().max(1.) as usize,
|
||||
scope_count: 0,
|
||||
};
|
||||
e.set_settings(settings)?;
|
||||
Ok(e)
|
||||
}
|
||||
pub fn latency_samples(&self) -> u32 {
|
||||
self.latency as u32
|
||||
}
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels
|
||||
}
|
||||
pub fn settings(&self) -> Settings {
|
||||
self.settings
|
||||
}
|
||||
pub fn set_settings(&mut self, s: Settings) -> Result<(), &'static str> {
|
||||
s.validate()?;
|
||||
let old = self.settings;
|
||||
if (s.split - old.split).abs() > 1e-3 {
|
||||
for x in &mut self.crossover {
|
||||
x.retune(self.rate, s.split);
|
||||
}
|
||||
}
|
||||
if s.source != old.source {
|
||||
self.release_all();
|
||||
}
|
||||
self.glide = if s.glide <= 0. {
|
||||
1.
|
||||
} else {
|
||||
coefficient(self.rate, s.glide / 3.)
|
||||
};
|
||||
self.settings = s;
|
||||
Ok(())
|
||||
}
|
||||
fn release_all(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.held_len = 0;
|
||||
self.event_len = 0;
|
||||
self.candidate = None;
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.delay.fill([0.; 2]);
|
||||
for x in &mut self.crossover {
|
||||
x.reset();
|
||||
}
|
||||
for f in &mut self.detect {
|
||||
f.reset();
|
||||
}
|
||||
self.ring.fill(0.);
|
||||
self.decimate_count = 0;
|
||||
self.hop_count = 0;
|
||||
self.gate_env = 0.;
|
||||
self.gate_open = false;
|
||||
self.gate_hold = 0;
|
||||
self.release_all();
|
||||
self.voice = Voice::default();
|
||||
self.old = Voice::default();
|
||||
self.old_gain = 0.;
|
||||
self.low_env = 0.;
|
||||
self.note_peak = 0.;
|
||||
self.low_power = 0.;
|
||||
self.sub_power = 0.;
|
||||
self.scope_low = [0.; 2 * SCOPE];
|
||||
self.scope_sub = [0.; 2 * SCOPE];
|
||||
}
|
||||
/// MIDI note start at the current input sample. Used when the source is MIDI.
|
||||
pub fn note_on(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOn(note.min(127)));
|
||||
}
|
||||
pub fn note_off(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOff(note.min(127)));
|
||||
}
|
||||
fn push(&mut self, kind: Kind) {
|
||||
if self.event_len == EVENTS {
|
||||
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
}
|
||||
let at = (self.event_head + self.event_len) % EVENTS;
|
||||
self.events[at] = Event {
|
||||
time: self.clock,
|
||||
kind,
|
||||
};
|
||||
self.event_len += 1;
|
||||
}
|
||||
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
|
||||
fn estimate(&mut self) -> Option<f32> {
|
||||
let n = self.ring.len();
|
||||
for i in 0..n {
|
||||
self.scratch[i] = self.ring[(self.ring_at + i) % n];
|
||||
}
|
||||
let (hz, clarity) = self
|
||||
.yin
|
||||
.estimate(&self.scratch, self.detect_rate, MIN_HZ, MAX_HZ)?;
|
||||
(clarity >= CLARITY).then(|| hz_to_midi(hz))
|
||||
}
|
||||
fn start(&mut self, pitch: f32, glide: bool) {
|
||||
let s = self.settings;
|
||||
let note = if s.snap { pitch.round() } else { pitch };
|
||||
let hz = midi_to_hz(note + 12. * s.octave as f32);
|
||||
self.tracked = pitch;
|
||||
self.candidate = None;
|
||||
self.pending = false;
|
||||
self.sounding = true;
|
||||
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
|
||||
if glide && audible {
|
||||
self.voice.target = hz;
|
||||
return;
|
||||
}
|
||||
if s.retrigger || !audible {
|
||||
if self.voice.amp > 1e-4 {
|
||||
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
|
||||
self.old = self.voice;
|
||||
self.old_gain = 1.;
|
||||
}
|
||||
self.voice = Voice {
|
||||
phase: s.phase / 360.,
|
||||
hz,
|
||||
target: hz,
|
||||
amp: 0.,
|
||||
releasing: false,
|
||||
};
|
||||
} else {
|
||||
self.voice.target = hz;
|
||||
self.voice.hz = hz;
|
||||
}
|
||||
self.note_peak = 0.;
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.candidate = None;
|
||||
}
|
||||
fn handle(&mut self, kind: Kind) {
|
||||
let midi = self.settings.source == Source::Midi;
|
||||
match kind {
|
||||
Kind::Onset if !midi => match self.estimate() {
|
||||
Some(p) => self.start(p, false),
|
||||
None => {
|
||||
self.pending = true;
|
||||
self.sounding = true;
|
||||
}
|
||||
},
|
||||
Kind::Release if !midi => self.stop(),
|
||||
Kind::NoteOn(n) if midi => {
|
||||
let len = self.held_len;
|
||||
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
if self.held_len == self.held.len() {
|
||||
self.held.copy_within(1.., 0);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
let legato = self.held_len > 0;
|
||||
self.held[self.held_len] = n;
|
||||
self.held_len += 1;
|
||||
self.start(n as f32, legato && self.settings.glide > 0.);
|
||||
}
|
||||
Kind::NoteOff(n) if midi => {
|
||||
let len = self.held_len;
|
||||
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
|
||||
return;
|
||||
};
|
||||
let top = i + 1 == len;
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
if self.held_len == 0 {
|
||||
self.stop();
|
||||
} else if top {
|
||||
let next = self.held[self.held_len - 1];
|
||||
self.start(next as f32, self.settings.glide > 0.);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Regular analysis; its window starts at the current output time.
|
||||
fn hop_analysis(&mut self) {
|
||||
if self.settings.source != Source::Audio || !self.sounding {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.estimate() else {
|
||||
return;
|
||||
};
|
||||
if self.pending {
|
||||
self.start(p, false);
|
||||
return;
|
||||
}
|
||||
if (p - self.tracked).abs() >= JUMP {
|
||||
match self.candidate {
|
||||
Some((c, count)) if (c - p).abs() < 0.5 => {
|
||||
if count + 1 >= 2 {
|
||||
self.start(p, self.settings.glide > 0.);
|
||||
} else {
|
||||
self.candidate = Some((p, count + 1));
|
||||
}
|
||||
}
|
||||
_ => self.candidate = Some((p, 1)),
|
||||
}
|
||||
} else {
|
||||
self.candidate = None;
|
||||
self.tracked += 0.3 * (p - self.tracked);
|
||||
if !self.settings.snap {
|
||||
self.voice.target = midi_to_hz(self.tracked + 12. * self.settings.octave as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn detect(&mut self, x: [f32; 2]) {
|
||||
let mono = if self.channels == 2 {
|
||||
0.5 * (x[0] + x[1])
|
||||
} else {
|
||||
x[0]
|
||||
};
|
||||
let d = self.detect[0].process(mono);
|
||||
let d = self.detect[1].process(d);
|
||||
if self.settings.source == Source::Audio {
|
||||
let a = d.abs();
|
||||
self.gate_env = if a > self.gate_env {
|
||||
a
|
||||
} else {
|
||||
self.gate_env * self.gate_release
|
||||
};
|
||||
let level = gain_to_db(self.gate_env);
|
||||
if level > self.settings.gate {
|
||||
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
|
||||
if !self.gate_open {
|
||||
self.gate_open = true;
|
||||
self.push(Kind::Onset);
|
||||
}
|
||||
} else if self.gate_open && level < self.settings.gate - GATE_HYSTERESIS_DB {
|
||||
if self.gate_hold == 0 {
|
||||
self.gate_open = false;
|
||||
self.push(Kind::Release);
|
||||
} else {
|
||||
self.gate_hold -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.decimate_count += 1;
|
||||
if self.decimate_count >= self.decimate {
|
||||
self.decimate_count = 0;
|
||||
self.ring[self.ring_at] = d;
|
||||
self.ring_at = (self.ring_at + 1) % self.ring.len();
|
||||
self.hop_count += 1;
|
||||
if self.hop_count >= self.hop {
|
||||
self.hop_count = 0;
|
||||
self.hop_analysis();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn shape(&self, x: f32) -> f32 {
|
||||
let drive = self.settings.drive * 0.01;
|
||||
if drive <= 0. {
|
||||
return x;
|
||||
}
|
||||
let k = 1. + 6. * drive;
|
||||
(k * x).tanh() / k.tanh()
|
||||
}
|
||||
fn oscillator(&mut self) -> f32 {
|
||||
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
|
||||
let release = 1. / (self.rate * RELEASE_MS * 0.001);
|
||||
let v = &mut self.voice;
|
||||
v.amp = if v.releasing {
|
||||
(v.amp - release).max(0.)
|
||||
} else {
|
||||
(v.amp + attack).min(1.)
|
||||
};
|
||||
v.hz += (v.target - v.hz) * self.glide;
|
||||
let mut out = 0.;
|
||||
if v.amp > 0. {
|
||||
out += (TAU * v.phase).sin() * v.amp;
|
||||
v.phase = (v.phase + v.hz / self.rate).fract();
|
||||
}
|
||||
if self.old_gain > 0. {
|
||||
let o = &mut self.old;
|
||||
out += (TAU * o.phase).sin() * o.amp * self.old_gain;
|
||||
o.phase = (o.phase + o.hz / self.rate).fract();
|
||||
self.old_gain = (self.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
|
||||
}
|
||||
self.shape(out)
|
||||
}
|
||||
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
|
||||
self.detect(x);
|
||||
let len = self.delay.len();
|
||||
self.delay[self.delay_at] = x;
|
||||
self.delay_at = (self.delay_at + 1) % len;
|
||||
// The slot after the newest write is the oldest: exactly `latency` samples ago.
|
||||
let y = self.delay[self.delay_at];
|
||||
while self.event_len > 0 {
|
||||
let e = self.events[self.event_head];
|
||||
if e.time + self.latency as u64 > self.clock {
|
||||
break;
|
||||
}
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
self.handle(e.kind);
|
||||
}
|
||||
self.clock += 1;
|
||||
let s = self.settings;
|
||||
let (l0, h0) = self.crossover[0].process(y[0]);
|
||||
let (l1, h1) = if self.channels == 2 {
|
||||
self.crossover[1].process(y[1])
|
||||
} else {
|
||||
(l0, h0)
|
||||
};
|
||||
let low = 0.5 * (l0 + l1);
|
||||
self.low_env += (low * low - self.low_env) * self.env_coef;
|
||||
let env = self.low_env.sqrt();
|
||||
if !self.voice.releasing {
|
||||
self.note_peak = self.note_peak.max(env);
|
||||
}
|
||||
let follow = if self.note_peak > 1e-6 {
|
||||
(env / self.note_peak).min(1.)
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
let dynamics = s.dynamics * 0.01;
|
||||
let shape = 1. - dynamics + dynamics * follow;
|
||||
self.level += (db_to_gain(s.level) - self.level) * self.smooth;
|
||||
self.keep += (s.keep * 0.01 - self.keep) * self.smooth;
|
||||
self.output += (db_to_gain(s.output) - self.output) * self.smooth;
|
||||
self.bypass += (f32::from(s.bypass) - self.bypass) * self.smooth;
|
||||
let sub = self.oscillator() * self.level * shape;
|
||||
self.low_power += (low * low - self.low_power) * self.meter_coef;
|
||||
self.sub_power += (sub * sub - self.sub_power) * self.meter_coef;
|
||||
self.scope_count += 1;
|
||||
if self.scope_count >= self.scope_stride {
|
||||
self.scope_count = 0;
|
||||
self.scope_low[self.scope_at] = low;
|
||||
self.scope_sub[self.scope_at] = sub;
|
||||
self.scope_at = (self.scope_at + 1) % (2 * SCOPE);
|
||||
}
|
||||
let wet = |l: f32, h: f32| {
|
||||
if monitor.solo {
|
||||
sub
|
||||
} else {
|
||||
h + self.keep * l + sub
|
||||
}
|
||||
};
|
||||
let w = [wet(l0, h0), wet(l1, h1)];
|
||||
let mut out = [0.; 2];
|
||||
for c in 0..self.channels {
|
||||
out[c] = (w[c] * self.output) * (1. - self.bypass) + y[c] * self.bypass;
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn take_analyzer(&self) -> AnalyzerFrame {
|
||||
let mut f = AnalyzerFrame {
|
||||
latency: self.latency as u32,
|
||||
sample_rate: self.rate,
|
||||
low_db: 10. * self.low_power.max(1e-12).log10() + 3.01,
|
||||
sub_db: 10. * self.sub_power.max(1e-12).log10() + 3.01,
|
||||
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
|
||||
..AnalyzerFrame::default()
|
||||
};
|
||||
if self.voice.amp > 0. && !self.voice.releasing {
|
||||
f.hz = self.voice.hz;
|
||||
f.note = Some(hz_to_midi(self.voice.hz).round().clamp(0., 127.) as u8);
|
||||
}
|
||||
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
|
||||
let n = 2 * SCOPE;
|
||||
let oldest = self.scope_at;
|
||||
let mut start = oldest;
|
||||
for back in 0..SCOPE {
|
||||
let i = (oldest + SCOPE - back) % n;
|
||||
let prev = (i + n - 1) % n;
|
||||
if self.scope_sub[prev] < 0. && self.scope_sub[i] >= 0. {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for k in 0..SCOPE {
|
||||
let i = (start + k) % n;
|
||||
f.scope_low[k] = self.scope_low[i];
|
||||
f.scope_sub[k] = self.scope_sub[i];
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Host-independent controls. Levels in dB, times in milliseconds, frequencies in Hz.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Source {
|
||||
/// Pitch and note starts are detected from the incoming bass.
|
||||
#[default]
|
||||
Audio,
|
||||
/// Notes come from MIDI note input; the audio only supplies the band above the split.
|
||||
Midi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub source: Source,
|
||||
/// Peak level of the new sub in dBFS, the same for every note.
|
||||
pub level: f32,
|
||||
/// Crossover frequency: below it the original sub is replaced.
|
||||
pub split: f32,
|
||||
/// 0 % holds a constant level per note; 100 % follows the original low band's shape.
|
||||
pub dynamics: f32,
|
||||
/// Soft saturation of the sine, adding upper harmonics for small speakers.
|
||||
pub drive: f32,
|
||||
pub octave: i32,
|
||||
/// Pitch slide time between overlapping notes.
|
||||
pub glide: f32,
|
||||
/// Start phase in degrees on every note start.
|
||||
pub phase: f32,
|
||||
/// Detection threshold in dBFS for note starts (Audio source).
|
||||
pub gate: f32,
|
||||
/// Amount of the original low band that is kept under the new sub.
|
||||
pub keep: f32,
|
||||
pub output: f32,
|
||||
/// Restart the sine at `phase` on every note, so each note and kick hit lines up the same.
|
||||
pub retrigger: bool,
|
||||
/// Round detected pitch to the nearest semitone.
|
||||
pub snap: bool,
|
||||
pub bypass: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: Source::Audio,
|
||||
level: -9.,
|
||||
split: 90.,
|
||||
dynamics: 0.,
|
||||
drive: 0.,
|
||||
octave: 0,
|
||||
glide: 0.,
|
||||
phase: 0.,
|
||||
gate: -42.,
|
||||
keep: 0.,
|
||||
output: 0.,
|
||||
retrigger: true,
|
||||
snap: true,
|
||||
bypass: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
let finite = [
|
||||
self.level,
|
||||
self.split,
|
||||
self.dynamics,
|
||||
self.drive,
|
||||
self.glide,
|
||||
self.phase,
|
||||
self.gate,
|
||||
self.keep,
|
||||
self.output,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite());
|
||||
if !finite {
|
||||
return Err("non-finite setting");
|
||||
}
|
||||
let ok = (-30.0..=0.).contains(&self.level)
|
||||
&& (40.0..=200.).contains(&self.split)
|
||||
&& (0.0..=100.).contains(&self.dynamics)
|
||||
&& (0.0..=100.).contains(&self.drive)
|
||||
&& (-2..=1).contains(&self.octave)
|
||||
&& (0.0..=200.).contains(&self.glide)
|
||||
&& (0.0..=360.).contains(&self.phase)
|
||||
&& (-60.0..=-20.).contains(&self.gate)
|
||||
&& (0.0..=100.).contains(&self.keep)
|
||||
&& (-12.0..=12.).contains(&self.output);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("setting out of range")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use anvil_core::{Engine, Monitor, Settings, Source};
|
||||
use common_dsp::Saw;
|
||||
|
||||
const RATE: f32 = 48000.;
|
||||
|
||||
/// Saw notes (start s, end s, Hz, peak) with 1 ms fades.
|
||||
fn notes(list: &[(f32, f32, f32, f32)], total: f32) -> Vec<f32> {
|
||||
let mut x = vec![0.; (total * RATE) as usize];
|
||||
for &(a, b, hz, amp) in list {
|
||||
let (a, b) = ((a * RATE) as usize, (b * RATE) as usize);
|
||||
let mut saw = Saw::new(RATE, hz);
|
||||
let fade = (0.001 * RATE) as usize;
|
||||
for i in a..b.min(x.len()) {
|
||||
let edge = ((i - a).min(b - 1 - i) as f32 / fade as f32).min(1.);
|
||||
x[i] += saw.sample() * amp * edge;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
fn render(e: &mut Engine, x: &[f32], solo: bool) -> Vec<f32> {
|
||||
x.iter()
|
||||
.map(|&s| e.process_sample([s, s], Monitor { solo })[0])
|
||||
.collect()
|
||||
}
|
||||
fn rms(x: &[f32]) -> f32 {
|
||||
(x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt()
|
||||
}
|
||||
fn db(x: f32) -> f32 {
|
||||
20. * x.max(1e-9).log10()
|
||||
}
|
||||
/// Frequency from interpolated rising zero crossings.
|
||||
fn frequency(x: &[f32]) -> f32 {
|
||||
let mut first = None;
|
||||
let mut last = 0.;
|
||||
let mut count = 0;
|
||||
for i in 1..x.len() {
|
||||
if x[i - 1] < 0. && x[i] >= 0. {
|
||||
let t = (i - 1) as f32 + x[i - 1] / (x[i - 1] - x[i]);
|
||||
if first.is_none() {
|
||||
first = Some(t);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
count as f32 * RATE / (last - first.unwrap())
|
||||
}
|
||||
fn seconds(t: f32) -> usize {
|
||||
(t * RATE) as usize
|
||||
}
|
||||
fn engine(s: Settings) -> (Engine, usize) {
|
||||
let mut e = Engine::new(RATE, 2).unwrap();
|
||||
e.set_settings(s).unwrap();
|
||||
let l = e.latency_samples() as usize;
|
||||
(e, l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_note_gets_the_same_sub_level() {
|
||||
let (mut e, l) = engine(Settings::default());
|
||||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.8, 1.3, 55., 0.1)], 1.6);
|
||||
let y = render(&mut e, &x, true);
|
||||
let loud = rms(&y[seconds(0.3) + l..seconds(0.5) + l]);
|
||||
let quiet = rms(&y[seconds(1.0) + l..seconds(1.2) + l]);
|
||||
assert!(
|
||||
(db(loud) - db(quiet)).abs() < 0.5,
|
||||
"{} vs {}",
|
||||
db(loud),
|
||||
db(quiet)
|
||||
);
|
||||
// −9 dBFS peak sine.
|
||||
assert!((db(loud * 2f32.sqrt()) + 9.).abs() < 0.5, "{}", db(loud));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_follows_the_detected_pitch_and_octave() {
|
||||
for (octave, expect) in [(0, 43.654), (-1, 21.827)] {
|
||||
let (mut e, l) = engine(Settings {
|
||||
octave,
|
||||
..Settings::default()
|
||||
});
|
||||
let x = notes(&[(0.1, 1.1, 43.65, 0.5)], 1.3);
|
||||
let y = render(&mut e, &x, true);
|
||||
let f = frequency(&y[seconds(0.3) + l..seconds(1.0) + l]);
|
||||
assert!((f / expect - 1.).abs() < 0.005, "octave {octave}: {f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pitch_change_without_a_gap_is_a_new_note() {
|
||||
let (mut e, l) = engine(Settings::default());
|
||||
let x = notes(&[(0.1, 0.6, 55., 0.5), (0.6, 1.1, 82.41, 0.5)], 1.3);
|
||||
let y = render(&mut e, &x, true);
|
||||
let f = frequency(&y[seconds(0.75) + l..seconds(1.05) + l]);
|
||||
assert!((f / 82.41 - 1.).abs() < 0.005, "{f}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_note_restarts_at_the_start_phase() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
phase: 90.,
|
||||
..Settings::default()
|
||||
});
|
||||
let x = notes(&[(0.1, 0.4, 55., 0.5), (0.6, 0.9, 55., 0.3)], 1.1);
|
||||
let y = render(&mut e, &x, true);
|
||||
let period = (RATE / 55.) as usize;
|
||||
let a = &y[seconds(0.1) + l..seconds(0.1) + l + 2 * period];
|
||||
let b = &y[seconds(0.6) + l..seconds(0.6) + l + 2 * period];
|
||||
let peak = y.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
let worst = a.iter().zip(b).fold(0f32, |m, (p, q)| m.max((p - q).abs()));
|
||||
assert!(worst < 0.1 * peak, "{worst} of {peak}");
|
||||
// Fit the phase over the second period, after the 2 ms attack.
|
||||
let w = std::f32::consts::TAU * 55. / RATE;
|
||||
let (mut s, mut c) = (0., 0.);
|
||||
for (k, v) in a.iter().enumerate().skip(period) {
|
||||
s += v * (w * k as f32).sin();
|
||||
c += v * (w * k as f32).cos();
|
||||
}
|
||||
let phase = c.atan2(s).to_degrees();
|
||||
assert!((phase - 90.).abs() < 20., "{phase}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_notes_set_pitch_and_are_delayed_by_the_latency() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
..Settings::default()
|
||||
});
|
||||
let mut y = Vec::new();
|
||||
for i in 0..seconds(0.8) {
|
||||
if i == 1000 {
|
||||
e.note_on(33);
|
||||
}
|
||||
if i == seconds(0.6) {
|
||||
e.note_off(33);
|
||||
}
|
||||
y.push(e.process_sample([0.; 2], Monitor { solo: true })[0]);
|
||||
}
|
||||
assert!(y[..1000 + l].iter().all(|v| *v == 0.));
|
||||
assert!(y[1000 + l + 50].abs() > 0.);
|
||||
let f = frequency(&y[seconds(0.2) + l..seconds(0.5)]);
|
||||
assert!((f / 55. - 1.).abs() < 0.001, "{f}");
|
||||
assert!(y[seconds(0.6) + l + seconds(0.05)..]
|
||||
.iter()
|
||||
.all(|v| v.abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_changes_do_not_click() {
|
||||
let (mut e, _) = engine(Settings::default());
|
||||
let x = notes(
|
||||
&[
|
||||
(0.1, 0.4, 55., 0.5),
|
||||
(0.4, 0.7, 73.42, 0.5),
|
||||
(0.7, 1.0, 41.2, 0.5),
|
||||
],
|
||||
1.3,
|
||||
);
|
||||
let y = render(&mut e, &x, true);
|
||||
let step = y.windows(2).fold(0f32, |m, w| m.max((w[1] - w[0]).abs()));
|
||||
// A −9 dBFS sine at 73 Hz moves at most 0.0034 per sample.
|
||||
assert!(step < 0.012, "{step}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_without_notes_is_an_allpass() {
|
||||
let (mut e, _) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
keep: 100.,
|
||||
..Settings::default()
|
||||
});
|
||||
for hz in [40., 90., 300., 3000.] {
|
||||
e.reset();
|
||||
let x: Vec<f32> = (0..seconds(1.))
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / RATE).sin())
|
||||
.collect();
|
||||
let y = render(&mut e, &x, false);
|
||||
let d = db(rms(&y[seconds(0.5)..])) - db(rms(&x[seconds(0.5)..]));
|
||||
assert!(d.abs() < 0.1, "{hz}: {d}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_removes_original_sub_and_keeps_mids() {
|
||||
let (mut e, l) = engine(Settings {
|
||||
source: Source::Midi,
|
||||
..Settings::default()
|
||||
});
|
||||
let x: Vec<f32> = (0..seconds(1.))
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * 40. * i as f32 / RATE).sin())
|
||||
.collect();
|
||||
let y = render(&mut e, &x, false);
|
||||
assert!(db(rms(&y[seconds(0.5) + l..])) < db(0.3) - 18.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_validated_and_serialise() {
|
||||
let mut e = Engine::new(RATE, 1).unwrap();
|
||||
assert!(e
|
||||
.set_settings(Settings {
|
||||
level: 3.,
|
||||
..Settings::default()
|
||||
})
|
||||
.is_err());
|
||||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||||
Settings::default()
|
||||
);
|
||||
assert!(Engine::new(1000., 2).is_err());
|
||||
assert!(Engine::new(48000., 3).is_err());
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use anvil_core::{Engine, Monitor, Settings, Source};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
};
|
||||
|
||||
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 processing_notes_settings_and_analysis_do_not_allocate_or_free() {
|
||||
let mut engine = Engine::new(48000., 2).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
engine.set_settings(settings).unwrap();
|
||||
CHECK.with(|v| v.set(true));
|
||||
for i in 0..96000 {
|
||||
if i % 4800 == 0 {
|
||||
settings.split = 60. + (i % 100) as f32;
|
||||
settings.source = if i > 48000 {
|
||||
Source::Midi
|
||||
} else {
|
||||
Source::Audio
|
||||
};
|
||||
settings.octave = -((i / 4800) % 2);
|
||||
engine.set_settings(settings).unwrap();
|
||||
engine.note_on(30 + (i / 4800 % 12) as u8);
|
||||
}
|
||||
if i % 4800 == 2400 {
|
||||
engine.note_off(30 + (i / 4800 % 12) as u8);
|
||||
}
|
||||
let t = i as f32 / 48000.;
|
||||
let x = if (i / 2400) % 2 == 0 {
|
||||
(std::f32::consts::TAU * 55. * t).sin() * 0.5
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
std::hint::black_box(engine.process_sample([x, x], Monitor { solo: i % 3 == 0 }));
|
||||
if i % 1600 == 0 {
|
||||
std::hint::black_box(engine.take_analyzer());
|
||||
}
|
||||
}
|
||||
engine.reset();
|
||||
CHECK.with(|v| v.set(false));
|
||||
assert_eq!(OPERATIONS.with(Cell::get), 0);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "anvil"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[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" }
|
||||
anvil-core = { path = "../core" }
|
||||
anvil-ui = { path = "../ui" }
|
||||
crossbeam-queue = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,199 @@
|
||||
//! CLAP audio effect. All DSP state is owned by the processing thread.
|
||||
mod params;
|
||||
use anvil_core::{AnalyzerFrame, Engine, Monitor};
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::AnvilParams;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
struct Shared {
|
||||
analyzer: ArrayQueue<AnalyzerFrame>,
|
||||
solo: AtomicBool,
|
||||
}
|
||||
struct EditorData {
|
||||
ui: anvil_ui::State,
|
||||
gestures: [bool; 9],
|
||||
}
|
||||
pub struct Anvil {
|
||||
params: Arc<AnvilParams>,
|
||||
shared: Arc<Shared>,
|
||||
engine: Option<Engine>,
|
||||
publish_count: usize,
|
||||
publish_interval: usize,
|
||||
}
|
||||
impl Default for Anvil {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(AnvilParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
analyzer: ArrayQueue::new(2),
|
||||
solo: AtomicBool::new(false),
|
||||
}),
|
||||
engine: None,
|
||||
publish_count: 0,
|
||||
publish_interval: 1600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Anvil {
|
||||
const NAME: &'static str = "Anvil";
|
||||
const VENDOR: &'static str = "Rivet & Patina";
|
||||
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: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: anvil_ui::State::default(),
|
||||
gestures: [false; 9],
|
||||
},
|
||||
|ctx, data| {
|
||||
data.ui.solo = false;
|
||||
anvil_ui::style(ctx);
|
||||
},
|
||||
move |ctx, setter, data| {
|
||||
while let Some(frame) = shared.analyzer.pop() {
|
||||
data.ui.accept_frame(frame);
|
||||
}
|
||||
let before = params.settings();
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("anvil-editor")
|
||||
.min_size(egui::vec2(
|
||||
anvil_ui::WIDTH as f32 - 80.,
|
||||
anvil_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
anvil_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
// Solo is a monitor only: never saved and never left on after the editor closes.
|
||||
shared.solo.store(data.ui.solo, Ordering::Release);
|
||||
if data.ui.analyzer.note.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
layout: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
context: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
let Ok(mut engine) = Engine::new(
|
||||
config.sample_rate,
|
||||
layout.main_input_channels.map_or(0, |v| v.get() as usize),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if engine.set_settings(self.params.settings()).is_err() {
|
||||
return false;
|
||||
}
|
||||
context.set_latency_samples(engine.latency_samples());
|
||||
self.publish_interval = (config.sample_rate / 30.) as usize;
|
||||
self.engine = Some(engine);
|
||||
self.shared.solo.store(false, Ordering::Release);
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if let Some(e) = self.engine.as_mut() {
|
||||
e.reset();
|
||||
}
|
||||
self.publish_count = 0;
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(engine) = self.engine.as_mut() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let _ = engine.set_settings(self.params.settings());
|
||||
let solo = self.shared.solo.load(Ordering::Acquire) && self.params.editor.is_open();
|
||||
let mut next = context.next_event();
|
||||
for (index, mut sample) in buffer.iter_samples().enumerate() {
|
||||
while let Some(event) = next {
|
||||
if event.timing() as usize > index {
|
||||
break;
|
||||
}
|
||||
match event {
|
||||
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
|
||||
engine.note_on(note)
|
||||
}
|
||||
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
|
||||
engine.note_off(note)
|
||||
}
|
||||
NoteEvent::Choke { note, .. } => engine.note_off(note),
|
||||
_ => {}
|
||||
}
|
||||
next = context.next_event();
|
||||
}
|
||||
let x = [
|
||||
*sample.get_mut(0).unwrap(),
|
||||
if engine.channels() == 2 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
];
|
||||
let out = engine.process_sample(x, Monitor { solo });
|
||||
for (c, value) in sample.into_iter().enumerate() {
|
||||
*value = out[c];
|
||||
}
|
||||
self.publish_count += 1;
|
||||
if self.publish_count >= self.publish_interval {
|
||||
self.shared.analyzer.force_push(engine.take_analyzer());
|
||||
self.publish_count = 0;
|
||||
}
|
||||
}
|
||||
// The lookahead delay and the sub release must drain after input stops.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Anvil {
|
||||
const CLAP_ID: &'static str = "io.storyboard.sub-anchor";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Replace the sub with a clean sine at one level and phase for every note");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Anvil);
|
||||
@@ -0,0 +1,192 @@
|
||||
use anvil_core::{Settings, Source};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SourceParam {
|
||||
#[id = "audio"]
|
||||
Audio,
|
||||
#[id = "midi"]
|
||||
#[name = "MIDI"]
|
||||
Midi,
|
||||
}
|
||||
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
|
||||
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
|
||||
}
|
||||
#[derive(Params)]
|
||||
pub struct AnvilParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[id = "source"]
|
||||
pub source: EnumParam<SourceParam>,
|
||||
#[id = "level"]
|
||||
pub level: FloatParam,
|
||||
#[id = "split"]
|
||||
pub split: FloatParam,
|
||||
#[id = "dynamics"]
|
||||
pub dynamics: FloatParam,
|
||||
#[id = "drive"]
|
||||
pub drive: FloatParam,
|
||||
#[id = "octave"]
|
||||
pub octave: IntParam,
|
||||
#[id = "glide"]
|
||||
pub glide: FloatParam,
|
||||
#[id = "phase"]
|
||||
pub phase: FloatParam,
|
||||
#[id = "gate"]
|
||||
pub gate: FloatParam,
|
||||
#[id = "keep"]
|
||||
pub keep: FloatParam,
|
||||
#[id = "output"]
|
||||
pub output: FloatParam,
|
||||
#[id = "retrigger"]
|
||||
pub retrigger: BoolParam,
|
||||
#[id = "snap"]
|
||||
pub snap: BoolParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for AnvilParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(anvil_ui::WIDTH, anvil_ui::HEIGHT),
|
||||
source: EnumParam::new("Pitch source", SourceParam::Audio),
|
||||
level: linear("Sub level", d.level, -30., 0., " dBFS"),
|
||||
split: FloatParam::new(
|
||||
"Split",
|
||||
d.split,
|
||||
FloatRange::Skewed {
|
||||
min: 40.,
|
||||
max: 200.,
|
||||
factor: FloatRange::skew_factor(-1.),
|
||||
},
|
||||
)
|
||||
.with_unit(" Hz"),
|
||||
dynamics: linear("Dynamics", d.dynamics, 0., 100., " %"),
|
||||
drive: linear("Drive", d.drive, 0., 100., " %"),
|
||||
octave: IntParam::new("Octave", d.octave, IntRange::Linear { min: -2, max: 1 }),
|
||||
glide: linear("Glide", d.glide, 0., 200., " ms"),
|
||||
phase: linear("Phase", d.phase, 0., 360., "°"),
|
||||
gate: linear("Gate", d.gate, -60., -20., " dB"),
|
||||
keep: linear("Keep", d.keep, 0., 100., " %"),
|
||||
output: linear("Output", d.output, -12., 12., " dB"),
|
||||
retrigger: BoolParam::new("Retrigger", d.retrigger),
|
||||
snap: BoolParam::new("Snap to semitone", d.snap),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl AnvilParams {
|
||||
pub fn settings(&self) -> Settings {
|
||||
Settings {
|
||||
source: match self.source.value() {
|
||||
SourceParam::Audio => Source::Audio,
|
||||
SourceParam::Midi => Source::Midi,
|
||||
},
|
||||
level: self.level.value(),
|
||||
split: self.split.value(),
|
||||
dynamics: self.dynamics.value(),
|
||||
drive: self.drive.value(),
|
||||
octave: self.octave.value(),
|
||||
glide: self.glide.value(),
|
||||
phase: self.phase.value(),
|
||||
gate: self.gate.value(),
|
||||
keep: self.keep.value(),
|
||||
output: self.output.value(),
|
||||
retrigger: self.retrigger.value(),
|
||||
snap: self.snap.value(),
|
||||
bypass: self.bypass.value(),
|
||||
}
|
||||
}
|
||||
/// Pushes every changed editor value to the host. Continuous controls hold one
|
||||
/// gesture while the pointer is down; switches are set in a single gesture.
|
||||
pub fn apply(
|
||||
&self,
|
||||
setter: &ParamSetter,
|
||||
old: &Settings,
|
||||
new: &Settings,
|
||||
dragging: bool,
|
||||
open: &mut [bool; 9],
|
||||
) {
|
||||
let floats: [(&FloatParam, f32, f32); 8] = [
|
||||
(&self.level, old.level, new.level),
|
||||
(&self.split, old.split, new.split),
|
||||
(&self.dynamics, old.dynamics, new.dynamics),
|
||||
(&self.drive, old.drive, new.drive),
|
||||
(&self.glide, old.glide, new.glide),
|
||||
(&self.phase, old.phase, new.phase),
|
||||
(&self.gate, old.gate, new.gate),
|
||||
(&self.keep, old.keep, new.keep),
|
||||
];
|
||||
let mut continuous = |i: usize, changed: bool, set: &dyn Fn()| {
|
||||
if changed {
|
||||
if !open[i] {
|
||||
open[i] = true;
|
||||
set_begin(setter, self, i);
|
||||
}
|
||||
set();
|
||||
}
|
||||
if !dragging && open[i] {
|
||||
set_end(setter, self, i);
|
||||
open[i] = false;
|
||||
}
|
||||
};
|
||||
for (i, (p, a, b)) in floats.into_iter().enumerate() {
|
||||
continuous(i, a != b, &|| setter.set_parameter(p, b));
|
||||
}
|
||||
continuous(8, old.output != new.output, &|| {
|
||||
setter.set_parameter(&self.output, new.output)
|
||||
});
|
||||
let once = |changed: bool, f: &dyn Fn()| {
|
||||
if changed {
|
||||
f();
|
||||
}
|
||||
};
|
||||
once(new.octave != old.octave, &|| {
|
||||
setter.begin_set_parameter(&self.octave);
|
||||
setter.set_parameter(&self.octave, new.octave);
|
||||
setter.end_set_parameter(&self.octave);
|
||||
});
|
||||
for (p, a, b) in [
|
||||
(&self.retrigger, old.retrigger, new.retrigger),
|
||||
(&self.snap, old.snap, new.snap),
|
||||
(&self.bypass, old.bypass, new.bypass),
|
||||
] {
|
||||
once(a != b, &|| {
|
||||
setter.begin_set_parameter(p);
|
||||
setter.set_parameter(p, b);
|
||||
setter.end_set_parameter(p);
|
||||
});
|
||||
}
|
||||
once(new.source != old.source, &|| {
|
||||
let v = match new.source {
|
||||
Source::Audio => SourceParam::Audio,
|
||||
Source::Midi => SourceParam::Midi,
|
||||
};
|
||||
setter.begin_set_parameter(&self.source);
|
||||
setter.set_parameter(&self.source, v);
|
||||
setter.end_set_parameter(&self.source);
|
||||
});
|
||||
}
|
||||
fn continuous(&self, i: usize) -> &FloatParam {
|
||||
[
|
||||
&self.level,
|
||||
&self.split,
|
||||
&self.dynamics,
|
||||
&self.drive,
|
||||
&self.glide,
|
||||
&self.phase,
|
||||
&self.gate,
|
||||
&self.keep,
|
||||
&self.output,
|
||||
][i]
|
||||
}
|
||||
}
|
||||
fn set_begin(setter: &ParamSetter, p: &AnvilParams, i: usize) {
|
||||
setter.begin_set_parameter(p.continuous(i));
|
||||
}
|
||||
fn set_end(setter: &ParamSetter, p: &AnvilParams, i: usize) {
|
||||
setter.end_set_parameter(p.continuous(i));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
|
||||
use clap_sys::{
|
||||
audio_buffer::*,
|
||||
events::*,
|
||||
ext::{audio_ports::*, latency::*, 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 effect_ports_state_latency_and_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 = &anvil::clap_entry;
|
||||
assert!(entry.init.unwrap()(c"anvil.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).name), c"Anvil");
|
||||
assert_eq!(
|
||||
CStr::from_ptr((*descriptor).id),
|
||||
c"io.storyboard.sub-anchor"
|
||||
);
|
||||
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_AUDIO_PORTS.as_ptr())
|
||||
as *const clap_plugin_audio_ports;
|
||||
assert!(!ports.is_null());
|
||||
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
|
||||
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
|
||||
as *const clap_plugin_latency;
|
||||
assert!(!latency.is_null());
|
||||
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();
|
||||
use nih_plug::wrapper::state::ParamValue;
|
||||
envelope
|
||||
.params
|
||||
.insert("level".into(), ParamValue::F32(-14.));
|
||||
envelope.params.insert("octave".into(), ParamValue::I32(-1));
|
||||
envelope
|
||||
.params
|
||||
.insert("snap".into(), ParamValue::Bool(false));
|
||||
envelope
|
||||
.params
|
||||
.insert("source".into(), ParamValue::String("midi".into()));
|
||||
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!(
|
||||
serde_json::to_value(&restored.params).unwrap(),
|
||||
serde_json::to_value(&envelope.params).unwrap()
|
||||
);
|
||||
assert!(!restored.fields.contains_key("solo"));
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
// About 80 ms of lookahead: two periods of the lowest detected note.
|
||||
assert!((delay as f64 / rate - 0.08).abs() < 0.01, "{delay}");
|
||||
assert!(p.start_processing.unwrap()(plugin));
|
||||
let total = delay + 9600;
|
||||
let mut position = 0;
|
||||
let (mut energy_in, mut energy_out) = (0f64, 0f64);
|
||||
while position < total {
|
||||
let frames = [1, 127, 512, 31][position % 4].min(total - position);
|
||||
// A 1 kHz tone sits above the split and passes through unchanged.
|
||||
let tone = |n: usize| {
|
||||
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
|
||||
};
|
||||
let mut input_left = [0f32; 512];
|
||||
let mut input_right = [0f32; 512];
|
||||
for i in 0..frames {
|
||||
input_left[i] = tone(position + i);
|
||||
input_right[i] = -tone(position + i);
|
||||
}
|
||||
let mut left = [0f32; 512];
|
||||
let mut right = [0f32; 512];
|
||||
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
|
||||
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
|
||||
let incoming = clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let mut outgoing_audio = clap_audio_buffer {
|
||||
data32: out_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: position as i64,
|
||||
frames_count: frames as u32,
|
||||
transport: ptr::null(),
|
||||
audio_inputs: &incoming,
|
||||
audio_outputs: &mut outgoing_audio,
|
||||
audio_inputs_count: 1,
|
||||
audio_outputs_count: 1,
|
||||
in_events: &input,
|
||||
out_events: &outgoing,
|
||||
};
|
||||
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
|
||||
for i in 0..frames {
|
||||
let n = position + i;
|
||||
if n < delay {
|
||||
assert!(left[i].abs() < 1e-6, "output before the lookahead");
|
||||
} else if n > delay + 4800 {
|
||||
energy_in += (tone(n - delay) as f64).powi(2);
|
||||
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
|
||||
}
|
||||
}
|
||||
position += frames;
|
||||
}
|
||||
let ratio_db = 10. * (energy_out / (2. * energy_in)).log10();
|
||||
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
|
||||
p.stop_processing.unwrap()(plugin);
|
||||
p.deactivate.unwrap()(plugin);
|
||||
}
|
||||
p.destroy.unwrap()(plugin);
|
||||
entry.deinit.unwrap()();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "anvil-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
anvil-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Anvil editor, shared by the CLAP wrapper and the headless renderer.
|
||||
use anvil_core::{AnalyzerFrame, Settings, Source, SCOPE};
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
pub use common_ui::style;
|
||||
pub const WIDTH: u32 = 980;
|
||||
pub const HEIGHT: u32 = 704;
|
||||
|
||||
pub struct State {
|
||||
pub solo: bool,
|
||||
pub analyzer: AnalyzerFrame,
|
||||
/// True while a pointer button is held, so knob drags become one host gesture.
|
||||
pub gesture_active: bool,
|
||||
pub help: String,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solo: false,
|
||||
analyzer: AnalyzerFrame::default(),
|
||||
gesture_active: false,
|
||||
help: DEFAULT_HELP.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
const DEFAULT_HELP: &str =
|
||||
"Put Anvil on the bass, set Split just above the sub, then set Sub level once for every patch.";
|
||||
impl State {
|
||||
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
|
||||
let old = self.analyzer;
|
||||
self.analyzer = frame;
|
||||
// Meters fall smoothly; the readout and scope stay exact.
|
||||
self.analyzer.low_db = frame.low_db.max(old.low_db - 1.5);
|
||||
self.analyzer.sub_db = frame.sub_db.max(old.sub_db - 1.5);
|
||||
}
|
||||
}
|
||||
fn hint(r: &egui::Response, state: &mut State, text: &str) {
|
||||
if r.hovered() || r.has_focus() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn knob(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut State,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
help: &str,
|
||||
) {
|
||||
ui.vertical_centered(|ui| {
|
||||
let r = rotary(ui, name, value, range, default, unit, size, ACCENT);
|
||||
hint(&r, state, help);
|
||||
});
|
||||
}
|
||||
fn toggle(ui: &mut egui::Ui, state: &mut State, on: &mut bool, label: &str, help: &str) {
|
||||
let r = ui.selectable_label(*on, RichText::new(label).size(13.));
|
||||
if r.clicked() {
|
||||
*on = !*on;
|
||||
}
|
||||
hint(&r, state, help);
|
||||
}
|
||||
fn scope(ui: &mut egui::Ui, frame: &AnalyzerFrame, height: f32) {
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2., SCREEN);
|
||||
let mid = rect.center().y;
|
||||
for k in 1..6 {
|
||||
let x = rect.left() + rect.width() * k as f32 / 6.;
|
||||
p.line_segment(
|
||||
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(24, 26, 30)),
|
||||
);
|
||||
}
|
||||
p.line_segment(
|
||||
[Pos2::new(rect.left(), mid), Pos2::new(rect.right(), mid)],
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
);
|
||||
let peak = frame
|
||||
.scope_low
|
||||
.iter()
|
||||
.chain(frame.scope_sub.iter())
|
||||
.fold(0.05f32, |m, v| m.max(v.abs()));
|
||||
let scale = rect.height() * 0.42 / peak;
|
||||
for (data, color, width) in [
|
||||
(&frame.scope_low, STEEL, 1.5_f32),
|
||||
(&frame.scope_sub, ACCENT, 2.0_f32),
|
||||
] {
|
||||
let points: Vec<Pos2> = data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
Pos2::new(
|
||||
rect.left() + rect.width() * i as f32 / (SCOPE - 1) as f32,
|
||||
mid - v * scale,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, Stroke::new(width, color)));
|
||||
}
|
||||
p.text(
|
||||
rect.left_top() + Vec2::new(10., 8.),
|
||||
egui::Align2::LEFT_TOP,
|
||||
"ORIGINAL LOW BAND",
|
||||
FontId::proportional(11.),
|
||||
STEEL,
|
||||
);
|
||||
p.text(
|
||||
rect.left_top() + Vec2::new(130., 8.),
|
||||
egui::Align2::LEFT_TOP,
|
||||
"NEW SUB",
|
||||
FontId::proportional(11.),
|
||||
ACCENT,
|
||||
);
|
||||
p.text(
|
||||
rect.right_top() + Vec2::new(-10., 8.),
|
||||
egui::Align2::RIGHT_TOP,
|
||||
format!("{:.0} ms", frame.scope_ms),
|
||||
FontId::proportional(11.),
|
||||
MUTED,
|
||||
);
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
2.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
backdrop(ui);
|
||||
state.help = DEFAULT_HELP.into();
|
||||
state.gesture_active = ui.input(|i| i.pointer.any_down());
|
||||
ui.add_space(2.);
|
||||
header(ui, "ANVIL", "SUB ANCHOR", |ui| {
|
||||
let r = ui
|
||||
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
|
||||
.on_hover_text("Crossfade to the delayed dry signal.");
|
||||
if r.clicked() {
|
||||
settings.bypass = !settings.bypass;
|
||||
}
|
||||
let r = ui
|
||||
.selectable_label(state.solo, RichText::new("SOLO SUB").size(13.))
|
||||
.on_hover_text("Hear only the generated sub.");
|
||||
if r.clicked() {
|
||||
state.solo = !state.solo;
|
||||
}
|
||||
ui.separator();
|
||||
for (source, label) in [(Source::Midi, "MIDI"), (Source::Audio, "AUDIO")] {
|
||||
let r = ui.selectable_label(settings.source == source, RichText::new(label).size(13.));
|
||||
if r.clicked() {
|
||||
settings.source = source;
|
||||
}
|
||||
}
|
||||
caption(ui, "PITCH FROM");
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let frame = state.analyzer;
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(260.);
|
||||
ui.set_min_height(TOP);
|
||||
ui.vertical(|ui| note_panel(ui, settings, &frame));
|
||||
});
|
||||
ui.add_space(4.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
scope(ui, &frame, TOP);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.vertical(|ui| controls(ui, settings, state));
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(RichText::new(&state.help).color(MUTED));
|
||||
}
|
||||
const TOP: f32 = 196.;
|
||||
fn note_panel(ui: &mut egui::Ui, settings: &Settings, frame: &AnalyzerFrame) {
|
||||
caption(ui, "NOTE");
|
||||
let (name, hz) = match frame.note {
|
||||
Some(n) if frame.hz > 0. => (common_dsp::note_name(n), format!("{:.1} Hz", frame.hz)),
|
||||
_ => ("—".into(), "no note".into()),
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(54., egui::FontFamily::Name("medium".into())))
|
||||
.color(if frame.note.is_some() { TEXT } else { DIM }),
|
||||
);
|
||||
ui.label(RichText::new(hz).monospace().size(14.).color(MUTED));
|
||||
ui.add_space(10.);
|
||||
meter(ui, "ORIGINAL", frame.low_db, STEEL, 100.);
|
||||
meter(ui, "NEW SUB", frame.sub_db, ACCENT, 100.);
|
||||
ui.add_space(10.);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{:.0} ms lookahead · {}",
|
||||
frame.latency as f32 / frame.sample_rate * 1000.,
|
||||
match settings.source {
|
||||
Source::Audio => "detecting pitch",
|
||||
Source::Midi => "following MIDI notes",
|
||||
}
|
||||
))
|
||||
.size(12.)
|
||||
.color(MUTED),
|
||||
);
|
||||
}
|
||||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let defaults = Settings::default();
|
||||
ui.columns(4, |c| {
|
||||
knob(
|
||||
&mut c[0],
|
||||
state,
|
||||
"Sub level",
|
||||
&mut settings.level,
|
||||
-30. ..=0.,
|
||||
defaults.level,
|
||||
" dBFS",
|
||||
88.,
|
||||
"Peak level of the new sub. Every note gets exactly this level.",
|
||||
);
|
||||
knob(
|
||||
&mut c[1],
|
||||
state,
|
||||
"Split",
|
||||
&mut settings.split,
|
||||
40. ..=200.,
|
||||
defaults.split,
|
||||
" Hz",
|
||||
88.,
|
||||
"Below this frequency the original sub is replaced.",
|
||||
);
|
||||
knob(
|
||||
&mut c[2],
|
||||
state,
|
||||
"Dynamics",
|
||||
&mut settings.dynamics,
|
||||
0. ..=100.,
|
||||
defaults.dynamics,
|
||||
" %",
|
||||
88.,
|
||||
"0 %: constant level per note. Higher: follow the original's wobble shape.",
|
||||
);
|
||||
knob(
|
||||
&mut c[3],
|
||||
state,
|
||||
"Drive",
|
||||
&mut settings.drive,
|
||||
0. ..=100.,
|
||||
defaults.drive,
|
||||
" %",
|
||||
88.,
|
||||
"Adds upper harmonics so the sub reads on small speakers.",
|
||||
);
|
||||
});
|
||||
ui.add_space(6.);
|
||||
ui.separator();
|
||||
ui.columns(6, |c| {
|
||||
let mut octave = settings.octave as f32;
|
||||
knob(
|
||||
&mut c[0],
|
||||
state,
|
||||
"Octave",
|
||||
&mut octave,
|
||||
-2. ..=1.,
|
||||
0.,
|
||||
"",
|
||||
54.,
|
||||
"Shift the sub against the detected note. −1 for basses played high.",
|
||||
);
|
||||
settings.octave = octave.round() as i32;
|
||||
knob(
|
||||
&mut c[1],
|
||||
state,
|
||||
"Glide",
|
||||
&mut settings.glide,
|
||||
0. ..=200.,
|
||||
defaults.glide,
|
||||
" ms",
|
||||
54.,
|
||||
"Slide time when notes overlap. 0 jumps.",
|
||||
);
|
||||
knob(
|
||||
&mut c[2],
|
||||
state,
|
||||
"Phase",
|
||||
&mut settings.phase,
|
||||
0. ..=360.,
|
||||
defaults.phase,
|
||||
"°",
|
||||
54.,
|
||||
"Start phase of every note. Line it up with your kick.",
|
||||
);
|
||||
knob(
|
||||
&mut c[3],
|
||||
state,
|
||||
"Gate",
|
||||
&mut settings.gate,
|
||||
-60. ..=-20.,
|
||||
defaults.gate,
|
||||
" dB",
|
||||
54.,
|
||||
"Input level that counts as a note. Raise it if tails retrigger.",
|
||||
);
|
||||
knob(
|
||||
&mut c[4],
|
||||
state,
|
||||
"Keep",
|
||||
&mut settings.keep,
|
||||
0. ..=100.,
|
||||
defaults.keep,
|
||||
" %",
|
||||
54.,
|
||||
"Blend some original low band back under the new sub.",
|
||||
);
|
||||
knob(
|
||||
&mut c[5],
|
||||
state,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
defaults.output,
|
||||
" dB",
|
||||
54.,
|
||||
"Output trim.",
|
||||
);
|
||||
});
|
||||
ui.add_space(4.);
|
||||
ui.horizontal(|ui| {
|
||||
toggle(
|
||||
ui,
|
||||
state,
|
||||
&mut settings.retrigger,
|
||||
"RETRIGGER",
|
||||
"Restart the sine at Phase on every note, so each note and kick hit lines up.",
|
||||
);
|
||||
toggle(
|
||||
ui,
|
||||
state,
|
||||
&mut settings.snap,
|
||||
"SNAP TO SEMITONE",
|
||||
"Round the detected pitch. Turn off for slides and pitch bends.",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -22,12 +22,47 @@
|
||||
"documentation": "docs/patina",
|
||||
"clap_id": "io.storyboard.resonance-compressor",
|
||||
"licenses": {
|
||||
"plugins/patina/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"patina-core",
|
||||
"patina-ui",
|
||||
"patina-render"
|
||||
]
|
||||
},
|
||||
"anvil": {
|
||||
"name": "Anvil",
|
||||
"description": "Replace the sub with a clean sine at one level and phase for every note",
|
||||
"manifest": "plugins/anvil/plugin/Cargo.toml",
|
||||
"library": "anvil",
|
||||
"documentation": "docs/anvil",
|
||||
"clap_id": "io.storyboard.sub-anchor",
|
||||
"licenses": {
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"common-dsp",
|
||||
"anvil-core",
|
||||
"anvil-ui"
|
||||
]
|
||||
},
|
||||
"crucible": {
|
||||
"name": "Crucible",
|
||||
"description": "Capture the last bars, slice them and export clean, key-labelled samples",
|
||||
"manifest": "plugins/crucible/plugin/Cargo.toml",
|
||||
"library": "crucible",
|
||||
"documentation": "docs/crucible",
|
||||
"clap_id": "io.storyboard.resampler",
|
||||
"licenses": {
|
||||
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
|
||||
},
|
||||
"packages": [
|
||||
"common-ui",
|
||||
"common-dsp",
|
||||
"crucible-core",
|
||||
"crucible-ui"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "common-dsp"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Shared allocation-free DSP building blocks: filters, pitch detection, notes, WAV files.
|
||||
pub mod wav;
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Transposed direct form II biquad (RBJ cookbook coefficients).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Biquad {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
impl Biquad {
|
||||
fn from(b: [f32; 3], a: [f32; 3]) -> Self {
|
||||
Self {
|
||||
b0: b[0] / a[0],
|
||||
b1: b[1] / a[0],
|
||||
b2: b[2] / a[0],
|
||||
a1: a[1] / a[0],
|
||||
a2: a[2] / a[0],
|
||||
z1: 0.,
|
||||
z2: 0.,
|
||||
}
|
||||
}
|
||||
pub fn lowpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. - c) / 2., 1. - c, (1. - c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
pub fn highpass(rate: f32, hz: f32, q: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[(1. + c) / 2., -(1. + c), (1. + c) / 2.],
|
||||
[1. + alpha, -2. * c, 1. - alpha],
|
||||
)
|
||||
}
|
||||
/// Peaking EQ; used by tests to model resonant material.
|
||||
pub fn peak(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self {
|
||||
let w = TAU * (hz / rate).clamp(1e-5, 0.49);
|
||||
let (s, c) = w.sin_cos();
|
||||
let a = 10f32.powf(gain_db / 40.);
|
||||
let alpha = s / (2. * q);
|
||||
Self::from(
|
||||
[1. + alpha * a, -2. * c, 1. - alpha * a],
|
||||
[1. + alpha / a, -2. * c, 1. - alpha / a],
|
||||
)
|
||||
}
|
||||
/// Replace the coefficients and keep the filter state, for smooth parameter changes.
|
||||
pub fn retune(&mut self, other: Self) {
|
||||
let (z1, z2) = (self.z1, self.z2);
|
||||
*self = other;
|
||||
self.z1 = z1;
|
||||
self.z2 = z2;
|
||||
}
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.b0 * x + self.z1;
|
||||
self.z1 = self.b1 * x - self.a1 * y + self.z2;
|
||||
self.z2 = self.b2 * x - self.a2 * y;
|
||||
y
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.z1 = 0.;
|
||||
self.z2 = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fourth-order Linkwitz–Riley crossover. Low plus high is an allpass: flat magnitude.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Crossover {
|
||||
low: [Biquad; 2],
|
||||
high: [Biquad; 2],
|
||||
}
|
||||
impl Crossover {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||||
Self {
|
||||
low: [Biquad::lowpass(rate, hz, q); 2],
|
||||
high: [Biquad::highpass(rate, hz, q); 2],
|
||||
}
|
||||
}
|
||||
pub fn retune(&mut self, rate: f32, hz: f32) {
|
||||
let fresh = Self::new(rate, hz);
|
||||
for i in 0..2 {
|
||||
self.low[i].retune(fresh.low[i]);
|
||||
self.high[i].retune(fresh.high[i]);
|
||||
}
|
||||
}
|
||||
/// Returns (low, high).
|
||||
#[inline]
|
||||
pub fn process(&mut self, x: f32) -> (f32, f32) {
|
||||
let low = self.low[0].process(x);
|
||||
let low = self.low[1].process(low);
|
||||
let high = self.high[0].process(x);
|
||||
let high = self.high[1].process(high);
|
||||
// LR4 bands are in phase at the crossover point, so they sum to an allpass.
|
||||
(low, high)
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
for f in self.low.iter_mut().chain(self.high.iter_mut()) {
|
||||
f.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// YIN fundamental-frequency estimator with preallocated scratch.
|
||||
pub struct Yin {
|
||||
diff: Vec<f32>,
|
||||
pub threshold: f32,
|
||||
}
|
||||
impl Yin {
|
||||
/// `max_lag` is the longest period in samples that will ever be requested.
|
||||
pub fn new(max_lag: usize) -> Self {
|
||||
Self {
|
||||
diff: vec![0.; max_lag + 2],
|
||||
threshold: 0.15,
|
||||
}
|
||||
}
|
||||
pub fn max_lag(&self) -> usize {
|
||||
self.diff.len() - 2
|
||||
}
|
||||
/// Estimates the fundamental of `x` in Hz with its clarity (1 = perfectly periodic).
|
||||
/// `x` must hold at least twice the longest period. Never allocates.
|
||||
pub fn estimate(
|
||||
&mut self,
|
||||
x: &[f32],
|
||||
rate: f32,
|
||||
min_hz: f32,
|
||||
max_hz: f32,
|
||||
) -> Option<(f32, f32)> {
|
||||
let hi = ((rate / min_hz).ceil() as usize).min(self.max_lag());
|
||||
let lo = ((rate / max_hz).floor() as usize).max(2);
|
||||
if hi <= lo + 2 || x.len() < hi + 2 {
|
||||
return None;
|
||||
}
|
||||
let window = x.len() - hi - 1;
|
||||
if window < hi {
|
||||
return None;
|
||||
}
|
||||
let energy: f32 = x[..window].iter().map(|v| v * v).sum();
|
||||
if energy < 1e-9 * window as f32 {
|
||||
return None;
|
||||
}
|
||||
let d = &mut self.diff;
|
||||
d[0] = 1.;
|
||||
let mut running = 0.;
|
||||
for tau in 1..=hi + 1 {
|
||||
let mut s = 0.;
|
||||
for j in 0..window {
|
||||
let e = x[j] - x[j + tau];
|
||||
s += e * e;
|
||||
}
|
||||
running += s;
|
||||
// Cumulative-mean normalisation: suppresses the zero-lag dip.
|
||||
d[tau] = if running > 0. {
|
||||
s * tau as f32 / running
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
}
|
||||
let mut best = None;
|
||||
let mut tau = lo;
|
||||
while tau < hi {
|
||||
if d[tau] < self.threshold {
|
||||
while tau + 1 < hi && d[tau + 1] < d[tau] {
|
||||
tau += 1;
|
||||
}
|
||||
best = Some(tau);
|
||||
break;
|
||||
}
|
||||
tau += 1;
|
||||
}
|
||||
let tau = match best {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let (t, v) = (lo..hi)
|
||||
.map(|t| (t, d[t]))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
if v > 0.35 {
|
||||
return None;
|
||||
}
|
||||
t
|
||||
}
|
||||
};
|
||||
let (a, b, c) = (d[tau - 1], d[tau], d[tau + 1]);
|
||||
let denom = a - 2. * b + c;
|
||||
let shift = if denom.abs() > 1e-12 {
|
||||
(0.5 * (a - c) / denom).clamp(-1., 1.)
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
Some((rate / (tau as f32 + shift), (1. - b).clamp(0., 1.)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn midi_to_hz(note: f32) -> f32 {
|
||||
440. * 2f32.powf((note - 69.) / 12.)
|
||||
}
|
||||
pub fn hz_to_midi(hz: f32) -> f32 {
|
||||
69. + 12. * (hz.max(1e-3) / 440.).log2()
|
||||
}
|
||||
/// Scientific note name: MIDI 60 = C4, 43.65 Hz = F1. FL Studio labels MIDI 60 as C5.
|
||||
pub fn note_name(note: u8) -> String {
|
||||
const NAMES: [&str; 12] = [
|
||||
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
|
||||
];
|
||||
format!("{}{}", NAMES[note as usize % 12], note as i32 / 12 - 1)
|
||||
}
|
||||
pub fn db_to_gain(db: f32) -> f32 {
|
||||
10f32.powf(db / 20.)
|
||||
}
|
||||
pub fn gain_to_db(g: f32) -> f32 {
|
||||
20. * g.abs().max(1e-9).log10()
|
||||
}
|
||||
/// One-pole smoothing coefficient for a time constant in milliseconds.
|
||||
pub fn coefficient(rate: f32, ms: f32) -> f32 {
|
||||
if ms <= 0. {
|
||||
1.
|
||||
} else {
|
||||
1. - (-1. / (rate * ms * 0.001)).exp()
|
||||
}
|
||||
}
|
||||
/// PolyBLEP sawtooth oscillator: cheap and close to band-limited, for tests and previews.
|
||||
pub struct Saw {
|
||||
phase: f32,
|
||||
inc: f32,
|
||||
}
|
||||
impl Saw {
|
||||
pub fn new(rate: f32, hz: f32) -> Self {
|
||||
Self {
|
||||
phase: 0.,
|
||||
inc: hz / rate,
|
||||
}
|
||||
}
|
||||
pub fn sample(&mut self) -> f32 {
|
||||
let t = self.phase;
|
||||
let dt = self.inc;
|
||||
let blep = if t < dt {
|
||||
let x = t / dt;
|
||||
x + x - x * x - 1.
|
||||
} else if t > 1. - dt {
|
||||
let x = (t - 1.) / dt;
|
||||
x * x + x + x + 1.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
self.phase += dt;
|
||||
if self.phase >= 1. {
|
||||
self.phase -= 1.;
|
||||
}
|
||||
2. * t - 1. - blep
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Minimal RIFF/WAVE writer and reader: 32-bit float data, sampler root key, Serum wavetable tag.
|
||||
use std::{io, path::Path};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Wave {
|
||||
pub channels: u16,
|
||||
pub rate: u32,
|
||||
/// Interleaved samples.
|
||||
pub samples: Vec<f32>,
|
||||
/// MIDI root key, written as a `smpl` chunk that samplers (FL included) read.
|
||||
pub root: Option<u8>,
|
||||
/// Wavetable frame size, written as Serum's `clm ` chunk.
|
||||
pub frame: Option<u32>,
|
||||
}
|
||||
|
||||
fn chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(w: &Wave) -> Vec<u8> {
|
||||
let channels = w.channels.max(1);
|
||||
let frames = w.samples.len() / channels as usize;
|
||||
let mut body = Vec::with_capacity(w.samples.len() * 4 + 256);
|
||||
body.extend_from_slice(b"WAVE");
|
||||
let mut fmt = Vec::new();
|
||||
fmt.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
|
||||
fmt.extend_from_slice(&channels.to_le_bytes());
|
||||
fmt.extend_from_slice(&w.rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&(w.rate * 4 * channels as u32).to_le_bytes());
|
||||
fmt.extend_from_slice(&(4 * channels).to_le_bytes());
|
||||
fmt.extend_from_slice(&32u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&0u16.to_le_bytes());
|
||||
chunk(&mut body, b"fmt ", &fmt);
|
||||
chunk(&mut body, b"fact", &(frames as u32).to_le_bytes());
|
||||
if let Some(frame) = w.frame {
|
||||
// Serum reads the frame size from the "<!>NNNN" prefix.
|
||||
chunk(
|
||||
&mut body,
|
||||
b"clm ",
|
||||
format!("<!>{frame} 00000000 wavetable (Crucible)").as_bytes(),
|
||||
);
|
||||
}
|
||||
if let Some(root) = w.root {
|
||||
let mut smpl = Vec::new();
|
||||
let period = (1e9 / w.rate.max(1) as f64).round() as u32;
|
||||
for v in [0, 0, period, root as u32, 0, 0, 0, 0, 0] {
|
||||
smpl.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"smpl", &smpl);
|
||||
}
|
||||
let mut data = Vec::with_capacity(w.samples.len() * 4);
|
||||
for s in &w.samples[..frames * channels as usize] {
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
chunk(&mut body, b"data", &data);
|
||||
let mut out = Vec::with_capacity(body.len() + 8);
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn write(path: &Path, w: &Wave) -> io::Result<()> {
|
||||
std::fs::write(path, encode(w))
|
||||
}
|
||||
|
||||
/// Reads float32 or PCM16/24 files written by this module or common tools.
|
||||
pub fn decode(b: &[u8]) -> Result<Wave, &'static str> {
|
||||
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
|
||||
return Err("expected RIFF/WAVE");
|
||||
}
|
||||
let mut w = Wave::default();
|
||||
let (mut format, mut bits) = (0u16, 0u16);
|
||||
let mut at = 12;
|
||||
let mut data = false;
|
||||
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
let u32_at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||
while at + 8 <= b.len() {
|
||||
let id = &b[at..at + 4];
|
||||
let len = u32_at(at + 4) as usize;
|
||||
let start = at + 8;
|
||||
let end = start
|
||||
.checked_add(len)
|
||||
.filter(|e| *e <= b.len())
|
||||
.ok_or("truncated chunk")?;
|
||||
match id {
|
||||
b"fmt " if len >= 16 => {
|
||||
format = u16_at(start);
|
||||
w.channels = u16_at(start + 2);
|
||||
w.rate = u32_at(start + 4);
|
||||
bits = u16_at(start + 14);
|
||||
}
|
||||
b"smpl" if len >= 16 => w.root = Some(u32_at(start + 12) as u8),
|
||||
b"clm " => {
|
||||
let text = std::str::from_utf8(&b[start..end]).map_err(|_| "bad clm chunk")?;
|
||||
w.frame = text
|
||||
.strip_prefix("<!>")
|
||||
.and_then(|t| t.split_whitespace().next())
|
||||
.and_then(|n| n.parse().ok());
|
||||
}
|
||||
b"data" => {
|
||||
data = true;
|
||||
let d = &b[start..end];
|
||||
w.samples = match (format, bits) {
|
||||
(3, 32) => d
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| f32::from_le_bytes(*c))
|
||||
.collect(),
|
||||
(1, 16) => d
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| i16::from_le_bytes(*c) as f32 / 32768.)
|
||||
.collect(),
|
||||
(1, 24) => d
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| (i32::from_le_bytes([0, c[0], c[1], c[2]]) >> 8) as f32 / 8388608.)
|
||||
.collect(),
|
||||
_ => return Err("unsupported sample format"),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
at = end + (len % 2);
|
||||
}
|
||||
if w.channels == 0 || w.rate == 0 {
|
||||
return Err("missing fmt chunk");
|
||||
}
|
||||
if !data {
|
||||
return Err("missing data chunk");
|
||||
}
|
||||
Ok(w)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use common_dsp::{hz_to_midi, midi_to_hz, note_name, wav, Crossover, Saw, Yin};
|
||||
|
||||
fn cents(a: f32, b: f32) -> f32 {
|
||||
1200. * (a / b).log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_tracks_bass_fundamentals_within_two_cents() {
|
||||
let rate = 48000.;
|
||||
let mut yin = Yin::new(2400);
|
||||
for hz in [43.65, 55., 110., 220.] {
|
||||
let mut saw = Saw::new(rate, hz);
|
||||
let x: Vec<f32> = (0..6000).map(|_| saw.sample()).collect();
|
||||
let (got, clarity) = yin.estimate(&x, rate, 25., 1000.).unwrap();
|
||||
assert!(cents(got, hz).abs() < 2., "{hz}: {got}");
|
||||
assert!(clarity > 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yin_rejects_silence_and_noise() {
|
||||
let mut yin = Yin::new(2400);
|
||||
assert!(yin.estimate(&[0.; 6000], 48000., 25., 1000.).is_none());
|
||||
let mut seed = 1u32;
|
||||
let noise: Vec<f32> = (0..6000)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
|
||||
})
|
||||
.collect();
|
||||
assert!(yin.estimate(&noise, 48000., 25., 1000.).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossover_bands_sum_to_flat_magnitude() {
|
||||
let rate = 48000.;
|
||||
for hz in [20., 45., 90., 180., 1000., 8000., 18000.] {
|
||||
let mut x = Crossover::new(rate, 90.);
|
||||
let (mut a, mut b) = (0f64, 0f64);
|
||||
let n = 96000;
|
||||
for i in 0..n {
|
||||
let s = (std::f32::consts::TAU * hz * i as f32 / rate).sin();
|
||||
let (l, h) = x.process(s);
|
||||
if i > n / 2 {
|
||||
a += (s as f64).powi(2);
|
||||
b += ((l + h) as f64).powi(2);
|
||||
}
|
||||
}
|
||||
let db = 10. * (b / a).log10();
|
||||
assert!(db.abs() < 0.1, "{hz} Hz: {db} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_round_trip() {
|
||||
assert!((midi_to_hz(29.) - 43.65).abs() < 0.01);
|
||||
assert!((hz_to_midi(440.) - 69.).abs() < 1e-4);
|
||||
assert_eq!(note_name(29), "F1");
|
||||
assert_eq!(note_name(61), "C#4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave_round_trip_keeps_root_and_wavetable_chunks() {
|
||||
let w = wav::Wave {
|
||||
channels: 2,
|
||||
rate: 44100,
|
||||
samples: (0..999)
|
||||
.map(|i| (i as f32 * 0.01).sin())
|
||||
.collect::<Vec<_>>()[..998]
|
||||
.to_vec(),
|
||||
root: Some(29),
|
||||
frame: Some(2048),
|
||||
};
|
||||
let bytes = wav::encode(&w);
|
||||
assert_eq!(&bytes[..4], b"RIFF");
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize,
|
||||
bytes.len() - 8
|
||||
);
|
||||
assert_eq!(wav::decode(&bytes).unwrap(), w);
|
||||
assert!(wav::decode(&bytes[..40]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
@@ -0,0 +1,320 @@
|
||||
//! Shared hardware look for every plugin editor: palette, fonts, knobs, panels.
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
|
||||
pub const BG: Color32 = Color32::from_rgb(14, 15, 17);
|
||||
pub const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
|
||||
pub const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
|
||||
pub const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
|
||||
pub const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
|
||||
pub const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
|
||||
pub const DIM: Color32 = Color32::from_rgb(70, 74, 82);
|
||||
/// Signal colour: cuts, active states, value arcs.
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
|
||||
/// Measured input and neutral data.
|
||||
pub const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
|
||||
/// Focus / protect curve, distinct from cuts without competing with them.
|
||||
pub const FOCUS: Color32 = Color32::from_rgb(126, 167, 216);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"regular".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"medium".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "regular".into());
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Name("medium".into()),
|
||||
vec![
|
||||
"medium".into(),
|
||||
"Ubuntu-Light".into(),
|
||||
"NotoEmoji-Regular".into(),
|
||||
"emoji-icon-font".into(),
|
||||
],
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
let v = &mut s.visuals;
|
||||
*v = egui::Visuals::dark();
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = PANEL;
|
||||
v.extreme_bg_color = SCREEN;
|
||||
v.faint_bg_color = PANEL;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.selection.bg_fill = Color32::from_rgb(110, 6, 46);
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.hyperlink_color = ACCENT;
|
||||
v.window_stroke = Stroke::new(1.0_f32, EDGE);
|
||||
for (w, fill, stroke) in [
|
||||
(&mut v.widgets.noninteractive, PANEL, EDGE),
|
||||
(&mut v.widgets.inactive, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
(
|
||||
&mut v.widgets.hovered,
|
||||
Color32::from_rgb(45, 48, 54),
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
),
|
||||
(&mut v.widgets.active, Color32::from_rgb(58, 18, 34), ACCENT),
|
||||
(&mut v.widgets.open, Color32::from_rgb(35, 38, 43), EDGE),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.bg_stroke = Stroke::new(1.0_f32, stroke);
|
||||
w.corner_radius = 3.into();
|
||||
}
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.slider_trailing_fill = true;
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||||
ctx.set_style(s);
|
||||
}
|
||||
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
|
||||
pub fn metal_disc(p: &egui::Painter, center: Pos2, radius: f32, top: Color32, bottom: Color32) {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let mix = |t: f32| {
|
||||
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Color32::from_rgb(
|
||||
l(top.r(), bottom.r()),
|
||||
l(top.g(), bottom.g()),
|
||||
l(top.b(), bottom.b()),
|
||||
)
|
||||
};
|
||||
mesh.colored_vertex(center, mix(0.5));
|
||||
let n = 48;
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * i as f32 / n as f32;
|
||||
let point = center + Vec2::angled(a) * radius;
|
||||
mesh.colored_vertex(point, mix((point.y - (center.y - radius)) / (2. * radius)));
|
||||
if i > 0 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn rotary(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) -> egui::Response {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
}
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|i| i.pointer.delta().y);
|
||||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
*value = default;
|
||||
}
|
||||
if response.has_focus() {
|
||||
let step = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||||
*value = (*value + step).min(high);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||||
{
|
||||
*value = (*value - step).max(low);
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.46;
|
||||
let p = ui.painter();
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
|
||||
// Scale track and value arc around the knob.
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(2.5_f32, DIM)),
|
||||
(normalized, Stroke::new(2.5_f32, color)),
|
||||
] {
|
||||
// A zero-length polyline tessellates to NaN vertices.
|
||||
if fraction < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 1.5)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
// Machined body: drop shadow, brushed gradient skirt, flat cap.
|
||||
let body = radius - 7.;
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 2.),
|
||||
body + 1.,
|
||||
Color32::from_black_alpha(140),
|
||||
);
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
body,
|
||||
Color32::from_rgb(78, 82, 90),
|
||||
Color32::from_rgb(28, 30, 34),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
body,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(10, 11, 12)),
|
||||
);
|
||||
let cap = body * 0.74;
|
||||
metal_disc(
|
||||
p,
|
||||
center,
|
||||
cap,
|
||||
Color32::from_rgb(44, 47, 52),
|
||||
Color32::from_rgb(33, 35, 40),
|
||||
);
|
||||
p.circle_stroke(
|
||||
center,
|
||||
cap,
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)),
|
||||
);
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (cap * 0.35),
|
||||
center + Vec2::angled(angle) * (cap - 2.),
|
||||
],
|
||||
Stroke::new(2.0_f32, TEXT),
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name.to_uppercase())
|
||||
.size(if size > 100. { 14. } else { 12. })
|
||||
.color(MUTED),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
response
|
||||
}
|
||||
/// Vertical gradient rectangle, used for panel faces.
|
||||
pub fn gradient(rect: Rect, top: Color32, bottom: Color32) -> egui::Shape {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (pos, color) in [
|
||||
(rect.left_top(), top),
|
||||
(rect.right_top(), top),
|
||||
(rect.right_bottom(), bottom),
|
||||
(rect.left_bottom(), bottom),
|
||||
] {
|
||||
mesh.colored_vertex(pos, color);
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
egui::Shape::mesh(mesh)
|
||||
}
|
||||
/// Raised hardware panel: graphite face, hairline edge, faint top highlight.
|
||||
pub fn panel<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let face = ui.painter().add(egui::Shape::Noop);
|
||||
let inner = egui::Frame::new()
|
||||
.inner_margin(14.)
|
||||
.stroke(Stroke::new(1.0_f32, EDGE))
|
||||
.show(ui, add);
|
||||
let r = inner.response.rect;
|
||||
ui.painter().set(face, gradient(r, PANEL_TOP, PANEL));
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
r.left_top() + Vec2::new(1., 1.),
|
||||
r.right_top() + Vec2::new(-1., 1.),
|
||||
],
|
||||
Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 16)),
|
||||
);
|
||||
inner.inner
|
||||
}
|
||||
pub fn caption(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).size(11.).color(MUTED));
|
||||
}
|
||||
/// Small labelled hardware-style readout.
|
||||
pub fn readout(ui: &mut egui::Ui, label: &str, value: String, color: Color32) {
|
||||
ui.label(RichText::new(label).size(11.).color(MUTED));
|
||||
ui.label(RichText::new(value).monospace().size(13.).color(color));
|
||||
ui.add_space(10.);
|
||||
}
|
||||
/// Product header: accent mark, product name, subtitle, then right-aligned controls.
|
||||
pub fn header(ui: &mut egui::Ui, name: &str, subtitle: &str, right: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(RichText::new(subtitle).size(12.).color(MUTED));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
|
||||
});
|
||||
}
|
||||
/// Full-window backdrop gradient used behind every editor.
|
||||
pub fn backdrop(ui: &egui::Ui) {
|
||||
ui.painter()
|
||||
.add(gradient(ui.max_rect(), Color32::from_rgb(20, 21, 24), BG));
|
||||
}
|
||||
/// Horizontal level bar in dBFS (−60..0) with a hairline frame.
|
||||
pub fn meter(ui: &mut egui::Ui, label: &str, db: f32, color: Color32, width: f32) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
Vec2::new(70., 16.),
|
||||
egui::Label::new(RichText::new(label).size(11.).color(MUTED)),
|
||||
);
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::new(width, 10.), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 1., SCREEN);
|
||||
let t = ((db + 60.) / 60.).clamp(0., 1.);
|
||||
let mut fill = rect;
|
||||
fill.set_width(rect.width() * t);
|
||||
p.rect_filled(fill, 1., color);
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
1.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if db <= -60. {
|
||||
"−∞".to_string()
|
||||
} else {
|
||||
format!("{db:.1} dB")
|
||||
})
|
||||
.monospace()
|
||||
.size(12.)
|
||||
.color(TEXT),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "crucible-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
crossbeam-queue = "0.3"
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Writes a take as ready-to-use files: the full loop, one file per slice and a wavetable.
|
||||
use crate::settings::Settings;
|
||||
use crate::take::{render, wavetable, Take, TABLE};
|
||||
use common_dsp::{note_name, wav};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Exported {
|
||||
pub folder: PathBuf,
|
||||
pub files: usize,
|
||||
/// Why no wavetable was written although one was requested.
|
||||
pub wavetable_skipped: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// UTC date and time for folder names, from the Unix time in seconds.
|
||||
pub fn stamp(unix: u64) -> String {
|
||||
let days = (unix / 86400) as i64;
|
||||
let secs = unix % 86400;
|
||||
// Civil-from-days (H. Hinnant), proleptic Gregorian calendar.
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!(
|
||||
"{year:04}-{month:02}-{day:02}_{:02}{:02}{:02}",
|
||||
secs / 3600,
|
||||
secs / 60 % 60,
|
||||
secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
pub fn export(
|
||||
take: &Take,
|
||||
settings: &Settings,
|
||||
selected: Option<usize>,
|
||||
root: &Path,
|
||||
unix: u64,
|
||||
) -> std::io::Result<Exported> {
|
||||
let folder = root.join(format!("{}_{:.0}bpm", stamp(unix), take.tempo()));
|
||||
std::fs::create_dir_all(&folder)?;
|
||||
let rate = take.rate.round() as u32;
|
||||
let stereo = |samples: Vec<f32>, root: Option<u8>| wav::Wave {
|
||||
channels: 2,
|
||||
rate,
|
||||
samples,
|
||||
root,
|
||||
frame: None,
|
||||
};
|
||||
wav::write(&folder.join("loop.wav"), &stereo(take.audio.clone(), None))?;
|
||||
let mut files = 1;
|
||||
for (i, s) in take.slices.iter().enumerate().filter(|(_, s)| s.enabled) {
|
||||
let name = match s.root {
|
||||
Some(n) => format!("s{:02}_{}.wav", i + 1, note_name(n)),
|
||||
None => format!("s{:02}.wav", i + 1),
|
||||
};
|
||||
wav::write(
|
||||
&folder.join(name),
|
||||
&stereo(render(take, s, settings), s.root),
|
||||
)?;
|
||||
files += 1;
|
||||
}
|
||||
let mut wavetable_skipped = None;
|
||||
if settings.wavetable {
|
||||
let source = selected
|
||||
.and_then(|i| take.slices.get(i))
|
||||
.or_else(|| take.slices.iter().find(|s| s.enabled && s.root.is_some()));
|
||||
match source.map(|s| wavetable(&take.mono(s.start, s.end), take.rate)) {
|
||||
Some(Some(table)) => {
|
||||
let w = wav::Wave {
|
||||
channels: 1,
|
||||
rate,
|
||||
samples: table,
|
||||
root: None,
|
||||
frame: Some(TABLE as u32),
|
||||
};
|
||||
wav::write(&folder.join("wavetable.wav"), &w)?;
|
||||
files += 1;
|
||||
}
|
||||
Some(None) => wavetable_skipped = Some("the slice has no clear pitch"),
|
||||
None => wavetable_skipped = Some("no slice selected"),
|
||||
}
|
||||
}
|
||||
Ok(Exported {
|
||||
folder,
|
||||
files,
|
||||
wavetable_skipped,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Resampler: always records the input, captures the newest bars on request, slices them,
|
||||
//! cleans each slice and writes ready-to-use files.
|
||||
mod export;
|
||||
mod player;
|
||||
mod ring;
|
||||
mod settings;
|
||||
mod take;
|
||||
pub use export::{export, stamp, Exported};
|
||||
pub use player::{Clip, Player, Queues};
|
||||
pub use ring::{Marker, Recorder, Ring, Transport, MARKERS};
|
||||
pub use settings::{default_folder, Division, Mode, Settings};
|
||||
pub use take::{
|
||||
capture, render, root_key, transients, wavetable, Segment, Slice, Take, FALLBACK_TEMPO,
|
||||
OVERVIEW, TABLE, TABLE_FRAMES,
|
||||
};
|
||||
|
||||
/// Seconds of history: 16 bars at 60 BPM in 4/4, plus slack for the capture copy.
|
||||
pub const HISTORY: f32 = 72.;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Audition of slices on the audio thread without allocating or freeing there.
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub type Clip = Arc<[f32]>;
|
||||
|
||||
/// Shared queues. The GUI sends clips and drains finished ones, so all frees happen there.
|
||||
pub struct Queues {
|
||||
incoming: ArrayQueue<Option<Clip>>,
|
||||
finished: ArrayQueue<Clip>,
|
||||
}
|
||||
impl Default for Queues {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
incoming: ArrayQueue::new(4),
|
||||
finished: ArrayQueue::new(16),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Queues {
|
||||
/// Plays an interleaved stereo clip, replacing the current one.
|
||||
pub fn play(&self, clip: Clip) {
|
||||
self.drain();
|
||||
let _ = self.incoming.force_push(Some(clip));
|
||||
}
|
||||
pub fn stop(&self) {
|
||||
let _ = self.incoming.force_push(None);
|
||||
}
|
||||
/// Frees finished clips. Call regularly from the GUI thread.
|
||||
pub fn drain(&self) {
|
||||
while self.finished.pop().is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Player {
|
||||
clip: Option<Clip>,
|
||||
at: usize,
|
||||
}
|
||||
impl Player {
|
||||
fn retire(&mut self, queues: &Queues) {
|
||||
if let Some(old) = self.clip.take() {
|
||||
// The GUI drains this queue every frame; a full queue only happens with no editor.
|
||||
if let Err(old) = queues.finished.push(old) {
|
||||
std::mem::forget(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Call once per block.
|
||||
pub fn block(&mut self, queues: &Queues) {
|
||||
while let Some(next) = queues.incoming.pop() {
|
||||
self.retire(queues);
|
||||
self.clip = next;
|
||||
self.at = 0;
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn next(&mut self, queues: &Queues) -> [f32; 2] {
|
||||
let Some(clip) = &self.clip else {
|
||||
return [0.; 2];
|
||||
};
|
||||
if self.at + 1 >= clip.len() {
|
||||
self.retire(queues);
|
||||
return [0.; 2];
|
||||
}
|
||||
let out = [clip[self.at], clip[self.at + 1]];
|
||||
self.at += 2;
|
||||
out
|
||||
}
|
||||
pub fn playing(&self) -> bool {
|
||||
self.clip.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Lock-free history of the input plus the host transport, shared by the audio and GUI threads.
|
||||
//!
|
||||
//! The audio thread writes samples with relaxed stores and publishes the frame count with a
|
||||
//! release store. The GUI thread reads the count with acquire, copies, then checks the count
|
||||
//! again: anything the writer may have overwritten during the copy is reported, never returned.
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
/// Transport markers kept for alignment; one is written at least every half second.
|
||||
pub const MARKERS: usize = 512;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Transport {
|
||||
pub playing: bool,
|
||||
pub tempo: f64,
|
||||
/// Song position in quarter notes at the first sample of the block.
|
||||
pub beats: f64,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
}
|
||||
impl Transport {
|
||||
/// Bar length in quarter notes.
|
||||
pub fn bar(&self) -> f64 {
|
||||
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Marker {
|
||||
/// Absolute recorded frame index.
|
||||
pub frame: u64,
|
||||
pub transport: Transport,
|
||||
}
|
||||
impl Marker {
|
||||
/// Song position at `frame`, extrapolated at this marker's tempo.
|
||||
pub fn beats_at(&self, frame: f64, rate: f64) -> f64 {
|
||||
self.transport.beats + (frame - self.frame as f64) / rate * self.transport.tempo / 60.
|
||||
}
|
||||
pub fn frame_at(&self, beats: f64, rate: f64) -> f64 {
|
||||
self.frame as f64 + (beats - self.transport.beats) * 60. / self.transport.tempo * rate
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ring {
|
||||
rate: f32,
|
||||
frames: usize,
|
||||
data: Box<[AtomicU32]>,
|
||||
written: AtomicU64,
|
||||
markers: Box<[[AtomicU64; 4]]>,
|
||||
marker_count: AtomicU64,
|
||||
peak: AtomicU32,
|
||||
}
|
||||
impl Ring {
|
||||
/// Stereo history of `seconds` at `rate`.
|
||||
pub fn new(rate: f32, seconds: f32) -> Self {
|
||||
let frames = (rate * seconds).ceil().max(1.) as usize;
|
||||
Self {
|
||||
rate,
|
||||
frames,
|
||||
data: (0..frames * 2).map(|_| AtomicU32::new(0)).collect(),
|
||||
written: AtomicU64::new(0),
|
||||
markers: (0..MARKERS)
|
||||
.map(|_| std::array::from_fn(|_| AtomicU64::new(0)))
|
||||
.collect(),
|
||||
marker_count: AtomicU64::new(0),
|
||||
peak: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
pub fn rate(&self) -> f32 {
|
||||
self.rate
|
||||
}
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.frames
|
||||
}
|
||||
pub fn written(&self) -> u64 {
|
||||
self.written.load(Ordering::Acquire)
|
||||
}
|
||||
/// Input peak since the last call, for the editor meter.
|
||||
pub fn take_peak(&self) -> f32 {
|
||||
f32::from_bits(self.peak.swap(0, Ordering::AcqRel))
|
||||
}
|
||||
/// Markers that are certainly intact, oldest first.
|
||||
pub fn markers(&self) -> Vec<Marker> {
|
||||
let count = self.marker_count.load(Ordering::Acquire);
|
||||
// A margin keeps slots the writer may be refilling during this read out of the result.
|
||||
let first = count.saturating_sub(MARKERS as u64 - 8);
|
||||
let mut out: Vec<Marker> = (first..count)
|
||||
.map(|i| {
|
||||
let s = &self.markers[(i % MARKERS as u64) as usize];
|
||||
let meta = s[3].load(Ordering::Relaxed);
|
||||
Marker {
|
||||
frame: s[0].load(Ordering::Relaxed),
|
||||
transport: Transport {
|
||||
beats: f64::from_bits(s[1].load(Ordering::Relaxed)),
|
||||
tempo: f64::from_bits(s[2].load(Ordering::Relaxed)),
|
||||
numerator: (meta & 0xffff) as u32,
|
||||
denominator: ((meta >> 16) & 0xffff) as u32,
|
||||
playing: meta >> 32 & 1 == 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let after = self.marker_count.load(Ordering::Acquire);
|
||||
let safe = after.saturating_sub(MARKERS as u64 - 8);
|
||||
out.drain(..(safe.saturating_sub(first) as usize).min(out.len()));
|
||||
out
|
||||
}
|
||||
/// Copies frames `[start, end)` as interleaved stereo. Fails if any were overwritten.
|
||||
pub fn copy(&self, start: u64, end: u64) -> Result<Vec<f32>, &'static str> {
|
||||
let written = self.written();
|
||||
if end > written || start > end {
|
||||
return Err("Capture range is not recorded yet.");
|
||||
}
|
||||
if written - start > self.frames as u64 {
|
||||
return Err("Capture is longer than the recording buffer.");
|
||||
}
|
||||
let mut out = Vec::with_capacity(((end - start) * 2) as usize);
|
||||
for f in start..end {
|
||||
let at = (f % self.frames as u64) as usize * 2;
|
||||
out.push(f32::from_bits(self.data[at].load(Ordering::Relaxed)));
|
||||
out.push(f32::from_bits(self.data[at + 1].load(Ordering::Relaxed)));
|
||||
}
|
||||
if self.written() - start > self.frames as u64 {
|
||||
return Err("Capture was overwritten while copying. Try again.");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio-thread writer. Never allocates.
|
||||
pub struct Recorder {
|
||||
last: Option<Marker>,
|
||||
frame: u64,
|
||||
since_marker: u64,
|
||||
peak: f32,
|
||||
}
|
||||
impl Default for Recorder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Recorder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last: None,
|
||||
frame: 0,
|
||||
since_marker: 0,
|
||||
peak: 0.,
|
||||
}
|
||||
}
|
||||
/// Call at the start of every block with the host transport.
|
||||
pub fn block(&mut self, ring: &Ring, transport: Option<Transport>) {
|
||||
let Some(t) =
|
||||
transport.filter(|t| t.tempo.is_finite() && t.tempo > 1. && t.beats.is_finite())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let rate = ring.rate as f64;
|
||||
let changed = match self.last {
|
||||
None => true,
|
||||
Some(m) => {
|
||||
let expected = m.beats_at(self.frame as f64, rate);
|
||||
m.transport.playing != t.playing
|
||||
|| (m.transport.tempo - t.tempo).abs() > 1e-6
|
||||
|| m.transport.numerator != t.numerator
|
||||
|| m.transport.denominator != t.denominator
|
||||
|| (t.playing && (expected - t.beats).abs() > 0.01)
|
||||
}
|
||||
};
|
||||
if changed || self.since_marker as f64 >= rate * 0.5 {
|
||||
let m = Marker {
|
||||
frame: self.frame,
|
||||
transport: t,
|
||||
};
|
||||
let count = ring.marker_count.load(Ordering::Relaxed);
|
||||
let s = &ring.markers[(count % MARKERS as u64) as usize];
|
||||
s[0].store(m.frame, Ordering::Relaxed);
|
||||
s[1].store(t.beats.to_bits(), Ordering::Relaxed);
|
||||
s[2].store(t.tempo.to_bits(), Ordering::Relaxed);
|
||||
let meta = (t.numerator as u64 & 0xffff)
|
||||
| (t.denominator as u64 & 0xffff) << 16
|
||||
| (t.playing as u64) << 32;
|
||||
s[3].store(meta, Ordering::Relaxed);
|
||||
ring.marker_count.store(count + 1, Ordering::Release);
|
||||
self.last = Some(m);
|
||||
self.since_marker = 0;
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
pub fn push(&mut self, ring: &Ring, frame: [f32; 2]) {
|
||||
let at = (self.frame % ring.frames as u64) as usize * 2;
|
||||
ring.data[at].store(frame[0].to_bits(), Ordering::Relaxed);
|
||||
ring.data[at + 1].store(frame[1].to_bits(), Ordering::Relaxed);
|
||||
self.frame += 1;
|
||||
self.since_marker += 1;
|
||||
self.peak = self.peak.max(frame[0].abs()).max(frame[1].abs());
|
||||
}
|
||||
/// Makes the frames pushed so far visible to the GUI thread. Call at the end of a block.
|
||||
pub fn publish(&mut self, ring: &Ring) {
|
||||
ring.written.store(self.frame, Ordering::Release);
|
||||
let old = f32::from_bits(ring.peak.load(Ordering::Relaxed));
|
||||
if self.peak > old {
|
||||
ring.peak.store(self.peak.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
self.peak = 0.;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Persisted editor choices. Takes are not part of plugin state; exported files are.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Mode {
|
||||
/// Cut on the bar/beat grid.
|
||||
#[default]
|
||||
Grid,
|
||||
/// Cut where a new hit starts.
|
||||
Transient,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Division {
|
||||
Quarter,
|
||||
Eighth,
|
||||
#[default]
|
||||
Sixteenth,
|
||||
EighthTriplet,
|
||||
SixteenthTriplet,
|
||||
}
|
||||
impl Division {
|
||||
pub const ALL: [Division; 5] = [
|
||||
Division::Quarter,
|
||||
Division::Eighth,
|
||||
Division::Sixteenth,
|
||||
Division::EighthTriplet,
|
||||
Division::SixteenthTriplet,
|
||||
];
|
||||
/// Slices per quarter note.
|
||||
pub fn per_quarter(self) -> u32 {
|
||||
match self {
|
||||
Division::Quarter => 1,
|
||||
Division::Eighth => 2,
|
||||
Division::Sixteenth => 4,
|
||||
Division::EighthTriplet => 3,
|
||||
Division::SixteenthTriplet => 6,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Division::Quarter => "1/4",
|
||||
Division::Eighth => "1/8",
|
||||
Division::Sixteenth => "1/16",
|
||||
Division::EighthTriplet => "1/8T",
|
||||
Division::SixteenthTriplet => "1/16T",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub bars: u32,
|
||||
pub mode: Mode,
|
||||
pub division: Division,
|
||||
/// Transient sensitivity, 0–100 %.
|
||||
pub sensitivity: f32,
|
||||
/// Fade length at both slice edges in milliseconds.
|
||||
pub declick: f32,
|
||||
/// Peak-normalise each slice to −0.3 dBFS.
|
||||
pub normalize: bool,
|
||||
/// Remove leading silence below −60 dBFS.
|
||||
pub trim: bool,
|
||||
/// Also write a wavetable from the selected slice.
|
||||
pub wavetable: bool,
|
||||
/// Export folder; empty selects the default.
|
||||
pub folder: String,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bars: 4,
|
||||
mode: Mode::Grid,
|
||||
division: Division::Sixteenth,
|
||||
sensitivity: 50.,
|
||||
declick: 2.,
|
||||
normalize: true,
|
||||
trim: true,
|
||||
wavetable: false,
|
||||
folder: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub const BARS: [u32; 5] = [1, 2, 4, 8, 16];
|
||||
/// Clamps values from old or hand-edited state into their ranges.
|
||||
pub fn sanitize(&mut self) {
|
||||
if !Self::BARS.contains(&self.bars) {
|
||||
self.bars = 4;
|
||||
}
|
||||
self.sensitivity = if self.sensitivity.is_finite() {
|
||||
self.sensitivity.clamp(0., 100.)
|
||||
} else {
|
||||
50.
|
||||
};
|
||||
self.declick = if self.declick.is_finite() {
|
||||
self.declick.clamp(0., 20.)
|
||||
} else {
|
||||
2.
|
||||
};
|
||||
}
|
||||
pub fn folder(&self) -> PathBuf {
|
||||
if !self.folder.trim().is_empty() {
|
||||
return PathBuf::from(self.folder.trim());
|
||||
}
|
||||
default_folder()
|
||||
}
|
||||
}
|
||||
pub fn default_folder() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
if let Some(home) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(home).join("Documents").join("Crucible");
|
||||
}
|
||||
}
|
||||
match std::env::var_os("HOME") {
|
||||
Some(home) => PathBuf::from(home).join("Crucible"),
|
||||
None => PathBuf::from("Crucible"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Captured audio with its tempo map, slice boundaries and per-slice cleanup.
|
||||
use crate::ring::{Marker, Ring, Transport};
|
||||
use crate::settings::{Division, Mode, Settings};
|
||||
use common_dsp::{hz_to_midi, Yin};
|
||||
|
||||
/// Used when the host reports no tempo.
|
||||
pub const FALLBACK_TEMPO: f64 = 140.;
|
||||
/// Waveform overview resolution.
|
||||
pub const OVERVIEW: usize = 2048;
|
||||
|
||||
/// One constant-tempo stretch of the take, relative to the take start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Segment {
|
||||
pub frame: f64,
|
||||
pub quarters: f64,
|
||||
pub tempo: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub enabled: bool,
|
||||
pub root: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Take {
|
||||
pub rate: f32,
|
||||
/// Interleaved stereo.
|
||||
pub audio: Vec<f32>,
|
||||
pub bars: u32,
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
/// True when aligned to the host's bar lines; false when the transport was stopped.
|
||||
pub synced: bool,
|
||||
pub segments: Vec<Segment>,
|
||||
/// Slice boundaries in frames, sorted, starting at 0 and ending at the take length.
|
||||
pub cuts: Vec<usize>,
|
||||
pub slices: Vec<Slice>,
|
||||
/// Min/max per overview bucket of the mono sum.
|
||||
pub overview: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Take {
|
||||
pub fn frames(&self) -> usize {
|
||||
self.audio.len() / 2
|
||||
}
|
||||
pub fn tempo(&self) -> f64 {
|
||||
self.segments.first().map_or(FALLBACK_TEMPO, |s| s.tempo)
|
||||
}
|
||||
pub fn bar_quarters(&self) -> f64 {
|
||||
self.numerator.max(1) as f64 * 4. / self.denominator.max(1) as f64
|
||||
}
|
||||
pub fn quarters(&self) -> f64 {
|
||||
self.bars as f64 * self.bar_quarters()
|
||||
}
|
||||
/// Frame at a position in quarter notes from the take start.
|
||||
pub fn frame_at(&self, quarters: f64) -> f64 {
|
||||
let s = self
|
||||
.segments
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|s| s.quarters <= quarters + 1e-9)
|
||||
.or(self.segments.first());
|
||||
match s {
|
||||
Some(s) => s.frame + (quarters - s.quarters) * 60. / s.tempo * self.rate as f64,
|
||||
None => quarters * 60. / FALLBACK_TEMPO * self.rate as f64,
|
||||
}
|
||||
}
|
||||
pub fn mono(&self, start: usize, end: usize) -> Vec<f32> {
|
||||
self.audio[start * 2..end * 2]
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|f| 0.5 * (f[0] + f[1]))
|
||||
.collect()
|
||||
}
|
||||
fn build_overview(&mut self) {
|
||||
let n = self.frames();
|
||||
self.overview = (0..OVERVIEW)
|
||||
.map(|b| {
|
||||
let (a, z) = (
|
||||
b * n / OVERVIEW,
|
||||
((b + 1) * n / OVERVIEW).max(b * n / OVERVIEW + 1),
|
||||
);
|
||||
let mut mm = [0f32, 0f32];
|
||||
for f in a..z.min(n) {
|
||||
let v = 0.5 * (self.audio[2 * f] + self.audio[2 * f + 1]);
|
||||
mm[0] = mm[0].min(v);
|
||||
mm[1] = mm[1].max(v);
|
||||
}
|
||||
mm
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub fn from_audio(
|
||||
rate: f32,
|
||||
audio: Vec<f32>,
|
||||
bars: u32,
|
||||
transport: Transport,
|
||||
segments: Vec<Segment>,
|
||||
synced: bool,
|
||||
) -> Self {
|
||||
let mut t = Take {
|
||||
rate,
|
||||
audio,
|
||||
bars,
|
||||
numerator: transport.numerator.max(1),
|
||||
denominator: transport.denominator.max(1),
|
||||
synced,
|
||||
segments,
|
||||
cuts: Vec::new(),
|
||||
slices: Vec::new(),
|
||||
overview: Vec::new(),
|
||||
};
|
||||
t.build_overview();
|
||||
let n = t.frames();
|
||||
t.cuts = vec![0, n];
|
||||
t.rebuild_slices();
|
||||
t
|
||||
}
|
||||
/// Re-cuts the take from the settings, replacing manual edits.
|
||||
pub fn slice(&mut self, settings: &Settings) {
|
||||
let n = self.frames();
|
||||
let mut cuts = match settings.mode {
|
||||
Mode::Grid => grid(self, settings.division),
|
||||
Mode::Transient => transients(&self.mono(0, n), self.rate, settings.sensitivity),
|
||||
};
|
||||
cuts.push(0);
|
||||
cuts.push(n);
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
cuts.retain(|c| *c <= n);
|
||||
self.cuts = cuts;
|
||||
self.rebuild_slices();
|
||||
}
|
||||
/// Adds a cut; returns false if it is too close to an existing one.
|
||||
pub fn add_cut(&mut self, frame: usize) -> bool {
|
||||
let min = (self.rate * 0.005) as usize;
|
||||
if frame == 0 || frame >= self.frames() || self.cuts.iter().any(|c| c.abs_diff(frame) < min)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = self.cuts.partition_point(|c| *c < frame);
|
||||
self.cuts.insert(at, frame);
|
||||
self.rebuild_slices();
|
||||
true
|
||||
}
|
||||
/// Removes the inner cut nearest to `frame`.
|
||||
pub fn remove_cut(&mut self, frame: usize) -> bool {
|
||||
let inner = 1..self.cuts.len().saturating_sub(1);
|
||||
let Some(i) = inner.min_by_key(|i| self.cuts[*i].abs_diff(frame)) else {
|
||||
return false;
|
||||
};
|
||||
self.cuts.remove(i);
|
||||
self.rebuild_slices();
|
||||
true
|
||||
}
|
||||
fn rebuild_slices(&mut self) {
|
||||
let old = std::mem::take(&mut self.slices);
|
||||
let mut yin = Yin::new((self.rate / 35.) as usize + 4);
|
||||
self.slices = self
|
||||
.cuts
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
// Manual include/exclude choices survive a re-cut when the slice keeps its start.
|
||||
let enabled = old
|
||||
.iter()
|
||||
.find(|s| s.start == w[0])
|
||||
.is_none_or(|s| s.enabled);
|
||||
let mono = self.mono(w[0], w[1]);
|
||||
Slice {
|
||||
start: w[0],
|
||||
end: w[1],
|
||||
enabled,
|
||||
root: root_key(&mut yin, &mono, self.rate),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub fn slice_at(&self, frame: usize) -> Option<usize> {
|
||||
self.slices
|
||||
.iter()
|
||||
.position(|s| frame >= s.start && frame < s.end)
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(take: &Take, division: Division) -> Vec<usize> {
|
||||
let per = division.per_quarter() as f64;
|
||||
let steps = (take.quarters() * per).round() as usize;
|
||||
(1..steps)
|
||||
.map(|k| take.frame_at(k as f64 / per).round() as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Onsets where a window's high-frequency weighted energy jumps above the window before it.
|
||||
/// Windows span 40 ms, longer than one cycle of the lowest bass note, so the edges inside a
|
||||
/// distorted bass cycle never count as hits. Each onset is refined to its first loud sample.
|
||||
pub fn transients(x: &[f32], rate: f32, sensitivity: f32) -> Vec<usize> {
|
||||
let hop = ((rate / 375.) as usize).max(32);
|
||||
let window = ((rate * 0.04) as usize / hop).max(1) * hop;
|
||||
if x.len() < 2 * window + hop {
|
||||
return Vec::new();
|
||||
}
|
||||
// Prefix sums of weighted energy, in f64 so long takes keep their precision.
|
||||
let mut sums = Vec::with_capacity(x.len() + 1);
|
||||
sums.push(0f64);
|
||||
let mut prev = 0f32;
|
||||
for v in x {
|
||||
let d = v - prev;
|
||||
prev = *v;
|
||||
let e = (v * v + 4. * d * d) as f64;
|
||||
sums.push(sums.last().unwrap() + e);
|
||||
}
|
||||
let level = |end: usize| {
|
||||
let e = (sums[end] - sums[end - window]) / window as f64;
|
||||
10. * (e + 1e-10).log10() as f32
|
||||
};
|
||||
let rise_db = 14. - 0.11 * sensitivity.clamp(0., 100.);
|
||||
let floor = -55.;
|
||||
let gap = (0.04 * rate) as usize;
|
||||
let mut cuts: Vec<usize> = Vec::new();
|
||||
let mut end = 2 * window;
|
||||
while end <= x.len() {
|
||||
let now = level(end);
|
||||
if now > floor && now - level(end - window) >= rise_db {
|
||||
let from = end - window;
|
||||
let to = (end + hop).min(x.len());
|
||||
let peak = x[from..to].iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
let first = x[from..to]
|
||||
.iter()
|
||||
.position(|v| v.abs() >= 0.3 * peak)
|
||||
.unwrap_or(0);
|
||||
// Start slightly before the first loud sample so the attack stays whole.
|
||||
let onset = (from + first).saturating_sub((rate * 0.0005) as usize);
|
||||
if cuts.last().is_none_or(|c| onset >= c + gap) {
|
||||
cuts.push(onset);
|
||||
// Skip past this attack so it is not found again as the window slides on.
|
||||
end = onset + window + gap;
|
||||
}
|
||||
}
|
||||
end += hop;
|
||||
}
|
||||
cuts
|
||||
}
|
||||
|
||||
/// Root note of a slice, if it is clearly pitched.
|
||||
pub fn root_key(yin: &mut Yin, mono: &[f32], rate: f32) -> Option<u8> {
|
||||
let window = (2 * yin.max_lag() + 2).min(mono.len());
|
||||
if window < (rate / 1000.) as usize * 4 || mono.len() < window {
|
||||
return None;
|
||||
}
|
||||
// Skip the attack: the body of a hit carries its pitch.
|
||||
let mut notes: Vec<f32> = Vec::new();
|
||||
let start = (mono.len() / 5).min(mono.len() - window);
|
||||
let step = (window / 2).max(1);
|
||||
let mut at = start;
|
||||
while at + window <= mono.len() && notes.len() < 6 {
|
||||
if let Some((hz, clarity)) = yin.estimate(&mono[at..at + window], rate, 35., 1000.) {
|
||||
if clarity > 0.7 {
|
||||
notes.push(hz_to_midi(hz));
|
||||
}
|
||||
}
|
||||
at += step;
|
||||
}
|
||||
if notes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
notes.sort_by(f32::total_cmp);
|
||||
let n = notes[notes.len() / 2].round();
|
||||
(0. ..=127.).contains(&n).then_some(n as u8)
|
||||
}
|
||||
|
||||
/// Builds a take from the newest `bars` complete bars, or from the newest audio when stopped.
|
||||
pub fn capture(ring: &Ring, bars: u32) -> Result<Take, &'static str> {
|
||||
let rate = ring.rate() as f64;
|
||||
let written = ring.written();
|
||||
let markers = ring.markers();
|
||||
let last = markers.iter().rev().find(|m| m.frame <= written).copied();
|
||||
let fallback = Transport {
|
||||
playing: false,
|
||||
tempo: FALLBACK_TEMPO,
|
||||
beats: 0.,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
};
|
||||
let t = last.map_or(fallback, |m| m.transport);
|
||||
let bar = t.bar();
|
||||
let quarters = bars as f64 * bar;
|
||||
let (start, end, segments, synced) = match last {
|
||||
Some(m) if m.transport.playing => {
|
||||
let now = m.beats_at(written as f64, rate);
|
||||
let end_beats = (now / bar + 1e-9).floor() * bar;
|
||||
let start_beats = end_beats - quarters;
|
||||
let end = frame_for(&markers, end_beats, rate);
|
||||
let start = frame_for(&markers, start_beats, rate);
|
||||
let segments = segments(&markers, start_beats, end_beats, start, rate);
|
||||
(start, end, segments, true)
|
||||
}
|
||||
_ => {
|
||||
let length = quarters * 60. / t.tempo * rate;
|
||||
let segments = vec![Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo: t.tempo,
|
||||
}];
|
||||
(written as f64 - length, written as f64, segments, false)
|
||||
}
|
||||
};
|
||||
if start < 0. {
|
||||
return Err("Not enough audio recorded yet. Play the section first.");
|
||||
}
|
||||
let (start, end) = (start.round() as u64, end.round() as u64);
|
||||
if end - start > ring.capacity() as u64 {
|
||||
return Err("Too long for the recording buffer. Capture fewer bars.");
|
||||
}
|
||||
let audio = ring.copy(start, end)?;
|
||||
Ok(Take::from_audio(
|
||||
ring.rate(),
|
||||
audio,
|
||||
bars,
|
||||
t,
|
||||
segments,
|
||||
synced,
|
||||
))
|
||||
}
|
||||
|
||||
/// Frame of a song position, from the marker whose stretch contains it.
|
||||
fn frame_for(markers: &[Marker], beats: f64, rate: f64) -> f64 {
|
||||
let m = markers
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.transport.playing && m.transport.beats <= beats + 1e-9)
|
||||
.or_else(|| markers.iter().find(|m| m.transport.playing));
|
||||
m.map_or(0., |m| m.frame_at(beats, rate))
|
||||
}
|
||||
|
||||
fn segments(markers: &[Marker], from: f64, to: f64, origin: f64, rate: f64) -> Vec<Segment> {
|
||||
let mut out: Vec<Segment> = Vec::new();
|
||||
for m in markers.iter().filter(|m| m.transport.playing) {
|
||||
let q = m.transport.beats - from;
|
||||
if m.transport.beats >= to {
|
||||
break;
|
||||
}
|
||||
let tempo = m.transport.tempo;
|
||||
if q <= 0. {
|
||||
out.clear();
|
||||
out.push(Segment {
|
||||
frame: m.frame_at(from, rate) - origin,
|
||||
quarters: 0.,
|
||||
tempo,
|
||||
});
|
||||
} else if out.last().is_none_or(|s| (s.tempo - tempo).abs() > 1e-6) {
|
||||
out.push(Segment {
|
||||
frame: m.frame as f64 - origin,
|
||||
quarters: q,
|
||||
tempo,
|
||||
});
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo: FALLBACK_TEMPO,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cleaned-up slice ready to save: trimmed, faded and optionally normalised.
|
||||
pub fn render(take: &Take, slice: &Slice, settings: &Settings) -> Vec<f32> {
|
||||
let mut start = slice.start;
|
||||
let end = slice.end;
|
||||
if settings.trim {
|
||||
let threshold = 10f32.powf(-60. / 20.);
|
||||
if let Some(i) = take.audio[start * 2..end * 2]
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.position(|f| f[0].abs().max(f[1].abs()) > threshold)
|
||||
{
|
||||
// Keep a short pre-roll so the attack is not cut into.
|
||||
start += i.saturating_sub((take.rate * 0.001) as usize);
|
||||
}
|
||||
}
|
||||
let mut out = take.audio[start * 2..end * 2].to_vec();
|
||||
let frames = out.len() / 2;
|
||||
let fade = ((settings.declick * 0.001 * take.rate) as usize).min(frames / 2);
|
||||
for i in 0..fade {
|
||||
let g = i as f32 / fade as f32;
|
||||
for c in 0..2 {
|
||||
out[2 * i + c] *= g;
|
||||
out[2 * (frames - 1 - i) + c] *= g;
|
||||
}
|
||||
}
|
||||
if settings.normalize {
|
||||
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
if peak > 1e-6 {
|
||||
let g = 10f32.powf(-0.3 / 20.) / peak;
|
||||
out.iter_mut().for_each(|v| *v *= g);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub const TABLE: usize = 2048;
|
||||
pub const TABLE_FRAMES: usize = 256;
|
||||
|
||||
/// Single-cycle frames from a pitched slice. Each frame starts on a rising zero crossing,
|
||||
/// so neighbouring frames stay in phase and morph smoothly in a wavetable synth.
|
||||
pub fn wavetable(mono: &[f32], rate: f32) -> Option<Vec<f32>> {
|
||||
let mut yin = Yin::new((rate / 30.) as usize + 4);
|
||||
let window = 2 * yin.max_lag() + 2;
|
||||
let probe = &mono[(mono.len() / 5).min(mono.len().saturating_sub(window))..];
|
||||
let (hz, _) = yin.estimate(&probe[..window.min(probe.len())], rate, 30., 2000.)?;
|
||||
let period = rate as f64 / hz as f64;
|
||||
let rising = |i: usize| mono[i - 1] < 0. && mono[i] >= 0.;
|
||||
let crossing = |i: usize| (i - 1) as f64 + mono[i - 1] as f64 / (mono[i - 1] - mono[i]) as f64;
|
||||
let mut starts = Vec::new();
|
||||
let mut at = (1..mono.len()).find(|i| rising(*i))?;
|
||||
starts.push(crossing(at));
|
||||
loop {
|
||||
let expect = starts.last().unwrap() + period;
|
||||
let tolerance = period * 0.2;
|
||||
let lo = (expect - tolerance).max(1.) as usize;
|
||||
let hi = ((expect + tolerance) as usize).min(mono.len() - 1);
|
||||
if lo >= hi {
|
||||
break;
|
||||
}
|
||||
// The rising crossing nearest one period on; slight drift and detune are followed.
|
||||
let Some(next) = (lo..=hi).filter(|i| rising(*i)).min_by(|a, b| {
|
||||
(crossing(*a) - expect)
|
||||
.abs()
|
||||
.total_cmp(&(crossing(*b) - expect).abs())
|
||||
}) else {
|
||||
// No crossing: skip one period and look again.
|
||||
at = (expect as usize).min(mono.len() - 1);
|
||||
match (at..mono.len()).find(|i| rising(*i)) {
|
||||
Some(i) => {
|
||||
starts.push(crossing(i));
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
starts.push(crossing(next));
|
||||
}
|
||||
let cycles: Vec<(f64, f64)> = starts
|
||||
.windows(2)
|
||||
.map(|w| (w[0], w[1]))
|
||||
.filter(|(a, b)| (b - a - period).abs() < period * 0.25)
|
||||
.collect();
|
||||
if cycles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let count = cycles.len().min(TABLE_FRAMES);
|
||||
let mut table = Vec::with_capacity(count * TABLE);
|
||||
for k in 0..count {
|
||||
let (a, b) = cycles[k * cycles.len() / count];
|
||||
for j in 0..TABLE {
|
||||
let pos = a + (b - a) * j as f64 / TABLE as f64;
|
||||
let i = pos.floor() as usize;
|
||||
let frac = (pos - i as f64) as f32;
|
||||
let x0 = mono[i.min(mono.len() - 1)];
|
||||
let x1 = mono[(i + 1).min(mono.len() - 1)];
|
||||
table.push(x0 + (x1 - x0) * frac);
|
||||
}
|
||||
}
|
||||
let peak = table.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
if peak < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let g = 10f32.powf(-0.3 / 20.) / peak;
|
||||
table.iter_mut().for_each(|v| *v *= g);
|
||||
Some(table)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use common_dsp::{wav, Saw};
|
||||
use crucible_core::*;
|
||||
|
||||
const RATE: f32 = 48000.;
|
||||
|
||||
fn playing(tempo: f64, beats: f64) -> Transport {
|
||||
Transport {
|
||||
playing: true,
|
||||
tempo,
|
||||
beats,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
}
|
||||
}
|
||||
/// Records `frames` of a ramp (sample value = frame index) in blocks, with transport.
|
||||
fn record(ring: &Ring, rec: &mut Recorder, from: u64, frames: u64, tempo: f64, beats0: f64) {
|
||||
let block = 480u64;
|
||||
let mut f = 0;
|
||||
while f < frames {
|
||||
let beats = beats0 + f as f64 / RATE as f64 * tempo / 60.;
|
||||
rec.block(ring, Some(playing(tempo, beats)));
|
||||
for i in 0..block.min(frames - f) {
|
||||
let v = (from + f + i) as f32;
|
||||
rec.push(ring, [v, -v]);
|
||||
}
|
||||
rec.publish(ring);
|
||||
f += block;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_copy_is_exact_across_the_wrap_and_detects_overwrites() {
|
||||
let ring = Ring::new(RATE, 1.);
|
||||
let mut rec = Recorder::new();
|
||||
record(&ring, &mut rec, 0, 70000, 120., 0.);
|
||||
let got = ring.copy(60000, 70000).unwrap();
|
||||
for (k, f) in got.chunks(2).enumerate() {
|
||||
assert_eq!(f[0], (60000 + k) as f32);
|
||||
assert_eq!(f[1], -((60000 + k) as f32));
|
||||
}
|
||||
assert!(ring.copy(10000, 20000).is_err());
|
||||
assert!(ring.copy(69000, 71000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_ends_on_the_last_bar_line() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
// 120 BPM: one beat = 24000 frames, one 4/4 bar = 96000 frames. Stop mid bar 4.
|
||||
record(&ring, &mut rec, 0, 96000 * 3 + 30000, 120., 0.);
|
||||
let take = capture(&ring, 2).unwrap();
|
||||
assert!(take.synced);
|
||||
assert_eq!(take.frames(), 2 * 96000);
|
||||
assert_eq!(take.audio[0], 96000.);
|
||||
assert_eq!(take.audio[take.audio.len() - 2], (3 * 96000 - 1) as f32);
|
||||
assert!(capture(&ring, 4).is_err(), "only three full bars exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_follows_tempo_changes() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
// 4 beats at 120 BPM (96000 frames), then 60 BPM (48000 frames per beat).
|
||||
record(&ring, &mut rec, 0, 96000, 120., 0.);
|
||||
record(&ring, &mut rec, 96000, 5 * 48000 + 1000, 60., 4.);
|
||||
let take = capture(&ring, 2).unwrap();
|
||||
// Bars 1–2 end at beat 8 = 96000 + 4 × 48000.
|
||||
assert_eq!(take.frames(), 96000 + 4 * 48000);
|
||||
assert_eq!(take.audio[0], 0.);
|
||||
assert_eq!(take.segments.len(), 2);
|
||||
assert!((take.frame_at(4.) - 96000.).abs() < 1.);
|
||||
assert!((take.frame_at(6.) - (96000. + 2. * 48000.)).abs() < 1.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_transport_captures_the_newest_audio() {
|
||||
let ring = Ring::new(RATE, HISTORY);
|
||||
let mut rec = Recorder::new();
|
||||
for _ in 0..500 {
|
||||
rec.block(
|
||||
&ring,
|
||||
Some(Transport {
|
||||
playing: false,
|
||||
..playing(150., 0.)
|
||||
}),
|
||||
);
|
||||
for _ in 0..480 {
|
||||
rec.push(&ring, [0.1, 0.1]);
|
||||
}
|
||||
rec.publish(&ring);
|
||||
}
|
||||
let take = capture(&ring, 1).unwrap();
|
||||
assert!(!take.synced);
|
||||
// One bar at 150 BPM = 1.6 s.
|
||||
assert_eq!(take.frames(), 76800);
|
||||
}
|
||||
|
||||
fn take_of(mono: Vec<f32>, tempo: f64, bars: u32) -> Take {
|
||||
let audio = mono.iter().flat_map(|v| [*v, *v]).collect();
|
||||
Take::from_audio(
|
||||
RATE,
|
||||
audio,
|
||||
bars,
|
||||
playing(tempo, 0.),
|
||||
vec![Segment {
|
||||
frame: 0.,
|
||||
quarters: 0.,
|
||||
tempo,
|
||||
}],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_slices_straight_and_triplet() {
|
||||
let mut take = take_of(vec![0.; 96000], 120., 1);
|
||||
let mut s = Settings {
|
||||
division: Division::Sixteenth,
|
||||
..Settings::default()
|
||||
};
|
||||
take.slice(&s);
|
||||
assert_eq!(take.slices.len(), 16);
|
||||
assert_eq!(take.cuts[1], 6000);
|
||||
s.division = Division::EighthTriplet;
|
||||
take.slice(&s);
|
||||
assert_eq!(take.slices.len(), 12);
|
||||
assert_eq!(take.cuts[1], 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transients_find_hits_within_two_milliseconds() {
|
||||
let mut x = vec![0.; 96000];
|
||||
let hits = [4800usize, 20000, 31111, 60000, 80000];
|
||||
for &h in &hits {
|
||||
for i in 0..2000 {
|
||||
x[h + i] = (i as f32 * 0.3).sin() * (-(i as f32) / 400.).exp() * 0.8;
|
||||
}
|
||||
}
|
||||
let found = transients(&x, RATE, 50.);
|
||||
assert_eq!(found.len(), hits.len(), "{found:?}");
|
||||
for (f, h) in found.iter().zip(hits) {
|
||||
assert!(f.abs_diff(h) < 96, "{f} vs {h}");
|
||||
}
|
||||
let mut take = take_of(x, 120., 1);
|
||||
take.slice(&Settings {
|
||||
mode: Mode::Transient,
|
||||
..Settings::default()
|
||||
});
|
||||
assert_eq!(take.slices.len(), hits.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_cuts_add_and_remove() {
|
||||
let mut take = take_of(vec![0.; 48000], 120., 1);
|
||||
assert!(take.add_cut(12000));
|
||||
assert!(!take.add_cut(12010), "too close");
|
||||
assert_eq!(take.slices.len(), 2);
|
||||
take.slices[1].enabled = false;
|
||||
assert!(take.add_cut(30000));
|
||||
assert!(!take.slices[1].enabled, "choice survives a re-cut");
|
||||
assert!(take.remove_cut(29000));
|
||||
assert_eq!(take.cuts, vec![0, 12000, 48000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_trims_fades_and_normalises() {
|
||||
let mut x = vec![0.; 4800];
|
||||
x[1000..]
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.for_each(|(i, v)| *v = 0.25 * (i as f32 * 0.05).sin().signum());
|
||||
let take = take_of(x, 120., 1);
|
||||
let out = render(&take, &take.slices[0], &Settings::default());
|
||||
let frames = out.len() / 2;
|
||||
// Leading silence gone except a 1 ms pre-roll.
|
||||
assert_eq!(frames, 3800 + 48);
|
||||
assert_eq!(out[0], 0.);
|
||||
assert_eq!(out[out.len() - 1], 0.);
|
||||
let peak = out.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!((20. * peak.log10() + 0.3).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_key_is_detected_per_slice() {
|
||||
let mut saw = Saw::new(RATE, 43.65);
|
||||
let take = take_of((0..48000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
|
||||
assert_eq!(take.slices[0].root, Some(29));
|
||||
let silent = take_of(vec![0.; 48000], 120., 1);
|
||||
assert_eq!(silent.slices[0].root, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wavetable_frames_start_on_rising_zero_crossings_and_match() {
|
||||
let mut saw = Saw::new(RATE, 110.);
|
||||
let x: Vec<f32> = (0..192000).map(|_| saw.sample() * 0.5).collect();
|
||||
let table = wavetable(&x, RATE).unwrap();
|
||||
let frames = table.len() / TABLE;
|
||||
assert_eq!(frames, TABLE_FRAMES);
|
||||
for f in table.chunks(TABLE) {
|
||||
assert!(f[0].abs() < 0.02 && f[1] > f[0], "{} {}", f[0], f[1]);
|
||||
let a = &table[..TABLE];
|
||||
let dot: f32 = a.iter().zip(f).map(|(p, q)| p * q).sum();
|
||||
let na: f32 = a.iter().map(|v| v * v).sum();
|
||||
let nb: f32 = f.iter().map(|v| v * v).sum();
|
||||
assert!(dot / (na * nb).sqrt() > 0.99);
|
||||
}
|
||||
assert!(wavetable(&vec![0.; 48000], RATE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_loop_slices_and_wavetable() {
|
||||
let dir = std::env::temp_dir().join(format!("crucible-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut saw = Saw::new(RATE, 55.);
|
||||
let mut take = take_of((0..96000).map(|_| saw.sample() * 0.5).collect(), 120., 1);
|
||||
let settings = Settings {
|
||||
division: Division::Quarter,
|
||||
wavetable: true,
|
||||
..Settings::default()
|
||||
};
|
||||
take.slice(&settings);
|
||||
take.slices[3].enabled = false;
|
||||
let done = export(&take, &settings, Some(0), &dir, 1_700_000_000).unwrap();
|
||||
assert_eq!(done.files, 1 + 3 + 1);
|
||||
assert!(done.folder.ends_with("2023-11-14_221320_120bpm"));
|
||||
let read = |name: &str| wav::decode(&std::fs::read(done.folder.join(name)).unwrap()).unwrap();
|
||||
assert_eq!(read("loop.wav").samples.len(), 192000);
|
||||
assert_eq!(read("s01_A1.wav").root, Some(33));
|
||||
assert_eq!(read("wavetable.wav").frame, Some(2048));
|
||||
assert!(!done.folder.join("s04_A1.wav").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audition_plays_and_returns_clips_for_freeing() {
|
||||
let q = Queues::default();
|
||||
let mut p = Player::default();
|
||||
q.play(std::sync::Arc::from(vec![0.5f32, -0.5, 0.25, -0.25]));
|
||||
p.block(&q);
|
||||
assert_eq!(p.next(&q), [0.5, -0.5]);
|
||||
assert_eq!(p.next(&q), [0.25, -0.25]);
|
||||
assert_eq!(p.next(&q), [0., 0.]);
|
||||
assert!(!p.playing());
|
||||
q.drain();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_survive_json_and_are_sanitised() {
|
||||
let mut s: Settings =
|
||||
serde_json::from_str(r#"{"bars":3,"declick":99,"sensitivity":-5}"#).unwrap();
|
||||
s.sanitize();
|
||||
assert_eq!((s.bars, s.declick, s.sensitivity), (4, 20., 0.));
|
||||
let json = serde_json::to_string(&Settings::default()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Settings>(&json).unwrap(),
|
||||
Settings::default()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crucible_core::{Player, Queues, Recorder, Ring, Transport};
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
cell::Cell,
|
||||
};
|
||||
|
||||
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 recording_transport_and_audition_do_not_allocate_or_free() {
|
||||
let ring = Ring::new(48000., 4.);
|
||||
let queues = Queues::default();
|
||||
let mut recorder = Recorder::new();
|
||||
let mut player = Player::default();
|
||||
let clips: Vec<Arc<[f32]>> = (0..4).map(|i| Arc::from(vec![0.1; 2000 + i])).collect();
|
||||
CHECK.with(|v| v.set(true));
|
||||
for block in 0..2000u32 {
|
||||
if block % 300 == 0 {
|
||||
// Sending only moves the Arc; freeing happens on drain, outside the check.
|
||||
queues.play(clips[(block / 300) as usize % 4].clone());
|
||||
}
|
||||
recorder.block(
|
||||
&ring,
|
||||
Some(Transport {
|
||||
playing: block % 700 < 600,
|
||||
tempo: if block < 1000 { 140. } else { 150. },
|
||||
beats: block as f64 * 0.1,
|
||||
numerator: 4,
|
||||
denominator: 4,
|
||||
}),
|
||||
);
|
||||
player.block(&queues);
|
||||
for i in 0..256 {
|
||||
let a = player.next(&queues);
|
||||
recorder.push(&ring, [i as f32 * 1e-3 + a[0], a[1]]);
|
||||
}
|
||||
recorder.publish(&ring);
|
||||
}
|
||||
CHECK.with(|v| v.set(false));
|
||||
assert_eq!(OPERATIONS.with(Cell::get), 0);
|
||||
queues.drain();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "crucible"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[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" }
|
||||
crucible-core = { path = "../core" }
|
||||
crucible-ui = { path = "../ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,200 @@
|
||||
//! CLAP audio effect. Audio passes through unchanged; the input is recorded continuously.
|
||||
use crucible_core::{Player, Queues, Recorder, Ring, Settings, Transport, HISTORY};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui, EguiState};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct CrucibleParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[persist = "crucible-settings"]
|
||||
pub settings: RwLock<Settings>,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for CrucibleParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
editor: EguiState::from_size(crucible_ui::WIDTH, crucible_ui::HEIGHT),
|
||||
settings: RwLock::new(Settings::default()),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// Replaced on activation; the editor clones the Arc briefly, never the audio thread.
|
||||
ring: Mutex<Option<Arc<Ring>>>,
|
||||
queues: Arc<Queues>,
|
||||
}
|
||||
pub struct Crucible {
|
||||
params: Arc<CrucibleParams>,
|
||||
shared: Arc<Shared>,
|
||||
ring: Option<Arc<Ring>>,
|
||||
recorder: Recorder,
|
||||
player: Player,
|
||||
}
|
||||
impl Default for Crucible {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(CrucibleParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
ring: Mutex::new(None),
|
||||
queues: Arc::new(Queues::default()),
|
||||
}),
|
||||
ring: None,
|
||||
recorder: Recorder::new(),
|
||||
player: Player::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Crucible {
|
||||
const NAME: &'static str = "Crucible";
|
||||
const VENDOR: &'static str = "Rivet & Patina";
|
||||
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: NonZeroU32::new(2),
|
||||
main_output_channels: NonZeroU32::new(2),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
AudioIOLayout {
|
||||
main_input_channels: NonZeroU32::new(1),
|
||||
main_output_channels: NonZeroU32::new(1),
|
||||
..AudioIOLayout::const_default()
|
||||
},
|
||||
];
|
||||
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
|
||||
type SysExMessage = ();
|
||||
type BackgroundTask = ();
|
||||
fn params(&self) -> Arc<dyn Params> {
|
||||
self.params.clone()
|
||||
}
|
||||
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||
let params = self.params.clone();
|
||||
let shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
crucible_ui::State::default(),
|
||||
|ctx, _| crucible_ui::style(ctx),
|
||||
move |ctx, _setter, state| {
|
||||
let link = crucible_ui::Link {
|
||||
ring: shared.ring.lock().ok().and_then(|r| r.clone()),
|
||||
queues: shared.queues.clone(),
|
||||
};
|
||||
let mut settings = params
|
||||
.settings
|
||||
.read()
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default();
|
||||
let before = settings.clone();
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("crucible-editor")
|
||||
.min_size(egui::vec2(
|
||||
crucible_ui::WIDTH as f32 - 80.,
|
||||
crucible_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
crucible_ui::show(ui, &mut settings, state, &link)
|
||||
});
|
||||
if settings != before {
|
||||
if let Ok(mut s) = params.settings.write() {
|
||||
*s = settings;
|
||||
}
|
||||
}
|
||||
// Meters and the transport readout follow the audio.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
},
|
||||
)
|
||||
}
|
||||
fn initialize(
|
||||
&mut self,
|
||||
_: &AudioIOLayout,
|
||||
config: &BufferConfig,
|
||||
_: &mut impl InitContext<Self>,
|
||||
) -> bool {
|
||||
if let Ok(mut s) = self.params.settings.write() {
|
||||
s.sanitize();
|
||||
}
|
||||
let ring = Arc::new(Ring::new(config.sample_rate, HISTORY));
|
||||
self.ring = Some(ring.clone());
|
||||
self.recorder = Recorder::new();
|
||||
self.player = Player::default();
|
||||
if let Ok(mut slot) = self.shared.ring.lock() {
|
||||
*slot = Some(ring);
|
||||
}
|
||||
true
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.player = Player::default();
|
||||
}
|
||||
fn process(
|
||||
&mut self,
|
||||
buffer: &mut Buffer,
|
||||
_: &mut AuxiliaryBuffers,
|
||||
context: &mut impl ProcessContext<Self>,
|
||||
) -> ProcessStatus {
|
||||
let Some(ring) = self.ring.as_deref() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let bypass = self.params.bypass.value();
|
||||
let t = context.transport();
|
||||
let transport = match (t.tempo, t.pos_beats()) {
|
||||
(Some(tempo), Some(beats)) => Some(Transport {
|
||||
playing: t.playing,
|
||||
tempo,
|
||||
beats,
|
||||
numerator: t.time_sig_numerator.unwrap_or(4).clamp(1, 64) as u32,
|
||||
denominator: t.time_sig_denominator.unwrap_or(4).clamp(1, 64) as u32,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if !bypass {
|
||||
self.recorder.block(ring, transport);
|
||||
}
|
||||
self.player.block(&self.shared.queues);
|
||||
let channels = buffer.channels();
|
||||
for mut sample in buffer.iter_samples() {
|
||||
let left = *sample.get_mut(0).unwrap();
|
||||
let right = if channels > 1 {
|
||||
*sample.get_mut(1).unwrap()
|
||||
} else {
|
||||
left
|
||||
};
|
||||
if bypass {
|
||||
continue;
|
||||
}
|
||||
self.recorder.push(ring, [left, right]);
|
||||
// Auditioned slices are added to the output, never recorded.
|
||||
let a = self.player.next(&self.shared.queues);
|
||||
if self.player.playing() || a != [0.; 2] {
|
||||
*sample.get_mut(0).unwrap() += if channels > 1 {
|
||||
a[0]
|
||||
} else {
|
||||
0.5 * (a[0] + a[1])
|
||||
};
|
||||
if channels > 1 {
|
||||
*sample.get_mut(1).unwrap() += a[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
self.recorder.publish(ring);
|
||||
ProcessStatus::Normal
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Crucible {
|
||||
const CLAP_ID: &'static str = "io.storyboard.resampler";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Capture the last bars, slice them and export clean, key-labelled samples");
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
ClapFeature::AudioEffect,
|
||||
ClapFeature::Utility,
|
||||
ClapFeature::Stereo,
|
||||
ClapFeature::Mono,
|
||||
];
|
||||
}
|
||||
nih_export_clap!(Crucible);
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
|
||||
use clap_sys::{
|
||||
audio_buffer::*,
|
||||
events::*,
|
||||
ext::{audio_ports::*, latency::*, 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 effect_ports_state_latency_and_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 = &crucible::clap_entry;
|
||||
assert!(entry.init.unwrap()(c"crucible.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).name), c"Crucible");
|
||||
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.resampler");
|
||||
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_AUDIO_PORTS.as_ptr())
|
||||
as *const clap_plugin_audio_ports;
|
||||
assert!(!ports.is_null());
|
||||
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
|
||||
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
|
||||
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
|
||||
as *const clap_plugin_latency;
|
||||
assert!(!latency.is_null());
|
||||
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();
|
||||
use nih_plug::wrapper::state::ParamValue;
|
||||
assert!(envelope.fields.contains_key("crucible-settings"));
|
||||
envelope
|
||||
.params
|
||||
.insert("bypass".into(), ParamValue::Bool(false));
|
||||
let settings = crucible_core::Settings {
|
||||
bars: 8,
|
||||
declick: 5.,
|
||||
wavetable: true,
|
||||
folder: "D:/Samples/Crucible".into(),
|
||||
..Default::default()
|
||||
};
|
||||
envelope.fields.insert(
|
||||
"crucible-settings".into(),
|
||||
serde_json::to_string(&settings).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!(
|
||||
serde_json::to_value(&restored.params).unwrap(),
|
||||
serde_json::to_value(&envelope.params).unwrap()
|
||||
);
|
||||
let restored_settings: crucible_core::Settings =
|
||||
serde_json::from_str(&restored.fields["crucible-settings"]).unwrap();
|
||||
assert_eq!(restored_settings, settings);
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
assert_eq!(delay, 0);
|
||||
assert!(p.start_processing.unwrap()(plugin));
|
||||
let mut position = 0;
|
||||
while position < delay + 1024 {
|
||||
let frames = [1, 127, 512, 31][position % 4].min(delay + 1024 - position);
|
||||
let mut input_left = [0f32; 512];
|
||||
let mut input_right = [0f32; 512];
|
||||
for i in 0..frames {
|
||||
let x = if position + i == 0 { 0.5 } else { 0. };
|
||||
input_left[i] = x;
|
||||
input_right[i] = -x;
|
||||
}
|
||||
let mut left = [0f32; 512];
|
||||
let mut right = [0f32; 512];
|
||||
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
|
||||
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
|
||||
let incoming = clap_audio_buffer {
|
||||
data32: in_channels.as_mut_ptr(),
|
||||
data64: ptr::null_mut(),
|
||||
channel_count: 2,
|
||||
latency: 0,
|
||||
constant_mask: 0,
|
||||
};
|
||||
let mut outgoing_audio = clap_audio_buffer {
|
||||
data32: out_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: position as i64,
|
||||
frames_count: frames as u32,
|
||||
transport: ptr::null(),
|
||||
audio_inputs: &incoming,
|
||||
audio_outputs: &mut outgoing_audio,
|
||||
audio_inputs_count: 1,
|
||||
audio_outputs_count: 1,
|
||||
in_events: &input,
|
||||
out_events: &outgoing,
|
||||
};
|
||||
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
|
||||
for i in 0..frames {
|
||||
// Audio passes through untouched.
|
||||
let expected = if position + i == 0 { 0.5 } else { 0. };
|
||||
assert!((left[i] - expected).abs() < 1e-6);
|
||||
assert!((right[i] + expected).abs() < 1e-6);
|
||||
}
|
||||
position += frames;
|
||||
}
|
||||
p.stop_processing.unwrap()(plugin);
|
||||
p.deactivate.unwrap()(plugin);
|
||||
}
|
||||
p.destroy.unwrap()(plugin);
|
||||
entry.deinit.unwrap()();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "crucible-ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
crucible-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
common-dsp = { path = "../../common/dsp" }
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Crucible editor, shared by the CLAP wrapper and the headless renderer.
|
||||
use common_ui::*;
|
||||
use crucible_core::{
|
||||
capture, export, render, Division, Exported, Mode, Queues, Ring, Settings, Take, Transport,
|
||||
OVERVIEW,
|
||||
};
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use common_ui::style;
|
||||
pub const WIDTH: u32 = 1040;
|
||||
pub const HEIGHT: u32 = 680;
|
||||
const MAX_TAKES: usize = 8;
|
||||
const DEFAULT_HELP: &str =
|
||||
"Play your bass, press Capture, then drag the exported files from FL's browser.";
|
||||
|
||||
/// Connection to the audio thread.
|
||||
pub struct Link {
|
||||
pub ring: Option<Arc<Ring>>,
|
||||
pub queues: Arc<Queues>,
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub takes: Vec<Take>,
|
||||
pub take: Option<usize>,
|
||||
pub slice: Option<usize>,
|
||||
pub status: String,
|
||||
pub help: String,
|
||||
pub peak: f32,
|
||||
pub transport: Option<Transport>,
|
||||
pub folder: String,
|
||||
counter: usize,
|
||||
names: Vec<String>,
|
||||
/// Filled by the export thread when it finishes.
|
||||
job: Option<Arc<Mutex<Option<std::io::Result<Exported>>>>>,
|
||||
last_export: Option<std::path::PathBuf>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
takes: Vec::new(),
|
||||
take: None,
|
||||
slice: None,
|
||||
status: String::new(),
|
||||
help: DEFAULT_HELP.into(),
|
||||
peak: 0.,
|
||||
transport: None,
|
||||
folder: String::new(),
|
||||
counter: 0,
|
||||
names: Vec::new(),
|
||||
job: None,
|
||||
last_export: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
/// Adds a take (newest first) and slices it with the current settings.
|
||||
pub fn add(&mut self, mut take: Take, settings: &Settings) {
|
||||
take.slice(settings);
|
||||
self.counter += 1;
|
||||
self.names.insert(
|
||||
0,
|
||||
format!(
|
||||
"Take {} · {} bar{} · {:.0} BPM",
|
||||
self.counter,
|
||||
take.bars,
|
||||
if take.bars == 1 { "" } else { "s" },
|
||||
take.tempo()
|
||||
),
|
||||
);
|
||||
self.takes.insert(0, take);
|
||||
self.takes.truncate(MAX_TAKES);
|
||||
self.names.truncate(MAX_TAKES);
|
||||
self.take = Some(0);
|
||||
self.slice = None;
|
||||
}
|
||||
fn current(&mut self) -> Option<&mut Take> {
|
||||
self.take.and_then(|i| self.takes.get_mut(i))
|
||||
}
|
||||
}
|
||||
|
||||
fn hint(r: &egui::Response, state: &mut State, text: &str) {
|
||||
if r.hovered() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
fn chip(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response {
|
||||
ui.selectable_label(on, RichText::new(label).size(13.))
|
||||
}
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
fn open_folder(path: &std::path::Path) {
|
||||
let program = if cfg!(windows) {
|
||||
"explorer"
|
||||
} else {
|
||||
"xdg-open"
|
||||
};
|
||||
let _ = std::process::Command::new(program).arg(path).spawn();
|
||||
}
|
||||
|
||||
fn audition(link: &Link, take: &Take, index: usize, settings: &Settings) {
|
||||
if let Some(slice) = take.slices.get(index) {
|
||||
link.queues.play(Arc::from(render(take, slice, settings)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
backdrop(ui);
|
||||
link.queues.drain();
|
||||
state.help = DEFAULT_HELP.into();
|
||||
if let Some(ring) = &link.ring {
|
||||
let p = ring.take_peak();
|
||||
state.peak = p.max(state.peak * 0.9);
|
||||
if let Some(m) = ring.markers().last() {
|
||||
state.transport = Some(m.transport);
|
||||
}
|
||||
}
|
||||
let finished = state
|
||||
.job
|
||||
.as_ref()
|
||||
.and_then(|job| job.lock().ok().and_then(|mut slot| slot.take()));
|
||||
if let Some(result) = finished {
|
||||
state.job = None;
|
||||
state.status = match result {
|
||||
Ok(done) => {
|
||||
let mut s = format!("Exported {} files to {}", done.files, done.folder.display());
|
||||
if let Some(why) = done.wavetable_skipped {
|
||||
s += &format!(" (no wavetable: {why})");
|
||||
}
|
||||
state.last_export = Some(done.folder);
|
||||
s
|
||||
}
|
||||
Err(e) => format!("Export failed: {e}"),
|
||||
};
|
||||
}
|
||||
ui.add_space(2.);
|
||||
header(ui, "CRUCIBLE", "RESAMPLER", |ui| {
|
||||
let db = 20. * state.peak.max(1e-6).log10();
|
||||
ui.label(
|
||||
RichText::new(if db < -90. {
|
||||
"—".into()
|
||||
} else {
|
||||
format!("{db:.1} dB")
|
||||
})
|
||||
.monospace()
|
||||
.size(12.)
|
||||
.color(if db > -0.1 { ACCENT } else { MUTED }),
|
||||
);
|
||||
caption(ui, "INPUT");
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| capture_row(ui, settings, state, link));
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let body = 300.;
|
||||
ui.horizontal_top(|ui| {
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(190.);
|
||||
ui.set_min_height(body);
|
||||
ui.vertical(|ui| takes(ui, state));
|
||||
});
|
||||
ui.add_space(4.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
waveform(ui, settings, state, link, body);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.vertical(|ui| controls(ui, settings, state, link));
|
||||
});
|
||||
ui.separator();
|
||||
let line = if state.status.is_empty() || state.help != DEFAULT_HELP {
|
||||
state.help.clone()
|
||||
} else {
|
||||
state.status.clone()
|
||||
};
|
||||
ui.label(RichText::new(line).color(MUTED));
|
||||
}
|
||||
|
||||
fn capture_row(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
let button = egui::Button::new(
|
||||
RichText::new("CAPTURE")
|
||||
.font(FontId::new(17., egui::FontFamily::Name("medium".into())))
|
||||
.color(Color32::WHITE),
|
||||
)
|
||||
.fill(Color32::from_rgb(150, 0, 56))
|
||||
.stroke(Stroke::new(1.0_f32, ACCENT))
|
||||
.min_size(Vec2::new(130., 34.));
|
||||
let r = ui.add_enabled(link.ring.is_some(), button);
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Grab the newest complete bars. While stopped, grabs the newest audio instead.",
|
||||
);
|
||||
if r.clicked() {
|
||||
if let Some(ring) = &link.ring {
|
||||
match capture(ring, settings.bars) {
|
||||
Ok(take) => {
|
||||
let synced = take.synced;
|
||||
state.add(take, settings);
|
||||
state.status = if synced {
|
||||
format!("Captured {} bars on the bar grid.", settings.bars)
|
||||
} else {
|
||||
"Transport stopped: captured the newest audio at the last tempo.".into()
|
||||
};
|
||||
}
|
||||
Err(e) => state.status = e.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(10.);
|
||||
caption(ui, "BARS");
|
||||
for bars in Settings::BARS {
|
||||
if chip(ui, settings.bars == bars, &bars.to_string()).clicked() {
|
||||
settings.bars = bars;
|
||||
}
|
||||
}
|
||||
ui.add_space(10.);
|
||||
ui.separator();
|
||||
let text = match state.transport {
|
||||
Some(t) => format!(
|
||||
"{:.1} BPM · {}/{} · {}",
|
||||
t.tempo,
|
||||
t.numerator,
|
||||
t.denominator,
|
||||
if t.playing { "playing" } else { "stopped" }
|
||||
),
|
||||
None if link.ring.is_some() => "No host tempo yet · 140 BPM assumed".into(),
|
||||
None => "Audio engine inactive".into(),
|
||||
};
|
||||
ui.label(RichText::new(text).monospace().size(13.).color(STEEL));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if let Some(ring) = &link.ring {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{:.0} s history",
|
||||
ring.capacity() as f32 / ring.rate()
|
||||
))
|
||||
.size(12.)
|
||||
.color(MUTED),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn takes(ui: &mut egui::Ui, state: &mut State) {
|
||||
caption(ui, "TAKES");
|
||||
if state.takes.is_empty() {
|
||||
ui.label(RichText::new("Nothing captured yet.").color(DIM));
|
||||
}
|
||||
let mut pick = None;
|
||||
for (i, name) in state.names.iter().enumerate() {
|
||||
let r = ui.add_sized(
|
||||
Vec2::new(ui.available_width(), 26.),
|
||||
egui::SelectableLabel::new(state.take == Some(i), RichText::new(name).size(13.)),
|
||||
);
|
||||
if r.clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = pick {
|
||||
state.take = Some(i);
|
||||
state.slice = None;
|
||||
}
|
||||
if let Some(i) = state.take {
|
||||
ui.add_space(8.);
|
||||
if ui.button(RichText::new("REMOVE TAKE").size(12.)).clicked() {
|
||||
state.takes.remove(i);
|
||||
state.names.remove(i);
|
||||
state.take = (!state.takes.is_empty()).then_some(0);
|
||||
state.slice = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn waveform(ui: &mut egui::Ui, settings: &Settings, state: &mut State, link: &Link, height: f32) {
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::click());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2., SCREEN);
|
||||
let Some(ti) = state.take.filter(|i| *i < state.takes.len()) else {
|
||||
p.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Play the part you want to resample, then press CAPTURE",
|
||||
FontId::proportional(15.),
|
||||
DIM,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let take = &state.takes[ti];
|
||||
let frames = take.frames().max(1) as f32;
|
||||
let label_band = 18.;
|
||||
let wave = Rect::from_min_max(rect.min + Vec2::new(0., label_band), rect.max);
|
||||
let x_of = |frame: usize| wave.left() + wave.width() * frame as f32 / frames;
|
||||
// Slices: tint, disabled dimming, selection.
|
||||
for (i, s) in take.slices.iter().enumerate() {
|
||||
let r = Rect::from_min_max(
|
||||
Pos2::new(x_of(s.start), wave.top()),
|
||||
Pos2::new(x_of(s.end), wave.bottom()),
|
||||
);
|
||||
let fill = if state.slice == Some(i) {
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 28)
|
||||
} else if !s.enabled {
|
||||
Color32::from_rgba_unmultiplied(0, 0, 0, 120)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
p.rect_filled(r, 0., fill);
|
||||
if r.width() > 26. {
|
||||
let text = s.root.map_or("·".into(), common_dsp::note_name);
|
||||
p.text(
|
||||
Pos2::new(r.center().x, rect.top() + 3.),
|
||||
egui::Align2::CENTER_TOP,
|
||||
text,
|
||||
FontId::proportional(11.),
|
||||
if s.enabled { STEEL } else { DIM },
|
||||
);
|
||||
}
|
||||
}
|
||||
// Bar and beat grid.
|
||||
let quarters = take.quarters();
|
||||
let bar = take.bar_quarters();
|
||||
let mut q = 0.;
|
||||
while q <= quarters + 1e-6 {
|
||||
let x = x_of(take.frame_at(q).round() as usize);
|
||||
let is_bar = ((q / bar).round() * bar - q).abs() < 1e-6;
|
||||
p.line_segment(
|
||||
[Pos2::new(x, wave.top()), Pos2::new(x, wave.bottom())],
|
||||
Stroke::new(
|
||||
1.0_f32,
|
||||
if is_bar {
|
||||
Color32::from_rgb(60, 64, 72)
|
||||
} else {
|
||||
Color32::from_rgb(28, 30, 34)
|
||||
},
|
||||
),
|
||||
);
|
||||
q += 1.;
|
||||
}
|
||||
// Overview waveform.
|
||||
let mid = wave.center().y;
|
||||
let half = wave.height() * 0.46;
|
||||
let peak = take
|
||||
.overview
|
||||
.iter()
|
||||
.fold(0.01f32, |m, v| m.max(v[0].abs()).max(v[1].abs()));
|
||||
let columns = wave.width().max(1.) as usize;
|
||||
for c in 0..columns {
|
||||
let a = c * OVERVIEW / columns;
|
||||
let z = ((c + 1) * OVERVIEW / columns).max(a + 1).min(OVERVIEW);
|
||||
let (mut lo, mut hi) = (0f32, 0f32);
|
||||
for v in &take.overview[a..z] {
|
||||
lo = lo.min(v[0]);
|
||||
hi = hi.max(v[1]);
|
||||
}
|
||||
let x = wave.left() + c as f32 + 0.5;
|
||||
p.line_segment(
|
||||
[
|
||||
Pos2::new(x, mid - hi / peak * half),
|
||||
Pos2::new(x, mid - lo / peak * half + 0.5),
|
||||
],
|
||||
Stroke::new(1.0_f32, STEEL),
|
||||
);
|
||||
}
|
||||
// Cuts.
|
||||
for c in &take.cuts[1..take.cuts.len().saturating_sub(1)] {
|
||||
let x = x_of(*c);
|
||||
p.line_segment(
|
||||
[Pos2::new(x, rect.top()), Pos2::new(x, rect.bottom())],
|
||||
Stroke::new(1.0_f32, ACCENT),
|
||||
);
|
||||
}
|
||||
p.rect_stroke(
|
||||
rect,
|
||||
2.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let frame_at =
|
||||
|pos: Pos2| (((pos.x - wave.left()) / wave.width()).clamp(0., 1.) * frames) as usize;
|
||||
if response.hovered() {
|
||||
state.help =
|
||||
"Click a slice to hear it. Double-click to add a cut, right-click to remove the nearest."
|
||||
.into();
|
||||
}
|
||||
let pointer = response.interact_pointer_pos();
|
||||
if response.double_clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
if let Some(t) = state.current() {
|
||||
if t.add_cut(f) {
|
||||
state.slice = state.takes[ti].slice_at(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if response.clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
state.slice = state.takes[ti].slice_at(f);
|
||||
if let Some(i) = state.slice {
|
||||
audition(link, &state.takes[ti], i, settings);
|
||||
}
|
||||
}
|
||||
} else if response.secondary_clicked() {
|
||||
if let Some(pos) = pointer {
|
||||
let f = frame_at(pos);
|
||||
if let Some(t) = state.current() {
|
||||
t.remove_cut(f);
|
||||
}
|
||||
state.slice = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn controls(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State, link: &Link) {
|
||||
let before = settings.clone();
|
||||
ui.horizontal(|ui| {
|
||||
caption(ui, "SLICE BY");
|
||||
for (mode, label) in [(Mode::Grid, "GRID"), (Mode::Transient, "TRANSIENTS")] {
|
||||
if chip(ui, settings.mode == mode, label).clicked() {
|
||||
settings.mode = mode;
|
||||
}
|
||||
}
|
||||
ui.add_space(8.);
|
||||
match settings.mode {
|
||||
Mode::Grid => {
|
||||
for d in Division::ALL {
|
||||
if chip(ui, settings.division == d, d.label()).clicked() {
|
||||
settings.division = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Transient => {
|
||||
caption(ui, "SENSITIVITY");
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut settings.sensitivity, 0. ..=100.)
|
||||
.suffix(" %")
|
||||
.clamping(egui::SliderClamping::Always),
|
||||
);
|
||||
hint(&r, state, "Higher finds quieter hits.");
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
caption(ui, "DECLICK");
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut settings.declick, 0. ..=20.)
|
||||
.suffix(" ms")
|
||||
.clamping(egui::SliderClamping::Always),
|
||||
);
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Fade at both ends of every slice, so cuts never click.",
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let r = chip(ui, settings.trim, "TRIM SILENCE");
|
||||
if r.clicked() {
|
||||
settings.trim = !settings.trim;
|
||||
}
|
||||
hint(&r, state, "Remove silence before the first sound of a slice.");
|
||||
let r = chip(ui, settings.normalize, "NORMALIZE");
|
||||
if r.clicked() {
|
||||
settings.normalize = !settings.normalize;
|
||||
}
|
||||
hint(&r, state, "Peak every slice at −0.3 dBFS.");
|
||||
let r = chip(ui, settings.wavetable, "WAVETABLE");
|
||||
if r.clicked() {
|
||||
settings.wavetable = !settings.wavetable;
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Also write wavetable.wav from the selected slice: 2048-sample frames for Serum or Vital.",
|
||||
);
|
||||
ui.separator();
|
||||
let take = state.take;
|
||||
let slice = state.slice;
|
||||
match (take.and_then(|t| state.takes.get_mut(t)), slice) {
|
||||
(Some(t), Some(i)) if i < t.slices.len() => {
|
||||
let s = &mut t.slices[i];
|
||||
let seconds = (s.end - s.start) as f32 / t.rate;
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"SLICE {} · {} · {:.2} s",
|
||||
i + 1,
|
||||
s.root.map_or("no pitch".into(), common_dsp::note_name),
|
||||
seconds
|
||||
))
|
||||
.size(13.)
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.checkbox(&mut s.enabled, "Export");
|
||||
if ui.small_button("▶").clicked() {
|
||||
audition(link, t, i, settings);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(RichText::new("No slice selected").size(13.).color(DIM));
|
||||
}
|
||||
}
|
||||
});
|
||||
let recut = settings.mode != before.mode
|
||||
|| settings.division != before.division
|
||||
|| (settings.mode == Mode::Transient && settings.sensitivity != before.sensitivity);
|
||||
if recut {
|
||||
if let Some(t) = state.current() {
|
||||
t.slice(settings);
|
||||
}
|
||||
state.slice = None;
|
||||
}
|
||||
ui.add_space(4.);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
caption(ui, "FOLDER");
|
||||
if state.folder.is_empty() {
|
||||
state.folder = settings.folder().display().to_string();
|
||||
}
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut state.folder).desired_width(420.));
|
||||
if r.changed() {
|
||||
settings.folder = state.folder.clone();
|
||||
}
|
||||
hint(
|
||||
&r,
|
||||
state,
|
||||
"Add this folder to FL's browser once; exports then appear there for dragging.",
|
||||
);
|
||||
let can = state.take.is_some() && state.job.is_none();
|
||||
let export_button =
|
||||
egui::Button::new(RichText::new("EXPORT").size(14.).color(Color32::WHITE))
|
||||
.fill(Color32::from_rgb(150, 0, 56))
|
||||
.stroke(Stroke::new(1.0_f32, ACCENT));
|
||||
if ui.add_enabled(can, export_button).clicked() {
|
||||
if let Some(take) = state.take.and_then(|i| state.takes.get(i)).cloned() {
|
||||
let slot = Arc::new(Mutex::new(None));
|
||||
let done = slot.clone();
|
||||
let settings = settings.clone();
|
||||
let selected = state.slice;
|
||||
std::thread::spawn(move || {
|
||||
let folder = settings.folder();
|
||||
let result = export(&take, &settings, selected, &folder, unix_now());
|
||||
if let Ok(mut slot) = done.lock() {
|
||||
*slot = Some(result);
|
||||
}
|
||||
});
|
||||
state.job = Some(slot);
|
||||
state.status = "Exporting…".into();
|
||||
}
|
||||
}
|
||||
if ui.button(RichText::new("OPEN FOLDER").size(13.)).clicked() {
|
||||
let path = state
|
||||
.last_export
|
||||
.clone()
|
||||
.unwrap_or_else(|| settings.folder());
|
||||
let _ = std::fs::create_dir_all(&path);
|
||||
open_folder(&path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina-core"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -5,9 +5,111 @@ pub use settings::{focus_offset, knee, Focus, Settings};
|
||||
use std::{f32::consts::PI, sync::Arc};
|
||||
|
||||
pub const GRID: usize = 512;
|
||||
/// dB of cut per dB of prominence above the threshold.
|
||||
const SLOPE: f32 = 1.5;
|
||||
/// Fixed soft-knee width, in dB of prominence.
|
||||
const KNEE: f32 = 3.;
|
||||
/// Neighboring peaks closer than this (Hz, or one octave) are joined by a
|
||||
/// straight line in dB, so harmonic gaps do not count as expected level.
|
||||
const JOIN_HZ: f32 = 800.;
|
||||
/// Detector floor, far below any audible content.
|
||||
const FLOOR_DB: f32 = -160.;
|
||||
/// Cells this far below the reference count as empty, not as a dip.
|
||||
const GAP_DB: f32 = 9.;
|
||||
/// Envelope is clipped this far above the previous fit on later passes.
|
||||
const CLIP_DB: f32 = 2.;
|
||||
/// Cells this far below the loudest nearby envelope are empty, not content.
|
||||
const EMPTY_DB: f32 = 24.;
|
||||
/// Steepest spectral tilt the reference follows, in dB per octave.
|
||||
const MAX_TILT: f32 = 12.;
|
||||
fn grid_step() -> f32 {
|
||||
1000f32.log2() / (GRID - 1) as f32
|
||||
}
|
||||
/// Box radius whose two-pass (triangular) response has the given FWHM in octaves.
|
||||
fn radius(octaves: f32) -> usize {
|
||||
(((octaves / grid_step()) - 1.) * 0.5)
|
||||
.round()
|
||||
.clamp(0., (GRID / 2) as f32) as usize
|
||||
}
|
||||
/// Two box passes over non-negative values (power, or dB above the floor). f64 prefix sums keep quiet cells exact
|
||||
/// next to loud ones. Edges average only the cells that exist.
|
||||
fn smooth(src: &[f32; GRID], dst: &mut [f32; GRID], radius: usize, passes: usize) {
|
||||
let mut prefix = [0f64; GRID + 1];
|
||||
*dst = *src;
|
||||
for _ in 0..passes {
|
||||
for i in 0..GRID {
|
||||
prefix[i + 1] = prefix[i] + dst[i] as f64;
|
||||
}
|
||||
for (i, value) in dst.iter_mut().enumerate() {
|
||||
let low = i.saturating_sub(radius);
|
||||
let high = (i + radius + 1).min(GRID);
|
||||
*value = ((prefix[high] - prefix[low]) / (high - low) as f64).max(0.) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Maximum over `i - radius..=i + radius` for each cell, in O(GRID) with a
|
||||
/// monotonic queue of candidate indices.
|
||||
fn local_max(y: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) {
|
||||
let mut queue = [0usize; GRID];
|
||||
let (mut head, mut tail) = (0, 0);
|
||||
let mut next = 0;
|
||||
for (i, value) in out.iter_mut().enumerate() {
|
||||
while next < GRID && next <= i + radius {
|
||||
while tail > head && y[queue[tail - 1]] <= y[next] {
|
||||
tail -= 1;
|
||||
}
|
||||
queue[tail] = next;
|
||||
tail += 1;
|
||||
next += 1;
|
||||
}
|
||||
while queue[head] + radius < i {
|
||||
head += 1;
|
||||
}
|
||||
*value = y[queue[head]];
|
||||
}
|
||||
}
|
||||
/// Weighted least-squares line through each cell's neighborhood, evaluated at
|
||||
/// that cell. The slope is limited to `MAX_TILT`, so an edge of the content
|
||||
/// (a steep rise below a fundamental, a filter cutoff) is not taken as tilt.
|
||||
fn fit(y: &[f32; GRID], w: &[f32; GRID], radius: usize, out: &mut [f32; GRID]) {
|
||||
let max_slope = (MAX_TILT * grid_step()) as f64;
|
||||
let mut sums = [[0f64; 5]; GRID + 1];
|
||||
for j in 0..GRID {
|
||||
let (x, wj, yj) = (j as f64, w[j] as f64, y[j] as f64);
|
||||
let s = sums[j];
|
||||
sums[j + 1] = [
|
||||
s[0] + wj,
|
||||
s[1] + wj * x,
|
||||
s[2] + wj * x * x,
|
||||
s[3] + wj * yj,
|
||||
s[4] + wj * x * yj,
|
||||
];
|
||||
}
|
||||
for (i, value) in out.iter_mut().enumerate() {
|
||||
let low = i.saturating_sub(radius);
|
||||
let high = (i + radius + 1).min(GRID);
|
||||
let [sw, sx, sxx, sy, sxy] = std::array::from_fn(|k| sums[high][k] - sums[low][k]);
|
||||
if sw < 1. {
|
||||
*value = y[i];
|
||||
continue;
|
||||
}
|
||||
let det = sw * sxx - sx * sx;
|
||||
let mean = sy / sw;
|
||||
let slope = if det > 1e-6 * sw * sw {
|
||||
((sw * sxy - sx * sy) / det).clamp(-max_slope, max_slope)
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
*value = (mean + slope * (i as f64 - sx / sw)) as f32;
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AnalyzerFrame {
|
||||
pub input: [f32; GRID],
|
||||
/// What the detector compares: the smoothed spectral envelope, in dB.
|
||||
pub envelope: [f32; GRID],
|
||||
/// Level the envelope is compared with, in dB.
|
||||
pub reference: [f32; GRID],
|
||||
pub reduction: [f32; GRID],
|
||||
pub focus: [f32; GRID],
|
||||
pub input_peak: f32,
|
||||
@@ -21,13 +123,15 @@ impl Default for AnalyzerFrame {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input: [-120.; GRID],
|
||||
envelope: [-120.; GRID],
|
||||
reference: [-120.; GRID],
|
||||
reduction: [0.; GRID],
|
||||
focus: [0.; GRID],
|
||||
input_peak: 0.,
|
||||
output_peak: 0.,
|
||||
guard: 0.,
|
||||
sample_rate: 48000.,
|
||||
latency: 4096,
|
||||
latency: 2048,
|
||||
channels: 2,
|
||||
}
|
||||
}
|
||||
@@ -54,15 +158,20 @@ pub struct Engine {
|
||||
scratch: Vec<Complex32>,
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
levels: [[f32; GRID]; 2],
|
||||
/// Per-cell power, smoothed over frames.
|
||||
power: [[f32; GRID]; 2],
|
||||
target: [f32; GRID],
|
||||
reduction: [f32; GRID],
|
||||
curve: [f32; GRID],
|
||||
mask: [f32; GRID],
|
||||
map: [(usize, f32); GRID],
|
||||
/// Per cell: FFT bin range `start..end`, or interpolation at `start + frac`
|
||||
/// when the cell is narrower than one bin.
|
||||
bands: [(usize, usize, f32); GRID],
|
||||
bin_map: Vec<(usize, f32)>,
|
||||
kernel: [f32; 65],
|
||||
radius: usize,
|
||||
/// Per cell: how many cells away the next peak may be and still be joined.
|
||||
join: [usize; GRID],
|
||||
envelope_radius: usize,
|
||||
reference_radius: usize,
|
||||
settings: Settings,
|
||||
frame: AnalyzerFrame,
|
||||
fresh: bool,
|
||||
@@ -93,7 +202,7 @@ impl Engine {
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let n = 4096 * scale;
|
||||
let n = 2048 * scale;
|
||||
let hop = n / 8;
|
||||
let mut planner = FftPlanner::new();
|
||||
let forward = planner.plan_fft_forward(n);
|
||||
@@ -110,9 +219,17 @@ impl Engine {
|
||||
.collect();
|
||||
let power_scale = 2. / window.iter().sum::<f32>().powi(2);
|
||||
let nyquist = rate * 0.5;
|
||||
let map = std::array::from_fn(|i| {
|
||||
let bin = (frequency(i).min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32);
|
||||
(bin.floor() as usize, bin.fract())
|
||||
let half_step = 2f32.powf(grid_step() * 0.5);
|
||||
let bands = std::array::from_fn(|i| {
|
||||
let to_bin = |f: f32| (f.min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32);
|
||||
let start = to_bin(frequency(i) / half_step).ceil() as usize;
|
||||
let end = to_bin(frequency(i) * half_step).floor() as usize + 1;
|
||||
if end > start + 1 {
|
||||
(start, end, 0.)
|
||||
} else {
|
||||
let bin = to_bin(frequency(i));
|
||||
(bin.floor() as usize, bin.floor() as usize, bin.fract())
|
||||
}
|
||||
});
|
||||
let bin_map = (0..=n / 2)
|
||||
.map(|k| {
|
||||
@@ -129,6 +246,11 @@ impl Engine {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let join = std::array::from_fn(|i| {
|
||||
let f = frequency(i);
|
||||
let octaves = ((f + JOIN_HZ) / f).log2().max(1.);
|
||||
(octaves / grid_step()).round() as usize
|
||||
});
|
||||
let mut engine = Self {
|
||||
rate,
|
||||
channels,
|
||||
@@ -143,15 +265,16 @@ impl Engine {
|
||||
scratch: vec![Complex32::default(); scratch_len],
|
||||
forward,
|
||||
inverse,
|
||||
levels: [[-120.; GRID]; 2],
|
||||
power: [[0.; GRID]; 2],
|
||||
target: [0.; GRID],
|
||||
reduction: [0.; GRID],
|
||||
curve: [0.; GRID],
|
||||
mask: [0.; GRID],
|
||||
map,
|
||||
bands,
|
||||
bin_map,
|
||||
kernel: [0.; 65],
|
||||
radius: 0,
|
||||
join,
|
||||
envelope_radius: 0,
|
||||
reference_radius: 0,
|
||||
settings: Settings::default(),
|
||||
frame: AnalyzerFrame::default(),
|
||||
fresh: false,
|
||||
@@ -186,7 +309,7 @@ impl Engine {
|
||||
let rebuild = settings.focus != self.settings.focus
|
||||
|| settings.low != self.settings.low
|
||||
|| settings.high != self.settings.high
|
||||
|| settings.cut_width != self.settings.cut_width;
|
||||
|| settings.sharpness != self.settings.sharpness;
|
||||
self.settings = settings;
|
||||
if rebuild {
|
||||
self.rebuild();
|
||||
@@ -201,12 +324,8 @@ impl Engine {
|
||||
Ok(())
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
let step = 1000f32.log2() / (GRID - 1) as f32;
|
||||
self.radius = ((self.settings.cut_width / step * 1.3).ceil() as usize).clamp(1, 64);
|
||||
let sigma = self.settings.cut_width / step / 2.355;
|
||||
for j in 0..=self.radius {
|
||||
self.kernel[j] = (-0.5 * (j as f32 / sigma).powi(2)).exp();
|
||||
}
|
||||
self.envelope_radius = radius(self.settings.envelope_width());
|
||||
self.reference_radius = radius(self.settings.reference_width());
|
||||
for i in 0..GRID {
|
||||
let f = frequency(i);
|
||||
self.curve[i] = focus_offset(f, &self.settings.focus);
|
||||
@@ -224,6 +343,7 @@ impl Engine {
|
||||
channel.fill(0.);
|
||||
}
|
||||
self.reduction.fill(0.);
|
||||
self.power = [[0.; GRID]; 2];
|
||||
self.pos = 0;
|
||||
self.hop_pos = 0;
|
||||
self.fast = 0.;
|
||||
@@ -291,7 +411,28 @@ impl Engine {
|
||||
}
|
||||
result
|
||||
}
|
||||
// Per-cell loops read several parallel spectra; indices are clearer here.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn process_window(&mut self) {
|
||||
let frame_time = self.hop as f32 / self.rate;
|
||||
// Detector power follows rises in about 5 ms and decays in about 40 ms,
|
||||
// so cuts hold on a stable ring instead of flickering with each frame.
|
||||
let rise = 1. - (-frame_time / 0.005).exp();
|
||||
let fall = 1. - (-frame_time / 0.04).exp();
|
||||
let awake = self.sample_power / self.hop as f32 > 1e-9;
|
||||
self.sample_power = 0.;
|
||||
let threshold = self.settings.threshold();
|
||||
self.target.fill(0.);
|
||||
self.frame.input.fill(-200.);
|
||||
self.frame.envelope.fill(-200.);
|
||||
self.frame.reference.fill(-200.);
|
||||
let mut levels = [0f32; GRID];
|
||||
let mut tops;
|
||||
let mut envelope = [0f32; GRID];
|
||||
let mut reference = [0f32; GRID];
|
||||
let mut clipped = [0f32; GRID];
|
||||
let mut weight = [0f32; GRID];
|
||||
let mut content = [false; GRID];
|
||||
for c in 0..self.channels {
|
||||
for i in 0..self.n {
|
||||
self.spectra[c][i] =
|
||||
@@ -300,53 +441,104 @@ impl Engine {
|
||||
self.forward
|
||||
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
|
||||
for i in 0..GRID {
|
||||
let (k, w) = self.map[i];
|
||||
let power = self.spectra[c][k].norm_sqr() * (1. - w)
|
||||
+ self.spectra[c][k + 1].norm_sqr() * w;
|
||||
self.levels[c][i] = 10. * (power * self.power_scale).max(1e-20).log10();
|
||||
let (start, end, w) = self.bands[i];
|
||||
let power = if end > start {
|
||||
self.spectra[c][start..end]
|
||||
.iter()
|
||||
.map(|z| z.norm_sqr())
|
||||
.sum::<f32>()
|
||||
/ (end - start) as f32
|
||||
} else {
|
||||
self.spectra[c][start].norm_sqr() * (1. - w)
|
||||
+ self.spectra[c][start + 1].norm_sqr() * w
|
||||
} * self.power_scale;
|
||||
let held = &mut self.power[c][i];
|
||||
*held += (power - *held) * if power > *held { rise } else { fall };
|
||||
if *held < 1e-30 {
|
||||
*held = 0.;
|
||||
}
|
||||
self.frame.input[i] = self.frame.input[i].max(10. * power.max(1e-20).log10());
|
||||
}
|
||||
}
|
||||
let awake = self.sample_power / self.hop as f32 > 1e-9;
|
||||
self.sample_power = 0.;
|
||||
let step = 1000f32.log2() / (GRID - 1) as f32;
|
||||
let radius = (self.settings.neighborhood / step * 0.5).round() as usize;
|
||||
self.target.fill(0.);
|
||||
for c in 0..self.channels {
|
||||
let mut prefix = [0.; GRID + 1];
|
||||
// 1. Upper envelope: join neighboring peaks (harmonic tops) with
|
||||
// straight lines in dB, so the gaps between harmonics do not count
|
||||
// as "expected level". Regions without nearby peaks keep their level.
|
||||
for i in 0..GRID {
|
||||
prefix[i + 1] = prefix[i] + self.levels[c][i];
|
||||
levels[i] = (10. * self.power[c][i].max(1e-30).log10()).max(FLOOR_DB);
|
||||
}
|
||||
tops = levels;
|
||||
let mut last: Option<usize> = None;
|
||||
for i in 1..GRID - 1 {
|
||||
if levels[i] >= levels[i - 1] && levels[i] > levels[i + 1] {
|
||||
if let Some(a) = last.filter(|a| i - a <= self.join[*a]) {
|
||||
let span = (i - a) as f32;
|
||||
for j in a + 1..i {
|
||||
let t = (j - a) as f32 / span;
|
||||
let line = levels[a] + (levels[i] - levels[a]) * t;
|
||||
tops[j] = tops[j].max(line);
|
||||
}
|
||||
}
|
||||
last = Some(i);
|
||||
}
|
||||
}
|
||||
for value in &mut tops {
|
||||
*value = 10f32.powf(*value / 10.);
|
||||
}
|
||||
// 2. Smooth it across Sharpness width: this is what the detector "hears".
|
||||
smooth(&tops, &mut envelope, self.envelope_radius, 2);
|
||||
for value in &mut envelope {
|
||||
*value = (10. * value.max(1e-30).log10()).max(FLOOR_DB);
|
||||
}
|
||||
// 3. Robust reference: a local straight-line fit in dB, so spectral
|
||||
// tilt is expected rather than prominent. Each pass clips the
|
||||
// envelope to the previous fit, so a bump cannot raise its own
|
||||
// reference. Cells far below the fit (empty regions, filter slopes,
|
||||
// below the fundamental) are left out instead of dragging it down.
|
||||
local_max(&envelope, self.reference_radius, &mut clipped);
|
||||
for i in 0..GRID {
|
||||
content[i] = envelope[i] > clipped[i] - EMPTY_DB;
|
||||
weight[i] = f32::from(content[i]);
|
||||
}
|
||||
fit(&envelope, &weight, self.reference_radius, &mut reference);
|
||||
for pass in 0..8 {
|
||||
for i in 0..GRID {
|
||||
weight[i] = f32::from(content[i] && envelope[i] > reference[i] - GAP_DB);
|
||||
// First find the content, then stop bumps from lifting the line.
|
||||
clipped[i] = if pass < 2 {
|
||||
envelope[i]
|
||||
} else {
|
||||
envelope[i].min(reference[i] + CLIP_DB)
|
||||
};
|
||||
}
|
||||
let previous = reference;
|
||||
fit(&clipped, &weight, self.reference_radius, &mut reference);
|
||||
for i in 0..GRID {
|
||||
if !reference[i].is_finite() {
|
||||
reference[i] = previous[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..GRID {
|
||||
let low = i.saturating_sub(radius);
|
||||
let high = (i + radius + 1).min(GRID);
|
||||
let e0 = i.saturating_sub(1).max(low);
|
||||
let e1 = (i + 2).min(high);
|
||||
let count = (high - low) - (e1 - e0);
|
||||
let baseline =
|
||||
(prefix[high] - prefix[low] - (prefix[e1] - prefix[e0])) / count.max(1) as f32;
|
||||
let excess =
|
||||
self.levels[c][i] - baseline + self.curve[i] - self.settings.sensitivity;
|
||||
let target = if awake {
|
||||
knee(excess, self.settings.knee) * self.settings.amount * 0.02
|
||||
let level = envelope[i];
|
||||
self.frame.envelope[i] = self.frame.envelope[i].max(level);
|
||||
self.frame.reference[i] = self.frame.reference[i].max(reference[i]);
|
||||
let excess = level - reference[i] + self.curve[i] - threshold;
|
||||
// Only real content can ring: leakage far below nearby
|
||||
// material is never cut.
|
||||
let target = if awake && content[i] && level > -130. {
|
||||
(knee(excess, KNEE) * SLOPE).min(self.settings.depth)
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
self.target[i] = self.target[i].max(target.min(self.settings.max_cut));
|
||||
// Linked stereo: the stronger channel decides.
|
||||
self.target[i] = self.target[i].max(target);
|
||||
}
|
||||
}
|
||||
let cut = self.target;
|
||||
smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1);
|
||||
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp();
|
||||
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
|
||||
for i in 0..GRID {
|
||||
let low = i.saturating_sub(self.radius);
|
||||
let high = (i + self.radius + 1).min(GRID);
|
||||
let mut sum = 0.;
|
||||
let mut weight = 0.;
|
||||
for j in low..high {
|
||||
let w = self.kernel[i.abs_diff(j)];
|
||||
sum += self.target[j] * w;
|
||||
weight += w;
|
||||
}
|
||||
let target = sum / weight;
|
||||
let target = self.target[i];
|
||||
let alpha = if target > self.reduction[i] {
|
||||
attack
|
||||
} else {
|
||||
@@ -359,13 +551,8 @@ impl Engine {
|
||||
self.frame.reduction[i] = (self.reduction[i]
|
||||
* self.mask[i]
|
||||
* (1. - self.protect * self.settings.transient_guard / 100.))
|
||||
.clamp(0., self.settings.max_cut);
|
||||
.clamp(0., self.settings.depth);
|
||||
self.frame.focus[i] = self.curve[i];
|
||||
self.frame.input[i] = self.levels[0][i].max(if self.channels == 2 {
|
||||
self.levels[1][i]
|
||||
} else {
|
||||
-200.
|
||||
});
|
||||
}
|
||||
for c in 0..self.channels {
|
||||
for k in 0..=self.n / 2 {
|
||||
|
||||
@@ -22,12 +22,12 @@ impl Default for Focus {
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub amount: f32,
|
||||
pub sensitivity: f32,
|
||||
pub max_cut: f32,
|
||||
pub neighborhood: f32,
|
||||
pub cut_width: f32,
|
||||
pub knee: f32,
|
||||
/// Largest cut in dB. Prominent bumps are flattened toward the threshold, never deeper.
|
||||
pub depth: f32,
|
||||
/// 0 % follows broad tonal bumps, 100 % targets narrow rings.
|
||||
pub sharpness: f32,
|
||||
/// 0 % treats any bump; 100 % treats only the most prominent bumps.
|
||||
pub selectivity: f32,
|
||||
pub attack: f32,
|
||||
pub release: f32,
|
||||
pub transient_guard: f32,
|
||||
@@ -41,17 +41,14 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
amount: 35.,
|
||||
sensitivity: 6.,
|
||||
max_cut: 3.,
|
||||
neighborhood: 1.,
|
||||
cut_width: 0.08,
|
||||
knee: 3.,
|
||||
attack: 15.,
|
||||
release: 120.,
|
||||
transient_guard: 50.,
|
||||
low: 120.,
|
||||
high: 12000.,
|
||||
depth: 6.,
|
||||
sharpness: 40.,
|
||||
selectivity: 50.,
|
||||
attack: 5.,
|
||||
release: 80.,
|
||||
transient_guard: 30.,
|
||||
low: 200.,
|
||||
high: 16000.,
|
||||
mix: 100.,
|
||||
output: 0.,
|
||||
bypass: false,
|
||||
@@ -62,12 +59,9 @@ impl Default for Settings {
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
for (v, low, high) in [
|
||||
(self.amount, 0., 100.),
|
||||
(self.sensitivity, 0., 24.),
|
||||
(self.max_cut, 0., 18.),
|
||||
(self.neighborhood, 0.25, 2.),
|
||||
(self.cut_width, 0.03, 0.5),
|
||||
(self.knee, 0., 12.),
|
||||
(self.depth, 0., 20.),
|
||||
(self.sharpness, 0., 100.),
|
||||
(self.selectivity, 0., 100.),
|
||||
(self.attack, 1., 200.),
|
||||
(self.release, 10., 1000.),
|
||||
(self.transient_guard, 0., 100.),
|
||||
@@ -96,37 +90,51 @@ impl Settings {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Detector envelope width in octaves (FWHM), from Sharpness.
|
||||
pub fn envelope_width(&self) -> f32 {
|
||||
(1. / 3.) * (1f32 / 8.).powf(self.sharpness / 100.)
|
||||
}
|
||||
/// Local reference width in octaves (FWHM). Much wider than the envelope.
|
||||
pub fn reference_width(&self) -> f32 {
|
||||
(self.envelope_width() * 16.).clamp(2., 3.)
|
||||
}
|
||||
/// Prominence threshold in dB above the local reference, from Selectivity.
|
||||
pub fn threshold(&self) -> f32 {
|
||||
0.5 + self.selectivity * 0.05
|
||||
}
|
||||
pub const PRESETS: [&'static str; 4] = [
|
||||
"Gentle cleanup",
|
||||
"Bass whistle",
|
||||
"Snare tail",
|
||||
"Bright melodic layer",
|
||||
"Vocal harshness",
|
||||
"Snare ring",
|
||||
"Bright synth",
|
||||
];
|
||||
pub fn preset(index: usize) -> Self {
|
||||
let mut s = Self::default();
|
||||
match index {
|
||||
1 => {
|
||||
s.low = 700.;
|
||||
s.high = 10000.;
|
||||
s.sensitivity = 5.;
|
||||
s.max_cut = 6.;
|
||||
s.cut_width = 0.05;
|
||||
s.attack = 8.;
|
||||
s.low = 1500.;
|
||||
s.high = 12000.;
|
||||
s.depth = 8.;
|
||||
s.sharpness = 55.;
|
||||
s.selectivity = 40.;
|
||||
s.attack = 3.;
|
||||
s.release = 60.;
|
||||
}
|
||||
2 => {
|
||||
s.low = 250.;
|
||||
s.high = 6000.;
|
||||
s.attack = 25.;
|
||||
s.release = 180.;
|
||||
s.transient_guard = 85.;
|
||||
s.max_cut = 5.;
|
||||
s.depth = 8.;
|
||||
s.sharpness = 75.;
|
||||
s.selectivity = 35.;
|
||||
s.attack = 10.;
|
||||
s.release = 150.;
|
||||
s.transient_guard = 80.;
|
||||
}
|
||||
3 => {
|
||||
s.low = 1800.;
|
||||
s.high = 15000.;
|
||||
s.sensitivity = 4.;
|
||||
s.max_cut = 4.;
|
||||
s.cut_width = 0.14;
|
||||
s.low = 2000.;
|
||||
s.high = 16000.;
|
||||
s.sharpness = 25.;
|
||||
s.selectivity = 45.;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ fn delayed_identity_and_all_mix_positions_at_all_rates() {
|
||||
for mix in [0., 37., 100.] {
|
||||
let mut e = Engine::new(rate, 2).unwrap();
|
||||
e.set_settings(Settings {
|
||||
amount: 0.,
|
||||
depth: 0.,
|
||||
mix,
|
||||
..Default::default()
|
||||
})
|
||||
@@ -43,7 +43,7 @@ fn delayed_identity_and_all_mix_positions_at_all_rates() {
|
||||
fn impulse_latency_delta_and_reset() {
|
||||
let mut e = Engine::new(48000., 1).unwrap();
|
||||
e.set_settings(Settings {
|
||||
amount: 0.,
|
||||
depth: 0.,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
@@ -57,44 +57,203 @@ fn impulse_latency_delta_and_reset() {
|
||||
assert_eq!(e.process_sample([0., 0.], Monitor { delta: true }), [0.; 2]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn resonance_is_local_bounded_and_recovers_in_silence() {
|
||||
let mut e = Engine::new(48000., 2).unwrap();
|
||||
e.set_settings(Settings {
|
||||
amount: 100.,
|
||||
sensitivity: 2.,
|
||||
max_cut: 6.,
|
||||
transient_guard: 0.,
|
||||
attack: 1.,
|
||||
release: 20.,
|
||||
low: 500.,
|
||||
high: 9000.,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
/// Band-limited sawtooth: every harmonic up to 18 kHz, amplitude 1/k.
|
||||
fn saw(f0: f32, i: usize, rate: f32) -> f32 {
|
||||
let t = i as f32 / rate;
|
||||
(1..)
|
||||
.map(|k| k as f32 * f0)
|
||||
.take_while(|f| *f < 18000.)
|
||||
.enumerate()
|
||||
.map(|(k, f)| (TAU * f * t).sin() / (k + 1) as f32)
|
||||
.sum::<f32>()
|
||||
* 0.2
|
||||
}
|
||||
/// RBJ peaking biquad, used to put a known resonance on a test signal.
|
||||
struct Peak {
|
||||
b: [f32; 3],
|
||||
a: [f32; 2],
|
||||
z: [f32; 2],
|
||||
}
|
||||
impl Peak {
|
||||
fn new(f: f32, q: f32, gain: f32, rate: f32) -> Self {
|
||||
let a = 10f32.powf(gain / 40.);
|
||||
let w = TAU * f / rate;
|
||||
let alpha = w.sin() / (2. * q);
|
||||
let a0 = 1. + alpha / a;
|
||||
Self {
|
||||
b: [
|
||||
(1. + alpha * a) / a0,
|
||||
-2. * w.cos() / a0,
|
||||
(1. - alpha * a) / a0,
|
||||
],
|
||||
a: [-2. * w.cos() / a0, (1. - alpha / a) / a0],
|
||||
z: [0.; 2],
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, x: f32) -> f32 {
|
||||
let y = self.b[0] * x + self.z[0];
|
||||
self.z[0] = self.b[1] * x - self.a[0] * y + self.z[1];
|
||||
self.z[1] = self.b[2] * x - self.a[1] * y;
|
||||
y
|
||||
}
|
||||
}
|
||||
fn cell(f: f32) -> usize {
|
||||
(0..GRID)
|
||||
.min_by(|a, b| {
|
||||
(frequency(*a) - f)
|
||||
.abs()
|
||||
.total_cmp(&(frequency(*b) - f).abs())
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
/// Run one second of a signal and return the last analyzer frame.
|
||||
fn settle(settings: Settings, mut signal: impl FnMut(usize) -> f32) -> AnalyzerFrame {
|
||||
let mut e = Engine::new(48000., 1).unwrap();
|
||||
e.set_settings(settings).unwrap();
|
||||
let mut frame = AnalyzerFrame::default();
|
||||
for i in 0..48000 {
|
||||
let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i);
|
||||
e.process_sample([signal(i), 0.], Monitor::default());
|
||||
if let Some(f) = e.take_analyzer() {
|
||||
frame = f;
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
fn peak_reduction(frame: &AnalyzerFrame, low: f32, high: f32) -> f32 {
|
||||
(0..GRID)
|
||||
.filter(|i| (low..=high).contains(&frequency(*i)))
|
||||
.map(|i| frame.reduction[i])
|
||||
.fold(0., f32::max)
|
||||
}
|
||||
#[test]
|
||||
fn plain_harmonic_tone_is_left_alone() {
|
||||
for f0 in [110., 220., 440.] {
|
||||
let frame = settle(Settings::default(), |i| saw(f0, i, 48000.));
|
||||
let peak = peak_reduction(&frame, 20., 20000.);
|
||||
assert!(peak < 1., "{f0} Hz sawtooth cut by {peak} dB");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn resonant_bump_is_cut_where_it_is_and_nowhere_else() {
|
||||
for f0 in [110., 220.] {
|
||||
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
||||
let frame = settle(Settings::default(), |i| bump.tick(saw(f0, i, 48000.)));
|
||||
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
|
||||
assert!(near >= 4., "{f0} Hz: bump cut only {near} dB");
|
||||
for f in [1000., 8000.] {
|
||||
let away = frame.reduction[cell(f)];
|
||||
assert!(away < 1., "{f0} Hz: {f} Hz cut by {away} dB");
|
||||
}
|
||||
assert!(peak_reduction(&frame, 20., 20000.) <= Settings::default().depth);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn depth_selectivity_and_focus_each_do_one_thing() {
|
||||
let run = |s: Settings| {
|
||||
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
||||
let frame = settle(s, |i| bump.tick(saw(220., i, 48000.)));
|
||||
frame.reduction[cell(3000.)]
|
||||
};
|
||||
let base = run(Settings::default());
|
||||
let shallow = run(Settings {
|
||||
depth: 2.,
|
||||
..Default::default()
|
||||
});
|
||||
let picky = run(Settings {
|
||||
selectivity: 100.,
|
||||
..Default::default()
|
||||
});
|
||||
let mut protect = Settings::default();
|
||||
protect.focus[0] = Focus {
|
||||
enabled: true,
|
||||
frequency: 3000.,
|
||||
width: 0.5,
|
||||
sensitivity: -12.,
|
||||
};
|
||||
let protected = run(protect);
|
||||
assert!(shallow <= 2.001 && shallow < base, "{shallow} vs {base}");
|
||||
assert!(picky < base - 1., "{picky} vs {base}");
|
||||
assert!(protected < base - 2., "{protected} vs {base}");
|
||||
}
|
||||
#[test]
|
||||
fn detection_is_level_independent() {
|
||||
let s = Settings {
|
||||
transient_guard: 0.,
|
||||
..Default::default()
|
||||
};
|
||||
let run = |gain: f32| {
|
||||
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
||||
settle(s, |i| bump.tick(saw(220., i, 48000.)) * gain)
|
||||
};
|
||||
let loud = run(1.);
|
||||
let quiet = run(0.03);
|
||||
for i in 0..GRID {
|
||||
assert!(
|
||||
(loud.reduction[i] - quiet.reduction[i]).abs() < 0.5,
|
||||
"{} Hz: {} vs {}",
|
||||
frequency(i),
|
||||
loud.reduction[i],
|
||||
quiet.reduction[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn noise_does_not_cause_flickering_cuts() {
|
||||
let mut e = Engine::new(48000., 1).unwrap();
|
||||
e.set_settings(Settings::default()).unwrap();
|
||||
let mut frames = Vec::new();
|
||||
for i in 0..96000 {
|
||||
e.process_sample([noise(i) * 3., 0.], Monitor::default());
|
||||
if let Some(f) = e.take_analyzer() {
|
||||
if i > 24000 {
|
||||
frames.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mean = frames
|
||||
.iter()
|
||||
.map(|f| f.reduction.iter().sum::<f32>() / GRID as f32)
|
||||
.sum::<f32>()
|
||||
/ frames.len() as f32;
|
||||
let jitter = frames
|
||||
.windows(2)
|
||||
.map(|w| {
|
||||
(0..GRID)
|
||||
.map(|i| (w[1].reduction[i] - w[0].reduction[i]).abs())
|
||||
.sum::<f32>()
|
||||
/ GRID as f32
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ (frames.len() - 1) as f32;
|
||||
assert!(mean < 0.5, "white noise mean cut {mean} dB");
|
||||
assert!(jitter < 0.1, "frame-to-frame change {jitter} dB");
|
||||
}
|
||||
#[test]
|
||||
fn processing_range_is_respected_and_cuts_recover_in_silence() {
|
||||
let s = Settings {
|
||||
low: 500.,
|
||||
high: 9000.,
|
||||
transient_guard: 0.,
|
||||
release: 20.,
|
||||
..Default::default()
|
||||
};
|
||||
let mut e = Engine::new(48000., 2).unwrap();
|
||||
e.set_settings(s).unwrap();
|
||||
let mut bump = Peak::new(3000., 4., 12., 48000.);
|
||||
let mut frame = AnalyzerFrame::default();
|
||||
for i in 0..48000 {
|
||||
let x = bump.tick(saw(220., i, 48000.));
|
||||
e.process_sample([x, -x], Monitor::default());
|
||||
if let Some(f) = e.take_analyzer() {
|
||||
frame = f;
|
||||
}
|
||||
}
|
||||
let peak = frame.reduction.iter().copied().fold(0f32, f32::max);
|
||||
assert!(peak > 3. && peak <= 6.);
|
||||
assert!(frame.reduction[cell(3000.)] > 3.);
|
||||
for i in 0..GRID {
|
||||
if frequency(i) < 500. || frequency(i) > 9000. {
|
||||
assert_eq!(frame.reduction[i], 0.);
|
||||
}
|
||||
}
|
||||
let ring = (0..GRID)
|
||||
.min_by(|a, b| {
|
||||
(frequency(*a) - 3000.)
|
||||
.abs()
|
||||
.total_cmp(&(frequency(*b) - 3000.).abs())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(frame.reduction[ring] > 3.);
|
||||
for _ in 0..48000 {
|
||||
e.process_sample([0.; 2], Monitor::default());
|
||||
}
|
||||
@@ -135,50 +294,6 @@ fn soft_knee_and_presets_are_valid() {
|
||||
assert_eq!(focus_offset(2000., &[p; 4]), -48.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_detector_scales_and_manual_protection_reduces_false_positives() {
|
||||
let s = Settings {
|
||||
amount: 80.,
|
||||
sensitivity: 3.,
|
||||
max_cut: 12.,
|
||||
attack: 1.,
|
||||
transient_guard: 0.,
|
||||
..Default::default()
|
||||
};
|
||||
let mut loud = Engine::new(48000., 1).unwrap();
|
||||
loud.set_settings(s).unwrap();
|
||||
let mut quiet = Engine::new(48000., 1).unwrap();
|
||||
quiet.set_settings(s).unwrap();
|
||||
let mut protected = Engine::new(48000., 1).unwrap();
|
||||
let mut p = s;
|
||||
p.focus[0] = Focus {
|
||||
enabled: true,
|
||||
frequency: 3000.,
|
||||
width: 0.5,
|
||||
sensitivity: -18.,
|
||||
};
|
||||
protected.set_settings(p).unwrap();
|
||||
for i in 0..24000 {
|
||||
let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i);
|
||||
loud.process_sample([x, 0.], Monitor::default());
|
||||
quiet.process_sample([x * 0.1, 0.], Monitor::default());
|
||||
protected.process_sample([x, 0.], Monitor::default());
|
||||
}
|
||||
let a = loud.take_analyzer().unwrap();
|
||||
let b = quiet.take_analyzer().unwrap();
|
||||
let c = protected.take_analyzer().unwrap();
|
||||
for i in 0..GRID {
|
||||
assert!((a.reduction[i] - b.reduction[i]).abs() < 0.02);
|
||||
}
|
||||
let k = (0..GRID)
|
||||
.min_by(|a, b| {
|
||||
(frequency(*a) - 3000.)
|
||||
.abs()
|
||||
.total_cmp(&(frequency(*b) - 3000.).abs())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(c.reduction[k] < a.reduction[k] - 1.);
|
||||
}
|
||||
#[test]
|
||||
fn settled_delta_and_bypass_have_precise_taps() {
|
||||
let s = Settings {
|
||||
@@ -205,3 +320,42 @@ fn settled_delta_and_bypass_have_precise_taps() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn narrow_ring_on_noise_is_cut_and_sharpness_narrows_the_cut() {
|
||||
let width = |sharpness: f32| {
|
||||
let s = Settings {
|
||||
sharpness,
|
||||
..Default::default()
|
||||
};
|
||||
let frame = settle(s, |i| {
|
||||
0.05 * (TAU * 5000. * i as f32 / 48000.).sin() + noise(i) * 3.
|
||||
});
|
||||
let peak = frame.reduction[cell(5000.)];
|
||||
let wide = (0..GRID)
|
||||
.filter(|i| frame.reduction[*i] > peak * 0.5)
|
||||
.count();
|
||||
(peak, wide)
|
||||
};
|
||||
let (soft, soft_width) = width(0.);
|
||||
let (sharp, sharp_width) = width(100.);
|
||||
assert!(soft > 3. && sharp > 3., "{soft} / {sharp}");
|
||||
assert!(sharp_width < soft_width, "{sharp_width} vs {soft_width}");
|
||||
}
|
||||
#[test]
|
||||
fn high_sample_rates_detect_the_same_bump() {
|
||||
for rate in [96000., 192000.] {
|
||||
let mut e = Engine::new(rate, 1).unwrap();
|
||||
e.set_settings(Settings::default()).unwrap();
|
||||
let mut bump = Peak::new(3000., 4., 12., rate);
|
||||
let mut frame = AnalyzerFrame::default();
|
||||
for i in 0..rate as usize {
|
||||
e.process_sample([bump.tick(saw(220., i, rate)), 0.], Monitor::default());
|
||||
if let Some(f) = e.take_analyzer() {
|
||||
frame = f;
|
||||
}
|
||||
}
|
||||
let near = peak_reduction(&frame, 3000. / 1.26, 3000. * 1.26);
|
||||
let away = peak_reduction(&frame, 20., 1500.);
|
||||
assert!(near >= 4. && away < 1., "{rate}: {near} / {away}");
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,13 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "common-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1412,7 +1419,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"crossbeam-queue",
|
||||
@@ -1425,7 +1432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina-core"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
"serde",
|
||||
@@ -1433,8 +1440,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "patina-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"common-ui",
|
||||
"egui",
|
||||
"patina-core",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ mod params;
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::{values, PatinaParams};
|
||||
use params::{values, PatinaParams, COUNT};
|
||||
use patina_core::{AnalyzerFrame, Engine, Monitor};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
@@ -92,7 +92,7 @@ impl Plugin for Patina {
|
||||
let old = values(&before);
|
||||
let new = values(&edited);
|
||||
let floats = params.floats();
|
||||
for i in 0..29 {
|
||||
for i in 0..COUNT {
|
||||
if new[i] != old[i] {
|
||||
if !state.gestures[i] {
|
||||
setter.begin_set_parameter(floats[i]);
|
||||
|
||||
@@ -42,18 +42,12 @@ impl Default for FocusParams {
|
||||
pub struct PatinaParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[id = "amount"]
|
||||
pub amount: FloatParam,
|
||||
#[id = "sensitivity"]
|
||||
pub sensitivity: FloatParam,
|
||||
#[id = "max-cut"]
|
||||
pub max_cut: FloatParam,
|
||||
#[id = "neighborhood"]
|
||||
pub neighborhood: FloatParam,
|
||||
#[id = "cut-width"]
|
||||
pub cut_width: FloatParam,
|
||||
#[id = "knee"]
|
||||
pub knee: FloatParam,
|
||||
#[id = "depth"]
|
||||
pub depth: FloatParam,
|
||||
#[id = "sharpness"]
|
||||
pub sharpness: FloatParam,
|
||||
#[id = "selectivity"]
|
||||
pub selectivity: FloatParam,
|
||||
#[id = "attack"]
|
||||
pub attack: FloatParam,
|
||||
#[id = "release"]
|
||||
@@ -75,19 +69,17 @@ pub struct PatinaParams {
|
||||
}
|
||||
impl Default for PatinaParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(1180, 760),
|
||||
amount: linear("Amount", 35., 0., 100., " %"),
|
||||
sensitivity: linear("Sensitivity", 6., 0., 24., " dB"),
|
||||
max_cut: linear("Max cut", 3., 0., 18., " dB"),
|
||||
neighborhood: linear("Neighborhood", 1., 0.25, 2., " oct"),
|
||||
cut_width: linear("Cut width", 0.08, 0.03, 0.5, " oct"),
|
||||
knee: linear("Knee", 3., 0., 12., " dB"),
|
||||
attack: log("Attack", 15., 1., 200., " ms"),
|
||||
release: log("Release", 120., 10., 1000., " ms"),
|
||||
transient_guard: linear("Transient guard", 50., 0., 100., " %"),
|
||||
low: log("Low frequency", 120., 20., 19999., " Hz"),
|
||||
high: log("High frequency", 12000., 21., 20000., " Hz"),
|
||||
depth: linear("Depth", d.depth, 0., 20., " dB"),
|
||||
sharpness: linear("Sharpness", d.sharpness, 0., 100., " %"),
|
||||
selectivity: linear("Selectivity", d.selectivity, 0., 100., " %"),
|
||||
attack: log("Attack", d.attack, 1., 200., " ms"),
|
||||
release: log("Release", d.release, 10., 1000., " ms"),
|
||||
transient_guard: linear("Transient guard", d.transient_guard, 0., 100., " %"),
|
||||
low: log("Low frequency", d.low, 20., 19999., " Hz"),
|
||||
high: log("High frequency", d.high, 21., 20000., " Hz"),
|
||||
mix: linear("Mix", 100., 0., 100., " %"),
|
||||
output: linear("Output", 0., -12., 12., " dB"),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
@@ -96,14 +88,11 @@ impl Default for PatinaParams {
|
||||
}
|
||||
}
|
||||
impl PatinaParams {
|
||||
pub fn floats(&self) -> [&FloatParam; 29] {
|
||||
pub fn floats(&self) -> [&FloatParam; COUNT] {
|
||||
[
|
||||
&self.amount,
|
||||
&self.sensitivity,
|
||||
&self.max_cut,
|
||||
&self.neighborhood,
|
||||
&self.cut_width,
|
||||
&self.knee,
|
||||
&self.depth,
|
||||
&self.sharpness,
|
||||
&self.selectivity,
|
||||
&self.attack,
|
||||
&self.release,
|
||||
&self.transient_guard,
|
||||
@@ -131,12 +120,9 @@ impl PatinaParams {
|
||||
}
|
||||
pub fn settings(&self) -> Settings {
|
||||
let mut s = Settings {
|
||||
amount: self.amount.value(),
|
||||
sensitivity: self.sensitivity.value(),
|
||||
max_cut: self.max_cut.value(),
|
||||
neighborhood: self.neighborhood.value(),
|
||||
cut_width: self.cut_width.value(),
|
||||
knee: self.knee.value(),
|
||||
depth: self.depth.value(),
|
||||
sharpness: self.sharpness.value(),
|
||||
selectivity: self.selectivity.value(),
|
||||
attack: self.attack.value(),
|
||||
release: self.release.value(),
|
||||
transient_guard: self.transient_guard.value(),
|
||||
@@ -159,15 +145,14 @@ impl PatinaParams {
|
||||
s
|
||||
}
|
||||
}
|
||||
pub fn values(s: &Settings) -> [f32; 29] {
|
||||
let mut v = [0.; 29];
|
||||
v[..13].copy_from_slice(&[
|
||||
s.amount,
|
||||
s.sensitivity,
|
||||
s.max_cut,
|
||||
s.neighborhood,
|
||||
s.cut_width,
|
||||
s.knee,
|
||||
/// Host float parameters: ten main controls, then four per focus point.
|
||||
pub const COUNT: usize = 26;
|
||||
pub fn values(s: &Settings) -> [f32; COUNT] {
|
||||
let mut v = [0.; COUNT];
|
||||
v[..10].copy_from_slice(&[
|
||||
s.depth,
|
||||
s.sharpness,
|
||||
s.selectivity,
|
||||
s.attack,
|
||||
s.release,
|
||||
s.transient_guard,
|
||||
@@ -177,7 +162,7 @@ pub fn values(s: &Settings) -> [f32; 29] {
|
||||
s.output,
|
||||
]);
|
||||
for i in 0..4 {
|
||||
v[13 + i * 4..17 + i * 4].copy_from_slice(&[
|
||||
v[10 + i * 4..14 + i * 4].copy_from_slice(&[
|
||||
f32::from(s.focus[i].enabled),
|
||||
s.focus[i].frequency,
|
||||
s.focus[i].width,
|
||||
|
||||
@@ -104,7 +104,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
let mut envelope: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
use nih_plug::wrapper::state::ParamValue;
|
||||
envelope.params.insert("amount".into(), ParamValue::F32(0.));
|
||||
envelope.params.insert("depth".into(), ParamValue::F32(0.));
|
||||
envelope
|
||||
.params
|
||||
.insert("frequency_1".into(), ParamValue::F32(2300.));
|
||||
@@ -133,7 +133,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
assert_eq!(delay, if rate > 48000. { 8192 } else { 4096 });
|
||||
assert_eq!(delay, if rate > 48000. { 4096 } else { 2048 });
|
||||
assert!(p.start_processing.unwrap()(plugin));
|
||||
let mut position = 0;
|
||||
while position < delay + 1024 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "patina-render"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let x = (i as f32 * 0.173).sin() * 0.3;
|
||||
std::hint::black_box(e.process_sample([x, -x], Monitor::default()));
|
||||
}
|
||||
println!("Stereo 48 kHz / FFT 4096 / hop 512 / 5 seconds: {:.3}s ({:.1}% of one core, run --release for meaningful results)",start.elapsed().as_secs_f64(),start.elapsed().as_secs_f64()/5.*100.);
|
||||
println!("Stereo 48 kHz / FFT 2048 / hop 256 / 5 seconds: {:.3}s ({:.1}% of one core, run --release for meaningful results)",start.elapsed().as_secs_f64(),start.elapsed().as_secs_f64()/5.*100.);
|
||||
return Ok(());
|
||||
}
|
||||
let (rate, channels, input, output) = if args[0] == "--demo" && args.len() == 2 {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
[package]
|
||||
name = "patina-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
[dependencies]
|
||||
egui = "0.31.1"
|
||||
patina-core = { path = "../core" }
|
||||
common-ui = { path = "../../common/ui" }
|
||||
@@ -1,57 +1,10 @@
|
||||
//! Shared native editor, also exercised by the headless renderer.
|
||||
pub use common_ui::style;
|
||||
use common_ui::*;
|
||||
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
|
||||
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BG: Color32 = Color32::from_rgb(23, 20, 31);
|
||||
const PANEL: Color32 = Color32::from_rgb(37, 33, 49);
|
||||
const TEXT: Color32 = Color32::from_rgb(238, 234, 245);
|
||||
const MUTED: Color32 = Color32::from_rgb(161, 151, 179);
|
||||
const CYAN: Color32 = Color32::from_rgb(102, 215, 230);
|
||||
const CORAL: Color32 = Color32::from_rgb(255, 136, 125);
|
||||
const VIOLET: Color32 = Color32::from_rgb(174, 154, 247);
|
||||
pub fn style(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"regular".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"medium".into(),
|
||||
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.get_mut(&egui::FontFamily::Proportional)
|
||||
.unwrap()
|
||||
.insert(0, "regular".into());
|
||||
fonts.families.insert(
|
||||
egui::FontFamily::Name("medium".into()),
|
||||
vec![
|
||||
"medium".into(),
|
||||
"Ubuntu-Light".into(),
|
||||
"NotoEmoji-Regular".into(),
|
||||
"emoji-icon-font".into(),
|
||||
],
|
||||
);
|
||||
ctx.set_fonts(fonts);
|
||||
let mut s = (*ctx.style()).clone();
|
||||
s.visuals = egui::Visuals::dark();
|
||||
s.visuals.panel_fill = BG;
|
||||
s.visuals.window_fill = PANEL;
|
||||
s.visuals.override_text_color = Some(TEXT);
|
||||
s.visuals.selection.bg_fill = Color32::from_rgb(80, 64, 112);
|
||||
s.visuals.widgets.inactive.bg_fill = PANEL;
|
||||
s.visuals.widgets.hovered.bg_fill = Color32::from_rgb(62, 50, 80);
|
||||
s.visuals.widgets.active.bg_fill = Color32::from_rgb(81, 58, 102);
|
||||
s.spacing.item_spacing = Vec2::new(10., 8.);
|
||||
s.spacing.button_padding = Vec2::new(12., 6.);
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Body, FontId::proportional(16.));
|
||||
s.text_styles
|
||||
.insert(egui::TextStyle::Button, FontId::proportional(16.));
|
||||
ctx.set_style(s);
|
||||
}
|
||||
pub struct State {
|
||||
pub delta: bool,
|
||||
pub selected: Option<usize>,
|
||||
@@ -77,7 +30,7 @@ impl Default for State {
|
||||
gesture_active: false,
|
||||
gestures: [false; 30],
|
||||
last_audio: Instant::now(),
|
||||
help: "Focus a troublesome region, then compare in the full mix.".into(),
|
||||
help: "Set Depth, then raise Selectivity until only the harsh parts are cut. Double-click a knob to reset; Shift for fine steps.".into(),
|
||||
drag: None,
|
||||
}
|
||||
}
|
||||
@@ -87,6 +40,8 @@ impl State {
|
||||
// Display-only smoothing; it never feeds the detector.
|
||||
for i in 0..GRID {
|
||||
self.analyzer.input[i] += 0.45 * (frame.input[i] - self.analyzer.input[i]);
|
||||
self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]);
|
||||
self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]);
|
||||
self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]);
|
||||
}
|
||||
self.analyzer.focus = frame.focus;
|
||||
@@ -107,6 +62,12 @@ impl State {
|
||||
fn db(x: f32) -> f32 {
|
||||
20. * x.max(1e-6).log10()
|
||||
}
|
||||
/// Shows a help line in the status bar while the knob is hovered or focused.
|
||||
fn hint(response: egui::Response, state: &mut State, text: &str) {
|
||||
if response.hovered() || response.has_focus() {
|
||||
state.help = text.into();
|
||||
}
|
||||
}
|
||||
fn note(ui: &mut egui::Ui, text: &str) {
|
||||
ui.label(RichText::new(text).color(MUTED));
|
||||
}
|
||||
@@ -122,7 +83,7 @@ fn control(
|
||||
state: &mut State,
|
||||
) {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(name).color(TEXT));
|
||||
caption(ui, &name.to_uppercase());
|
||||
let previous = *value;
|
||||
let response = ui.add(
|
||||
egui::Slider::new(value, range)
|
||||
@@ -137,15 +98,9 @@ fn control(
|
||||
if response.double_clicked() {
|
||||
let defaults = Settings::default();
|
||||
*value = match name {
|
||||
"Amount" => defaults.amount,
|
||||
"Sensitivity" => defaults.sensitivity,
|
||||
"Max cut" => defaults.max_cut,
|
||||
"Transient guard" => defaults.transient_guard,
|
||||
"Attack" => defaults.attack,
|
||||
"Release" => defaults.release,
|
||||
"Neighborhood" => defaults.neighborhood,
|
||||
"Cut width" => defaults.cut_width,
|
||||
"Knee" => defaults.knee,
|
||||
"Low frequency" => defaults.low,
|
||||
"High frequency" => defaults.high,
|
||||
"Mix" => defaults.mix,
|
||||
@@ -157,139 +112,41 @@ fn control(
|
||||
}
|
||||
});
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotary(
|
||||
ui: &mut egui::Ui,
|
||||
name: &str,
|
||||
value: &mut f32,
|
||||
range: std::ops::RangeInclusive<f32>,
|
||||
default: f32,
|
||||
unit: &str,
|
||||
size: f32,
|
||||
color: Color32,
|
||||
) {
|
||||
let low = *range.start();
|
||||
let high = *range.end();
|
||||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
|
||||
if response.drag_started() {
|
||||
response.request_focus();
|
||||
}
|
||||
if response.dragged() {
|
||||
let delta = ui.input(|i| i.pointer.delta().y);
|
||||
let fine = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
*value = default;
|
||||
}
|
||||
if response.has_focus() {
|
||||
let step = if ui.input(|i| i.modifiers.shift) {
|
||||
0.1
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
|
||||
*value = (*value + step).min(high);
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
|
||||
{
|
||||
*value = (*value - step).max(low);
|
||||
}
|
||||
}
|
||||
let center = rect.center();
|
||||
let radius = size * 0.45;
|
||||
let p = ui.painter();
|
||||
p.circle_filled(
|
||||
center + Vec2::new(0., 3.),
|
||||
radius,
|
||||
Color32::from_black_alpha(90),
|
||||
);
|
||||
p.circle_filled(center, radius, Color32::from_rgb(20, 18, 29));
|
||||
p.circle_stroke(
|
||||
center,
|
||||
radius,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(74, 65, 90)),
|
||||
);
|
||||
p.circle_filled(center, radius - 6., Color32::from_rgb(43, 37, 56));
|
||||
p.circle_stroke(
|
||||
center,
|
||||
radius - 7.,
|
||||
Stroke::new(1.0_f32, Color32::from_rgb(60, 51, 77)),
|
||||
);
|
||||
let start = std::f32::consts::PI * 0.75;
|
||||
let span = std::f32::consts::PI * 1.5;
|
||||
let normalized = (*value - low) / (high - low);
|
||||
for (fraction, stroke) in [
|
||||
(1., Stroke::new(3.0_f32, Color32::from_rgb(59, 49, 76))),
|
||||
(normalized, Stroke::new(3.0_f32, color)),
|
||||
] {
|
||||
let points: Vec<_> = (0..=64)
|
||||
.map(|i| {
|
||||
let a = start + span * fraction * i as f32 / 64.;
|
||||
center + Vec2::angled(a) * (radius - 3.)
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
let angle = start + span * normalized;
|
||||
p.line_segment(
|
||||
[
|
||||
center + Vec2::angled(angle) * (radius - 14.),
|
||||
center + Vec2::angled(angle) * (radius - 8.),
|
||||
],
|
||||
Stroke::new(2.0_f32, color),
|
||||
);
|
||||
p.text(
|
||||
center,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{:.0}{}", *value, unit),
|
||||
FontId::proportional(if size > 100. { 25. } else { 18. }),
|
||||
color,
|
||||
);
|
||||
if response.has_focus() {
|
||||
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, TEXT));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.size(if size > 100. { 20. } else { 17. })
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(value)
|
||||
.range(range)
|
||||
.speed(0.1)
|
||||
.suffix(unit),
|
||||
);
|
||||
}
|
||||
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
|
||||
let before = *settings;
|
||||
let full = ui.max_rect();
|
||||
ui.painter()
|
||||
.add(gradient(full, Color32::from_rgb(20, 21, 24), BG));
|
||||
ui.add_space(2.);
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Patina")
|
||||
.font(FontId::new(30., egui::FontFamily::Name("medium".into()))),
|
||||
);
|
||||
note(ui, "Keep the character. Control the ring.");
|
||||
});
|
||||
let (mark, _) = ui.allocate_exact_size(Vec2::new(4., 26.), Sense::hover());
|
||||
ui.painter().rect_filled(mark, 1., ACCENT);
|
||||
ui.label(
|
||||
RichText::new("PATINA")
|
||||
.font(FontId::new(26., egui::FontFamily::Name("medium".into())))
|
||||
.color(TEXT),
|
||||
);
|
||||
ui.label(RichText::new("RESONANCE SUPPRESSOR").size(12.).color(MUTED));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut settings.bypass, "Bypass");
|
||||
if ui
|
||||
.selectable_label(
|
||||
state.delta,
|
||||
RichText::new("Delta").color(if state.delta { CORAL } else { TEXT }),
|
||||
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
|
||||
.on_hover_text("Crossfade to the delayed dry signal.")
|
||||
.clicked()
|
||||
{
|
||||
settings.bypass = !settings.bypass;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(state.delta, RichText::new("DELTA").size(13.))
|
||||
.on_hover_text(
|
||||
"Hear only what is removed: delayed dry minus wet, before output trim.",
|
||||
)
|
||||
.on_hover_text("Hear delayed dry minus wet, before output trim.")
|
||||
.clicked()
|
||||
{
|
||||
state.delta = !state.delta;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Copy A to B").clicked() {
|
||||
if ui.button(RichText::new("COPY A TO B").size(13.)).clicked() {
|
||||
state.snapshots[state.active] = *settings;
|
||||
state.snapshots[1] = state.snapshots[0];
|
||||
if state.active == 1 {
|
||||
@@ -298,7 +155,10 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
for i in (0..2).rev() {
|
||||
if ui
|
||||
.selectable_label(state.active == i, if i == 0 { "A" } else { "B" })
|
||||
.selectable_label(
|
||||
state.active == i,
|
||||
RichText::new(if i == 0 { "A" } else { "B" }).size(13.),
|
||||
)
|
||||
.clicked()
|
||||
&& state.active != i
|
||||
{
|
||||
@@ -307,11 +167,12 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
state.active = i;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
egui::ComboBox::from_id_salt("resonance-preset")
|
||||
.selected_text(
|
||||
(0..4)
|
||||
.find(|i| *settings == Settings::preset(*i))
|
||||
.map_or("Custom settings", |i| Settings::PRESETS[i]),
|
||||
.map_or("Custom", |i| Settings::PRESETS[i]),
|
||||
)
|
||||
.width(155.)
|
||||
.show_ui(ui, |ui| {
|
||||
@@ -324,117 +185,128 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.);
|
||||
ui.add_space(6.);
|
||||
ui.horizontal(|ui| {
|
||||
let input = db(state.analyzer.input_peak);
|
||||
let output = db(state.analyzer.output_peak);
|
||||
ui.label(RichText::new(format!("Input {input:>5.1} dBFS")).color(CYAN));
|
||||
ui.label(
|
||||
RichText::new(format!("Output {output:>5.1} dBFS")).color(if output >= 0. {
|
||||
CORAL
|
||||
} else {
|
||||
TEXT
|
||||
}),
|
||||
);
|
||||
let max = state
|
||||
.analyzer
|
||||
.reduction
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0f32, f32::max);
|
||||
ui.label(RichText::new(format!("Reduction {max:.1} dB")).color(CORAL));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Transient guard {:.0}%",
|
||||
state.analyzer.guard * 100.
|
||||
))
|
||||
.color(MUTED),
|
||||
readout(ui, "IN", format!("{input:>6.1} dBFS"), STEEL);
|
||||
readout(
|
||||
ui,
|
||||
"OUT",
|
||||
format!("{output:>6.1} dBFS"),
|
||||
if output >= 0. { ACCENT } else { TEXT },
|
||||
);
|
||||
readout(ui, "CUT", format!("{max:>4.1} dB"), ACCENT);
|
||||
readout(
|
||||
ui,
|
||||
"GUARD",
|
||||
format!("{:>3.0} %", state.analyzer.guard * 100.),
|
||||
MUTED,
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
note(
|
||||
caption(
|
||||
ui,
|
||||
&format!(
|
||||
"{:.1} ms latency • {}",
|
||||
"{:.1} ms latency · {}",
|
||||
state.analyzer.latency as f32 / state.analyzer.sample_rate * 1000.,
|
||||
if state.analyzer.channels == 1 {
|
||||
"Mono"
|
||||
} else {
|
||||
"Stereo linked"
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.);
|
||||
let body_height = (ui.available_height() - 35.).max(500.);
|
||||
let body_height = (ui.available_height() - 40.).max(480.);
|
||||
ui.horizontal_top(|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL)
|
||||
.corner_radius(8.)
|
||||
.inner_margin(14.)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(156.);
|
||||
ui.set_min_height(body_height - 28.);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(4.);
|
||||
rotary(
|
||||
ui,
|
||||
"Amount",
|
||||
&mut settings.amount,
|
||||
0. ..=100.,
|
||||
35.,
|
||||
" %",
|
||||
126.,
|
||||
CYAN,
|
||||
);
|
||||
ui.add_space(8.);
|
||||
ui.separator();
|
||||
ui.add_space(12.);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_width(72.);
|
||||
rotary(
|
||||
ui,
|
||||
"Mix",
|
||||
&mut settings.mix,
|
||||
0. ..=100.,
|
||||
100.,
|
||||
" %",
|
||||
64.,
|
||||
VIOLET,
|
||||
);
|
||||
});
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_width(72.);
|
||||
rotary(
|
||||
ui,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
0.,
|
||||
" dB",
|
||||
64.,
|
||||
CORAL,
|
||||
);
|
||||
});
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(176.);
|
||||
ui.set_min_height(body_height - 28.);
|
||||
ui.vertical_centered(|ui| {
|
||||
caption(ui, "DETECTION");
|
||||
ui.add_space(4.);
|
||||
let defaults = Settings::default();
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Depth",
|
||||
&mut settings.depth,
|
||||
0. ..=20.,
|
||||
defaults.depth,
|
||||
" dB",
|
||||
108.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Largest cut. 0 dB does nothing.");
|
||||
ui.add_space(8.);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Sharpness",
|
||||
&mut settings.sharpness,
|
||||
0. ..=100.,
|
||||
defaults.sharpness,
|
||||
" %",
|
||||
58.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Low: smooth broad bumps. High: narrow rings.");
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
let knob = rotary(
|
||||
ui,
|
||||
"Selectivity",
|
||||
&mut settings.selectivity,
|
||||
0. ..=100.,
|
||||
defaults.selectivity,
|
||||
" %",
|
||||
58.,
|
||||
ACCENT,
|
||||
);
|
||||
hint(knob, state, "Low: treat any bump. High: only the worst.");
|
||||
});
|
||||
});
|
||||
ui.add_space(10.);
|
||||
ui.separator();
|
||||
ui.add_space(4.);
|
||||
caption(ui, "OUTPUT");
|
||||
ui.add_space(4.);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered(|ui| {
|
||||
rotary(
|
||||
ui,
|
||||
"Mix",
|
||||
&mut settings.mix,
|
||||
0. ..=100.,
|
||||
100.,
|
||||
" %",
|
||||
58.,
|
||||
STEEL,
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered(|ui| {
|
||||
rotary(
|
||||
ui,
|
||||
"Output",
|
||||
&mut settings.output,
|
||||
-12. ..=12.,
|
||||
0.,
|
||||
" dB",
|
||||
58.,
|
||||
STEEL,
|
||||
);
|
||||
});
|
||||
ui.add_space(10.);
|
||||
note(ui, "Peak reduction");
|
||||
let max = state
|
||||
.analyzer
|
||||
.reduction
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0f32, f32::max);
|
||||
ui.label(RichText::new(format!("{max:.1} dB")).size(25.).color(CORAL));
|
||||
ui.add_space(12.);
|
||||
ui.label(
|
||||
RichText::new("Drag to adjust. Shift for fine steps. Double-click resets.")
|
||||
.size(13.)
|
||||
.color(MUTED),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(4.);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
@@ -442,7 +314,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
spectrum(ui, settings, state);
|
||||
ui.add_space(4.);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new("Focus / protect").color(VIOLET));
|
||||
ui.label(RichText::new("FOCUS / PROTECT").size(11.).color(FOCUS));
|
||||
for i in 0..4 {
|
||||
let f = settings.focus[i];
|
||||
if ui
|
||||
@@ -464,7 +336,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
.range(20. ..=20000.)
|
||||
.speed(5.),
|
||||
);
|
||||
ui.label("Sensitivity");
|
||||
ui.label("Cut more (+) / protect (−)");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut f.sensitivity)
|
||||
.range(-18. ..=18.)
|
||||
@@ -488,59 +360,18 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
} else {
|
||||
note(
|
||||
ui,
|
||||
"Double-click the spectrum to place a point. Drag to tame or protect.",
|
||||
"Double-click the spectrum to place a point. Drag up to cut more there, down to protect.",
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.);
|
||||
ui.separator();
|
||||
ui.add_space(4.);
|
||||
ui.add_space(8.);
|
||||
panel(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
egui::Grid::new("engine-controls")
|
||||
.num_columns(4)
|
||||
.num_columns(3)
|
||||
.spacing([16., 12.])
|
||||
.min_col_width((ui.available_width() - 48.) / 4.)
|
||||
.min_col_width((ui.available_width() - 32.) / 3.)
|
||||
.show(ui, |ui| {
|
||||
control(
|
||||
ui,
|
||||
"Sensitivity",
|
||||
&mut settings.sensitivity,
|
||||
0. ..=24.,
|
||||
" dB",
|
||||
false,
|
||||
"Lower catches more peaks. Measured relative to the local spectral reference.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Max cut",
|
||||
&mut settings.max_cut,
|
||||
0. ..=18.,
|
||||
" dB",
|
||||
false,
|
||||
"Upper bound on spectral attenuation. This is not a limiter.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Transient guard",
|
||||
&mut settings.transient_guard,
|
||||
0. ..=100.,
|
||||
" %",
|
||||
false,
|
||||
"Relaxes reduction during attacks. Turn down to catch short resonances.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Knee",
|
||||
&mut settings.knee,
|
||||
0. ..=12.,
|
||||
" dB",
|
||||
false,
|
||||
"Softness of the transition into prominence reduction.",
|
||||
state,
|
||||
);
|
||||
ui.end_row();
|
||||
control(
|
||||
ui,
|
||||
"Attack",
|
||||
@@ -548,7 +379,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
1. ..=200.,
|
||||
" ms",
|
||||
true,
|
||||
"Attenuation rise time, in addition to window and detection delay.",
|
||||
"How fast cuts grow. Short catches brief whistles.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
@@ -558,27 +389,17 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
10. ..=1000.,
|
||||
" ms",
|
||||
true,
|
||||
"Recovery time after a resonance subsides.",
|
||||
"How fast cuts recover after a resonance stops.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Neighborhood",
|
||||
&mut settings.neighborhood,
|
||||
0.25..=2.,
|
||||
" oct",
|
||||
"Transient guard",
|
||||
&mut settings.transient_guard,
|
||||
0. ..=100.,
|
||||
" %",
|
||||
false,
|
||||
"Width of the surrounding spectral reference. Wider includes more context.",
|
||||
state,
|
||||
);
|
||||
control(
|
||||
ui,
|
||||
"Cut width",
|
||||
&mut settings.cut_width,
|
||||
0.03..=0.5,
|
||||
" oct",
|
||||
false,
|
||||
"Frequency smoothing width of the attenuation surface.",
|
||||
"Relaxes cuts on attacks to keep punch. Turn down to catch short rings.",
|
||||
state,
|
||||
);
|
||||
ui.end_row();
|
||||
@@ -609,6 +430,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
state,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
@@ -626,42 +448,48 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
}
|
||||
fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let height = (ui.available_height() - 360.).clamp(180., 480.);
|
||||
let height = (ui.available_height() - 290.).clamp(180., 540.);
|
||||
let (outer, response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), height),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(outer, 6., Color32::from_rgb(15, 13, 22));
|
||||
p.rect_filled(outer, 3., SCREEN);
|
||||
p.rect_stroke(
|
||||
outer,
|
||||
3.,
|
||||
Stroke::new(1.0_f32, EDGE),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let r = Rect::from_min_max(
|
||||
outer.min + Vec2::new(46., 26.),
|
||||
outer.max - Vec2::new(42., 26.),
|
||||
);
|
||||
let x = |f: f32| r.left() + ((f.max(20.) / 20.).ln() / 1000f32.ln()).clamp(0., 1.) * r.width();
|
||||
let y = |d: f32| r.bottom() - (d + 100.).clamp(0., 100.) / 100. * r.height();
|
||||
let red_y = |d: f32| r.top() + d / 18. * r.height() * 0.75;
|
||||
let red_y = |d: f32| r.top() + d / 20. * r.height() * 0.75;
|
||||
for (xx, level, color) in [
|
||||
(outer.left() + 5., state.analyzer.input_peak, CYAN),
|
||||
(outer.left() + 5., state.analyzer.input_peak, STEEL),
|
||||
(
|
||||
outer.right() - 9.,
|
||||
state.analyzer.output_peak,
|
||||
if state.analyzer.output_peak >= 1. {
|
||||
CORAL
|
||||
ACCENT
|
||||
} else {
|
||||
VIOLET
|
||||
TEXT
|
||||
},
|
||||
),
|
||||
] {
|
||||
let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom()));
|
||||
p.rect_filled(rail, 2., Color32::from_rgb(38, 33, 48));
|
||||
p.rect_filled(rail, 1., Color32::from_rgb(26, 28, 32));
|
||||
let height = ((db(level) + 72.) / 72.).clamp(0., 1.) * rail.height();
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(Pos2::new(xx, rail.bottom() - height), rail.max),
|
||||
2.,
|
||||
color.gamma_multiply(0.65),
|
||||
color.gamma_multiply(0.75),
|
||||
);
|
||||
}
|
||||
let color_grid = Color32::from_rgb(58, 51, 70);
|
||||
let color_grid = Color32::from_rgb(30, 32, 36);
|
||||
for f in [
|
||||
20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000.,
|
||||
] {
|
||||
@@ -696,13 +524,13 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
MUTED,
|
||||
);
|
||||
}
|
||||
for d in [0., 6., 12., 18.] {
|
||||
for d in [0., 5., 10., 15., 20.] {
|
||||
p.text(
|
||||
Pos2::new(r.right() + 7., red_y(d)),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("−{d:.0}"),
|
||||
FontId::proportional(12.),
|
||||
CORAL,
|
||||
ACCENT,
|
||||
);
|
||||
}
|
||||
p.text(
|
||||
@@ -717,17 +545,17 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
egui::Align2::RIGHT_TOP,
|
||||
"Cut dB",
|
||||
FontId::proportional(12.),
|
||||
CORAL,
|
||||
ACCENT,
|
||||
);
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())),
|
||||
0.,
|
||||
Color32::from_black_alpha(90),
|
||||
Color32::from_black_alpha(120),
|
||||
);
|
||||
p.rect_filled(
|
||||
Rect::from_min_max(Pos2::new(x(settings.high), r.top()), r.max),
|
||||
0.,
|
||||
Color32::from_black_alpha(90),
|
||||
Color32::from_black_alpha(120),
|
||||
);
|
||||
let signal: Vec<_> = (0..GRID)
|
||||
.map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i])))
|
||||
@@ -736,9 +564,9 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
for (i, point) in signal.iter().enumerate() {
|
||||
source_fill.colored_vertex(
|
||||
Pos2::new(point.x, r.bottom()),
|
||||
Color32::from_rgba_unmultiplied(102, 215, 230, 7),
|
||||
Color32::from_rgba_unmultiplied(184, 192, 204, 4),
|
||||
);
|
||||
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(102, 215, 230, 60));
|
||||
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(184, 192, 204, 34));
|
||||
if i > 0 {
|
||||
let j = (i * 2) as u32;
|
||||
source_fill.add_triangle(j - 2, j - 1, j);
|
||||
@@ -746,7 +574,21 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
}
|
||||
}
|
||||
p.add(egui::Shape::mesh(source_fill));
|
||||
p.add(egui::Shape::line(signal, Stroke::new(1.5_f32, CYAN)));
|
||||
p.add(egui::Shape::line(
|
||||
signal,
|
||||
Stroke::new(1.2_f32, STEEL.gamma_multiply(0.7)),
|
||||
));
|
||||
// What the detector hears (envelope) and what it expects (reference).
|
||||
// Accent cuts appear where the envelope rises above the reference.
|
||||
for (values, color, width) in [
|
||||
(&state.analyzer.reference, DIM, 1.0_f32),
|
||||
(&state.analyzer.envelope, TEXT.gamma_multiply(0.8), 1.2_f32),
|
||||
] {
|
||||
let line: Vec<_> = (0..GRID)
|
||||
.map(|i| Pos2::new(x(frequency(i)), y(values[i])))
|
||||
.collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(width, color)));
|
||||
}
|
||||
let mut fill = Vec::with_capacity(GRID + 2);
|
||||
fill.push(r.left_top());
|
||||
for i in 0..GRID {
|
||||
@@ -763,11 +605,11 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
let yy = red_y(state.analyzer.reduction[i]);
|
||||
mesh.colored_vertex(
|
||||
Pos2::new(xx, r.top()),
|
||||
Color32::from_rgba_unmultiplied(255, 136, 125, 8),
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 6),
|
||||
);
|
||||
mesh.colored_vertex(
|
||||
Pos2::new(xx, yy),
|
||||
Color32::from_rgba_unmultiplied(255, 136, 125, 90),
|
||||
Color32::from_rgba_unmultiplied(255, 1, 96, 100),
|
||||
);
|
||||
if i > 0 {
|
||||
let j = (i * 2) as u32;
|
||||
@@ -778,7 +620,7 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
p.add(egui::Shape::mesh(mesh));
|
||||
p.add(egui::Shape::line(
|
||||
fill[1..=GRID].to_vec(),
|
||||
Stroke::new(1.6_f32, CORAL),
|
||||
Stroke::new(1.6_f32, ACCENT),
|
||||
));
|
||||
let center = r.center().y;
|
||||
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
|
||||
@@ -792,12 +634,15 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
.collect();
|
||||
p.line_segment(
|
||||
[Pos2::new(r.left(), center), Pos2::new(r.right(), center)],
|
||||
Stroke::new(0.7_f32, VIOLET.gamma_multiply(0.35)),
|
||||
Stroke::new(0.7_f32, FOCUS.gamma_multiply(0.3)),
|
||||
);
|
||||
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, VIOLET)));
|
||||
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, FOCUS)));
|
||||
for (i, f) in settings.focus.iter().enumerate().filter(|(_, f)| f.enabled) {
|
||||
let pos = Pos2::new(x(f.frequency), focus_y(f.sensitivity));
|
||||
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, VIOLET);
|
||||
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, FOCUS);
|
||||
if state.selected == Some(i) {
|
||||
p.circle_stroke(pos, 10., Stroke::new(1.0_f32, FOCUS.gamma_multiply(0.6)));
|
||||
}
|
||||
p.text(
|
||||
pos - Vec2::new(0., 12.),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
@@ -892,14 +737,14 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
|
||||
for f in [settings.low, settings.high] {
|
||||
p.line_segment(
|
||||
[Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())],
|
||||
Stroke::new(2.0_f32, MUTED),
|
||||
Stroke::new(1.0_f32, MUTED.gamma_multiply(0.7)),
|
||||
);
|
||||
let handle = Rect::from_center_size(Pos2::new(x(f), r.center().y), Vec2::new(12., 30.));
|
||||
p.rect_filled(handle, 6., Color32::from_rgb(65, 77, 96));
|
||||
p.rect_filled(handle, 2., Color32::from_rgb(46, 49, 55));
|
||||
p.rect_stroke(
|
||||
handle,
|
||||
6.,
|
||||
Stroke::new(1.0_f32, CYAN.gamma_multiply(0.65)),
|
||||
Stroke::new(1.0_f32, STEEL.gamma_multiply(0.5)),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rivet-app"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! Shared application state and bounded control messages. The audio callback never locks a session.
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use rivet_core::{
|
||||
Audition, Candidate, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip,
|
||||
PerformancePlayer, Result, Session, SECTION,
|
||||
Audition, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip,
|
||||
PerformancePlayer, Result, Session,
|
||||
};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
|
||||
Arc, Mutex, RwLock,
|
||||
},
|
||||
};
|
||||
@@ -34,17 +34,17 @@ pub struct Shared {
|
||||
pub queued: AtomicBool,
|
||||
pub playing: AtomicBool,
|
||||
pub playhead: AtomicU64,
|
||||
/// Bar length in ticks; queued auditions switch on the next bar line.
|
||||
pub bar: AtomicU32,
|
||||
pub commands: ArrayQueue<PlayRequest>,
|
||||
pub busy: AtomicBool,
|
||||
results: Mutex<Vec<Completion>>,
|
||||
}
|
||||
pub enum Output {
|
||||
Imported(rivet_midi::MidiFile),
|
||||
Generated(Vec<Candidate>),
|
||||
Exported(PathBuf),
|
||||
Saved(PathBuf),
|
||||
Loaded(Box<Session>),
|
||||
LibraryLoaded(Vec<rivet_core::Gesture>),
|
||||
Canceled,
|
||||
}
|
||||
pub struct Completion {
|
||||
@@ -65,6 +65,7 @@ impl Default for Shared {
|
||||
queued: AtomicBool::new(false),
|
||||
playing: AtomicBool::new(false),
|
||||
playhead: AtomicU64::new(0),
|
||||
bar: AtomicU32::new(rivet_core::BAR),
|
||||
commands: ArrayQueue::new(2),
|
||||
busy: AtomicBool::new(false),
|
||||
results: Mutex::new(Vec::new()),
|
||||
@@ -87,6 +88,7 @@ impl Shared {
|
||||
self.midi
|
||||
.store(s.audition == Audition::Midi, Ordering::Release);
|
||||
self.looping.store(s.looping, Ordering::Release);
|
||||
self.bar.store(s.settings.meter.bar(), Ordering::Release);
|
||||
self.revision.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
pub fn replace(&self, mut session: Session) -> Result<()> {
|
||||
@@ -107,10 +109,7 @@ impl Shared {
|
||||
"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
|
||||
if current.settings.meter != session.settings.meter
|
||||
|| current.performance != session.performance
|
||||
|| current.audition != session.audition
|
||||
{
|
||||
@@ -130,8 +129,9 @@ impl Shared {
|
||||
};
|
||||
let settings = state.performance.clone();
|
||||
let compensate = state.audition == Audition::Midi;
|
||||
let meter = state.settings.meter;
|
||||
drop(state);
|
||||
Performance::compile(phrase, &settings, tempo, compensate)?.clip()
|
||||
Performance::compile(phrase, &settings, tempo, compensate, meter)?.clip()
|
||||
}
|
||||
pub fn preview(&self, phrase: &rivet_core::Phrase) -> Result<()> {
|
||||
let epoch = self.epoch.load(Ordering::Acquire);
|
||||
@@ -279,9 +279,8 @@ impl AudioEngine {
|
||||
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);
|
||||
let bar = shared.bar.load(Ordering::Acquire).max(1) as f64;
|
||||
let boundary = ((now + lead) / bar).floor().mul_add(bar, bar);
|
||||
self.pending = Some((boundary - lead, request.clip));
|
||||
} else {
|
||||
self.player.play(request.clip);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use rivet_app::{AudioEngine, Shared};
|
||||
use rivet_core::{Note, Phrase, PPQ, SECTION};
|
||||
use rivet_core::{Note, Phrase, BAR, PPQ};
|
||||
|
||||
fn clip(pitch: u8, length: u32) -> Phrase {
|
||||
Phrase {
|
||||
@@ -90,20 +90,20 @@ fn adjacent_same_pitch_notes_have_separate_sample_accurate_attacks() {
|
||||
assert_eq!(events[2].2, None);
|
||||
}
|
||||
#[test]
|
||||
fn queued_audition_switches_at_section_boundary_and_stop_cancels_it() {
|
||||
fn queued_audition_switches_at_the_next_bar_line_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();
|
||||
shared.preview(&clip(60, 4 * BAR)).unwrap();
|
||||
a.begin_block(&shared);
|
||||
for _ in 0..100 {
|
||||
a.frame(120., true);
|
||||
}
|
||||
a.end_block(&shared);
|
||||
shared.audition(&clip(67, 2 * SECTION)).unwrap();
|
||||
shared.audition(&clip(67, 4 * BAR)).unwrap();
|
||||
a.begin_block(&shared);
|
||||
let mut switched = None;
|
||||
for index in 100i32..32003 {
|
||||
for index in 100i32..16003 {
|
||||
let f = a.frame(120., true);
|
||||
if f.on == Some((67, 90)) {
|
||||
assert_eq!(f.off, Some(60));
|
||||
@@ -111,9 +111,9 @@ fn queued_audition_switches_at_section_boundary_and_stop_cancels_it() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!((switched.unwrap() - 32000).abs() <= 1);
|
||||
assert!((switched.unwrap() - 16000).abs() <= 1);
|
||||
a.end_block(&shared);
|
||||
shared.audition(&clip(72, SECTION)).unwrap();
|
||||
shared.audition(&clip(72, 2 * BAR)).unwrap();
|
||||
a.begin_block(&shared);
|
||||
shared.stop();
|
||||
a.begin_block(&shared);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rivet-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{err, Note, Result, Scale, BAR, MAX_PLAYBACK_NOTES};
|
||||
use crate::{err, Meter, Note, Result, Scale, MAX_LENGTH, MAX_PLAYBACK_NOTES};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -64,8 +64,8 @@ impl Harmony {
|
||||
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."));
|
||||
if self.length == 0 || self.length > MAX_LENGTH || self.spans.len() > 256 {
|
||||
return Err(err("Harmony must fit 32 bars and at most 256 changes."));
|
||||
}
|
||||
let mut end = 0;
|
||||
for span in &self.spans {
|
||||
@@ -92,8 +92,12 @@ impl Harmony {
|
||||
.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."));
|
||||
if notes.is_empty()
|
||||
|| notes.len() > MAX_PLAYBACK_NOTES
|
||||
|| length == 0
|
||||
|| length > MAX_LENGTH
|
||||
{
|
||||
return Err(err("Import a harmony track of up to 32 bars."));
|
||||
}
|
||||
let mut edges = std::collections::BTreeSet::new();
|
||||
for n in notes {
|
||||
@@ -133,8 +137,9 @@ impl Harmony {
|
||||
context.validate()?;
|
||||
Ok(context)
|
||||
}
|
||||
pub fn progression(root: u8, scale: Scale) -> Self {
|
||||
let minor = scale != Scale::Major;
|
||||
pub fn progression(root: u8, scale: Scale, meter: Meter) -> Self {
|
||||
let minor = scale.minor();
|
||||
let bar = meter.bar();
|
||||
let chords = if minor {
|
||||
[
|
||||
(0, ChordQuality::Minor),
|
||||
@@ -151,15 +156,15 @@ impl Harmony {
|
||||
]
|
||||
};
|
||||
Self {
|
||||
length: 4 * BAR,
|
||||
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,
|
||||
start: i as u32 * bar,
|
||||
duration: bar,
|
||||
tones: quality.mask(root),
|
||||
bass: 48 + root,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Sessions from versions 1–3: one source motif developed into a four- or
|
||||
//! eight-bar phrase of fixed two-bar sections. Read leniently, then upgraded.
|
||||
use crate::{
|
||||
Audition, Ending, Harmony, Meter, PerformanceSettings, Phrase, Role, Scale, Template,
|
||||
STATE_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct Settings {
|
||||
bars: u8,
|
||||
root: u8,
|
||||
scale: Scale,
|
||||
variation: u8,
|
||||
preserve_rhythm: bool,
|
||||
preserve_contour: bool,
|
||||
ending: Ending,
|
||||
low: u8,
|
||||
high: u8,
|
||||
seed: u64,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
let s = crate::Settings::default();
|
||||
Self {
|
||||
bars: 8,
|
||||
root: s.root,
|
||||
scale: s.scale,
|
||||
variation: s.variation,
|
||||
preserve_rhythm: s.preserve_rhythm,
|
||||
preserve_contour: s.preserve_contour,
|
||||
ending: s.ending,
|
||||
low: s.low,
|
||||
high: s.high,
|
||||
seed: s.seed,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct Gesture {
|
||||
name: String,
|
||||
phrase: Phrase,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct Session {
|
||||
source: Option<Phrase>,
|
||||
settings: Settings,
|
||||
accepted: Option<Phrase>,
|
||||
tempo: f64,
|
||||
follow_host: bool,
|
||||
harmony: Harmony,
|
||||
audition: Audition,
|
||||
/// Version-1 sessions had one-shot audition.
|
||||
looping: bool,
|
||||
performance: PerformanceSettings,
|
||||
gestures: Vec<Gesture>,
|
||||
}
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: None,
|
||||
settings: Settings::default(),
|
||||
accepted: None,
|
||||
tempo: 140.,
|
||||
follow_host: false,
|
||||
harmony: Harmony::default(),
|
||||
audition: Audition::Internal,
|
||||
looping: false,
|
||||
performance: PerformanceSettings::default(),
|
||||
gestures: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Session {
|
||||
/// The source becomes the first motif; a kept phrase and saved gestures follow it
|
||||
/// in the bin. Four-bar phrases become question/answer, eight-bar phrases an arc.
|
||||
pub(crate) fn upgrade(self) -> crate::Session {
|
||||
let old = self.settings;
|
||||
let mut s = crate::Session {
|
||||
version: STATE_VERSION,
|
||||
settings: crate::Settings {
|
||||
root: old.root,
|
||||
scale: old.scale,
|
||||
meter: Meter::default(),
|
||||
variation: old.variation,
|
||||
preserve_rhythm: old.preserve_rhythm,
|
||||
preserve_contour: old.preserve_contour,
|
||||
ending: old.ending,
|
||||
low: old.low,
|
||||
high: old.high,
|
||||
seed: old.seed,
|
||||
},
|
||||
tempo: self.tempo,
|
||||
follow_host: self.follow_host,
|
||||
harmony: self.harmony,
|
||||
audition: self.audition,
|
||||
looping: self.looping,
|
||||
performance: self.performance,
|
||||
..crate::Session::default()
|
||||
};
|
||||
let source = self.source.and_then(|p| s.add_motif("Motif", p).ok());
|
||||
if let Some(kept) = self.accepted {
|
||||
let _ = s.add_motif("Kept phrase", kept);
|
||||
}
|
||||
for gesture in self.gestures {
|
||||
let _ = s.add_motif(&gesture.name, gesture.phrase);
|
||||
}
|
||||
if let Some(id) = source {
|
||||
if old.bars == 4 {
|
||||
let _ = s.apply_template(Template::QuestionAnswer, id);
|
||||
s.blocks.truncate(2);
|
||||
if let Some(last) = s.blocks.last_mut() {
|
||||
last.role = Role::Return;
|
||||
}
|
||||
s.realize_all();
|
||||
} else {
|
||||
let _ = s.apply_template(Template::Arc, id);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
//! Musical time and deterministic motif development, independent of the host and UI.
|
||||
mod creative;
|
||||
//! Musical time, motifs, and storyboard composition, independent of the host and UI.
|
||||
mod harmony;
|
||||
mod legacy;
|
||||
mod performance;
|
||||
mod playback;
|
||||
mod story;
|
||||
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};
|
||||
pub use story::{
|
||||
Block, Join, Motif, Role, Session, Template, MAX_BARS, MAX_BLOCKS, MAX_MOTIFS, MAX_MOTIF_BARS,
|
||||
};
|
||||
pub use variation::realize;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
pub const PPQ: u32 = 960;
|
||||
/// One 4/4 bar. Use [`Meter::bar`] for the session's actual bar length.
|
||||
pub const BAR: u32 = PPQ * 4;
|
||||
pub const MAX_SOURCE_NOTES: usize = 256;
|
||||
pub const STATE_VERSION: u32 = 3;
|
||||
pub const STATE_VERSION: u32 = 4;
|
||||
pub const MAX_STATE_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const SECTION: u32 = 2 * BAR;
|
||||
/// Longest phrase in ticks: 32 bars of 12/8.
|
||||
pub const MAX_LENGTH: u32 = 48 * BAR;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Error(pub String);
|
||||
@@ -67,16 +71,15 @@ pub struct Phrase {
|
||||
pub length: u32,
|
||||
}
|
||||
impl Phrase {
|
||||
pub fn validate(&self, max_notes: usize) -> Result<()> {
|
||||
if self.notes.is_empty() || self.notes.len() > max_notes {
|
||||
/// Checks timing and MIDI ranges. An empty note list is allowed only when `allow_empty`.
|
||||
pub fn check(&self, max_notes: usize, allow_empty: bool) -> Result<()> {
|
||||
if (!allow_empty && 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.",
|
||||
));
|
||||
if self.length == 0 || self.length > MAX_LENGTH {
|
||||
return Err(err("Phrase length must be positive and at most 32 bars."));
|
||||
}
|
||||
let mut end = 0;
|
||||
for n in &self.notes {
|
||||
@@ -97,12 +100,60 @@ impl Phrase {
|
||||
}
|
||||
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."));
|
||||
pub fn validate(&self, max_notes: usize) -> Result<()> {
|
||||
self.check(max_notes, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Time signature. Compound meters (6/8, 12/8) pulse in dotted quarters.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Meter {
|
||||
pub beats: u8,
|
||||
pub unit: u8,
|
||||
}
|
||||
impl Default for Meter {
|
||||
fn default() -> Self {
|
||||
Self { beats: 4, unit: 4 }
|
||||
}
|
||||
}
|
||||
impl Meter {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self { beats: 2, unit: 4 },
|
||||
Self { beats: 3, unit: 4 },
|
||||
Self { beats: 4, unit: 4 },
|
||||
Self { beats: 5, unit: 4 },
|
||||
Self { beats: 6, unit: 8 },
|
||||
Self { beats: 7, unit: 8 },
|
||||
Self { beats: 12, unit: 8 },
|
||||
];
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if Self::ALL.contains(&self) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(err("Supported meters: 2/4, 3/4, 4/4, 5/4, 6/8, 7/8, 12/8."))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn name(self) -> String {
|
||||
format!("{}/{}", self.beats, self.unit)
|
||||
}
|
||||
/// Bar length in ticks.
|
||||
pub fn bar(self) -> u32 {
|
||||
PPQ * 4 * self.beats as u32 / self.unit as u32
|
||||
}
|
||||
/// The felt pulse: a quarter in simple meters, a dotted quarter in compound ones.
|
||||
pub fn beat(self) -> u32 {
|
||||
if self.unit == 8 && self.beats.is_multiple_of(3) {
|
||||
PPQ * 3 / 2
|
||||
} else {
|
||||
PPQ * 4 / self.unit as u32
|
||||
}
|
||||
}
|
||||
pub fn strong(self, tick: u32) -> bool {
|
||||
tick.is_multiple_of(self.beat())
|
||||
}
|
||||
/// Whole bars needed to hold `ticks`, at least one.
|
||||
pub fn bars_for(self, ticks: u32) -> u32 {
|
||||
ticks.div_ceil(self.bar()).max(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,15 +163,29 @@ pub enum Scale {
|
||||
Major,
|
||||
Dorian,
|
||||
Pentatonic,
|
||||
Phrygian,
|
||||
HarmonicMinor,
|
||||
Mixolydian,
|
||||
}
|
||||
impl Scale {
|
||||
pub const ALL: [Self; 4] = [Self::Minor, Self::Major, Self::Dorian, Self::Pentatonic];
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Minor,
|
||||
Self::Major,
|
||||
Self::Dorian,
|
||||
Self::Phrygian,
|
||||
Self::HarmonicMinor,
|
||||
Self::Mixolydian,
|
||||
Self::Pentatonic,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Minor => "Natural minor",
|
||||
Self::Major => "Major",
|
||||
Self::Dorian => "Dorian",
|
||||
Self::Pentatonic => "Minor pentatonic",
|
||||
Self::Phrygian => "Phrygian",
|
||||
Self::HarmonicMinor => "Harmonic minor",
|
||||
Self::Mixolydian => "Mixolydian",
|
||||
}
|
||||
}
|
||||
pub fn intervals(self) -> &'static [u8] {
|
||||
@@ -129,20 +194,27 @@ impl Scale {
|
||||
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],
|
||||
Self::Phrygian => &[0, 1, 3, 5, 7, 8, 10],
|
||||
Self::HarmonicMinor => &[0, 2, 3, 5, 7, 8, 11],
|
||||
Self::Mixolydian => &[0, 2, 4, 5, 7, 9, 10],
|
||||
}
|
||||
}
|
||||
pub fn minor(self) -> bool {
|
||||
!matches!(self, Self::Major | Self::Mixolydian)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub bars: u8,
|
||||
pub root: u8,
|
||||
pub scale: Scale,
|
||||
pub meter: Meter,
|
||||
/// How far proposals may move away from the motif, 0–100.
|
||||
pub variation: u8,
|
||||
pub preserve_rhythm: bool,
|
||||
pub preserve_contour: bool,
|
||||
pub task: Task,
|
||||
/// How the final block lands.
|
||||
pub ending: Ending,
|
||||
pub low: u8,
|
||||
pub high: u8,
|
||||
@@ -151,13 +223,12 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bars: 8,
|
||||
root: 0,
|
||||
scale: Scale::Minor,
|
||||
meter: Meter::default(),
|
||||
variation: 55,
|
||||
preserve_rhythm: false,
|
||||
preserve_contour: true,
|
||||
task: Task::Develop,
|
||||
ending: Ending::Resolve,
|
||||
low: 48,
|
||||
high: 84,
|
||||
@@ -165,26 +236,6 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
@@ -199,13 +250,11 @@ pub enum Audition {
|
||||
}
|
||||
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."));
|
||||
self.meter.validate()?;
|
||||
if self.root > 11 || self.variation > 100 || self.low > self.high || self.high > 127 {
|
||||
return Err(err(
|
||||
"Use a valid key, variation 0–100, and an ordered MIDI register.",
|
||||
));
|
||||
}
|
||||
if !(self.low..=self.high).any(|p| self.contains(p)) {
|
||||
return Err(err(
|
||||
@@ -219,7 +268,8 @@ impl Settings {
|
||||
.intervals()
|
||||
.contains(&((p + 12 - self.root) % 12))
|
||||
}
|
||||
fn degree(&self, p: u8, steps: i16) -> u8 {
|
||||
/// Scale step `steps` away from the scale note nearest `p`, inside the register.
|
||||
pub fn degree(&self, p: u8, steps: i16) -> u8 {
|
||||
let choices: Vec<_> = (self.low..=self.high)
|
||||
.filter(|&n| self.contains(n))
|
||||
.collect();
|
||||
@@ -244,6 +294,8 @@ pub enum Strategy {
|
||||
CallResponse,
|
||||
HarmonicReturn,
|
||||
SavedIdea,
|
||||
Quote,
|
||||
Silence,
|
||||
}
|
||||
impl Strategy {
|
||||
pub const ALL: [Self; 8] = [
|
||||
@@ -259,17 +311,20 @@ impl Strategy {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::ChangedEnding => "Changed ending",
|
||||
Self::LaterPeak => "Later peak",
|
||||
Self::DelayedResolution => "Delayed resolution",
|
||||
Self::LaterPeak => "Higher peak",
|
||||
Self::DelayedResolution => "Held back",
|
||||
Self::NewRhythm => "Displaced groove",
|
||||
Self::FragmentAnswer => "Fragment and answer",
|
||||
Self::FragmentAnswer => "Fragment",
|
||||
Self::NegativeSpace => "Room to breathe",
|
||||
Self::CallResponse => "Short question, long answer",
|
||||
Self::CallResponse => "Short call, long answer",
|
||||
Self::HarmonicReturn => "Familiar return",
|
||||
Self::SavedIdea => "Working gesture",
|
||||
Self::Quote => "Quote",
|
||||
Self::Silence => "Silence",
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One realized option for a block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Candidate {
|
||||
pub strategy: Strategy,
|
||||
@@ -277,238 +332,6 @@ pub struct Candidate {
|
||||
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,
|
||||
@@ -533,125 +356,3 @@ pub fn demo_motif() -> Phrase {
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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 crate::{err, Meter, Note, Phrase, Result, MAX_LENGTH, MAX_PLAYBACK_NOTES, PPQ};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
@@ -28,12 +28,14 @@ impl RoleMode {
|
||||
Self::Interlock => "Strong beats and replies",
|
||||
}
|
||||
}
|
||||
pub fn role(self, index: usize, n: &Note) -> usize {
|
||||
/// Which of the two voices plays note `index`. Question/answer alternates bars;
|
||||
/// strong beats follow the meter's pulse.
|
||||
pub fn role(self, index: usize, n: &Note, meter: Meter) -> 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)),
|
||||
Self::CallResponse => ((n.start / meter.bar()) % 2) as usize,
|
||||
Self::Interlock => usize::from(!meter.strong(n.start)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +226,7 @@ impl Performance {
|
||||
settings: &PerformanceSettings,
|
||||
bpm: f64,
|
||||
compensate: bool,
|
||||
meter: Meter,
|
||||
) -> Result<Self> {
|
||||
phrase.validate(MAX_PLAYBACK_NOTES)?;
|
||||
settings.validate()?;
|
||||
@@ -252,7 +255,7 @@ impl Performance {
|
||||
.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, n)| settings.mode.role(*i, n) == role)
|
||||
.filter(|(i, n)| settings.mode.role(*i, n, meter) == role)
|
||||
.map(|(_, n)| n)
|
||||
.collect();
|
||||
if routed.is_empty() && !profile.controller.enabled {
|
||||
@@ -378,7 +381,7 @@ impl Performance {
|
||||
pub fn clip(&self) -> Result<PerformanceClip> {
|
||||
if self.events.len() > MAX_PERFORMANCE_EVENTS
|
||||
|| self.length == 0
|
||||
|| self.length > 8 * BAR
|
||||
|| self.length > MAX_LENGTH
|
||||
|| self.lead_in >= self.length
|
||||
{
|
||||
return Err(err("Invalid performance clip."));
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
//! The storyboard: a bin of motifs, and a timeline of blocks that develop them.
|
||||
//! Rivet joins neighbouring blocks at their seams.
|
||||
use crate::{
|
||||
err, legacy, realize, Audition, Candidate, Error, Harmony, Note, PerformanceSettings, Phrase,
|
||||
Result, Settings, MAX_PLAYBACK_NOTES, MAX_SOURCE_NOTES, MAX_STATE_BYTES, STATE_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub const MAX_MOTIFS: usize = 16;
|
||||
pub const MAX_BLOCKS: usize = 32;
|
||||
/// Longest storyboard, in bars.
|
||||
pub const MAX_BARS: u32 = 32;
|
||||
/// Longest motif and longest block, in bars.
|
||||
pub const MAX_MOTIF_BARS: u32 = 8;
|
||||
|
||||
/// A short idea the producer brought in. Locks live on its notes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Motif {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub phrase: Phrase,
|
||||
}
|
||||
|
||||
/// What a block does in the story.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// Present the idea so it can be recognized later.
|
||||
#[default]
|
||||
Statement,
|
||||
/// Reply to what came before.
|
||||
Answer,
|
||||
/// Move away: new register, new rhythm.
|
||||
Departure,
|
||||
/// Bring the idea back.
|
||||
Return,
|
||||
/// Silence.
|
||||
Rest,
|
||||
}
|
||||
impl Role {
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Statement,
|
||||
Self::Answer,
|
||||
Self::Departure,
|
||||
Self::Return,
|
||||
Self::Rest,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Statement => "Statement",
|
||||
Self::Answer => "Answer",
|
||||
Self::Departure => "Departure",
|
||||
Self::Return => "Return",
|
||||
Self::Rest => "Rest",
|
||||
}
|
||||
}
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Statement => "Present the motif so it can be recognized later.",
|
||||
Self::Answer => "Reply to what came before: shorter call, longer arrival.",
|
||||
Self::Departure => "Move away: higher register, displaced rhythm, fragments.",
|
||||
Self::Return => "Bring the motif back and land it.",
|
||||
Self::Rest => "Silence. Space makes the next entry count.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a block hands over to the next one.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Join {
|
||||
/// No change at the seam.
|
||||
#[default]
|
||||
Cut,
|
||||
/// Leave half a beat of air before the next block.
|
||||
Breath,
|
||||
/// Two short notes in the last beat climb or fall into the next block's first note.
|
||||
Pickup,
|
||||
/// The last note moves one scale step next to the next block's first note.
|
||||
Approach,
|
||||
}
|
||||
impl Join {
|
||||
pub const ALL: [Self; 4] = [Self::Cut, Self::Breath, Self::Pickup, Self::Approach];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cut => "Cut",
|
||||
Self::Breath => "Breath",
|
||||
Self::Pickup => "Pickup",
|
||||
Self::Approach => "Approach",
|
||||
}
|
||||
}
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cut => "Blocks meet as written.",
|
||||
Self::Breath => "The last note is shortened to leave air before the next block.",
|
||||
Self::Pickup => "Two short notes lead into the next block's first note.",
|
||||
Self::Approach => "The last note steps next to the next block's first note.",
|
||||
}
|
||||
}
|
||||
/// A musical default for a transition between two roles.
|
||||
pub fn between(from: Role, to: Role) -> Self {
|
||||
match (from, to) {
|
||||
(Role::Rest, _) | (_, Role::Rest) => Self::Cut,
|
||||
(_, Role::Departure) => Self::Pickup,
|
||||
(_, Role::Return) => Self::Approach,
|
||||
_ => Self::Breath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One section of the storyboard.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Block {
|
||||
pub id: u64,
|
||||
/// Motif this block develops. Ignored for rests.
|
||||
pub motif: u64,
|
||||
pub role: Role,
|
||||
pub bars: u8,
|
||||
/// Hand-over into the next block.
|
||||
pub join: Join,
|
||||
/// Increment for another set of options.
|
||||
#[serde(default)]
|
||||
pub take: u64,
|
||||
/// Up to three realized options; the first fits the role best.
|
||||
#[serde(default)]
|
||||
pub options: Vec<Candidate>,
|
||||
#[serde(default)]
|
||||
pub choice: usize,
|
||||
/// Kept blocks are not regenerated when other inputs change.
|
||||
#[serde(default)]
|
||||
pub kept: bool,
|
||||
}
|
||||
impl Block {
|
||||
pub fn phrase(&self) -> Option<&Phrase> {
|
||||
self.options.get(self.choice).map(|c| &c.phrase)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starting shapes for a storyboard built from one motif.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Template {
|
||||
Arc,
|
||||
QuestionAnswer,
|
||||
Breathe,
|
||||
Build,
|
||||
}
|
||||
impl Template {
|
||||
pub const ALL: [Self; 4] = [Self::Arc, Self::QuestionAnswer, Self::Breathe, Self::Build];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Arc => "Arc: statement, answer, departure, return",
|
||||
Self::QuestionAnswer => "Question and answer, twice",
|
||||
Self::Breathe => "Breathe: ideas separated by rests",
|
||||
Self::Build => "Build: two departures before the return",
|
||||
}
|
||||
}
|
||||
fn roles(self) -> &'static [Role] {
|
||||
use Role::*;
|
||||
match self {
|
||||
Self::Arc => &[Statement, Answer, Departure, Return],
|
||||
Self::QuestionAnswer => &[Statement, Answer, Statement, Answer],
|
||||
Self::Breathe => &[Statement, Rest, Answer, Rest, Return],
|
||||
Self::Build => &[Statement, Answer, Departure, Departure, Answer, Return],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct Session {
|
||||
pub version: u32,
|
||||
pub settings: Settings,
|
||||
pub motifs: Vec<Motif>,
|
||||
pub blocks: Vec<Block>,
|
||||
pub tempo: f64,
|
||||
pub follow_host: bool,
|
||||
pub harmony: Harmony,
|
||||
pub audition: Audition,
|
||||
pub looping: bool,
|
||||
pub performance: PerformanceSettings,
|
||||
pub next_id: u64,
|
||||
}
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: STATE_VERSION,
|
||||
settings: Settings::default(),
|
||||
motifs: Vec::new(),
|
||||
blocks: Vec::new(),
|
||||
tempo: 140.0,
|
||||
follow_host: false,
|
||||
harmony: Harmony::default(),
|
||||
audition: Audition::Internal,
|
||||
looping: true,
|
||||
performance: PerformanceSettings::default(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current-format fields, read leniently so added fields default.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Current {
|
||||
version: u32,
|
||||
settings: Settings,
|
||||
motifs: Vec<Motif>,
|
||||
blocks: Vec<Block>,
|
||||
tempo: f64,
|
||||
follow_host: bool,
|
||||
harmony: Harmony,
|
||||
audition: Audition,
|
||||
looping: bool,
|
||||
performance: PerformanceSettings,
|
||||
next_id: u64,
|
||||
}
|
||||
impl Default for Current {
|
||||
fn default() -> Self {
|
||||
let s = Session::default();
|
||||
Self {
|
||||
version: s.version,
|
||||
settings: s.settings,
|
||||
motifs: s.motifs,
|
||||
blocks: s.blocks,
|
||||
tempo: s.tempo,
|
||||
follow_host: s.follow_host,
|
||||
harmony: s.harmony,
|
||||
audition: s.audition,
|
||||
looping: s.looping,
|
||||
performance: s.performance,
|
||||
next_id: s.next_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Sessions from version 1–3 (single motif, fixed two-bar sections) become a
|
||||
/// storyboard: the motif goes into the bin and the old phrase length becomes blocks.
|
||||
impl<'de> Deserialize<'de> for Session {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
use serde::de::Error as _;
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
let version = value
|
||||
.get("version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(1);
|
||||
if version < STATE_VERSION as u64 {
|
||||
let old: legacy::Session = serde_json::from_value(value).map_err(D::Error::custom)?;
|
||||
return Ok(old.upgrade());
|
||||
}
|
||||
let c: Current = serde_json::from_value(value).map_err(D::Error::custom)?;
|
||||
Ok(Self {
|
||||
version: c.version,
|
||||
settings: c.settings,
|
||||
motifs: c.motifs,
|
||||
blocks: c.blocks,
|
||||
tempo: c.tempo,
|
||||
follow_host: c.follow_host,
|
||||
harmony: c.harmony,
|
||||
audition: c.audition,
|
||||
looping: c.looping,
|
||||
performance: c.performance,
|
||||
next_id: c.next_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn migrate(&mut self) -> Result<()> {
|
||||
if self.version != STATE_VERSION {
|
||||
return Err(err("Unsupported session version."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn id(&mut self) -> u64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
id
|
||||
}
|
||||
pub fn motif(&self, id: u64) -> Option<&Motif> {
|
||||
self.motifs.iter().find(|m| m.id == id)
|
||||
}
|
||||
pub fn motif_mut(&mut self, id: u64) -> Option<&mut Motif> {
|
||||
self.motifs.iter_mut().find(|m| m.id == id)
|
||||
}
|
||||
pub fn total_bars(&self) -> u32 {
|
||||
self.blocks.iter().map(|b| b.bars as u32).sum()
|
||||
}
|
||||
/// Start of block `index` in ticks.
|
||||
pub fn block_start(&self, index: usize) -> u32 {
|
||||
self.blocks[..index.min(self.blocks.len())]
|
||||
.iter()
|
||||
.map(|b| b.bars as u32 * self.settings.meter.bar())
|
||||
.sum()
|
||||
}
|
||||
fn last_sounding(&self) -> Option<usize> {
|
||||
self.blocks.iter().rposition(|b| b.role != Role::Rest)
|
||||
}
|
||||
fn check_motif(&self, phrase: &Phrase) -> Result<()> {
|
||||
phrase.validate(MAX_SOURCE_NOTES)?;
|
||||
if phrase.length > MAX_MOTIF_BARS * self.settings.meter.bar() {
|
||||
return Err(Error(format!(
|
||||
"A motif can be at most {MAX_MOTIF_BARS} bars of {}.",
|
||||
self.settings.meter.name()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Adds a motif to the bin. Its length is rounded up to whole bars.
|
||||
pub fn add_motif(&mut self, name: &str, mut phrase: Phrase) -> Result<u64> {
|
||||
if self.motifs.len() >= MAX_MOTIFS {
|
||||
return Err(Error(format!(
|
||||
"The bin holds {MAX_MOTIFS} motifs. Remove one first."
|
||||
)));
|
||||
}
|
||||
let bar = self.settings.meter.bar();
|
||||
phrase.length = self.settings.meter.bars_for(phrase.length) * bar;
|
||||
self.check_motif(&phrase)?;
|
||||
let id = self.id();
|
||||
let name = name.trim();
|
||||
self.motifs.push(Motif {
|
||||
id,
|
||||
name: if name.is_empty() {
|
||||
format!("Motif {}", self.motifs.len() + 1)
|
||||
} else {
|
||||
name.chars().take(60).collect()
|
||||
},
|
||||
phrase,
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
/// Removes a motif and every block that develops it.
|
||||
pub fn remove_motif(&mut self, id: u64) {
|
||||
self.motifs.retain(|m| m.id != id);
|
||||
self.blocks
|
||||
.retain(|b| b.role == Role::Rest || b.motif != id);
|
||||
self.realize_all();
|
||||
}
|
||||
/// Motif length in whole bars of the current meter.
|
||||
pub fn motif_bars(&self, id: u64) -> u8 {
|
||||
self.motif(id).map_or(2, |m| {
|
||||
self.settings
|
||||
.meter
|
||||
.bars_for(m.phrase.length)
|
||||
.min(MAX_MOTIF_BARS) as u8
|
||||
})
|
||||
}
|
||||
/// Inserts a block at `index` and realizes the storyboard.
|
||||
pub fn insert_block(&mut self, index: usize, motif: u64, role: Role, bars: u8) -> Result<u64> {
|
||||
if self.blocks.len() >= MAX_BLOCKS {
|
||||
return Err(Error(format!("A storyboard holds {MAX_BLOCKS} blocks.")));
|
||||
}
|
||||
if !(1..=MAX_MOTIF_BARS as u8).contains(&bars) {
|
||||
return Err(err("A block is 1–8 bars long."));
|
||||
}
|
||||
if self.total_bars() + bars as u32 > MAX_BARS {
|
||||
return Err(Error(format!(
|
||||
"The storyboard is limited to {MAX_BARS} bars."
|
||||
)));
|
||||
}
|
||||
if role != Role::Rest && self.motif(motif).is_none() {
|
||||
return Err(err("Choose a motif from the bin first."));
|
||||
}
|
||||
let index = index.min(self.blocks.len());
|
||||
let id = self.id();
|
||||
let join = self
|
||||
.blocks
|
||||
.get(index)
|
||||
.map_or(Join::Cut, |next| Join::between(role, next.role));
|
||||
if let Some(previous) = index.checked_sub(1).and_then(|i| self.blocks.get_mut(i)) {
|
||||
previous.join = Join::between(previous.role, role);
|
||||
}
|
||||
self.blocks.insert(
|
||||
index,
|
||||
Block {
|
||||
id,
|
||||
motif,
|
||||
role,
|
||||
bars,
|
||||
join,
|
||||
take: 0,
|
||||
options: Vec::new(),
|
||||
choice: 0,
|
||||
kept: false,
|
||||
},
|
||||
);
|
||||
self.realize_all();
|
||||
Ok(id)
|
||||
}
|
||||
pub fn remove_block(&mut self, index: usize) {
|
||||
if index < self.blocks.len() {
|
||||
self.blocks.remove(index);
|
||||
self.realize_all();
|
||||
}
|
||||
}
|
||||
pub fn move_block(&mut self, from: usize, to: usize) {
|
||||
if from < self.blocks.len() && to < self.blocks.len() && from != to {
|
||||
let block = self.blocks.remove(from);
|
||||
self.blocks.insert(to, block);
|
||||
self.realize_all();
|
||||
}
|
||||
}
|
||||
pub fn duplicate_block(&mut self, index: usize) -> Result<()> {
|
||||
let Some(block) = self.blocks.get(index).cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.blocks.len() >= MAX_BLOCKS || self.total_bars() + block.bars as u32 > MAX_BARS {
|
||||
return Err(Error(format!(
|
||||
"The storyboard is limited to {MAX_BLOCKS} blocks and {MAX_BARS} bars."
|
||||
)));
|
||||
}
|
||||
let id = self.id();
|
||||
self.blocks.insert(index + 1, Block { id, ..block });
|
||||
self.realize_all();
|
||||
Ok(())
|
||||
}
|
||||
/// Replaces the timeline with a template built from one motif.
|
||||
pub fn apply_template(&mut self, template: Template, motif: u64) -> Result<()> {
|
||||
if self.motif(motif).is_none() {
|
||||
return Err(err("Choose a motif from the bin first."));
|
||||
}
|
||||
let size = self.motif_bars(motif);
|
||||
let roles = template.roles();
|
||||
let mut blocks = Vec::new();
|
||||
let mut total = 0;
|
||||
for (i, &role) in roles.iter().enumerate() {
|
||||
let bars = if role == Role::Rest { 1 } else { size };
|
||||
if total + bars as u32 > MAX_BARS {
|
||||
break;
|
||||
}
|
||||
total += bars as u32;
|
||||
let join = roles
|
||||
.get(i + 1)
|
||||
.map_or(Join::Cut, |&next| Join::between(role, next));
|
||||
let id = self.id();
|
||||
blocks.push(Block {
|
||||
id,
|
||||
motif,
|
||||
role,
|
||||
bars,
|
||||
join,
|
||||
take: 0,
|
||||
options: Vec::new(),
|
||||
choice: 0,
|
||||
kept: false,
|
||||
});
|
||||
}
|
||||
self.blocks = blocks;
|
||||
self.realize_all();
|
||||
Ok(())
|
||||
}
|
||||
/// New options for one block; it is no longer kept.
|
||||
pub fn another_take(&mut self, index: usize) {
|
||||
if let Some(block) = self.blocks.get_mut(index) {
|
||||
block.take = block.take.wrapping_add(1);
|
||||
block.kept = false;
|
||||
block.choice = 0;
|
||||
self.realize(index);
|
||||
}
|
||||
}
|
||||
fn realize(&mut self, index: usize) {
|
||||
let offset = self.block_start(index);
|
||||
let last = self.last_sounding() == Some(index);
|
||||
let block = &self.blocks[index];
|
||||
let motif = self
|
||||
.motif(block.motif)
|
||||
.map(|m| m.phrase.clone())
|
||||
.unwrap_or_default();
|
||||
let options = if block.role == Role::Rest || !motif.notes.is_empty() {
|
||||
realize(
|
||||
&motif,
|
||||
block.role,
|
||||
block.bars,
|
||||
offset,
|
||||
last,
|
||||
block.take,
|
||||
&self.settings,
|
||||
&self.harmony,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let block = &mut self.blocks[index];
|
||||
block.choice = block.choice.min(options.len().saturating_sub(1));
|
||||
block.options = options;
|
||||
}
|
||||
/// Realizes every block that is not kept. Kept blocks are realized only when
|
||||
/// their stored options no longer fit (for example after a meter change).
|
||||
pub fn realize_all(&mut self) {
|
||||
let bar = self.settings.meter.bar();
|
||||
for index in 0..self.blocks.len() {
|
||||
let block = &self.blocks[index];
|
||||
let fits = block
|
||||
.phrase()
|
||||
.is_some_and(|p| p.length == block.bars as u32 * bar);
|
||||
if !block.kept || !fits {
|
||||
self.realize(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The whole storyboard as one phrase, with joins applied at every seam.
|
||||
pub fn compose(&self) -> Result<Phrase> {
|
||||
if self.blocks.is_empty() {
|
||||
return Err(err("Add blocks to the storyboard first."));
|
||||
}
|
||||
let mut notes = Vec::new();
|
||||
let mut offset = 0;
|
||||
for (i, block) in self.blocks.iter().enumerate() {
|
||||
let length = block.bars as u32 * self.settings.meter.bar();
|
||||
let mut local = block.phrase().map(|p| p.notes.clone()).unwrap_or_default();
|
||||
let next = self
|
||||
.blocks
|
||||
.get(i + 1)
|
||||
.and_then(Block::phrase)
|
||||
.and_then(|p| p.notes.first().copied());
|
||||
if i + 1 < self.blocks.len() {
|
||||
self.join(&mut local, length, block.join, next);
|
||||
}
|
||||
notes.extend(local.into_iter().map(|n| Note {
|
||||
start: n.start + offset,
|
||||
..n
|
||||
}));
|
||||
offset += length;
|
||||
}
|
||||
if notes.is_empty() {
|
||||
return Err(err("The storyboard is silent. Add a block with notes."));
|
||||
}
|
||||
let phrase = Phrase {
|
||||
notes,
|
||||
length: offset,
|
||||
};
|
||||
phrase.validate(MAX_PLAYBACK_NOTES)?;
|
||||
Ok(phrase)
|
||||
}
|
||||
/// Applies a hand-over to the end of one block. Never touches locked attributes
|
||||
/// and never moves notes across the block boundary.
|
||||
fn join(&self, notes: &mut Vec<Note>, length: u32, join: Join, next: Option<Note>) {
|
||||
let beat = self.settings.meter.beat();
|
||||
let Some(last) = notes.last().copied() else {
|
||||
return;
|
||||
};
|
||||
match join {
|
||||
Join::Cut => {}
|
||||
Join::Breath => {
|
||||
let limit = length - beat / 2;
|
||||
let n = notes.last_mut().unwrap();
|
||||
if !n.keeps_rhythm() && n.start < limit && n.end() > limit {
|
||||
n.duration = limit - n.start;
|
||||
}
|
||||
}
|
||||
Join::Approach => {
|
||||
let Some(target) = next else { return };
|
||||
let n = notes.last_mut().unwrap();
|
||||
if n.keeps_pitch() {
|
||||
return;
|
||||
}
|
||||
let below = self.settings.degree(target.pitch, -1);
|
||||
let above = self.settings.degree(target.pitch, 1);
|
||||
n.pitch = if n.pitch > target.pitch && above != target.pitch {
|
||||
above
|
||||
} else if below != target.pitch {
|
||||
below
|
||||
} else {
|
||||
above
|
||||
};
|
||||
}
|
||||
Join::Pickup => {
|
||||
let Some(target) = next else { return };
|
||||
let region = length - beat;
|
||||
if notes.iter().any(|n| n.end() > region && n.is_anchor()) {
|
||||
return;
|
||||
}
|
||||
notes.retain(|n| n.start < region);
|
||||
if let Some(n) = notes.last_mut() {
|
||||
if n.end() > region {
|
||||
n.duration = region - n.start;
|
||||
}
|
||||
}
|
||||
let reference = notes.last().unwrap_or(&last).pitch;
|
||||
let direction: i16 = if reference > target.pitch { 1 } else { -1 };
|
||||
let half = beat / 2;
|
||||
for (k, steps) in [(0, 2 * direction), (1, direction)] {
|
||||
notes.push(Note {
|
||||
start: region + k * half,
|
||||
duration: half,
|
||||
pitch: self.settings.degree(target.pitch, steps),
|
||||
velocity: (target.velocity as i16 - 18 + 8 * k as i16).clamp(1, 127) as u8,
|
||||
..Note::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.version != STATE_VERSION {
|
||||
return Err(err("Unsupported session version."));
|
||||
}
|
||||
self.settings.validate()?;
|
||||
self.performance.validate()?;
|
||||
self.harmony.validate()?;
|
||||
if !self.tempo.is_finite() || !(30.0..=300.0).contains(&self.tempo) {
|
||||
return Err(err("Tempo must be between 30 and 300 BPM."));
|
||||
}
|
||||
if self.motifs.len() > MAX_MOTIFS || self.blocks.len() > MAX_BLOCKS {
|
||||
return Err(err("Too many motifs or blocks."));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for m in &self.motifs {
|
||||
if m.id >= self.next_id || !ids.insert(m.id) || m.name.chars().count() > 60 {
|
||||
return Err(err("Invalid motif."));
|
||||
}
|
||||
self.check_motif(&m.phrase)?;
|
||||
}
|
||||
let bar = self.settings.meter.bar();
|
||||
for b in &self.blocks {
|
||||
if b.id >= self.next_id
|
||||
|| !ids.insert(b.id)
|
||||
|| !(1..=MAX_MOTIF_BARS as u8).contains(&b.bars)
|
||||
|| (b.role != Role::Rest && self.motif(b.motif).is_none())
|
||||
|| b.options.len() > 3
|
||||
|| (!b.options.is_empty() && b.choice >= b.options.len())
|
||||
{
|
||||
return Err(err("Invalid storyboard block."));
|
||||
}
|
||||
for option in &b.options {
|
||||
option.phrase.check(MAX_PLAYBACK_NOTES, true)?;
|
||||
if option.phrase.length != b.bars as u32 * bar {
|
||||
return Err(err("A block option does not match its length."));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.total_bars() > MAX_BARS {
|
||||
return Err(Error(format!(
|
||||
"The storyboard is limited to {MAX_BARS} bars."
|
||||
)));
|
||||
}
|
||||
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)
|
||||
}
|
||||
/// The demo motif and an arc storyboard built from it.
|
||||
pub fn demo() -> Self {
|
||||
let mut s = Self::default();
|
||||
let id = s
|
||||
.add_motif("Demo motif", crate::demo_motif())
|
||||
.expect("valid demo");
|
||||
s.apply_template(Template::Arc, id).expect("valid demo");
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Phrase-level proposals. Randomness chooses compositional alternatives, not independent pitches.
|
||||
//! Block-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,
|
||||
Candidate, Ending, Harmony, Note, Phrase, Result, Role, Settings, Strategy, MAX_PLAYBACK_NOTES,
|
||||
MAX_SOURCE_NOTES,
|
||||
};
|
||||
|
||||
const FAMILIES: [Strategy; 8] = Strategy::ALL;
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
fn next(&mut self) -> u64 {
|
||||
@@ -24,7 +23,7 @@ fn same_music(a: &Phrase, b: &Phrase) -> bool {
|
||||
.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 {
|
||||
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;
|
||||
@@ -39,28 +38,30 @@ pub fn distance(a: &Phrase, b: &Phrase) -> u32 {
|
||||
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 => [
|
||||
/// How well a transformation serves a block's role. Zero means "only as a last resort".
|
||||
fn fit(role: Role, family: Strategy) -> u32 {
|
||||
let preferred = match role {
|
||||
Role::Statement => [
|
||||
Strategy::Quote,
|
||||
Strategy::NewRhythm,
|
||||
Strategy::FragmentAnswer,
|
||||
Strategy::LaterPeak,
|
||||
Strategy::ChangedEnding,
|
||||
],
|
||||
Task::Answer => [
|
||||
Role::Answer => [
|
||||
Strategy::CallResponse,
|
||||
Strategy::ChangedEnding,
|
||||
Strategy::DelayedResolution,
|
||||
],
|
||||
Task::MakeSpace => [
|
||||
Strategy::NegativeSpace,
|
||||
Strategy::FragmentAnswer,
|
||||
Strategy::CallResponse,
|
||||
],
|
||||
Task::Return => [
|
||||
Strategy::HarmonicReturn,
|
||||
Role::Departure => [
|
||||
Strategy::LaterPeak,
|
||||
Strategy::ChangedEnding,
|
||||
Strategy::NewRhythm,
|
||||
Strategy::FragmentAnswer,
|
||||
],
|
||||
Role::Return => [
|
||||
Strategy::HarmonicReturn,
|
||||
Strategy::Quote,
|
||||
Strategy::NegativeSpace,
|
||||
],
|
||||
Role::Rest => [Strategy::Silence; 3],
|
||||
};
|
||||
if family == preferred[0] {
|
||||
300
|
||||
@@ -70,70 +71,137 @@ fn fit(task: Task, family: Strategy) -> u32 {
|
||||
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()));
|
||||
fn explain(family: Strategy) -> &'static str {
|
||||
match family {
|
||||
Strategy::Quote => "The motif exactly as written.",
|
||||
Strategy::Silence => "Silence. Lets the previous idea land.",
|
||||
Strategy::SavedIdea => "Saved working gesture.",
|
||||
Strategy::NewRhythm => "Same contour; entries move off the beat and pickups repeat.",
|
||||
Strategy::FragmentAnswer => "Only the first half of the motif, leaving space after it.",
|
||||
Strategy::NegativeSpace => "Connecting notes fall away; the signature anchors remain.",
|
||||
Strategy::CallResponse => "A short call in the first half, answered with longer notes.",
|
||||
Strategy::HarmonicReturn => "Close to the motif, landing where it began.",
|
||||
Strategy::LaterPeak => "The register opens upward for contrast.",
|
||||
Strategy::ChangedEnding => "Same groove; the second half bends downward.",
|
||||
Strategy::DelayedResolution => "Stays a step away from rest so the phrase keeps moving.",
|
||||
}
|
||||
let mut pool: Vec<Candidate> = Vec::new();
|
||||
for (family_index, family) in FAMILIES.into_iter().enumerate() {
|
||||
for variant in 0..3 {
|
||||
}
|
||||
|
||||
/// The motif repeated to fill `length`. Notes crossing the end are shortened, or
|
||||
/// dropped when their rhythm is locked.
|
||||
fn tile(motif: &Phrase, length: u32) -> Vec<Note> {
|
||||
let mut notes = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < length {
|
||||
for original in &motif.notes {
|
||||
let start = offset + original.start;
|
||||
if start >= length {
|
||||
break;
|
||||
}
|
||||
let mut n = Note { start, ..*original };
|
||||
if n.end() > length {
|
||||
if n.keeps_rhythm() {
|
||||
continue;
|
||||
}
|
||||
n.duration = length - start;
|
||||
}
|
||||
notes.push(n);
|
||||
}
|
||||
offset += motif.length;
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// Where a block sits in the storyboard.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Place {
|
||||
/// Start of the block, in ticks from the start of the storyboard.
|
||||
offset: u32,
|
||||
/// The final sounding block: its last note follows the ending choice.
|
||||
last: bool,
|
||||
}
|
||||
|
||||
/// Up to three distinct options for one block. The first option fits the role best.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn realize(
|
||||
motif: &Phrase,
|
||||
role: Role,
|
||||
bars: u8,
|
||||
offset: u32,
|
||||
last: bool,
|
||||
take: u64,
|
||||
s: &Settings,
|
||||
harmony: &Harmony,
|
||||
) -> Result<Vec<Candidate>> {
|
||||
s.validate()?;
|
||||
let length = bars as u32 * s.meter.bar();
|
||||
if role == Role::Rest {
|
||||
return Ok(vec![Candidate {
|
||||
strategy: Strategy::Silence,
|
||||
explanation: explain(Strategy::Silence).into(),
|
||||
phrase: Phrase {
|
||||
notes: Vec::new(),
|
||||
length,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
motif.validate(MAX_SOURCE_NOTES)?;
|
||||
harmony.validate()?;
|
||||
let base = tile(motif, length);
|
||||
if base.len() > MAX_PLAYBACK_NOTES {
|
||||
return Err(crate::Error(
|
||||
"This motif is too dense for a block this long. Shorten the block or the motif.".into(),
|
||||
));
|
||||
}
|
||||
let place = Place { offset, last };
|
||||
let mut pool: Vec<Candidate> = vec![Candidate {
|
||||
strategy: Strategy::Quote,
|
||||
explanation: explain(Strategy::Quote).into(),
|
||||
phrase: Phrase {
|
||||
notes: base.clone(),
|
||||
length,
|
||||
},
|
||||
}];
|
||||
// Blocks at different positions of the same role should not sound identical.
|
||||
let position = (offset / s.meter.bar()) as u64;
|
||||
for (family_index, family) in Strategy::ALL.into_iter().enumerate() {
|
||||
for variant in 0..3u64 {
|
||||
let mut rng = Random(
|
||||
s.seed
|
||||
.wrapping_add(family_index as u64 * 7919 + variant * 104729),
|
||||
.wrapping_add(family_index as u64 * 7919 + variant * 104729)
|
||||
.wrapping_add(take.wrapping_mul(1_000_003))
|
||||
.wrapping_add(position.wrapping_mul(15_485_863)),
|
||||
);
|
||||
let phrase = develop(source, s, harmony, family, &mut rng);
|
||||
if phrase.notes.len() > MAX_PLAYBACK_NOTES {
|
||||
let phrase = develop(
|
||||
motif,
|
||||
base.clone(),
|
||||
length,
|
||||
s,
|
||||
harmony,
|
||||
role,
|
||||
family,
|
||||
place,
|
||||
&mut rng,
|
||||
);
|
||||
if phrase.notes.is_empty() || 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 {
|
||||
""
|
||||
} else {
|
||||
" Strong notes follow the harmony."
|
||||
};
|
||||
let candidate = Candidate {
|
||||
strategy: family,
|
||||
explanation: format!("{description}{note}{constraints}"),
|
||||
explanation: format!("{}{note}", explain(family)),
|
||||
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) {
|
||||
if fit(role, family) > fit(role, pool[index].strategy) {
|
||||
pool[index] = candidate;
|
||||
}
|
||||
} else {
|
||||
@@ -152,15 +220,11 @@ pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result<Vec<
|
||||
.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
|
||||
};
|
||||
let new_family = selected.iter().all(|chosen| chosen.strategy != c.strategy);
|
||||
(
|
||||
fit(s.task, c.strategy) > 0,
|
||||
new_family > 0,
|
||||
fit(s.task, c.strategy) + diversity,
|
||||
fit(role, c.strategy) > 0,
|
||||
new_family,
|
||||
fit(role, c.strategy) + diversity,
|
||||
usize::MAX - *i,
|
||||
)
|
||||
})
|
||||
@@ -171,256 +235,243 @@ pub fn generate(source: &Phrase, s: &Settings, harmony: &Harmony) -> Result<Vec<
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn develop(
|
||||
source: &Phrase,
|
||||
motif: &Phrase,
|
||||
mut notes: Vec<Note>,
|
||||
length: u32,
|
||||
s: &Settings,
|
||||
harmony: &Harmony,
|
||||
role: Role,
|
||||
family: Strategy,
|
||||
place: Place,
|
||||
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
|
||||
let meter = s.meter;
|
||||
let departure = role == Role::Departure;
|
||||
let returning = role == Role::Return || place.last;
|
||||
let pattern = (rng.next() % 3) as usize;
|
||||
let original_count = notes.len();
|
||||
let half = length / 2;
|
||||
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 => n.start < half.max(motif.length / 2),
|
||||
Strategy::CallResponse if !returning => n.start < half,
|
||||
_ => 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 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 meter.strong(n.start) { 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 {
|
||||
let unit = meter.beat();
|
||||
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(length, |n| n.start);
|
||||
let n = &mut notes[i];
|
||||
let shift = unit / 4 + (s.variation as u32 * unit / 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)
|
||||
};
|
||||
if n.end() <= SECTION {
|
||||
notes.push(n);
|
||||
n.start = wanted.clamp(previous, next.saturating_sub(1).max(previous));
|
||||
}
|
||||
let desired = match family {
|
||||
Strategy::NegativeSpace | Strategy::FragmentAnswer => n.duration.min(unit / 2),
|
||||
Strategy::CallResponse if n.start >= half => n.duration.saturating_add(unit / 2),
|
||||
Strategy::NewRhythm if (i + pattern) % 2 == 1 => n.duration / 2,
|
||||
_ => n.duration,
|
||||
};
|
||||
n.duration = desired.max(1).min(next.saturating_sub(n.start).max(1));
|
||||
}
|
||||
if family == Strategy::NewRhythm {
|
||||
let mut extra = Vec::new();
|
||||
for (i, n) in notes.iter().enumerate() {
|
||||
let next = notes.get(i + 1).map_or(length, |n| n.start);
|
||||
if !n.is_anchor() && next.saturating_sub(n.end()) >= unit && i % 2 == pattern % 2 {
|
||||
extra.push(Note {
|
||||
start: n.end() + unit / 4,
|
||||
duration: unit / 4,
|
||||
pitch: n.pitch,
|
||||
velocity: n.velocity.saturating_sub(12).max(1),
|
||||
..Note::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
notes.extend(extra);
|
||||
notes.sort_by_key(|n| n.start);
|
||||
}
|
||||
if section == 0 {
|
||||
result.extend(notes.into_iter().map(|n| Note {
|
||||
start: n.start + offset,
|
||||
..n
|
||||
}));
|
||||
}
|
||||
// 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 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,
|
||||
}
|
||||
});
|
||||
let final_note = i + 1 == notes.len();
|
||||
let global = notes[i].start + place.offset;
|
||||
let strong = meter.strong(global);
|
||||
let wants_open = final_note
|
||||
&& ((place.last && s.ending == Ending::Open)
|
||||
|| (!place.last
|
||||
&& (family == Strategy::DelayedResolution || role == Role::Statement)));
|
||||
let wants_closed = final_note && place.last && s.ending == Ending::Resolve;
|
||||
let mask = harmony.at(global).map(|h| h.tones);
|
||||
if !strong && !final_note {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
let allowed = |pitch: u8| {
|
||||
if !s.preserve_contour {
|
||||
return true;
|
||||
}
|
||||
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 = 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 {
|
||||
motif.notes.last().map_or(notes[i].pitch, |n| n.pitch)
|
||||
} else {
|
||||
notes[i].pitch
|
||||
};
|
||||
let chosen = (s.low..=s.high)
|
||||
.filter(|&pitch| {
|
||||
if !allowed(pitch) {
|
||||
return false;
|
||||
}
|
||||
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()
|
||||
});
|
||||
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,
|
||||
}
|
||||
notes.extend(extra);
|
||||
notes.sort_by_key(|n| n.start);
|
||||
}
|
||||
})
|
||||
.min_by_key(|&pitch| pitch.abs_diff(target));
|
||||
if let Some(pitch) = chosen {
|
||||
notes[i].pitch = pitch;
|
||||
}
|
||||
// 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,
|
||||
}
|
||||
Phrase { notes, length }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{demo_motif, Meter, BAR};
|
||||
#[test]
|
||||
fn later_peak_returns_before_the_final_note() {
|
||||
let source = crate::demo_motif();
|
||||
let settings = Settings {
|
||||
fn departure_peak_rises_above_the_motif() {
|
||||
let motif = demo_motif();
|
||||
let s = Settings {
|
||||
preserve_rhythm: true,
|
||||
..Settings::default()
|
||||
};
|
||||
let phrase = develop(
|
||||
&source,
|
||||
&settings,
|
||||
&motif,
|
||||
tile(&motif, 2 * BAR),
|
||||
2 * BAR,
|
||||
&s,
|
||||
&Harmony::default(),
|
||||
Role::Departure,
|
||||
Strategy::LaterPeak,
|
||||
Place {
|
||||
offset: 4 * BAR,
|
||||
last: false,
|
||||
},
|
||||
&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);
|
||||
let peak = phrase.notes.iter().map(|n| n.pitch).max().unwrap();
|
||||
assert!(peak > motif.notes.iter().map(|n| n.pitch).max().unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn preserved_contour_has_no_direction_reversals_with_a_fixed_groove() {
|
||||
let source = crate::demo_motif();
|
||||
let settings = Settings {
|
||||
let motif = demo_motif();
|
||||
let s = Settings {
|
||||
preserve_rhythm: true,
|
||||
preserve_contour: true,
|
||||
..Settings::default()
|
||||
};
|
||||
for family in FAMILIES {
|
||||
for family in Strategy::ALL {
|
||||
let phrase = develop(
|
||||
&source,
|
||||
&settings,
|
||||
&motif,
|
||||
tile(&motif, 2 * BAR),
|
||||
2 * BAR,
|
||||
&s,
|
||||
&Harmony::default(),
|
||||
Role::Answer,
|
||||
family,
|
||||
Place {
|
||||
offset: 2 * BAR,
|
||||
last: false,
|
||||
},
|
||||
&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);
|
||||
}
|
||||
for (original, new) in motif.notes.windows(2).zip(phrase.notes.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, "{family:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn tiling_fills_blocks_of_any_meter_without_crossing_the_end() {
|
||||
let motif = demo_motif();
|
||||
for meter in Meter::ALL {
|
||||
for bars in 1..=8 {
|
||||
let length = bars * meter.bar();
|
||||
let notes = tile(&motif, length);
|
||||
assert!(!notes.is_empty());
|
||||
assert!(notes.iter().all(|n| n.end() <= length));
|
||||
assert!(notes.windows(2).all(|w| w[0].end() <= w[1].start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
use rivet_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);
|
||||
}
|
||||
@@ -3,85 +3,110 @@ use rivet_core::*;
|
||||
fn rhythm(p: &Phrase) -> Vec<(u32, u32)> {
|
||||
p.notes.iter().map(|n| (n.start, n.duration)).collect()
|
||||
}
|
||||
fn options(motif: &Phrase, role: Role, s: &Settings) -> Vec<Candidate> {
|
||||
realize(motif, role, 2, 2 * BAR, false, 0, s, &Harmony::default()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn alternatives_change_density_and_rhythm_instead_of_only_pitch() {
|
||||
let source = demo_motif();
|
||||
fn each_role_offers_distinct_reproducible_options_led_by_its_best_fit() {
|
||||
let motif = 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()
|
||||
);
|
||||
for (role, first) in [
|
||||
(Role::Statement, Strategy::Quote),
|
||||
(Role::Answer, Strategy::CallResponse),
|
||||
(Role::Departure, Strategy::LaterPeak),
|
||||
(Role::Return, Strategy::HarmonicReturn),
|
||||
] {
|
||||
let a = options(&motif, role, &s);
|
||||
assert_eq!(a, options(&motif, role, &s), "{role:?} is deterministic");
|
||||
assert_eq!(a.len(), 3, "{role:?}");
|
||||
assert_eq!(a[0].strategy, first, "{role:?}");
|
||||
for pair in a.windows(2) {
|
||||
assert_ne!(pair[0].phrase, pair[1].phrase);
|
||||
}
|
||||
for c in &a {
|
||||
c.phrase.validate(MAX_PLAYBACK_NOTES).unwrap();
|
||||
assert_eq!(c.phrase.length, 2 * BAR);
|
||||
}
|
||||
}
|
||||
assert_eq!(candidates, generate(&source, &s).unwrap());
|
||||
let quote = &options(&motif, Role::Statement, &s)[0];
|
||||
assert_eq!(
|
||||
quote.phrase.notes, motif.notes,
|
||||
"a statement quotes the motif"
|
||||
);
|
||||
let rest = realize(&motif, Role::Rest, 1, 0, false, 0, &s, &Harmony::default()).unwrap();
|
||||
assert_eq!(rest.len(), 1);
|
||||
assert!(rest[0].phrase.notes.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn options_change_rhythm_and_density_not_only_pitch() {
|
||||
let motif = demo_motif();
|
||||
let s = Settings::default();
|
||||
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));
|
||||
for role in [Role::Answer, Role::Departure] {
|
||||
for c in options(&motif, role, &Settings { seed, ..s.clone() }) {
|
||||
signatures.insert(rhythm(&c.phrase));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
signatures.len() > 3,
|
||||
"seeds should explore multiple rhythmic decisions"
|
||||
"seeds should explore rhythmic decisions"
|
||||
);
|
||||
assert!(options(&motif, Role::Answer, &s)
|
||||
.iter()
|
||||
.any(|c| c.phrase.notes.len() != motif.notes.len()));
|
||||
}
|
||||
#[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 mut motif = demo_motif();
|
||||
motif.notes[1].pitch_locked = true;
|
||||
motif.notes[1].pitch = 99;
|
||||
motif.notes[3].rhythm_locked = true;
|
||||
for role in [Role::Answer, Role::Departure, Role::Return] {
|
||||
for c in realize(
|
||||
&motif,
|
||||
role,
|
||||
4,
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
&Settings::default(),
|
||||
&Harmony::default(),
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
let locked: Vec<_> = c.phrase.notes.iter().filter(|n| n.pitch_locked).collect();
|
||||
assert_eq!(locked.len(), 2, "{role:?} {:?}", c.strategy);
|
||||
assert!(locked.iter().all(|n| n.pitch == 99));
|
||||
for rep in 0..2 {
|
||||
assert!(c.phrase.notes.iter().any(|n| n.rhythm_locked
|
||||
&& n.start == motif.notes[3].start + rep * motif.length
|
||||
&& n.duration == motif.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);
|
||||
let motif = demo_motif();
|
||||
let s = Settings {
|
||||
preserve_rhythm: true,
|
||||
..Settings::default()
|
||||
};
|
||||
for c in options(&motif, Role::Departure, &s) {
|
||||
assert_eq!(rhythm(&c.phrase), rhythm(&motif));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn impossible_diversity_returns_one_honest_proposal() {
|
||||
let mut source = demo_motif();
|
||||
for n in &mut source.notes {
|
||||
fn fully_locked_motif_returns_one_honest_option() {
|
||||
let mut motif = demo_motif();
|
||||
for n in &mut motif.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);
|
||||
let all = options(&motif, Role::Answer, &Settings::default());
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].phrase.notes, motif.notes);
|
||||
}
|
||||
#[test]
|
||||
fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
|
||||
fn harmony_drives_strong_notes_and_the_final_ending() {
|
||||
let harmony = Harmony {
|
||||
length: BAR,
|
||||
spans: vec![HarmonySpan {
|
||||
@@ -97,13 +122,30 @@ fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
|
||||
ending,
|
||||
..Settings::default()
|
||||
};
|
||||
for c in generate_with_harmony(&demo_motif(), &s, &harmony).unwrap() {
|
||||
for c in realize(
|
||||
&demo_motif(),
|
||||
Role::Return,
|
||||
2,
|
||||
6 * BAR,
|
||||
true,
|
||||
0,
|
||||
&s,
|
||||
&harmony,
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
if c.strategy == Strategy::Quote {
|
||||
continue;
|
||||
}
|
||||
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_eq!(chord, ending == Ending::Resolve, "{:?}", c.strategy);
|
||||
for n in c
|
||||
.phrase
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| n.start.is_multiple_of(PPQ) && n.start != last.start)
|
||||
{
|
||||
assert_ne!(
|
||||
harmony.at(n.start).unwrap().tones & (1 << (n.pitch % 12)),
|
||||
0
|
||||
@@ -114,29 +156,16 @@ fn harmony_drives_strong_notes_and_distinct_open_or_closed_arrivals() {
|
||||
}
|
||||
#[test]
|
||||
fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
|
||||
let notes = vec![
|
||||
Note {
|
||||
let notes: Vec<_> = [64, 67, 72]
|
||||
.into_iter()
|
||||
.map(|pitch| Note {
|
||||
start: 0,
|
||||
duration: BAR,
|
||||
pitch: 64,
|
||||
pitch,
|
||||
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()
|
||||
},
|
||||
];
|
||||
})
|
||||
.collect();
|
||||
let h = Harmony::from_midi(¬es, 2 * BAR).unwrap();
|
||||
assert_eq!(h.spans.len(), 1);
|
||||
assert_eq!(h.spans[0].bass, 64);
|
||||
@@ -145,84 +174,30 @@ fn imported_harmony_preserves_voicings_and_gaps_without_guessing_roots() {
|
||||
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);
|
||||
fn every_meter_key_and_block_length_realizes_valid_options() {
|
||||
let motif = demo_motif();
|
||||
for meter in Meter::ALL {
|
||||
for root in [0, 5, 11] {
|
||||
for scale in Scale::ALL {
|
||||
let s = Settings {
|
||||
root,
|
||||
scale,
|
||||
meter,
|
||||
low: 60,
|
||||
high: 72,
|
||||
..Settings::default()
|
||||
};
|
||||
for bars in [1, 3, 8] {
|
||||
for role in Role::ALL {
|
||||
for c in realize(&motif, role, bars, 0, true, 0, &s, &Harmony::default())
|
||||
.unwrap()
|
||||
{
|
||||
c.phrase.check(MAX_PLAYBACK_NOTES, true).unwrap();
|
||||
assert_eq!(c.phrase.length, bars as u32 * meter.bar());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -34,7 +34,7 @@ fn role_handoffs_preserve_event_identity_and_respect_locks() {
|
||||
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 p = Performance::compile(&source, &s, 140., false, Meter::default()).unwrap();
|
||||
let on: Vec<_> = p
|
||||
.events
|
||||
.iter()
|
||||
@@ -42,7 +42,7 @@ fn role_handoffs_preserve_event_identity_and_respect_locks() {
|
||||
.collect();
|
||||
assert_eq!(on.len(), source.notes.len());
|
||||
for (i, n) in source.notes.iter().enumerate() {
|
||||
let role = mode.role(i, n);
|
||||
let role = mode.role(i, n, Meter::default());
|
||||
let actual = on.iter().find(|e| e.tick == n.start as i32).unwrap();
|
||||
assert_eq!(actual.message.channel, s.voices[role].channel);
|
||||
assert_eq!(
|
||||
@@ -69,7 +69,7 @@ fn controller_interpolation_and_validation_are_explicit() {
|
||||
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();
|
||||
let p = Performance::compile(&held(), &s, 120., false, Meter::default()).unwrap();
|
||||
assert!(p
|
||||
.events
|
||||
.iter()
|
||||
@@ -83,7 +83,7 @@ fn controller_interpolation_and_validation_are_explicit() {
|
||||
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();
|
||||
let performance = Performance::compile(&held(), &s, 120., true, Meter::default()).unwrap();
|
||||
assert_eq!(performance.lead_in, 192);
|
||||
assert!(performance
|
||||
.events
|
||||
@@ -127,8 +127,14 @@ fn attack_compensation_warms_up_once_and_wraps_without_cutting_the_next_note() {
|
||||
}
|
||||
#[test]
|
||||
fn public_clip_validation_rejects_bad_roles_and_dense_events() {
|
||||
let mut p =
|
||||
Performance::compile(&held(), &PerformanceSettings::default(), 120., false).unwrap();
|
||||
let mut p = Performance::compile(
|
||||
&held(),
|
||||
&PerformanceSettings::default(),
|
||||
120.,
|
||||
false,
|
||||
Meter::default(),
|
||||
)
|
||||
.unwrap();
|
||||
p.events[0].role = 9;
|
||||
assert!(p.clip().is_err());
|
||||
let event = TimedMessage {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use rivet_core::*;
|
||||
|
||||
fn demo() -> Session {
|
||||
Session::demo()
|
||||
}
|
||||
#[test]
|
||||
fn templates_build_the_story_from_one_motif() {
|
||||
let s = demo();
|
||||
let roles: Vec<_> = s.blocks.iter().map(|b| b.role).collect();
|
||||
assert_eq!(
|
||||
roles,
|
||||
[Role::Statement, Role::Answer, Role::Departure, Role::Return]
|
||||
);
|
||||
assert_eq!(s.total_bars(), 8);
|
||||
assert!(s.blocks.iter().all(|b| b.options.len() == 3));
|
||||
let phrase = s.compose().unwrap();
|
||||
assert_eq!(phrase.length, 8 * BAR);
|
||||
assert_eq!(
|
||||
&phrase.notes[..demo_motif().notes.len()],
|
||||
demo_motif().notes.as_slice(),
|
||||
"the story opens with the motif"
|
||||
);
|
||||
let mut s = demo();
|
||||
let id = s.motifs[0].id;
|
||||
s.apply_template(Template::Breathe, id).unwrap();
|
||||
assert_eq!(s.blocks[1].role, Role::Rest);
|
||||
assert_eq!(s.blocks[1].bars, 1);
|
||||
assert_eq!(s.total_bars(), 8);
|
||||
}
|
||||
#[test]
|
||||
fn blocks_have_free_lengths_up_to_the_storyboard_limit() {
|
||||
let mut s = demo();
|
||||
let motif = s.motifs[0].id;
|
||||
s.insert_block(4, motif, Role::Departure, 5).unwrap();
|
||||
s.insert_block(5, motif, Role::Rest, 1).unwrap();
|
||||
assert_eq!(s.total_bars(), 14);
|
||||
assert_eq!(s.compose().unwrap().length, 14 * BAR);
|
||||
assert!(s.insert_block(0, motif, Role::Answer, 9).is_err());
|
||||
while s.total_bars() + 8 <= MAX_BARS {
|
||||
s.insert_block(0, motif, Role::Answer, 8).unwrap();
|
||||
}
|
||||
assert!(s.insert_block(0, motif, Role::Answer, 8).is_err());
|
||||
s.validate().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn joins_shape_the_seam_without_crossing_it() {
|
||||
let mut s = demo();
|
||||
let first = s.blocks[1].phrase().unwrap().notes[0];
|
||||
let bar = s.settings.meter.bar();
|
||||
for join in Join::ALL {
|
||||
s.blocks[0].join = join;
|
||||
let phrase = s.compose().unwrap();
|
||||
let tail: Vec<_> = phrase
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| n.start < 2 * bar)
|
||||
.copied()
|
||||
.collect();
|
||||
assert!(tail.iter().all(|n| n.end() <= 2 * bar), "{join:?}");
|
||||
let last = tail.last().unwrap();
|
||||
match join {
|
||||
Join::Cut => assert_eq!(tail, s.blocks[0].phrase().unwrap().notes),
|
||||
Join::Breath => assert!(last.end() <= 2 * bar - s.settings.meter.beat() / 2),
|
||||
Join::Approach => {
|
||||
assert_ne!(last.pitch, first.pitch);
|
||||
assert!(last.pitch.abs_diff(first.pitch) <= 2);
|
||||
}
|
||||
Join::Pickup => {
|
||||
let pickup: Vec<_> = tail
|
||||
.iter()
|
||||
.filter(|n| n.start >= 2 * bar - s.settings.meter.beat())
|
||||
.collect();
|
||||
assert_eq!(pickup.len(), 2);
|
||||
let direction = (pickup[1].pitch as i16 - pickup[0].pitch as i16).signum();
|
||||
let into = (first.pitch as i16 - pickup[1].pitch as i16).signum();
|
||||
assert_eq!(direction, into, "the pickup moves toward the next block");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn kept_blocks_survive_new_settings_and_other_blocks_follow_them() {
|
||||
let mut s = demo();
|
||||
s.blocks[1].choice = 2;
|
||||
s.blocks[1].kept = true;
|
||||
let kept = s.blocks[1].phrase().unwrap().clone();
|
||||
let free = s.blocks[2].options.clone();
|
||||
s.settings.seed += 7;
|
||||
s.realize_all();
|
||||
assert_eq!(s.blocks[1].phrase().unwrap(), &kept);
|
||||
assert_ne!(s.blocks[2].options, free);
|
||||
s.another_take(1);
|
||||
assert!(!s.blocks[1].kept);
|
||||
assert_eq!(Session::from_json(&s.to_json().unwrap()).unwrap(), s);
|
||||
}
|
||||
#[test]
|
||||
fn compound_and_odd_meters_compose_whole_bars() {
|
||||
for meter in [
|
||||
Meter { beats: 3, unit: 4 },
|
||||
Meter { beats: 6, unit: 8 },
|
||||
Meter { beats: 7, unit: 8 },
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
s.settings.meter = meter;
|
||||
let motif = Phrase {
|
||||
length: meter.bar(),
|
||||
notes: (0..3)
|
||||
.map(|i| Note {
|
||||
start: i * meter.beat() / 2,
|
||||
duration: meter.beat() / 2,
|
||||
pitch: 60 + 2 * i as u8,
|
||||
velocity: 90,
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let id = s.add_motif("Waltz", motif).unwrap();
|
||||
s.apply_template(Template::Build, id).unwrap();
|
||||
let phrase = s.compose().unwrap();
|
||||
assert_eq!(phrase.length, s.total_bars() * meter.bar());
|
||||
s.validate().unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn removing_a_motif_removes_its_blocks_and_limits_hold() {
|
||||
let mut s = demo();
|
||||
let id = s.motifs[0].id;
|
||||
let other = s.add_motif("Second", demo_motif()).unwrap();
|
||||
s.insert_block(4, other, Role::Answer, 2).unwrap();
|
||||
s.remove_motif(id);
|
||||
assert_eq!(s.blocks.len(), 1);
|
||||
assert_eq!(s.blocks[0].motif, other);
|
||||
let long = Phrase {
|
||||
length: 9 * BAR,
|
||||
..demo_motif()
|
||||
};
|
||||
assert!(s.add_motif("Too long", long).is_err());
|
||||
for i in s.motifs.len()..MAX_MOTIFS {
|
||||
s.add_motif(&format!("M{i}"), demo_motif()).unwrap();
|
||||
}
|
||||
assert!(s.add_motif("One more", demo_motif()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn version_three_sessions_become_a_storyboard_without_losing_notes() {
|
||||
let mut source = demo_motif();
|
||||
source.notes[2].locked = true;
|
||||
for (bars, blocks) in [(8, 4), (4, 2)] {
|
||||
let old = serde_json::json!({
|
||||
"version": 3,
|
||||
"source": source,
|
||||
"settings": {"bars": bars, "root": 2, "scale": "Dorian", "variation": 40,
|
||||
"preserve_rhythm": false, "preserve_contour": true, "task": "Answer",
|
||||
"ending": "Open", "low": 50, "high": 80, "seed": 9},
|
||||
"candidates": [], "selected": 0, "accepted": null, "previous_accepted": null,
|
||||
"tempo": 128.0, "follow_host": true, "pins": [], "looping": true,
|
||||
"gestures": [{"id": 1, "name": "Hook", "phrase": demo_motif(),
|
||||
"performance": PerformanceSettings::default()}],
|
||||
"branches": [], "active_branch": null, "next_id": 2
|
||||
});
|
||||
let s = Session::from_json(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
assert_eq!(s.version, STATE_VERSION);
|
||||
assert_eq!(s.motifs[0].phrase, source);
|
||||
assert_eq!(s.motifs[1].name, "Hook");
|
||||
assert_eq!(s.blocks.len(), blocks);
|
||||
assert_eq!(s.blocks.last().unwrap().role, Role::Return);
|
||||
assert_eq!(s.settings.root, 2);
|
||||
assert_eq!(s.settings.scale, Scale::Dorian);
|
||||
assert_eq!(s.settings.ending, Ending::Open);
|
||||
assert_eq!(s.tempo, 128.);
|
||||
assert!(s.follow_host);
|
||||
assert!(s.compose().is_ok());
|
||||
}
|
||||
let v1 = serde_json::json!({"version": 1, "source": demo_motif(),
|
||||
"settings": {"bars": 8}, "candidates": [], "selected": 0, "accepted": null,
|
||||
"previous_accepted": null, "tempo": 140.0, "follow_host": false});
|
||||
let s = Session::from_json(&serde_json::to_vec(&v1).unwrap()).unwrap();
|
||||
assert!(!s.looping, "version-1 sessions kept one-shot audition");
|
||||
assert!(s.harmony.spans.is_empty());
|
||||
let mut future = serde_json::to_value(Session::default()).unwrap();
|
||||
future["version"] = 99.into();
|
||||
assert!(Session::from_json(&serde_json::to_vec(&future).unwrap()).is_err());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rivet-midi"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Strict Standard MIDI File import/export. Unsupported data is reported, never flattened.
|
||||
use rivet_core::{
|
||||
Error, MessageKind, Note, Performance, PerformanceSettings, Phrase, Result, BAR,
|
||||
Error, MessageKind, Meter, Note, Performance, PerformanceSettings, Phrase, Result,
|
||||
MAX_PLAYBACK_NOTES, PPQ,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct MidiTrack {
|
||||
pub struct MidiFile {
|
||||
pub tracks: Vec<MidiTrack>,
|
||||
pub tempo: Option<f64>,
|
||||
/// The file's time signature; track lengths are rounded to its bars.
|
||||
pub meter: Option<Meter>,
|
||||
}
|
||||
fn error(s: &str) -> Error {
|
||||
Error(s.into())
|
||||
@@ -108,6 +110,8 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
|
||||
r.take(header_len - 6)?;
|
||||
let mut tracks = Vec::new();
|
||||
let mut tempo = None;
|
||||
let mut meter: Option<Meter> = None;
|
||||
let mut lengths = Vec::new();
|
||||
for track_index in 0..count {
|
||||
if r.take(4)? != b"MTrk" {
|
||||
return Err(error("Missing MIDI track chunk."));
|
||||
@@ -179,11 +183,17 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
|
||||
if body.len() != 4 {
|
||||
return Err(error("Invalid time signature."));
|
||||
}
|
||||
if body[0] != 4 || body[1] != 2 {
|
||||
let found = Meter {
|
||||
beats: body[0],
|
||||
unit: 1u8.checked_shl(body[1] as u32).unwrap_or(0),
|
||||
};
|
||||
found.validate()?;
|
||||
if meter.is_some_and(|m| m != found) {
|
||||
return Err(error(
|
||||
"Only 4/4 motifs are supported in this version.",
|
||||
"Meter changes inside one file are unsupported. Export one section per meter.",
|
||||
));
|
||||
}
|
||||
meter = Some(found);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -250,11 +260,7 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
|
||||
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;
|
||||
lengths.push(end);
|
||||
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 {
|
||||
@@ -262,7 +268,7 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
|
||||
};
|
||||
tracks.push(MidiTrack {
|
||||
name,
|
||||
phrase: Phrase { notes, length },
|
||||
phrase: Phrase { notes, length: 0 },
|
||||
warnings,
|
||||
channels: (0..16).filter(|c| channels & (1 << c) != 0).collect(),
|
||||
});
|
||||
@@ -271,7 +277,20 @@ pub fn read(bytes: &[u8]) -> Result<MidiFile> {
|
||||
if tracks.is_empty() {
|
||||
return Err(error("This MIDI file has no note tracks."));
|
||||
}
|
||||
Ok(MidiFile { tracks, tempo })
|
||||
// The time signature may follow the notes in another track, so bars are known only now.
|
||||
let bar = meter.unwrap_or_default().bar();
|
||||
for (track, end) in tracks.iter_mut().zip(lengths) {
|
||||
track.phrase.length = end
|
||||
.checked_add(bar - 1)
|
||||
.ok_or_else(|| error("MIDI timeline overflow."))?
|
||||
/ bar
|
||||
* bar;
|
||||
}
|
||||
Ok(MidiFile {
|
||||
tracks,
|
||||
tempo,
|
||||
meter,
|
||||
})
|
||||
}
|
||||
|
||||
fn vlq(mut value: u32, out: &mut Vec<u8>) {
|
||||
@@ -287,13 +306,21 @@ fn vlq(mut value: u32, out: &mut Vec<u8>) {
|
||||
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."));
|
||||
}
|
||||
fn time_signature(meter: Meter) -> [u8; 8] {
|
||||
[
|
||||
0,
|
||||
0xff,
|
||||
0x58,
|
||||
4,
|
||||
meter.beats,
|
||||
meter.unit.trailing_zeros() as u8,
|
||||
24,
|
||||
8,
|
||||
]
|
||||
}
|
||||
fn tempo_event(bpm: f64) -> [u8; 7] {
|
||||
let us = (60_000_000. / bpm).round() as u32;
|
||||
let mut track = vec![
|
||||
[
|
||||
0,
|
||||
0xff,
|
||||
0x51,
|
||||
@@ -301,15 +328,17 @@ pub fn write(phrase: &Phrase, bpm: f64) -> Result<Vec<u8>> {
|
||||
(us >> 16) as u8,
|
||||
(us >> 8) as u8,
|
||||
us as u8,
|
||||
0,
|
||||
0xff,
|
||||
0x58,
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
24,
|
||||
8,
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
pub fn write(phrase: &Phrase, bpm: f64, meter: Meter) -> Result<Vec<u8>> {
|
||||
phrase.validate(MAX_PLAYBACK_NOTES)?;
|
||||
meter.validate()?;
|
||||
if !bpm.is_finite() || !(30.0..=300.0).contains(&bpm) {
|
||||
return Err(error("Tempo must be 30–300 BPM."));
|
||||
}
|
||||
let mut track = tempo_event(bpm).to_vec();
|
||||
track.extend_from_slice(&time_signature(meter));
|
||||
let mut events = Vec::new();
|
||||
for n in &phrase.notes {
|
||||
events.push((n.start, 1, n.pitch, n.velocity));
|
||||
@@ -338,31 +367,17 @@ pub fn write_performance(
|
||||
phrase: &Phrase,
|
||||
settings: &PerformanceSettings,
|
||||
bpm: f64,
|
||||
meter: Meter,
|
||||
) -> Result<Vec<u8>> {
|
||||
let p = Performance::compile(phrase, settings, bpm, settings.compensated_export)?;
|
||||
meter.validate()?;
|
||||
let p = Performance::compile(phrase, settings, bpm, settings.compensated_export, meter)?;
|
||||
let shift = if settings.compensated_export && p.lead_in > 0 {
|
||||
BAR
|
||||
meter.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,
|
||||
];
|
||||
let mut conductor = tempo_event(bpm).to_vec();
|
||||
conductor.extend_from_slice(&time_signature(meter));
|
||||
vlq(phrase.length + shift, &mut conductor);
|
||||
conductor.extend_from_slice(&[0xff, 0x2f, 0]);
|
||||
let mut tracks = vec![conductor];
|
||||
@@ -432,16 +447,19 @@ mod tests {
|
||||
#[test]
|
||||
fn round_trip_source_and_generated_phrase() {
|
||||
let source = rivet_core::demo_motif();
|
||||
let bytes = write(&source, 140.).unwrap();
|
||||
let bytes = write(&source, 140., Meter::default()).unwrap();
|
||||
let parsed = read(&bytes).unwrap();
|
||||
assert_eq!(parsed.tracks[0].phrase, source);
|
||||
assert!((parsed.tempo.unwrap() - 140.).abs() < 0.001);
|
||||
for c in rivet_core::generate(&source, &rivet_core::Settings::default()).unwrap() {
|
||||
assert_eq!(
|
||||
read(&write(&c.phrase, 140.).unwrap()).unwrap().tracks[0].phrase,
|
||||
c.phrase
|
||||
);
|
||||
}
|
||||
assert_eq!(parsed.meter, Some(Meter::default()));
|
||||
let story = rivet_core::Session::demo().compose().unwrap();
|
||||
assert_eq!(
|
||||
read(&write(&story, 140., Meter::default()).unwrap())
|
||||
.unwrap()
|
||||
.tracks[0]
|
||||
.phrase,
|
||||
story
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn running_status_and_zero_velocity_off() {
|
||||
@@ -453,7 +471,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn malformed_and_unsupported_files() {
|
||||
let good = write(&rivet_core::demo_motif(), 140.).unwrap();
|
||||
let good = write(&rivet_core::demo_motif(), 140., Meter::default()).unwrap();
|
||||
for n in 0..good.len() {
|
||||
assert!(read(&good[..n]).is_err());
|
||||
}
|
||||
@@ -462,10 +480,10 @@ mod tests {
|
||||
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]))
|
||||
read(&file(&[0, 0xff, 0x58, 4, 11, 3, 24, 8, 0, 0xff, 0x2f, 0]))
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("4/4")
|
||||
.contains("Supported meters")
|
||||
);
|
||||
assert!(read(&file(&[0x81, 0x81, 0x81, 0x81, 0, 0xff, 0x2f, 0])).is_err());
|
||||
}
|
||||
@@ -477,14 +495,14 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(p.tracks[0]
|
||||
.phrase
|
||||
.validate_source()
|
||||
.validate(rivet_core::MAX_SOURCE_NOTES)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("Overlapping"));
|
||||
}
|
||||
#[test]
|
||||
fn separate_tracks_and_timing_resolution() {
|
||||
let a = write(&rivet_core::demo_motif(), 140.).unwrap();
|
||||
let a = write(&rivet_core::demo_motif(), 140., Meter::default()).unwrap();
|
||||
let mut two = a[..14].to_vec();
|
||||
two[9] = 1;
|
||||
two[11] = 2;
|
||||
@@ -508,7 +526,7 @@ mod tests {
|
||||
v.controller.rest = 5 + role as u8;
|
||||
}
|
||||
let source = rivet_core::demo_motif();
|
||||
let bytes = write_performance(&source, &settings, 120.).unwrap();
|
||||
let bytes = write_performance(&source, &settings, 120., Meter::default()).unwrap();
|
||||
let file = read(&bytes).unwrap();
|
||||
assert_eq!(file.tracks.len(), 2);
|
||||
assert_eq!(file.tracks[0].channels, vec![0]);
|
||||
@@ -536,11 +554,43 @@ mod tests {
|
||||
let source = rivet_core::demo_motif();
|
||||
let mut settings = PerformanceSettings::default();
|
||||
settings.voices[0].attack_ms = 100;
|
||||
let direct = read(&write_performance(&source, &settings, 120.).unwrap()).unwrap();
|
||||
let direct =
|
||||
read(&write_performance(&source, &settings, 120., Meter::default()).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);
|
||||
let compensated =
|
||||
read(&write_performance(&source, &settings, 120., Meter::default()).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
compensated.tracks[0].phrase.notes[0].start,
|
||||
rivet_core::BAR - 192
|
||||
);
|
||||
assert_eq!(
|
||||
compensated.tracks[0].phrase.length,
|
||||
source.length + rivet_core::BAR
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compound_meter_round_trips_and_sets_bar_length() {
|
||||
let meter = Meter { beats: 6, unit: 8 };
|
||||
let phrase = Phrase {
|
||||
length: 2 * meter.bar(),
|
||||
notes: vec![Note {
|
||||
start: meter.beat(),
|
||||
duration: 480,
|
||||
pitch: 62,
|
||||
velocity: 80,
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
let parsed = read(&write(&phrase, 120., meter).unwrap()).unwrap();
|
||||
assert_eq!(parsed.meter, Some(meter));
|
||||
assert_eq!(parsed.tracks[0].phrase, phrase);
|
||||
let three = read(&file(&[
|
||||
0, 0xff, 0x58, 4, 3, 2, 24, 8, 0, 0x90, 60, 80, 0x83, 0x60, 0x80, 60, 0, 0, 0xff, 0x2f,
|
||||
0,
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(three.meter, Some(Meter { beats: 3, unit: 4 }));
|
||||
assert_eq!(three.tracks[0].phrase.length, 3 * PPQ);
|
||||
}
|
||||
}
|
||||
@@ -855,7 +855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2054,7 +2054,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rivet"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"nih_plug",
|
||||
@@ -2067,7 +2067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-app"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"crossbeam-queue",
|
||||
"rivet-core",
|
||||
@@ -2077,7 +2077,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-core"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2085,7 +2085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rivet-midi"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"rivet-core",
|
||||
]
|
||||
@@ -2143,7 +2143,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2329,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2617,7 +2617,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rivet"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "ISC"
|
||||
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
use super::{egui, report, start, EditorState, LOCK, MUTED, NOTE};
|
||||
use rivet_app::{Output, Shared};
|
||||
use rivet_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session};
|
||||
use std::sync::Arc;
|
||||
|
||||
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) = rivet_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); };
|
||||
if path.extension().is_none() { path.set_extension("json"); }
|
||||
rivet_app::write_new(&path, &bytes)?; Ok(Output::Saved(path))
|
||||
});
|
||||
}
|
||||
if ui.button("Import gestures…").clicked() {
|
||||
start(shared, state, move || {
|
||||
let Some(path) = rivet_app::dialog::choose(false, true)? else { return Ok(Output::Canceled); };
|
||||
let bytes = rivet_app::read_limited(&path, 2 * 1024 * 1024)?;
|
||||
Ok(Output::LibraryLoaded(rivet_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,rivet_core::CurvePoint {position,value}); }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -178,8 +178,9 @@ impl Plugin for Rivet {
|
||||
}
|
||||
impl ClapPlugin for Rivet {
|
||||
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_DESCRIPTION: Option<&'static str> = Some(
|
||||
"Tell a MIDI motif as a story: a storyboard of statement, answer, departure and return",
|
||||
);
|
||||
const CLAP_MANUAL_URL: Option<&'static str> = None;
|
||||
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Sound roles: split a phrase across two synth parts with their own channels and CC gestures.
|
||||
use super::{egui, MUTED, NOTE, ROLE, SURFACE};
|
||||
use rivet_core::{PerformanceSettings, RoleMode};
|
||||
|
||||
pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
|
||||
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., SURFACE);
|
||||
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 { ROLE }),
|
||||
);
|
||||
}
|
||||
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, rivet_core::CurvePoint { position, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,28 +97,22 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
|
||||
};
|
||||
let mut envelope: nih_plug::prelude::PluginState =
|
||||
serde_json::from_slice(&save()[8..]).unwrap();
|
||||
let mut session = rivet_core::Session {
|
||||
source: Some(rivet_core::demo_motif()),
|
||||
tempo: 150.,
|
||||
..Default::default()
|
||||
};
|
||||
session.source.as_mut().unwrap().notes[1].locked = true;
|
||||
session.harmony = rivet_core::Harmony::progression(0, rivet_core::Scale::Minor);
|
||||
let mut session = rivet_core::Session::demo();
|
||||
session.tempo = 150.;
|
||||
session.motifs[0].phrase.notes[1].locked = true;
|
||||
session.settings.meter = rivet_core::Meter { beats: 6, unit: 8 };
|
||||
session.harmony =
|
||||
rivet_core::Harmony::progression(0, rivet_core::Scale::Minor, session.settings.meter);
|
||||
session.audition = rivet_core::Audition::Midi;
|
||||
session.regenerate().unwrap();
|
||||
session.pin_selected(1).unwrap();
|
||||
session.accept();
|
||||
session.selected = 2;
|
||||
session.accept();
|
||||
session.realize_all();
|
||||
session.blocks[1].choice = 1;
|
||||
session.blocks[1].kept = true;
|
||||
session.blocks[2].join = rivet_core::Join::Breath;
|
||||
session.another_take(3);
|
||||
session.performance.mode = rivet_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(rivet_core::CreativeMove::Thin, Some(0))
|
||||
.unwrap();
|
||||
envelope.fields.insert(
|
||||
"story-session-v1".into(),
|
||||
String::from_utf8(session.to_json().unwrap()).unwrap(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rivet-preview"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Headless end-to-end development harness; uses exactly the plugin's composition and voice.
|
||||
use rivet_core::{demo_motif, Harmony, Performance, PerformancePlayer, Session};
|
||||
use rivet_core::{Harmony, Performance, PerformancePlayer, Session, Template};
|
||||
use std::{env, fs, io::Write, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
@@ -11,13 +11,13 @@ fn main() {
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = env::args().skip(1).collect();
|
||||
if args.is_empty() || args[0] == "--help" {
|
||||
println!("Rivet preview\n rivet-preview demo <output-directory>\n rivet-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n rivet-preview session <session.json> <output-directory>\nWrites source.mid, up to three distinct candidates, session.json, and selected.wav. Track indices start at 0.");
|
||||
println!("Rivet preview\n rivet-preview demo <output-directory>\n rivet-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n rivet-preview session <session.json> <output-directory>\nBuilds an arc storyboard from the motif (or uses the saved one) and writes motif.mid, storyboard.mid, session.json, and storyboard.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());
|
||||
session = Session::demo();
|
||||
PathBuf::from(&args[1])
|
||||
}
|
||||
"session" if args.len() == 3 => {
|
||||
@@ -25,62 +25,65 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
PathBuf::from(&args[2])
|
||||
}
|
||||
"develop" if args.len() == 4 || args.len() == 6 => {
|
||||
let bytes = fs::read(&args[1])?;
|
||||
let file = rivet_midi::read(&bytes)?;
|
||||
let file = rivet_midi::read(&fs::read(&args[1])?)?;
|
||||
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());
|
||||
session.settings.meter = file.meter.unwrap_or_default();
|
||||
if args.len() == 6 {
|
||||
let file = rivet_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)?;
|
||||
}
|
||||
let id = session.add_motif(&track.name, track.phrase.clone())?;
|
||||
session.apply_template(Template::Arc, id)?;
|
||||
PathBuf::from(&args[3])
|
||||
}
|
||||
_ => return Err("Use --help for usage.".into()),
|
||||
};
|
||||
if args.len() == 6 {
|
||||
let file = rivet_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()?;
|
||||
let meter = session.settings.meter;
|
||||
let selected = session.compose()?;
|
||||
fs::create_dir_all(&output)?;
|
||||
if let Some(source) = &session.source {
|
||||
if let Some(motif) = session.motifs.first() {
|
||||
fs::write(
|
||||
output.join("source.mid"),
|
||||
rivet_midi::write(source, session.tempo)?,
|
||||
output.join("motif.mid"),
|
||||
rivet_midi::write(&motif.phrase, session.tempo, meter)?,
|
||||
)?;
|
||||
}
|
||||
for (i, c) in session.candidates.iter().enumerate() {
|
||||
fs::write(
|
||||
output.join(format!("candidate-{}.mid", i + 1)),
|
||||
rivet_midi::write_performance(&c.phrase, &session.performance, session.tempo)?,
|
||||
)?;
|
||||
println!("{}: {}", c.strategy.name(), c.explanation);
|
||||
for (i, block) in session.blocks.iter().enumerate() {
|
||||
let option = block.options.get(block.choice);
|
||||
println!(
|
||||
"{:>2}. {:<9} {} bars {:<22} → {}",
|
||||
i + 1,
|
||||
block.role.name(),
|
||||
block.bars,
|
||||
option.map_or("(empty)", |c| c.strategy.name()),
|
||||
block.join.name()
|
||||
);
|
||||
}
|
||||
fs::write(output.join("session.json"), session.to_json()?)?;
|
||||
fs::write(
|
||||
output.join("selected.mid"),
|
||||
rivet_midi::write_performance(&selected, &session.performance, session.tempo)?,
|
||||
output.join("storyboard.mid"),
|
||||
rivet_midi::write_performance(&selected, &session.performance, session.tempo, meter)?,
|
||||
)?;
|
||||
let mut player = PerformancePlayer::default();
|
||||
player.set_sample_rate(44100.)?;
|
||||
player
|
||||
.play(Performance::compile(&selected, &session.performance, session.tempo, false)?.clip()?);
|
||||
player.play(
|
||||
Performance::compile(&selected, &session.performance, session.tempo, false, meter)?
|
||||
.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"))?;
|
||||
let mut wav = fs::File::create(output.join("storyboard.wav"))?;
|
||||
wav.write_all(b"RIFF")?;
|
||||
wav.write_all(&(36 + len).to_le_bytes())?;
|
||||
wav.write_all(b"WAVEfmt ")?;
|
||||
|
||||