restructure
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -34,5 +34,5 @@ venv/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# GitLab job-local Cargo cache.
|
||||
# Job-local Cargo cache.
|
||||
/.cargo-ci/
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 658 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 658 KiB |
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
|
After Width: | Height: | Size: 698 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 702 KiB |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
After Width: | Height: | Size: 654 KiB |
|
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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
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).
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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, ¶ms.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" }
|
||||
@@ -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(¤t);
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
@@ -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.] {
|
||||
@@ -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.)?;
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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}");
|
||||
}
|
||||