restructure

This commit is contained in:
nvrl committed 2026-11-05 23:58:00 +01:00
1 parent 3c5b05fdec
commit 2b298dc823
87 files changed
+2065 -1198

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: Rust plugin toolchain
description: Native checks and optional Windows GNU cross-compilation in Node's Debian container
inputs:
windows:
description: Install the Windows cross compiler
default: "false"
runs:
using: composite
steps:
- 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
- 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
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
+78
View File
@@ -0,0 +1,78 @@
name: Check and Windows preview
on:
push:
branches: ["**"]
pull_request:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_HOME: ${{ gitea.workspace }}/.cargo-ci
CARGO_TERM_COLOR: always
jobs:
check:
runs-on: ubuntu-latest
container: node:22-bookworm
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina]
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- uses: ./.gitea/actions/setup
- run: python3 -m unittest discover -s scripts/tests -v
- run: python3 scripts/check-plugin.py --plugin "${{ matrix.plugin }}"
previews:
runs-on: ubuntu-latest
container: node:22-bookworm
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- uses: ./.gitea/actions/setup
- run: |
cargo fmt --all -- --check
cargo fmt --manifest-path tools/editor-check/Cargo.toml -- --check
mkdir -p target/editor
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/rivet.png --plugin rivet
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina.png --plugin patina
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/patina-compact.png --plugin patina --compact
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
path: target/editor/*.png
retention-days: 7
windows:
needs: [check, previews]
runs-on: ubuntu-latest
container: node:22-bookworm
env:
CARGO_TARGET_DIR: ${{ gitea.workspace }}/target/windows
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- uses: ./.gitea/actions/setup
with:
windows: "true"
- run: |
python3 scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: rivet-patina-windows-x86_64
path: |
dist/rivet-patina-windows-x86_64.zip
dist/rivet-patina-windows-x86_64.zip.sha256
retention-days: 30
+82
View File
@@ -0,0 +1,82 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*"]
permissions:
contents: read
env:
CARGO_HOME: ${{ gitea.workspace }}/.cargo-ci
CARGO_TERM_COLOR: always
RELEASE_REF: ${{ gitea.ref }}
jobs:
plan:
runs-on: ubuntu-latest
container: node:22-bookworm
outputs:
plugin: ${{ steps.metadata.outputs.plugin }}
version: ${{ steps.metadata.outputs.version }}
tag: ${{ steps.metadata.outputs.tag }}
archive: ${{ steps.metadata.outputs.archive }}
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- run: apt-get update && apt-get install -y --no-install-recommends python3
- id: metadata
run: python3 scripts/release.py plan --tag "$RELEASE_REF"
build:
needs: plan
runs-on: ubuntu-latest
container: node:22-bookworm
env:
PLUGIN: ${{ needs.plan.outputs.plugin }}
CARGO_TARGET_DIR: ${{ gitea.workspace }}/target/windows
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- uses: ./.gitea/actions/setup
with:
windows: "true"
- run: |
python3 -m unittest discover -s scripts/tests -v
python3 scripts/check-plugin.py --plugin "$PLUGIN"
python3 scripts/build-plugin.py --plugin "$PLUGIN" --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py --plugin "$PLUGIN"
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: ${{ needs.plan.outputs.plugin }}-windows-${{ needs.plan.outputs.version }}
path: |
dist/${{ needs.plan.outputs.archive }}
dist/${{ needs.plan.outputs.archive }}.sha256
retention-days: 30
publish:
needs: [plan, build]
runs-on: ubuntu-latest
container: node:22-bookworm
permissions:
contents: write
steps:
- uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
persist-credentials: false
- run: apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates
- uses: https://github.com/actions/download-artifact@v3
with:
name: ${{ needs.plan.outputs.plugin }}-windows-${{ needs.plan.outputs.version }}
path: dist
- name: Publish Gitea release and attachments
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_REF: ${{ gitea.ref }}
run: python3 scripts/release.py publish --tag "$RELEASE_REF"
+1 -1
View File
@@ -34,5 +34,5 @@ venv/
.DS_Store
Thumbs.db
# GitLab job-local Cargo cache.
# Job-local Cargo cache.
/.cargo-ci/
-80
View File
@@ -1,80 +0,0 @@
# Linux Docker runner, Windows deliverables. No Linux plugin artifacts.
image: rust:1-bookworm
stages: [check, build]
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"'
when: never
- if: '$CI_COMMIT_BRANCH || $CI_COMMIT_TAG'
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo-ci"
CARGO_TERM_COLOR: always
WINDOWS_TARGET: x86_64-pc-windows-gnu
default:
interruptible: true
cache:
key:
files:
- Cargo.lock
- plugins/resonance-compressor/Cargo.lock
prefix: rust-windows-v1
paths:
- .cargo-ci/registry/
- .cargo-ci/git/
check:
stage: check
before_script:
- apt-get update
- apt-get install -y --no-install-recommends python3 pkg-config libgl1-mesa-dev libx11-dev libxcursor-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxkbcommon-x11-dev
- rustup component add rustfmt clippy
script:
- cargo fmt --manifest-path tools/editor-check/Cargo.toml -- --check
- cargo fmt --all -- --check
- cargo test --workspace --locked
- cargo clippy --workspace --all-targets --locked -- -D warnings
- cargo fmt --manifest-path plugins/motif-storyboard/Cargo.toml -- --check
- cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --locked
- cargo clippy --manifest-path plugins/motif-storyboard/Cargo.toml --all-targets --locked -- -D warnings
- cargo fmt --manifest-path plugins/resonance-compressor/Cargo.toml -- --check
- cargo test --manifest-path plugins/resonance-compressor/Cargo.toml --locked
- cargo clippy --manifest-path plugins/resonance-compressor/Cargo.toml --all-targets --locked -- -D warnings
- mkdir -p target/editor
- cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/resonance-default.png --resonance
- cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/resonance-compact.png --resonance --compact
artifacts:
name: "editor-previews-$CI_COMMIT_SHORT_SHA"
expire_in: 7 days
paths:
- target/editor/*.png
windows:
stage: build
needs:
- job: check
artifacts: false
variables:
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target/windows"
before_script:
- apt-get update
- apt-get install -y --no-install-recommends python3 gcc-mingw-w64-x86-64
- rustup target add "$WINDOWS_TARGET"
script:
- python3 scripts/build-plugin.py --plugin motif-storyboard --target "$WINDOWS_TARGET"
- python3 scripts/build-plugin.py --plugin resonance-compressor --target "$WINDOWS_TARGET"
- python3 scripts/package-windows.py
artifacts:
name: "fl-plugins-windows-$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
expire_in: 30 days
paths:
- dist/fl-plugins-windows-x86_64.zip
- dist/x86_64-pc-windows-gnu/*.clap
- dist/x86_64-pc-windows-gnu/*setup.md
- dist/x86_64-pc-windows-gnu/LICENSE
- dist/x86_64-pc-windows-gnu/Roboto-OFL.txt
- dist/x86_64-pc-windows-gnu/SHA256SUMS
Generated
+44 -44
View File
@@ -212,6 +212,31 @@ dependencies = [
"windows-link",
]
[[package]]
name = "patina-core"
version = "0.1.0"
dependencies = [
"rustfft",
"serde",
"serde_json",
]
[[package]]
name = "patina-render"
version = "0.1.0"
dependencies = [
"patina-core",
"serde_json",
]
[[package]]
name = "patina-ui"
version = "0.1.0"
dependencies = [
"egui",
"patina-core",
]
[[package]]
name = "primal-check"
version = "0.3.4"
@@ -255,28 +280,36 @@ dependencies = [
]
[[package]]
name = "resonance-core"
version = "0.1.0"
name = "rivet-app"
version = "0.3.0"
dependencies = [
"crossbeam-queue",
"rivet-core",
"rivet-midi",
"windows-sys",
]
[[package]]
name = "rivet-core"
version = "0.3.0"
dependencies = [
"rustfft",
"serde",
"serde_json",
]
[[package]]
name = "resonance-render"
version = "0.1.0"
name = "rivet-midi"
version = "0.3.0"
dependencies = [
"resonance-core",
"serde_json",
"rivet-core",
]
[[package]]
name = "resonance-ui"
version = "0.1.0"
name = "rivet-preview"
version = "0.3.0"
dependencies = [
"egui",
"resonance-core",
"rivet-core",
"rivet-midi",
]
[[package]]
@@ -348,39 +381,6 @@ version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "story-app"
version = "0.3.0"
dependencies = [
"crossbeam-queue",
"story-core",
"story-midi",
"windows-sys",
]
[[package]]
name = "story-core"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "story-midi"
version = "0.3.0"
dependencies = [
"story-core",
]
[[package]]
name = "story-preview"
version = "0.3.0"
dependencies = [
"story-core",
"story-midi",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
+4 -5
View File
@@ -1,18 +1,17 @@
[workspace]
resolver = "2"
members = ["crates/story-core", "crates/story-midi", "crates/story-preview", "crates/story-app", "crates/resonance-core", "crates/resonance-render", "crates/resonance-ui"]
exclude = ["plugins/motif-storyboard", "tools/editor-check", "plugins/resonance-compressor"]
members = ["plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin"]
[workspace.package]
version = "0.3.0"
edition = "2021"
license = "ISC"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
story-core = { path = "crates/story-core" }
story-midi = { path = "crates/story-midi" }
rivet-core = { path = "plugins/rivet/core" }
rivet-midi = { path = "plugins/rivet/midi" }
[profile.release]
lto = "thin"
+55 -159
View File
@@ -1,174 +1,70 @@
# FL Studio plugins
# Rivet & Patina
Two Rust CLAP plugins: **Motif Storyboard**, a melodic composition companion,
and **[Resonance Compressor](docs/resonance-compressor.md)**, a mixing effect
with spectral resonance control and a full native engineer panel.
Two Rust CLAP plugins for writing and refining bass music.
GitLab CI builds a Windows x86-64 ZIP containing both plugins, setup guides,
licenses, and checksums. Download it from the **windows** job artifacts.
| Plugin | Purpose | Guide |
| --- | --- | --- |
| **Rivet** | Join motifs into connected phrases, preserve identity, and explore branches | [Rivet](docs/rivet/README.md) |
| **Patina** | Smooth harsh resonances while retaining tone and transient character | [Patina](docs/patina/README.md) |
## Motif Storyboard
The names come from metalwork: a **rivet** joins pieces; **patina** changes a
surface while leaving its character. They share a Rust foundation and keep
their own musical purpose, code, documentation, and version.
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.
## Install
![Motif Storyboard editor](docs/editor.png)
Download the desired plugin's Windows ZIP from its Gitea release. Extract it
and copy `Rivet.clap` or `Patina.clap` into:
## Write with it
1. Import a monophonic MIDI motif of up to two bars in 4/4, or load the demo.
2. Choose a task: **Develop the motif**, **Write an answer**, **Make room**, or
**Find a return**. Choose a settled or open ending and a four/eight-bar length.
3. Decide what defines the idea. **Keep groove** fixes note positions and lengths;
**Keep contour** protects the direction of movement, allowing flattened steps
at register limits. Click source notes to lock the whole note, pitch only, or
rhythm only. Clicking uses the currently selected lock mode. Locks
and identity constraints take precedence over the proposed transformations.
4. Optionally open **Harmony** and import a chord/bass MIDI track (up to eight
bars), or create and edit a four-chord progression. Imported overlapping notes
become pitch-class regions with the actual lowest held pitch; the tool does
not guess chord roots or chord names. Empty regions remain gaps, and short
contexts repeat. Harmony guides strong notes and arrivals; the preview plays
the melody only. Supplied chord tones may extend beyond the selected scale.
5. Explore alternatives. A deterministic pool of eight transformation families
supplies up to three distinct proposals, selected for the task and differences
in rhythm, density, duration, and pitch. Fully constrained motifs may yield
fewer than three; cosmetic velocity differences do not count as new ideas.
6. Select a proposal and pin its two-bar sections. Select another to fill the
remaining sections, or explore another take. Preview and export the combined
phrase from the fixed bottom toolbar. Keep a combined result to retain it
independently of new proposals; undo/redo switches the kept result and its
previous value. Individual proposals and kept results can also be exported.
The opening quotation stays intact. Later sections use displaced entries,
repeated pickups, fragments, subtraction, short/long answers, changed endings,
register contrast, and returns. These are compositional suggestions, not promises
of an emotion or of commercial originality. Different seeds choose structured
alternatives; they do not independently randomize every pitch.
Pins survive task/seed/harmony changes and regeneration. Changing the motif,
its locks, or the phrase length clears pins. Pinned sections override newly
proposed material. Unsafe boundaries in legacy candidates are rejected instead
of truncating sustained notes. Changing generation inputs clears current
proposals while retaining kept work.
## Audition and commit
**Internal voice** provides two simple reference voices. **MIDI output** sends
notes and optional CC curves on the configured channels to routed external synths
and mutes the internal voice. Solo defaults to channel 1.
Looping is optional and enabled for new sessions. When audition is already
running, another selection waits until the next two-bar boundary or current
phrase end. Note-offs precede repeated same-pitch note-ons; stop, replacement,
loop boundaries, reset, restoration, and mode changes release active output notes
on the processing thread. Queued auditions are canceled by Stop.
**Host tempo** follows tempo, with the local BPM as fallback. It does not align
the initial audition to the DAW's song position: start it at the desired point
in the project loop. Host start never initiates audition; host stop silences it.
Exported MIDI is in musical ticks and carries the local BPM as its tempo event.
Native OS MIDI drag-out is not implemented; export files to commit a phrase.
See [FL Studio setup and acceptance checks](docs/fl-studio.md).
Open **Gestures, moves, and branches** to save a source or selected two-bar
section with its performance choices. Export/import a gesture library between
projects. Save a branch before experimenting; restore any branch and make a new
child with subtraction, a delayed entry, longer gates, or transposition.
Moves respect pins and locks, reject unsafe overlaps, and report when constraints
leave nothing to change. Libraries and histories each hold up to 16 entries;
remove a gesture or a leaf branch to make room.
Open **Sound roles and expressive gestures** to pass existing notes between two
patches: alternate events, alternate question/answer bars, or divide strong beats
from replies. These modes distribute a monophonic phrase rather than generate
a second independent melody. Assign separate MIDI channels and explicitly map
each optional CC lane to a synth macro. Curves are normalized over the whole
phrase, with editable points and rise/fall/arch/pulse starting shapes.
Names describe your intent; the plugin does not infer feelings.
Patch profiles set register offset, gate, velocity, and estimated attack/release.
MIDI audition uses a short warmup for early triggers. Re-audition after a tempo
change to recompile millisecond offsets. Compensation estimates trigger timing;
it does not measure patch latency or model its sound. Default export retains
musical positions; compensated export adds one bar of lead-in. Role tracks carry
names, channels, and CC curves. Stop resets mapped controllers to their chosen
stop values. Internal audition does not reproduce mapped timbre.
See [the theory and producer workflow](docs/theory/README.md).
Sessions save inside the host project and can be saved/loaded as JSON. Version-1/2
sessions and the original host field identifier remain supported; older sessions
retain one-shot audition. New sessions persist harmony, task, lock modes on notes,
pins, proposals, kept history, gestures, branches, patch profiles, CC curves, and
audition settings. JSON state is bounded at 16 MiB; gesture libraries at 2 MiB. File export/save refuses to
silently overwrite an existing file.
## Build and verify
The plugin is CLAP-only, using NIH-plug pinned to
`de421011f41a6d10fc8c7a6084e4f4dee0143683` with egui 0.31. VST3 is disabled.
The independent plugin workspace keeps Git/GUI downloads separate from engine
and MIDI tests. Stable Rust and Python are required for packaging.
```sh
cargo test --workspace --offline
cargo clippy --workspace --all-targets --offline -- -D warnings
cargo run -p story-preview --offline -- demo /tmp/motif-demo
```text
C:\Program Files\Common Files\CLAP\
```
The headless demo writes source/candidate MIDI, a session JSON, and a WAV using
the same engine and voice as the plugin. To use existing MIDI, note-track indices
exclude conductor-only tracks and start at zero. Optional harmony arguments
accept a separate polyphonic MIDI file and track index.
In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable
**Verify plugins**, and **Find installed plugins**. Load Rivet in the Channel Rack;
load Patina in a mixer effect slot.
```sh
cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase
cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase-with-chords chords.mid 0
cargo run -p story-preview -- session saved-session.json /tmp/rendered-session
Normal branch/PR pipelines also provide a combined Windows preview ZIP in the
**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.1.0`
publish independently. See [release workflow](docs/releases.md).
These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs
and saved-state identifiers are preserved; replace old scanned copies and rescan.
## Repository
```text
plugins/
catalog.json Product metadata used by build and release tools
rivet/
core/ Motifs, harmony, variation, history, performance
midi/ Standard MIDI import/export
app/ Worker jobs and real-time audition control
preview/ Offline MIDI/WAV development tool
plugin/ CLAP wrapper, editor, optional standalone
patina/
core/ Spectral detector and attenuation engine
ui/ Shared native editor and embedded fonts
render/ Offline WAV evaluation
plugin/ CLAP audio-effect wrapper
tools/editor-check/ Headless visual QA for both editors
scripts/ Shared build, packaging, release, and analysis tools
docs/rivet/ Rivet guide, theory, research, and editor previews
docs/patina/ Patina guide, research, and editor preview
references/ Local reference audio; excluded from distribution
```
Windows needs the Rust MSVC build tools; Linux needs X11/OpenGL development
headers, plus ALSA/JACK headers for standalone. The GitLab pipeline installs its build dependencies. A MinGW cross-build needs the `x86_64-pc-windows-gnu` Rust target and
`x86_64-w64-mingw32-gcc`.
The root Cargo workspace contains the engines and supporting tools. Each CLAP
wrapper has an independent workspace and lockfile so SDK dependencies stay
separate. [Development and testing](docs/development.md) covers the commands.
```sh
python scripts/build-plugin.py # native CLAP
python3 scripts/build-plugin.py --standalone # Linux CLAP + app
python3 scripts/build-plugin.py --target x86_64-pc-windows-gnu
cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --offline
cargo clippy --manifest-path plugins/motif-storyboard/Cargo.toml --all-targets --offline -- -D warnings
cargo run --manifest-path tools/editor-check/Cargo.toml --offline -- /tmp/editor.png
cargo test --workspace --locked
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python scripts/package-windows.py
```
Packages are written to `dist/<target>/Motif Storyboard.clap`; builds do not
install into a DAW. The visual QA harness compiles the shared editor against the
same egui version and software-renders it without a display. Interactive window
embedding, FL Studio MIDI routing, and audible artistic evaluation still need
manual testing. The native Linux window cannot access X11 in this environment.
The root GitLab pipeline checks both plugins and cross-builds Windows CLAPs.
Linux is used for CI execution and local development, with no Linux release
artifact. A live GitLab run still needs verification on the configured runner.
## Design and references
[Theory findings and implemented design](docs/theory/README.md) connect the research
to practical controls. [Implementation plan](docs/variation-plan.md) records the musical and technical
choices. [Devath reference measurements](docs/devath-reference-notes.md) document
analysis of the seven supplied FLACs, with reproducible plots and explicit limits.
Original reference audio is excluded from source distribution.
- `story-core`: musical model, harmonic context, structured proposals, section
composition, session migration, and bounded playback clips.
- `story-midi`: bounded SMF 0/1 import/export. Rejects SMPTE, unsupported meters,
incomplete notes, and timing not exactly representable at 960 PPQ. Reports
ignored controllers/pitch bends. Polyphonic tracks work as harmonic context;
motif import requires monophony.
- `story-app`: worker jobs, revision handling, bounded queues, sample-accurate
preview/MIDI scheduling, and Windows/Zenity dialogs.
- `motif-storyboard`: CLAP wrapper, host persistence, editor, and standalone.
Tests cover musical constraints/diversity, harmony, legacy state, section seams,
MIDI round trips, sample timing, looping, queued switches, note releases, and
zero allocation/deallocation during rendering and queued clip replacement. The
CLAP ABI test verifies discovery, note-output port exposure, state restoration,
and silent startup at several sample rates and buffer sizes.
Windows is the release target. Linux remains useful for tests, cross-compilation,
and headless editor previews. FL Studio routing, native window behavior, and
artistic listening still require manual checks in the target setup.
+4 -552
View File
@@ -1,554 +1,6 @@
# Resonance compressor: research and proposed design
# Patina research
Research date: **2026-11-05**. This document preserves the research and design
proposal. The first implementation is now in the repository; see the
[implementation and setup guide](docs/resonance-compressor.md).
The resonance-compressor research now lives in
[docs/patina/research.md](docs/patina/research.md).
## 1. What we want to build
A Rust audio effect for FL Studio that reduces objectionable spectral peaks
while retaining the source's intended tone, movement, and punch. The conventional
name for this category is **dynamic resonance suppressor**. “Resonance compressor”
is a useful working name: gain reduction varies with both frequency and time.
The main application here is aggressive, harmonically rich riddim sound design,
with melodic parts and full mixes as additional evaluation material. A useful
result is a bass that keeps its vowel, metallic edge, and rhythm while a painful
ring becomes less distracting. Making every spectrum smooth is not the goal.
The recommendation is an independently designed **STFT spectral attenuation
engine**, first delivered as a separate CLAP effect. Start with explicit controls,
conservative reduction, linked stereo, delta monitoring, and correct host latency.
Add transient protection after validating the basic detector. Defer automatic
harmonic protection, multiple resolution engines, and zero-latency processing.
Throughout this document:
- **Documented behavior** means a manufacturer, paper, or source code describes it.
- **Proposed design** means our engineering choice, not a disclosed commercial
algorithm or a claim of equivalent sound quality.
- **Listening hypothesis** means something to test with producers.
No commercial binaries were reverse engineered or benchmarked. Product claims
below were researched from public primary sources; they are not listening results.
## 2. What the reference tools actually offer
### Quance — CodWaves
This is the product referred to as “quance.” At the research date its official
page lists **€49.99 launch pricing**, a crossed-out **€79.99** price, and a sale
ending November 30, 2026. These are temporary advertised prices, not a guaranteed
future checkout price. Formats listed are VST3, AU, and AAX for Windows/macOS.
Its useful workflow references include editable tame/protect regions, learn,
tone/transient protection, delta monitoring, and multiple processing grades.
The developer describes both multi-resolution studio processing and a separate
causal filter-bank audio path for zero added sample latency.
[Official product page](https://codwaves.com/quance/).
The manual describes a detector based on neighboring spectral content and an
adaptive longer-term reference. Reduction uses a soft knee and temporal
smoothing. Its zero-latency audio path still uses an STFT for detection: detection
therefore reacts late even though audio is not delayed. This distinction is
important for our design. The manual also documents stereo linking, dry alignment,
and an optional reduction ceiling. These descriptions do not disclose all tuning
or implementation details.
[Quance manual, especially §§9 and 13](https://codwaves.com/assets/Quance-User-Manual.pdf).
### Soothe2 — oeksound
Soothe2 documents adaptive resonance reduction with frequency-dependent attack
and release. Sharpness controls the shape of cuts; selectivity controls which
resonances qualify. A frequency curve steers sensitivity rather than applying
ordinary static EQ. Delta, external sidechain detection, stereo options, and
quality controls support contextual audition. The manual warns that extreme
settings can damage the sound.
Design lesson: keep **where to work**, **what qualifies**, **how deeply to cut**,
and **how quickly to react** understandable as separate decisions. This is an
interpretation of the workflow, not a reconstruction of Soothe2's DSP.
[Official Soothe2 manual](https://oeksound.com/manuals/soothe2/).
### Soothe3 — the current oeksound reference
Research should include Soothe3 rather than treating Soothe2 as the newest
version. Its manual describes a level-adaptive soft mode and an absolute-level
hard mode, a Detail control, maximum-cut limiting, and minimum-phase processing
by default with a linear-phase option. It also documents frequency-dependent
timing adjustments. These are useful references for control design and phase
tradeoffs; our first STFT processor would not reproduce these modes.
[Official Soothe3 manual](https://oeksound.com/manuals/soothe3/).
Oeksound's product listing currently advertises **$259** for Soothe3. Price alone
does not establish comparative value or sound quality.
[Official product listing](https://oeksound.com/plugins/).
### Useful lower-cost comparison points
| Tool | Why compare it | What the comparison does not establish |
| --- | --- | --- |
| TDR Nova | Free parallel dynamic EQ with four dynamic bands: a practical manual corrective baseline | It is not the same workflow as automatic dense resonance suppression |
| NIH-plug Spectral Compressor | Public Rust example of FFT-domain dynamics and sidechain spectral processing | Many-band compression is not automatically a resonance detector |
| Static parametric EQ | Tests whether a simple fixed cut solves the problem adequately | A changing resonance may need different treatment over time |
Sources: [Tokyo Dawn's Nova page](https://www.tokyodawn.net/tdr-nova/),
[Spectral Compressor's author README](https://github.com/robbert-vdh/nih-plug/blob/master/plugins/spectral_compressor/README.md).
We should compare output against bypass and a sensible manual correction before
claiming an advantage over either commercial reference.
## 3. The perceptual problem
### A peak is observable; an unwanted peak is a judgment
A spectral peak can be a fundamental, a formant, an instrument resonance, a room
mode, deliberate filter emphasis, or an analysis artifact. Height alone cannot
classify it as a defect. A stable peak may be the exact feature that makes a bass
recognizable. Noise-like sibilance can be objectionable without a narrow peak.
For this project, use **excess relative to a local spectral reference** as an
operational detection rule, and expose decisions to the producer. It is not a
physical measurement of the source's resonator and not an emotion detector.
### Frequency neighborhoods should reflect listening, not just FFT indices
Human auditory filtering provides a reason to explore perceptual frequency
coordinates. Glasberg and Moore's research derives auditory-filter shapes from
notched-noise experiments; its scope is hearing, not a universal rule for desirable
EQ. An ERB-spaced or logarithmic neighborhood is a useful engineering hypothesis.
It should not be marketed as a complete model of perception.
[Glasberg and Moore, 1990](https://pubmed.ncbi.nlm.nih.gov/2228789/).
### Relevant research on automatic resonance equalization
Grachten, Deruty, and Tanguy describe a spectral resonance attenuator separately
from a neural network that predicts attenuation strength. Their detector compares
a smoothed spectral reference with a perceptually weighted spectrum on a
log-frequency representation. Their listening experiment involved 15 professional
participants and 150 excerpts, with substantial variation in preferred treatment.
This supports trying a transparent, non-neural detector first and evaluating
preferred strength through listening. It does not demonstrate equivalence to
Soothe, success on riddim stems, or that their long analysis windows suit live
monitoring. Use the explicitly linked **2018 v1** when reproducing this account;
the unversioned arXiv record has subsequently changed.
[Paper, v1, §§2–3](https://arxiv.org/pdf/1807.08636v1).
Corbach and colleagues study automated room-resonance suppression using measured
room behavior and parametric filtering. It is relevant to targeted filters, but
room-response identification is a different problem from deciding which peaks
to remove from an arbitrary musical recording.
[DAFx-09 paper](https://dafx.de/paper-archive/2009/papers/paper_28.pdf).
### Consequences for riddim and melodic bass
The following are project-specific listening hypotheses, not findings established
by the cited papers:
- A resonant vowel sweep may be the musical gesture. Protect its body and reduce
only a distracting upper whistle instead of flattening the entire sweep.
- Distortion and multiband dynamics can change which partial dominates from note
to note. Evaluate after the intended processing chain as well as before it.
- A repeated metallic resonance can function as a hook. Persistence alone is
insufficient evidence that it should be removed.
- A snare's initial crack and its later ring need different treatment. Preserving
the attack while controlling the tail may retain impact.
- Cleaning a soloed bass can reduce its ability to cut through the mix. Make final
decisions against the drums and melodic context, at comparable listening level.
The Devath reference work in this repository measured broad signal changes. It
did not establish which resonances were intentional or identify an ideal correction.
Use original stems and controlled synthetic examples for development; full mastered
references cannot isolate cause or provide a clean target.
## 4. Architecture choices
| Approach | Advantages | Main difficulties | Recommendation |
| --- | --- | --- | --- |
| STFT → gain surface → inverse STFT | Dense frequency control; straightforward delta and analysis; testable reconstruction | Window latency, time/frequency tradeoff, ringing and modulation artifacts | First implementation |
| Detector → a few tracked dynamic notches | Economical audio path; can target persistent rings | Peak tracking, filter stability, crossing peaks, phase movement | Later experiment |
| Dense causal filter bank | Audio path can have no added buffering delay | Finite band selectivity, detector lag, interacting filters, moving phase | Separate live-mode project |
| Multi-resolution spectral processing | Longer low-frequency observation and shorter high-frequency observation | Crossovers, alignment, phase, extra CPU and more validation | After single-resolution baseline |
| Learned detector or parameter estimator | Could model preferences or source categories | Dataset, labels, domain shift, inference cost, explainability | Defer |
These are engineering tradeoffs. Zero audio delay does not imply instantaneous
detection. Minimum phase does not imply no phase change. Retaining FFT phase does
not make a time-varying processor equivalent to one fixed linear-phase filter.
## 5. Proposed first DSP pipeline
```text
main audio ── analysis window / FFT ──────────────── complex-bin gains
│ │
└─ detector spectrum inverse FFT
local reference synthesis window
prominence overlap-add
sensitivity mask │
knee + strength + cap wet audio
frequency/time smoothing │
└───────────────────────────────┘
main audio ── matching delay ── dry ── mix / delta / bypass ── output trim
```
This is our proposed baseline, not either manufacturer's internal block diagram.
An external sidechain can later replace the detector input without replacing the
main audio being filtered. Spectral ducking needs its own detection semantics;
simply feeding a resonance-only detector with a sidechain will not duck all its
musically relevant energy.
### 5.1 Reconstruction before suppression
Implement weighted overlap-add (WOLA), then demonstrate a unity-gain null against
the delayed input before adding detection. With analysis window `wa`, synthesis
window `ws`, and hop `H`, reconstruction requires a constant window-product sum:
```text
sum_m wa[n - mH] * ws[n - mH] = C
```
Normalize by `C`, or use a corresponding reconstruction-normalization buffer.
Use a compatible periodic window/hop pair and explicitly test stream startup,
tail flushing, and arbitrary host block boundaries. The analysis window alone
is not sufficient when a synthesis window is also applied.
[Julius O. Smith, Weighted Overlap Add](https://ccrma.stanford.edu/~jos/sasp/Weighted_Overlap_Add.html).
For the prototype, try a periodic square-root Hann pair with `H = N/4` and
the appropriate product-sum normalization. Use the FFT library's required
round-trip normalization separately. RealFFT does not normalize either transform;
a forward/inverse pair needs a total `1/N` factor.
[RealFFT scaling documentation](https://docs.rs/realfft/latest/realfft/#scaling).
### 5.2 Detector and local reference
The following equations define an original experimental detector. They are
starting points to measure and tune, not validated optimal settings.
For channel `c`, frame `t`, bin `k`:
```text
P[c,t,k] = calibrated_power(|X[c,t,k]|²)
L[c,t,k] = 10 * log10(max(P[c,t,k], epsilon))
B[c,t,k] = robust_local_reference(L[c,t,*], frequency[k], neighborhood_width)
E[c,t,k] = max(0, L[c,t,k] - B[c,t,k])
```
`calibrated_power` must define its units. Correct for FFT size, window gain, and
one-sided spectrum conventions before introducing any absolute-level threshold.
Do not interchange per-bin power, band energy, and power spectral density.
Start with a log-frequency neighborhood and a bounded weighted mean baseline;
compare a median/percentile baseline offline. Excluding a small central region
can keep a strong peak from raising its own reference too much, but can also make
leakage look like multiple resonances. Define edge behavior near DC/Nyquist.
Precompute interpolation maps and neighbor lists outside the audio callback.
Adding a constant gain to the signal shifts both `L` and `B`; their difference is
approximately level-invariant above the numerical floor. That is useful for a
relative mode. An absolute gate, saturation, and noise-floor handling break this
invariance and must be documented and tested.
A smooth spectrum can still sound harsh, while harmonics can have large `E`.
Keep detection range and manual protection available from the first usable build.
Do not promise automatic separation of tone from unwanted resonance.
### 5.3 Independent controls and reduction law
Use these meanings consistently:
- **Sensitivity:** local-prominence threshold `T` in dB.
- **Amount:** slope `a` of attenuation above that threshold.
- **Max cut:** explicit upper bound `Dmax` on the applied attenuation surface.
- **Detail:** neighborhood/reduction width, with the actual mapping documented.
- **Focus/protect curve:** steers sensitivity or reduction; label which it does.
A simple continuous knee function for positive excess `u = E - T`, width `W > 0`:
```text
K(u,W) = 0 if u <= -W/2
(u + W/2)² / (2W) if -W/2 < u < W/2
u if u >= W/2
Dtarget = clamp(a * K(E - T,W), 0, Dmax)
```
For `W = 0`, use `max(0,u)` explicitly. This is a knee on **relative prominence**,
not the complete static transfer curve of an ordinary broadband compressor.
Apply smooth frequency limits and protection weights. Smooth reduction across
frequency to avoid isolated, rapidly appearing holes. Reapply the mask and cap
after operations that could spread or overshoot reduction into excluded regions.
Transition mask edits smoothly rather than abruptly changing gains.
### 5.4 Temporal behavior
Smooth attenuation in dB at the frame rate, with separate attack and release:
```text
alpha = exp(-H / (sample_rate * tau_seconds))
D[t,k] = alpha * D[t-1,k] + (1-alpha) * Dtarget[t,k]
```
Choose attack when the target attenuation increases and release when it decreases.
Handle zero time explicitly. The time constants describe this smoother, not total
response latency: windows, hop, and detector averaging also contribute.
Compare frequency-scaled timing against uniform timing; do not assume one wins.
Very fast changing narrow cuts can produce audible movement and sidebands.
Very slow cuts can miss a short whistle or remain after it disappears. Inspect
step response and note transitions as well as steady-state attenuation.
### 5.5 Apply the gain surface
```text
G[t,k] = 10^(-D[t,k]/20)
Y[t,k] = G[t,k] * X[t,k]
```
Use amplitude gains on complex bins, not power ratios directly. Preserve the
real-spectrum constraints at DC and Nyquist and keep reconstruction scaling
consistent. Initially retain each bin's phase; evaluate ringing and transient
smear honestly. This is not a claimed minimum-phase implementation.
`G <= 1` is a spectral attenuation constraint. It does **not** guarantee that
every output sample peak is lower than the input peak: changing frequency
components can change their time-domain cancellation. This is not a limiter.
### 5.6 Stereo and monitoring
Default to a shared attenuation surface for left and right. One proposed rule is
to compute both channels' target reductions and use their per-bin maximum before
smoothing. This avoids oppositely panned tones canceling in a summed detector.
Identical gains preserve per-bin L/R relationships, though tonal balance changes.
Add dual-mono and mid/side only after linked stereo passes image and mono checks.
Use an explicit reversible M/S normalization and test it with no attenuation.
For monitoring, define the signal taps precisely:
```text
delta = delayed_dry - wet_before_output_trim
mixed = (1-mix) * delayed_dry + mix * wet
```
Use a linear dry/wet law so identical dry/wet signals remain unity at all mix
positions. Bypass keeps the same delay and crossfades to dry. Delta bypasses makeup
gain and loudness matching so the subtraction remains interpretable. It reveals
changed material, including phase/time changes; it is not proof that everything
heard there was unwanted. Restore normal monitoring when loading a preset.
## 6. Resolution, latency, and phase
For a length-`N` transform, nominal bin spacing is `sample_rate/N`. This is not
the full practical ability to distinguish neighboring tones: the window matters.
Zero-padding adds interpolated spectral samples, not more observation time.
Calculated examples at **48 kHz**, with `H=N/4`:
| FFT length | Observation length | Bin spacing | Hop duration |
| --- | --- | --- | --- |
| 1024 | 21.33 ms | 46.875 Hz | 5.33 ms |
| 2048 | 42.67 ms | 23.438 Hz | 10.67 ms |
| 4096 | 85.33 ms | 11.719 Hz | 21.33 ms |
| 8192 | 170.67 ms | 5.859 Hz | 42.67 ms |
These are calculations, not measured plugin latencies. A causal implementation's
actual buffering schedule determines its reported delay. In the repository's
pinned NIH-plug revision, `StftHelper::latency_samples()` returns its active
window length. With that helper, `N=2048` reports 2048 samples, not `N/2` or `H`.
Report the actual helper delay to the host and verify it with an impulse.
[Pinned STFT helper source](https://github.com/robbert-vdh/nih-plug/blob/de421011f41a6d10fc8c7a6084e4f4dee0143683/src/util/stft.rs).
Start with one fixed configuration: `N=2048`, `H=512` at 44.1/48 kHz. Consider
doubling both at 88.2/96 kHz to retain similar time/frequency behavior. This choice
is primarily for mid/high resonance work; it is not precise sub-bass surgery.
Increasing FFT length while retaining 75% overlap also increases time between
updates. Greater overlap can improve update rate at extra CPU cost.
Do not advertise zero latency for this first engine. Keep quality changes out
of ordinary automation initially. Later mode switches need state transitions,
host latency updates, and aligned crossfades or a controlled processing restart.
Offline rendering should initially use the same DSP configuration as playback.
Linear-phase-style spectral shaping can ring before transients. Minimum-phase
shaping changes phase and can complicate parallel blending. Neither removes the
time/frequency tradeoff. A future minimum-phase option needs its own validated
filter construction, not just a label on the existing gain surface.
## 7. Protection features worth researching next
| Feature | Candidate approach | Failure to evaluate |
| --- | --- | --- |
| Transient protection | Positive spectral flux or fast/slow energy comparison reduces suppression briefly; separate onset and tail | Missing a short painful peak, or ducking protection too often on distorted bass |
| Harmonic protection | Confidence-weighted fundamental/harmonic evidence relaxes reduction | Polyphonic, inharmonic, noisy, or rapidly changing tones; a harmful ring may itself be pitched |
| Learn | Aggregate prominence statistics over a representative passage and propose editable focus points | Learning the hook as a defect or treating one passage as the entire song |
| Fixed protect regions | User draws a smooth exclusion around the bass body or snare | Protecting a problem along with the desired tone |
| External spectral ducking | Derive a smooth masking/priority curve from a routed reference | Removing too much accompaniment, noisy sidechains, missing input routing |
For a first protection feature, prefer an optional transient guard with a visible
effect and an off switch. Explicit protect regions are a more dependable initial
interface than promising perfect automatic harmonic preservation.
If Learn is added, keep the result editable and undoable; show exactly which
parameters changed. It should suggest where to investigate rather than declare
that detected peaks are mistakes.
## 8. Professional workflow
1. Insert on the problem source or bus and loop the offending passage in context.
2. Restrict the processing range; protect the source's body or deliberate vowel.
3. Raise Amount until the problem is clearly affected, then back off. Use an
explicit modest Max cut as an initial experiment, not a universal preset.
4. Listen to delta briefly. If it carries the melody, vowel, or whole snare attack,
reassess what is being removed. This is a diagnostic, not an automatic verdict.
5. Adjust sensitivity/detail and timing. Check the quieter phrase and the next
note as well as the loudest part.
6. Compare bypass and processed sound at similar loudness in the whole mix.
7. Save A/B states and automation only when the choice improves the musical role.
Useful starting tasks: upper-mid whistle on a growl, a moving metallic partial,
snare-tail ring, a bright melodic layer masking another part, and harsh buildup
on a bass bus. Evaluate these separately: one universal setting is unlikely.
The first interface should expose Amount, Sensitivity, Detail, Max cut, Attack,
Release, frequency limits, linked stereo, Mix, output trim, Delta, and Bypass.
Show input spectrum and actual reduction as distinct traces, with a fixed
logarithmic frequency axis and readable reduction units. The UI should distinguish
frequency sensitivity from static EQ gain.
Loudness matching can initially be a manual output-trim workflow. A later automatic
comparison feature should use bounded, slow compensation with silence handling;
avoid makeup gain that disguises excessive removal or boosts between phrases.
## 9. Rust integration in this repository
Suggested new components, without modifying the musical generator's DSP:
```text
crates/resonance-core/ detector, gain law, smoothing, fixed DSP state
crates/resonance-render/ offline WAV processing and measurement harness
plugins/resonance-compressor/ independent NIH-plug CLAP effect + editor
```
Reuse the existing pinned NIH-plug wrapper strategy and packaging conventions,
but register a new plugin identifier and an **audio effect** layout with mono and
stereo input/output. Do not inherit the generator's instrument classification,
MIDI scheduling, or sound-generation path. External audio sidechain is a later
optional port with explicit host-routing tests.
Candidate FFT implementation: `realfft` backed by `rustfft`. Plan transforms and
allocate all ring buffers, spectra, smoothing state, neighbor maps, and scratch
storage during initialization. RustFFT's convenient `process()` allocates scratch;
use the supplied-scratch methods in the processing path and query their required
sizes during setup.
[RustFFT API](https://docs.rs/rustfft/latest/rustfft/trait.Fft.html).
Additional engineering requirements:
- No heap allocation/deallocation, blocking locks, file I/O, or FFT planning in
the audio callback; prevent an overwritten shared buffer from freeing there.
- Bound detector work. A naive all-bin neighborhood scan is quadratic; use
precomputed bounded neighborhoods, running sums, or a coarser detector grid.
- Process arbitrary host buffer lengths through the ring-buffer scheduler.
- Smooth automatable parameters; keep latency-changing parameters nonautomatable
until the transition contract is implemented.
- Decimate analyzer data into preallocated UI buffers; rendering must not drive
processing or stall the callback.
- Reset histories and delay buffers consistently on reinitialization. Bound and
version state; default to normal monitoring, never unexpected delta playback.
- Sanitize invalid numerical state and handle silence/denormals without noise
bursts. Flush the delayed tail correctly in offline rendering.
### Existing source-code reference and licensing distinction
NIH-plug itself has an ISC license, but its **Spectral Compressor example is
GPL-3.0-or-later**, including in the locally cached revision used by this project.
The example's manifest and source header were checked. Treat it as a reference;
do not assume the framework license applies to every bundled plugin or copy that
implementation into an ISC-labeled plugin without resolving the differing terms.
[Framework license](https://github.com/robbert-vdh/nih-plug/blob/master/LICENSE),
[example manifest](https://github.com/robbert-vdh/nih-plug/blob/master/plugins/spectral_compressor/Cargo.toml).
## 10. Validation before calling it useful
### Deterministic DSP checks
| Check | Required observation |
| --- | --- |
| Unity reconstruction | With attenuation disabled, null against delayed input within a declared floating-point tolerance, including startup/tail |
| Reported latency | Impulse delay agrees with host-reported samples at each supported sample rate |
| Dry/mix/bypass | Unity response at every Mix position when wet equals dry; crossfades and delta use aligned taps |
| Synthetic narrow ring | A gated tone over a shaped noise bed triggers localized reduction with predictable timing |
| Sweeping tone | No unstable hopping, clicks, or unexpectedly wide removal as a peak crosses bins |
| Harmonic stack | Fundamentals and intentional harmonics reveal the detector's false-positive behavior |
| Silence and fades | No gain bursts, NaNs, denormal CPU spikes, or stale reduction after silence |
| Gain scaling | Relative mode behaves similarly above its gate/floor after input-level changes |
| Stereo | In-phase, opposite-polarity, hard-panned, and decorrelated inputs retain expected linking behavior |
| Parameter motion | Extreme valid automation does not click, allocate, or exceed the defined attenuation cap |
| Block invariance | Equivalent stream output for fixed, tiny, irregular, and changing host buffer sizes |
| Real-time performance | Allocation counter stays zero during processing; worst-frame CPU tested with many instances |
Measure the gain-surface bound separately from output sample/true-peak behavior.
Use swept and stepped resonances to measure attack, release, and detector lag;
steady-state plots alone are insufficient. Benchmark on named hardware with
stereo, sample rate, FFT size, overlap, host buffer size, and instance count stated.
### Listening evaluation
Use producer-owned dry stems, synthetic controls, and several source categories.
For each, compare bypass, a manual EQ/dynamic-EQ correction, our output, and
commercial references only when legitimately available. Randomize labels and
match listening level before judging. Include cases where bypass is preferred.
Ask concrete questions: Is the whistle less distracting? Does the vowel still
move? Does the snare retain impact? Does the melodic layer retain its identity?
Can a producer achieve the result faster? Repeat on the complete arrangement.
Record dulling, loss of body, pre-ringing, warbling, transient smear, image changes,
and the amount of useful adjustment. A visually flatter spectrum and a larger
reduction number are not success metrics.
### FL Studio acceptance
Verify discovery as an effect, insert routing, latency compensation, parallel
mixing, automation, save/reopen, sample-rate changes, render tails, and repeated
instance loading. Test mono/stereo channel layouts and multiple buffer sizes.
When sidechain support arrives, test absent/connected/changed routes explicitly.
Do not infer host compatibility solely from a successful Rust build.
## 11. Implementation sequence and unresolved decisions
1. **Reconstruction milestone:** offline identity processor, impulse/latency tests,
ring-buffer scheduling, and allocation checks. No suppression UI yet.
2. **Detector milestone:** relative prominence, explicit range/protection, knee,
separate Amount/Max cut, linked stereo, gain smoothing, and offline comparisons.
3. **Usable effect milestone:** CLAP audio wrapper, stable parameters and state,
analyzer/reduction display, delta, aligned bypass/mix, and FL Studio validation.
4. **Musical evaluation milestone:** transient guard, producer A/B sessions, tuned
defaults, CPU measurements, and a documented set of failures.
5. **Only then:** learn, harmonic evidence, sidechain ducking, additional quality
modes, minimum-phase filtering, or a separate low-latency engine.
Open decisions require prototypes: the local baseline statistic and bandwidth,
detector grid, detail mapping, smoothing order, frequency-scaled timing, and the
acceptable CPU/latency envelope. No claim of matching Quance or Soothe is justified
until repeatable comparisons establish which tasks the new plugin handles well.
The most valuable first outcome is a reliable, understandable suppressor that
helps the producer keep a sound's character while making a specific annoyance
easier to control.
## 12. First implementation
Version 0.1 uses a complex RustFFT engine with preallocated scratch storage.
The initial mixing configuration is 4096 samples with a 512-sample hop at
44.1/48 kHz, scaling both for higher supported rates. This supersedes the
smaller experimental configuration proposed above. It implements linked stereo,
relative prominence, soft-knee bounded reduction, explicit focus/protect controls,
transient guard, aligned monitoring, and the full visible engineer panel.
The original research scope and unverified listening hypotheses still apply.
The effect is independently implemented. The commercial references were not
reverse engineered. Tests establish reconstruction and scheduling behavior;
FL Studio acceptance and producer listening remain separate requirements.
See the [Patina guide](docs/patina/README.md) for the implemented effect.
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "story-preview"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
story-core.workspace = true
story-midi.workspace = true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 KiB

+93
View File
@@ -0,0 +1,93 @@
# Development
Run commands from the repository root. Rust stable and Python **3.11 or newer**
are required. Lockfiles are committed for the core workspace, both CLAP wrappers,
and the headless editor tool.
## Product boundaries
The catalog in [plugins/catalog.json](../plugins/catalog.json) defines product
names, wrapper manifests, documentation, internal packages, and distribution
licenses. Shared build/check/release tools consume it. Each wrapper's Cargo
package version is the authoritative release version.
The root workspace contains Rivet's core, MIDI, application, and offline preview
crates, plus Patina's core, editor, and renderer. Wrappers under
`plugins/<product>/plugin/` remain independent workspaces because they depend on
a pinned NIH-plug Git revision. Their host identities and persistence stay separate.
## Checks
```sh
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
python scripts/check-plugin.py --plugin rivet
python scripts/check-plugin.py --plugin patina
python -m unittest discover -s scripts/tests -v
```
The product check script checks formatting, tests, and lints that product's
packages and wrapper without compiling the other plugin. Offline builds work
once dependencies are cached; append `--offline` to manual Cargo commands.
Native wrapper tests on Linux require pkg-config, X11/XCB, and OpenGL development
headers. Rivet's optional standalone also needs ALSA/JACK headers. Gitea installs
the headers needed by its check jobs.
## Windows builds
On Windows, install Rust and the MSVC build tools. For Linux cross-compilation,
install the Rust `x86_64-pc-windows-gnu` target and MinGW's
`x86_64-w64-mingw32-gcc`.
```sh
python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
python scripts/package-windows.py
python scripts/package-windows.py --plugin rivet
python scripts/package-windows.py --plugin patina
```
Build outputs use `dist/<target>/Rivet.clap` and `Patina.clap`, with docs and
licenses retaining their directory structure. `CARGO_TARGET_DIR` is supported
for sharing compilation between wrappers. MSVC packaging accepts
`--target x86_64-pc-windows-msvc`.
The combined ZIP is `dist/rivet-patina-windows-x86_64.zip`. Individual ZIPs use
`<product>-windows-x86_64-<version>.zip`. Each ZIP has internal file checksums and
an external `.zip.sha256`. ZIP metadata is deterministic; binaries still depend
on the toolchain and build environment. Packaging includes only selected catalog
entries, never old binaries left in `dist`.
Linux builds remain available for local development; CI produces Windows release
binaries only. Neither build nor packaging installs plugins into a DAW.
## Editor previews
```sh
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet.png --plugin rivet
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/rivet-creative.png --plugin rivet --creative
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina.png --plugin patina
cargo run --manifest-path tools/editor-check/Cargo.toml -- /tmp/patina-compact.png --plugin patina --compact
```
Patina also accepts `--silent`, `--delta`, and `--bypass` as render fixtures.
The tool compiles actual editor code and software-renders without a display.
These checks do not establish native host embedding, keyboard routing, or audible
artistic quality. Use the product guides for FL Studio acceptance checks.
## Compatibility and naming
| Product | Former display name | Preserved CLAP ID |
| --- | --- | --- |
| Rivet | Motif Storyboard | `io.storyboard.motif` |
| Patina | Resonance Compressor | `io.storyboard.resonance-compressor` |
Rivet retains `story-session-v1` and its session migration support; Patina retains
all parameter IDs. No JSON/state migration is introduced by the rename. These
historical identifiers and copyright notices intentionally remain in source.
Replace old scanned plugin files before rescanning, rather than keeping duplicate
CLAP IDs installed. Verify existing-project recall in FL Studio.
See [release workflow](releases.md) for tagged publishing. Actual Gitea runner
execution and FL Studio validation remain separate from local checks.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 KiB

+10
View File
@@ -0,0 +1,10 @@
# Patina changelog
## 0.1.0
- Introduce Patina, formerly Resonance Compressor, as an independent mixing effect.
- Control spectral prominence with bounded reduction, timing, and transient guard.
- Shape focus/protect regions and compare aligned Delta, Mix, Bypass, and A/B.
- Add the dark native engineer panel with cyan, coral, and violet spectrum layers.
- Preserve the original CLAP identity and parameter IDs.
- Provide independent Windows release packages.
@@ -1,17 +1,20 @@
# Resonance Compressor 0.1
# Patina
Patina smooths a sound’s rough surface without erasing its character. Formerly
**Resonance Compressor**; current version **0.1.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.
![Resonance Compressor](resonance-editor.png)
![Patina](assets/editor.png)
## Install on Windows
Download the Windows artifact from the GitLab pipeline's **windows** job and
extract the ZIP. Copy `Resonance Compressor.clap` into
Download the Windows artifact from the Gitea plugin's Gitea release, or the branch pipeline's **windows** job and
extract the ZIP. Copy `Patina.clap` into
`C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1 or newer, open
Options → Manage plugins, enable Verify plugins, and Find installed plugins.
Load Resonance Compressor in a mixer effect slot. Keep the font license with
Load Patina in a mixer effect slot. Keep the font license with
redistributed packages.
## Work with it
@@ -38,8 +41,8 @@ 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.
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.
## Timing and limits
@@ -58,11 +61,11 @@ unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1
```sh
cargo test --workspace --locked
cargo run --release -p resonance-render -- input.wav processed.wav
cargo run --release -p resonance-render -- input.wav delta.wav --delta
cargo run --release -p resonance-render -- --demo /tmp/resonance-demo.wav
cargo run --release -p resonance-render -- --benchmark
python scripts/build-plugin.py --plugin resonance-compressor --target x86_64-pc-windows-gnu
cargo run --release -p patina-render -- input.wav processed.wav
cargo run --release -p patina-render -- input.wav delta.wav --delta
cargo run --release -p patina-render -- --demo /tmp/patina-demo.wav
cargo run --release -p patina-render -- --benchmark
python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu
```
The renderer accepts mono/stereo PCM16/24/32 and float32 RIFF/WAVE, capped at
@@ -72,12 +75,11 @@ An optional settings JSON uses the core's Settings structure.
## CI and acceptance
The root GitLab pipeline runs checks in a Linux Docker executor and cross-builds
**Windows binaries only** using MinGW. It needs an available untagged Docker
runner, outbound access to Debian/Rust/Cargo/Git dependencies, and no Windows
runner. The Windows job produces both CLAPs, setup guides, licenses, checksums,
and a ZIP. Runner execution must be verified on the actual GitLab instance;
local verification is not a remote pipeline run.
Gitea Actions cross-builds Windows binaries with MinGW on a Docker-capable
runner labelled `ubuntu-latest`. Branch/PR workflows build a combined preview
bundle; `patina-v<version>` tags check, build, and publish Patina independently.
See [release workflow](../releases.md) for runner setup and release attachments.
Actual runner execution remains to be verified on the Gitea instance.
Before calling host support complete, test FL Studio discovery as an effect,
automation, plugin delay compensation, parallel dry/wet alignment, repeated loading,
@@ -89,3 +91,15 @@ 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.
## 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
rescan; avoid two scanned copies with the same ID. Host project recall still
needs verification in FL Studio.
Source is grouped into `plugins/patina/{core,ui,render,plugin}`. See
[research](research.md), [development](../development.md), and the
[independent release workflow](../releases.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

+555
View File
@@ -0,0 +1,555 @@
# Patina: resonance-compressor research and design
Research date: **2026-11-05**. This document preserves the research and design
proposal. The first implementation is now in the repository; see the
[implementation and setup guide](README.md).
## 1. What we want to build
A Rust audio effect for FL Studio that reduces objectionable spectral peaks
while retaining the source's intended tone, movement, and punch. The conventional
name for this category is **dynamic resonance suppressor**. “Resonance compressor”
is a useful working name: gain reduction varies with both frequency and time.
The main application here is aggressive, harmonically rich riddim sound design,
with melodic parts and full mixes as additional evaluation material. A useful
result is a bass that keeps its vowel, metallic edge, and rhythm while a painful
ring becomes less distracting. Making every spectrum smooth is not the goal.
The recommendation is an independently designed **STFT spectral attenuation
engine**, first delivered as a separate CLAP effect. Start with explicit controls,
conservative reduction, linked stereo, delta monitoring, and correct host latency.
Add transient protection after validating the basic detector. Defer automatic
harmonic protection, multiple resolution engines, and zero-latency processing.
Throughout this document:
- **Documented behavior** means a manufacturer, paper, or source code describes it.
- **Proposed design** means our engineering choice, not a disclosed commercial
algorithm or a claim of equivalent sound quality.
- **Listening hypothesis** means something to test with producers.
No commercial binaries were reverse engineered or benchmarked. Product claims
below were researched from public primary sources; they are not listening results.
## 2. What the reference tools actually offer
### Quance — CodWaves
This is the product referred to as “quance.” At the research date its official
page lists **€49.99 launch pricing**, a crossed-out **€79.99** price, and a sale
ending November 30, 2026. These are temporary advertised prices, not a guaranteed
future checkout price. Formats listed are VST3, AU, and AAX for Windows/macOS.
Its useful workflow references include editable tame/protect regions, learn,
tone/transient protection, delta monitoring, and multiple processing grades.
The developer describes both multi-resolution studio processing and a separate
causal filter-bank audio path for zero added sample latency.
[Official product page](https://codwaves.com/quance/).
The manual describes a detector based on neighboring spectral content and an
adaptive longer-term reference. Reduction uses a soft knee and temporal
smoothing. Its zero-latency audio path still uses an STFT for detection: detection
therefore reacts late even though audio is not delayed. This distinction is
important for our design. The manual also documents stereo linking, dry alignment,
and an optional reduction ceiling. These descriptions do not disclose all tuning
or implementation details.
[Quance manual, especially §§9 and 13](https://codwaves.com/assets/Quance-User-Manual.pdf).
### Soothe2 — oeksound
Soothe2 documents adaptive resonance reduction with frequency-dependent attack
and release. Sharpness controls the shape of cuts; selectivity controls which
resonances qualify. A frequency curve steers sensitivity rather than applying
ordinary static EQ. Delta, external sidechain detection, stereo options, and
quality controls support contextual audition. The manual warns that extreme
settings can damage the sound.
Design lesson: keep **where to work**, **what qualifies**, **how deeply to cut**,
and **how quickly to react** understandable as separate decisions. This is an
interpretation of the workflow, not a reconstruction of Soothe2's DSP.
[Official Soothe2 manual](https://oeksound.com/manuals/soothe2/).
### Soothe3 — the current oeksound reference
Research should include Soothe3 rather than treating Soothe2 as the newest
version. Its manual describes a level-adaptive soft mode and an absolute-level
hard mode, a Detail control, maximum-cut limiting, and minimum-phase processing
by default with a linear-phase option. It also documents frequency-dependent
timing adjustments. These are useful references for control design and phase
tradeoffs; our first STFT processor would not reproduce these modes.
[Official Soothe3 manual](https://oeksound.com/manuals/soothe3/).
Oeksound's product listing currently advertises **$259** for Soothe3. Price alone
does not establish comparative value or sound quality.
[Official product listing](https://oeksound.com/plugins/).
### Useful lower-cost comparison points
| Tool | Why compare it | What the comparison does not establish |
| --- | --- | --- |
| TDR Nova | Free parallel dynamic EQ with four dynamic bands: a practical manual corrective baseline | It is not the same workflow as automatic dense resonance suppression |
| NIH-plug Spectral Compressor | Public Rust example of FFT-domain dynamics and sidechain spectral processing | Many-band compression is not automatically a resonance detector |
| Static parametric EQ | Tests whether a simple fixed cut solves the problem adequately | A changing resonance may need different treatment over time |
Sources: [Tokyo Dawn's Nova page](https://www.tokyodawn.net/tdr-nova/),
[Spectral Compressor's author README](https://github.com/robbert-vdh/nih-plug/blob/master/plugins/spectral_compressor/README.md).
We should compare output against bypass and a sensible manual correction before
claiming an advantage over either commercial reference.
## 3. The perceptual problem
### A peak is observable; an unwanted peak is a judgment
A spectral peak can be a fundamental, a formant, an instrument resonance, a room
mode, deliberate filter emphasis, or an analysis artifact. Height alone cannot
classify it as a defect. A stable peak may be the exact feature that makes a bass
recognizable. Noise-like sibilance can be objectionable without a narrow peak.
For this project, use **excess relative to a local spectral reference** as an
operational detection rule, and expose decisions to the producer. It is not a
physical measurement of the source's resonator and not an emotion detector.
### Frequency neighborhoods should reflect listening, not just FFT indices
Human auditory filtering provides a reason to explore perceptual frequency
coordinates. Glasberg and Moore's research derives auditory-filter shapes from
notched-noise experiments; its scope is hearing, not a universal rule for desirable
EQ. An ERB-spaced or logarithmic neighborhood is a useful engineering hypothesis.
It should not be marketed as a complete model of perception.
[Glasberg and Moore, 1990](https://pubmed.ncbi.nlm.nih.gov/2228789/).
### Relevant research on automatic resonance equalization
Grachten, Deruty, and Tanguy describe a spectral resonance attenuator separately
from a neural network that predicts attenuation strength. Their detector compares
a smoothed spectral reference with a perceptually weighted spectrum on a
log-frequency representation. Their listening experiment involved 15 professional
participants and 150 excerpts, with substantial variation in preferred treatment.
This supports trying a transparent, non-neural detector first and evaluating
preferred strength through listening. It does not demonstrate equivalence to
Soothe, success on riddim stems, or that their long analysis windows suit live
monitoring. Use the explicitly linked **2018 v1** when reproducing this account;
the unversioned arXiv record has subsequently changed.
[Paper, v1, §§2–3](https://arxiv.org/pdf/1807.08636v1).
Corbach and colleagues study automated room-resonance suppression using measured
room behavior and parametric filtering. It is relevant to targeted filters, but
room-response identification is a different problem from deciding which peaks
to remove from an arbitrary musical recording.
[DAFx-09 paper](https://dafx.de/paper-archive/2009/papers/paper_28.pdf).
### Consequences for riddim and melodic bass
The following are project-specific listening hypotheses, not findings established
by the cited papers:
- A resonant vowel sweep may be the musical gesture. Protect its body and reduce
only a distracting upper whistle instead of flattening the entire sweep.
- Distortion and multiband dynamics can change which partial dominates from note
to note. Evaluate after the intended processing chain as well as before it.
- A repeated metallic resonance can function as a hook. Persistence alone is
insufficient evidence that it should be removed.
- A snare's initial crack and its later ring need different treatment. Preserving
the attack while controlling the tail may retain impact.
- Cleaning a soloed bass can reduce its ability to cut through the mix. Make final
decisions against the drums and melodic context, at comparable listening level.
The Devath reference work in this repository measured broad signal changes. It
did not establish which resonances were intentional or identify an ideal correction.
Use original stems and controlled synthetic examples for development; full mastered
references cannot isolate cause or provide a clean target.
## 4. Architecture choices
| Approach | Advantages | Main difficulties | Recommendation |
| --- | --- | --- | --- |
| STFT → gain surface → inverse STFT | Dense frequency control; straightforward delta and analysis; testable reconstruction | Window latency, time/frequency tradeoff, ringing and modulation artifacts | First implementation |
| Detector → a few tracked dynamic notches | Economical audio path; can target persistent rings | Peak tracking, filter stability, crossing peaks, phase movement | Later experiment |
| Dense causal filter bank | Audio path can have no added buffering delay | Finite band selectivity, detector lag, interacting filters, moving phase | Separate live-mode project |
| Multi-resolution spectral processing | Longer low-frequency observation and shorter high-frequency observation | Crossovers, alignment, phase, extra CPU and more validation | After single-resolution baseline |
| Learned detector or parameter estimator | Could model preferences or source categories | Dataset, labels, domain shift, inference cost, explainability | Defer |
These are engineering tradeoffs. Zero audio delay does not imply instantaneous
detection. Minimum phase does not imply no phase change. Retaining FFT phase does
not make a time-varying processor equivalent to one fixed linear-phase filter.
## 5. Proposed first DSP pipeline
```text
main audio ── analysis window / FFT ──────────────── complex-bin gains
│ │
└─ detector spectrum inverse FFT
local reference synthesis window
prominence overlap-add
sensitivity mask │
knee + strength + cap wet audio
frequency/time smoothing │
└───────────────────────────────┘
main audio ── matching delay ── dry ── mix / delta / bypass ── output trim
```
This is our proposed baseline, not either manufacturer's internal block diagram.
An external sidechain can later replace the detector input without replacing the
main audio being filtered. Spectral ducking needs its own detection semantics;
simply feeding a resonance-only detector with a sidechain will not duck all its
musically relevant energy.
### 5.1 Reconstruction before suppression
Implement weighted overlap-add (WOLA), then demonstrate a unity-gain null against
the delayed input before adding detection. With analysis window `wa`, synthesis
window `ws`, and hop `H`, reconstruction requires a constant window-product sum:
```text
sum_m wa[n - mH] * ws[n - mH] = C
```
Normalize by `C`, or use a corresponding reconstruction-normalization buffer.
Use a compatible periodic window/hop pair and explicitly test stream startup,
tail flushing, and arbitrary host block boundaries. The analysis window alone
is not sufficient when a synthesis window is also applied.
[Julius O. Smith, Weighted Overlap Add](https://ccrma.stanford.edu/~jos/sasp/Weighted_Overlap_Add.html).
For the prototype, try a periodic square-root Hann pair with `H = N/4` and
the appropriate product-sum normalization. Use the FFT library's required
round-trip normalization separately. RealFFT does not normalize either transform;
a forward/inverse pair needs a total `1/N` factor.
[RealFFT scaling documentation](https://docs.rs/realfft/latest/realfft/#scaling).
### 5.2 Detector and local reference
The following equations define an original experimental detector. They are
starting points to measure and tune, not validated optimal settings.
For channel `c`, frame `t`, bin `k`:
```text
P[c,t,k] = calibrated_power(|X[c,t,k]|²)
L[c,t,k] = 10 * log10(max(P[c,t,k], epsilon))
B[c,t,k] = robust_local_reference(L[c,t,*], frequency[k], neighborhood_width)
E[c,t,k] = max(0, L[c,t,k] - B[c,t,k])
```
`calibrated_power` must define its units. Correct for FFT size, window gain, and
one-sided spectrum conventions before introducing any absolute-level threshold.
Do not interchange per-bin power, band energy, and power spectral density.
Start with a log-frequency neighborhood and a bounded weighted mean baseline;
compare a median/percentile baseline offline. Excluding a small central region
can keep a strong peak from raising its own reference too much, but can also make
leakage look like multiple resonances. Define edge behavior near DC/Nyquist.
Precompute interpolation maps and neighbor lists outside the audio callback.
Adding a constant gain to the signal shifts both `L` and `B`; their difference is
approximately level-invariant above the numerical floor. That is useful for a
relative mode. An absolute gate, saturation, and noise-floor handling break this
invariance and must be documented and tested.
A smooth spectrum can still sound harsh, while harmonics can have large `E`.
Keep detection range and manual protection available from the first usable build.
Do not promise automatic separation of tone from unwanted resonance.
### 5.3 Independent controls and reduction law
Use these meanings consistently:
- **Sensitivity:** local-prominence threshold `T` in dB.
- **Amount:** slope `a` of attenuation above that threshold.
- **Max cut:** explicit upper bound `Dmax` on the applied attenuation surface.
- **Detail:** neighborhood/reduction width, with the actual mapping documented.
- **Focus/protect curve:** steers sensitivity or reduction; label which it does.
A simple continuous knee function for positive excess `u = E - T`, width `W > 0`:
```text
K(u,W) = 0 if u <= -W/2
(u + W/2)² / (2W) if -W/2 < u < W/2
u if u >= W/2
Dtarget = clamp(a * K(E - T,W), 0, Dmax)
```
For `W = 0`, use `max(0,u)` explicitly. This is a knee on **relative prominence**,
not the complete static transfer curve of an ordinary broadband compressor.
Apply smooth frequency limits and protection weights. Smooth reduction across
frequency to avoid isolated, rapidly appearing holes. Reapply the mask and cap
after operations that could spread or overshoot reduction into excluded regions.
Transition mask edits smoothly rather than abruptly changing gains.
### 5.4 Temporal behavior
Smooth attenuation in dB at the frame rate, with separate attack and release:
```text
alpha = exp(-H / (sample_rate * tau_seconds))
D[t,k] = alpha * D[t-1,k] + (1-alpha) * Dtarget[t,k]
```
Choose attack when the target attenuation increases and release when it decreases.
Handle zero time explicitly. The time constants describe this smoother, not total
response latency: windows, hop, and detector averaging also contribute.
Compare frequency-scaled timing against uniform timing; do not assume one wins.
Very fast changing narrow cuts can produce audible movement and sidebands.
Very slow cuts can miss a short whistle or remain after it disappears. Inspect
step response and note transitions as well as steady-state attenuation.
### 5.5 Apply the gain surface
```text
G[t,k] = 10^(-D[t,k]/20)
Y[t,k] = G[t,k] * X[t,k]
```
Use amplitude gains on complex bins, not power ratios directly. Preserve the
real-spectrum constraints at DC and Nyquist and keep reconstruction scaling
consistent. Initially retain each bin's phase; evaluate ringing and transient
smear honestly. This is not a claimed minimum-phase implementation.
`G <= 1` is a spectral attenuation constraint. It does **not** guarantee that
every output sample peak is lower than the input peak: changing frequency
components can change their time-domain cancellation. This is not a limiter.
### 5.6 Stereo and monitoring
Default to a shared attenuation surface for left and right. One proposed rule is
to compute both channels' target reductions and use their per-bin maximum before
smoothing. This avoids oppositely panned tones canceling in a summed detector.
Identical gains preserve per-bin L/R relationships, though tonal balance changes.
Add dual-mono and mid/side only after linked stereo passes image and mono checks.
Use an explicit reversible M/S normalization and test it with no attenuation.
For monitoring, define the signal taps precisely:
```text
delta = delayed_dry - wet_before_output_trim
mixed = (1-mix) * delayed_dry + mix * wet
```
Use a linear dry/wet law so identical dry/wet signals remain unity at all mix
positions. Bypass keeps the same delay and crossfades to dry. Delta bypasses makeup
gain and loudness matching so the subtraction remains interpretable. It reveals
changed material, including phase/time changes; it is not proof that everything
heard there was unwanted. Restore normal monitoring when loading a preset.
## 6. Resolution, latency, and phase
For a length-`N` transform, nominal bin spacing is `sample_rate/N`. This is not
the full practical ability to distinguish neighboring tones: the window matters.
Zero-padding adds interpolated spectral samples, not more observation time.
Calculated examples at **48 kHz**, with `H=N/4`:
| FFT length | Observation length | Bin spacing | Hop duration |
| --- | --- | --- | --- |
| 1024 | 21.33 ms | 46.875 Hz | 5.33 ms |
| 2048 | 42.67 ms | 23.438 Hz | 10.67 ms |
| 4096 | 85.33 ms | 11.719 Hz | 21.33 ms |
| 8192 | 170.67 ms | 5.859 Hz | 42.67 ms |
These are calculations, not measured plugin latencies. A causal implementation's
actual buffering schedule determines its reported delay. In the repository's
pinned NIH-plug revision, `StftHelper::latency_samples()` returns its active
window length. With that helper, `N=2048` reports 2048 samples, not `N/2` or `H`.
Report the actual helper delay to the host and verify it with an impulse.
[Pinned STFT helper source](https://github.com/robbert-vdh/nih-plug/blob/de421011f41a6d10fc8c7a6084e4f4dee0143683/src/util/stft.rs).
Start with one fixed configuration: `N=2048`, `H=512` at 44.1/48 kHz. Consider
doubling both at 88.2/96 kHz to retain similar time/frequency behavior. This choice
is primarily for mid/high resonance work; it is not precise sub-bass surgery.
Increasing FFT length while retaining 75% overlap also increases time between
updates. Greater overlap can improve update rate at extra CPU cost.
Do not advertise zero latency for this first engine. Keep quality changes out
of ordinary automation initially. Later mode switches need state transitions,
host latency updates, and aligned crossfades or a controlled processing restart.
Offline rendering should initially use the same DSP configuration as playback.
Linear-phase-style spectral shaping can ring before transients. Minimum-phase
shaping changes phase and can complicate parallel blending. Neither removes the
time/frequency tradeoff. A future minimum-phase option needs its own validated
filter construction, not just a label on the existing gain surface.
## 7. Protection features worth researching next
| Feature | Candidate approach | Failure to evaluate |
| --- | --- | --- |
| Transient protection | Positive spectral flux or fast/slow energy comparison reduces suppression briefly; separate onset and tail | Missing a short painful peak, or ducking protection too often on distorted bass |
| Harmonic protection | Confidence-weighted fundamental/harmonic evidence relaxes reduction | Polyphonic, inharmonic, noisy, or rapidly changing tones; a harmful ring may itself be pitched |
| Learn | Aggregate prominence statistics over a representative passage and propose editable focus points | Learning the hook as a defect or treating one passage as the entire song |
| Fixed protect regions | User draws a smooth exclusion around the bass body or snare | Protecting a problem along with the desired tone |
| External spectral ducking | Derive a smooth masking/priority curve from a routed reference | Removing too much accompaniment, noisy sidechains, missing input routing |
For a first protection feature, prefer an optional transient guard with a visible
effect and an off switch. Explicit protect regions are a more dependable initial
interface than promising perfect automatic harmonic preservation.
If Learn is added, keep the result editable and undoable; show exactly which
parameters changed. It should suggest where to investigate rather than declare
that detected peaks are mistakes.
## 8. Professional workflow
1. Insert on the problem source or bus and loop the offending passage in context.
2. Restrict the processing range; protect the source's body or deliberate vowel.
3. Raise Amount until the problem is clearly affected, then back off. Use an
explicit modest Max cut as an initial experiment, not a universal preset.
4. Listen to delta briefly. If it carries the melody, vowel, or whole snare attack,
reassess what is being removed. This is a diagnostic, not an automatic verdict.
5. Adjust sensitivity/detail and timing. Check the quieter phrase and the next
note as well as the loudest part.
6. Compare bypass and processed sound at similar loudness in the whole mix.
7. Save A/B states and automation only when the choice improves the musical role.
Useful starting tasks: upper-mid whistle on a growl, a moving metallic partial,
snare-tail ring, a bright melodic layer masking another part, and harsh buildup
on a bass bus. Evaluate these separately: one universal setting is unlikely.
The first interface should expose Amount, Sensitivity, Detail, Max cut, Attack,
Release, frequency limits, linked stereo, Mix, output trim, Delta, and Bypass.
Show input spectrum and actual reduction as distinct traces, with a fixed
logarithmic frequency axis and readable reduction units. The UI should distinguish
frequency sensitivity from static EQ gain.
Loudness matching can initially be a manual output-trim workflow. A later automatic
comparison feature should use bounded, slow compensation with silence handling;
avoid makeup gain that disguises excessive removal or boosts between phrases.
## 9. Rust integration in this repository
Current components, kept separate from Rivet’s musical generator:
```text
plugins/patina/core/ detector, gain law, smoothing, fixed DSP state
plugins/patina/render/ offline WAV processing and measurement harness
plugins/patina/ui/ shared native editor and font assets
plugins/patina/plugin/ independent NIH-plug CLAP effect wrapper
```
Reuse the existing pinned NIH-plug wrapper strategy and packaging conventions,
but register a new plugin identifier and an **audio effect** layout with mono and
stereo input/output. Do not inherit the generator's instrument classification,
MIDI scheduling, or sound-generation path. External audio sidechain is a later
optional port with explicit host-routing tests.
Candidate FFT implementation: `realfft` backed by `rustfft`. Plan transforms and
allocate all ring buffers, spectra, smoothing state, neighbor maps, and scratch
storage during initialization. RustFFT's convenient `process()` allocates scratch;
use the supplied-scratch methods in the processing path and query their required
sizes during setup.
[RustFFT API](https://docs.rs/rustfft/latest/rustfft/trait.Fft.html).
Additional engineering requirements:
- No heap allocation/deallocation, blocking locks, file I/O, or FFT planning in
the audio callback; prevent an overwritten shared buffer from freeing there.
- Bound detector work. A naive all-bin neighborhood scan is quadratic; use
precomputed bounded neighborhoods, running sums, or a coarser detector grid.
- Process arbitrary host buffer lengths through the ring-buffer scheduler.
- Smooth automatable parameters; keep latency-changing parameters nonautomatable
until the transition contract is implemented.
- Decimate analyzer data into preallocated UI buffers; rendering must not drive
processing or stall the callback.
- Reset histories and delay buffers consistently on reinitialization. Bound and
version state; default to normal monitoring, never unexpected delta playback.
- Sanitize invalid numerical state and handle silence/denormals without noise
bursts. Flush the delayed tail correctly in offline rendering.
### Existing source-code reference and licensing distinction
NIH-plug itself has an ISC license, but its **Spectral Compressor example is
GPL-3.0-or-later**, including in the locally cached revision used by this project.
The example's manifest and source header were checked. Treat it as a reference;
do not assume the framework license applies to every bundled plugin or copy that
implementation into an ISC-labeled plugin without resolving the differing terms.
[Framework license](https://github.com/robbert-vdh/nih-plug/blob/master/LICENSE),
[example manifest](https://github.com/robbert-vdh/nih-plug/blob/master/plugins/spectral_compressor/Cargo.toml).
## 10. Validation before calling it useful
### Deterministic DSP checks
| Check | Required observation |
| --- | --- |
| Unity reconstruction | With attenuation disabled, null against delayed input within a declared floating-point tolerance, including startup/tail |
| Reported latency | Impulse delay agrees with host-reported samples at each supported sample rate |
| Dry/mix/bypass | Unity response at every Mix position when wet equals dry; crossfades and delta use aligned taps |
| Synthetic narrow ring | A gated tone over a shaped noise bed triggers localized reduction with predictable timing |
| Sweeping tone | No unstable hopping, clicks, or unexpectedly wide removal as a peak crosses bins |
| Harmonic stack | Fundamentals and intentional harmonics reveal the detector's false-positive behavior |
| Silence and fades | No gain bursts, NaNs, denormal CPU spikes, or stale reduction after silence |
| Gain scaling | Relative mode behaves similarly above its gate/floor after input-level changes |
| Stereo | In-phase, opposite-polarity, hard-panned, and decorrelated inputs retain expected linking behavior |
| Parameter motion | Extreme valid automation does not click, allocate, or exceed the defined attenuation cap |
| Block invariance | Equivalent stream output for fixed, tiny, irregular, and changing host buffer sizes |
| Real-time performance | Allocation counter stays zero during processing; worst-frame CPU tested with many instances |
Measure the gain-surface bound separately from output sample/true-peak behavior.
Use swept and stepped resonances to measure attack, release, and detector lag;
steady-state plots alone are insufficient. Benchmark on named hardware with
stereo, sample rate, FFT size, overlap, host buffer size, and instance count stated.
### Listening evaluation
Use producer-owned dry stems, synthetic controls, and several source categories.
For each, compare bypass, a manual EQ/dynamic-EQ correction, our output, and
commercial references only when legitimately available. Randomize labels and
match listening level before judging. Include cases where bypass is preferred.
Ask concrete questions: Is the whistle less distracting? Does the vowel still
move? Does the snare retain impact? Does the melodic layer retain its identity?
Can a producer achieve the result faster? Repeat on the complete arrangement.
Record dulling, loss of body, pre-ringing, warbling, transient smear, image changes,
and the amount of useful adjustment. A visually flatter spectrum and a larger
reduction number are not success metrics.
### FL Studio acceptance
Verify discovery as an effect, insert routing, latency compensation, parallel
mixing, automation, save/reopen, sample-rate changes, render tails, and repeated
instance loading. Test mono/stereo channel layouts and multiple buffer sizes.
When sidechain support arrives, test absent/connected/changed routes explicitly.
Do not infer host compatibility solely from a successful Rust build.
## 11. Implementation sequence and unresolved decisions
1. **Reconstruction milestone:** offline identity processor, impulse/latency tests,
ring-buffer scheduling, and allocation checks. No suppression UI yet.
2. **Detector milestone:** relative prominence, explicit range/protection, knee,
separate Amount/Max cut, linked stereo, gain smoothing, and offline comparisons.
3. **Usable effect milestone:** CLAP audio wrapper, stable parameters and state,
analyzer/reduction display, delta, aligned bypass/mix, and FL Studio validation.
4. **Musical evaluation milestone:** transient guard, producer A/B sessions, tuned
defaults, CPU measurements, and a documented set of failures.
5. **Only then:** learn, harmonic evidence, sidechain ducking, additional quality
modes, minimum-phase filtering, or a separate low-latency engine.
Open decisions require prototypes: the local baseline statistic and bandwidth,
detector grid, detail mapping, smoothing order, frequency-scaled timing, and the
acceptable CPU/latency envelope. No claim of matching Quance or Soothe is justified
until repeatable comparisons establish which tasks the new plugin handles well.
The most valuable first outcome is a reliable, understandable suppressor that
helps the producer keep a sound's character while making a specific annoyance
easier to control.
## 12. First implementation
Version 0.1 uses a complex RustFFT engine with preallocated scratch storage.
The initial mixing configuration is 4096 samples with a 512-sample hop at
44.1/48 kHz, scaling both for higher supported rates. This supersedes the
smaller experimental configuration proposed above. It implements linked stereo,
relative prominence, soft-knee bounded reduction, explicit focus/protect controls,
transient guard, aligned monitoring, and the full visible engineer panel.
The original research scope and unverified listening hypotheses still apply.
The effect is independently implemented. The commercial references were not
reverse engineered. Tests establish reconstruction and scheduling behavior;
FL Studio acceptance and producer listening remain separate requirements.
+94
View File
@@ -0,0 +1,94 @@
# Independent plugin releases on Gitea
Rivet and Patina have independent versions and releases. Workflows live under
`.gitea/workflows/`.
| Tag | Gitea release | Windows download |
| --- | --- | --- |
| `rivet-v0.3.0` | Rivet 0.3.0 | `rivet-windows-x86_64-0.3.0.zip` |
| `patina-v0.1.0` | Patina 0.1.0 | `patina-windows-x86_64-0.1.0.zip` |
## Make a release
1. Update the selected wrapper's `Cargo.toml` version and its
`docs/<product>/CHANGELOG.md`. The wrapper version determines the release
version; internal crate versions can evolve separately.
2. Refresh affected lockfiles, run the product check, and commit/push the change.
3. Tag the intended commit and push that product's tag.
For Rivet:
```sh
python scripts/check-plugin.py --plugin rivet
git tag -a rivet-v0.3.0 -m "Rivet 0.3.0"
git push origin rivet-v0.3.0
```
For Patina:
```sh
python scripts/check-plugin.py --plugin patina
git tag -a patina-v0.1.0 -m "Patina 0.1.0"
git push origin patina-v0.1.0
```
These are instructions; this change does not create tags or publish a release.
Tags must exactly match the selected wrapper version and a changelog section.
Wrong product/version tags fail before building or publishing. Cargo prerelease
versions such as `patina-v0.2.0-rc.1` are supported and marked as prereleases.
## Workflows
**Check and Windows preview** runs on branch pushes, pull requests, and manual
dispatch. It checks both products, renders editor previews, and provides a combined
Windows ZIP as an Actions artifact. It never publishes a release.
**Independent plugin release** runs on `rivet-v*` or `patina-v*` tag pushes:
1. `plan` validates the product/version/changelog and exposes the selected plugin.
2. `build` checks and cross-compiles only that plugin, then packages its Windows
binary, documentation, licenses, internal checksums, and ZIP SHA256 sidecar.
3. `publish` downloads those exact artifacts and creates a Gitea draft release.
It uploads the ZIP and checksum as release attachments, then publishes the
release only after both attachments are present.
Release attachments are independent of the 30-day Actions artifact retention.
The Gitea release page provides the persistent Windows and checksum downloads.
Private repositories keep their usual download access requirements.
## Runner and authentication
Enable Actions in the repository and provide a Docker-capable Gitea runner with
the `ubuntu-latest` label. Jobs use `node:22-bookworm` so JavaScript actions have a
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.
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
token is required. Repository/owner maximum token
permissions must allow release writes. The runner needs access to the Gitea
instance, action repositories, and build dependencies.
References: [Gitea Actions quick start](https://docs.gitea.com/usage/actions/quickstart/),
[job-token permissions](https://docs.gitea.com/usage/actions/token-permissions/),
[release creation API](https://docs.gitea.com/api/next/operations/repo-create-release/),
and [release attachment API](https://docs.gitea.com/api/next/operations/repo-create-release-attachment/).
## Retries
Publication reuses matching attachments, preserves existing release notes, and
repairs incomplete draft uploads. An existing attachment with different bytes is
never replaced. Retry the publish job with its original build artifacts; changing
the compiler or build environment can change a binary even for identical source.
ZIP metadata is deterministic, but that is not a cross-toolchain guarantee.
The release tag must already exist in Gitea; the publisher does not create tags.
The ZIP must contain exactly the selected plugin. Its sidecar and internal file
checksums are verified before any release write.
Choose a plugin's named release/tag rather than assuming the repository's global
latest release refers to both products. Package and API behavior are tested with
fake transports; an actual runner/tag release on this Gitea instance remains to
be verified.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

+9
View File
@@ -0,0 +1,9 @@
# Rivet changelog
## 0.3.0
- Introduce the Rivet name and a dedicated product source/documentation tree.
- Develop motifs into connected phrases with harmony, selective locks, and pins.
- Retain gestures and branching ideas; route two performance roles with CC curves.
- Preserve Motif Storyboard's CLAP identity and existing saved-state formats.
- Provide independent Windows release packages and a shared development bundle.
+149
View File
@@ -0,0 +1,149 @@
# Rivet
Rivet joins short motifs into connected musical phrases, like a fastener joining
individual pieces. Formerly **Motif Storyboard**; current version **0.3.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.
![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.
The opening quotation stays intact. Later sections use displaced entries,
repeated pickups, fragments, subtraction, short/long answers, changed endings,
register contrast, and returns. These are compositional suggestions, not promises
of an emotion or of commercial originality. Different seeds choose structured
alternatives; they do not independently randomize every pitch.
Pins survive task/seed/harmony changes and regeneration. Changing the motif,
its locks, or the phrase length clears pins. Pinned sections override newly
proposed material. Unsafe boundaries in legacy candidates are rejected instead
of truncating sustained notes. Changing generation inputs clears current
proposals while retaining kept work.
## Audition and commit
**Internal voice** provides two simple reference voices. **MIDI output** sends
notes and optional CC curves on the configured channels to routed external synths
and mutes the internal voice. Solo defaults to channel 1.
Looping is optional and enabled for new sessions. When audition is already
running, another selection waits until the next two-bar boundary or current
phrase end. Note-offs precede repeated same-pitch note-ons; stop, replacement,
loop boundaries, reset, restoration, and mode changes release active output notes
on the processing thread. Queued auditions are canceled by Stop.
**Host tempo** follows tempo, with the local BPM as fallback. It does not align
the initial audition to the DAW's song position: start it at the desired point
in the project loop. Host start never initiates audition; host stop silences it.
Exported MIDI is in musical ticks and carries the local BPM as its tempo event.
Native OS MIDI drag-out is not implemented; export files to commit a phrase.
See [FL Studio setup and acceptance checks](fl-studio.md).
Open **Gestures, moves, and branches** to save a source or selected two-bar
section with its performance choices. Export/import a gesture library between
projects. Save a branch before experimenting; restore any branch and make a new
child with subtraction, a delayed entry, longer gates, or transposition.
Moves respect pins and locks, reject unsafe overlaps, and report when constraints
leave nothing to change. Libraries and histories each hold up to 16 entries;
remove a gesture or a leaf branch to make room.
Open **Sound roles and expressive gestures** to pass existing notes between two
patches: alternate events, alternate question/answer bars, or divide strong beats
from replies. These modes distribute a monophonic phrase rather than generate
a second independent melody. Assign separate MIDI channels and explicitly map
each optional CC lane to a synth macro. Curves are normalized over the whole
phrase, with editable points and rise/fall/arch/pulse starting shapes.
Names describe your intent; the plugin does not infer feelings.
Patch profiles set register offset, gate, velocity, and estimated attack/release.
MIDI audition uses a short warmup for early triggers. Re-audition after a tempo
change to recompile millisecond offsets. Compensation estimates trigger timing;
it does not measure patch latency or model its sound. Default export retains
musical positions; compensated export adds one bar of lead-in. Role tracks carry
names, channels, and CC curves. Stop resets mapped controllers to their chosen
stop values. Internal audition does not reproduce mapped timbre.
See [the theory and producer workflow](theory/README.md).
Sessions save inside the host project and can be saved/loaded as JSON. Version-1/2
sessions and the original host field identifier remain supported; older sessions
retain one-shot audition. New sessions persist harmony, task, lock modes on notes,
pins, proposals, kept history, gestures, branches, patch profiles, CC curves, and
audition settings. JSON state is bounded at 16 MiB; gesture libraries at 2 MiB. File export/save refuses to
silently overwrite an existing file.
## Build and offline preview
Run from the repository root:
```sh
cargo run -p rivet-preview -- demo /tmp/rivet-demo
cargo run -p rivet-preview -- develop motif.mid 0 /tmp/phrase
cargo run -p rivet-preview -- develop motif.mid 0 /tmp/with-chords chords.mid 0
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
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.
## Design and references
[Theory findings and implemented design](theory/README.md) connect the research
to practical controls. [Implementation plan](implementation.md) records the musical and technical
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-midi`: bounded SMF 0/1 import/export. Rejects SMPTE, unsupported meters,
incomplete notes, and timing not exactly representable at 960 PPQ. Reports
ignored controllers/pitch bends. Polyphonic tracks work as harmonic context;
motif import requires monophony.
- `rivet-app`: worker jobs, revision handling, bounded queues, sample-accurate
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,
MIDI round trips, sample timing, looping, queued switches, note releases, and
zero allocation/deallocation during rendering and queued clip replacement. The
CLAP ABI test verifies discovery, note-output port exposure, state restoration,
and silent startup at several sample rates and buffer sizes.
## Existing projects
The name, Cargo packages, source paths, and output filename have changed.
The CLAP ID `io.storyboard.motif`, `story-session-v1` host field, and session
JSON format are preserved. Rescan FL Studio after replacing the old plugin file
with `Rivet.clap`; remove the old copy from scanned folders to avoid duplicate
instances with the same ID. Project recall still needs a check in the target host.
Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

@@ -8,10 +8,10 @@ against FL Studio in this environment.
1. Build with `python scripts/build-plugin.py` on Windows, or obtain a matching
Windows artifact from the configured CI workflow.
2. Copy `Motif Storyboard.clap` to
2. Copy `Rivet.clap` to
`C:\Program Files\Common Files\CLAP`, or a CLAP location scanned by your
FL Studio version. Close existing instances before replacing the binary.
3. In FL Studio's Plugin Manager, rescan plugins and locate Motif Storyboard.
3. In FL Studio's Plugin Manager, rescan plugins and locate Rivet.
Use an FL Studio version with CLAP support. Insert it as a generator.
## Creative workflow
@@ -56,7 +56,7 @@ piano roll. Native MIDI drag-out is outside this release.
## Version 0.2: contextual audition and composition
- Select MIDI output in Motif Storyboard. Where FL Studio exposes wrapper MIDI
- Select MIDI output in Rivet. Where FL Studio exposes wrapper MIDI
ports, set its Output port to an unused port and the target synth's Input port
to the same value; receive notes on channel 1. These are FL Studio's documented
[wrapper input/output port controls](https://www.image-line.com/fl-studio-learning/fl-studio-online-manual/html/plugins/wrapper.htm).
@@ -1,4 +1,4 @@
# Motif Storyboard 0.2 implementation plan
# Rivet 0.2 implementation plan
## Musical intent
Develop a producer's own motif through audible choices about recognition, space,
@@ -4,7 +4,7 @@ Analyzed the seven user-supplied FLACs in this workspace using their actual audi
samples. This is signal analysis of the rendered mixes, not a listening-based
transcription of their melodies or an account of the artist's intent.
![Relative level and spectral attack timelines](reference-analysis/reference-timelines.png)
![Relative level and spectral attack timelines](measurements/reference-timelines.png)
## Observable contrasts
@@ -34,7 +34,7 @@ No source notes or audio were copied into presets or used to train a model.
## Reproduce
```sh
python scripts/analyze-references.py . --output docs/reference-analysis
python scripts/analyze-references.py references/devath --output docs/rivet/research/measurements
```
Requires `ffmpeg`, NumPy, and Matplotlib. The script decodes mono audio at 11,025
@@ -42,7 +42,7 @@ Hz and uses a 2,048-sample Hann window with a 256-sample hop. It summarizes RMS
and positive log-spectral flux per second, normalizing each curve to its own
95th percentile for visibility. Candidate texture changes compare standardized
low/mid/high-band energy over eight-second neighborhoods, with separated peaks.
[Raw summaries](reference-analysis/measurements.json) include track durations and
[Raw summaries](measurements/measurements.json) include track durations and
within-track 80th/20th percentile ratios. Automatic tempo and chord estimates are
intentionally omitted: coarse onset periodicity and spectral peaks are not
reliable transcriptions of these full mixes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 589 KiB

After

Width:  |  Height:  |  Size: 589 KiB

@@ -82,7 +82,7 @@ not become an automatic good/bad ranking.
## Reading the supplied Devath references responsibly
The [local measurements](devath-reference-notes.md) establish changes in mix
The [local measurements](devath.md) establish changes in mix
level, attack activity, and broad spectral texture. For example, HENKA has a
measured reduction around 2:03–2:35 and Phantasy around 1:27–2:23. Petals maintains
more consistent relative activity across much of its earlier duration. These
@@ -1,8 +1,8 @@
# Theory: perceptual identity as a compositional resource
The research and local reference work are consolidated in
[psychoacoustics and creative direction](../psychoacoustics-and-creative-direction.md)
and [Devath measurements](../devath-reference-notes.md). This document connects
[psychoacoustics and creative direction](../research/psychoacoustics.md)
and [Devath measurements](../research/devath.md). This document connects
those findings to implementation choices and their practical limits.
## Findings and their interpretation
@@ -90,6 +90,6 @@ channels/controller export, warmup/loop timing, mapping resets, and allocation-f
rendering. Actual patch perception, FL Studio routing, and producer usefulness
still require listening in the target workflow.
For implementation, see [creative history](../../crates/story-core/src/creative.rs),
[performance scheduling](../../crates/story-core/src/performance.rs), and
For implementation, see [creative history](../../../plugins/rivet/core/src/creative.rs),
[performance scheduling](../../../plugins/rivet/core/src/performance.rs), and
[FL Studio acceptance checks](../fl-studio.md).
+33
View File
@@ -0,0 +1,33 @@
{
"rivet": {
"name": "Rivet",
"description": "Join motifs into connected musical phrases",
"manifest": "plugins/rivet/plugin/Cargo.toml",
"library": "rivet",
"documentation": "docs/rivet",
"standalone": "rivet",
"clap_id": "io.storyboard.motif",
"packages": [
"rivet-core",
"rivet-midi",
"rivet-app",
"rivet-preview"
]
},
"patina": {
"name": "Patina",
"description": "Smooth harsh resonances while retaining character",
"manifest": "plugins/patina/plugin/Cargo.toml",
"library": "patina",
"documentation": "docs/patina",
"clap_id": "io.storyboard.resonance-compressor",
"licenses": {
"plugins/patina/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"patina-core",
"patina-ui",
"patina-render"
]
}
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
nih_plug::prelude::nih_export_standalone::<motif_storyboard::MotifStoryboard>();
}
@@ -1,5 +1,5 @@
[package]
name = "resonance-core"
name = "patina-core"
version = "0.1.0"
edition = "2021"
license = "ISC"
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
use resonance_core::*;
use patina_core::*;
use std::f32::consts::TAU;
fn noise(i: usize) -> f32 {
@@ -1,4 +1,4 @@
use resonance_core::{Engine, Monitor, Settings};
use patina_core::{Engine, Monitor, Settings};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
@@ -1410,6 +1410,35 @@ dependencies = [
"windows-link",
]
[[package]]
name = "patina"
version = "0.1.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
"nih_plug",
"nih_plug_egui",
"patina-core",
"patina-ui",
"serde_json",
]
[[package]]
name = "patina-core"
version = "0.1.0"
dependencies = [
"rustfft",
"serde",
]
[[package]]
name = "patina-ui"
version = "0.1.0"
dependencies = [
"egui",
"patina-core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1561,35 +1590,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "resonance-compressor"
version = "0.1.0"
dependencies = [
"clap-sys",
"crossbeam-queue",
"nih_plug",
"nih_plug_egui",
"resonance-core",
"resonance-ui",
"serde_json",
]
[[package]]
name = "resonance-core"
version = "0.1.0"
dependencies = [
"rustfft",
"serde",
]
[[package]]
name = "resonance-ui"
version = "0.1.0"
dependencies = [
"egui",
"resonance-core",
]
[[package]]
name = "rustc-demangle"
version = "0.1.28"
@@ -1,5 +1,5 @@
[package]
name = "resonance-compressor"
name = "patina"
version = "0.1.0"
edition = "2021"
license = "ISC"
@@ -12,8 +12,8 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
resonance-core = { path = "../../crates/resonance-core" }
resonance-ui = { path = "../../crates/resonance-ui" }
patina-core = { path = "../core" }
patina-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
@@ -3,8 +3,8 @@ mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{values, ResonanceParams};
use resonance_core::{AnalyzerFrame, Engine, Monitor};
use params::{values, PatinaParams};
use patina_core::{AnalyzerFrame, Engine, Monitor};
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
@@ -15,17 +15,17 @@ struct Shared {
delta: AtomicBool,
reset: AtomicU64,
}
pub struct ResonanceCompressor {
params: Arc<ResonanceParams>,
pub struct Patina {
params: Arc<PatinaParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for ResonanceCompressor {
impl Default for Patina {
fn default() -> Self {
Self {
params: Arc::new(ResonanceParams::default()),
params: Arc::new(PatinaParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(3),
delta: AtomicBool::new(false),
@@ -37,9 +37,9 @@ impl Default for ResonanceCompressor {
}
}
}
impl Plugin for ResonanceCompressor {
const NAME: &'static str = "Resonance Compressor";
const VENDOR: &'static str = "Storyboard";
impl Plugin for Patina {
const NAME: &'static str = "Patina";
const VENDOR: &'static str = "Rivet & Patina";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
@@ -66,10 +66,10 @@ impl Plugin for ResonanceCompressor {
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
resonance_ui::State::default(),
patina_ui::State::default(),
|ctx, state| {
state.delta = false;
resonance_ui::style(ctx);
patina_ui::style(ctx);
},
move |ctx, setter, state| {
let epoch = shared.reset.load(Ordering::Acquire);
@@ -87,7 +87,7 @@ impl Plugin for ResonanceCompressor {
nih_plug_egui::resizable_window::ResizableWindow::new("resonance-editor")
.min_size(egui::vec2(1040., 700.))
.show(ctx, &params.editor, |ui| {
resonance_ui::show(ui, &mut edited, state)
patina_ui::show(ui, &mut edited, state)
});
let old = values(&before);
let new = values(&edited);
@@ -186,7 +186,7 @@ fn state_epoch(ctx: &egui::Context) -> u64 {
fn store_epoch(ctx: &egui::Context, e: u64) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("reset-epoch"), e));
}
impl ClapPlugin for ResonanceCompressor {
impl ClapPlugin for Patina {
const CLAP_ID: &'static str = "io.storyboard.resonance-compressor";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Dynamic resonance attenuation with explicit tone protection");
@@ -199,4 +199,4 @@ impl ClapPlugin for ResonanceCompressor {
ClapFeature::Mono,
];
}
nih_export_clap!(ResonanceCompressor);
nih_export_clap!(Patina);
@@ -1,6 +1,6 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use resonance_core::{Focus, Settings};
use patina_core::{Focus, Settings};
use std::sync::Arc;
#[derive(Params)]
pub struct FocusParams {
@@ -39,7 +39,7 @@ impl Default for FocusParams {
}
}
#[derive(Params)]
pub struct ResonanceParams {
pub struct PatinaParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "amount"]
@@ -73,7 +73,7 @@ pub struct ResonanceParams {
#[nested(array, group = "Focus")]
pub focus: [FocusParams; 4],
}
impl Default for ResonanceParams {
impl Default for PatinaParams {
fn default() -> Self {
Self {
editor: EguiState::from_size(1180, 760),
@@ -95,7 +95,7 @@ impl Default for ResonanceParams {
}
}
}
impl ResonanceParams {
impl PatinaParams {
pub fn floats(&self) -> [&FloatParam; 29] {
[
&self.amount,
@@ -49,8 +49,8 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &resonance_compressor::clap_entry;
assert!(entry.init.unwrap()(c"resonance-compressor.clap".as_ptr()));
let entry = &patina::clap_entry;
assert!(entry.init.unwrap()(c"patina.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
@@ -58,6 +58,7 @@ fn effect_ports_state_latency_and_audio_lifecycle() {
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Patina");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.resonance-compressor"
@@ -1,9 +1,9 @@
[package]
name = "resonance-render"
name = "patina-render"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
resonance-core = { path = "../resonance-core" }
patina-core = { path = "../core" }
serde_json = "1"
@@ -1,5 +1,5 @@
//! Bounded offline evaluation: PCM16/24/32 and IEEE float32 RIFF/WAVE input.
use resonance_core::{Engine, Monitor, Settings};
use patina_core::{Engine, Monitor, Settings};
use std::{
env, fs,
io::{Read, Write},
@@ -15,7 +15,7 @@ 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!("resonance-render <input.wav> <output.wav> [settings.json] [--delta]\nresonance-render --demo <output.wav>\nresonance-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused.");
println!("patina-render <input.wav> <output.wav> [settings.json] [--delta]\npatina-render --demo <output.wav>\npatina-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused.");
return Ok(());
}
if args[0] == "--benchmark" {
@@ -1,9 +1,9 @@
[package]
name = "resonance-ui"
name = "patina-ui"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
egui = "0.31.1"
resonance-core = { path = "../resonance-core" }
patina-core = { path = "../core" }
File renamed without changes.
@@ -1,6 +1,6 @@
//! Shared native editor, also exercised by the headless renderer.
use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use resonance_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
use patina_core::{focus_offset, frequency, AnalyzerFrame, Focus, Settings, GRID};
use std::time::{Duration, Instant};
const BG: Color32 = Color32::from_rgb(23, 20, 31);
@@ -271,7 +271,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new("Resonance Compressor")
RichText::new("Patina")
.font(FontId::new(30., egui::FontFamily::Name("medium".into()))),
);
note(ui, "Keep the character. Control the ring.");
@@ -1,12 +1,12 @@
[package]
name = "story-app"
version.workspace = true
name = "rivet-app"
version = "0.3.0"
edition.workspace = true
license.workspace = true
[dependencies]
story-core.workspace = true
story-midi.workspace = true
rivet-core.workspace = true
rivet-midi.workspace = true
crossbeam-queue = "0.3"
[target.'cfg(windows)'.dependencies]
@@ -1,6 +1,6 @@
//! Dialogs execute only on the file worker. A path field remains available in the editor.
use rivet_core::{Error, Result};
use std::path::PathBuf;
use story_core::{Error, Result};
#[cfg(windows)]
pub fn choose(save: bool, session: bool) -> Result<Option<PathBuf>> {
@@ -1,5 +1,9 @@
//! 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,
};
use std::{
fs::File,
io::{Read, Write},
@@ -9,10 +13,6 @@ use std::{
Arc, Mutex, RwLock,
},
};
use story_core::{
Audition, Candidate, Error, EventBatch, MessageKind, MidiMessage, Performance, PerformanceClip,
PerformancePlayer, Result, Session, SECTION,
};
pub mod dialog;
#[derive(Clone, Copy)]
@@ -39,12 +39,12 @@ pub struct Shared {
results: Mutex<Vec<Completion>>,
}
pub enum Output {
Imported(story_midi::MidiFile),
Imported(rivet_midi::MidiFile),
Generated(Vec<Candidate>),
Exported(PathBuf),
Saved(PathBuf),
Loaded(Box<Session>),
LibraryLoaded(Vec<story_core::Gesture>),
LibraryLoaded(Vec<rivet_core::Gesture>),
Canceled,
}
pub struct Completion {
@@ -120,7 +120,7 @@ impl Shared {
self.publish(&current);
Ok(())
}
fn compile_clip(&self, phrase: &story_core::Phrase) -> Result<PerformanceClip> {
fn compile_clip(&self, phrase: &rivet_core::Phrase) -> Result<PerformanceClip> {
let state = self.session.read().unwrap_or_else(|p| p.into_inner());
let observed = f64::from_bits(self.audition_tempo.load(Ordering::Acquire));
let tempo = if state.follow_host && observed.is_finite() {
@@ -133,7 +133,7 @@ impl Shared {
drop(state);
Performance::compile(phrase, &settings, tempo, compensate)?.clip()
}
pub fn preview(&self, phrase: &story_core::Phrase) -> Result<()> {
pub fn preview(&self, phrase: &rivet_core::Phrase) -> Result<()> {
let epoch = self.epoch.load(Ordering::Acquire);
let clip = self.compile_clip(phrase)?;
let next = epoch.wrapping_add(1);
@@ -149,7 +149,7 @@ impl Shared {
});
Ok(())
}
pub fn audition(&self, phrase: &story_core::Phrase) -> Result<()> {
pub fn audition(&self, phrase: &rivet_core::Phrase) -> Result<()> {
if !self.playing.load(Ordering::Acquire) {
return self.preview(phrase);
}
@@ -398,7 +398,7 @@ mod tests {
#[test]
fn stop_cancels_pending_and_active_preview() {
let shared = Shared::default();
let motif = story_core::demo_motif();
let motif = rivet_core::demo_motif();
let mut audio = AudioEngine::default();
shared.preview(&motif).unwrap();
shared.stop();
@@ -425,7 +425,7 @@ mod tests {
fn bounded_preview_queue_keeps_latest_request() {
let s = Shared::default();
for _ in 0..8 {
s.preview(&story_core::demo_motif()).unwrap();
s.preview(&rivet_core::demo_motif()).unwrap();
}
assert_eq!(s.commands.len(), 2);
let mut a = AudioEngine::default();
@@ -1,5 +1,5 @@
use story_app::{AudioEngine, Shared};
use story_core::{Note, Phrase, PPQ, SECTION};
use rivet_app::{AudioEngine, Shared};
use rivet_core::{Note, Phrase, PPQ, SECTION};
fn clip(pitch: u8, length: u32) -> Phrase {
Phrase {
@@ -1,8 +1,8 @@
use rivet_app::{AudioEngine, Shared};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
use story_app::{AudioEngine, Shared};
struct CheckedAllocator;
thread_local! {
@@ -34,8 +34,8 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn playback_control_and_rendering_do_not_allocate_or_free() {
let shared = Shared::default();
let mut settings = story_core::PerformanceSettings {
mode: story_core::RoleMode::Alternating,
let mut settings = rivet_core::PerformanceSettings {
mode: rivet_core::RoleMode::Alternating,
..Default::default()
};
for (i, voice) in settings.voices.iter_mut().enumerate() {
@@ -45,15 +45,15 @@ fn playback_control_and_rendering_do_not_allocate_or_free() {
voice.controller.cc = 20 + i as u8;
}
shared
.replace(story_core::Session {
.replace(rivet_core::Session {
performance: settings,
audition: story_core::Audition::Midi,
audition: rivet_core::Audition::Midi,
tempo: 300.,
..Default::default()
})
.unwrap();
let mut audio = AudioEngine::default();
let motif = story_core::demo_motif();
let motif = rivet_core::demo_motif();
audio.player.set_sample_rate(8000.).unwrap();
shared.preview(&motif).unwrap();
CHECK.with(|flag| flag.set(true));
@@ -1,5 +1,5 @@
use story_app::{AudioEngine, Shared};
use story_core::*;
use rivet_app::{AudioEngine, Shared};
use rivet_core::*;
fn fixture() -> (Shared, AudioEngine, Phrase) {
let mut settings = PerformanceSettings {
mode: RoleMode::Alternating,
@@ -1,6 +1,6 @@
[package]
name = "story-core"
version.workspace = true
name = "rivet-core"
version = "0.3.0"
edition.workspace = true
license.workspace = true
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
use story_core::*;
use rivet_core::*;
fn session() -> Session {
let mut s = Session {
@@ -1,4 +1,4 @@
use story_core::*;
use rivet_core::*;
fn rhythm(p: &Phrase) -> Vec<(u32, u32)> {
p.notes.iter().map(|n| (n.start, n.duration)).collect()
@@ -1,4 +1,4 @@
use story_core::*;
use rivet_core::*;
fn held() -> Phrase {
Phrase {
length: PPQ,
@@ -1,8 +1,8 @@
[package]
name = "story-midi"
version.workspace = true
name = "rivet-midi"
version = "0.3.0"
edition.workspace = true
license.workspace = true
[dependencies]
story-core.workspace = true
rivet-core.workspace = true
@@ -1,5 +1,5 @@
//! Strict Standard MIDI File import/export. Unsupported data is reported, never flattened.
use story_core::{
use rivet_core::{
Error, MessageKind, Note, Performance, PerformanceSettings, Phrase, Result, BAR,
MAX_PLAYBACK_NOTES, PPQ,
};
@@ -431,12 +431,12 @@ mod tests {
}
#[test]
fn round_trip_source_and_generated_phrase() {
let source = story_core::demo_motif();
let source = rivet_core::demo_motif();
let bytes = write(&source, 140.).unwrap();
let parsed = read(&bytes).unwrap();
assert_eq!(parsed.tracks[0].phrase, source);
assert!((parsed.tempo.unwrap() - 140.).abs() < 0.001);
for c in story_core::generate(&source, &story_core::Settings::default()).unwrap() {
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
@@ -453,7 +453,7 @@ mod tests {
}
#[test]
fn malformed_and_unsupported_files() {
let good = write(&story_core::demo_motif(), 140.).unwrap();
let good = write(&rivet_core::demo_motif(), 140.).unwrap();
for n in 0..good.len() {
assert!(read(&good[..n]).is_err());
}
@@ -484,7 +484,7 @@ mod tests {
}
#[test]
fn separate_tracks_and_timing_resolution() {
let a = write(&story_core::demo_motif(), 140.).unwrap();
let a = write(&rivet_core::demo_motif(), 140.).unwrap();
let mut two = a[..14].to_vec();
two[9] = 1;
two[11] = 2;
@@ -499,7 +499,7 @@ mod tests {
#[test]
fn performance_export_has_named_channel_tracks_and_controller_resets() {
let mut settings = PerformanceSettings {
mode: story_core::RoleMode::Alternating,
mode: rivet_core::RoleMode::Alternating,
..Default::default()
};
for (role, v) in settings.voices.iter_mut().enumerate() {
@@ -507,7 +507,7 @@ mod tests {
v.controller.cc = 20 + role as u8;
v.controller.rest = 5 + role as u8;
}
let source = story_core::demo_motif();
let source = rivet_core::demo_motif();
let bytes = write_performance(&source, &settings, 120.).unwrap();
let file = read(&bytes).unwrap();
assert_eq!(file.tracks.len(), 2);
@@ -533,7 +533,7 @@ mod tests {
#[test]
fn compensated_export_adds_lead_in_while_default_export_keeps_the_grid() {
let source = story_core::demo_motif();
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();
@@ -1350,19 +1350,6 @@ dependencies = [
"adler2",
]
[[package]]
name = "motif-storyboard"
version = "0.3.0"
dependencies = [
"clap-sys",
"nih_plug",
"nih_plug_egui",
"serde_json",
"story-app",
"story-core",
"story-midi",
]
[[package]]
name = "ndk"
version = "0.8.0"
@@ -2065,6 +2052,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rivet"
version = "0.3.0"
dependencies = [
"clap-sys",
"nih_plug",
"nih_plug_egui",
"rivet-app",
"rivet-core",
"rivet-midi",
"serde_json",
]
[[package]]
name = "rivet-app"
version = "0.3.0"
dependencies = [
"crossbeam-queue",
"rivet-core",
"rivet-midi",
"windows-sys 0.61.2",
]
[[package]]
name = "rivet-core"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "rivet-midi"
version = "0.3.0"
dependencies = [
"rivet-core",
]
[[package]]
name = "rtrb"
version = "0.2.3"
@@ -2249,31 +2274,6 @@ dependencies = [
"serde",
]
[[package]]
name = "story-app"
version = "0.3.0"
dependencies = [
"crossbeam-queue",
"story-core",
"story-midi",
"windows-sys 0.61.2",
]
[[package]]
name = "story-core"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "story-midi"
version = "0.3.0"
dependencies = [
"story-core",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -1,5 +1,5 @@
[package]
name = "motif-storyboard"
name = "rivet"
version = "0.3.0"
edition = "2021"
license = "ISC"
@@ -11,7 +11,7 @@ license = "ISC"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "motif-storyboard"
name = "rivet"
path = "src/main.rs"
required-features = ["standalone"]
@@ -21,9 +21,9 @@ standalone = ["nih_plug/standalone"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
story-core = { path = "../../crates/story-core" }
story-midi = { path = "../../crates/story-midi" }
story-app = { path = "../../crates/story-app" }
rivet-core = { path = "../core" }
rivet-midi = { path = "../midi" }
rivet-app = { path = "../app" }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
@@ -1,7 +1,7 @@
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;
use story_app::{Output, Shared};
use story_core::{CreativeMove, PerformanceSettings, Result, RoleMode, Session};
pub struct CreativeUi {
gesture_name: String,
@@ -77,16 +77,16 @@ pub fn ideas(
let saved = session.export_gesture_library();
start(shared, state, move || {
let bytes = saved?;
let Some(mut path) = story_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); };
let Some(mut path) = rivet_app::dialog::choose(true, true)? else { return Ok(Output::Canceled); };
if path.extension().is_none() { path.set_extension("json"); }
story_app::write_new(&path, &bytes)?; Ok(Output::Saved(path))
rivet_app::write_new(&path, &bytes)?; Ok(Output::Saved(path))
});
}
if ui.button("Import gestures…").clicked() {
start(shared, state, move || {
let Some(path) = story_app::dialog::choose(false, true)? else { return Ok(Output::Canceled); };
let bytes = story_app::read_limited(&path, 2 * 1024 * 1024)?;
Ok(Output::LibraryLoaded(story_core::read_gesture_library(&bytes)?))
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)?))
});
}
});
@@ -204,7 +204,7 @@ pub fn performance(ui: &mut egui::Ui, settings: &mut PerformanceSettings) {
if ui.add_enabled(curve.points.len()<16, egui::Button::new("Add point in largest gap")).clicked() {
if let Some((index,pair)) = curve.points.windows(2).enumerate().max_by_key(|(_,p)|p[1].position-p[0].position) {
let position = (pair[0].position+pair[1].position)/2;
if position > pair[0].position { let value = curve.value_at(position); curve.points.insert(index+1,story_core::CurvePoint {position,value}); }
if position > pair[0].position { let value = curve.value_at(position); curve.points.insert(index+1,rivet_core::CurvePoint {position,value}); }
}
}
}
@@ -1,14 +1,14 @@
//! Shared egui view. Also compiled by the offline visual QA harness against cached egui.
use crate::egui::{self, Align2, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2};
use rivet_app::{Output, Shared};
use std::{
path::PathBuf,
sync::{atomic::Ordering, Arc},
time::Duration,
};
use story_app::{Output, Shared};
#[path = "creative_editor.rs"]
mod creative_editor;
use story_core::{
use rivet_core::{
Audition, ChordQuality, Ending, Error, Harmony, Phrase, Result, Scale, Session, Task, BAR,
};
const INK: Color32 = Color32::from_rgb(225, 231, 243);
@@ -25,7 +25,7 @@ pub struct EditorState {
error: bool,
import_path: String,
export_path: String,
pending: Option<story_midi::MidiFile>,
pending: Option<rivet_midi::MidiFile>,
track: usize,
preview_lane: Option<usize>,
pending_lane: Option<usize>,
@@ -109,13 +109,13 @@ fn import(shared: &Arc<Shared>, state: &mut EditorState, path: Option<PathBuf>,
start(shared, state, move || {
let path = match path {
Some(p) => Some(p),
None => story_app::dialog::choose(false, false)?,
None => rivet_app::dialog::choose(false, false)?,
};
let Some(path) = path else {
return Ok(Output::Canceled);
};
let bytes = story_app::read_limited(&path, story_midi::MAX_FILE_BYTES)?;
Ok(Output::Imported(story_midi::read(&bytes)?))
let bytes = rivet_app::read_limited(&path, rivet_midi::MAX_FILE_BYTES)?;
Ok(Output::Imported(rivet_midi::read(&bytes)?))
});
}
fn export(
@@ -138,7 +138,7 @@ fn export(
start(shared, state, move || {
let path = match path {
Some(p) => Some(p),
None => story_app::dialog::choose(true, false)?,
None => rivet_app::dialog::choose(true, false)?,
};
let Some(mut path) = path else {
return Ok(Output::Canceled);
@@ -146,9 +146,9 @@ fn export(
if path.extension().is_none() {
path.set_extension("mid");
}
story_app::write_new(
rivet_app::write_new(
&path,
&story_midi::write_performance(&phrase, &performance, tempo)?,
&rivet_midi::write_performance(&phrase, &performance, tempo)?,
)?;
Ok(Output::Exported(path))
});
@@ -349,13 +349,13 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc<Shared>, state: &mut EditorState) {
});
egui::ScrollArea::vertical().show(ui,|ui|{
ui.horizontal(|ui|{
ui.label(RichText::new("Motif Storyboard").size(28.).strong());
ui.label(RichText::new("Rivet").size(28.).strong());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center),|ui|{
if ui.add_enabled(!busy,egui::Button::new("Import motif…")).clicked(){
import(shared,state,None,false);
}
if ui.add_enabled(!busy,egui::Button::new("Load demo")).clicked(){
session.source=Some(story_core::demo_motif());
session.source=Some(rivet_core::demo_motif());
session.pins.clear();
state.status="Choose what to preserve, then explore a musical task.".into();
state.error=false;
@@ -548,22 +548,22 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc<Shared>, state: &mut EditorState) {
if ui.add_enabled(!busy,egui::Button::new("Save session…")).clicked(){
let saved=session.clone();
start(shared,state,move||{
let Some(mut path)=story_app::dialog::choose(true,true)? else{
let Some(mut path)=rivet_app::dialog::choose(true,true)? else{
return Ok(Output::Canceled);
};
if path.extension().is_none(){
path.set_extension("json");
}
story_app::write_new(&path,&saved.to_json()?)?;
rivet_app::write_new(&path,&saved.to_json()?)?;
Ok(Output::Saved(path))
});
}
if ui.add_enabled(!busy,egui::Button::new("Load session…")).clicked(){
start(shared,state,move||{
let Some(path)=story_app::dialog::choose(false,true)? else{
let Some(path)=rivet_app::dialog::choose(false,true)? else{
return Ok(Output::Canceled);
};
Ok(Output::Loaded(Box::new(Session::from_json(&story_app::read_limited(&path,story_core::MAX_STATE_BYTES)?)?)))
Ok(Output::Loaded(Box::new(Session::from_json(&rivet_app::read_limited(&path,rivet_core::MAX_STATE_BYTES)?)?)))
});
}
});
@@ -620,7 +620,7 @@ pub fn show(ui: &mut egui::Ui, shared: &Arc<Shared>, state: &mut EditorState) {
let settings = session.settings.clone();
let harmony = session.harmony.clone();
start_at(shared, state, generation_revision, move || {
Ok(Output::Generated(story_core::generate_with_harmony(
Ok(Output::Generated(rivet_core::generate_with_harmony(
&source, &settings, &harmony,
)?))
});
@@ -712,7 +712,7 @@ fn harmony_editor(
#[derive(Clone, Copy)]
struct RollStyle {
register: (u8, u8),
roles: story_core::RoleMode,
roles: rivet_core::RoleMode,
}
fn roll(
ui: &mut egui::Ui,
@@ -1,9 +1,9 @@
use nih_plug::{params::persist::PersistentField, prelude::*};
pub use nih_plug_egui::egui;
use nih_plug_egui::{create_egui_editor, EguiState};
use rivet_app::{AudioEngine, Shared};
use rivet_core::Session;
use std::sync::{atomic::Ordering, Arc};
use story_app::{AudioEngine, Shared};
use story_core::Session;
mod editor;
pub struct SessionField(Arc<Shared>);
@@ -24,7 +24,7 @@ impl<'a> PersistentField<'a, Session> for SessionField {
}
#[derive(Params)]
struct StoryParams {
struct RivetParams {
#[persist = "editor"]
editor: Arc<EguiState>,
#[persist = "story-session-v1"]
@@ -34,17 +34,17 @@ struct StoryParams {
revision: IntParam,
}
pub struct MotifStoryboard {
params: Arc<StoryParams>,
pub struct Rivet {
params: Arc<RivetParams>,
shared: Arc<Shared>,
audio: AudioEngine,
host_was_playing: bool,
}
impl Default for MotifStoryboard {
impl Default for Rivet {
fn default() -> Self {
let shared = Arc::new(Shared::default());
Self {
params: Arc::new(StoryParams {
params: Arc::new(RivetParams {
editor: EguiState::from_size(1100, 850),
session: SessionField(shared.clone()),
revision: IntParam::new(
@@ -64,9 +64,9 @@ impl Default for MotifStoryboard {
}
}
}
impl Plugin for MotifStoryboard {
const NAME: &'static str = "Motif Storyboard";
const VENDOR: &'static str = "Storyboard";
impl Plugin for Rivet {
const NAME: &'static str = "Rivet";
const VENDOR: &'static str = "Rivet & Patina";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
@@ -144,21 +144,21 @@ impl Plugin for MotifStoryboard {
let frame = self.audio.frame(bpm, midi);
for message in frame.events.events[..frame.events.len].iter().flatten() {
match message.kind {
story_core::MessageKind::Off => context.send_event(NoteEvent::NoteOff {
rivet_core::MessageKind::Off => context.send_event(NoteEvent::NoteOff {
timing: index as u32,
voice_id: None,
channel: message.channel,
note: message.key,
velocity: 0.,
}),
story_core::MessageKind::On => context.send_event(NoteEvent::NoteOn {
rivet_core::MessageKind::On => context.send_event(NoteEvent::NoteOn {
timing: index as u32,
voice_id: None,
channel: message.channel,
note: message.key,
velocity: message.value as f32 / 127.,
}),
story_core::MessageKind::Control => context.send_event(NoteEvent::MidiCC {
rivet_core::MessageKind::Control => context.send_event(NoteEvent::MidiCC {
timing: index as u32,
channel: message.channel,
cc: message.key,
@@ -176,7 +176,7 @@ impl Plugin for MotifStoryboard {
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for MotifStoryboard {
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");
@@ -189,4 +189,4 @@ impl ClapPlugin for MotifStoryboard {
ClapFeature::Utility,
];
}
nih_export_clap!(MotifStoryboard);
nih_export_clap!(Rivet);
+3
View File
@@ -0,0 +1,3 @@
fn main() {
nih_plug::prelude::nih_export_standalone::<rivet::Rivet>();
}
@@ -49,8 +49,8 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &motif_storyboard::clap_entry;
assert!(entry.init.unwrap()(c"motif-storyboard.clap".as_ptr()));
let entry = &rivet::clap_entry;
assert!(entry.init.unwrap()(c"rivet.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
@@ -58,6 +58,7 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Rivet");
assert_eq!(CStr::from_ptr((*descriptor).id), c"io.storyboard.motif");
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
@@ -96,27 +97,27 @@ 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 = story_core::Session {
source: Some(story_core::demo_motif()),
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 = story_core::Harmony::progression(0, story_core::Scale::Minor);
session.audition = story_core::Audition::Midi;
session.harmony = rivet_core::Harmony::progression(0, rivet_core::Scale::Minor);
session.audition = rivet_core::Audition::Midi;
session.regenerate().unwrap();
session.pin_selected(1).unwrap();
session.accept();
session.selected = 2;
session.accept();
session.performance.mode = story_core::RoleMode::CallResponse;
session.performance.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(story_core::CreativeMove::Thin, Some(0))
.apply_move(rivet_core::CreativeMove::Thin, Some(0))
.unwrap();
envelope.fields.insert(
"story-session-v1".into(),
@@ -134,7 +135,7 @@ fn exported_factory_state_and_silent_audio_lifecycle() {
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
story_core::Session::from_json(restored.fields["story-session-v1"].as_bytes()).unwrap(),
rivet_core::Session::from_json(restored.fields["story-session-v1"].as_bytes()).unwrap(),
session
);
for rate in [44100., 48000., 96000.] {
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "rivet-preview"
version = "0.3.0"
edition.workspace = true
license.workspace = true
[dependencies]
rivet-core.workspace = true
rivet-midi.workspace = true
@@ -1,6 +1,6 @@
//! Headless end-to-end development harness; uses exactly the plugin's composition and voice.
use rivet_core::{demo_motif, Harmony, Performance, PerformancePlayer, Session};
use std::{env, fs, io::Write, path::PathBuf};
use story_core::{demo_motif, Harmony, Performance, PerformancePlayer, Session};
fn main() {
if let Err(e) = run() {
@@ -11,7 +11,7 @@ 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!("Motif Storyboard preview\n story-preview demo <output-directory>\n story-preview develop <input.mid> <track-index> <output-directory> [harmony.mid harmony-track-index]\n story-preview session <session.json> <output-directory>\nWrites source.mid, up to three distinct candidates, session.json, and selected.wav. Track indices start at 0.");
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.");
return Ok(());
}
let mut session = Session::default();
@@ -26,7 +26,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}
"develop" if args.len() == 4 || args.len() == 6 => {
let bytes = fs::read(&args[1])?;
let file = story_midi::read(&bytes)?;
let file = rivet_midi::read(&bytes)?;
let index: usize = args[2].parse()?;
let track = file.tracks.get(index).ok_or("Track index out of range")?;
track.phrase.validate_source()?;
@@ -39,7 +39,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
_ => return Err("Use --help for usage.".into()),
};
if args.len() == 6 {
let file = story_midi::read(&fs::read(&args[4])?)?;
let file = rivet_midi::read(&fs::read(&args[4])?)?;
let index: usize = args[5].parse()?;
let track = file
.tracks
@@ -56,20 +56,20 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
if let Some(source) = &session.source {
fs::write(
output.join("source.mid"),
story_midi::write(source, session.tempo)?,
rivet_midi::write(source, session.tempo)?,
)?;
}
for (i, c) in session.candidates.iter().enumerate() {
fs::write(
output.join(format!("candidate-{}.mid", i + 1)),
story_midi::write_performance(&c.phrase, &session.performance, session.tempo)?,
rivet_midi::write_performance(&c.phrase, &session.performance, session.tempo)?,
)?;
println!("{}: {}", c.strategy.name(), c.explanation);
}
fs::write(output.join("session.json"), session.to_json()?)?;
fs::write(
output.join("selected.mid"),
story_midi::write_performance(&selected, &session.performance, session.tempo)?,
rivet_midi::write_performance(&selected, &session.performance, session.tempo)?,
)?;
let mut player = PerformancePlayer::default();
player.set_sample_rate(44100.)?;
+14
View File
@@ -0,0 +1,14 @@
# Local listening references
Put reference audio under `references/devath/`. The seven supplied FLAC files
have been moved there; audio files remain ignored by Git and are not packaged.
From the repository root, run:
```sh
python scripts/analyze-references.py
```
Measurements are written to `docs/rivet/research/measurements/`.
The script requires ffmpeg, NumPy, and Matplotlib. It measures level, spectral
attacks, and texture changes; it does not transcribe notes or infer emotion.
+2 -2
View File
@@ -12,8 +12,8 @@ import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
parser = argparse.ArgumentParser(description='Measure relative level and attack/texture changes in local FLAC references. Requires ffmpeg, numpy, and matplotlib. Does not transcribe notes or infer emotion.')
parser.add_argument('folder', nargs='?', default='.')
parser.add_argument('--output', default='docs/reference-analysis')
parser.add_argument('folder', nargs='?', default='references/devath')
parser.add_argument('--output', default='docs/rivet/research/measurements')
args = parser.parse_args()
root = Path(args.folder)
out = Path(args.output)
+24 -19
View File
@@ -1,48 +1,53 @@
#!/usr/bin/env python3
"""Build and package CLAP without modifying a DAW's plugin directories."""
"""Build a catalogued CLAP plugin without modifying a DAW installation."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
root = Path(__file__).resolve().parents[1]
catalog = json.loads((root / "plugins/catalog.json").read_text())
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plugin", choices=["motif-storyboard", "resonance-compressor"], default="motif-storyboard", help="Plugin to package")
parser.add_argument("--plugin", choices=catalog, default="rivet", help="Plugin to package")
parser.add_argument("--target", help="Rust target triple; defaults to rustc's host")
parser.add_argument("--standalone", action="store_true", help="Also build the standalone preview app")
parser.add_argument("--standalone", action="store_true", help="Also build Rivet's standalone preview")
args = parser.parse_args()
product = catalog[args.plugin]
target = args.target
if target is None:
info = subprocess.check_output(["rustc", "-vV"], text=True)
target = next(line.removeprefix("host: ") for line in info.splitlines() if line.startswith("host: "))
if "windows" not in target and "linux" not in target:
parser.error("This milestone packages Windows and Linux only.")
if args.standalone and args.plugin != "motif-storyboard":
parser.error("Standalone is available only for Motif Storyboard.")
manifest = root / "plugins" / args.plugin / "Cargo.toml"
parser.error("Packaging supports Windows releases and native Linux development builds.")
if args.standalone and "standalone" not in product:
parser.error(f"{product['name']} has no standalone application.")
manifest = root / product["manifest"]
cmd = ["cargo", "build", "--manifest-path", str(manifest), "--release", "--locked", "--target", target, "--lib"]
if args.standalone:
cmd += ["--features", "standalone", "--bin", "motif-storyboard"]
cmd += ["--features", "standalone", "--bin", product["standalone"]]
subprocess.run(cmd, cwd=root, check=True)
target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "plugins" / args.plugin / "target"))
target_root = Path(os.environ.get("CARGO_TARGET_DIR", manifest.parent / "target"))
if not target_root.is_absolute():
target_root = root / target_root
build = target_root / target / "release"
destination = root / "dist" / target
destination.mkdir(parents=True, exist_ok=True)
stem = args.plugin.replace("-", "_")
stem = product["library"]
library = build / (f"{stem}.dll" if "windows" in target else f"lib{stem}.so")
display = "Motif Storyboard" if args.plugin == "motif-storyboard" else "Resonance Compressor"
plugin = destination / f"{display}.clap"
plugin = destination / f"{product['name']}.clap"
shutil.copy2(library, plugin)
shutil.copy2(root / "LICENSE", destination)
guide = "fl-studio.md" if args.plugin == "motif-storyboard" else "resonance-compressor.md"
shutil.copy2(root / "docs" / guide, destination / f"{args.plugin}-setup.md")
if args.plugin == "resonance-compressor":
shutil.copy2(root / "crates/resonance-ui/assets/OFL.txt", destination / "Roboto-OFL.txt")
shutil.copy2(root / "docs/resonance-editor.png", destination / "resonance-editor.png")
for name in ["LICENSE", "README.md", "docs/development.md", "docs/releases.md"]:
output = destination / name
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / name, output)
shutil.copytree(root / product["documentation"], destination / product["documentation"], dirs_exist_ok=True)
for source, name in product.get("licenses", {}).items():
output = destination / name
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / source, output)
if args.standalone:
exe = "motif-storyboard.exe" if "windows" in target else "motif-storyboard"
exe = product["standalone"] + (".exe" if "windows" in target else "")
shutil.copy2(build / exe, destination)
print(f"Packaged {plugin}")
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Check one product without compiling the other product's engine or wrapper."""
import argparse
import subprocess
from plugin_catalog import ROOT, catalog
products = catalog()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plugin", required=True, choices=products)
args = parser.parse_args()
product = products[args.plugin]
packages = [part for name in product["packages"] for part in ["-p", name]]
commands = [
["cargo", "fmt", *packages, "--", "--check"],
["cargo", "test", *packages, "--locked"],
["cargo", "clippy", *packages, "--all-targets", "--locked", "--", "-D", "warnings"],
["cargo", "fmt", "--manifest-path", product["manifest"], "--", "--check"],
["cargo", "test", "--manifest-path", product["manifest"], "--locked"],
["cargo", "clippy", "--manifest-path", product["manifest"], "--all-targets", "--locked", "--", "-D", "warnings"],
]
for command in commands:
subprocess.run(command, cwd=ROOT, check=True)
+59 -21
View File
@@ -1,29 +1,67 @@
#!/usr/bin/env python3
"""Validate and archive the two Windows CLAP deliverables; no Linux output."""
"""Package a combined preview bundle or an independently versioned plugin."""
import argparse
import hashlib
from pathlib import Path
import struct
import zipfile
from plugin_catalog import ROOT, archive_name, catalog, version
root = Path(__file__).resolve().parents[1]
folder = root / "dist/x86_64-pc-windows-gnu"
names = [
"Motif Storyboard.clap", "Resonance Compressor.clap", "LICENSE",
"motif-storyboard-setup.md", "resonance-compressor-setup.md", "Roboto-OFL.txt", "resonance-editor.png",
]
for name in names[:2]:
data = (folder / name).read_bytes()
if data[:2] != b"MZ":
raise SystemExit(f"{name}: expected Windows PE binary")
def pe_check(name, data):
if len(data) < 64 or data[:2] != b"MZ":
raise ValueError(f"{name}: expected Windows PE binary")
offset = struct.unpack_from("<I", data, 0x3C)[0]
if data[offset:offset + 4] != b"PE\0\0" or struct.unpack_from("<H", data, offset + 4)[0] != 0x8664:
raise SystemExit(f"{name}: expected x86-64 Windows DLL")
if offset + 24 > len(data) or data[offset:offset+4] != b"PE\0\0" or struct.unpack_from("<H", data, offset+4)[0] != 0x8664:
raise ValueError(f"{name}: expected x86-64 Windows DLL")
if b"clap_entry\0" not in data:
raise SystemExit(f"{name}: CLAP entry export missing")
checksums = "".join(f"{hashlib.sha256((folder / name).read_bytes()).hexdigest()} {name}\n" for name in names)
(folder / "SHA256SUMS").write_text(checksums, encoding="utf-8")
archive = root / "dist/fl-plugins-windows-x86_64.zip"
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
for name in names + ["SHA256SUMS"]:
output.write(folder / name, arcname=name)
print(f"Packaged {archive}")
raise ValueError(f"{name}: CLAP entry export missing")
def package(target="x86_64-pc-windows-gnu", plugin=None, root=ROOT):
products = catalog(root)
selected = {plugin: products[plugin]} if plugin else products
folder = root / "dist" / target
names = ["LICENSE", "docs/development.md", "docs/releases.md"]
for product in selected.values():
name = f"{product['name']}.clap"
pe_check(name, (folder / name).read_bytes())
names.append(name)
names.extend(p.relative_to(root).as_posix() for p in (root / product["documentation"]).rglob("*") if p.is_file())
names.extend(product.get("licenses", {}).values())
payload = {name: (folder / name).read_bytes() for name in sorted(set(names))}
if plugin:
product = selected[plugin]
release_version = version(plugin, root)
filename = archive_name(plugin, release_version)
payload["README.md"] = (
f"# {product['name']} {release_version}\n\n{product['description']}.\n\n"
f"Copy {product['name']}.clap into C:\\Program Files\\Common Files\\CLAP\\ "
"and rescan plugins in FL Studio 2024.1 or newer.\n\n"
f"See [{product['name']} guide]({product['documentation']}/README.md).\n"
).encode()
else:
filename = "rivet-patina-windows-x86_64.zip"
payload["README.md"] = (folder / "README.md").read_bytes()
checksums = "".join(f"{hashlib.sha256(data).hexdigest()} {name}\n" for name, data in sorted(payload.items()))
payload["SHA256SUMS"] = checksums.encode()
archive = root / "dist" / filename
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
for name, data in sorted(payload.items()):
info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
output.writestr(info, data)
# Stable ZIP metadata makes repeated packaging of identical files reproducible.
sidecar = archive.with_suffix(".zip.sha256")
sidecar.write_text(f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n", encoding="utf-8")
print(f"Packaged {archive}")
return archive
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plugin", choices=catalog(), help="Omit for a combined development bundle")
parser.add_argument("--target", default="x86_64-pc-windows-gnu", choices=["x86_64-pc-windows-gnu", "x86_64-pc-windows-msvc"])
args = parser.parse_args()
package(args.target, args.plugin)
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
"""Shared product metadata and independent release versions (Python 3.11+)."""
import json
from pathlib import Path
import re
import tomllib
ROOT = Path(__file__).resolve().parents[1]
def catalog(root=ROOT):
return json.loads((root / "plugins/catalog.json").read_text())
def version(plugin, root=ROOT):
product = catalog(root)[plugin]
return tomllib.loads((root / product["manifest"]).read_text())["package"]["version"]
def tag_version(plugin, tag, root=ROOT):
match = re.fullmatch(re.escape(plugin) + r"-v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)", tag)
if not match or match[1] != version(plugin, root):
raise ValueError(f"Expected {plugin}-v{version(plugin, root)}, got {tag!r}")
return match[1]
def archive_name(plugin, release_version):
return f"{plugin}-windows-x86_64-{release_version}.zip"
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Validate independent product tags and publish Windows assets to Gitea."""
import argparse
import hashlib
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile
from plugin_catalog import ROOT, archive_name, catalog, tag_version
class SafeRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, fp, code, msg, headers, newurl):
redirect = super().redirect_request(request, fp, code, msg, headers, newurl)
if redirect and urllib.parse.urlsplit(request.full_url)[:2] != urllib.parse.urlsplit(newurl)[:2]:
redirect.remove_header("Authorization")
return redirect
class Gitea:
def __init__(self, server, repository, token):
parts = repository.split("/")
if len(parts) != 2 or not all(parts):
raise ValueError("GITEA_REPOSITORY must be owner/repository")
self.server = server.rstrip("/")
self.base = self.server + "/api/v1/repos/" + "/".join(urllib.parse.quote(p, safe="") for p in parts)
self.token = token
self.opener = urllib.request.build_opener(SafeRedirect())
def fetch(self, method, url, data=None, content_type=None, missing=False):
headers = {}
if urllib.parse.urlsplit(url)[:2] == urllib.parse.urlsplit(self.server)[:2]:
headers["Authorization"] = "token " + self.token
if content_type:
headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with self.opener.open(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as error:
if error.code == 404 and missing:
return None
raise RuntimeError(f"Gitea {method}: HTTP {error.code}") from None
def request(self, method, path, data=None, missing=False):
raw = self.fetch(method, self.base + path,
json.dumps(data).encode() if data is not None else None,
"application/json" if data is not None else None, missing)
return json.loads(raw) if raw else None
def download(self, url):
return self.fetch("GET", url)
def upload(self, release_id, path):
boundary = "rivet-patina-" + uuid.uuid4().hex
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{path.name}\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
url = self.base + f"/releases/{release_id}/assets?name=" + urllib.parse.quote(path.name, safe="")
return json.loads(self.fetch("POST", url, body, f"multipart/form-data; boundary={boundary}"))
def release_notes(plugin, release_version, root=ROOT):
notes = (root / "docs" / plugin / "CHANGELOG.md").read_text()
match = re.search(r"^## " + re.escape(release_version) + r"\s*\n(.*?)(?=^## |\Z)", notes, re.M | re.S)
if not match:
raise ValueError(f"Add a {release_version} section to docs/{plugin}/CHANGELOG.md")
return match[1].strip()
def plan(tag, root=ROOT):
tag = tag.removeprefix("refs/tags/")
plugin = next((p for p in catalog(root) if tag.startswith(p + "-v")), None)
if plugin is None:
raise ValueError("Use a rivet-v<version> or patina-v<version> tag")
release_version = tag_version(plugin, tag, root)
release_notes(plugin, release_version, root)
return {"plugin": plugin, "version": release_version, "tag": tag,
"archive": archive_name(plugin, release_version)}
def publish(plugin, tag, client, root=ROOT):
metadata = plan(tag, root)
if metadata["plugin"] != plugin:
raise ValueError("Selected plugin does not match the release tag")
tag = metadata["tag"]
product = catalog(root)[plugin]
archive = root / "dist" / metadata["archive"]
sidecar = archive.with_suffix(".zip.sha256")
expected = f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n"
if sidecar.read_text() != expected:
raise ValueError("Archive checksum does not match its sidecar")
with zipfile.ZipFile(archive) as package:
if package.testzip() is not None or [n for n in package.namelist() if n.endswith(".clap")] != [f"{product['name']}.clap"]:
raise ValueError("Release ZIP must contain exactly the selected plugin")
sums = package.read("SHA256SUMS").decode().splitlines()
for line in sums:
digest, name = line.split(" ", 1)
if hashlib.sha256(package.read(name)).hexdigest() != digest:
raise ValueError("Internal package checksum mismatch")
escaped = urllib.parse.quote(tag, safe="")
# Do not let CreateRelease silently manufacture a missing repository tag.
if client.request("GET", "/tags/" + escaped, missing=True) is None:
raise ValueError("The release tag must already exist in Gitea")
release = client.request("GET", "/releases/tags/" + escaped, missing=True)
if release is None:
release = client.request("POST", "/releases", {
"name": f"{product['name']} {metadata['version']}", "tag_name": tag,
"body": release_notes(plugin, metadata["version"], root),
"draft": True, "prerelease": "-" in metadata["version"],
})
assets = release.get("assets", [])
for path in [archive, sidecar]:
existing = next((asset for asset in assets if asset["name"] == path.name), None)
if existing is not None:
if client.download(existing["browser_download_url"]) != path.read_bytes():
raise ValueError(f"Refusing to replace release asset with different content: {path.name}")
else:
client.upload(release["id"], path)
# New releases remain drafts until both assets have uploaded successfully.
if release.get("draft"):
client.request("PATCH", f"/releases/{release['id']}", {"draft": False})
print(f"Published {tag}: {archive.name}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["plan", "validate", "publish"])
parser.add_argument("--plugin", choices=catalog())
parser.add_argument("--tag", required=True)
args = parser.parse_args()
metadata = plan(args.tag)
if args.plugin is not None and metadata["plugin"] != args.plugin:
raise ValueError("Selected plugin does not match the release tag")
if args.action in ["plan", "validate"]:
print(f"Validated {metadata['tag']}")
output = os.environ.get("GITHUB_OUTPUT") or os.environ.get("GITEA_OUTPUT")
if args.action == "plan" and output:
with open(output, "a", encoding="utf-8") as stream:
for name, value in metadata.items():
stream.write(f"{name}={value}\n")
return
if os.environ.get("GITEA_REF") != "refs/tags/" + metadata["tag"]:
raise ValueError("Publishing requires a matching Gitea tag workflow")
client = Gitea(os.environ["GITEA_SERVER_URL"], os.environ["GITEA_REPOSITORY"], os.environ["GITEA_TOKEN"])
publish(metadata["plugin"], metadata["tag"], client)
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError, KeyError, OSError) as error:
raise SystemExit(str(error)) from None
+187
View File
@@ -0,0 +1,187 @@
import contextlib
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import struct
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from plugin_catalog import archive_name, tag_version
import release
spec = importlib.util.spec_from_file_location("package_windows", Path(__file__).resolve().parents[1] / "package-windows.py")
packaging = importlib.util.module_from_spec(spec)
spec.loader.exec_module(packaging)
class FakeGitea:
def __init__(self):
self.release = None
self.bytes = {}
self.calls = []
self.tag_exists = True
self.fail_upload = False
def request(self, method, path, data=None, missing=False):
self.calls.append((method, path, data))
if path.startswith("/tags/"):
return {"name": path.split("/")[-1]} if self.tag_exists else None
if method == "GET":
return self.release
if method == "POST" and path == "/releases":
self.release = dict(data, id=7, assets=[])
return self.release
if method == "PATCH":
self.release.update(data)
return self.release
raise AssertionError((method, path))
def upload(self, release_id, path):
if self.fail_upload and path.name.endswith(".sha256"):
raise RuntimeError("simulated interruption")
self.calls.append(("UPLOAD", path.name, None))
url = "https://gitea.invalid/download/" + path.name
self.bytes[url] = path.read_bytes()
asset = {"name": path.name, "browser_download_url": url}
self.release["assets"].append(asset)
return asset
def download(self, url):
return self.bytes[url]
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.folder = self.root / "dist/x86_64-pc-windows-gnu"
self.folder.mkdir(parents=True)
catalog = {}
for plugin, name, version in [("rivet", "Rivet", "0.3.0"), ("patina", "Patina", "0.1.0")]:
manifest = f"plugins/{plugin}/plugin/Cargo.toml"
catalog[plugin] = {"name": name, "description": name + " processor", "manifest": manifest,
"documentation": f"docs/{plugin}"}
self.write(manifest, f'[package]\nname="{plugin}"\nversion="{version}"\n')
self.write(f"docs/{plugin}/README.md", name + " guide")
self.write(f"docs/{plugin}/CHANGELOG.md", f"# Changelog\n\n## {version}\n\nFirst {name} release.\n")
self.write(f"dist/x86_64-pc-windows-gnu/docs/{plugin}/README.md", name + " guide")
self.write(f"dist/x86_64-pc-windows-gnu/docs/{plugin}/CHANGELOG.md", f"# Changelog\n\n## {version}\n\nFirst {name} release.\n")
binary = bytearray(160)
binary[:2] = b"MZ"
struct.pack_into("<I", binary, 0x3C, 64)
binary[64:68] = b"PE\0\0"
struct.pack_into("<H", binary, 68, 0x8664)
binary[120:131] = b"clap_entry\0"
(self.folder / f"{name}.clap").write_bytes(binary)
self.write("plugins/catalog.json", json.dumps(catalog))
for name in ["README.md", "LICENSE", "docs/development.md", "docs/releases.md"]:
self.write("dist/x86_64-pc-windows-gnu/" + name, name)
(self.folder / "Legacy.clap").write_bytes(b"unused")
def write(self, path, value):
p = self.root / path
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(value)
def package(self, plugin):
with contextlib.redirect_stdout(io.StringIO()):
return packaging.package(plugin=plugin, root=self.root)
def publish(self, client, plugin="patina"):
tag = "patina-v0.1.0" if plugin == "patina" else "rivet-v0.3.0"
with contextlib.redirect_stdout(io.StringIO()):
release.publish(plugin, tag, client, self.root)
def test_tag_and_version_are_independent(self):
self.assertEqual(tag_version("rivet", "rivet-v0.3.0", self.root), "0.3.0")
self.assertEqual(release.plan("refs/tags/patina-v0.1.0", self.root)["plugin"], "patina")
for plugin, tag in [("rivet", "patina-v0.1.0"), ("patina", "patina-v0.3.0"), ("patina", "v0.1.0")]:
with self.assertRaises(ValueError):
tag_version(plugin, tag, self.root)
def test_individual_zip_excludes_other_plugin_and_is_reproducible(self):
path = self.package("patina")
first = path.read_bytes()
self.package("patina")
self.assertEqual(first, path.read_bytes())
with zipfile.ZipFile(path) as z:
self.assertIn("Patina.clap", z.namelist())
self.assertNotIn("Rivet.clap", z.namelist())
self.assertNotIn("Legacy.clap", z.namelist())
self.assertFalse(any(n.startswith("docs/rivet/") for n in z.namelist()))
for line in z.read("SHA256SUMS").decode().splitlines():
digest, name = line.split(" ", 1)
self.assertEqual(hashlib.sha256(z.read(name)).hexdigest(), digest)
def test_draft_publish_and_retry_preserve_notes(self):
self.package("patina")
client = FakeGitea()
self.publish(client)
self.assertFalse(client.release["draft"])
self.assertEqual(len(client.release["assets"]), 2)
client.release["body"] = "User edited notes"
client.calls.clear()
self.publish(client)
self.assertEqual(client.release["body"], "User edited notes")
self.assertTrue(all(call[0] == "GET" for call in client.calls))
def test_partial_upload_is_repaired_before_draft_is_published(self):
self.package("patina")
client = FakeGitea()
client.fail_upload = True
with self.assertRaises(RuntimeError):
self.publish(client)
self.assertTrue(client.release["draft"])
self.assertEqual(len(client.release["assets"]), 1)
client.fail_upload = False
self.publish(client)
self.assertFalse(client.release["draft"])
self.assertEqual(len(client.release["assets"]), 2)
def test_different_existing_asset_is_never_replaced(self):
self.package("patina")
client = FakeGitea()
self.publish(client)
client.bytes[client.release["assets"][0]["browser_download_url"]] = b"different"
client.calls.clear()
with self.assertRaises(ValueError):
self.publish(client)
self.assertTrue(all(call[0] == "GET" for call in client.calls))
def test_bad_checksum_or_missing_tag_cannot_publish(self):
path = self.package("rivet")
path.with_suffix(".zip.sha256").write_text("invalid")
client = FakeGitea()
with self.assertRaises(ValueError):
self.publish(client, "rivet")
self.assertEqual(client.calls, [])
self.package("rivet")
client.tag_exists = False
with self.assertRaises(ValueError):
self.publish(client, "rivet")
self.assertFalse(any(call[0] != "GET" for call in client.calls))
def test_no_release_notes_fails_before_network(self):
self.package("patina")
self.write("docs/patina/CHANGELOG.md", "# No matching release")
client = FakeGitea()
with self.assertRaises(ValueError):
self.publish(client)
self.assertEqual(client.calls, [])
def test_foreign_plugin_in_archive_cannot_publish(self):
path = self.package("patina")
with zipfile.ZipFile(path, "a") as z:
z.writestr("Rivet.clap", b"wrong plugin")
path.with_suffix(".zip.sha256").write_text(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n")
client = FakeGitea()
with self.assertRaises(ValueError):
self.publish(client)
self.assertEqual(client.calls, [])
if __name__ == "__main__":
unittest.main()
+38 -38
View File
@@ -105,11 +105,11 @@ version = "0.1.0"
dependencies = [
"egui",
"image",
"resonance-core",
"resonance-ui",
"story-app",
"story-core",
"story-midi",
"patina-core",
"patina-ui",
"rivet-app",
"rivet-core",
"rivet-midi",
]
[[package]]
@@ -315,6 +315,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "patina-core"
version = "0.1.0"
dependencies = [
"rustfft",
"serde",
]
[[package]]
name = "patina-ui"
version = "0.1.0"
dependencies = [
"egui",
"patina-core",
]
[[package]]
name = "png"
version = "0.18.1"
@@ -377,19 +393,28 @@ dependencies = [
]
[[package]]
name = "resonance-core"
version = "0.1.0"
name = "rivet-app"
version = "0.3.0"
dependencies = [
"rustfft",
"serde",
"crossbeam-queue",
"rivet-core",
"rivet-midi",
"windows-sys",
]
[[package]]
name = "resonance-ui"
version = "0.1.0"
name = "rivet-core"
version = "0.3.0"
dependencies = [
"egui",
"resonance-core",
"serde",
"serde_json",
]
[[package]]
name = "rivet-midi"
version = "0.3.0"
dependencies = [
"rivet-core",
]
[[package]]
@@ -467,31 +492,6 @@ version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "story-app"
version = "0.3.0"
dependencies = [
"crossbeam-queue",
"story-core",
"story-midi",
"windows-sys",
]
[[package]]
name = "story-core"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "story-midi"
version = "0.3.0"
dependencies = [
"story-core",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
+5 -5
View File
@@ -9,8 +9,8 @@ publish = false
[dependencies]
egui = "0.31.1"
image = { version = "0.25", default-features = false, features = ["png"] }
story-app = { path = "../../crates/story-app" }
story-core = { path = "../../crates/story-core" }
story-midi = { path = "../../crates/story-midi" }
resonance-core = { path = "../../crates/resonance-core" }
resonance-ui = { path = "../../crates/resonance-ui" }
rivet-app = { path = "../../plugins/rivet/app" }
rivet-core = { path = "../../plugins/rivet/core" }
rivet-midi = { path = "../../plugins/rivet/midi" }
patina-core = { path = "../../plugins/patina/core" }
patina-ui = { path = "../../plugins/patina/ui" }
+36 -26
View File
@@ -1,12 +1,22 @@
//! Offline compilation and software-rendered visual QA for the shared editor.
pub use egui;
#[path = "../../../plugins/motif-storyboard/src/editor.rs"]
#[path = "../../../plugins/rivet/plugin/src/editor.rs"]
mod editor;
use std::sync::Arc;
fn main() {
let resonance = std::env::args().any(|arg| arg == "--resonance");
let args: Vec<_> = std::env::args().collect();
let plugin = args
.windows(2)
.find(|a| a[0] == "--plugin")
.map(|a| a[1].as_str())
.unwrap_or("rivet");
if !["rivet", "patina"].contains(&plugin) {
eprintln!("Use --plugin rivet or --plugin patina");
std::process::exit(2);
}
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
let compact = std::env::args().any(|arg| arg == "--compact");
let (width, height) = if resonance {
let (width, height) = if patina {
if compact {
(1040, 700)
} else {
@@ -15,48 +25,48 @@ fn main() {
} else {
(1100, 850)
};
let mut resonance_settings = resonance_core::Settings::preset(1);
resonance_settings.focus[0] = resonance_core::Focus {
let mut patina_settings = patina_core::Settings::preset(1);
patina_settings.focus[0] = patina_core::Focus {
enabled: true,
frequency: 380.,
width: 0.5,
sensitivity: -12.,
};
resonance_settings.focus[1] = resonance_core::Focus {
patina_settings.focus[1] = patina_core::Focus {
enabled: true,
frequency: 3000.,
width: 0.3,
sensitivity: 6.,
};
let mut resonance_state = resonance_ui::State::default();
resonance_state.selected = Some(1);
resonance_state.preset = 1;
resonance_state.delta = std::env::args().any(|arg| arg == "--delta");
resonance_settings.bypass = std::env::args().any(|arg| arg == "--bypass");
if resonance && !std::env::args().any(|arg| arg == "--silent") {
let mut engine = resonance_core::Engine::new(48000., 2).unwrap();
engine.set_settings(resonance_settings).unwrap();
let mut patina_state = patina_ui::State::default();
patina_state.selected = Some(1);
patina_state.preset = 1;
patina_state.delta = std::env::args().any(|arg| arg == "--delta");
patina_settings.bypass = std::env::args().any(|arg| arg == "--bypass");
if patina && !std::env::args().any(|arg| arg == "--silent") {
let mut engine = patina_core::Engine::new(48000., 2).unwrap();
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;
engine.process_sample([x, x * 0.85], resonance_core::Monitor::default());
engine.process_sample([x, x * 0.85], patina_core::Monitor::default());
if let Some(frame) = engine.take_analyzer() {
resonance_state.accept_frame(frame);
patina_state.accept_frame(frame);
}
}
}
let shared = Arc::new(story_app::Shared::default());
let mut session = story_core::Session {
source: Some(story_core::demo_motif()),
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") {
session.performance.mode = story_core::RoleMode::CallResponse;
session.performance.mode = rivet_core::RoleMode::CallResponse;
session.performance.voices[0].name = "Glass question".into();
session.performance.voices[1].name = "Warm answer".into();
for (i, voice) in session.performance.voices.iter_mut().enumerate() {
@@ -68,13 +78,13 @@ fn main() {
session.save_gesture("Hover, then resolve", None).unwrap();
session.save_branch("Opening thought").unwrap();
session
.apply_move(story_core::CreativeMove::Thin, Some(1))
.apply_move(rivet_core::CreativeMove::Thin, Some(1))
.unwrap();
}
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if resonance {
resonance_ui::style(&ctx);
if patina {
patina_ui::style(&ctx);
} else {
editor::style(&ctx);
}
@@ -92,8 +102,8 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if resonance {
resonance_ui::show(ui, &mut resonance_settings, &mut resonance_state);
if patina {
patina_ui::show(ui, &mut patina_settings, &mut patina_state);
} else {
editor::show(ui, &shared, &mut state);
}
@@ -212,7 +222,7 @@ fn main() {
}
let path = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/motif-storyboard-editor.png".into());
.unwrap_or_else(|| "/tmp/rivet-editor.png".into());
image.save(&path).unwrap();
println!("Rendered {path}");
}