feat: add resonance compressor and Windows CI
This commit is contained in:
1 parent
3281535a04
commit
3c5b05fdec
31 files changed
+6340
-57
No files matched your search
@@ -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/
|
||||
@@ -33,3 +33,6 @@ venv/
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# GitLab job-local Cargo cache.
|
||||
/.cargo-ci/
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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 |
Generated
+2435
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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, ¶ms.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(¶ms.bypass);
|
||||
setter.set_parameter(¶ms.bypass, edited.bypass);
|
||||
setter.end_set_parameter(¶ms.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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
Generated
+75
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user