Compare commits

..
Author SHA1 Message Date
nvrl f36ff0780c feat: add Anvil sub anchor and Crucible resampler
Check and Windows preview / check (anvil) (push) Successful in 2m41s
Check and Windows preview / check (crucible) (push) Successful in 2m7s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m33s
Check and Windows preview / previews (push) Successful in 1m10s
Check and Windows preview / windows (push) Successful in 2m2s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 3m3s
Two new CLAP effects aimed at riddim and dubstep production, plus shared
crates so all four plugins keep one look and reuse DSP.

Anvil 0.1.0 (io.storyboard.sub-anchor):
- Replaces the band below Split with a clean mono sine. Every note gets
  the same peak level and restarts at the same phase, so switch-ups keep
  their weight and kick hits line up.
- Pitch and note starts come from the audio (YIN, 80 ms lookahead
  reported to the host) or from MIDI notes sent to the effect.
- Dynamics, Drive, Octave, Glide, Phase, Gate and Keep; Solo sub monitor,
  note readout, level meters and a low-band oscilloscope.

Crucible 0.1.0 (io.storyboard.resampler):
- Records its input continuously in a lock-free ring with host transport
  markers. Capture takes the newest 1-16 complete bars on the bar grid,
  following tempo changes, or the newest audio when stopped.
- Grid (straight and triplet) or transient slicing, manual cuts, slice
  audition without audio-thread allocation.
- Export writes the loop, trimmed, declicked and normalised slices named
  and tagged with their root note, and an optional 2048-sample wavetable
  for Serum and Vital.

Shared:
- common-ui holds the palette, fonts, knobs and panels taken from Patina;
  Patina renders identically. Rotary knobs at their minimum no longer
  tessellate a zero-length arc into NaN vertices.
- common-dsp holds biquads, a Linkwitz-Riley crossover, YIN, note helpers
  and a float WAV writer with sampler root and Serum frame chunks.
- Catalog, CI matrix, release tags, editor previews and docs cover both
  plugins; a script test keeps the catalog and workflows in step.
2026-11-06 03:22:00 +01:00
nvrl f1366280ff feat(rivet): storyboard composition with flexible lengths and meters
Check and Windows preview / check (patina) (push) Successful in 3m59s
Check and Windows preview / check (rivet) (push) Successful in 2m2s
Check and Windows preview / previews (push) Successful in 1m8s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 2m41s
Independent plugin release / build (push) Successful in 3m0s
Independent plugin release / publish (push) Successful in 9s
Rivet 0.4.0 tells a motif as a story. A timeline of blocks with roles
(statement, answer, departure, return, rest) replaces tasks, pins and
branches. Rivet writes each block from a motif and joins the seams with
a cut, breath, pickup or approach.

- Motifs of 1-8 bars in a bin of 16; storyboards up to 32 bars.
- Meters 2/4, 3/4, 4/4, 5/4, 6/8, 7/8 and 12/8 for import, writing,
  audition and export. Phrygian, harmonic minor and Mixolydian scales.
- Per-block options, Another take and Keep; templates for new boards.
- Editor rebuilt: motif bin, timeline, large piano roll, inspector,
  tabs for generation, harmony and sound roles, 32-step undo/redo.
- Sessions from 0.1-0.3 open as a storyboard from their source motif.
2026-11-06 02:15:18 +01:00
nvrl abd37fd4fe ci: cache Rust toolchain, registry and build directories
Every Rust job restores the stable toolchain, Cargo registry and its own
target directories from the runner cache. Keys combine the ISO week and
all Cargo.lock files, so dependencies are reused across pushes while
stale build output and old toolchains expire weekly. Release jobs keep
separate entries per plugin.
2026-11-06 01:43:17 +01:00
nvrl 17c6fe3ab9 feat(patina): rebuild resonance detector, simplify controls, new look
Check and Windows preview / check (patina) (push) Successful in 1m48s
Check and Windows preview / check (rivet) (push) Successful in 1m26s
Check and Windows preview / previews (push) Successful in 55s
Independent plugin release / plan (push) Successful in 7s
Check and Windows preview / windows (push) Successful in 1m41s
Independent plugin release / build (push) Successful in 2m28s
Independent plugin release / publish (push) Successful in 7s
Patina 0.2.0. The old detector compared raw FFT bins with a one-octave
dB mean, so every harmonic of a tonal sound looked like a resonance and
real harsh bumps were barely touched. The new detector:

- averages power over each log band and smooths it over time
- joins harmonic tops into an envelope, smoothed by Sharpness
- compares it with a robust, tilt-aware local line fit that ignores
  empty regions and cannot be lifted by the bump itself

Six overlapping controls become Depth, Sharpness and Selectivity; focus
points now read "up cuts more, down protects". The analysis window is
halved to 2048 samples (about 43 ms delay). Behaviour tests cover plain
harmonic tones, resonant bumps, narrow rings, level independence,
flicker on noise and high sample rates.

Both editors get a dark metallic look with #FF0160 as the single
accent colour; screenshots and docs are updated. Projects saved with
Patina 0.1 load the new controls at their defaults.
2026-11-06 01:38:17 +01:00
105 changed files with 15120 additions and 3204 deletions

No files matched your search

+52 -10
View File
@@ -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
+18 -1
View File
@@ -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:
+7 -1
View File
@@ -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"
Generated
+58 -7
View File
@@ -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",
+2 -2
View File
@@ -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"
+27 -11
View File
@@ -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
```
+13
View File
@@ -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.
+62
View File
@@ -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.
![Anvil](assets/editor.png)
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

+13
View File
@@ -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.
+60
View File
@@ -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**.
![Crucible](assets/editor.png)
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

+12 -2
View File
@@ -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
+12
View File
@@ -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.
+50 -27
View File
@@ -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.
![Patina](assets/editor.png)
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 415 KiB

+18
View File
@@ -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
+16
View File
@@ -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.
+62 -59
View File
@@ -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.
![Rivet editor](assets/editor.png)
## Write with it
1. Import a monophonic MIDI motif of up to two bars in 4/4, or load the demo.
2. Choose a task: **Develop the motif**, **Write an answer**, **Make room**, or
**Find a return**. Choose a settled or open ending and a four/eight-bar length.
3. Decide what defines the idea. **Keep groove** fixes note positions and lengths;
**Keep contour** protects the direction of movement, allowing flattened steps
at register limits. Click source notes to lock the whole note, pitch only, or
rhythm only. Clicking uses the currently selected lock mode. Locks
and identity constraints take precedence over the proposed transformations.
4. Optionally open **Harmony** and import a chord/bass MIDI track (up to eight
bars), or create and edit a four-chord progression. Imported overlapping notes
become pitch-class regions with the actual lowest held pitch; the tool does
not guess chord roots or chord names. Empty regions remain gaps, and short
contexts repeat. Harmony guides strong notes and arrivals; the preview plays
the melody only. Supplied chord tones may extend beyond the selected scale.
5. Explore alternatives. A deterministic pool of eight transformation families
supplies up to three distinct proposals, selected for the task and differences
in rhythm, density, duration, and pitch. Fully constrained motifs may yield
fewer than three; cosmetic velocity differences do not count as new ideas.
6. Select a proposal and pin its two-bar sections. Select another to fill the
remaining sections, or explore another take. Preview and export the combined
phrase from the fixed bottom toolbar. Keep a combined result to retain it
independently of new proposals; undo/redo switches the kept result and its
previous value. Individual proposals and kept results can also be exported.
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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

After

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

After

Width:  |  Height:  |  Size: 715 KiB

+20 -26
View File
@@ -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.
+58 -48
View File
@@ -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)).
+16 -15
View File
@@ -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.
+12
View File
@@ -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 }
+596
View File
@@ -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
}
}
+95
View File
@@ -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")
}
}
}
+213
View File
@@ -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());
}
+69
View File
@@ -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);
}
+2382
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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"
+199
View File
@@ -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, &params.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);
+192
View File
@@ -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));
}
+212
View File
@@ -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()();
}
}
+11
View File
@@ -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" }
+350
View File
@@ -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.",
);
});
}
+36 -1
View File
@@ -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"
]
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "common-dsp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
+263
View File
@@ -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
}
}
+142
View File
@@ -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)
}
+82
View File
@@ -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());
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "common-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
File renamed without changes.
+320
View File
@@ -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),
);
});
}
+13
View File
@@ -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 }
+93
View File
@@ -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,
})
}
+18
View File
@@ -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.;
+73
View File
@@ -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()
}
}
+206
View File
@@ -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.;
}
}
+120
View File
@@ -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"),
}
}
+478
View File
@@ -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)
}
+258
View File
@@ -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()
);
}
+68
View File
@@ -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();
}
+2382
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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"
+200
View File
@@ -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, &params.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);
+205
View File
@@ -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()();
}
}
+11
View File
@@ -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" }
+567
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina-core"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+248 -61
View File
@@ -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 {
+49 -41
View File
@@ -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.;
}
_ => {}
}
+226 -72
View File
@@ -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}");
}
}
+11 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+2 -2
View File
@@ -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]);
+31 -46
View File
@@ -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,
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina-render"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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" }
+201 -356
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-app"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+12 -13
View File
@@ -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);
+7 -7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-core"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
-407
View File
@@ -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(())
}
}
+15 -10
View File
@@ -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,
}
+124
View File
@@ -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
}
}
+110 -409
View File
@@ -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());
}
}
+9 -6
View File
@@ -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."));
+661
View File
@@ -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
}
}
+329 -278
View File
@@ -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));
}
}
}
-138
View File
@@ -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);
}
+135 -160
View File
@@ -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(&notes, 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());
}
+12 -6
View File
@@ -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 {
+182
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-midi"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+112 -62
View File
@@ -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);
}
}
+8 -8
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "ISC"
-214
View File
@@ -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}); }
}
}
}
});
}
});
}
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -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] = &[
+173
View File
@@ -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 });
}
}
}
}
});
}
}
+11 -17
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rivet-preview"
version = "0.3.0"
version = "0.4.0"
edition.workspace = true
license.workspace = true
+38 -35
View File
@@ -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 ")?;
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more