Compare commits

..
Author SHA1 Message Date
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
57 changed files with 4906 additions and 3078 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
+13
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:
@@ -26,6 +29,11 @@ jobs:
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
@@ -65,6 +76,8 @@ 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
+6
View File
@@ -8,7 +8,9 @@ permissions:
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
+7 -7
View File
@@ -214,7 +214,7 @@ dependencies = [
[[package]]
name = "patina-core"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"rustfft",
"serde",
@@ -223,7 +223,7 @@ dependencies = [
[[package]]
name = "patina-render"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"patina-core",
"serde_json",
@@ -231,7 +231,7 @@ dependencies = [
[[package]]
name = "patina-ui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"egui",
"patina-core",
@@ -281,7 +281,7 @@ dependencies = [
[[package]]
name = "rivet-app"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"crossbeam-queue",
"rivet-core",
@@ -291,7 +291,7 @@ dependencies = [
[[package]]
name = "rivet-core"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -299,14 +299,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
@@ -4,7 +4,7 @@ Two Rust CLAP plugins for writing and refining 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) |
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
@@ -25,7 +25,7 @@ In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
load Patina in a mixer effect slot.
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`
**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.2.0`
publish independently. See [release workflow](docs/releases.md).
These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs
+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.
+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}");
}
}
+3 -3
View File
@@ -1412,7 +1412,7 @@ dependencies = [
[[package]]
name = "patina"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
@@ -1425,7 +1425,7 @@ dependencies = [
[[package]]
name = "patina-core"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"rustfft",
"serde",
@@ -1433,7 +1433,7 @@ dependencies = [
[[package]]
name = "patina-ui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"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 {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "patina-ui"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "ISC"
+356 -251
View File
@@ -3,13 +3,21 @@ 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);
// Dark anodized hardware: graphite surfaces, one signal colour, steel data traces.
const BG: Color32 = Color32::from_rgb(14, 15, 17);
const PANEL: Color32 = Color32::from_rgb(25, 27, 31);
const PANEL_TOP: Color32 = Color32::from_rgb(34, 36, 41);
const EDGE: Color32 = Color32::from_rgb(44, 47, 53);
const SCREEN: Color32 = Color32::from_rgb(9, 10, 11);
const TEXT: Color32 = Color32::from_rgb(232, 233, 236);
const MUTED: Color32 = Color32::from_rgb(140, 146, 156);
const DIM: Color32 = Color32::from_rgb(70, 74, 82);
/// Signal colour: cuts, active states, value arcs.
const ACCENT: Color32 = Color32::from_rgb(255, 1, 96);
/// Measured input and neutral data.
const STEEL: Color32 = Color32::from_rgb(184, 192, 204);
/// Focus / protect curve, distinct from cuts without competing with them.
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(
@@ -36,14 +44,36 @@ pub fn style(ctx: &egui::Context) {
);
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);
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
@@ -77,7 +107,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 +117,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 +139,35 @@ impl State {
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
/// Disc with a vertical light-to-dark gradient, like brushed metal lit from above.
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));
}
/// 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 +183,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 +198,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,
@@ -167,7 +222,7 @@ fn rotary(
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());
@@ -201,62 +256,71 @@ fn rotary(
}
}
let center = rect.center();
let radius = size * 0.45;
let radius = size * 0.46;
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);
let normalized = ((*value - low) / (high - low)).clamp(0., 1.);
// Scale track and value arc around the knob.
for (fraction, stroke) in [
(1., Stroke::new(3.0_f32, Color32::from_rgb(59, 49, 76))),
(normalized, Stroke::new(3.0_f32, color)),
(1., Stroke::new(2.5_f32, DIM)),
(normalized, Stroke::new(2.5_f32, color)),
] {
let points: Vec<_> = (0..=64)
.map(|i| {
let a = start + span * fraction * i as f32 / 64.;
center + Vec2::angled(a) * (radius - 3.)
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) * (radius - 14.),
center + Vec2::angled(angle) * (radius - 8.),
center + Vec2::angled(angle) * (cap * 0.35),
center + Vec2::angled(angle) * (cap - 2.),
],
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,
Stroke::new(2.0_f32, TEXT),
);
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, TEXT));
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, color));
}
ui.label(
RichText::new(name)
.size(if size > 100. { 20. } else { 17. })
.color(TEXT),
RichText::new(name.to_uppercase())
.size(if size > 100. { 14. } else { 12. })
.color(MUTED),
);
ui.add(
egui::DragValue::new(value)
@@ -264,32 +328,85 @@ fn rotary(
.speed(0.1)
.suffix(unit),
);
response
}
/// Vertical gradient rectangle, used for panel faces.
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.
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
}
fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).size(11.).color(MUTED));
}
/// Small labelled hardware-style readout.
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.);
}
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 +415,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 +427,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 +445,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 +574,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 +596,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 +620,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 +639,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 +649,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 +690,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
state,
);
});
});
});
});
ui.separator();
@@ -626,42 +708,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 +784,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 +805,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 +824,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 +834,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 +865,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 +880,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 +894,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 +997,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 ")?;
+5 -5
View File
@@ -317,7 +317,7 @@ dependencies = [
[[package]]
name = "patina-core"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"rustfft",
"serde",
@@ -325,7 +325,7 @@ dependencies = [
[[package]]
name = "patina-ui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"egui",
"patina-core",
@@ -394,7 +394,7 @@ dependencies = [
[[package]]
name = "rivet-app"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"crossbeam-queue",
"rivet-core",
@@ -404,7 +404,7 @@ dependencies = [
[[package]]
name = "rivet-core"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -412,7 +412,7 @@ dependencies = [
[[package]]
name = "rivet-midi"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"rivet-core",
]
+22 -16
View File
@@ -48,9 +48,15 @@ fn main() {
engine.set_settings(patina_settings).unwrap();
for i in 0..24000 {
let t = i as f32 / 48000.;
let x = (std::f32::consts::TAU * 3000. * t).sin() * 0.28
+ (std::f32::consts::TAU * 240. * t).sin() * 0.15
+ (std::f32::consts::TAU * 1200. * t).sin() * 0.08;
// A 220 Hz harmonic tone with a ring around 3 kHz.
let x = (1..80)
.map(|k| {
let f = 220. * k as f32;
let ring = 1. + 5. * (-((f / 3000.).log2() / 0.12).powi(2)).exp();
(std::f32::consts::TAU * f * t).sin() * ring / k as f32
})
.sum::<f32>()
* 0.12;
engine.process_sample([x, x * 0.85], patina_core::Monitor::default());
if let Some(frame) = engine.take_analyzer() {
patina_state.accept_frame(frame);
@@ -58,14 +64,12 @@ fn main() {
}
}
let shared = Arc::new(rivet_app::Shared::default());
let mut session = rivet_core::Session {
source: Some(rivet_core::demo_motif()),
..Default::default()
};
session.source.as_mut().unwrap().notes[0].locked = true;
session.regenerate().unwrap();
session.accept();
if std::env::args().any(|arg| arg == "--creative") {
let mut session = rivet_core::Session::demo();
session.motifs[0].phrase.notes[0].locked = true;
session.realize_all();
session.blocks[2].kept = true;
let creative = std::env::args().any(|arg| arg == "--creative");
if creative {
session.performance.mode = rivet_core::RoleMode::CallResponse;
session.performance.voices[0].name = "Glass question".into();
session.performance.voices[1].name = "Warm answer".into();
@@ -75,12 +79,8 @@ fn main() {
voice.controller.shape(2);
voice.attack_ms = 40;
}
session.save_gesture("Hover, then resolve", None).unwrap();
session.save_branch("Opening thought").unwrap();
session
.apply_move(rivet_core::CreativeMove::Thin, Some(1))
.unwrap();
}
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if patina {
@@ -89,6 +89,12 @@ fn main() {
editor::style(&ctx);
}
let mut state = editor::EditorState::default();
if creative {
state.tab = editor::Tab::Sound;
}
if std::env::args().any(|arg| arg == "--motif") {
state.focus = Some(editor::Focus::Motif(session_motif));
}
let mut textures = std::collections::HashMap::new();
let mut output = None;
for _ in 0..3 {