diff --git a/.gitea/actions/setup/action.yml b/.gitea/actions/setup/action.yml new file mode 100644 index 0000000..cc3cb6b --- /dev/null +++ b/.gitea/actions/setup/action.yml @@ -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 diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..36c7c3b --- /dev/null +++ b/.gitea/workflows/check.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..d499f1a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/.gitignore b/.gitignore index 7a3c255..20e40be 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,5 @@ venv/ .DS_Store Thumbs.db -# GitLab job-local Cargo cache. +# Job-local Cargo cache. /.cargo-ci/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 301d899..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -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 diff --git a/Cargo.lock b/Cargo.lock index db6153d..69d4f6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e3f9f4d..6be5600 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index ef1f202..b9bdec8 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,70 @@ -# FL Studio plugins +# Rivet & Patina -Two Rust CLAP plugins: **Motif Storyboard**, a melodic composition companion, -and **[Resonance Compressor](docs/resonance-compressor.md)**, a mixing effect -with spectral resonance control and a full native engineer panel. +Two Rust CLAP plugins for writing and refining bass music. -GitLab CI builds a Windows x86-64 ZIP containing both plugins, setup guides, -licenses, and checksums. Download it from the **windows** job artifacts. +| Plugin | Purpose | Guide | +| --- | --- | --- | +| **Rivet** | Join motifs into connected phrases, preserve identity, and explore branches | [Rivet](docs/rivet/README.md) | +| **Patina** | Smooth harsh resonances while retaining tone and transient character | [Patina](docs/patina/README.md) | -## Motif Storyboard +The names come from metalwork: a **rivet** joins pieces; **patina** changes a +surface while leaving its character. They share a Rust foundation and keep +their own musical purpose, code, documentation, and version. -A Rust composition companion for an experienced producer developing their own -melodic ideas. Version 0.3 explores rhythm, space, contour, harmonic arrivals, and -returns, with reusable gestures, branching ideas, and two sound roles. +## Install -![Motif Storyboard editor](docs/editor.png) +Download the desired plugin's Windows ZIP from its Gitea release. Extract it +and copy `Rivet.clap` or `Patina.clap` into: -## Write with it - -1. Import a monophonic MIDI motif of up to two bars in 4/4, or load the demo. -2. Choose a task: **Develop the motif**, **Write an answer**, **Make room**, or - **Find a return**. Choose a settled or open ending and a four/eight-bar length. -3. Decide what defines the idea. **Keep groove** fixes note positions and lengths; - **Keep contour** protects the direction of movement, allowing flattened steps - at register limits. Click source notes to lock the whole note, pitch only, or - rhythm only. Clicking uses the currently selected lock mode. Locks - and identity constraints take precedence over the proposed transformations. -4. Optionally open **Harmony** and import a chord/bass MIDI track (up to eight - bars), or create and edit a four-chord progression. Imported overlapping notes - become pitch-class regions with the actual lowest held pitch; the tool does - not guess chord roots or chord names. Empty regions remain gaps, and short - contexts repeat. Harmony guides strong notes and arrivals; the preview plays - the melody only. Supplied chord tones may extend beyond the selected scale. -5. Explore alternatives. A deterministic pool of eight transformation families - supplies up to three distinct proposals, selected for the task and differences - in rhythm, density, duration, and pitch. Fully constrained motifs may yield - fewer than three; cosmetic velocity differences do not count as new ideas. -6. Select a proposal and pin its two-bar sections. Select another to fill the - remaining sections, or explore another take. Preview and export the combined - phrase from the fixed bottom toolbar. Keep a combined result to retain it - independently of new proposals; undo/redo switches the kept result and its - previous value. Individual proposals and kept results can also be exported. - -The opening quotation stays intact. Later sections use displaced entries, -repeated pickups, fragments, subtraction, short/long answers, changed endings, -register contrast, and returns. These are compositional suggestions, not promises -of an emotion or of commercial originality. Different seeds choose structured -alternatives; they do not independently randomize every pitch. - -Pins survive task/seed/harmony changes and regeneration. Changing the motif, -its locks, or the phrase length clears pins. Pinned sections override newly -proposed material. Unsafe boundaries in legacy candidates are rejected instead -of truncating sustained notes. Changing generation inputs clears current -proposals while retaining kept work. - -## Audition and commit - -**Internal voice** provides two simple reference voices. **MIDI output** sends -notes and optional CC curves on the configured channels to routed external synths -and mutes the internal voice. Solo defaults to channel 1. -Looping is optional and enabled for new sessions. When audition is already -running, another selection waits until the next two-bar boundary or current -phrase end. Note-offs precede repeated same-pitch note-ons; stop, replacement, -loop boundaries, reset, restoration, and mode changes release active output notes -on the processing thread. Queued auditions are canceled by Stop. - -**Host tempo** follows tempo, with the local BPM as fallback. It does not align -the initial audition to the DAW's song position: start it at the desired point -in the project loop. Host start never initiates audition; host stop silences it. -Exported MIDI is in musical ticks and carries the local BPM as its tempo event. -Native OS MIDI drag-out is not implemented; export files to commit a phrase. -See [FL Studio setup and acceptance checks](docs/fl-studio.md). - -Open **Gestures, moves, and branches** to save a source or selected two-bar -section with its performance choices. Export/import a gesture library between -projects. Save a branch before experimenting; restore any branch and make a new -child with subtraction, a delayed entry, longer gates, or transposition. -Moves respect pins and locks, reject unsafe overlaps, and report when constraints -leave nothing to change. Libraries and histories each hold up to 16 entries; -remove a gesture or a leaf branch to make room. - -Open **Sound roles and expressive gestures** to pass existing notes between two -patches: alternate events, alternate question/answer bars, or divide strong beats -from replies. These modes distribute a monophonic phrase rather than generate -a second independent melody. Assign separate MIDI channels and explicitly map -each optional CC lane to a synth macro. Curves are normalized over the whole -phrase, with editable points and rise/fall/arch/pulse starting shapes. -Names describe your intent; the plugin does not infer feelings. - -Patch profiles set register offset, gate, velocity, and estimated attack/release. -MIDI audition uses a short warmup for early triggers. Re-audition after a tempo -change to recompile millisecond offsets. Compensation estimates trigger timing; -it does not measure patch latency or model its sound. Default export retains -musical positions; compensated export adds one bar of lead-in. Role tracks carry -names, channels, and CC curves. Stop resets mapped controllers to their chosen -stop values. Internal audition does not reproduce mapped timbre. -See [the theory and producer workflow](docs/theory/README.md). - -Sessions save inside the host project and can be saved/loaded as JSON. Version-1/2 -sessions and the original host field identifier remain supported; older sessions -retain one-shot audition. New sessions persist harmony, task, lock modes on notes, -pins, proposals, kept history, gestures, branches, patch profiles, CC curves, and -audition settings. JSON state is bounded at 16 MiB; gesture libraries at 2 MiB. File export/save refuses to -silently overwrite an existing file. - -## Build and verify - -The plugin is CLAP-only, using NIH-plug pinned to -`de421011f41a6d10fc8c7a6084e4f4dee0143683` with egui 0.31. VST3 is disabled. -The independent plugin workspace keeps Git/GUI downloads separate from engine -and MIDI tests. Stable Rust and Python are required for packaging. - -```sh -cargo test --workspace --offline -cargo clippy --workspace --all-targets --offline -- -D warnings -cargo run -p story-preview --offline -- demo /tmp/motif-demo +```text +C:\Program Files\Common Files\CLAP\ ``` -The headless demo writes source/candidate MIDI, a session JSON, and a WAV using -the same engine and voice as the plugin. To use existing MIDI, note-track indices -exclude conductor-only tracks and start at zero. Optional harmony arguments -accept a separate polyphonic MIDI file and track index. +In FL Studio 2024.1 or newer, open **Options → Manage plugins**, enable +**Verify plugins**, and **Find installed plugins**. Load Rivet in the Channel Rack; +load Patina in a mixer effect slot. -```sh -cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase -cargo run -p story-preview -- develop motif.mid 0 /tmp/phrase-with-chords chords.mid 0 -cargo run -p story-preview -- session saved-session.json /tmp/rendered-session +Normal branch/PR pipelines also provide a combined Windows preview ZIP in the +**windows** job. Releases are separate: `rivet-v0.3.0` and `patina-v0.1.0` +publish independently. See [release workflow](docs/releases.md). + +These names replace Motif Storyboard and Resonance Compressor. Their CLAP IDs +and saved-state identifiers are preserved; replace old scanned copies and rescan. + +## Repository + +```text +plugins/ + catalog.json Product metadata used by build and release tools + rivet/ + core/ Motifs, harmony, variation, history, performance + midi/ Standard MIDI import/export + app/ Worker jobs and real-time audition control + preview/ Offline MIDI/WAV development tool + plugin/ CLAP wrapper, editor, optional standalone + patina/ + core/ Spectral detector and attenuation engine + ui/ Shared native editor and embedded fonts + render/ Offline WAV evaluation + plugin/ CLAP audio-effect wrapper +tools/editor-check/ Headless visual QA for both editors +scripts/ Shared build, packaging, release, and analysis tools +docs/rivet/ Rivet guide, theory, research, and editor previews +docs/patina/ Patina guide, research, and editor preview +references/ Local reference audio; excluded from distribution ``` -Windows needs the Rust MSVC build tools; Linux needs X11/OpenGL development -headers, plus ALSA/JACK headers for standalone. The GitLab pipeline installs its build dependencies. A MinGW cross-build needs the `x86_64-pc-windows-gnu` Rust target and -`x86_64-w64-mingw32-gcc`. +The root Cargo workspace contains the engines and supporting tools. Each CLAP +wrapper has an independent workspace and lockfile so SDK dependencies stay +separate. [Development and testing](docs/development.md) covers the commands. ```sh -python scripts/build-plugin.py # native CLAP -python3 scripts/build-plugin.py --standalone # Linux CLAP + app -python3 scripts/build-plugin.py --target x86_64-pc-windows-gnu -cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --offline -cargo clippy --manifest-path plugins/motif-storyboard/Cargo.toml --all-targets --offline -- -D warnings -cargo run --manifest-path tools/editor-check/Cargo.toml --offline -- /tmp/editor.png +cargo test --workspace --locked +python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu +python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu +python scripts/package-windows.py ``` -Packages are written to `dist//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. diff --git a/RESONANCE_COMPRESSOR.md b/RESONANCE_COMPRESSOR.md index 0bee4f6..6a2e791 100644 --- a/RESONANCE_COMPRESSOR.md +++ b/RESONANCE_COMPRESSOR.md @@ -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. diff --git a/crates/story-preview/Cargo.toml b/crates/story-preview/Cargo.toml deleted file mode 100644 index 7603e2f..0000000 --- a/crates/story-preview/Cargo.toml +++ /dev/null @@ -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 diff --git a/docs/creative-editor.png b/docs/creative-editor.png deleted file mode 100644 index 556981e..0000000 Binary files a/docs/creative-editor.png and /dev/null differ diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ac07e12 --- /dev/null +++ b/docs/development.md @@ -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//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//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 +`-windows-x86_64-.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. diff --git a/docs/editor.png b/docs/editor.png deleted file mode 100644 index 18efa0f..0000000 Binary files a/docs/editor.png and /dev/null differ diff --git a/docs/patina/CHANGELOG.md b/docs/patina/CHANGELOG.md new file mode 100644 index 0000000..52ed31e --- /dev/null +++ b/docs/patina/CHANGELOG.md @@ -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. diff --git a/docs/resonance-compressor.md b/docs/patina/README.md similarity index 68% rename from docs/resonance-compressor.md rename to docs/patina/README.md index 7a2f69f..2a9915a 100644 --- a/docs/resonance-compressor.md +++ b/docs/patina/README.md @@ -1,17 +1,20 @@ -# Resonance Compressor 0.1 +# Patina + +Patina smooths a sound’s rough surface without erasing its character. Formerly +**Resonance Compressor**; current version **0.1.0**. A mixing effect for controlling local spectral peaks while retaining the intended sound. Independently designed in Rust; no claim of matching Soothe or Quance. -![Resonance Compressor](resonance-editor.png) +![Patina](assets/editor.png) ## Install on Windows -Download the Windows artifact from the GitLab pipeline's **windows** job and -extract the ZIP. Copy `Resonance Compressor.clap` into +Download the Windows artifact from the Gitea plugin's Gitea release, or the branch pipeline's **windows** job and +extract the ZIP. Copy `Patina.clap` into `C:\Program Files\Common Files\CLAP\`. In FL Studio 2024.1 or newer, open Options → Manage plugins, enable Verify plugins, and Find installed plugins. -Load Resonance Compressor in a mixer effect slot. Keep the font license with +Load Patina in a mixer effect slot. Keep the font license with redistributed packages. ## Work with it @@ -38,8 +41,8 @@ sample peaks can increase even when frequency gains never exceed unity. Four starting presets and A/B comparison help explore alternatives. All controls are visible. The instrument-style sidebar holds Amount, Mix, and Output; the spectrum includes filled energy traces, cut overlays, meter rails, and range -handles, using the plugin’s cyan/coral/violet palette. Host parameters and window size persist; Delta does not. The editor -supports numeric entry, keyboard focus, and direct graph editing. +handles, using the plugin’s cyan/coral/violet palette. Host parameters and window +size persist; Delta does not. The editor supports numeric entry, keyboard focus, and direct graph editing. ## Timing and limits @@ -58,11 +61,11 @@ unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1 ```sh cargo test --workspace --locked -cargo run --release -p resonance-render -- input.wav processed.wav -cargo run --release -p resonance-render -- input.wav delta.wav --delta -cargo run --release -p resonance-render -- --demo /tmp/resonance-demo.wav -cargo run --release -p resonance-render -- --benchmark -python scripts/build-plugin.py --plugin resonance-compressor --target x86_64-pc-windows-gnu +cargo run --release -p patina-render -- input.wav processed.wav +cargo run --release -p patina-render -- input.wav delta.wav --delta +cargo run --release -p patina-render -- --demo /tmp/patina-demo.wav +cargo run --release -p patina-render -- --benchmark +python scripts/build-plugin.py --plugin patina --target x86_64-pc-windows-gnu ``` The renderer accepts mono/stereo PCM16/24/32 and float32 RIFF/WAVE, capped at @@ -72,12 +75,11 @@ An optional settings JSON uses the core's Settings structure. ## CI and acceptance -The root GitLab pipeline runs checks in a Linux Docker executor and cross-builds -**Windows binaries only** using MinGW. It needs an available untagged Docker -runner, outbound access to Debian/Rust/Cargo/Git dependencies, and no Windows -runner. The Windows job produces both CLAPs, setup guides, licenses, checksums, -and a ZIP. Runner execution must be verified on the actual GitLab instance; -local verification is not a remote pipeline run. +Gitea Actions cross-builds Windows binaries with MinGW on a Docker-capable +runner labelled `ubuntu-latest`. Branch/PR workflows build a combined preview +bundle; `patina-v` 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). diff --git a/docs/patina/assets/editor.png b/docs/patina/assets/editor.png new file mode 100644 index 0000000..c122ff8 Binary files /dev/null and b/docs/patina/assets/editor.png differ diff --git a/docs/patina/research.md b/docs/patina/research.md new file mode 100644 index 0000000..0eda27b --- /dev/null +++ b/docs/patina/research.md @@ -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. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..4fe90e0 --- /dev/null +++ b/docs/releases.md @@ -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//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. diff --git a/docs/resonance-editor.png b/docs/resonance-editor.png deleted file mode 100644 index 158d6ce..0000000 Binary files a/docs/resonance-editor.png and /dev/null differ diff --git a/docs/rivet/CHANGELOG.md b/docs/rivet/CHANGELOG.md new file mode 100644 index 0000000..73f401d --- /dev/null +++ b/docs/rivet/CHANGELOG.md @@ -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. diff --git a/docs/rivet/README.md b/docs/rivet/README.md new file mode 100644 index 0000000..c3c63c7 --- /dev/null +++ b/docs/rivet/README.md @@ -0,0 +1,149 @@ +# Rivet + +Rivet joins short motifs into connected musical phrases, like a fastener joining +individual pieces. Formerly **Motif Storyboard**; current version **0.3.0**. + + +A Rust composition companion for an experienced producer developing their own +melodic ideas. Version 0.3 explores rhythm, space, contour, harmonic arrivals, and +returns, with reusable gestures, branching ideas, and two sound roles. + +![Rivet editor](assets/editor.png) + +## Write with it + +1. Import a monophonic MIDI motif of up to two bars in 4/4, or load the demo. +2. Choose a task: **Develop the motif**, **Write an answer**, **Make room**, or + **Find a return**. Choose a settled or open ending and a four/eight-bar length. +3. Decide what defines the idea. **Keep groove** fixes note positions and lengths; + **Keep contour** protects the direction of movement, allowing flattened steps + at register limits. Click source notes to lock the whole note, pitch only, or + rhythm only. Clicking uses the currently selected lock mode. Locks + and identity constraints take precedence over the proposed transformations. +4. Optionally open **Harmony** and import a chord/bass MIDI track (up to eight + bars), or create and edit a four-chord progression. Imported overlapping notes + become pitch-class regions with the actual lowest held pitch; the tool does + not guess chord roots or chord names. Empty regions remain gaps, and short + contexts repeat. Harmony guides strong notes and arrivals; the preview plays + the melody only. Supplied chord tones may extend beyond the selected scale. +5. Explore alternatives. A deterministic pool of eight transformation families + supplies up to three distinct proposals, selected for the task and differences + in rhythm, density, duration, and pitch. Fully constrained motifs may yield + fewer than three; cosmetic velocity differences do not count as new ideas. +6. Select a proposal and pin its two-bar sections. Select another to fill the + remaining sections, or explore another take. Preview and export the combined + phrase from the fixed bottom toolbar. Keep a combined result to retain it + independently of new proposals; undo/redo switches the kept result and its + previous value. Individual proposals and kept results can also be exported. + +The opening quotation stays intact. Later sections use displaced entries, +repeated pickups, fragments, subtraction, short/long answers, changed endings, +register contrast, and returns. These are compositional suggestions, not promises +of an emotion or of commercial originality. Different seeds choose structured +alternatives; they do not independently randomize every pitch. + +Pins survive task/seed/harmony changes and regeneration. Changing the motif, +its locks, or the phrase length clears pins. Pinned sections override newly +proposed material. Unsafe boundaries in legacy candidates are rejected instead +of truncating sustained notes. Changing generation inputs clears current +proposals while retaining kept work. + +## Audition and commit + +**Internal voice** provides two simple reference voices. **MIDI output** sends +notes and optional CC curves on the configured channels to routed external synths +and mutes the internal voice. Solo defaults to channel 1. +Looping is optional and enabled for new sessions. When audition is already +running, another selection waits until the next two-bar boundary or current +phrase end. Note-offs precede repeated same-pitch note-ons; stop, replacement, +loop boundaries, reset, restoration, and mode changes release active output notes +on the processing thread. Queued auditions are canceled by Stop. + +**Host tempo** follows tempo, with the local BPM as fallback. It does not align +the initial audition to the DAW's song position: start it at the desired point +in the project loop. Host start never initiates audition; host stop silences it. +Exported MIDI is in musical ticks and carries the local BPM as its tempo event. +Native OS MIDI drag-out is not implemented; export files to commit a phrase. +See [FL Studio setup and acceptance checks](fl-studio.md). + +Open **Gestures, moves, and branches** to save a source or selected two-bar +section with its performance choices. Export/import a gesture library between +projects. Save a branch before experimenting; restore any branch and make a new +child with subtraction, a delayed entry, longer gates, or transposition. +Moves respect pins and locks, reject unsafe overlaps, and report when constraints +leave nothing to change. Libraries and histories each hold up to 16 entries; +remove a gesture or a leaf branch to make room. + +Open **Sound roles and expressive gestures** to pass existing notes between two +patches: alternate events, alternate question/answer bars, or divide strong beats +from replies. These modes distribute a monophonic phrase rather than generate +a second independent melody. Assign separate MIDI channels and explicitly map +each optional CC lane to a synth macro. Curves are normalized over the whole +phrase, with editable points and rise/fall/arch/pulse starting shapes. +Names describe your intent; the plugin does not infer feelings. + +Patch profiles set register offset, gate, velocity, and estimated attack/release. +MIDI audition uses a short warmup for early triggers. Re-audition after a tempo +change to recompile millisecond offsets. Compensation estimates trigger timing; +it does not measure patch latency or model its sound. Default export retains +musical positions; compensated export adds one bar of lead-in. Role tracks carry +names, channels, and CC curves. Stop resets mapped controllers to their chosen +stop values. Internal audition does not reproduce mapped timbre. +See [the theory and producer workflow](theory/README.md). + +Sessions save inside the host project and can be saved/loaded as JSON. Version-1/2 +sessions and the original host field identifier remain supported; older sessions +retain one-shot audition. New sessions persist harmony, task, lock modes on notes, +pins, proposals, kept history, gestures, branches, patch profiles, CC curves, and +audition settings. JSON state is bounded at 16 MiB; gesture libraries at 2 MiB. File export/save refuses to +silently overwrite an existing file. + +## Build and offline preview + +Run from the repository root: + +```sh +cargo run -p rivet-preview -- demo /tmp/rivet-demo +cargo run -p rivet-preview -- develop motif.mid 0 /tmp/phrase +cargo run -p rivet-preview -- develop motif.mid 0 /tmp/with-chords chords.mid 0 +cargo run -p rivet-preview -- session saved-session.json /tmp/rendered-session +python scripts/build-plugin.py --plugin rivet --target x86_64-pc-windows-gnu +``` + +The preview writes source/candidate MIDI, a session JSON, and WAV using the same +engine as the plugin. Track indices exclude conductor-only tracks and start at +zero. Optional harmony arguments accept a separate polyphonic MIDI file. +See [development](../development.md) for checks, dependencies, architecture, and +[releases](../releases.md) for independent release tags. + +## Design and references + +[Theory findings and implemented design](theory/README.md) connect the research +to practical controls. [Implementation plan](implementation.md) records the musical and technical +choices. [Devath reference measurements](research/devath.md) document +analysis of the seven supplied FLACs, with reproducible plots and explicit limits. +Original reference audio is excluded from source distribution. + +- `rivet-core`: musical model, harmonic context, structured proposals, section + composition, session migration, and bounded playback clips. +- `rivet-midi`: bounded SMF 0/1 import/export. Rejects SMPTE, unsupported meters, + incomplete notes, and timing not exactly representable at 960 PPQ. Reports + ignored controllers/pitch bends. Polyphonic tracks work as harmonic context; + motif import requires monophony. +- `rivet-app`: worker jobs, revision handling, bounded queues, sample-accurate + preview/MIDI scheduling, and Windows/Zenity dialogs. +- `rivet`: CLAP wrapper, host persistence, editor, and standalone. + +Tests cover musical constraints/diversity, harmony, legacy state, section seams, +MIDI round trips, sample timing, looping, queued switches, note releases, and +zero allocation/deallocation during rendering and queued clip replacement. The +CLAP ABI test verifies discovery, note-output port exposure, state restoration, +and silent startup at several sample rates and buffer sizes. + +## Existing projects + +The name, Cargo packages, source paths, and output filename have changed. +The CLAP ID `io.storyboard.motif`, `story-session-v1` host field, and session +JSON format are preserved. Rescan FL Studio after replacing the old plugin file +with `Rivet.clap`; remove the old copy from scanned folders to avoid duplicate +instances with the same ID. Project recall still needs a check in the target host. diff --git a/docs/rivet/assets/creative-editor.png b/docs/rivet/assets/creative-editor.png new file mode 100644 index 0000000..f4c71d5 Binary files /dev/null and b/docs/rivet/assets/creative-editor.png differ diff --git a/docs/rivet/assets/editor.png b/docs/rivet/assets/editor.png new file mode 100644 index 0000000..5f0aa31 Binary files /dev/null and b/docs/rivet/assets/editor.png differ diff --git a/docs/fl-studio.md b/docs/rivet/fl-studio.md similarity index 97% rename from docs/fl-studio.md rename to docs/rivet/fl-studio.md index eb7e641..3e190a1 100644 --- a/docs/fl-studio.md +++ b/docs/rivet/fl-studio.md @@ -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). diff --git a/docs/variation-plan.md b/docs/rivet/implementation.md similarity index 98% rename from docs/variation-plan.md rename to docs/rivet/implementation.md index 4ef6df9..55f0ad2 100644 --- a/docs/variation-plan.md +++ b/docs/rivet/implementation.md @@ -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, diff --git a/docs/devath-reference-notes.md b/docs/rivet/research/devath.md similarity index 92% rename from docs/devath-reference-notes.md rename to docs/rivet/research/devath.md index 401039b..0e53694 100644 --- a/docs/devath-reference-notes.md +++ b/docs/rivet/research/devath.md @@ -4,7 +4,7 @@ Analyzed the seven user-supplied FLACs in this workspace using their actual audi samples. This is signal analysis of the rendered mixes, not a listening-based transcription of their melodies or an account of the artist's intent. -![Relative level and spectral attack timelines](reference-analysis/reference-timelines.png) +![Relative level and spectral attack timelines](measurements/reference-timelines.png) ## Observable contrasts @@ -34,7 +34,7 @@ No source notes or audio were copied into presets or used to train a model. ## Reproduce ```sh -python scripts/analyze-references.py . --output docs/reference-analysis +python scripts/analyze-references.py references/devath --output docs/rivet/research/measurements ``` Requires `ffmpeg`, NumPy, and Matplotlib. The script decodes mono audio at 11,025 @@ -42,7 +42,7 @@ Hz and uses a 2,048-sample Hann window with a 256-sample hop. It summarizes RMS and positive log-spectral flux per second, normalizing each curve to its own 95th percentile for visibility. Candidate texture changes compare standardized low/mid/high-band energy over eight-second neighborhoods, with separated peaks. -[Raw summaries](reference-analysis/measurements.json) include track durations and +[Raw summaries](measurements/measurements.json) include track durations and within-track 80th/20th percentile ratios. Automatic tempo and chord estimates are intentionally omitted: coarse onset periodicity and spectral peaks are not reliable transcriptions of these full mixes. diff --git a/docs/reference-analysis/measurements.json b/docs/rivet/research/measurements/measurements.json similarity index 100% rename from docs/reference-analysis/measurements.json rename to docs/rivet/research/measurements/measurements.json diff --git a/docs/reference-analysis/reference-timelines.png b/docs/rivet/research/measurements/reference-timelines.png similarity index 100% rename from docs/reference-analysis/reference-timelines.png rename to docs/rivet/research/measurements/reference-timelines.png diff --git a/docs/psychoacoustics-and-creative-direction.md b/docs/rivet/research/psychoacoustics.md similarity index 99% rename from docs/psychoacoustics-and-creative-direction.md rename to docs/rivet/research/psychoacoustics.md index 87f7b2b..74ed123 100644 --- a/docs/psychoacoustics-and-creative-direction.md +++ b/docs/rivet/research/psychoacoustics.md @@ -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 diff --git a/docs/theory/README.md b/docs/rivet/theory/README.md similarity index 94% rename from docs/theory/README.md rename to docs/rivet/theory/README.md index 04409f6..bed04d8 100644 --- a/docs/theory/README.md +++ b/docs/rivet/theory/README.md @@ -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). diff --git a/plugins/catalog.json b/plugins/catalog.json new file mode 100644 index 0000000..751b27e --- /dev/null +++ b/plugins/catalog.json @@ -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" + ] + } +} diff --git a/plugins/motif-storyboard/src/main.rs b/plugins/motif-storyboard/src/main.rs deleted file mode 100644 index 529a709..0000000 --- a/plugins/motif-storyboard/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - nih_plug::prelude::nih_export_standalone::(); -} diff --git a/crates/resonance-core/Cargo.toml b/plugins/patina/core/Cargo.toml similarity index 88% rename from crates/resonance-core/Cargo.toml rename to plugins/patina/core/Cargo.toml index 75b67c6..5ddfcd2 100644 --- a/crates/resonance-core/Cargo.toml +++ b/plugins/patina/core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "resonance-core" +name = "patina-core" version = "0.1.0" edition = "2021" license = "ISC" diff --git a/crates/resonance-core/src/lib.rs b/plugins/patina/core/src/lib.rs similarity index 100% rename from crates/resonance-core/src/lib.rs rename to plugins/patina/core/src/lib.rs diff --git a/crates/resonance-core/src/settings.rs b/plugins/patina/core/src/settings.rs similarity index 100% rename from crates/resonance-core/src/settings.rs rename to plugins/patina/core/src/settings.rs diff --git a/crates/resonance-core/tests/dsp.rs b/plugins/patina/core/tests/dsp.rs similarity index 99% rename from crates/resonance-core/tests/dsp.rs rename to plugins/patina/core/tests/dsp.rs index e8959c4..3da233e 100644 --- a/crates/resonance-core/tests/dsp.rs +++ b/plugins/patina/core/tests/dsp.rs @@ -1,4 +1,4 @@ -use resonance_core::*; +use patina_core::*; use std::f32::consts::TAU; fn noise(i: usize) -> f32 { diff --git a/crates/resonance-core/tests/realtime.rs b/plugins/patina/core/tests/realtime.rs similarity index 97% rename from crates/resonance-core/tests/realtime.rs rename to plugins/patina/core/tests/realtime.rs index be0566d..c71d456 100644 --- a/crates/resonance-core/tests/realtime.rs +++ b/plugins/patina/core/tests/realtime.rs @@ -1,4 +1,4 @@ -use resonance_core::{Engine, Monitor, Settings}; +use patina_core::{Engine, Monitor, Settings}; use std::{ alloc::{GlobalAlloc, Layout, System}, cell::Cell, diff --git a/plugins/resonance-compressor/Cargo.lock b/plugins/patina/plugin/Cargo.lock similarity index 99% rename from plugins/resonance-compressor/Cargo.lock rename to plugins/patina/plugin/Cargo.lock index 06c3c75..7e14d18 100644 --- a/plugins/resonance-compressor/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -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" diff --git a/plugins/resonance-compressor/Cargo.toml b/plugins/patina/plugin/Cargo.toml similarity index 81% rename from plugins/resonance-compressor/Cargo.toml rename to plugins/patina/plugin/Cargo.toml index 070f3db..0f224e4 100644 --- a/plugins/resonance-compressor/Cargo.toml +++ b/plugins/patina/plugin/Cargo.toml @@ -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] diff --git a/plugins/resonance-compressor/src/lib.rs b/plugins/patina/plugin/src/lib.rs similarity index 91% rename from plugins/resonance-compressor/src/lib.rs rename to plugins/patina/plugin/src/lib.rs index 469ac39..54c08be 100644 --- a/plugins/resonance-compressor/src/lib.rs +++ b/plugins/patina/plugin/src/lib.rs @@ -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, +pub struct Patina { + params: Arc, shared: Arc, engine: Option, 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); diff --git a/plugins/resonance-compressor/src/params.rs b/plugins/patina/plugin/src/params.rs similarity index 97% rename from plugins/resonance-compressor/src/params.rs rename to plugins/patina/plugin/src/params.rs index 9eb12c0..ba2763f 100644 --- a/plugins/resonance-compressor/src/params.rs +++ b/plugins/patina/plugin/src/params.rs @@ -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, #[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, diff --git a/plugins/resonance-compressor/tests/host.rs b/plugins/patina/plugin/tests/host.rs similarity index 97% rename from plugins/resonance-compressor/tests/host.rs rename to plugins/patina/plugin/tests/host.rs index 690b491..24c039d 100644 --- a/plugins/resonance-compressor/tests/host.rs +++ b/plugins/patina/plugin/tests/host.rs @@ -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" diff --git a/crates/resonance-render/Cargo.toml b/plugins/patina/render/Cargo.toml similarity index 55% rename from crates/resonance-render/Cargo.toml rename to plugins/patina/render/Cargo.toml index b5f113f..3e0b7f1 100644 --- a/crates/resonance-render/Cargo.toml +++ b/plugins/patina/render/Cargo.toml @@ -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" diff --git a/crates/resonance-render/src/main.rs b/plugins/patina/render/src/main.rs similarity index 95% rename from crates/resonance-render/src/main.rs rename to plugins/patina/render/src/main.rs index 5255a2a..14bbe7f 100644 --- a/crates/resonance-render/src/main.rs +++ b/plugins/patina/render/src/main.rs @@ -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> { let args: Vec<_> = env::args().skip(1).collect(); if args.is_empty() || args[0] == "--help" { - println!("resonance-render [settings.json] [--delta]\nresonance-render --demo \nresonance-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused."); + println!("patina-render [settings.json] [--delta]\npatina-render --demo \npatina-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused."); return Ok(()); } if args[0] == "--benchmark" { diff --git a/crates/resonance-ui/Cargo.toml b/plugins/patina/ui/Cargo.toml similarity index 57% rename from crates/resonance-ui/Cargo.toml rename to plugins/patina/ui/Cargo.toml index 8bab09c..a292b1e 100644 --- a/crates/resonance-ui/Cargo.toml +++ b/plugins/patina/ui/Cargo.toml @@ -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" } diff --git a/crates/resonance-ui/assets/OFL.txt b/plugins/patina/ui/assets/OFL.txt similarity index 100% rename from crates/resonance-ui/assets/OFL.txt rename to plugins/patina/ui/assets/OFL.txt diff --git a/crates/resonance-ui/assets/RobotoCondensed-Medium.ttf b/plugins/patina/ui/assets/RobotoCondensed-Medium.ttf similarity index 100% rename from crates/resonance-ui/assets/RobotoCondensed-Medium.ttf rename to plugins/patina/ui/assets/RobotoCondensed-Medium.ttf diff --git a/crates/resonance-ui/assets/RobotoCondensed-Regular.ttf b/plugins/patina/ui/assets/RobotoCondensed-Regular.ttf similarity index 100% rename from crates/resonance-ui/assets/RobotoCondensed-Regular.ttf rename to plugins/patina/ui/assets/RobotoCondensed-Regular.ttf diff --git a/crates/resonance-ui/src/lib.rs b/plugins/patina/ui/src/lib.rs similarity index 99% rename from crates/resonance-ui/src/lib.rs rename to plugins/patina/ui/src/lib.rs index fc0af37..5efbf7f 100644 --- a/crates/resonance-ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -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."); diff --git a/crates/story-app/Cargo.toml b/plugins/rivet/app/Cargo.toml similarity index 70% rename from crates/story-app/Cargo.toml rename to plugins/rivet/app/Cargo.toml index 6e2254a..da4dff9 100644 --- a/crates/story-app/Cargo.toml +++ b/plugins/rivet/app/Cargo.toml @@ -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] diff --git a/crates/story-app/src/dialog.rs b/plugins/rivet/app/src/dialog.rs similarity index 98% rename from crates/story-app/src/dialog.rs rename to plugins/rivet/app/src/dialog.rs index 815b322..81a1cba 100644 --- a/crates/story-app/src/dialog.rs +++ b/plugins/rivet/app/src/dialog.rs @@ -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> { diff --git a/crates/story-app/src/lib.rs b/plugins/rivet/app/src/lib.rs similarity index 97% rename from crates/story-app/src/lib.rs rename to plugins/rivet/app/src/lib.rs index e0c4dd7..33363b8 100644 --- a/crates/story-app/src/lib.rs +++ b/plugins/rivet/app/src/lib.rs @@ -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>, } pub enum Output { - Imported(story_midi::MidiFile), + Imported(rivet_midi::MidiFile), Generated(Vec), Exported(PathBuf), Saved(PathBuf), Loaded(Box), - LibraryLoaded(Vec), + LibraryLoaded(Vec), Canceled, } pub struct Completion { @@ -120,7 +120,7 @@ impl Shared { self.publish(¤t); Ok(()) } - fn compile_clip(&self, phrase: &story_core::Phrase) -> Result { + fn compile_clip(&self, phrase: &rivet_core::Phrase) -> Result { 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(); diff --git a/crates/story-app/tests/midi.rs b/plugins/rivet/app/tests/midi.rs similarity index 97% rename from crates/story-app/tests/midi.rs rename to plugins/rivet/app/tests/midi.rs index d2f54d7..e4e9589 100644 --- a/crates/story-app/tests/midi.rs +++ b/plugins/rivet/app/tests/midi.rs @@ -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 { diff --git a/crates/story-app/tests/realtime.rs b/plugins/rivet/app/tests/realtime.rs similarity index 89% rename from crates/story-app/tests/realtime.rs rename to plugins/rivet/app/tests/realtime.rs index 189f9ea..04d0168 100644 --- a/crates/story-app/tests/realtime.rs +++ b/plugins/rivet/app/tests/realtime.rs @@ -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)); diff --git a/crates/story-app/tests/roles.rs b/plugins/rivet/app/tests/roles.rs similarity index 98% rename from crates/story-app/tests/roles.rs rename to plugins/rivet/app/tests/roles.rs index 1246449..e69c4d9 100644 --- a/crates/story-app/tests/roles.rs +++ b/plugins/rivet/app/tests/roles.rs @@ -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, diff --git a/crates/story-core/Cargo.toml b/plugins/rivet/core/Cargo.toml similarity index 73% rename from crates/story-core/Cargo.toml rename to plugins/rivet/core/Cargo.toml index 289ba45..599f960 100644 --- a/crates/story-core/Cargo.toml +++ b/plugins/rivet/core/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "story-core" -version.workspace = true +name = "rivet-core" +version = "0.3.0" edition.workspace = true license.workspace = true diff --git a/crates/story-core/src/creative.rs b/plugins/rivet/core/src/creative.rs similarity index 100% rename from crates/story-core/src/creative.rs rename to plugins/rivet/core/src/creative.rs diff --git a/crates/story-core/src/harmony.rs b/plugins/rivet/core/src/harmony.rs similarity index 100% rename from crates/story-core/src/harmony.rs rename to plugins/rivet/core/src/harmony.rs diff --git a/crates/story-core/src/lib.rs b/plugins/rivet/core/src/lib.rs similarity index 100% rename from crates/story-core/src/lib.rs rename to plugins/rivet/core/src/lib.rs diff --git a/crates/story-core/src/performance.rs b/plugins/rivet/core/src/performance.rs similarity index 100% rename from crates/story-core/src/performance.rs rename to plugins/rivet/core/src/performance.rs diff --git a/crates/story-core/src/playback.rs b/plugins/rivet/core/src/playback.rs similarity index 100% rename from crates/story-core/src/playback.rs rename to plugins/rivet/core/src/playback.rs diff --git a/crates/story-core/src/variation.rs b/plugins/rivet/core/src/variation.rs similarity index 100% rename from crates/story-core/src/variation.rs rename to plugins/rivet/core/src/variation.rs diff --git a/crates/story-core/tests/creative.rs b/plugins/rivet/core/tests/creative.rs similarity index 99% rename from crates/story-core/tests/creative.rs rename to plugins/rivet/core/tests/creative.rs index fdf2f07..5c1a597 100644 --- a/crates/story-core/tests/creative.rs +++ b/plugins/rivet/core/tests/creative.rs @@ -1,4 +1,4 @@ -use story_core::*; +use rivet_core::*; fn session() -> Session { let mut s = Session { diff --git a/crates/story-core/tests/musical.rs b/plugins/rivet/core/tests/musical.rs similarity index 99% rename from crates/story-core/tests/musical.rs rename to plugins/rivet/core/tests/musical.rs index 3da1104..1416720 100644 --- a/crates/story-core/tests/musical.rs +++ b/plugins/rivet/core/tests/musical.rs @@ -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() diff --git a/crates/story-core/tests/performance.rs b/plugins/rivet/core/tests/performance.rs similarity index 99% rename from crates/story-core/tests/performance.rs rename to plugins/rivet/core/tests/performance.rs index c1cda2a..06cb140 100644 --- a/crates/story-core/tests/performance.rs +++ b/plugins/rivet/core/tests/performance.rs @@ -1,4 +1,4 @@ -use story_core::*; +use rivet_core::*; fn held() -> Phrase { Phrase { length: PPQ, diff --git a/crates/story-midi/Cargo.toml b/plugins/rivet/midi/Cargo.toml similarity index 51% rename from crates/story-midi/Cargo.toml rename to plugins/rivet/midi/Cargo.toml index 19704d2..c251546 100644 --- a/crates/story-midi/Cargo.toml +++ b/plugins/rivet/midi/Cargo.toml @@ -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 diff --git a/crates/story-midi/src/lib.rs b/plugins/rivet/midi/src/lib.rs similarity index 97% rename from crates/story-midi/src/lib.rs rename to plugins/rivet/midi/src/lib.rs index 57e1d5f..805fe25 100644 --- a/crates/story-midi/src/lib.rs +++ b/plugins/rivet/midi/src/lib.rs @@ -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(); diff --git a/plugins/motif-storyboard/Cargo.lock b/plugins/rivet/plugin/Cargo.lock similarity index 99% rename from plugins/motif-storyboard/Cargo.lock rename to plugins/rivet/plugin/Cargo.lock index 1ab6628..f3a4bf3 100644 --- a/plugins/motif-storyboard/Cargo.lock +++ b/plugins/rivet/plugin/Cargo.lock @@ -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" diff --git a/plugins/motif-storyboard/Cargo.toml b/plugins/rivet/plugin/Cargo.toml similarity index 79% rename from plugins/motif-storyboard/Cargo.toml rename to plugins/rivet/plugin/Cargo.toml index 1738f15..712dfd0 100644 --- a/plugins/motif-storyboard/Cargo.toml +++ b/plugins/rivet/plugin/Cargo.toml @@ -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" } diff --git a/plugins/motif-storyboard/src/creative_editor.rs b/plugins/rivet/plugin/src/creative_editor.rs similarity index 96% rename from plugins/motif-storyboard/src/creative_editor.rs rename to plugins/rivet/plugin/src/creative_editor.rs index 8ef1bd5..07df939 100644 --- a/plugins/motif-storyboard/src/creative_editor.rs +++ b/plugins/rivet/plugin/src/creative_editor.rs @@ -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}); } } } } diff --git a/plugins/motif-storyboard/src/editor.rs b/plugins/rivet/plugin/src/editor.rs similarity index 97% rename from plugins/motif-storyboard/src/editor.rs rename to plugins/rivet/plugin/src/editor.rs index 2455fd4..c720b81 100644 --- a/plugins/motif-storyboard/src/editor.rs +++ b/plugins/rivet/plugin/src/editor.rs @@ -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, + pending: Option, track: usize, preview_lane: Option, pending_lane: Option, @@ -109,13 +109,13 @@ fn import(shared: &Arc, state: &mut EditorState, path: Option, 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, 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, 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, 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, diff --git a/plugins/motif-storyboard/src/lib.rs b/plugins/rivet/plugin/src/lib.rs similarity index 91% rename from plugins/motif-storyboard/src/lib.rs rename to plugins/rivet/plugin/src/lib.rs index ccf7923..f0e5db0 100644 --- a/plugins/motif-storyboard/src/lib.rs +++ b/plugins/rivet/plugin/src/lib.rs @@ -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); @@ -24,7 +24,7 @@ impl<'a> PersistentField<'a, Session> for SessionField { } #[derive(Params)] -struct StoryParams { +struct RivetParams { #[persist = "editor"] editor: Arc, #[persist = "story-session-v1"] @@ -34,17 +34,17 @@ struct StoryParams { revision: IntParam, } -pub struct MotifStoryboard { - params: Arc, +pub struct Rivet { + params: Arc, shared: Arc, 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); diff --git a/plugins/rivet/plugin/src/main.rs b/plugins/rivet/plugin/src/main.rs new file mode 100644 index 0000000..2e54f58 --- /dev/null +++ b/plugins/rivet/plugin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + nih_plug::prelude::nih_export_standalone::(); +} diff --git a/plugins/motif-storyboard/tests/host.rs b/plugins/rivet/plugin/tests/host.rs similarity index 92% rename from plugins/motif-storyboard/tests/host.rs rename to plugins/rivet/plugin/tests/host.rs index e25ed94..3cd4d77 100644 --- a/plugins/motif-storyboard/tests/host.rs +++ b/plugins/rivet/plugin/tests/host.rs @@ -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.] { diff --git a/plugins/rivet/preview/Cargo.toml b/plugins/rivet/preview/Cargo.toml new file mode 100644 index 0000000..3f81e44 --- /dev/null +++ b/plugins/rivet/preview/Cargo.toml @@ -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 diff --git a/crates/story-preview/src/main.rs b/plugins/rivet/preview/src/main.rs similarity index 82% rename from crates/story-preview/src/main.rs rename to plugins/rivet/preview/src/main.rs index 542378d..5e3d50f 100644 --- a/crates/story-preview/src/main.rs +++ b/plugins/rivet/preview/src/main.rs @@ -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> { let args: Vec<_> = env::args().skip(1).collect(); if args.is_empty() || args[0] == "--help" { - println!("Motif Storyboard preview\n story-preview demo \n story-preview develop [harmony.mid harmony-track-index]\n story-preview session \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 \n rivet-preview develop [harmony.mid harmony-track-index]\n rivet-preview session \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> { } "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> { _ => 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> { 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.)?; diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..9432286 --- /dev/null +++ b/references/README.md @@ -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. diff --git a/scripts/analyze-references.py b/scripts/analyze-references.py index b078209..72983f0 100644 --- a/scripts/analyze-references.py +++ b/scripts/analyze-references.py @@ -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) diff --git a/scripts/build-plugin.py b/scripts/build-plugin.py index d8457f1..a3bae2a 100644 --- a/scripts/build-plugin.py +++ b/scripts/build-plugin.py @@ -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}") diff --git a/scripts/check-plugin.py b/scripts/check-plugin.py new file mode 100644 index 0000000..066a824 --- /dev/null +++ b/scripts/check-plugin.py @@ -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) diff --git a/scripts/package-windows.py b/scripts/package-windows.py index 32db090..ab372ac 100644 --- a/scripts/package-windows.py +++ b/scripts/package-windows.py @@ -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(" len(data) or data[offset:offset+4] != b"PE\0\0" or struct.unpack_from(" or patina-v 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 diff --git a/scripts/tests/test_release.py b/scripts/tests/test_release.py new file mode 100644 index 0000000..542063b --- /dev/null +++ b/scripts/tests/test_release.py @@ -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(" = 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}"); }