feat: add resonance compressor and Windows CI

This commit is contained in:
nvrl committed 2026-11-05 23:37:24 +01:00
1 parent 3281535a04
commit 3c5b05fdec
31 files changed
+6340 -57

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
name: Check and build
on: [push, pull_request, workflow_dispatch]
permissions:
contents: read
jobs:
core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt --all --check
- run: cargo test --workspace --locked
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
plugin:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Linux GUI and audio headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-jackd2-dev libgl1-mesa-dev libx11-dev libxcursor-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxkbcommon-x11-dev
- run: cargo fmt --manifest-path plugins/motif-storyboard/Cargo.toml --check
- run: cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --locked
- name: Windows CLAP
if: runner.os == 'Windows'
run: python scripts/build-plugin.py
- name: Linux CLAP and standalone
if: runner.os == 'Linux'
run: python3 scripts/build-plugin.py --standalone
- uses: actions/upload-artifact@v4
with:
name: motif-storyboard-${{ runner.os }}
path: dist/
+3
View File
@@ -33,3 +33,6 @@ venv/
*~
.DS_Store
Thumbs.db
# GitLab job-local Cargo cache.
/.cargo-ci/
+80
View File
@@ -0,0 +1,80 @@
# Linux Docker runner, Windows deliverables. No Linux plugin artifacts.
image: rust:1-bookworm
stages: [check, build]
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"'
when: never
- if: '$CI_COMMIT_BRANCH || $CI_COMMIT_TAG'
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo-ci"
CARGO_TERM_COLOR: always
WINDOWS_TARGET: x86_64-pc-windows-gnu
default:
interruptible: true
cache:
key:
files:
- Cargo.lock
- plugins/resonance-compressor/Cargo.lock
prefix: rust-windows-v1
paths:
- .cargo-ci/registry/
- .cargo-ci/git/
check:
stage: check
before_script:
- apt-get update
- apt-get install -y --no-install-recommends python3 pkg-config libgl1-mesa-dev libx11-dev libxcursor-dev libx11-xcb-dev libxcb1-dev libxcb-dri2-0-dev libxcb-icccm4-dev libxkbcommon-x11-dev
- rustup component add rustfmt clippy
script:
- cargo fmt --manifest-path tools/editor-check/Cargo.toml -- --check
- cargo fmt --all -- --check
- cargo test --workspace --locked
- cargo clippy --workspace --all-targets --locked -- -D warnings
- cargo fmt --manifest-path plugins/motif-storyboard/Cargo.toml -- --check
- cargo test --manifest-path plugins/motif-storyboard/Cargo.toml --locked
- cargo clippy --manifest-path plugins/motif-storyboard/Cargo.toml --all-targets --locked -- -D warnings
- cargo fmt --manifest-path plugins/resonance-compressor/Cargo.toml -- --check
- cargo test --manifest-path plugins/resonance-compressor/Cargo.toml --locked
- cargo clippy --manifest-path plugins/resonance-compressor/Cargo.toml --all-targets --locked -- -D warnings
- mkdir -p target/editor
- cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/resonance-default.png --resonance
- cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/resonance-compact.png --resonance --compact
artifacts:
name: "editor-previews-$CI_COMMIT_SHORT_SHA"
expire_in: 7 days
paths:
- target/editor/*.png
windows:
stage: build
needs:
- job: check
artifacts: false
variables:
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target/windows"
before_script:
- apt-get update
- apt-get install -y --no-install-recommends python3 gcc-mingw-w64-x86-64
- rustup target add "$WINDOWS_TARGET"
script:
- python3 scripts/build-plugin.py --plugin motif-storyboard --target "$WINDOWS_TARGET"
- python3 scripts/build-plugin.py --plugin resonance-compressor --target "$WINDOWS_TARGET"
- python3 scripts/package-windows.py
artifacts:
name: "fl-plugins-windows-$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
expire_in: 30 days
paths:
- dist/fl-plugins-windows-x86_64.zip
- dist/x86_64-pc-windows-gnu/*.clap
- dist/x86_64-pc-windows-gnu/*setup.md
- dist/x86_64-pc-windows-gnu/LICENSE
- dist/x86_64-pc-windows-gnu/Roboto-OFL.txt
- dist/x86_64-pc-windows-gnu/SHA256SUMS
Generated
+318 -1
View File
@@ -2,6 +2,52 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -17,18 +63,164 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "ecolor"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1"
dependencies = [
"emath",
]
[[package]]
name = "egui"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3"
dependencies = [
"ahash",
"bitflags",
"emath",
"epaint",
"nohash-hasher",
"profiling",
]
[[package]]
name = "emath"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
[[package]]
name = "epaint"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562"
dependencies = [
"ab_glyph",
"ahash",
"ecolor",
"emath",
"epaint_default_fonts",
"nohash-hasher",
"parking_lot",
"profiling",
]
[[package]]
name = "epaint_default_fonts"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -38,6 +230,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "profiling"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quote"
version = "1.0.47"
@@ -47,6 +245,60 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "resonance-core"
version = "0.1.0"
dependencies = [
"rustfft",
"serde",
"serde_json",
]
[[package]]
name = "resonance-render"
version = "0.1.0"
dependencies = [
"resonance-core",
"serde_json",
]
[[package]]
name = "resonance-ui"
version = "0.1.0"
dependencies = [
"egui",
"resonance-core",
]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@@ -74,7 +326,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -90,6 +342,12 @@ dependencies = [
"zmij",
]
[[package]]
name = "smallvec"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "story-app"
version = "0.3.0"
@@ -123,6 +381,23 @@ dependencies = [
"story-midi",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -134,12 +409,34 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -155,6 +452,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
+2 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["crates/story-core", "crates/story-midi", "crates/story-preview", "crates/story-app"]
exclude = ["plugins/motif-storyboard", "tools/editor-check"]
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"]
[workspace.package]
version = "0.3.0"
+14 -5
View File
@@ -1,4 +1,13 @@
# Motif Storyboard
# FL Studio plugins
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.
GitLab CI builds a Windows x86-64 ZIP containing both plugins, setup guides,
licenses, and checksums. Download it from the **windows** job artifacts.
## Motif Storyboard
A Rust composition companion for an experienced producer developing their own
melodic ideas. Version 0.3 explores rhythm, space, contour, harmonic arrivals, and
@@ -119,8 +128,7 @@ cargo run -p story-preview -- session saved-session.json /tmp/rendered-session
```
Windows needs the Rust MSVC build tools; Linux needs X11/OpenGL development
headers, plus ALSA/JACK headers for standalone. The CI workflow lists Ubuntu
packages. A MinGW cross-build needs the `x86_64-pc-windows-gnu` Rust target and
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`.
```sh
@@ -137,8 +145,9 @@ 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.
CI is configured for Windows CLAP and Linux CLAP/standalone, but has not been
triggered remotely from this workspace.
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
+554
View File
@@ -0,0 +1,554 @@
# Resonance compressor: research and proposed 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](docs/resonance-compressor.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.
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "resonance-core"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
rustfft = "6.4"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
+404
View File
@@ -0,0 +1,404 @@
//! Independently designed spectral attenuation. No host or GUI dependencies.
mod settings;
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
pub use settings::{focus_offset, knee, Focus, Settings};
use std::{f32::consts::PI, sync::Arc};
pub const GRID: usize = 512;
#[derive(Clone, Copy, Debug)]
pub struct AnalyzerFrame {
pub input: [f32; GRID],
pub reduction: [f32; GRID],
pub focus: [f32; GRID],
pub input_peak: f32,
pub output_peak: f32,
pub guard: f32,
pub sample_rate: f32,
pub latency: u32,
pub channels: usize,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
input: [-120.; GRID],
reduction: [0.; GRID],
focus: [0.; GRID],
input_peak: 0.,
output_peak: 0.,
guard: 0.,
sample_rate: 48000.,
latency: 4096,
channels: 2,
}
}
}
pub fn frequency(index: usize) -> f32 {
20. * 1000f32.powf(index as f32 / (GRID - 1) as f32)
}
#[derive(Clone, Copy, Default)]
pub struct Monitor {
pub delta: bool,
}
pub struct Engine {
rate: f32,
channels: usize,
n: usize,
hop: usize,
pos: usize,
hop_pos: usize,
input: [Vec<f32>; 2],
output: [Vec<f32>; 2],
spectra: [Vec<Complex32>; 2],
window: Vec<f32>,
scratch: Vec<Complex32>,
forward: Arc<dyn Fft<f32>>,
inverse: Arc<dyn Fft<f32>>,
levels: [[f32; GRID]; 2],
target: [f32; GRID],
reduction: [f32; GRID],
curve: [f32; GRID],
mask: [f32; GRID],
map: [(usize, f32); GRID],
bin_map: Vec<(usize, f32)>,
kernel: [f32; 65],
radius: usize,
settings: Settings,
frame: AnalyzerFrame,
fresh: bool,
fast: f32,
slow: f32,
protect: f32,
sample_power: f32,
mix: f32,
gain: f32,
bypass: f32,
delta: f32,
mix_initialized: bool,
power_scale: f32,
in_peak: f32,
out_peak: f32,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if ![44100., 48000., 88200., 96000., 176400., 192000.].contains(&rate)
|| !(1..=2).contains(&channels)
{
return Err("Supported rates: 44.1/48/88.2/96/176.4/192 kHz; mono or stereo");
}
let scale = if rate > 96000. {
4
} else if rate > 48000. {
2
} else {
1
};
let n = 4096 * scale;
let hop = n / 8;
let mut planner = FftPlanner::new();
let forward = planner.plan_fft_forward(n);
let inverse = planner.plan_fft_inverse(n);
let scratch_len = forward
.get_inplace_scratch_len()
.max(inverse.get_inplace_scratch_len());
let window: Vec<_> = (0..n)
.map(|i| {
(0.5 - 0.5 * (2. * PI * i as f32 / n as f32).cos())
.max(0.)
.sqrt()
})
.collect();
let power_scale = 2. / window.iter().sum::<f32>().powi(2);
let nyquist = rate * 0.5;
let map = std::array::from_fn(|i| {
let bin = (frequency(i).min(nyquist) * n as f32 / rate).min((n / 2 - 1) as f32);
(bin.floor() as usize, bin.fract())
});
let bin_map = (0..=n / 2)
.map(|k| {
let grid = ((k as f32 * rate / n as f32).max(20.) / 20.).log2() / 1000f32.log2()
* (GRID - 1) as f32;
let grid = grid.clamp(0., (GRID - 1) as f32);
(
(grid.floor() as usize).min(GRID - 2),
if grid >= (GRID - 1) as f32 {
1.
} else {
grid.fract()
},
)
})
.collect();
let mut engine = Self {
rate,
channels,
n,
hop,
pos: 0,
hop_pos: 0,
input: std::array::from_fn(|_| vec![0.; n]),
output: std::array::from_fn(|_| vec![0.; n]),
spectra: std::array::from_fn(|_| vec![Complex32::default(); n]),
window,
scratch: vec![Complex32::default(); scratch_len],
forward,
inverse,
levels: [[-120.; GRID]; 2],
target: [0.; GRID],
reduction: [0.; GRID],
curve: [0.; GRID],
mask: [0.; GRID],
map,
bin_map,
kernel: [0.; 65],
radius: 0,
settings: Settings::default(),
frame: AnalyzerFrame::default(),
fresh: false,
fast: 0.,
slow: 0.,
protect: 0.,
sample_power: 0.,
mix: 1.,
gain: 1.,
bypass: 0.,
delta: 0.,
mix_initialized: false,
power_scale,
in_peak: 0.,
out_peak: 0.,
};
engine.rebuild();
Ok(engine)
}
pub fn latency_samples(&self) -> u32 {
self.n as u32
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> Settings {
self.settings
}
pub fn set_settings(&mut self, settings: Settings) -> Result<(), &'static str> {
settings.validate()?;
if settings != self.settings {
let rebuild = settings.focus != self.settings.focus
|| settings.low != self.settings.low
|| settings.high != self.settings.high
|| settings.cut_width != self.settings.cut_width;
self.settings = settings;
if rebuild {
self.rebuild();
}
}
if !self.mix_initialized {
self.mix = settings.mix / 100.;
self.gain = 10f32.powf(settings.output / 20.);
self.bypass = f32::from(settings.bypass);
self.mix_initialized = true;
}
Ok(())
}
fn rebuild(&mut self) {
let step = 1000f32.log2() / (GRID - 1) as f32;
self.radius = ((self.settings.cut_width / step * 1.3).ceil() as usize).clamp(1, 64);
let sigma = self.settings.cut_width / step / 2.355;
for j in 0..=self.radius {
self.kernel[j] = (-0.5 * (j as f32 / sigma).powi(2)).exp();
}
for i in 0..GRID {
let f = frequency(i);
self.curve[i] = focus_offset(f, &self.settings.focus);
let low = (f / self.settings.low).log2() / 0.15;
let high = (self.settings.high.min(self.rate * 0.49) / f).log2() / 0.15;
let edge = low.min(high).clamp(0., 1.);
self.mask[i] = edge * edge * (3. - 2. * edge);
}
}
pub fn reset(&mut self) {
for channel in &mut self.input {
channel.fill(0.);
}
for channel in &mut self.output {
channel.fill(0.);
}
self.reduction.fill(0.);
self.pos = 0;
self.hop_pos = 0;
self.fast = 0.;
self.slow = 0.;
self.protect = 0.;
self.sample_power = 0.;
self.in_peak = 0.;
self.out_peak = 0.;
self.fresh = false;
self.frame = AnalyzerFrame {
sample_rate: self.rate,
latency: self.n as u32,
..Default::default()
};
self.mix_initialized = false;
self.delta = 0.;
}
pub fn take_analyzer(&mut self) -> Option<AnalyzerFrame> {
if self.fresh {
self.fresh = false;
Some(self.frame)
} else {
None
}
}
/// Sample scheduling is independent of the caller's block partition.
pub fn process_sample(&mut self, sample: [f32; 2], monitor: Monitor) -> [f32; 2] {
let mut result = [0.; 2];
let mut energy = 0f32;
let smooth = 1. - (-1. / (self.rate * 0.01)).exp();
self.mix += (self.settings.mix / 100. - self.mix) * smooth;
self.gain += (10f32.powf(self.settings.output / 20.) - self.gain) * smooth;
self.bypass += (f32::from(self.settings.bypass) - self.bypass) * smooth;
self.delta += (f32::from(monitor.delta) - self.delta) * smooth;
for c in 0..self.channels {
let x = if sample[c].is_finite() {
sample[c].clamp(-64., 64.)
} else {
0.
};
let dry = self.input[c][self.pos];
let wet = self.output[c][self.pos];
self.output[c][self.pos] = 0.;
let x = if x.abs() < 1e-20 { 0. } else { x };
self.input[c][self.pos] = x;
let mixed = (dry + (wet - dry) * self.mix) * self.gain;
let normal = mixed + (dry - mixed) * self.bypass;
let audible = normal + ((dry - wet) - normal) * self.delta;
result[c] = if audible.is_finite() { audible } else { 0. };
self.in_peak = self.in_peak.max(x.abs());
self.out_peak = self.out_peak.max(result[c].abs());
energy = energy.max(x * x);
}
self.fast += (energy - self.fast) * (1. - (-1. / (self.rate * 0.001)).exp());
self.slow += (energy - self.slow) * (1. - (-1. / (self.rate * 0.05)).exp());
let onset =
((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.);
self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp());
self.sample_power += energy;
self.pos = (self.pos + 1) % self.n;
self.hop_pos += 1;
if self.hop_pos == self.hop {
self.hop_pos = 0;
self.process_window();
}
result
}
fn process_window(&mut self) {
for c in 0..self.channels {
for i in 0..self.n {
self.spectra[c][i] =
Complex32::new(self.input[c][(self.pos + i) % self.n] * self.window[i], 0.);
}
self.forward
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
for i in 0..GRID {
let (k, w) = self.map[i];
let power = self.spectra[c][k].norm_sqr() * (1. - w)
+ self.spectra[c][k + 1].norm_sqr() * w;
self.levels[c][i] = 10. * (power * self.power_scale).max(1e-20).log10();
}
}
let awake = self.sample_power / self.hop as f32 > 1e-9;
self.sample_power = 0.;
let step = 1000f32.log2() / (GRID - 1) as f32;
let radius = (self.settings.neighborhood / step * 0.5).round() as usize;
self.target.fill(0.);
for c in 0..self.channels {
let mut prefix = [0.; GRID + 1];
for i in 0..GRID {
prefix[i + 1] = prefix[i] + self.levels[c][i];
}
for i in 0..GRID {
let low = i.saturating_sub(radius);
let high = (i + radius + 1).min(GRID);
let e0 = i.saturating_sub(1).max(low);
let e1 = (i + 2).min(high);
let count = (high - low) - (e1 - e0);
let baseline =
(prefix[high] - prefix[low] - (prefix[e1] - prefix[e0])) / count.max(1) as f32;
let excess =
self.levels[c][i] - baseline + self.curve[i] - self.settings.sensitivity;
let target = if awake {
knee(excess, self.settings.knee) * self.settings.amount * 0.02
} else {
0.
};
self.target[i] = self.target[i].max(target.min(self.settings.max_cut));
}
}
let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp();
let release = (-(self.hop as f32) / (self.rate * self.settings.release * 0.001)).exp();
for i in 0..GRID {
let low = i.saturating_sub(self.radius);
let high = (i + self.radius + 1).min(GRID);
let mut sum = 0.;
let mut weight = 0.;
for j in low..high {
let w = self.kernel[i.abs_diff(j)];
sum += self.target[j] * w;
weight += w;
}
let target = sum / weight;
let alpha = if target > self.reduction[i] {
attack
} else {
release
};
self.reduction[i] = alpha * self.reduction[i] + (1. - alpha) * target;
if self.reduction[i] < 1e-12 {
self.reduction[i] = 0.;
}
self.frame.reduction[i] = (self.reduction[i]
* self.mask[i]
* (1. - self.protect * self.settings.transient_guard / 100.))
.clamp(0., self.settings.max_cut);
self.frame.focus[i] = self.curve[i];
self.frame.input[i] = self.levels[0][i].max(if self.channels == 2 {
self.levels[1][i]
} else {
-200.
});
}
for c in 0..self.channels {
for k in 0..=self.n / 2 {
let (i, w) = self.bin_map[k];
let d = self.frame.reduction[i] * (1. - w) + self.frame.reduction[i + 1] * w;
let f = k as f32 * self.rate / self.n as f32;
let d = if f >= self.settings.low && f <= self.settings.high.min(self.rate * 0.49) {
d
} else {
0.
};
let g = 10f32.powf(-d / 20.);
self.spectra[c][k] *= g;
if k > 0 && k < self.n / 2 {
self.spectra[c][self.n - k] *= g;
}
}
self.inverse
.process_with_scratch(&mut self.spectra[c], &mut self.scratch);
// Eight overlaps of a periodic Hann sum to four.
for i in 0..self.n {
self.output[c][(self.pos + i) % self.n] +=
self.spectra[c][i].re * self.window[i] / (self.n as f32 * 4.);
}
}
self.frame.input_peak = self.in_peak;
self.frame.output_peak = self.out_peak;
self.frame.guard = self.protect;
self.frame.sample_rate = self.rate;
self.frame.latency = self.n as u32;
self.frame.channels = self.channels;
self.in_peak = 0.;
self.out_peak = 0.;
self.fresh = true;
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Stable, host-independent controls. Frequencies are Hz, times milliseconds.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Focus {
pub enabled: bool,
pub frequency: f32,
pub width: f32,
pub sensitivity: f32,
}
impl Default for Focus {
fn default() -> Self {
Self {
enabled: false,
frequency: 2000.,
width: 0.5,
sensitivity: 0.,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub amount: f32,
pub sensitivity: f32,
pub max_cut: f32,
pub neighborhood: f32,
pub cut_width: f32,
pub knee: f32,
pub attack: f32,
pub release: f32,
pub transient_guard: f32,
pub low: f32,
pub high: f32,
pub mix: f32,
pub output: f32,
pub bypass: bool,
pub focus: [Focus; 4],
}
impl Default for Settings {
fn default() -> Self {
Self {
amount: 35.,
sensitivity: 6.,
max_cut: 3.,
neighborhood: 1.,
cut_width: 0.08,
knee: 3.,
attack: 15.,
release: 120.,
transient_guard: 50.,
low: 120.,
high: 12000.,
mix: 100.,
output: 0.,
bypass: false,
focus: [Focus::default(); 4],
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
for (v, low, high) in [
(self.amount, 0., 100.),
(self.sensitivity, 0., 24.),
(self.max_cut, 0., 18.),
(self.neighborhood, 0.25, 2.),
(self.cut_width, 0.03, 0.5),
(self.knee, 0., 12.),
(self.attack, 1., 200.),
(self.release, 10., 1000.),
(self.transient_guard, 0., 100.),
(self.low, 20., 20000.),
(self.high, 20., 20000.),
(self.mix, 0., 100.),
(self.output, -12., 12.),
] {
if !v.is_finite() || !(low..=high).contains(&v) {
return Err("Invalid resonance control value");
}
}
if self.low >= self.high {
return Err("Low frequency must be below high frequency");
}
for f in self.focus {
if !f.frequency.is_finite()
|| !(20. ..=20000.).contains(&f.frequency)
|| !f.width.is_finite()
|| !(0.05..=2.).contains(&f.width)
|| !f.sensitivity.is_finite()
|| !(-18. ..=18.).contains(&f.sensitivity)
{
return Err("Invalid focus control");
}
}
Ok(())
}
pub const PRESETS: [&'static str; 4] = [
"Gentle cleanup",
"Bass whistle",
"Snare tail",
"Bright melodic layer",
];
pub fn preset(index: usize) -> Self {
let mut s = Self::default();
match index {
1 => {
s.low = 700.;
s.high = 10000.;
s.sensitivity = 5.;
s.max_cut = 6.;
s.cut_width = 0.05;
s.attack = 8.;
}
2 => {
s.low = 250.;
s.high = 6000.;
s.attack = 25.;
s.release = 180.;
s.transient_guard = 85.;
s.max_cut = 5.;
}
3 => {
s.low = 1800.;
s.high = 15000.;
s.sensitivity = 4.;
s.max_cut = 4.;
s.cut_width = 0.14;
}
_ => {}
}
s
}
}
/// Soft knee on excess prominence, measured in dB.
pub fn knee(excess: f32, width: f32) -> f32 {
if width <= 0. {
excess.max(0.)
} else if excess <= -width * 0.5 {
0.
} else if excess >= width * 0.5 {
excess
} else {
(excess + width * 0.5).powi(2) / (2. * width)
}
}
pub fn focus_offset(f: f32, focus: &[Focus; 4]) -> f32 {
focus
.iter()
.filter(|p| p.enabled)
.map(|p| {
let z = (f / p.frequency).log2() / p.width;
p.sensitivity * (-4. * std::f32::consts::LN_2 * z * z).exp()
})
.sum()
}
+207
View File
@@ -0,0 +1,207 @@
use resonance_core::*;
use std::f32::consts::TAU;
fn noise(i: usize) -> f32 {
let x = (i as u32).wrapping_mul(747796405).wrapping_add(2891336453);
let word = ((x >> ((x >> 28) + 4)) ^ x).wrapping_mul(277803737);
(((word >> 22) ^ word) as f32 / u32::MAX as f32 - 0.5) * 0.1
}
#[test]
fn delayed_identity_and_all_mix_positions_at_all_rates() {
for rate in [44100., 48000., 88200., 96000., 176400., 192000.] {
for mix in [0., 37., 100.] {
let mut e = Engine::new(rate, 2).unwrap();
e.set_settings(Settings {
amount: 0.,
mix,
..Default::default()
})
.unwrap();
let n = e.latency_samples() as usize;
let mut error = 0f64;
let mut power = 0f64;
for i in 0..n * 4 {
let x = if i < n * 2 { noise(i) } else { 0. };
let y = e.process_sample([x, -x], Monitor::default());
let expected = if i >= n && i < n * 3 {
noise(i - n)
} else {
0.
};
error += (y[0] as f64 - expected as f64).powi(2);
power += (expected as f64).powi(2);
assert!((y[0] + y[1]).abs() < 1e-6);
}
assert!(
10. * (error / power).max(1e-30).log10() < -100.,
"rate {rate}, mix {mix}: {error}"
);
}
}
}
#[test]
fn impulse_latency_delta_and_reset() {
let mut e = Engine::new(48000., 1).unwrap();
e.set_settings(Settings {
amount: 0.,
..Default::default()
})
.unwrap();
let latency = e.latency_samples() as usize;
for i in 0..latency * 2 {
let y = e.process_sample([if i == 0 { 1. } else { 0. }, 0.], Monitor::default())[0];
assert!((y - if i == latency { 1. } else { 0. }).abs() < 1e-6);
}
e.reset();
for _ in 0..latency * 2 {
assert_eq!(e.process_sample([0., 0.], Monitor { delta: true }), [0.; 2]);
}
}
#[test]
fn resonance_is_local_bounded_and_recovers_in_silence() {
let mut e = Engine::new(48000., 2).unwrap();
e.set_settings(Settings {
amount: 100.,
sensitivity: 2.,
max_cut: 6.,
transient_guard: 0.,
attack: 1.,
release: 20.,
low: 500.,
high: 9000.,
..Default::default()
})
.unwrap();
let mut frame = AnalyzerFrame::default();
for i in 0..48000 {
let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i);
e.process_sample([x, -x], Monitor::default());
if let Some(f) = e.take_analyzer() {
frame = f;
}
}
let peak = frame.reduction.iter().copied().fold(0f32, f32::max);
assert!(peak > 3. && peak <= 6.);
for i in 0..GRID {
if frequency(i) < 500. || frequency(i) > 9000. {
assert_eq!(frame.reduction[i], 0.);
}
}
let ring = (0..GRID)
.min_by(|a, b| {
(frequency(*a) - 3000.)
.abs()
.total_cmp(&(frequency(*b) - 3000.).abs())
})
.unwrap();
assert!(frame.reduction[ring] > 3.);
for _ in 0..48000 {
e.process_sample([0.; 2], Monitor::default());
}
let f = e.take_analyzer().unwrap();
assert!(f.reduction.iter().all(|d| *d < 0.001));
}
#[test]
fn invalid_settings_do_not_mutate_and_numerical_inputs_stay_finite() {
let mut e = Engine::new(48000., 1).unwrap();
let before = e.settings();
assert!(e
.set_settings(Settings {
attack: f32::NAN,
..before
})
.is_err());
assert_eq!(e.settings(), before);
assert!(Engine::new(8000., 2).is_err());
for _ in 0..10000 {
let y = e.process_sample([f32::NAN, f32::INFINITY], Monitor::default());
assert!(y.iter().all(|v| v.is_finite()));
}
}
#[test]
fn soft_knee_and_presets_are_valid() {
for i in 0..4 {
Settings::preset(i).validate().unwrap();
}
assert_eq!(knee(-2., 3.), 0.);
assert_eq!(knee(5., 3.), 5.);
assert_eq!(knee(-1., 0.), 0.);
let p = Focus {
enabled: true,
frequency: 2000.,
width: 1.,
sensitivity: -12.,
};
assert_eq!(focus_offset(2000., &[p; 4]), -48.);
}
#[test]
fn relative_detector_scales_and_manual_protection_reduces_false_positives() {
let s = Settings {
amount: 80.,
sensitivity: 3.,
max_cut: 12.,
attack: 1.,
transient_guard: 0.,
..Default::default()
};
let mut loud = Engine::new(48000., 1).unwrap();
loud.set_settings(s).unwrap();
let mut quiet = Engine::new(48000., 1).unwrap();
quiet.set_settings(s).unwrap();
let mut protected = Engine::new(48000., 1).unwrap();
let mut p = s;
p.focus[0] = Focus {
enabled: true,
frequency: 3000.,
width: 0.5,
sensitivity: -18.,
};
protected.set_settings(p).unwrap();
for i in 0..24000 {
let x = 0.3 * (TAU * 3000. * i as f32 / 48000.).sin() + noise(i);
loud.process_sample([x, 0.], Monitor::default());
quiet.process_sample([x * 0.1, 0.], Monitor::default());
protected.process_sample([x, 0.], Monitor::default());
}
let a = loud.take_analyzer().unwrap();
let b = quiet.take_analyzer().unwrap();
let c = protected.take_analyzer().unwrap();
for i in 0..GRID {
assert!((a.reduction[i] - b.reduction[i]).abs() < 0.02);
}
let k = (0..GRID)
.min_by(|a, b| {
(frequency(*a) - 3000.)
.abs()
.total_cmp(&(frequency(*b) - 3000.).abs())
})
.unwrap();
assert!(c.reduction[k] < a.reduction[k] - 1.);
}
#[test]
fn settled_delta_and_bypass_have_precise_taps() {
let s = Settings {
attack: 1.,
transient_guard: 0.,
..Default::default()
};
let mut wet = Engine::new(48000., 1).unwrap();
wet.set_settings(s).unwrap();
let mut delta = Engine::new(48000., 1).unwrap();
delta.set_settings(s).unwrap();
let mut bypass = Engine::new(48000., 1).unwrap();
bypass.set_settings(Settings { bypass: true, ..s }).unwrap();
let latency = wet.latency_samples() as usize;
for i in 0..24000 {
let x = noise(i);
let a = wet.process_sample([x, 0.], Monitor::default())[0];
let b = delta.process_sample([x, 0.], Monitor { delta: true })[0];
let c = bypass.process_sample([x, 0.], Monitor::default())[0];
if i > 12000 {
let expected = noise(i - latency);
assert!((a + b - expected).abs() < 1e-5);
assert!((c - expected).abs() < 1e-6);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
use resonance_core::{Engine, Monitor, Settings};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
};
struct CheckedAllocator;
thread_local! {
static CHECK:Cell<bool>=const {Cell::new(false)};
static OPERATIONS:Cell<usize>=const {Cell::new(0)};
}
fn record() {
if CHECK.try_with(Cell::get).unwrap_or(false) {
let _ = OPERATIONS.try_with(|n| n.set(n.get() + 1));
}
}
unsafe impl GlobalAlloc for CheckedAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record();
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
record();
unsafe { System.dealloc(pointer, layout) }
}
unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record();
unsafe { System.realloc(pointer, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CheckedAllocator = CheckedAllocator;
#[test]
fn processing_automation_and_analysis_do_not_allocate_or_free() {
let mut engine = Engine::new(48000., 2).unwrap();
let mut settings = Settings::preset(1);
engine.set_settings(settings).unwrap();
CHECK.with(|v| v.set(true));
for i in 0..48000 {
if i % 512 == 0 {
settings.focus[0].enabled = true;
settings.focus[0].frequency = 1000. + (i % 10000) as f32;
settings.focus[0].sensitivity = 6.;
settings.low = 120. + (i % 100) as f32;
engine.set_settings(settings).unwrap();
}
std::hint::black_box(engine.process_sample([0.3, -0.2], Monitor { delta: i > 24000 }));
std::hint::black_box(engine.take_analyzer());
}
engine.reset();
CHECK.with(|v| v.set(false));
assert_eq!(OPERATIONS.with(Cell::get), 0);
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "resonance-render"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
resonance-core = { path = "../resonance-core" }
serde_json = "1"
+195
View File
@@ -0,0 +1,195 @@
//! Bounded offline evaluation: PCM16/24/32 and IEEE float32 RIFF/WAVE input.
use resonance_core::{Engine, Monitor, Settings};
use std::{
env, fs,
io::{Read, Write},
path::Path,
time::Instant,
};
fn main() {
if let Err(e) = run() {
eprintln!("{e}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = env::args().skip(1).collect();
if args.is_empty() || args[0] == "--help" {
println!("resonance-render <input.wav> <output.wav> [settings.json] [--delta]\nresonance-render --demo <output.wav>\nresonance-render --benchmark\nOutput is float32 WAV, latency-trimmed with original length. Existing files are refused.");
return Ok(());
}
if args[0] == "--benchmark" {
let mut e = Engine::new(48000., 2)?;
e.set_settings(Settings::preset(1))?;
let frames = 48000 * 5;
let start = Instant::now();
for i in 0..frames {
let x = (i as f32 * 0.173).sin() * 0.3;
std::hint::black_box(e.process_sample([x, -x], Monitor::default()));
}
println!("Stereo 48 kHz / FFT 4096 / hop 512 / 5 seconds: {:.3}s ({:.1}% of one core, run --release for meaningful results)",start.elapsed().as_secs_f64(),start.elapsed().as_secs_f64()/5.*100.);
return Ok(());
}
let (rate, channels, input, output) = if args[0] == "--demo" && args.len() == 2 {
let samples = (0..48000 * 3)
.map(|i| {
let t = i as f32 / 48000.;
let envelope = if t % 0.5 < 0.3 { 1. } else { 0.2 };
[((t * 440. * std::f32::consts::TAU).sin() * 0.14
+ (t * 3000. * std::f32::consts::TAU).sin() * 0.28)
* envelope; 2]
})
.collect();
(48000., 2, samples, args[1].clone())
} else if args.len() >= 2 {
let (r, c, x) = read_wave(Path::new(&args[0]))?;
(r, c, x, args[1].clone())
} else {
return Err("Use --help for usage".into());
};
let settings = if args.get(2).is_some_and(|s| !s.starts_with("--")) {
let bytes = read_limit(Path::new(&args[2]), 64 * 1024)?;
serde_json::from_slice::<Settings>(&bytes)?
} else {
Settings::default()
};
settings.validate()?;
let mut engine = Engine::new(rate, channels)?;
engine.set_settings(settings)?;
let latency = engine.latency_samples() as usize;
let start = Instant::now();
let mut rendered = Vec::with_capacity(input.len());
let monitor = Monitor {
delta: args.iter().any(|a| a == "--delta"),
};
for i in 0..input.len() + latency {
let sample = input.get(i).copied().unwrap_or([0.; 2]);
let out = engine.process_sample(sample, monitor);
if i >= latency {
rendered.push(out);
}
}
write_wave(Path::new(&output), rate as u32, channels, &rendered)?;
println!(
"Rendered {} frames, {} channel(s), {} Hz in {:.3}s; compensated {} samples → {}",
rendered.len(),
channels,
rate,
start.elapsed().as_secs_f64(),
latency,
output
);
Ok(())
}
fn read_limit(path: &Path, limit: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut bytes = Vec::new();
fs::File::open(path)?
.take(limit as u64 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > limit {
return Err("Input exceeds size limit".into());
}
Ok(bytes)
}
type Wave = (f32, usize, Vec<[f32; 2]>);
fn read_wave(path: &Path) -> Result<Wave, Box<dyn std::error::Error>> {
let b = read_limit(path, 256 * 1024 * 1024)?;
if b.len() < 12 || &b[..4] != b"RIFF" || &b[8..12] != b"WAVE" {
return Err("Expected RIFF/WAVE".into());
}
let u16_at = |p| u16::from_le_bytes([b[p], b[p + 1]]);
let u32_at = |p| u32::from_le_bytes([b[p], b[p + 1], b[p + 2], b[p + 3]]);
let end = (u32_at(4) as usize)
.checked_add(8)
.ok_or("Invalid RIFF length")?;
if end > b.len() {
return Err("Truncated RIFF container".into());
}
let mut pos = 12;
let mut format = None;
let mut data = None;
while pos + 8 <= end {
let len = u32_at(pos + 4) as usize;
let start = pos + 8;
let stop = start.checked_add(len).ok_or("Invalid chunk length")?;
if stop > end {
return Err("Truncated WAV chunk".into());
}
match &b[pos..pos + 4] {
b"fmt " if len >= 16 => {
format = Some((
u16_at(start),
u16_at(start + 2) as usize,
u32_at(start + 4),
u16_at(start + 12) as usize,
u16_at(start + 14) as usize,
))
}
b"data" => data = Some((start, stop)),
_ => {}
}
pos = stop + (len % 2);
}
let (tag, channels, rate, align, bits) = format.ok_or("Missing WAV format")?;
let (start, stop) = data.ok_or("Missing WAV data")?;
if !(1..=2).contains(&channels) || !matches!((tag, bits), (1, 16) | (1, 24) | (1, 32) | (3, 32))
{
return Err("Use mono/stereo PCM16/24/32 or float32 WAV".into());
}
let size = bits / 8;
if align != channels * size || (stop - start) % align != 0 {
return Err("Invalid WAV block alignment".into());
}
let mut samples = Vec::with_capacity((stop - start) / align);
for p in (start..stop).step_by(align) {
let mut frame = [0.; 2];
for (c, value) in frame.iter_mut().enumerate().take(channels) {
let q = p + c * size;
*value = match (tag, bits) {
(3, 32) => f32::from_le_bytes(b[q..q + 4].try_into()?),
(1, 16) => i16::from_le_bytes(b[q..q + 2].try_into()?) as f32 / 32768.,
(1, 24) => {
((b[q] as i32 | ((b[q + 1] as i32) << 8) | ((b[q + 2] as i32) << 16)) << 8 >> 8)
as f32
/ 8388608.
}
_ => i32::from_le_bytes(b[q..q + 4].try_into()?) as f32 / 2147483648.,
};
if !value.is_finite() {
return Err("Non-finite WAV samples".into());
}
}
samples.push(frame);
}
Ok((rate as f32, channels, samples))
}
fn write_wave(
path: &Path,
rate: u32,
channels: usize,
samples: &[[f32; 2]],
) -> Result<(), Box<dyn std::error::Error>> {
let bytes = (samples.len() * channels * 4) as u32;
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
f.write_all(b"RIFF")?;
f.write_all(&(36 + bytes).to_le_bytes())?;
f.write_all(b"WAVEfmt ")?;
f.write_all(&16u32.to_le_bytes())?;
f.write_all(&3u16.to_le_bytes())?;
f.write_all(&(channels as u16).to_le_bytes())?;
f.write_all(&rate.to_le_bytes())?;
f.write_all(&(rate * channels as u32 * 4).to_le_bytes())?;
f.write_all(&(channels as u16 * 4).to_le_bytes())?;
f.write_all(&32u16.to_le_bytes())?;
f.write_all(b"data")?;
f.write_all(&bytes.to_le_bytes())?;
for frame in samples {
for sample in frame.iter().take(channels) {
f.write_all(&sample.to_le_bytes())?;
}
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "resonance-ui"
version = "0.1.0"
edition = "2021"
license = "ISC"
[dependencies]
egui = "0.31.1"
resonance-core = { path = "../resonance-core" }
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+906
View File
@@ -0,0 +1,906 @@
//! 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 std::time::{Duration, Instant};
const BG: Color32 = Color32::from_rgb(23, 20, 31);
const PANEL: Color32 = Color32::from_rgb(37, 33, 49);
const TEXT: Color32 = Color32::from_rgb(238, 234, 245);
const MUTED: Color32 = Color32::from_rgb(161, 151, 179);
const CYAN: Color32 = Color32::from_rgb(102, 215, 230);
const CORAL: Color32 = Color32::from_rgb(255, 136, 125);
const VIOLET: Color32 = Color32::from_rgb(174, 154, 247);
pub fn style(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"regular".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Regular.ttf")).into(),
);
fonts.font_data.insert(
"medium".into(),
egui::FontData::from_static(include_bytes!("../assets/RobotoCondensed-Medium.ttf")).into(),
);
fonts
.families
.get_mut(&egui::FontFamily::Proportional)
.unwrap()
.insert(0, "regular".into());
fonts.families.insert(
egui::FontFamily::Name("medium".into()),
vec![
"medium".into(),
"Ubuntu-Light".into(),
"NotoEmoji-Regular".into(),
"emoji-icon-font".into(),
],
);
ctx.set_fonts(fonts);
let mut s = (*ctx.style()).clone();
s.visuals = egui::Visuals::dark();
s.visuals.panel_fill = BG;
s.visuals.window_fill = PANEL;
s.visuals.override_text_color = Some(TEXT);
s.visuals.selection.bg_fill = Color32::from_rgb(80, 64, 112);
s.visuals.widgets.inactive.bg_fill = PANEL;
s.visuals.widgets.hovered.bg_fill = Color32::from_rgb(62, 50, 80);
s.visuals.widgets.active.bg_fill = Color32::from_rgb(81, 58, 102);
s.spacing.item_spacing = Vec2::new(10., 8.);
s.spacing.button_padding = Vec2::new(12., 6.);
s.text_styles
.insert(egui::TextStyle::Body, FontId::proportional(16.));
s.text_styles
.insert(egui::TextStyle::Button, FontId::proportional(16.));
ctx.set_style(s);
}
pub struct State {
pub delta: bool,
pub selected: Option<usize>,
pub preset: usize,
pub snapshots: [Settings; 2],
pub active: usize,
pub analyzer: AnalyzerFrame,
pub gesture_active: bool,
pub gestures: [bool; 30],
last_audio: Instant,
pub help: String,
drag: Option<usize>,
}
impl Default for State {
fn default() -> Self {
Self {
delta: false,
selected: None,
preset: 0,
snapshots: [Settings::default(); 2],
active: 0,
analyzer: AnalyzerFrame::default(),
gesture_active: false,
gestures: [false; 30],
last_audio: Instant::now(),
help: "Focus a troublesome region, then compare in the full mix.".into(),
drag: None,
}
}
}
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
// Display-only smoothing; it never feeds the detector.
for i in 0..GRID {
self.analyzer.input[i] += 0.45 * (frame.input[i] - self.analyzer.input[i]);
self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]);
}
self.analyzer.focus = frame.focus;
self.analyzer.input_peak = frame.input_peak;
self.analyzer.output_peak = frame.output_peak;
self.analyzer.guard = frame.guard;
self.analyzer.sample_rate = frame.sample_rate;
self.analyzer.latency = frame.latency;
self.analyzer.channels = frame.channels;
if frame.input_peak > 1e-6
|| frame.output_peak > 1e-6
|| frame.reduction.iter().any(|v| *v > 0.001)
{
self.last_audio = Instant::now();
}
}
}
fn db(x: f32) -> f32 {
20. * x.max(1e-6).log10()
}
fn note(ui: &mut egui::Ui, text: &str) {
ui.label(RichText::new(text).color(MUTED));
}
#[allow(clippy::too_many_arguments)] // Explicit widget metadata keeps the control table readable.
fn control(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
unit: &str,
log: bool,
help: &str,
state: &mut State,
) {
ui.vertical(|ui| {
ui.label(RichText::new(name).color(TEXT));
let previous = *value;
let response = ui.add(
egui::Slider::new(value, range)
.logarithmic(log)
.suffix(unit)
.clamping(egui::SliderClamping::Always)
.smart_aim(false),
);
if response.dragged() && ui.input(|i| i.modifiers.shift) {
*value = previous + (*value - previous) * 0.1;
}
if response.double_clicked() {
let defaults = Settings::default();
*value = match name {
"Amount" => defaults.amount,
"Sensitivity" => defaults.sensitivity,
"Max cut" => defaults.max_cut,
"Transient guard" => defaults.transient_guard,
"Attack" => defaults.attack,
"Release" => defaults.release,
"Neighborhood" => defaults.neighborhood,
"Cut width" => defaults.cut_width,
"Knee" => defaults.knee,
"Low frequency" => defaults.low,
"High frequency" => defaults.high,
"Mix" => defaults.mix,
_ => *value,
};
}
if response.hovered() || response.has_focus() {
state.help = help.into();
}
});
}
#[allow(clippy::too_many_arguments)]
fn rotary(
ui: &mut egui::Ui,
name: &str,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
default: f32,
unit: &str,
size: f32,
color: Color32,
) {
let low = *range.start();
let high = *range.end();
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click_and_drag());
if response.drag_started() {
response.request_focus();
}
if response.dragged() {
let delta = ui.input(|i| i.pointer.delta().y);
let fine = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
*value = (*value - delta * (high - low) / 180. * fine).clamp(low, high);
}
if response.double_clicked() {
*value = default;
}
if response.has_focus() {
let step = if ui.input(|i| i.modifiers.shift) {
0.1
} else {
1.
};
if ui.input(|i| i.key_pressed(egui::Key::ArrowUp) || i.key_pressed(egui::Key::ArrowRight)) {
*value = (*value + step).min(high);
}
if ui.input(|i| i.key_pressed(egui::Key::ArrowDown) || i.key_pressed(egui::Key::ArrowLeft))
{
*value = (*value - step).max(low);
}
}
let center = rect.center();
let radius = size * 0.45;
let p = ui.painter();
p.circle_filled(
center + Vec2::new(0., 3.),
radius,
Color32::from_black_alpha(90),
);
p.circle_filled(center, radius, Color32::from_rgb(20, 18, 29));
p.circle_stroke(
center,
radius,
Stroke::new(1.0_f32, Color32::from_rgb(74, 65, 90)),
);
p.circle_filled(center, radius - 6., Color32::from_rgb(43, 37, 56));
p.circle_stroke(
center,
radius - 7.,
Stroke::new(1.0_f32, Color32::from_rgb(60, 51, 77)),
);
let start = std::f32::consts::PI * 0.75;
let span = std::f32::consts::PI * 1.5;
let normalized = (*value - low) / (high - low);
for (fraction, stroke) in [
(1., Stroke::new(3.0_f32, Color32::from_rgb(59, 49, 76))),
(normalized, Stroke::new(3.0_f32, color)),
] {
let points: Vec<_> = (0..=64)
.map(|i| {
let a = start + span * fraction * i as f32 / 64.;
center + Vec2::angled(a) * (radius - 3.)
})
.collect();
p.add(egui::Shape::line(points, stroke));
}
let angle = start + span * normalized;
p.line_segment(
[
center + Vec2::angled(angle) * (radius - 14.),
center + Vec2::angled(angle) * (radius - 8.),
],
Stroke::new(2.0_f32, color),
);
p.text(
center,
egui::Align2::CENTER_CENTER,
format!("{:.0}{}", *value, unit),
FontId::proportional(if size > 100. { 25. } else { 18. }),
color,
);
if response.has_focus() {
p.circle_stroke(center, radius + 2., Stroke::new(1.0_f32, TEXT));
}
ui.label(
RichText::new(name)
.size(if size > 100. { 20. } else { 17. })
.color(TEXT),
);
ui.add(
egui::DragValue::new(value)
.range(range)
.speed(0.1)
.suffix(unit),
);
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
ui.spacing_mut().slider_width = ((ui.available_width() - 100.) / 4. - 85.).max(95.);
let before = *settings;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new("Resonance Compressor")
.font(FontId::new(30., egui::FontFamily::Name("medium".into()))),
);
note(ui, "Keep the character. Control the ring.");
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.checkbox(&mut settings.bypass, "Bypass");
if ui
.selectable_label(
state.delta,
RichText::new("Delta").color(if state.delta { CORAL } else { TEXT }),
)
.on_hover_text("Hear delayed dry minus wet, before output trim.")
.clicked()
{
state.delta = !state.delta;
}
ui.separator();
if ui.button("Copy A to B").clicked() {
state.snapshots[state.active] = *settings;
state.snapshots[1] = state.snapshots[0];
if state.active == 1 {
*settings = state.snapshots[1];
}
}
for i in (0..2).rev() {
if ui
.selectable_label(state.active == i, if i == 0 { "A" } else { "B" })
.clicked()
&& state.active != i
{
state.snapshots[state.active] = *settings;
*settings = state.snapshots[i];
state.active = i;
}
}
egui::ComboBox::from_id_salt("resonance-preset")
.selected_text(
(0..4)
.find(|i| *settings == Settings::preset(*i))
.map_or("Custom settings", |i| Settings::PRESETS[i]),
)
.width(155.)
.show_ui(ui, |ui| {
for (i, name) in Settings::PRESETS.iter().enumerate() {
if ui.selectable_value(&mut state.preset, i, *name).clicked() {
*settings = Settings::preset(i);
state.delta = false;
}
}
});
});
});
ui.add_space(8.);
ui.horizontal(|ui| {
let input = db(state.analyzer.input_peak);
let output = db(state.analyzer.output_peak);
ui.label(RichText::new(format!("Input {input:>5.1} dBFS")).color(CYAN));
ui.label(
RichText::new(format!("Output {output:>5.1} dBFS")).color(if output >= 0. {
CORAL
} else {
TEXT
}),
);
let max = state
.analyzer
.reduction
.iter()
.copied()
.fold(0f32, f32::max);
ui.label(RichText::new(format!("Reduction {max:.1} dB")).color(CORAL));
ui.label(
RichText::new(format!(
"Transient guard {:.0}%",
state.analyzer.guard * 100.
))
.color(MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
note(
ui,
&format!(
"{:.1} ms latency • {}",
state.analyzer.latency as f32 / state.analyzer.sample_rate * 1000.,
if state.analyzer.channels == 1 {
"Mono"
} else {
"Stereo linked"
}
),
);
});
});
ui.add_space(6.);
let body_height = (ui.available_height() - 35.).max(500.);
ui.horizontal_top(|ui| {
egui::Frame::new()
.fill(PANEL)
.corner_radius(8.)
.inner_margin(14.)
.show(ui, |ui| {
ui.set_width(156.);
ui.set_min_height(body_height - 28.);
ui.vertical_centered(|ui| {
ui.add_space(4.);
rotary(
ui,
"Amount",
&mut settings.amount,
0. ..=100.,
35.,
" %",
126.,
CYAN,
);
ui.add_space(8.);
ui.separator();
ui.add_space(12.);
ui.horizontal_top(|ui| {
ui.vertical_centered(|ui| {
ui.set_width(72.);
rotary(
ui,
"Mix",
&mut settings.mix,
0. ..=100.,
100.,
" %",
64.,
VIOLET,
);
});
ui.vertical_centered(|ui| {
ui.set_width(72.);
rotary(
ui,
"Output",
&mut settings.output,
-12. ..=12.,
0.,
" dB",
64.,
CORAL,
);
});
});
ui.add_space(10.);
note(ui, "Peak reduction");
let max = state
.analyzer
.reduction
.iter()
.copied()
.fold(0f32, f32::max);
ui.label(RichText::new(format!("{max:.1} dB")).size(25.).color(CORAL));
ui.add_space(12.);
ui.label(
RichText::new("Drag to adjust. Shift for fine steps. Double-click resets.")
.size(13.)
.color(MUTED),
);
});
});
ui.add_space(4.);
ui.vertical(|ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().slider_width = ((ui.available_width() - 48.) / 4. - 103.).max(72.);
spectrum(ui, settings, state);
ui.add_space(4.);
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("Focus / protect").color(VIOLET));
for i in 0..4 {
let f = settings.focus[i];
if ui
.selectable_label(
state.selected == Some(i),
format!("{} {}", i + 1, if f.enabled { "●" } else { "○" }),
)
.clicked()
{
state.selected = Some(i);
}
}
if let Some(i) = state.selected {
let f = &mut settings.focus[i];
ui.checkbox(&mut f.enabled, "Enabled");
ui.label("Hz");
ui.add(
egui::DragValue::new(&mut f.frequency)
.range(20. ..=20000.)
.speed(5.),
);
ui.label("Sensitivity");
ui.add(
egui::DragValue::new(&mut f.sensitivity)
.range(-18. ..=18.)
.speed(0.1)
.suffix(" dB"),
);
ui.label("Width");
ui.add(
egui::DragValue::new(&mut f.width)
.range(0.05..=2.)
.speed(0.01)
.suffix(" oct"),
);
if ui.small_button("Reset").clicked() {
f.sensitivity = 0.;
}
if ui.small_button("Remove").clicked() {
*f = Focus::default();
state.selected = None;
}
} else {
note(
ui,
"Double-click the spectrum to place a point. Drag to tame or protect.",
);
}
});
ui.add_space(6.);
ui.separator();
ui.add_space(4.);
egui::Grid::new("engine-controls")
.num_columns(4)
.spacing([16., 12.])
.min_col_width((ui.available_width() - 48.) / 4.)
.show(ui, |ui| {
control(
ui,
"Sensitivity",
&mut settings.sensitivity,
0. ..=24.,
" dB",
false,
"Lower catches more peaks. Measured relative to the local spectral reference.",
state,
);
control(
ui,
"Max cut",
&mut settings.max_cut,
0. ..=18.,
" dB",
false,
"Upper bound on spectral attenuation. This is not a limiter.",
state,
);
control(
ui,
"Transient guard",
&mut settings.transient_guard,
0. ..=100.,
" %",
false,
"Relaxes reduction during attacks. Turn down to catch short resonances.",
state,
);
control(
ui,
"Knee",
&mut settings.knee,
0. ..=12.,
" dB",
false,
"Softness of the transition into prominence reduction.",
state,
);
ui.end_row();
control(
ui,
"Attack",
&mut settings.attack,
1. ..=200.,
" ms",
true,
"Attenuation rise time, in addition to window and detection delay.",
state,
);
control(
ui,
"Release",
&mut settings.release,
10. ..=1000.,
" ms",
true,
"Recovery time after a resonance subsides.",
state,
);
control(
ui,
"Neighborhood",
&mut settings.neighborhood,
0.25..=2.,
" oct",
false,
"Width of the surrounding spectral reference. Wider includes more context.",
state,
);
control(
ui,
"Cut width",
&mut settings.cut_width,
0.03..=0.5,
" oct",
false,
"Frequency smoothing width of the attenuation surface.",
state,
);
ui.end_row();
});
ui.add_space(6.);
egui::Grid::new("range-controls")
.num_columns(2)
.spacing([22., 8.])
.show(ui, |ui| {
control(
ui,
"Low frequency",
&mut settings.low,
20. ..=19999.,
" Hz",
true,
"Lower processing boundary. Protect the source's body.",
state,
);
control(
ui,
"High frequency",
&mut settings.high,
21. ..=20000.,
" Hz",
true,
"Upper processing boundary, also limited by Nyquist.",
state,
);
});
});
});
ui.separator();
note(ui, &state.help);
if settings.low >= settings.high {
if settings.low != before.low {
settings.low = (settings.high - 1.).max(20.);
} else {
settings.high = (settings.low + 1.).min(20000.);
}
}
state.gesture_active = ui.input(|i| i.pointer.any_down());
if state.last_audio.elapsed() < Duration::from_millis(300) {
ui.ctx().request_repaint_after(Duration::from_millis(33));
}
}
fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
let height = (ui.available_height() - 360.).clamp(180., 480.);
let (outer, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), height),
Sense::click_and_drag(),
);
let p = ui.painter();
p.rect_filled(outer, 6., Color32::from_rgb(15, 13, 22));
let r = Rect::from_min_max(
outer.min + Vec2::new(46., 26.),
outer.max - Vec2::new(42., 26.),
);
let x = |f: f32| r.left() + ((f.max(20.) / 20.).ln() / 1000f32.ln()).clamp(0., 1.) * r.width();
let y = |d: f32| r.bottom() - (d + 100.).clamp(0., 100.) / 100. * r.height();
let red_y = |d: f32| r.top() + d / 18. * r.height() * 0.75;
for (xx, level, color) in [
(outer.left() + 5., state.analyzer.input_peak, CYAN),
(
outer.right() - 9.,
state.analyzer.output_peak,
if state.analyzer.output_peak >= 1. {
CORAL
} else {
VIOLET
},
),
] {
let rail = Rect::from_min_max(Pos2::new(xx, r.top()), Pos2::new(xx + 4., r.bottom()));
p.rect_filled(rail, 2., Color32::from_rgb(38, 33, 48));
let height = ((db(level) + 72.) / 72.).clamp(0., 1.) * rail.height();
p.rect_filled(
Rect::from_min_max(Pos2::new(xx, rail.bottom() - height), rail.max),
2.,
color.gamma_multiply(0.65),
);
}
let color_grid = Color32::from_rgb(58, 51, 70);
for f in [
20., 50., 100., 200., 500., 1000., 2000., 5000., 10000., 20000.,
] {
let xx = x(f);
p.line_segment(
[Pos2::new(xx, r.top()), Pos2::new(xx, r.bottom())],
Stroke::new(1.0_f32, color_grid),
);
let label = if f >= 1000. {
format!("{}k", f / 1000.)
} else {
format!("{f:.0}")
};
p.text(
Pos2::new(xx, r.bottom() + 7.),
egui::Align2::CENTER_TOP,
label,
FontId::proportional(12.),
MUTED,
);
}
for d in [-96., -72., -48., -24., 0.] {
p.line_segment(
[Pos2::new(r.left(), y(d)), Pos2::new(r.right(), y(d))],
Stroke::new(1.0_f32, color_grid),
);
p.text(
Pos2::new(r.left() - 7., y(d)),
egui::Align2::RIGHT_CENTER,
format!("{d:.0}"),
FontId::proportional(12.),
MUTED,
);
}
for d in [0., 6., 12., 18.] {
p.text(
Pos2::new(r.right() + 7., red_y(d)),
egui::Align2::LEFT_CENTER,
format!("−{d:.0}"),
FontId::proportional(12.),
CORAL,
);
}
p.text(
r.left_top() - Vec2::new(30., 19.),
egui::Align2::LEFT_TOP,
"dBFS / bin",
FontId::proportional(12.),
MUTED,
);
p.text(
r.right_top() + Vec2::new(30., -19.),
egui::Align2::RIGHT_TOP,
"Cut dB",
FontId::proportional(12.),
CORAL,
);
p.rect_filled(
Rect::from_min_max(r.min, Pos2::new(x(settings.low), r.bottom())),
0.,
Color32::from_black_alpha(90),
);
p.rect_filled(
Rect::from_min_max(Pos2::new(x(settings.high), r.top()), r.max),
0.,
Color32::from_black_alpha(90),
);
let signal: Vec<_> = (0..GRID)
.map(|i| Pos2::new(x(frequency(i)), y(state.analyzer.input[i])))
.collect();
let mut source_fill = egui::Mesh::default();
for (i, point) in signal.iter().enumerate() {
source_fill.colored_vertex(
Pos2::new(point.x, r.bottom()),
Color32::from_rgba_unmultiplied(102, 215, 230, 7),
);
source_fill.colored_vertex(*point, Color32::from_rgba_unmultiplied(102, 215, 230, 60));
if i > 0 {
let j = (i * 2) as u32;
source_fill.add_triangle(j - 2, j - 1, j);
source_fill.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(source_fill));
p.add(egui::Shape::line(signal, Stroke::new(1.5_f32, CYAN)));
let mut fill = Vec::with_capacity(GRID + 2);
fill.push(r.left_top());
for i in 0..GRID {
fill.push(Pos2::new(
x(frequency(i)),
red_y(state.analyzer.reduction[i]),
));
}
fill.push(r.right_top());
// A triangle strip makes non-convex reduction envelopes render accurately.
let mut mesh = egui::Mesh::default();
for i in 0..GRID {
let xx = x(frequency(i));
let yy = red_y(state.analyzer.reduction[i]);
mesh.colored_vertex(
Pos2::new(xx, r.top()),
Color32::from_rgba_unmultiplied(255, 136, 125, 8),
);
mesh.colored_vertex(
Pos2::new(xx, yy),
Color32::from_rgba_unmultiplied(255, 136, 125, 90),
);
if i > 0 {
let j = (i * 2) as u32;
mesh.add_triangle(j - 2, j - 1, j);
mesh.add_triangle(j - 1, j, j + 1);
}
}
p.add(egui::Shape::mesh(mesh));
p.add(egui::Shape::line(
fill[1..=GRID].to_vec(),
Stroke::new(1.6_f32, CORAL),
));
let center = r.center().y;
let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65;
let points: Vec<_> = (0..GRID)
.map(|i| {
Pos2::new(
x(frequency(i)),
focus_y(focus_offset(frequency(i), &settings.focus)),
)
})
.collect();
p.line_segment(
[Pos2::new(r.left(), center), Pos2::new(r.right(), center)],
Stroke::new(0.7_f32, VIOLET.gamma_multiply(0.35)),
);
p.add(egui::Shape::line(points, Stroke::new(1.3_f32, VIOLET)));
for (i, f) in settings.focus.iter().enumerate().filter(|(_, f)| f.enabled) {
let pos = Pos2::new(x(f.frequency), focus_y(f.sensitivity));
p.circle_filled(pos, if state.selected == Some(i) { 7. } else { 5. }, VIOLET);
p.text(
pos - Vec2::new(0., 12.),
egui::Align2::CENTER_BOTTOM,
format!("{}", i + 1),
FontId::proportional(12.),
TEXT,
);
}
if state.analyzer.input_peak < 1e-6 {
p.text(
r.center() + Vec2::new(0., 42.),
egui::Align2::CENTER_CENTER,
"Play audio to inspect its resonances",
FontId::proportional(18.),
MUTED,
);
}
if let Some(pos) = response.hover_pos() {
if r.contains(pos) {
let f = 20. * 1000f32.powf((pos.x - r.left()) / r.width());
p.line_segment(
[Pos2::new(pos.x, r.top()), Pos2::new(pos.x, r.bottom())],
Stroke::new(0.7_f32, TEXT.gamma_multiply(0.3)),
);
response.clone().on_hover_text(format!("{f:.0} Hz • Double-click to add focus; drag a node to tame/protect; wheel sets width"));
if response.double_clicked() {
if let Some(i) = settings.focus.iter().position(|p| !p.enabled) {
settings.focus[i] = Focus {
enabled: true,
frequency: f,
sensitivity: 0.,
..Default::default()
};
state.selected = Some(i);
}
}
}
}
if response.drag_started() {
if let Some(pos) = response.interact_pointer_pos() {
state.drag = settings
.focus
.iter()
.enumerate()
.filter(|(_, f)| f.enabled)
.find(|(_, f)| {
Pos2::new(x(f.frequency), focus_y(f.sensitivity)).distance(pos) < 18.
})
.map(|(i, _)| i);
if state.drag.is_some() {
state.selected = state.drag;
} else if (pos.x - x(settings.low)).abs() < 12. {
state.drag = Some(4);
} else if (pos.x - x(settings.high)).abs() < 12. {
state.drag = Some(5);
}
}
}
if response.dragged() {
if let (Some(i), Some(pos)) = (state.drag, response.interact_pointer_pos()) {
let fine = ui.input(|i| i.modifiers.shift);
let f = (20. * 1000f32.powf((pos.x - r.left()) / r.width())).clamp(20., 20000.);
if i < 4 {
let node = &mut settings.focus[i];
let next = ((center - pos.y) / (r.height() * 0.65) * 36.).clamp(-18., 18.);
if fine {
node.frequency += (f - node.frequency) * 0.1;
node.sensitivity += (next - node.sensitivity) * 0.1;
} else {
node.frequency = f;
node.sensitivity = next;
}
} else if i == 4 {
settings.low = f.min(settings.high - 1.);
} else {
settings.high = f.max(settings.low + 1.);
}
}
}
if response.drag_stopped() {
state.drag = None;
}
if response.hovered() {
if let Some(i) = state.selected {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0. {
settings.focus[i].width =
(settings.focus[i].width + scroll * 0.002).clamp(0.05, 2.);
}
}
}
for f in [settings.low, settings.high] {
p.line_segment(
[Pos2::new(x(f), r.top()), Pos2::new(x(f), r.bottom())],
Stroke::new(2.0_f32, MUTED),
);
let handle = Rect::from_center_size(Pos2::new(x(f), r.center().y), Vec2::new(12., 30.));
p.rect_filled(handle, 6., Color32::from_rgb(65, 77, 96));
p.rect_stroke(
handle,
6.,
Stroke::new(1.0_f32, CYAN.gamma_multiply(0.65)),
egui::StrokeKind::Inside,
);
}
}
+91
View File
@@ -0,0 +1,91 @@
# Resonance Compressor 0.1
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)
## Install on Windows
Download the Windows artifact from the GitLab pipeline's **windows** job and
extract the ZIP. Copy `Resonance Compressor.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
redistributed packages.
## Work with it
Loop a troublesome passage in context. Restrict Low/High frequency, then adjust
Amount and Max cut. Sensitivity is a prominence threshold: lower catches more.
Neighborhood defines the spectral reference; Cut width smooths the attenuation.
Attack and Release smooth gain changes; Transient guard briefly relaxes cuts.
Double-click the plot to add one of four focus points. Positive sensitivity
makes the detector more eager; negative values protect the region by raising
the threshold. Drag a point, wheel to change width, or use its numeric fields.
Range boundary handles constrain where processing may act. Focus is detector
weighting, not a static EQ boost and not an absolute protection guarantee.
Cyan shows spectral input energy, coral the attenuation, violet detector focus.
The left scale is calibrated per-bin spectral power; the right scale is cut dB.
Delta plays delayed dry minus wet before output trim. Use it to inspect changes,
then return to normal monitoring to decide whether the music improved.
Mix is a linear blend of aligned signals. Bypass keeps the same latency.
Use Output for matched-level comparison. Spectral attenuation is not a limiter:
sample peaks can increase even when frequency gains never exceed unity.
Four starting presets and A/B comparison help explore alternatives. All controls
are visible. The instrument-style sidebar holds Amount, Mix, and Output; the
spectrum includes filled energy traces, cut overlays, meter rails, and range
handles, using the plugin’s cyan/coral/violet palette. Host parameters and window size persist; Delta does not. The editor
supports numeric entry, keyboard focus, and direct graph editing.
## Timing and limits
Mixing only: 4096-sample analysis windows at 44.1/48 kHz, 8192 at 88.2/96 kHz,
16384 at 176.4/192 kHz, with eight overlaps. Delay is one window, about 85–93 ms.
The plugin reports it for host compensation. Supported layouts are mono and
linked stereo; supported rates are those six standard rates.
The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstruction,
a log-frequency prominence detector, bounded soft-knee cuts, and temporal/frequency
smoothing. Original bin phases are retained. This is not a minimum-phase filter
bank, a live monitoring effect, a limiter, or an automatic separation of tone and
unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1.
## Build and offline evaluation
```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
```
The renderer accepts mono/stereo PCM16/24/32 and float32 RIFF/WAVE, capped at
256 MiB; WAV extensible/RF64 are not accepted. It writes float32 WAV, compensates
the processing delay, retains original duration, and refuses existing output.
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.
Before calling host support complete, test FL Studio discovery as an effect,
automation, plugin delay compensation, parallel dry/wet alignment, repeated loading,
project restoration, mono/stereo, all supported sample rates, and render tails.
Judge through real patches with matched listening levels: preserve vowel motion,
snare punch, bass body, and stereo placement. Synthetic tests do not establish
artistic usefulness.
Local benchmark: AMD Ryzen 7 PRO 3700U, optimized build, stereo 48 kHz, FFT
4096/hop 512: five seconds processed in 0.233 seconds (about 4.7% of one
core in that run). This is not a worst-case multi-instance guarantee.
Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "resonance-compressor"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
resonance-core = { path = "../../crates/resonance-core" }
resonance-ui = { path = "../../crates/resonance-ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+202
View File
@@ -0,0 +1,202 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
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 std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
delta: AtomicBool,
reset: AtomicU64,
}
pub struct ResonanceCompressor {
params: Arc<ResonanceParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for ResonanceCompressor {
fn default() -> Self {
Self {
params: Arc::new(ResonanceParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(3),
delta: AtomicBool::new(false),
reset: AtomicU64::new(0),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for ResonanceCompressor {
const NAME: &'static str = "Resonance Compressor";
const VENDOR: &'static str = "Storyboard";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let shared = self.shared.clone();
create_egui_editor(
params.editor.clone(),
resonance_ui::State::default(),
|ctx, state| {
state.delta = false;
resonance_ui::style(ctx);
},
move |ctx, setter, state| {
let epoch = shared.reset.load(Ordering::Acquire);
if epoch != state_epoch(ctx) {
state.delta = false;
store_epoch(ctx, epoch);
}
// Delta is never retained after editor close/reopen or a host reset.
shared.delta.store(state.delta, Ordering::Release);
while let Some(frame) = shared.analyzer.pop() {
state.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("resonance-editor")
.min_size(egui::vec2(1040., 700.))
.show(ctx, &params.editor, |ui| {
resonance_ui::show(ui, &mut edited, state)
});
let old = values(&before);
let new = values(&edited);
let floats = params.floats();
for i in 0..29 {
if new[i] != old[i] {
if !state.gestures[i] {
setter.begin_set_parameter(floats[i]);
state.gestures[i] = true;
}
setter.set_parameter(floats[i], new[i]);
}
if !state.gesture_active && state.gestures[i] {
setter.end_set_parameter(floats[i]);
state.gestures[i] = false;
}
}
if edited.bypass != before.bypass {
setter.begin_set_parameter(&params.bypass);
setter.set_parameter(&params.bypass, edited.bypass);
setter.end_set_parameter(&params.bypass);
}
shared.delta.store(state.delta, Ordering::Release);
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
self.shared.delta.store(false, Ordering::Release);
self.shared.reset.fetch_add(1, Ordering::AcqRel);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
self.shared.delta.store(false, Ordering::Release);
self.shared.reset.fetch_add(1, Ordering::AcqRel);
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &mut AuxiliaryBuffers,
_: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
let delta = self.shared.delta.load(Ordering::Acquire) && self.params.editor.is_open();
for mut sample in buffer.iter_samples() {
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x, Monitor { delta });
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
if let Some(frame) = engine.take_analyzer() {
self.shared.analyzer.force_push(frame);
}
self.publish_count = 0;
}
}
// The scheduler must drain its overlap buffers on silent input.
ProcessStatus::KeepAlive
}
}
fn state_epoch(ctx: &egui::Context) -> u64 {
ctx.data(|d| d.get_temp(egui::Id::new("reset-epoch")).unwrap_or(0))
}
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 {
const CLAP_ID: &'static str = "io.storyboard.resonance-compressor";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Dynamic resonance attenuation with explicit tone protection");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Compressor,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(ResonanceCompressor);
+188
View File
@@ -0,0 +1,188 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use resonance_core::{Focus, Settings};
use std::sync::Arc;
#[derive(Params)]
pub struct FocusParams {
#[id = "enabled"]
pub enabled: FloatParam,
#[id = "frequency"]
pub frequency: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "sensitivity"]
pub sensitivity: FloatParam,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
fn log(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(
name,
v,
FloatRange::Skewed {
min,
max,
factor: FloatRange::skew_factor(-2.),
},
)
.with_unit(unit)
}
impl Default for FocusParams {
fn default() -> Self {
Self {
enabled: linear("Enabled", 0., 0., 1., "").with_step_size(1.),
frequency: log("Frequency", 2000., 20., 20000., " Hz"),
width: linear("Width", 0.5, 0.05, 2., " oct"),
sensitivity: linear("Sensitivity", 0., -18., 18., " dB"),
}
}
}
#[derive(Params)]
pub struct ResonanceParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "amount"]
pub amount: FloatParam,
#[id = "sensitivity"]
pub sensitivity: FloatParam,
#[id = "max-cut"]
pub max_cut: FloatParam,
#[id = "neighborhood"]
pub neighborhood: FloatParam,
#[id = "cut-width"]
pub cut_width: FloatParam,
#[id = "knee"]
pub knee: FloatParam,
#[id = "attack"]
pub attack: FloatParam,
#[id = "release"]
pub release: FloatParam,
#[id = "transient-guard"]
pub transient_guard: FloatParam,
#[id = "low"]
pub low: FloatParam,
#[id = "high"]
pub high: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(array, group = "Focus")]
pub focus: [FocusParams; 4],
}
impl Default for ResonanceParams {
fn default() -> Self {
Self {
editor: EguiState::from_size(1180, 760),
amount: linear("Amount", 35., 0., 100., " %"),
sensitivity: linear("Sensitivity", 6., 0., 24., " dB"),
max_cut: linear("Max cut", 3., 0., 18., " dB"),
neighborhood: linear("Neighborhood", 1., 0.25, 2., " oct"),
cut_width: linear("Cut width", 0.08, 0.03, 0.5, " oct"),
knee: linear("Knee", 3., 0., 12., " dB"),
attack: log("Attack", 15., 1., 200., " ms"),
release: log("Release", 120., 10., 1000., " ms"),
transient_guard: linear("Transient guard", 50., 0., 100., " %"),
low: log("Low frequency", 120., 20., 19999., " Hz"),
high: log("High frequency", 12000., 21., 20000., " Hz"),
mix: linear("Mix", 100., 0., 100., " %"),
output: linear("Output", 0., -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
focus: std::array::from_fn(|_| FocusParams::default()),
}
}
}
impl ResonanceParams {
pub fn floats(&self) -> [&FloatParam; 29] {
[
&self.amount,
&self.sensitivity,
&self.max_cut,
&self.neighborhood,
&self.cut_width,
&self.knee,
&self.attack,
&self.release,
&self.transient_guard,
&self.low,
&self.high,
&self.mix,
&self.output,
&self.focus[0].enabled,
&self.focus[0].frequency,
&self.focus[0].width,
&self.focus[0].sensitivity,
&self.focus[1].enabled,
&self.focus[1].frequency,
&self.focus[1].width,
&self.focus[1].sensitivity,
&self.focus[2].enabled,
&self.focus[2].frequency,
&self.focus[2].width,
&self.focus[2].sensitivity,
&self.focus[3].enabled,
&self.focus[3].frequency,
&self.focus[3].width,
&self.focus[3].sensitivity,
]
}
pub fn settings(&self) -> Settings {
let mut s = Settings {
amount: self.amount.value(),
sensitivity: self.sensitivity.value(),
max_cut: self.max_cut.value(),
neighborhood: self.neighborhood.value(),
cut_width: self.cut_width.value(),
knee: self.knee.value(),
attack: self.attack.value(),
release: self.release.value(),
transient_guard: self.transient_guard.value(),
low: self.low.value(),
high: self.high.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
focus: std::array::from_fn(|i| Focus {
enabled: self.focus[i].enabled.value() >= 0.5,
frequency: self.focus[i].frequency.value(),
width: self.focus[i].width.value(),
sensitivity: self.focus[i].sensitivity.value(),
}),
};
// Independent automation of bounds may cross them; preserve a valid effective range.
if s.low >= s.high {
s.low = (s.high - 1.).max(20.);
}
s
}
}
pub fn values(s: &Settings) -> [f32; 29] {
let mut v = [0.; 29];
v[..13].copy_from_slice(&[
s.amount,
s.sensitivity,
s.max_cut,
s.neighborhood,
s.cut_width,
s.knee,
s.attack,
s.release,
s.transient_guard,
s.low,
s.high,
s.mix,
s.output,
]);
for i in 0..4 {
v[13 + i * 4..17 + i * 4].copy_from_slice(&[
f32::from(s.focus[i].enabled),
s.focus[i].frequency,
s.focus[i].width,
s.focus[i].sensitivity,
]);
}
v
}
+199
View File
@@ -0,0 +1,199 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn effect_ports_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &resonance_compressor::clap_entry;
assert!(entry.init.unwrap()(c"resonance-compressor.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.resonance-compressor"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope.params.insert("amount".into(), ParamValue::F32(0.));
envelope
.params
.insert("frequency_1".into(), ParamValue::F32(2300.));
envelope
.params
.insert("enabled_1".into(), ParamValue::F32(1.));
envelope
.params
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
assert!(!restored.fields.contains_key("delta"));
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
assert_eq!(delay, if rate > 48000. { 8192 } else { 4096 });
assert!(p.start_processing.unwrap()(plugin));
let mut position = 0;
while position < delay + 1024 {
let frames = [1, 127, 512, 31][position % 4].min(delay + 1024 - position);
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
let x = if position + i == 0 { 0.5 } else { 0. };
input_left[i] = x;
input_right[i] = -x;
}
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: &incoming,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let expected = if position + i == delay { 0.5 } else { 0. };
assert!((left[i] - expected).abs() < 1e-6);
assert!((right[i] + expected).abs() < 1e-6);
}
position += frames;
}
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+18 -5
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
"""Build and package CLAP without modifying a DAW's plugin directories."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--plugin", choices=["motif-storyboard", "resonance-compressor"], default="motif-storyboard", 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")
args = parser.parse_args()
@@ -16,19 +18,30 @@ if target is None:
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.")
manifest = root / "plugins/motif-storyboard/Cargo.toml"
if args.standalone and args.plugin != "motif-storyboard":
parser.error("Standalone is available only for Motif Storyboard.")
manifest = root / "plugins" / args.plugin / "Cargo.toml"
cmd = ["cargo", "build", "--manifest-path", str(manifest), "--release", "--locked", "--target", target, "--lib"]
if args.standalone:
cmd += ["--features", "standalone", "--bin", "motif-storyboard"]
subprocess.run(cmd, cwd=root, check=True)
build = root / "plugins/motif-storyboard/target" / target / "release"
target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "plugins" / args.plugin / "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)
library = build / ("motif_storyboard.dll" if "windows" in target else "libmotif_storyboard.so")
plugin = destination / "Motif Storyboard.clap"
stem = args.plugin.replace("-", "_")
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"
shutil.copy2(library, plugin)
shutil.copy2(root / "LICENSE", destination)
shutil.copy2(root / "docs/fl-studio.md", destination / "FL-Studio-setup.md")
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")
if args.standalone:
exe = "motif-storyboard.exe" if "windows" in target else "motif-storyboard"
shutil.copy2(build / exe, destination)
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Validate and archive the two Windows CLAP deliverables; no Linux output."""
import hashlib
from pathlib import Path
import struct
import zipfile
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")
offset = struct.unpack_from("<I", data, 0x3C)[0]
if data[offset:offset + 4] != b"PE\0\0" or struct.unpack_from("<H", data, offset + 4)[0] != 0x8664:
raise SystemExit(f"{name}: expected x86-64 Windows DLL")
if b"clap_entry\0" not in data:
raise SystemExit(f"{name}: CLAP entry export missing")
checksums = "".join(f"{hashlib.sha256((folder / name).read_bytes()).hexdigest()} {name}\n" for name in names)
(folder / "SHA256SUMS").write_text(checksums, encoding="utf-8")
archive = root / "dist/fl-plugins-windows-x86_64.zip"
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
for name in names + ["SHA256SUMS"]:
output.write(folder / name, arcname=name)
print(f"Packaged {archive}")
+75
View File
@@ -105,6 +105,8 @@ version = "0.1.0"
dependencies = [
"egui",
"image",
"resonance-core",
"resonance-ui",
"story-app",
"story-core",
"story-midi",
@@ -248,6 +250,24 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -308,6 +328,15 @@ dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -347,6 +376,36 @@ dependencies = [
"bitflags",
]
[[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 = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -433,6 +492,12 @@ dependencies = [
"story-core",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "syn"
version = "2.0.119"
@@ -455,6 +520,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
+2
View File
@@ -12,3 +12,5 @@ image = { version = "0.25", default-features = false, features = ["png"] }
story-app = { path = "../../crates/story-app" }
story-core = { path = "../../crates/story-core" }
story-midi = { path = "../../crates/story-midi" }
resonance-core = { path = "../../crates/resonance-core" }
resonance-ui = { path = "../../crates/resonance-ui" }
+59 -6
View File
@@ -4,6 +4,49 @@ pub use egui;
mod editor;
use std::sync::Arc;
fn main() {
let resonance = std::env::args().any(|arg| arg == "--resonance");
let compact = std::env::args().any(|arg| arg == "--compact");
let (width, height) = if resonance {
if compact {
(1040, 700)
} else {
(1180, 760)
}
} else {
(1100, 850)
};
let mut resonance_settings = resonance_core::Settings::preset(1);
resonance_settings.focus[0] = resonance_core::Focus {
enabled: true,
frequency: 380.,
width: 0.5,
sensitivity: -12.,
};
resonance_settings.focus[1] = resonance_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();
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());
if let Some(frame) = engine.take_analyzer() {
resonance_state.accept_frame(frame);
}
}
}
let shared = Arc::new(story_app::Shared::default());
let mut session = story_core::Session {
source: Some(story_core::demo_motif()),
@@ -30,7 +73,11 @@ fn main() {
}
shared.replace(session).unwrap();
let ctx = egui::Context::default();
editor::style(&ctx);
if resonance {
resonance_ui::style(&ctx);
} else {
editor::style(&ctx);
}
let mut state = editor::EditorState::default();
let mut textures = std::collections::HashMap::new();
let mut output = None;
@@ -39,12 +86,18 @@ fn main() {
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1100., 850.),
egui::vec2(width as f32, height as f32),
)),
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| editor::show(ui, &shared, &mut state));
egui::CentralPanel::default().show(ui, |ui| {
if resonance {
resonance_ui::show(ui, &mut resonance_settings, &mut resonance_state);
} else {
editor::show(ui, &shared, &mut state);
}
});
},
);
for (id, delta) in &frame.textures_delta.set {
@@ -72,7 +125,7 @@ fn main() {
}
let output = output.unwrap();
let meshes = ctx.tessellate(output.shapes, 1.);
let mut image = image::RgbaImage::from_pixel(1100, 850, image::Rgba([23, 34, 51, 255]));
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([23, 34, 51, 255]));
for clipped in meshes {
let egui::epaint::Primitive::Mesh(mesh) = clipped.primitive else {
continue;
@@ -106,14 +159,14 @@ fn main() {
.max(b.pos.x)
.max(c.pos.x)
.min(clipped.clip_rect.right())
.min(1099.) as u32;
.min((width - 1) as f32) as u32;
let max_y = a
.pos
.y
.max(b.pos.y)
.max(c.pos.y)
.min(clipped.clip_rect.bottom())
.min(849.) as u32;
.min((height - 1) as f32) as u32;
let cross = |u: egui::Vec2, v: egui::Vec2| u.x * v.y - u.y * v.x;
let area = cross(b.pos - a.pos, c.pos - a.pos);
if area.abs() < 0.0001 {