Compare commits
64 Commits
c398b86d2b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 74dfdc50cc | |||
| 94d41d5c8a | |||
| b27ef57676 | |||
| c940f31ec7 | |||
| a8f1244b00 | |||
| d107127017 | |||
| d51de80de8 | |||
| 7a92485cf1 | |||
| 6c0332d79e | |||
| 309a152d58 | |||
| a62efe44b3 | |||
| 3a144064e3 | |||
| 3161aa9388 | |||
| d87934c428 | |||
| 1361d7c919 | |||
| 6ab9fb9c3f | |||
| 37f34e1175 | |||
| 73edf58b32 | |||
| 193cee0aef | |||
| 814dd5eacd | |||
| 466ab478c9 | |||
| 87a6f2e276 | |||
| 570dd65fa8 | |||
| 058d73f6cd | |||
| 5958b077e2 | |||
| b862f5a971 | |||
| 43483dd303 | |||
| b86d5d3eaf | |||
| 17c602bb10 | |||
| 03d60a157b | |||
| eda3d09399 | |||
| 057d630c5b | |||
| abc9ea8b4f | |||
| 45735f71f7 | |||
| a4c542b2d9 | |||
| 123703d34d | |||
| b1477f7ec6 | |||
| a8be2179c3 | |||
| 42c5f7dcd2 | |||
| eee94379bc | |||
| 3aeb09957a | |||
| 514fd964f6 | |||
| 9a60b0ea72 | |||
| fe4033b772 | |||
| 0c2e1597a1 | |||
| eb2499bad3 | |||
| 3ae860188e | |||
| 411b27fcf3 | |||
| 4c5165b0bc | |||
| 9a54413bfa | |||
| f8f652f2c7 | |||
| a420d5dd39 | |||
| 8c550da92e | |||
| 20c5a17a61 | |||
| 263d5e688f | |||
| f6c45123fa | |||
| ada0b00313 | |||
| d776e564fd | |||
| 1d939535be | |||
| 0f66e9638c | |||
| 8d9eaeaffc | |||
| d9dc61cf90 | |||
| e9337473b9 | |||
| d0734ff803 |
@@ -0,0 +1,2 @@
|
|||||||
|
[alias]
|
||||||
|
xtask = "run --package xtask --release --"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Line endings: LF in the repository and in every working tree, on every OS.
|
||||||
|
#
|
||||||
|
# This file is the policy, not each machine's git config. Git for Windows installs
|
||||||
|
# with core.autocrlf=true, which checks text out as CRLF; an attribute here beats
|
||||||
|
# that setting, so a fresh clone gets LF regardless of how its machine is set up.
|
||||||
|
#
|
||||||
|
# It matters beyond tidiness. tools/branding-sites.tsv is read line by line with
|
||||||
|
# `read`, and under CRLF every row's last field gained a trailing CR byte - so the
|
||||||
|
# branding checker reported all 29 sites as missing on Windows while passing on the
|
||||||
|
# Linux CI runner. text=auto leaves genuinely binary files alone.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Shell scripts are pinned explicitly as well: a strict POSIX sh (dash, CI
|
||||||
|
# containers) rejects a CRLF shebang outright rather than misbehaving quietly.
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# The exception. cmd.exe misparses LF-only batch files around labels and long
|
||||||
|
# lines, so these alone are checked out CRLF. The repository copy is still LF.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'xtask/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'bundler.toml'
|
||||||
|
- 'tools/**'
|
||||||
|
# This workflow validates itself: a broken step, a moved script path or a
|
||||||
|
# wrong runner label then fails loudly on the commit that caused it, rather
|
||||||
|
# than surfacing later against an unrelated source change. Note the limit -
|
||||||
|
# it checks the job body, not the trigger block above. Break `on:` and the
|
||||||
|
# symptom is silence, which looks like success.
|
||||||
|
- '.github/workflows/**'
|
||||||
|
# Mirrors the push filter. Without it a PR touching only the README would run a
|
||||||
|
# full build - the branch filter alone constrains where, not what.
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'xtask/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- 'bundler.toml'
|
||||||
|
- 'tools/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Target the self-hosted Rust/audio runner registered to the `musiikki` org.
|
||||||
|
#
|
||||||
|
# The runner is host-mode, so the rustup toolchain installed for the
|
||||||
|
# `gitea-runner` user is used directly — nothing installs per run, and the cargo
|
||||||
|
# registry cache stays warm between jobs.
|
||||||
|
#
|
||||||
|
# Adapted from musiikki/nih-plug-template. The template gates VST3 behind an
|
||||||
|
# opt-in `vst3` feature and so builds twice; this plugin always ships VST3
|
||||||
|
# (nih_plug is taken with default features), so one build covers both formats.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: rust-audio
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Toolchain
|
||||||
|
run: |
|
||||||
|
cargo --version
|
||||||
|
rustc --version
|
||||||
|
echo "CARGO_HOME=${CARGO_HOME:?not set on this runner}"
|
||||||
|
|
||||||
|
# Cheap and needs no toolchain, so it runs first: a rename that only
|
||||||
|
# landed in half the files is worth catching before spending a build on it.
|
||||||
|
- name: Branding consistency
|
||||||
|
run: sh tools/check-branding.sh
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --release
|
||||||
|
|
||||||
|
# Proves the xtask bundler still works, which a plain `cargo build` does
|
||||||
|
# not exercise — the bundle step is where bundler.toml and the crate name
|
||||||
|
# have to agree.
|
||||||
|
- name: Bundle
|
||||||
|
run: cargo xtask bundle bari_206 --release
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
|
||||||
|
# Local NIH-plug reference clone ("deluxe kit") and cookiecutter template.
|
||||||
|
# Kept out of version control; the build depends on nih-plug via a pinned git rev,
|
||||||
|
# not on these working copies. Clone them locally for reading the examples.
|
||||||
|
/nih-plug
|
||||||
|
/_template
|
||||||
Generated
+2546
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "bari_206"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Mikkeli Matlock <mikkeli@novoyuuparosk.org>"]
|
||||||
|
homepage = "https://github.com/mikkeli/codename-206"
|
||||||
|
description = "A VST3/CLAP multiband compressor/limiter with a custom gain-curve display"
|
||||||
|
# VST3 is on by default in nih_plug, and its bindings (vst3-sys) are GPLv3. Any
|
||||||
|
# distributed VST3 binary built with them must therefore be GPL-3.0-or-later
|
||||||
|
# (or covered by a commercial Steinberg licence). CLAP carries no such requirement.
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["xtask"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests.
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
|
||||||
|
# Future-incompatibility warnings are deferred hard errors: rustc is telling you
|
||||||
|
# this code stops compiling on some later release. Denying the group turns that
|
||||||
|
# into a build failure the day it appears, rather than 16 of them accumulating
|
||||||
|
# unnoticed until a toolchain bump breaks the build on a day you did not choose.
|
||||||
|
# Style and clippy lints are deliberately untouched - they stay warnings.
|
||||||
|
[lints.rust]
|
||||||
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Pinned to the exact commit of the local ./nih-plug reference clone so the build
|
||||||
|
# is reproducible and matches the examples we read from. Bump the `rev` together
|
||||||
|
# with `git -C nih-plug pull` when you want to update.
|
||||||
|
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23", features = ["assert_process_allocs"] }
|
||||||
|
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
strip = "symbols"
|
||||||
|
|
||||||
|
[profile.profiling]
|
||||||
|
inherits = "release"
|
||||||
|
debug = true
|
||||||
|
strip = "none"
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) 2026 Mikkeli Matlock
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) 2026 Mikkeli Matlock
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Bari 206
|
||||||
|
Copyright 2026 Mikkeli Matlock
|
||||||
|
|
||||||
|
Licensed under the GNU General Public License v3.0 or later.
|
||||||
|
See LICENSE for the full terms.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Third-party material
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
The plugin scaffolding derives from the NIH-plug cookiecutter template and
|
||||||
|
NIH-plug's example plugins, by Robbert van der Helm:
|
||||||
|
|
||||||
|
https://github.com/robbert-vdh/nih-plug-template
|
||||||
|
https://github.com/robbert-vdh/nih-plug
|
||||||
|
|
||||||
|
Those portions are licensed under the ISC License:
|
||||||
|
|
||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Robbert van der Helm
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||||
|
SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||||
|
CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
ISC is GPL-compatible, so this notice is retained rather than superseded.
|
||||||
|
|
||||||
|
"VST" is a trademark of Steinberg Media Technologies GmbH.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Codename 206
|
# Bari 206
|
||||||
|
|
||||||
*Called 206 because the Peugeot 206 has a 'maxi' variant. Subdued lineage to the FL Studio plugin 'maximiser'*
|
*Called 206 because the Peugeot 206 has a 'maxi' variant. You'll know this is a Maximizer knockoff if you can follow that trail of thoughts.*
|
||||||
Multiband Compressor / Limiter VST3 — Project Plan
|
Multiband Compressor / Limiter VST3 — Project Plan
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -10,6 +10,7 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
|
|||||||
|
|
||||||
**Goals:**
|
**Goals:**
|
||||||
- 3-band (configurable crossover points) compressor/limiter
|
- 3-band (configurable crossover points) compressor/limiter
|
||||||
|
- An 'All' aggregate channel: a 4th comp/lim stack on the summed bands, so bypassing all bands turns the plugin into a simple full-band compressor (mirrors FL's Maximizer)
|
||||||
- Look-ahead brickwall output limiter with true-peak detection
|
- Look-ahead brickwall output limiter with true-peak detection
|
||||||
- Real-time gain reduction metering per band
|
- Real-time gain reduction metering per band
|
||||||
- Custom gain curve visualiser
|
- Custom gain curve visualiser
|
||||||
@@ -33,14 +34,24 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
|
|||||||
```
|
```
|
||||||
Input
|
Input
|
||||||
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
||||||
├─ Band 1 (low) → look-ahead delay → compressor VCA → gain stage
|
├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix)
|
||||||
├─ Band 2 (mid) → look-ahead delay → compressor VCA → gain stage
|
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||||
└─ Band 3 (high) → look-ahead delay → compressor VCA → gain stage
|
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||||
└─ Sum → output brickwall limiter (true-peak, 4x oversampled) → output
|
│
|
||||||
|
Sum of bands ◄─────────────────────────────────────────────────────------┘
|
||||||
|
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
|
||||||
|
└─ output brickwall limiter (true-peak, 4x oversampled) → output
|
||||||
```
|
```
|
||||||
|
|
||||||
The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduction is already ramping when the transient arrives.
|
The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduction is already ramping when the transient arrives.
|
||||||
|
|
||||||
|
**The 'All' aggregate channel** (mirrors FL's Maximizer): the three bands are summed and the
|
||||||
|
result passes through a *fourth*, full-band compressor/limiter stack before the output limiter.
|
||||||
|
Because the LR4 filterbank sums phase-coherently flat, **bypassing all three bands leaves the
|
||||||
|
summed signal identical to the input** — so the plugin collapses into a plain single-band
|
||||||
|
compressor/limiter driven entirely by the 'All' channel. That makes "multiband off = simple comp"
|
||||||
|
a first-class mode, not an afterthought.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DSP Architecture
|
## DSP Architecture
|
||||||
@@ -48,122 +59,190 @@ The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduc
|
|||||||
### Crossover Filterbank
|
### Crossover Filterbank
|
||||||
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
|
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
|
||||||
- LR4 = two cascaded biquads (Butterworth LP or HP)
|
- LR4 = two cascaded biquads (Butterworth LP or HP)
|
||||||
- Bands sum phase-coherently back to flat
|
- Bands sum phase-coherently to flat **magnitude** (the sum is an all-pass; lower bands get an all-pass at each later crossover to match phase — not a bit-exact time-domain null)
|
||||||
- Crossover frequencies are user-adjustable parameters
|
- Crossover frequencies are user-adjustable parameters
|
||||||
### Per-Band Compressor
|
### Per-Band Compressor
|
||||||
- Level detection: switchable RMS / peak, with configurable window
|
- **Pre-gain (drive)**: scales the band *before* the detector, so it pushes harder into compression and feeds the sum/limiter hotter — a mild "compressed semi-distortion" without a dedicated saturator. Applied in the wiring (the compressor itself is untouched). Pairs with makeup for full input/output gain-staging
|
||||||
|
- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later)
|
||||||
- Gain computer: threshold, ratio, soft knee
|
- Gain computer: threshold, ratio, soft knee
|
||||||
- Attack / release envelopes (logarithmic ballistics)
|
- Attack / release envelopes (logarithmic ballistics)
|
||||||
- Makeup gain per band
|
- Makeup gain per band (−24…+24 dB — attenuates as well as boosts)
|
||||||
- Look-ahead: circular delay buffer on the audio path; detector reads ahead
|
- Look-ahead: circular delay buffer on the audio path; detector reads ahead
|
||||||
|
### 'All' Aggregate Channel
|
||||||
|
- Structurally **identical to a per-band compressor** — reuse the same comp/lim code/params, just fed the summed signal instead of a filtered band
|
||||||
|
- Runs after the three bands are summed, before the output brickwall limiter
|
||||||
|
- Bands have a per-channel dry/wet **mix** (parallel compression); at 0% (or all three dry) the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim
|
||||||
|
- Has its own look-ahead; the plugin reports a single **constant** total latency (the fixed band + 'All' look-ahead), set once — see Latency below
|
||||||
### Output Limiter
|
### Output Limiter
|
||||||
- True-peak brickwall (ceiling = 0 dBFS or user-defined)
|
- Brickwall, ceiling = 0 dBFS or user-defined (`output_ceiling`). Look-ahead + sliding-max peak detection + a ceiling clamp guarantee the output never exceeds the ceiling
|
||||||
- 4x oversampling for inter-sample peak detection
|
- Short attack (≤ 0.1 ms), auto-release (release time user-set)
|
||||||
- Short attack (≤ 0.1 ms), auto-release
|
- **True-peak**: 4× polyphase oversampling estimates the inter-sample peak (detection only — the upsampled signal is discarded); the limiter targets a 0.3 dB margin under the ceiling to cover the 4× residual
|
||||||
### Latency
|
### Latency
|
||||||
- Look-ahead duration must be reported via `Plugin::latency()` for DAW compensation
|
- Reported via `context.set_latency_samples()` in `initialize()` — **never** from `process()`; renegotiating latency mid-stream crashes some hosts (FL included)
|
||||||
|
- Reported latency is a **constant** (the max look-ahead); the look-ahead control only moves the detector tap within that fixed delay
|
||||||
- All bands use equal delay to preserve phase alignment
|
- All bands use equal delay to preserve phase alignment
|
||||||
---
|
---
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
### Global
|
### Global
|
||||||
- `input_gain` — pre-gain before filterbank (dB)
|
|
||||||
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
||||||
- `look_ahead_ms` — look-ahead time (0–10 ms)
|
- `limiter_release_ms` — output limiter release time
|
||||||
|
- `look_ahead_ms` — look-ahead time (0–5 ms). Reported latency is **constant** (the max look-ahead); the knob only moves the detector tap within that fixed delay, so it is safe to adjust during playback (changing reported latency mid-stream crashes some hosts, FL included)
|
||||||
- `crossover_low_hz` — low/mid crossover frequency
|
- `crossover_low_hz` — low/mid crossover frequency
|
||||||
- `crossover_high_hz` — mid/high crossover frequency
|
- `crossover_high_hz` — mid/high crossover frequency
|
||||||
### Per Band (× 3, use a `#[nested]` params struct)
|
|
||||||
|
> **Crossover automation caveat:** the lo ≤ hi limit is enforced in the **editor only** (the two
|
||||||
|
> are independent params). Host automation writes them directly, so it can drive lo past hi and
|
||||||
|
> momentarily invert the mid band. The DSP clamps to a monotonic split so it won't break audio,
|
||||||
|
> but FL's automation can misbehave once inverted. Not fixed by design — just don't automate the
|
||||||
|
> two across each other.
|
||||||
|
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
|
||||||
|
- `pre_gain_db` — drive into the compressor (−24…+36 dB, smoothed)
|
||||||
|
- `detection` — peak / RMS level detection
|
||||||
|
- `low_slope` — low-level shaper slope at the silence floor (1 = unity, >1 fans up/boost, <1 fans down/cut). **Serial**: reshapes the level *before* the threshold, so a boost can lift quiet material up into compression
|
||||||
|
- `low_curve` — bends the low shaper toward a bounded saturation (0% = straight line) so the serial composition doesn't run away
|
||||||
- `threshold_db`
|
- `threshold_db`
|
||||||
- `ratio` — 1.0 (off) to ∞ (limiting)
|
- `ratio` — 1.0 (off) to ∞ (limiting)
|
||||||
|
- `knee_db` — soft knee width
|
||||||
- `attack_ms`
|
- `attack_ms`
|
||||||
- `release_ms`
|
- `release_ms`
|
||||||
- `knee_db` — soft knee width
|
- `makeup_db` — makeup gain (−24…+24 dB)
|
||||||
- `makeup_gain_db`
|
- `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel
|
||||||
- `bypass` — per-band bypass
|
|
||||||
|
The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
Target layout (✅ = exists today; the rest is planned):
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
lib.rs # Plugin entry point, implements Plugin trait
|
lib.rs # ✅ Plugin trait + DSP wiring + process()
|
||||||
params.rs # Params struct with NIH-plug #[id] attributes
|
params.rs # ✅ Params structs, defaults, build_settings()
|
||||||
|
editor.rs # ✅ egui editor: meter panel + rolling plot (drawn via Painter) + slider columns
|
||||||
|
meters.rs # ✅ lock-free Meters (atomics): decayed bar values + raw plot feed
|
||||||
dsp/
|
dsp/
|
||||||
mod.rs
|
mod.rs # ✅ module declarations
|
||||||
crossover.rs # LR4 filterbank (biquad chains)
|
compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay
|
||||||
compressor.rs # Per-band compressor + look-ahead
|
crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation
|
||||||
limiter.rs # Output true-peak brickwall limiter
|
biquad.rs # ✅ generic biquad (Transposed Direct Form II)
|
||||||
biquad.rs # Generic biquad filter (Direct Form II transposed)
|
limiter.rs # ✅ look-ahead brickwall limiter (true-peak via oversampler)
|
||||||
delay.rs # Circular buffer for look-ahead delay lines
|
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
|
||||||
oversampler.rs # 4x oversampler for true-peak detection
|
|
||||||
editor/
|
|
||||||
mod.rs # egui editor setup via nih_plug_egui
|
|
||||||
widgets/
|
|
||||||
gain_curve.rs # Custom egui Widget: gain curve display
|
|
||||||
band_meter.rs # Per-band gain reduction meter
|
|
||||||
level_meter.rs# Input/output level meter
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The editor lives in an `editor/` module — one file per visualiser widget (each owns its GUI
|
||||||
|
state), with `mod.rs` as the aggregator/layout. Drawn directly with egui's `Painter`.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
editor/
|
||||||
|
mod.rs # aggregator: create(), EditorState, layout, placeholder slider columns
|
||||||
|
meter.rs # |L | GR | R| level + gain-reduction bars + per-channel ceiling lamp
|
||||||
|
plot.rs # rolling in/out/GR scope (200 Hz ring feed) + ceiling-hit markers
|
||||||
|
crossover.rs # log-freq strip with draggable crossover handles + number boxes
|
||||||
|
gain_curve.rs # static gain-curve display (out vs in) for the selected channel
|
||||||
|
```
|
||||||
|
|
||||||
|
Remaining UI work: replace the placeholder per-channel slider columns in `mod.rs` with the real
|
||||||
|
layout.
|
||||||
|
|
||||||
|
Deferred until the redesign — no need to split prematurely while the layout is still a placeholder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build Steps
|
## Build Steps
|
||||||
|
|
||||||
```bash
|
The project is already scaffolded (NIH-plug + nih_plug_egui, pinned to a fixed git rev in
|
||||||
# Install Rust (if not already)
|
`Cargo.toml`). You do **not** need the Steinberg VST3 SDK — NIH-plug bundles its own bindings.
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
||||||
|
|
||||||
# Clone NIH-plug cookiecutter or start fresh
|
**Prerequisites (Windows):**
|
||||||
cargo new --lib my_maximizer
|
- Rust stable (`rustup` — `winget install Rustlang.Rustup`)
|
||||||
cd my_maximizer
|
- Visual Studio 2022 with the "Desktop development with C++" workload (provides the MSVC linker)
|
||||||
|
|
||||||
# Add dependencies to Cargo.toml:
|
```powershell
|
||||||
# nih-plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["assert_process_allocs"] }
|
# Build + bundle the VST3 and CLAP
|
||||||
# nih-plug-egui = { git = "https://github.com/robbert-vdh/nih-plug" }
|
cargo xtask bundle bari_206 --release
|
||||||
|
# Output: target\bundled\Bari 206.vst3 and Bari 206.clap
|
||||||
# Build and bundle
|
|
||||||
cargo xtask bundle my_maximizer --release
|
|
||||||
# Output: target/bundled/my_maximizer.vst3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
FL Studio scans `C:\Program Files\Common Files\VST3` by default, **ignores directory junctions**
|
||||||
|
(so a symlinked bundle is invisible to its scanner), and caches failed scans. So deployment must
|
||||||
|
copy a *real* bundle into a folder FL scans, then FL must be told to rescan failed plugins.
|
||||||
|
|
||||||
|
Use the provided script (no need to remember the details):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy.ps1 # build, then copy to the global VST3/CLAP folders (one UAC prompt)
|
||||||
|
.\deploy.ps1 -SkipBuild # reinstall the last build without rebuilding
|
||||||
|
.\deploy.ps1 -User # copy to %LOCALAPPDATA%\Programs\Common\VST3 instead (no admin) — best for a dev loop
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.bat` is a double-click wrapper around the same script.
|
||||||
|
|
||||||
|
After deploying, in FL Studio: **Options → Manage plugins → tick "Rescan previously failed
|
||||||
|
plugins" → Find installed plugins**, then search for **Bari 206**. (The rescan-failed step
|
||||||
|
is essential — without it FL silently skips a plugin it has seen before.)
|
||||||
|
|
||||||
|
> **Known issue (deferred):** the **CLAP** build shows its name/vendor/type correctly in FL, but
|
||||||
|
> the **VST3** still displays stale/missing metadata there. Suspected cause is FL caching the VST3
|
||||||
|
> by its unchanged `VST3_CLASS_ID`. Likely fix is to regenerate that class ID (and/or clear FL's
|
||||||
|
> plugin DB); low priority for now — use the CLAP build meanwhile.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Order
|
## Implementation Order
|
||||||
|
|
||||||
Work through these stages in order — each stage produces a loadable, audible plugin.
|
Work through these stages in order — each stage produces a loadable, audible plugin.
|
||||||
|
|
||||||
### Stage 1 — Skeleton plugin
|
**Status (2026-06-25):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
||||||
- [ ] NIH-plug "passthrough" compiling and loading in DAW
|
per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
|
||||||
- [ ] `Params` struct with all parameters declared (no DSP yet)
|
brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
|
||||||
- [ ] `process()` passes audio through untouched
|
an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
|
||||||
- [ ] Verify plugin loads and parameters appear in DAW
|
meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
|
||||||
### Stage 2 — Single-band compressor (no look-ahead, no UI)
|
ceiling-hit markers), **draggable crossover handles**, and a **static gain-curve display**. **Next:
|
||||||
- [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed
|
replace the placeholder slider columns with the real UI layout.**
|
||||||
- [ ] Implement basic RMS level detector
|
|
||||||
- [ ] Implement gain computer (threshold, ratio, knee)
|
### Stage 1 — Skeleton plugin ✅
|
||||||
- [ ] Implement attack/release envelope on gain reduction
|
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
||||||
- [ ] Wire into `process()`, test with a sine sweep
|
- [ ] `Params` struct with all parameters declared *(partial — compressor + look-ahead params done; global `input_gain`/`output_ceiling` and crossover params pending)*
|
||||||
### Stage 3 — Crossover filterbank
|
- [x] `process()` passes audio through untouched *(since superseded by the compressor)*
|
||||||
- [ ] Implement LR4 LP and HP biquad chains in `crossover.rs`
|
- [x] Verify plugin loads and parameters appear in DAW *(verified in FL Studio)*
|
||||||
- [ ] Verify bands sum flat (null test: sum vs dry should be silence)
|
### Stage 2 — Single-band (full-band) compressor ✅
|
||||||
- [ ] Apply per-band compressor to each band
|
- [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed *(deferred to Stage 3 — not needed for the full-band comp)*
|
||||||
- [ ] Sum bands back to output
|
- [x] Level detector — switchable **peak / RMS** (RMS window hardcoded for now)
|
||||||
### Stage 4 — Look-ahead + brickwall limiter
|
- [x] Implement gain computer (threshold, ratio, soft knee)
|
||||||
- [ ] Implement `delay.rs` circular buffer
|
- [x] Implement attack/release envelope (smooth decoupled peak detector)
|
||||||
- [ ] Wire look-ahead: detector reads N samples ahead of VCA
|
- [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency)
|
||||||
- [ ] Report latency via `Plugin::latency()`
|
### Stage 3 — Crossover filterbank ✅
|
||||||
- [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc)
|
- [x] Implement LR4 LP/HP biquad chains in `crossover.rs` (+ generic `biquad.rs`, Transposed Direct Form II)
|
||||||
- [ ] Implement brickwall output limiter with true-peak detection
|
- [x] Verify bands sum flat — for IIR LR4 the sum is an **all-pass** (flat *magnitude*, phase-shifted), not a bit-exact null; lower bands get an all-pass at each later crossover to phase-match. Tested via `bands_sum_to_flat_magnitude`
|
||||||
### Stage 5 — Basic egui UI
|
- [x] Per-band bypass — a bypassed band passes its delayed dry band; with all three bypassed the 'All' channel sees the flat-magnitude reconstruction = the simple-comp mode
|
||||||
- [ ] Add `nih_plug_egui` editor
|
- [x] Apply per-band compressor to each band
|
||||||
- [ ] Knobs / sliders for all parameters
|
- [x] Sum bands back together
|
||||||
- [ ] Per-band bypass toggles
|
- [x] Run the summed signal through the 'All' channel compressor before output
|
||||||
- [ ] Confirm UI controls update DSP in real time
|
### Stage 4 — Output brickwall limiter + oversampler ✅
|
||||||
|
- [x] Look-ahead delay (circular buffer) — inside `compressor.rs` and `limiter.rs`, no separate `delay.rs`
|
||||||
|
- [x] Wire look-ahead: detector reads N samples ahead of the VCA
|
||||||
|
- [x] Report latency — `context.set_latency_samples()` once; constant three-stage total (bands + 'All' + limiter)
|
||||||
|
- [x] Brickwall output limiter (`limiter.rs`): look-ahead + sliding-max + ceiling clamp guarantee
|
||||||
|
- [x] `oversampler.rs` — 4× polyphase windowed-sinc, detection-only (returns the inter-sample max)
|
||||||
|
- [x] True-peak limiting: limiter peak = max(sample, inter-sample); targets a 0.3 dB margin under the ceiling for the 4× residual
|
||||||
|
### Stage 5 — Basic egui UI *(basic version done early)*
|
||||||
|
- [x] Add `nih_plug_egui` editor
|
||||||
|
- [x] Sliders for all current parameters (`ParamSlider` grid)
|
||||||
|
- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle)
|
||||||
|
- [x] Confirm UI controls update DSP in real time
|
||||||
### Stage 6 — Custom visualisations
|
### Stage 6 — Custom visualisations
|
||||||
- [ ] `level_meter.rs` — input/output RMS + peak meters
|
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
|
||||||
- [ ] `band_meter.rs` — per-band gain reduction meters (vertical bars)
|
- [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
|
||||||
- [ ] `gain_curve.rs` — static gain curve display per band (threshold/ratio/knee)
|
- [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
|
||||||
- [ ] Draggable crossover handles on a frequency display
|
- [x] Static gain-curve display (out vs in; includes pre-gain + makeup) for the selected channel
|
||||||
|
- [x] Draggable crossover handles on a log-frequency display (with number boxes)
|
||||||
|
- [ ] Replace the placeholder slider columns with the real UI
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Implementation Notes
|
## Key Implementation Notes
|
||||||
@@ -174,20 +253,58 @@ must be pre-allocated in `initialize()`. Use `assert_process_allocs` feature fla
|
|||||||
development to catch violations.
|
development to catch violations.
|
||||||
|
|
||||||
### Denormal flushing
|
### Denormal flushing
|
||||||
Add `#[cfg(target_arch = "x86_64")] std::arch::x86_64::_MM_SET_FLUSH_ZERO_MODE(...)` in
|
Handled by the framework — no plugin code needed. NIH-plug wraps `process()` and `reset()` in
|
||||||
`initialize()`, or add a small DC offset (1e-25) to filter inputs.
|
`process_wrapper`, which enables the CPU's **Flush-To-Zero** mode for the duration via its
|
||||||
|
`ScopedFtz` guard (x86 `MXCSR` bit 15 / AArch64 `FPCR` bit 24, set with inline asm and restored
|
||||||
|
on drop). FTZ has a fixed threshold at the normal/subnormal boundary (~−759 dB for f32), so the
|
||||||
|
decaying envelope/RMS tails and all the IIR filter state are flushed to zero automatically,
|
||||||
|
far below audibility. We therefore do **not** set the register ourselves or flush values in code.
|
||||||
|
(Note: NIH-plug sets FTZ but not DAZ; for our feed-forward IIR work FTZ on results is sufficient.)
|
||||||
|
|
||||||
### Parameter smoothing
|
### Parameter smoothing
|
||||||
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
|
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
|
||||||
|
|
||||||
### Thread safety
|
### Thread safety
|
||||||
Params are atomics. The editor and audio thread communicate only through params and
|
Params are atomics. The editor and audio thread communicate only through params and a shared
|
||||||
`Arc<Mutex<...>>` meter data. Never pass DSP state to the UI directly.
|
`Arc<Meters>` (`meters.rs`) — never a mutex on the audio path. Two lock-free feeds, both gated on
|
||||||
|
the editor being open:
|
||||||
|
- **Bar meters** — decayed atomic scalars, one store per block; the editor reads them each frame.
|
||||||
|
- **Scrolling plot** — a single-producer/single-consumer `ScopeRing` of buckets clocked at
|
||||||
|
~200 Hz, so the plot's horizontal resolution is decoupled from the ~60 fps repaint. The editor
|
||||||
|
drains all new buckets each frame. The scope is **transport-gated** (advances only while playing)
|
||||||
|
so it freezes rather than scrolling silence when the host is stopped/paused.
|
||||||
|
|
||||||
|
Never pass DSP state to the UI directly.
|
||||||
|
|
||||||
### VST3 licensing
|
### VST3 licensing
|
||||||
You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries.
|
This plugin is **GPL-3.0-or-later** (see `LICENSE`, and `NOTICE` for attribution).
|
||||||
NIH-plug's VST3 bindings are GPLv3; if you distribute, the plugin must also be GPLv3
|
|
||||||
(or you need a commercial Steinberg licence). CLAP has no such restriction.
|
`nih_export_vst3!()` links `vst3-sys`, which is GPLv3-or-later by its own authors' choice —
|
||||||
|
they describe their port as a derivative of Steinberg's SDK that "was not made in isolation".
|
||||||
|
That is what makes a distributed VST3 build GPL-licensed, and it holds regardless of any
|
||||||
|
question about Steinberg's own rights.
|
||||||
|
|
||||||
|
No Steinberg SDK source is vendored or redistributed here, so there is nothing to accept at
|
||||||
|
build time. Separately from copyright, **"VST" is a trademark of Steinberg Media Technologies
|
||||||
|
GmbH**; describing a distributed product as a VST plugin is governed by their trademark terms,
|
||||||
|
which no source licence resolves. CLAP carries neither encumbrance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/ci.yaml` gates pushes and PRs on the branding check, a release build,
|
||||||
|
`cargo test`, and a bundle — enough to catch a rename that only half-landed, a build break,
|
||||||
|
or a `bundler.toml` that no longer matches the crate name.
|
||||||
|
|
||||||
|
It targets `runs-on: rust-audio`, a self-hosted host-mode runner registered to the `musiikki`
|
||||||
|
org. That label is org-scoped: a fork outside `musiikki` will not see the runner and its jobs
|
||||||
|
will queue indefinitely rather than fail.
|
||||||
|
|
||||||
|
Plugin performance is deliberately not gated. Realtime cost is only meaningful against an
|
||||||
|
audio deadline, and `Plugin::process()` cannot be called from a test at all — `ProcessContext`
|
||||||
|
requires a `&Transport`, whose constructor and fields are `pub(crate)` to nih_plug. The DSP
|
||||||
|
types under `src/dsp/` take no nih-plug context and are directly testable if you need it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Metadata for NIH-plug's `cargo xtask bundle <package>` bundler.
|
||||||
|
#
|
||||||
|
# [package_name]
|
||||||
|
# name = "Human Readable Plugin Name" # defaults to <package_name>
|
||||||
|
|
||||||
|
[bari_206]
|
||||||
|
name = "Bari 206"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@echo off
|
||||||
|
REM Double-click wrapper for deploy.ps1 (build + install to system VST3/CLAP folders).
|
||||||
|
REM Pass-through args, e.g.: deploy.bat -User / deploy.bat -SkipBuild
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
|
||||||
|
pause
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build Bari 206 and install the VST3 (and CLAP) into the system plugin folders.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
FL Studio scans `C:\Program Files\Common Files\VST3` by default and ignores
|
||||||
|
directory junctions, so deployment must place a REAL bundle copy there. That
|
||||||
|
folder needs admin, so the copy step self-elevates (one UAC prompt).
|
||||||
|
|
||||||
|
After deploying, in FL Studio: Options -> Manage plugins ->
|
||||||
|
tick "Rescan previously failed plugins" -> "Find installed plugins".
|
||||||
|
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
Install the existing target\bundled output without rebuilding.
|
||||||
|
|
||||||
|
.PARAMETER User
|
||||||
|
Install to the per-user folder %LOCALAPPDATA%\Programs\Common\VST3 instead of
|
||||||
|
the global one. No admin required; add that path in FL's plugin search paths.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy.ps1 # build + install to global VST3/CLAP (UAC prompt)
|
||||||
|
.\deploy.ps1 -SkipBuild # reinstall last build
|
||||||
|
.\deploy.ps1 -User # no-admin install for a fast dev loop
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$User,
|
||||||
|
[string]$Package = "bari_206"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
# rustup installs cargo here; make sure it's reachable even in a fresh shell.
|
||||||
|
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
|
||||||
|
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
|
||||||
|
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host "Building $Package (release)..." -ForegroundColor Cyan
|
||||||
|
Push-Location $root
|
||||||
|
try { cargo xtask bundle $Package --release } finally { Pop-Location }
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "cargo xtask bundle failed (exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$vst3Name = "Bari 206.vst3"
|
||||||
|
$clapName = "Bari 206.clap"
|
||||||
|
$bundled = Join-Path $root "target\bundled"
|
||||||
|
$vst3Src = Join-Path $bundled $vst3Name
|
||||||
|
$clapSrc = Join-Path $bundled $clapName
|
||||||
|
if (-not (Test-Path $vst3Src)) { throw "Bundle not found: $vst3Src (build first)" }
|
||||||
|
|
||||||
|
if ($User) {
|
||||||
|
$vst3Dst = Join-Path $env:LOCALAPPDATA "Programs\Common\VST3"
|
||||||
|
$clapDst = Join-Path $env:LOCALAPPDATA "Programs\Common\CLAP"
|
||||||
|
New-Item -ItemType Directory -Force -Path $vst3Dst, $clapDst | Out-Null
|
||||||
|
Write-Host "Installing (per-user, no admin)..." -ForegroundColor Cyan
|
||||||
|
Remove-Item (Join-Path $vst3Dst $vst3Name) -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item $vst3Src -Destination $vst3Dst -Recurse -Force
|
||||||
|
if (Test-Path $clapSrc) { Copy-Item $clapSrc -Destination $clapDst -Force }
|
||||||
|
Write-Host "Installed to $vst3Dst" -ForegroundColor Green
|
||||||
|
Write-Host "Add that folder to FL's plugin search paths if you haven't." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$vst3Dst = Join-Path $env:CommonProgramFiles "VST3"
|
||||||
|
$clapDst = Join-Path $env:CommonProgramFiles "CLAP"
|
||||||
|
Write-Host "Installing to $vst3Dst (requires admin - accept the UAC prompt)..." -ForegroundColor Cyan
|
||||||
|
$cmd = @"
|
||||||
|
New-Item -ItemType Directory -Force -Path '$clapDst' | Out-Null
|
||||||
|
Remove-Item -LiteralPath '$vst3Dst\$vst3Name' -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Copy-Item -LiteralPath '$vst3Src' -Destination '$vst3Dst' -Recurse -Force
|
||||||
|
if (Test-Path -LiteralPath '$clapSrc') { Copy-Item -LiteralPath '$clapSrc' -Destination '$clapDst' -Force }
|
||||||
|
"@
|
||||||
|
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
|
||||||
|
Start-Process powershell -Verb RunAs -Wait -ArgumentList '-NoProfile','-EncodedCommand',$enc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify each bundle was actually refreshed. A plugin currently loaded in a DAW keeps its
|
||||||
|
# binary locked, so the copy fails silently and leaves a STALE install — re-scanning then runs
|
||||||
|
# old code. Compare install vs build timestamps to catch exactly that.
|
||||||
|
function Test-Installed($label, $src, $dst) {
|
||||||
|
if (-not (Test-Path $src)) { return } # nothing was built for this format
|
||||||
|
if (-not (Test-Path $dst)) { throw "$label install verification failed: $dst not found" }
|
||||||
|
$srcT = (Get-Item $src).LastWriteTime
|
||||||
|
$dstT = (Get-Item $dst).LastWriteTime
|
||||||
|
if ($dstT -lt $srcT) {
|
||||||
|
Write-Warning "$label is STALE (installed $dstT < built $srcT). It's almost certainly loaded in your DAW (file locked). Close the plugin/DAW and re-run deploy."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "$label installed OK -> $dst" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Installed "VST3" (Join-Path $vst3Src "Contents\x86_64-win\$vst3Name") (Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name")
|
||||||
|
Test-Installed "CLAP" $clapSrc (Join-Path $clapDst $clapName)
|
||||||
|
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Generic second-order IIR biquad, Transposed Direct Form II.
|
||||||
|
//!
|
||||||
|
//! Coefficient formulas are the RBJ Audio EQ Cookbook
|
||||||
|
//! (<https://www.w3.org/TR/audio-eq-cookbook/>), prenormalised by `a0`. Scalar `f32`; we run
|
||||||
|
//! one filter per channel rather than SIMD to match the rest of the per-channel DSP.
|
||||||
|
|
||||||
|
use std::f32::consts;
|
||||||
|
|
||||||
|
/// Butterworth Q (= 1/√2). Two cascaded Butterworth sections make a 4th-order Linkwitz-Riley.
|
||||||
|
pub const NEUTRAL_Q: f32 = consts::FRAC_1_SQRT_2;
|
||||||
|
|
||||||
|
/// Prenormalised biquad coefficients `[b0, b1, b2, a1, a2]` (already divided by `a0`).
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct BiquadCoefficients {
|
||||||
|
b0: f32,
|
||||||
|
b1: f32,
|
||||||
|
b2: f32,
|
||||||
|
a1: f32,
|
||||||
|
a2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BiquadCoefficients {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::identity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BiquadCoefficients {
|
||||||
|
/// Passes the signal through unchanged.
|
||||||
|
pub fn identity() -> Self {
|
||||||
|
Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lowpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: ((1.0 - cos_w0) / 2.0) / a0,
|
||||||
|
b1: (1.0 - cos_w0) / a0,
|
||||||
|
b2: ((1.0 - cos_w0) / 2.0) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: ((1.0 + cos_w0) / 2.0) / a0,
|
||||||
|
b1: -(1.0 + cos_w0) / a0,
|
||||||
|
b2: ((1.0 + cos_w0) / 2.0) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
|
||||||
|
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
|
||||||
|
let a0 = 1.0 + alpha;
|
||||||
|
Self {
|
||||||
|
b0: (1.0 - alpha) / a0,
|
||||||
|
b1: (-2.0 * cos_w0) / a0,
|
||||||
|
b2: (1.0 + alpha) / a0,
|
||||||
|
a1: (-2.0 * cos_w0) / a0,
|
||||||
|
a2: (1.0 - alpha) / a0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared intermediate terms: `(cos ω0, α)`.
|
||||||
|
fn omega(sample_rate: f32, frequency: f32, q: f32) -> (f32, f32) {
|
||||||
|
let w0 = consts::TAU * (frequency / sample_rate);
|
||||||
|
(w0.cos(), w0.sin() / (2.0 * q))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A biquad filter holding its two state variables.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct Biquad {
|
||||||
|
coefficients: BiquadCoefficients,
|
||||||
|
s1: f32,
|
||||||
|
s2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biquad {
|
||||||
|
/// Replace the coefficients (keeps the state — fine for smooth coefficient changes).
|
||||||
|
pub fn set_coefficients(&mut self, coefficients: BiquadCoefficients) {
|
||||||
|
self.coefficients = coefficients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one sample (Transposed Direct Form II).
|
||||||
|
#[inline]
|
||||||
|
pub fn process(&mut self, x: f32) -> f32 {
|
||||||
|
let c = &self.coefficients;
|
||||||
|
let y = c.b0 * x + self.s1;
|
||||||
|
self.s1 = c.b1 * x - c.a1 * y + self.s2;
|
||||||
|
self.s2 = c.b2 * x - c.a2 * y;
|
||||||
|
y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the filter state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.s1 = 0.0;
|
||||||
|
self.s2 = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
fn magnitude_at(mut coeffs_filter: Biquad, freq: f32) -> f32 {
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
let n = 16_000usize;
|
||||||
|
let mut acc = 0.0f64;
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * freq * i as f32 / SR).sin();
|
||||||
|
let y = coeffs_filter.process(x);
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
acc += (y * y) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RMS of a unit sine is 1/√2; divide it out to get the magnitude response.
|
||||||
|
((acc / 8_000.0).sqrt() as f32) * std::f32::consts::SQRT_2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lowpass_passes_dc_blocks_highs() {
|
||||||
|
let lp = {
|
||||||
|
let mut b = Biquad::default();
|
||||||
|
b.set_coefficients(BiquadCoefficients::lowpass(SR, 1_000.0, NEUTRAL_Q));
|
||||||
|
b
|
||||||
|
};
|
||||||
|
assert!((magnitude_at(lp, 100.0) - 1.0).abs() < 0.05); // ~passband
|
||||||
|
assert!(magnitude_at(lp, 12_000.0) < 0.05); // ~stopband
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allpass_is_unity_magnitude() {
|
||||||
|
for &f in &[100.0, 1_000.0, 8_000.0] {
|
||||||
|
let mut b = Biquad::default();
|
||||||
|
b.set_coefficients(BiquadCoefficients::allpass(SR, 2_000.0, NEUTRAL_Q));
|
||||||
|
assert!((magnitude_at(b, f) - 1.0).abs() < 0.02, "allpass not flat at {f} Hz");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Single full-band feed-forward compressor with look-ahead.
|
||||||
|
//!
|
||||||
|
//! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor
|
||||||
|
//! Design — A Tutorial and Analysis" (JAES 2012):
|
||||||
|
//!
|
||||||
|
//! * a **log-domain gain computer** with a quadratic **soft knee**, and
|
||||||
|
//! * a **smooth, decoupled peak detector** for the attack/release ballistics
|
||||||
|
//! (their preferred topology — avoids the artefacts of naive branching smoothers).
|
||||||
|
//!
|
||||||
|
//! Detection is **stereo-linked** (the control signal is `max(|ch|)` across channels)
|
||||||
|
//! so a single gain is applied to every channel and the stereo image is preserved.
|
||||||
|
//!
|
||||||
|
//! Look-ahead is a per-channel delay line on the audio path: the output sample is the
|
||||||
|
//! input from `L` samples ago, while the gain is computed from the *current* input —
|
||||||
|
//! so the gain reduction leads the audio by `L` samples. `L` is the plugin's latency.
|
||||||
|
|
||||||
|
/// Maximum look-ahead. This is also the **fixed** latency the plugin reports: the audio is
|
||||||
|
/// always delayed by this much and the latency is reported once, so the look-ahead knob can be
|
||||||
|
/// adjusted during playback without ever renegotiating latency with the host (which crashes
|
||||||
|
/// some DAWs, FL included). The knob only moves where the detector taps within this window.
|
||||||
|
pub const MAX_LOOKAHEAD_MS: f32 = 5.0;
|
||||||
|
|
||||||
|
/// Most channels we ever process in one frame (our audio layouts are mono/stereo).
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// ~ -240 dBFS; keeps the level conversion away from zero without affecting audible levels.
|
||||||
|
const LEVEL_EPS: f32 = 1e-12;
|
||||||
|
|
||||||
|
/// `ln(x) * LOG10_GAIN_TO_DB == log10(x) * 20`, but a bare `ln()` runs faster than `log10()` on
|
||||||
|
/// most architectures (mirrors nih-plug's `util::gain_to_db_fast`). Used in the per-sample level
|
||||||
|
/// detector, called 4x/sample (once per band + 'All').
|
||||||
|
const LOG10_GAIN_TO_DB: f32 = std::f32::consts::LOG10_E * 20.0;
|
||||||
|
|
||||||
|
/// `exp(x * DB_TO_GAIN_EXP) == 10.0f32.powf(x / 20.0)`, but `exp()` runs faster than a general
|
||||||
|
/// `powf()` on most architectures (mirrors nih-plug's `util::db_to_gain_fast`) — `powf` has to
|
||||||
|
/// handle an arbitrary runtime base, while this bakes `ln(10)` in as a constant. Used for the
|
||||||
|
/// per-sample wet gain, called 4x/sample.
|
||||||
|
const DB_TO_GAIN_EXP: f32 = std::f32::consts::LN_10 / 20.0;
|
||||||
|
|
||||||
|
/// Silence-floor anchor for the below-threshold shaping: at/below this level the gain change is 0
|
||||||
|
/// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
|
||||||
|
/// the editor gain-curve's display floor.
|
||||||
|
const LOW_ANCHOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
|
||||||
|
/// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
|
||||||
|
/// both ends (silence floor and the knee), so it never moves those anchors.
|
||||||
|
const LOW_BULGE_MAX_DB: f32 = 12.0;
|
||||||
|
|
||||||
|
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
||||||
|
/// promoted to a parameter later.
|
||||||
|
/// Default RMS averaging time constant for tests that do not care which band they model.
|
||||||
|
/// Real instances pass a per-band value - see the `RMS_WINDOW_MS` table in `lib.rs`.
|
||||||
|
#[cfg(test)]
|
||||||
|
const RMS_WINDOW_DEFAULT_MS: f32 = 20.0;
|
||||||
|
|
||||||
|
// Denormals (the exponentially-decaying envelope/RMS tails and the IIR filter state) are handled
|
||||||
|
// by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its
|
||||||
|
// `ScopedFtz` guard (x86 MXCSR / AArch64 FPCR). So no per-value flushing is needed here.
|
||||||
|
|
||||||
|
/// Per-block compressor settings. Cheap to copy; rebuilt each process block from params.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CompressorSettings {
|
||||||
|
pub threshold_db: f32,
|
||||||
|
pub ratio: f32,
|
||||||
|
pub knee_db: f32,
|
||||||
|
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
||||||
|
/// Reshapes the level the compressor sees (serial), anchored at the floor.
|
||||||
|
pub low_slope: f32,
|
||||||
|
/// Low shaper curvature, 0..1 (0 = straight line, 1 = max bend toward bounded saturation).
|
||||||
|
pub low_curve: f32,
|
||||||
|
/// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]).
|
||||||
|
pub attack_coef: f32,
|
||||||
|
/// One-pole coefficient for the release ramp.
|
||||||
|
pub release_coef: f32,
|
||||||
|
pub makeup_db: f32,
|
||||||
|
/// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`.
|
||||||
|
/// This does NOT change the reported latency — the audio delay is always `fixed_delay`.
|
||||||
|
pub lookahead_samples: usize,
|
||||||
|
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
||||||
|
pub use_rms: bool,
|
||||||
|
/// Dry/wet blend, 0..=1. 1 = fully compressed (incl. makeup), 0 = dry passthrough (bypass).
|
||||||
|
/// Parallel: dry and wet share the same delayed input, so the mix is phase-aligned.
|
||||||
|
pub mix: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Compressor {
|
||||||
|
sample_rate: f32,
|
||||||
|
|
||||||
|
/// Per-channel circular delay line, each `capacity` samples long.
|
||||||
|
delay: Vec<Vec<f32>>,
|
||||||
|
capacity: usize,
|
||||||
|
write_pos: usize,
|
||||||
|
/// Constant audio delay applied to every sample == the reported plugin latency.
|
||||||
|
fixed_delay: usize,
|
||||||
|
|
||||||
|
/// RMS detector state: running mean of the squared (linked) level, plus its coefficient.
|
||||||
|
mean_sq: f32,
|
||||||
|
rms_coef: f32,
|
||||||
|
|
||||||
|
/// Smooth decoupled peak-detector state, in dB of attenuation (signed: usually >= 0, but can go
|
||||||
|
/// negative = boost when `low_slope < 1`). The `max()` recurrence makes cut fast / boost slow.
|
||||||
|
y1: f32, // release branch (peak-with-decay)
|
||||||
|
yl: f32, // attack-smoothed output
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Compressor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
delay: Vec::new(),
|
||||||
|
capacity: 0,
|
||||||
|
write_pos: 0,
|
||||||
|
fixed_delay: 0,
|
||||||
|
mean_sq: 0.0,
|
||||||
|
rms_coef: 0.0,
|
||||||
|
y1: 0.0,
|
||||||
|
yl: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compressor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`,
|
||||||
|
/// where allocation is allowed — never from `process()`.
|
||||||
|
/// `rms_window_ms` is the time constant of the RMS detector's one-pole average. It is a
|
||||||
|
/// design constant per band rather than a user param: the squared signal carries a component
|
||||||
|
/// at twice the fundamental, so a window that does not span a cycle leaves the detector
|
||||||
|
/// rippling at 2f and the gain intermodulating with the bass. The needed window is therefore
|
||||||
|
/// set by how low the band goes, which the band - not the compressor - knows.
|
||||||
|
pub fn prepare(
|
||||||
|
&mut self,
|
||||||
|
sample_rate: f32,
|
||||||
|
num_channels: usize,
|
||||||
|
max_lookahead_ms: f32,
|
||||||
|
rms_window_ms: f32,
|
||||||
|
) {
|
||||||
|
self.sample_rate = sample_rate;
|
||||||
|
self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize;
|
||||||
|
// +1 so the oldest (output) sample and the newest (write) sample never alias.
|
||||||
|
self.capacity = self.fixed_delay + 1;
|
||||||
|
self.rms_coef = Self::time_to_coef(rms_window_ms, sample_rate);
|
||||||
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all state. Real-time safe (no allocation).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for ch in self.delay.iter_mut() {
|
||||||
|
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||||
|
}
|
||||||
|
self.write_pos = 0;
|
||||||
|
self.mean_sq = 0.0;
|
||||||
|
self.y1 = 0.0;
|
||||||
|
self.yl = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an attack/release time in milliseconds to a one-pole smoothing coefficient.
|
||||||
|
///
|
||||||
|
/// Convention: after `time_ms`, a step response reaches ~63% (1 − 1/e) of its target.
|
||||||
|
/// 0 ms (or less) yields coefficient 0 = instantaneous.
|
||||||
|
pub fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
|
||||||
|
if time_ms <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(-1.0 / (time_ms * 0.001 * sample_rate)).exp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure compressor transfer (threshold / ratio / quadratic soft knee). Returns gain reduction
|
||||||
|
/// in dB (<= 0) for an input `level_db`.
|
||||||
|
fn comp_gain_db(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
|
||||||
|
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
|
||||||
|
let over = level_db - threshold_db;
|
||||||
|
if knee_db > 0.0 && 2.0 * over.abs() <= knee_db {
|
||||||
|
let x = over + knee_db * 0.5; // 0..knee
|
||||||
|
slope * x * x / (2.0 * knee_db)
|
||||||
|
} else if over > 0.0 {
|
||||||
|
slope * over
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
|
||||||
|
/// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
|
||||||
|
/// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
|
||||||
|
/// bulges up (boost the quiet middle), negative down (suppress). Reshapes the level the
|
||||||
|
/// compressor then sees.
|
||||||
|
fn low_gain_db(level_db: f32, threshold_db: f32, low_slope: f32, low_curve: f32) -> f32 {
|
||||||
|
let d = level_db - LOW_ANCHOR_DB;
|
||||||
|
if d <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
|
||||||
|
let t = (d / span).min(1.0); // normalized position, clamped at the knee
|
||||||
|
// Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
|
||||||
|
let slope_line = (low_slope - 1.0) * span * t;
|
||||||
|
// Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
|
||||||
|
let bulge = low_curve * LOW_BULGE_MAX_DB * 4.0 * t * (1.0 - t);
|
||||||
|
slope_line + bulge
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
|
||||||
|
/// threshold sees the shaped level. Returns total gain in dB (signed: negative = cut, positive
|
||||||
|
/// = boost). `gain = low + comp(level + low)`. Shared with the editor's gain-curve display —
|
||||||
|
/// single source of truth.
|
||||||
|
pub fn gain_computer(
|
||||||
|
level_db: f32,
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
knee_db: f32,
|
||||||
|
low_slope: f32,
|
||||||
|
low_curve: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let low = Self::low_gain_db(level_db, threshold_db, low_slope, low_curve);
|
||||||
|
low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plugin's fixed reported latency in samples (the constant audio delay).
|
||||||
|
pub fn latency(&self) -> u32 {
|
||||||
|
self.fixed_delay as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering.
|
||||||
|
/// This is the smoothed detector output `yl`, so it tracks the visible needle, not the
|
||||||
|
/// instantaneous static curve.
|
||||||
|
pub fn gain_reduction_db(&self) -> f32 {
|
||||||
|
self.yl
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
|
||||||
|
///
|
||||||
|
/// `input` and `output` are short stack slices (one value per channel), so this
|
||||||
|
/// performs no allocation. Detection is linked across the provided channels.
|
||||||
|
///
|
||||||
|
/// The audio is always delayed by `fixed_delay`; `set.lookahead_samples` (0..=fixed_delay)
|
||||||
|
/// only chooses how far *ahead* of the output the detector taps, so changing it never
|
||||||
|
/// alters latency.
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut [f32], set: &CompressorSettings) {
|
||||||
|
debug_assert_eq!(input.len(), output.len());
|
||||||
|
let n = input.len().min(self.delay.len());
|
||||||
|
let l = set.lookahead_samples.min(self.fixed_delay);
|
||||||
|
|
||||||
|
// 1) Write the current input into the delay lines.
|
||||||
|
for ch in 0..n {
|
||||||
|
self.delay[ch][self.write_pos] = input[ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads
|
||||||
|
// `l` samples newer than the output, i.e. `l` samples into the output's future.
|
||||||
|
// `wrap_add` instead of `%`: capacity is rarely a power of two, so a plain modulo forces
|
||||||
|
// an integer division here; a conditional subtract is cheaper and behaves identically
|
||||||
|
// given the add is always <= capacity (true for every offset computed in this function).
|
||||||
|
let out_pos = wrap_add(self.write_pos, self.capacity - self.fixed_delay, self.capacity);
|
||||||
|
let det_pos =
|
||||||
|
wrap_add(self.write_pos, self.capacity - (self.fixed_delay - l), self.capacity);
|
||||||
|
|
||||||
|
// 3) Linked peak detector at the look-ahead tap.
|
||||||
|
let mut peak = 0.0f32;
|
||||||
|
for ch in 0..n {
|
||||||
|
peak = peak.max(self.delay[ch][det_pos].abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
|
||||||
|
// reflects the wet gain reduction regardless of the dry/wet blend.
|
||||||
|
// RMS = running mean of the linked squared level over a fixed window. Updated whenever
|
||||||
|
// active (regardless of mode) so switching peak<->RMS is seamless.
|
||||||
|
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
||||||
|
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
||||||
|
let level_db = (detector + LEVEL_EPS).ln() * LOG10_GAIN_TO_DB;
|
||||||
|
// Desired attenuation in dB, as a positive quantity.
|
||||||
|
let target = -Self::gain_computer(
|
||||||
|
level_db,
|
||||||
|
set.threshold_db,
|
||||||
|
set.ratio,
|
||||||
|
set.knee_db,
|
||||||
|
set.low_slope,
|
||||||
|
set.low_curve,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
||||||
|
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
||||||
|
// yl = attack-smoothed y1
|
||||||
|
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
|
||||||
|
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
|
||||||
|
|
||||||
|
let wet_gain = ((set.makeup_db - self.yl) * DB_TO_GAIN_EXP).exp();
|
||||||
|
|
||||||
|
// 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the
|
||||||
|
// blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
|
||||||
|
let mix = set.mix.clamp(0.0, 1.0);
|
||||||
|
let blend = (1.0 - mix) + mix * wet_gain;
|
||||||
|
for ch in 0..n {
|
||||||
|
output[ch] = self.delay[ch][out_pos] * blend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Advance the write head.
|
||||||
|
self.write_pos = wrap_add(self.write_pos, 1, self.capacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `pos + add` into `[0, capacity)`, assuming `add <= capacity` — a conditional subtract in
|
||||||
|
/// place of `%`, since `capacity` is rarely a power of two (so `%` costs a real integer division).
|
||||||
|
#[inline]
|
||||||
|
fn wrap_add(pos: usize, add: usize, capacity: usize) -> usize {
|
||||||
|
let sum = pos + add;
|
||||||
|
if sum >= capacity {
|
||||||
|
sum - capacity
|
||||||
|
} else {
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
fn assert_close(a: f32, b: f32, tol: f32) {
|
||||||
|
assert!((a - b).abs() <= tol, "expected {a} ≈ {b} (tol {tol})");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(threshold_db: f32, ratio: f32, knee_db: f32) -> CompressorSettings {
|
||||||
|
CompressorSettings {
|
||||||
|
threshold_db,
|
||||||
|
ratio,
|
||||||
|
knee_db,
|
||||||
|
attack_coef: Compressor::time_to_coef(1.0, SR),
|
||||||
|
release_coef: Compressor::time_to_coef(1.0, SR),
|
||||||
|
makeup_db: 0.0,
|
||||||
|
lookahead_samples: 0,
|
||||||
|
use_rms: false,
|
||||||
|
mix: 1.0,
|
||||||
|
low_slope: 1.0,
|
||||||
|
low_curve: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn below_threshold_is_untouched() {
|
||||||
|
// -30 dB input, -20 dB threshold -> no reduction.
|
||||||
|
assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0, 1.0, 0.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_shaper_serial_slope_and_bipolar_bulge() {
|
||||||
|
let thr = -18.0;
|
||||||
|
// Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
|
||||||
|
// comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
|
||||||
|
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
|
||||||
|
// Cut (slope 0.5) -> -15 dB; shaped to -45, still below threshold -> net -15.
|
||||||
|
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
|
||||||
|
|
||||||
|
// Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
|
||||||
|
let mid = -39.0; // middle of [-60, -18]
|
||||||
|
let flat = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 0.0);
|
||||||
|
let up = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 1.0);
|
||||||
|
let down = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, -1.0);
|
||||||
|
assert!(up > flat && flat > down, "bipolar bulge expected: {down} < {flat} < {up}");
|
||||||
|
// Endpoints are unaffected by curvature (silence anchored).
|
||||||
|
assert_close(Compressor::gain_computer(-60.0, thr, 4.0, 0.0, 1.0, 1.0), 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn above_knee_follows_ratio() {
|
||||||
|
// 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction.
|
||||||
|
let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0, 1.0, 0.0);
|
||||||
|
assert_close(r, -7.5, 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn knee_is_continuous_with_linear_region() {
|
||||||
|
// At the upper knee edge the soft-knee and linear formulas must agree.
|
||||||
|
let (t, ratio, knee) = (0.0, 4.0, 6.0);
|
||||||
|
let edge = t + knee / 2.0;
|
||||||
|
let knee_val = Compressor::gain_computer(edge, t, ratio, knee, 1.0, 0.0);
|
||||||
|
let linear_val = (1.0 / ratio - 1.0) * (edge - t);
|
||||||
|
assert_close(knee_val, linear_val, 1e-4);
|
||||||
|
// At the lower edge there is still no reduction.
|
||||||
|
assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee, 1.0, 0.0), 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steady_state_matches_static_curve() {
|
||||||
|
// Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp:
|
||||||
|
// reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20).
|
||||||
|
let mut comp = Compressor::new();
|
||||||
|
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS);
|
||||||
|
let set = settings(-18.0, 2.0, 0.0);
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
for _ in 0..SR as usize {
|
||||||
|
comp.process(&[0.5], &mut out, &set);
|
||||||
|
}
|
||||||
|
let level_db = 20.0 * 0.5f32.log10();
|
||||||
|
let reduction = (1.0 / 2.0 - 1.0) * (level_db - (-18.0));
|
||||||
|
let expected = 0.5 * 10.0f32.powf(reduction / 20.0);
|
||||||
|
assert_close(out[0], expected, 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rms_compresses_a_sine_less_than_peak() {
|
||||||
|
// For a sine, RMS level is ~3 dB below the peak (A/√2), so RMS detection sees a lower
|
||||||
|
// level and applies less gain reduction -> louder output than peak detection.
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
fn output_rms(use_rms: bool) -> f32 {
|
||||||
|
let mut comp = Compressor::new();
|
||||||
|
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS);
|
||||||
|
let mut set = settings(-30.0, 4.0, 0.0);
|
||||||
|
set.use_rms = use_rms;
|
||||||
|
set.attack_coef = Compressor::time_to_coef(1.0, SR);
|
||||||
|
set.release_coef = Compressor::time_to_coef(50.0, SR);
|
||||||
|
|
||||||
|
let (amp, freq) = (0.5f32, 2000.0f32);
|
||||||
|
let total = SR as usize;
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
let (mut acc, mut cnt) = (0.0f64, 0u32);
|
||||||
|
for i in 0..total {
|
||||||
|
let x = amp * (2.0 * PI * freq * i as f32 / SR).sin();
|
||||||
|
comp.process(&[x], &mut out, &set);
|
||||||
|
if i >= total - 4800 {
|
||||||
|
// measure RMS over the last 0.1 s, after settling
|
||||||
|
acc += (out[0] * out[0]) as f64;
|
||||||
|
cnt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(acc / cnt as f64).sqrt() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output_rms(true) > output_rms(false),
|
||||||
|
"RMS detection should compress a sine less than peak"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latency_is_constant_regardless_of_lookahead() {
|
||||||
|
// Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead
|
||||||
|
// knob must NOT change that (this is what keeps live adjustment from renegotiating
|
||||||
|
// latency and crashing the host).
|
||||||
|
let mut comp = Compressor::new();
|
||||||
|
comp.prepare(SR, 1, 0.5, RMS_WINDOW_DEFAULT_MS); // small max so the fixed delay is quick to test
|
||||||
|
let d = comp.latency() as usize;
|
||||||
|
assert!(d > 0);
|
||||||
|
|
||||||
|
for &l in &[0usize, d / 2, d] {
|
||||||
|
comp.reset();
|
||||||
|
let mut set = settings(0.0, 1.0, 0.0);
|
||||||
|
set.mix = 0.0; // dry passthrough -> isolate the delay behaviour
|
||||||
|
set.lookahead_samples = l;
|
||||||
|
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
for n in 0..(d + 5) {
|
||||||
|
let x = if n == 0 { 1.0 } else { 0.0 };
|
||||||
|
comp.process(&[x], &mut out, &set);
|
||||||
|
if n == d {
|
||||||
|
assert_close(out[0], 1.0, 1e-6); // impulse always emerges after `d`, any L
|
||||||
|
} else {
|
||||||
|
assert_close(out[0], 0.0, 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure peak-to-peak gain-reduction ripple for a steady sine, with the ballistics
|
||||||
|
/// disabled so the gain follows the detector directly. Squaring puts a component at 2f in
|
||||||
|
/// the detector, so a window that does not span a cycle leaves the gain wobbling at 2f -
|
||||||
|
/// which is intermodulation, heard as growl on bass.
|
||||||
|
fn gr_ripple_db(rms_window_ms: f32, freq: f32) -> f32 {
|
||||||
|
let mut c = Compressor::new();
|
||||||
|
c.prepare(SR, 1, MAX_LOOKAHEAD_MS, rms_window_ms);
|
||||||
|
let set = CompressorSettings {
|
||||||
|
threshold_db: -40.0, ratio: 8.0, knee_db: 0.0,
|
||||||
|
// coef 0 = no attack/release smoothing, so yl == the detector's target.
|
||||||
|
attack_coef: 0.0, release_coef: 0.0,
|
||||||
|
makeup_db: 0.0, mix: 1.0, lookahead_samples: 0,
|
||||||
|
use_rms: true, low_slope: 1.0, low_curve: 0.0,
|
||||||
|
};
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
// Settle well past the longest window under test.
|
||||||
|
let settle = (SR * 0.5) as usize;
|
||||||
|
for i in 0..settle {
|
||||||
|
let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin();
|
||||||
|
c.process(&[x], &mut out, &set);
|
||||||
|
}
|
||||||
|
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||||
|
for i in settle..settle + (SR / freq * 4.0) as usize {
|
||||||
|
let x = (i as f32 / SR * freq * std::f32::consts::TAU).sin();
|
||||||
|
c.process(&[x], &mut out, &set);
|
||||||
|
let gr = c.gain_reduction_db();
|
||||||
|
lo = lo.min(gr);
|
||||||
|
hi = hi.max(gr);
|
||||||
|
}
|
||||||
|
hi - lo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-band RMS windows in `lib.rs` exist to keep this ripple down; without a test the
|
||||||
|
/// next person to "tune" them has nothing telling them what they are for.
|
||||||
|
#[test]
|
||||||
|
fn rms_window_suppresses_low_frequency_ripple() {
|
||||||
|
// The LOW band's 50 ms window against the lowest content it can be asked to carry.
|
||||||
|
let low_band = gr_ripple_db(50.0, 30.0);
|
||||||
|
assert!(low_band < 1.0, "50 ms window rippled {low_band:.2} dB at 30 Hz, expected < 1 dB");
|
||||||
|
|
||||||
|
// A short window on the same signal must be visibly worse - this is what the per-band
|
||||||
|
// table is avoiding, and it keeps the assertion above from passing for the wrong reason.
|
||||||
|
let short = gr_ripple_db(5.0, 30.0);
|
||||||
|
assert!(
|
||||||
|
short > 3.0 * low_band,
|
||||||
|
"5 ms ({short:.2} dB) should ripple far more than 50 ms ({low_band:.2} dB)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ripple must fall monotonically as the window grows.
|
||||||
|
let mut prev = f32::MAX;
|
||||||
|
for w in [5.0, 10.0, 20.0, 35.0, 50.0] {
|
||||||
|
let r = gr_ripple_db(w, 30.0);
|
||||||
|
assert!(r < prev, "ripple did not fall from the previous window at {w} ms ({r:.2} dB)");
|
||||||
|
prev = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HIGH band's short window is fine because nothing up there ripples.
|
||||||
|
let high_band = gr_ripple_db(10.0, 5_000.0);
|
||||||
|
assert!(high_band < 0.1, "10 ms window rippled {high_band:.2} dB at 5 kHz");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank.
|
||||||
|
//!
|
||||||
|
//! Each crossover splits into a low-passed band output and a high-passed remainder that feeds
|
||||||
|
//! the next crossover. Because higher bands pass through more filters, lower bands are phase-
|
||||||
|
//! compensated with an all-pass at every *later* crossover frequency so the three bands sum back
|
||||||
|
//! to flat **magnitude** (the sum is an all-pass of the input — phase-shifted, not bit-identical,
|
||||||
|
//! which is inherent to IIR Linkwitz-Riley). Approach mirrors NIH-plug's `crossover` plugin.
|
||||||
|
//!
|
||||||
|
//! For 3 bands there are two crossovers (low/mid at `f_lo`, mid/high at `f_hi`); only the low
|
||||||
|
//! band needs compensation (one all-pass at `f_hi`).
|
||||||
|
|
||||||
|
use super::biquad::{Biquad, BiquadCoefficients, NEUTRAL_Q};
|
||||||
|
|
||||||
|
/// Mono/stereo only, matching the plugin's audio layouts.
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// One channel's worth of filter state for the 3-band split.
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct BandSplitter {
|
||||||
|
lp_lo: [Biquad; 2], // LR4 low-pass at f_lo (two cascaded Butterworth)
|
||||||
|
hp_lo: [Biquad; 2], // LR4 high-pass at f_lo
|
||||||
|
lp_hi: [Biquad; 2], // LR4 low-pass at f_hi
|
||||||
|
hp_hi: [Biquad; 2], // LR4 high-pass at f_hi
|
||||||
|
ap_low: Biquad, // all-pass at f_hi, phase-compensates the low band
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BandSplitter {
|
||||||
|
/// Split one sample into `[low, mid, high]`.
|
||||||
|
fn split(&mut self, x: f32) -> [f32; 3] {
|
||||||
|
// Crossover at f_lo: low-passed band + high-passed remainder.
|
||||||
|
let mut lp = x;
|
||||||
|
for f in &mut self.lp_lo {
|
||||||
|
lp = f.process(lp);
|
||||||
|
}
|
||||||
|
let mut hp = x;
|
||||||
|
for f in &mut self.hp_lo {
|
||||||
|
hp = f.process(hp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low band is phase-compensated for the f_hi crossover the upper bands pass through.
|
||||||
|
let low = self.ap_low.process(lp);
|
||||||
|
|
||||||
|
// Crossover at f_hi splits the remainder into mid + high.
|
||||||
|
let mut mid = hp;
|
||||||
|
for f in &mut self.lp_hi {
|
||||||
|
mid = f.process(mid);
|
||||||
|
}
|
||||||
|
let mut high = hp;
|
||||||
|
for f in &mut self.hp_hi {
|
||||||
|
high = f.process(high);
|
||||||
|
}
|
||||||
|
|
||||||
|
[low, mid, high]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_coefficients(
|
||||||
|
&mut self,
|
||||||
|
lp_lo: BiquadCoefficients,
|
||||||
|
hp_lo: BiquadCoefficients,
|
||||||
|
lp_hi: BiquadCoefficients,
|
||||||
|
hp_hi: BiquadCoefficients,
|
||||||
|
ap_low: BiquadCoefficients,
|
||||||
|
) {
|
||||||
|
for f in &mut self.lp_lo {
|
||||||
|
f.set_coefficients(lp_lo);
|
||||||
|
}
|
||||||
|
for f in &mut self.hp_lo {
|
||||||
|
f.set_coefficients(hp_lo);
|
||||||
|
}
|
||||||
|
for f in &mut self.lp_hi {
|
||||||
|
f.set_coefficients(lp_hi);
|
||||||
|
}
|
||||||
|
for f in &mut self.hp_hi {
|
||||||
|
f.set_coefficients(hp_hi);
|
||||||
|
}
|
||||||
|
self.ap_low.set_coefficients(ap_low);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
for f in self
|
||||||
|
.lp_lo
|
||||||
|
.iter_mut()
|
||||||
|
.chain(&mut self.hp_lo)
|
||||||
|
.chain(&mut self.lp_hi)
|
||||||
|
.chain(&mut self.hp_hi)
|
||||||
|
{
|
||||||
|
f.reset();
|
||||||
|
}
|
||||||
|
self.ap_low.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Crossover {
|
||||||
|
channels: usize,
|
||||||
|
splitters: [BandSplitter; MAX_CHANNELS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Crossover {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: 2,
|
||||||
|
splitters: [BandSplitter::default(); MAX_CHANNELS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Crossover {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the active channel count and clear state. Call from `initialize()`.
|
||||||
|
pub fn prepare(&mut self, channels: usize) {
|
||||||
|
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute and apply crossover coefficients. Cheap enough to call once per block.
|
||||||
|
/// Frequencies are clamped to a valid range and forced monotonic (`f_lo <= f_hi`).
|
||||||
|
pub fn update(&mut self, sample_rate: f32, low_hz: f32, high_hz: f32) {
|
||||||
|
let max_hz = sample_rate * 0.49;
|
||||||
|
let f_lo = low_hz.clamp(20.0, max_hz);
|
||||||
|
let f_hi = high_hz.clamp(f_lo, max_hz);
|
||||||
|
|
||||||
|
let lp_lo = BiquadCoefficients::lowpass(sample_rate, f_lo, NEUTRAL_Q);
|
||||||
|
let hp_lo = BiquadCoefficients::highpass(sample_rate, f_lo, NEUTRAL_Q);
|
||||||
|
let lp_hi = BiquadCoefficients::lowpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
let hp_hi = BiquadCoefficients::highpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
let ap_low = BiquadCoefficients::allpass(sample_rate, f_hi, NEUTRAL_Q);
|
||||||
|
|
||||||
|
for s in &mut self.splitters {
|
||||||
|
s.set_coefficients(lp_lo, hp_lo, lp_hi, hp_hi, ap_low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for s in &mut self.splitters {
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split one sample of `channel` into `[low, mid, high]`.
|
||||||
|
#[inline]
|
||||||
|
pub fn split(&mut self, channel: usize, x: f32) -> [f32; 3] {
|
||||||
|
self.splitters[channel].split(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bands_sum_to_flat_magnitude() {
|
||||||
|
// LR4 bands sum to an all-pass: the magnitude is flat at every frequency (including the
|
||||||
|
// crossovers), even though the time-domain signal is phase-shifted (so it is NOT a
|
||||||
|
// bit-exact null — that only holds for linear-phase FIR crossovers).
|
||||||
|
let mut xo = Crossover::new();
|
||||||
|
xo.prepare(1);
|
||||||
|
xo.update(SR, 200.0, 2_500.0);
|
||||||
|
|
||||||
|
for &f in &[50.0, 200.0, 1_000.0, 2_500.0, 9_000.0] {
|
||||||
|
xo.reset();
|
||||||
|
let n = 24_000usize;
|
||||||
|
let (mut in_acc, mut out_acc) = (0.0f64, 0.0f64);
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * f * i as f32 / SR).sin();
|
||||||
|
let [lo, mid, hi] = xo.split(0, x);
|
||||||
|
let y = lo + mid + hi;
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
in_acc += (x * x) as f64;
|
||||||
|
out_acc += (y * y) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ratio = (out_acc / in_acc).sqrt() as f32;
|
||||||
|
assert!(
|
||||||
|
(ratio - 1.0).abs() < 0.06,
|
||||||
|
"reconstruction not flat at {f} Hz: {ratio}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bands_are_actually_split() {
|
||||||
|
// Sanity: the low band should keep lows and reject highs; the high band vice versa.
|
||||||
|
fn band_energy(band: usize, freq: f32) -> f64 {
|
||||||
|
let mut xo = Crossover::new();
|
||||||
|
xo.prepare(1);
|
||||||
|
xo.update(SR, 200.0, 2_500.0);
|
||||||
|
let n = 24_000usize;
|
||||||
|
let mut acc = 0.0f64;
|
||||||
|
for i in 0..n {
|
||||||
|
let x = (TAU * freq * i as f32 / SR).sin();
|
||||||
|
let bands = xo.split(0, x);
|
||||||
|
if i >= n - 8_000 {
|
||||||
|
acc += (bands[band] * bands[band]) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
assert!(band_energy(0, 50.0) > band_energy(0, 9_000.0) * 100.0); // low band: lows >> highs
|
||||||
|
assert!(band_energy(2, 9_000.0) > band_energy(2, 50.0) * 100.0); // high band: highs >> lows
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Look-ahead brickwall peak limiter (base-rate).
|
||||||
|
//!
|
||||||
|
//! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp
|
||||||
|
//! down *before* a peak reaches the output (click-free), driven by a **sliding maximum** over the
|
||||||
|
//! look-ahead window so the reduction is fully in place in time. A final clamp at the ceiling is
|
||||||
|
//! the hard guarantee against any residual from smoothing lag or float error.
|
||||||
|
//!
|
||||||
|
//! Detection is stereo-linked (one gain for all channels). This stage limits **sample** peaks at
|
||||||
|
//! the base rate; true-peak (inter-sample) limiting via oversampling is a later addition.
|
||||||
|
|
||||||
|
use super::oversampler::{self, Oversampler};
|
||||||
|
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
/// Fixed look-ahead — also this stage's constant latency contribution.
|
||||||
|
const LOOKAHEAD_MS: f32 = 1.5;
|
||||||
|
/// Near-instant attack; the look-ahead gives it time to act before the peak arrives.
|
||||||
|
const ATTACK_MS: f32 = 0.05;
|
||||||
|
/// The 4× true-peak detector can still under-read by a few tenths of a dB near Nyquist, so we
|
||||||
|
/// target a hair below the ceiling to keep the actual inter-sample peak under it.
|
||||||
|
const TRUE_PEAK_MARGIN_DB: f32 = 0.3;
|
||||||
|
|
||||||
|
fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
|
||||||
|
if time_ms <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(-1.0 / (time_ms * 0.001 * sample_rate)).exp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap `pos + add` into `[0, capacity)`, assuming `add <= capacity` — a conditional subtract in
|
||||||
|
/// place of `%`, since `capacity` is rarely a power of two (so `%` costs a real integer division).
|
||||||
|
#[inline]
|
||||||
|
fn wrap_add(pos: usize, add: usize, capacity: usize) -> usize {
|
||||||
|
let sum = pos + add;
|
||||||
|
if sum >= capacity {
|
||||||
|
sum - capacity
|
||||||
|
} else {
|
||||||
|
sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O(1)-amortized fixed-window sliding maximum (the classic monotonic-deque algorithm), in place
|
||||||
|
/// of rescanning the whole window every sample. Values are pushed one at a time; each push returns
|
||||||
|
/// the max over the last `window` pushes (inclusive of the one just pushed).
|
||||||
|
///
|
||||||
|
/// Backed by two preallocated ring buffers sized to `window` (never grows past that: entries are
|
||||||
|
/// evicted once their age reaches `window`, and a strictly-decreasing run of un-evicted entries
|
||||||
|
/// still ages out within `window` pushes) — so `push` never allocates, keeping it RT-safe.
|
||||||
|
struct SlidingMax {
|
||||||
|
val: Vec<f32>,
|
||||||
|
seq: Vec<u64>,
|
||||||
|
cap: usize,
|
||||||
|
head: usize,
|
||||||
|
len: usize,
|
||||||
|
next_seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlidingMax {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { val: Vec::new(), seq: Vec::new(), cap: 0, head: 0, len: 0, next_seq: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(&mut self, window: usize) {
|
||||||
|
self.cap = window.max(1);
|
||||||
|
self.val = vec![0.0; self.cap];
|
||||||
|
self.seq = vec![0; self.cap];
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.head = 0;
|
||||||
|
self.len = 0;
|
||||||
|
self.next_seq = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a new value; return the maximum over the last `cap` pushes (including this one).
|
||||||
|
fn push(&mut self, value: f32) -> f32 {
|
||||||
|
let t = self.next_seq;
|
||||||
|
|
||||||
|
// Values can never be the max while the new one is still in the window, so drop them.
|
||||||
|
while self.len > 0 {
|
||||||
|
let back = (self.head + self.len - 1) % self.cap;
|
||||||
|
if self.val[back] <= value {
|
||||||
|
self.len -= 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let back = (self.head + self.len) % self.cap;
|
||||||
|
self.val[back] = value;
|
||||||
|
self.seq[back] = t;
|
||||||
|
self.len += 1;
|
||||||
|
|
||||||
|
// Evict entries that have aged out of the window.
|
||||||
|
while self.seq[self.head] + self.cap as u64 <= t {
|
||||||
|
self.head = (self.head + 1) % self.cap;
|
||||||
|
self.len -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.next_seq = t + 1;
|
||||||
|
self.val[self.head]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Limiter {
|
||||||
|
/// Per-channel audio delay ring.
|
||||||
|
delay: Vec<Vec<f32>>,
|
||||||
|
/// O(1)-amortized sliding-window maximum over the linked `|x|` history (replaces an O(window)
|
||||||
|
/// full-rescan every sample).
|
||||||
|
sliding_max: SlidingMax,
|
||||||
|
capacity: usize,
|
||||||
|
write_pos: usize,
|
||||||
|
fixed_delay: usize,
|
||||||
|
/// Current smoothed gain (<= 1).
|
||||||
|
gain: f32,
|
||||||
|
attack_coef: f32,
|
||||||
|
/// `10^(-TRUE_PEAK_MARGIN_DB/20)`, precomputed once (the margin is a fixed constant, so
|
||||||
|
/// there's no reason to recompute this `powf` on every sample).
|
||||||
|
margin_gain: f32,
|
||||||
|
/// 4× interpolator for true-peak (inter-sample) detection.
|
||||||
|
oversampler: Oversampler,
|
||||||
|
/// Sliding max of the sample peak over the interpolator's FIR support, which bounds what
|
||||||
|
/// its next evaluation can possibly return. Lets `process` skip the evaluation entirely
|
||||||
|
/// when that bound is already under the ceiling.
|
||||||
|
support_max: SlidingMax,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
delay: Vec::new(),
|
||||||
|
sliding_max: SlidingMax::new(),
|
||||||
|
capacity: 0,
|
||||||
|
write_pos: 0,
|
||||||
|
fixed_delay: 0,
|
||||||
|
gain: 1.0,
|
||||||
|
attack_coef: 0.0,
|
||||||
|
margin_gain: 1.0,
|
||||||
|
oversampler: Oversampler::new(),
|
||||||
|
support_max: SlidingMax::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Limiter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate buffers. Call from `initialize()` (allocation allowed).
|
||||||
|
pub fn prepare(&mut self, sample_rate: f32, num_channels: usize) {
|
||||||
|
self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize;
|
||||||
|
self.capacity = self.fixed_delay + 1;
|
||||||
|
self.attack_coef = time_to_coef(ATTACK_MS, sample_rate);
|
||||||
|
self.margin_gain = 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0);
|
||||||
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||||
|
self.sliding_max.prepare(self.capacity);
|
||||||
|
self.support_max.prepare(oversampler::SUPPORT_LEN);
|
||||||
|
self.oversampler.prepare(channels);
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for ch in &mut self.delay {
|
||||||
|
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||||
|
}
|
||||||
|
self.sliding_max.reset();
|
||||||
|
self.support_max.reset();
|
||||||
|
self.oversampler.reset();
|
||||||
|
self.write_pos = 0;
|
||||||
|
self.gain = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant reported latency (the fixed look-ahead delay).
|
||||||
|
pub fn latency(&self) -> u32 {
|
||||||
|
self.fixed_delay as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a
|
||||||
|
/// positive dB amount for the ceiling lamp / metering.
|
||||||
|
pub fn gain_reduction_db(&self) -> f32 {
|
||||||
|
-20.0 * self.gain.max(1e-9).log10()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit one frame in place: `input[ch]` -> `output[ch]`.
|
||||||
|
///
|
||||||
|
/// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a
|
||||||
|
/// release time. Output is guaranteed `|y| <= ceiling`.
|
||||||
|
pub fn process(&mut self, input: &[f32], output: &mut [f32], ceiling: f32, release_coef: f32) {
|
||||||
|
let n = input.len().min(self.delay.len());
|
||||||
|
|
||||||
|
// Detector = max of the sample peak and the 4× true-peak (inter-sample) estimate.
|
||||||
|
let mut sample_peak = 0.0f32;
|
||||||
|
for &x in &input[..n] {
|
||||||
|
sample_peak = sample_peak.max(x.abs());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target a hair below the ceiling so the (slightly under-read) true peak stays under it.
|
||||||
|
let target_ceiling = ceiling * self.margin_gain;
|
||||||
|
|
||||||
|
// The polyphase evaluation is ~PHASES*TAPS multiply-adds per channel and runs on every
|
||||||
|
// sample, which makes it the most expensive single thing in the chain. It is also
|
||||||
|
// skippable: the interpolator is linear, so over its FIR support
|
||||||
|
// |true peak| <= peak_gain * max|history|
|
||||||
|
// and when that bound sits under the ceiling the true peak provably cannot reach it.
|
||||||
|
// The limiter's target would be 1.0 for this sample either way, so feeding the sample
|
||||||
|
// peak instead cannot change the outcome - `sliding_max` still sees any genuinely loud
|
||||||
|
// neighbour through that neighbour's own evaluation.
|
||||||
|
//
|
||||||
|
// The history must still be fed on every sample, or later evaluations read holes.
|
||||||
|
self.oversampler.push(&input[..n]);
|
||||||
|
let support_max = self.support_max.push(sample_peak);
|
||||||
|
let peak = if support_max * self.oversampler.peak_gain() <= target_ceiling {
|
||||||
|
sample_peak
|
||||||
|
} else {
|
||||||
|
sample_peak.max(self.oversampler.evaluate(n))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write into the ring.
|
||||||
|
for ch in 0..n {
|
||||||
|
self.delay[ch][self.write_pos] = input[ch];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample
|
||||||
|
// (the one we output now) is in this window, `ceiling / window_max` applied to it can never
|
||||||
|
// exceed the ceiling, and the gain has pre-dropped for any louder sample still to come.
|
||||||
|
let window_max = self.sliding_max.push(peak);
|
||||||
|
let target = if window_max > target_ceiling {
|
||||||
|
target_ceiling / window_max
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decoupled smoothing: fast attack down, slow release up.
|
||||||
|
self.gain = if target < self.gain {
|
||||||
|
self.attack_coef * self.gain + (1.0 - self.attack_coef) * target
|
||||||
|
} else {
|
||||||
|
release_coef * self.gain + (1.0 - release_coef) * target
|
||||||
|
};
|
||||||
|
|
||||||
|
// Output the delayed sample, clamped to the ceiling as the hard guarantee.
|
||||||
|
// Conditional subtract instead of `%`: capacity is rarely a power of two, and the add
|
||||||
|
// here (1) is always <= capacity, so a plain compare-and-subtract is enough.
|
||||||
|
let out_pos = wrap_add(self.write_pos, 1, self.capacity); // oldest sample = fixed_delay ago
|
||||||
|
for ch in 0..n {
|
||||||
|
output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_pos = wrap_add(self.write_pos, 1, self.capacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::TAU;
|
||||||
|
|
||||||
|
const SR: f32 = 48_000.0;
|
||||||
|
|
||||||
|
fn release() -> f32 {
|
||||||
|
time_to_coef(50.0, SR)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sliding_max_matches_naive_full_rescan() {
|
||||||
|
// A small xorshift-ish PRNG (no extra deps) exercising both rising and falling runs.
|
||||||
|
let mut state = 0x1234_5678u32;
|
||||||
|
let mut next = || {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
(state % 1000) as f32 / 1000.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let window = 7usize;
|
||||||
|
let mut sm = SlidingMax::new();
|
||||||
|
sm.prepare(window);
|
||||||
|
let mut history: Vec<f32> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
let v = next();
|
||||||
|
history.push(v);
|
||||||
|
let start = history.len().saturating_sub(window);
|
||||||
|
let expected = history[start..].iter().cloned().fold(0.0f32, f32::max);
|
||||||
|
assert_eq!(sm.push(v), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn never_exceeds_ceiling_on_spikes() {
|
||||||
|
// Mostly silence with occasional large spikes — output must never exceed the ceiling.
|
||||||
|
let mut lim = Limiter::new();
|
||||||
|
lim.prepare(SR, 1);
|
||||||
|
let ceiling = 1.0;
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
for i in 0..10_000 {
|
||||||
|
let x = if i % 500 == 0 { 5.0 } else { 0.01 };
|
||||||
|
lim.process(&[x], &mut out, ceiling, release());
|
||||||
|
assert!(out[0].abs() <= ceiling + 1e-6, "overshoot at {i}: {}", out[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limits_loud_sine_to_ceiling() {
|
||||||
|
// A sine well above the ceiling settles to ~ceiling, not silenced.
|
||||||
|
let mut lim = Limiter::new();
|
||||||
|
lim.prepare(SR, 1);
|
||||||
|
let ceiling = 1.0;
|
||||||
|
let (amp, freq) = (2.0f32, 1_000.0);
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
let mut max_tail = 0.0f32;
|
||||||
|
let total = SR as usize;
|
||||||
|
for i in 0..total {
|
||||||
|
let x = amp * (TAU * freq * i as f32 / SR).sin();
|
||||||
|
lim.process(&[x], &mut out, ceiling, release());
|
||||||
|
if i >= total - 4_800 {
|
||||||
|
max_tail = max_tail.max(out[0].abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(max_tail <= ceiling + 1e-6, "exceeded ceiling: {max_tail}");
|
||||||
|
assert!(max_tail > 0.9, "over-attenuated: {max_tail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transparent_below_ceiling() {
|
||||||
|
// A signal under the ceiling passes through unattenuated (just delayed).
|
||||||
|
let mut lim = Limiter::new();
|
||||||
|
lim.prepare(SR, 1);
|
||||||
|
let ceiling = 1.0;
|
||||||
|
let (amp, freq) = (0.5f32, 1_000.0);
|
||||||
|
let mut out = [0.0f32];
|
||||||
|
let mut max_tail = 0.0f32;
|
||||||
|
let total = SR as usize / 2;
|
||||||
|
for i in 0..total {
|
||||||
|
let x = amp * (TAU * freq * i as f32 / SR).sin();
|
||||||
|
lim.process(&[x], &mut out, ceiling, release());
|
||||||
|
if i >= total - 4_800 {
|
||||||
|
max_tail = max_tail.max(out[0].abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!((max_tail - amp).abs() < 1e-3, "not transparent: {max_tail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latency_is_the_lookahead() {
|
||||||
|
let mut lim = Limiter::new();
|
||||||
|
lim.prepare(SR, 1);
|
||||||
|
let expected = (LOOKAHEAD_MS * 0.001 * SR).ceil() as u32;
|
||||||
|
assert_eq!(lim.latency(), expected);
|
||||||
|
assert!(expected > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! DSP building blocks for Bari 206.
|
||||||
|
//!
|
||||||
|
//! The signal chain: a `crossover` filterbank splits into bands, each band (plus the summed
|
||||||
|
//! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector)
|
||||||
|
//! is the final stage. `biquad` is the shared filter primitive the crossover is built from.
|
||||||
|
|
||||||
|
pub mod biquad;
|
||||||
|
pub mod compressor;
|
||||||
|
pub mod crossover;
|
||||||
|
pub mod limiter;
|
||||||
|
pub mod oversampler;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! 4× polyphase interpolation for **true-peak (inter-sample) detection only**.
|
||||||
|
//!
|
||||||
|
//! A band-limited signal can overshoot its sample values between samples, so the digital sample
|
||||||
|
//! peak under-reads the real (post-DAC) peak. We reconstruct the 4× grid with a polyphase
|
||||||
|
//! windowed-sinc interpolator and report only the maximum magnitude found — the interpolated
|
||||||
|
//! samples themselves are discarded. The audio path is untouched; this just feeds a better peak
|
||||||
|
//! estimate into the limiter.
|
||||||
|
//!
|
||||||
|
//! Speed: the prototype `PHASES * TAPS_PER_PHASE`-tap low-pass is split into `PHASES` sub-filters
|
||||||
|
//! of `TAPS_PER_PHASE` taps, each run at the base rate (no zero-stuffed multiplies). Cost is
|
||||||
|
//! `PHASES * TAPS_PER_PHASE` MACs per input sample per channel — about one base-rate FIR.
|
||||||
|
//!
|
||||||
|
//! The interpolation point sits at the centre of the tap window, so the estimate lags the input by
|
||||||
|
//! ~`TAPS_PER_PHASE/2` samples. That is far smaller than the limiter's look-ahead, which absorbs it
|
||||||
|
//! — so this adds no reported latency.
|
||||||
|
|
||||||
|
const PHASES: usize = 4;
|
||||||
|
const TAPS_PER_PHASE: usize = 12;
|
||||||
|
/// Length of the FIR support, i.e. how many past input frames an evaluation reads. A caller
|
||||||
|
/// bounding the true peak needs the max over exactly this many frames.
|
||||||
|
pub const SUPPORT_LEN: usize = TAPS_PER_PHASE;
|
||||||
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
|
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
|
||||||
|
/// sub-filters, each normalised to unity DC gain so reconstruction preserves amplitude.
|
||||||
|
fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] {
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
let n = PHASES * TAPS_PER_PHASE;
|
||||||
|
let center = (n as f32 - 1.0) / 2.0;
|
||||||
|
|
||||||
|
let mut proto = [0.0f32; PHASES * TAPS_PER_PHASE];
|
||||||
|
for (m, p) in proto.iter_mut().enumerate() {
|
||||||
|
// Sinc low-pass at the base-rate Nyquist (cutoff = 1/PHASES of the oversampled rate).
|
||||||
|
let x = (m as f32 - center) / PHASES as f32;
|
||||||
|
let sinc = if x.abs() < 1e-7 { 1.0 } else { (PI * x).sin() / (PI * x) };
|
||||||
|
// Blackman window.
|
||||||
|
let t = m as f32 / (n as f32 - 1.0);
|
||||||
|
let window = 0.42 - 0.5 * (2.0 * PI * t).cos() + 0.08 * (4.0 * PI * t).cos();
|
||||||
|
*p = sinc * window;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut coeffs = [[0.0f32; TAPS_PER_PHASE]; PHASES];
|
||||||
|
for (phase, row) in coeffs.iter_mut().enumerate() {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for (k, c) in row.iter_mut().enumerate() {
|
||||||
|
*c = proto[k * PHASES + phase];
|
||||||
|
sum += *c;
|
||||||
|
}
|
||||||
|
if sum.abs() > 1e-12 {
|
||||||
|
for c in row.iter_mut() {
|
||||||
|
*c /= sum; // unity DC per phase -> amplitude-preserving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest L1 norm across phases: `max_phase sum |c|`. Bounds the interpolator's output for a
|
||||||
|
/// bounded input, which is what makes the [`Oversampler::peak_gain`] early-out sound.
|
||||||
|
fn build_peak_gain(coeffs: &[[f32; TAPS_PER_PHASE]; PHASES]) -> f32 {
|
||||||
|
coeffs
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.iter().map(|c| c.abs()).sum::<f32>())
|
||||||
|
.fold(0.0f32, f32::max)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Oversampler {
|
||||||
|
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
|
||||||
|
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
|
||||||
|
history: Vec<[f32; TAPS_PER_PHASE]>,
|
||||||
|
pos: usize,
|
||||||
|
/// Largest L1 norm across the phases. Since every interpolated sample is a dot product of
|
||||||
|
/// one phase with the history, `|interpolated| <= peak_gain * max|history|` - a hard bound,
|
||||||
|
/// which lets a caller prove the true peak cannot reach a ceiling without evaluating it.
|
||||||
|
peak_gain: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Oversampler {
|
||||||
|
fn default() -> Self {
|
||||||
|
let coeffs = build_coefficients();
|
||||||
|
Self {
|
||||||
|
peak_gain: build_peak_gain(&coeffs),
|
||||||
|
coeffs,
|
||||||
|
history: Vec::new(),
|
||||||
|
pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Oversampler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prepare(&mut self, num_channels: usize) {
|
||||||
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
|
self.history = vec![[0.0; TAPS_PER_PHASE]; channels];
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
for ch in &mut self.history {
|
||||||
|
*ch = [0.0; TAPS_PER_PHASE];
|
||||||
|
}
|
||||||
|
self.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
|
||||||
|
/// 4× grid (the reconstructed samples are not kept).
|
||||||
|
/// Upper bound on `|interpolated|` for an input bounded by 1.0. See [`Self::peak_gain`].
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn peak_gain(&self) -> f32 {
|
||||||
|
self.peak_gain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one input frame into the history. Cheap - a write per channel plus a counter.
|
||||||
|
/// Must be called for EVERY frame even when [`Self::evaluate`] is skipped, or the FIR
|
||||||
|
/// history develops holes and later evaluations read stale samples.
|
||||||
|
#[inline]
|
||||||
|
pub fn push(&mut self, input: &[f32]) {
|
||||||
|
let n = input.len().min(self.history.len());
|
||||||
|
let slot = self.pos % TAPS_PER_PHASE;
|
||||||
|
for ch in 0..n {
|
||||||
|
self.history[ch][slot] = input[ch];
|
||||||
|
}
|
||||||
|
self.pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual polyphase evaluation: `PHASES * TAPS_PER_PHASE` multiply-adds per channel.
|
||||||
|
/// This is the expensive half, and the only half a caller can skip.
|
||||||
|
#[inline]
|
||||||
|
pub fn evaluate(&self, channels: usize) -> f32 {
|
||||||
|
let n = channels.min(self.history.len());
|
||||||
|
// The tap index only depends on `pos` and `k`, not on `phase` or `ch` - hoist it out of
|
||||||
|
// those loops instead of recomputing (and re-dividing) it PHASES*MAX_CHANNELS times over.
|
||||||
|
// `pos` already advanced past the newest sample in push(), hence the -1.
|
||||||
|
let base = self.pos + TAPS_PER_PHASE - 1;
|
||||||
|
let mut idx = [0usize; TAPS_PER_PHASE];
|
||||||
|
for (k, slot) in idx.iter_mut().enumerate() {
|
||||||
|
// k = 0 is the newest sample, increasing k goes back in time.
|
||||||
|
*slot = (base - k) % TAPS_PER_PHASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut peak = 0.0f32;
|
||||||
|
for ch in 0..n {
|
||||||
|
let hist = &self.history[ch];
|
||||||
|
for phase in &self.coeffs {
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for (&c, &i) in phase.iter().zip(idx.iter()) {
|
||||||
|
acc += c * hist[i];
|
||||||
|
}
|
||||||
|
peak = peak.max(acc.abs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peak
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a frame and evaluate it unconditionally. The real path splits these so the
|
||||||
|
/// evaluation can be skipped (see `Limiter::process`); this is kept for tests that want
|
||||||
|
/// the true peak of every frame regardless of any ceiling.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
|
||||||
|
let n = input.len().min(self.history.len());
|
||||||
|
self.push(input);
|
||||||
|
self.evaluate(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_inter_sample_overshoot() {
|
||||||
|
// A unit sine at fs/4 phased so every sample lands at ±0.707 while the true peak is 1.0
|
||||||
|
// (a classic ~3 dB inter-sample overshoot). The detector must see well above 0.707.
|
||||||
|
let mut os = Oversampler::new();
|
||||||
|
os.prepare(1);
|
||||||
|
let mut detected = 0.0f32;
|
||||||
|
for n in 0..2_000 {
|
||||||
|
let x = (PI * n as f32 / 2.0 + PI / 4.0).sin(); // sin(πn/2 + π/4)
|
||||||
|
detected = detected.max(os.max_true_peak(&[x]));
|
||||||
|
}
|
||||||
|
assert!(detected > 0.9, "missed inter-sample peak: {detected}");
|
||||||
|
assert!(detected < 1.1, "implausible overshoot: {detected}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_amplitude_of_constant() {
|
||||||
|
// Unity-DC normalisation: a constant signal reconstructs at its own level.
|
||||||
|
let mut os = Oversampler::new();
|
||||||
|
os.prepare(1);
|
||||||
|
let mut last = 0.0f32;
|
||||||
|
for _ in 0..200 {
|
||||||
|
last = os.max_true_peak(&[0.5]);
|
||||||
|
}
|
||||||
|
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The early-out in `Limiter::process` is only sound if `evaluate()` can never exceed
|
||||||
|
/// `peak_gain * max|history|`. Assert that directly against adversarial input - full-scale
|
||||||
|
/// square waves and alternating impulses maximise the interpolator's overshoot.
|
||||||
|
#[test]
|
||||||
|
fn evaluate_never_exceeds_peak_gain_bound() {
|
||||||
|
let mut os = Oversampler::default();
|
||||||
|
os.prepare(2);
|
||||||
|
let mut rng: u32 = 0xC0FF_EE01;
|
||||||
|
let mut worst_ratio = 0.0f32;
|
||||||
|
for i in 0..20_000 {
|
||||||
|
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
// Mix of adversarial shapes: alternating full scale, impulses, and noise.
|
||||||
|
let x = match i % 3 {
|
||||||
|
0 => if (i / 3) % 2 == 0 { 1.0 } else { -1.0 },
|
||||||
|
1 => if i % 97 == 0 { 1.0 } else { 0.0 },
|
||||||
|
_ => (rng >> 8) as f32 / (1 << 23) as f32 - 1.0,
|
||||||
|
};
|
||||||
|
let frame = [x, -x];
|
||||||
|
os.push(&frame);
|
||||||
|
let got = os.evaluate(2);
|
||||||
|
// Bound uses the max over the FIR support, which for these inputs is at most 1.0.
|
||||||
|
let bound = os.peak_gain() * 1.0;
|
||||||
|
assert!(
|
||||||
|
got <= bound + 1e-4,
|
||||||
|
"evaluate() = {got} exceeded peak_gain bound {bound} at i={i}"
|
||||||
|
);
|
||||||
|
if bound > 0.0 {
|
||||||
|
worst_ratio = worst_ratio.max(got / bound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sanity: the bound must be reachable enough to be useful, not vacuously huge.
|
||||||
|
assert!(worst_ratio > 0.3, "bound looks far too loose (worst ratio {worst_ratio})");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Draggable crossover handles — replaces the two plain crossover sliders.
|
||||||
|
//!
|
||||||
|
//! A horizontal log-frequency strip split into LOW / MID / HIGH by two draggable handles, plus a
|
||||||
|
//! number box per crossover (double-click to type an exact value). The handles enforce a dynamic
|
||||||
|
//! limit: lo/mid can never exceed mid/hi (and vice-versa), on top of each param's own range.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{
|
||||||
|
self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::params::Bari206Params;
|
||||||
|
use super::palette;
|
||||||
|
|
||||||
|
/// Height of the handle strip.
|
||||||
|
const GRAPH_H: f32 = 54.0;
|
||||||
|
/// Displayed frequency axis (log), independent of the params' own ranges.
|
||||||
|
const DISP_MIN_HZ: f32 = 20.0;
|
||||||
|
const DISP_MAX_HZ: f32 = 20_000.0;
|
||||||
|
|
||||||
|
fn log_span() -> f32 {
|
||||||
|
DISP_MAX_HZ.ln() - DISP_MIN_HZ.ln()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_hz(hz: f32) -> String {
|
||||||
|
if hz >= 1000.0 {
|
||||||
|
format!("{:.2} kHz", hz / 1000.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.0} Hz", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, setter: &ParamSetter) {
|
||||||
|
let lo = params.crossover_low_hz.value();
|
||||||
|
let hi = params.crossover_high_hz.value();
|
||||||
|
// Each param's own min/max (normalized 0/1 map to the range ends).
|
||||||
|
let lo_min = params.crossover_low_hz.preview_plain(0.0);
|
||||||
|
let lo_max = params.crossover_low_hz.preview_plain(1.0);
|
||||||
|
let hi_min = params.crossover_high_hz.preview_plain(0.0);
|
||||||
|
let hi_max = params.crossover_high_hz.preview_plain(1.0);
|
||||||
|
// Dynamic limits so the two never cross: lo <= hi.
|
||||||
|
let lo_upper = lo_max.min(hi);
|
||||||
|
let hi_lower = hi_min.max(lo);
|
||||||
|
|
||||||
|
ui.label("Crossover");
|
||||||
|
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), GRAPH_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
let (left, right, top, bottom) =
|
||||||
|
(rect.left() + 2.0, rect.right() - 2.0, rect.top() + 2.0, rect.bottom() - 2.0);
|
||||||
|
let width = right - left;
|
||||||
|
let x_for = |hz: f32| left + (hz.max(1.0).ln() - DISP_MIN_HZ.ln()) / log_span() * width;
|
||||||
|
let xlo = x_for(lo);
|
||||||
|
let xhi = x_for(hi);
|
||||||
|
|
||||||
|
// Three band regions.
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, palette::CHANNELS[0].strip);
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, palette::CHANNELS[1].strip);
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, palette::CHANNELS[2].strip);
|
||||||
|
|
||||||
|
let band_label = |cx: f32, t: &str| {
|
||||||
|
p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), palette::TEXT);
|
||||||
|
};
|
||||||
|
band_label((left + xlo) * 0.5, "LOW");
|
||||||
|
band_label((xlo + xhi) * 0.5, "MID");
|
||||||
|
band_label((xhi + right) * 0.5, "HIGH");
|
||||||
|
|
||||||
|
handle(ui, &p, setter, "xover_lo", xlo, lo, ¶ms.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, palette::CHANNELS[0].handle);
|
||||||
|
handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, palette::CHANNELS[2].handle);
|
||||||
|
|
||||||
|
// Number boxes (double-click to type). Clamped to the same dynamic limits.
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Lo/Mid");
|
||||||
|
let mut v = lo as f64;
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut v).range(lo_min as f64..=lo_upper as f64).speed(0.5).suffix(" Hz"))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
set_clamped(setter, ¶ms.crossover_low_hz, v as f32, lo_min, lo_upper);
|
||||||
|
}
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.label("Mid/Hi");
|
||||||
|
let mut v = hi as f64;
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut v).range(hi_lower as f64..=hi_max as f64).speed(2.0).suffix(" Hz"))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
set_clamped(setter, ¶ms.crossover_high_hz, v as f32, hi_lower, hi_max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One draggable vertical handle. Drives `param` from the pointer's x (log-frequency), clamped to
|
||||||
|
/// `[min, max]` (which already encodes the dynamic lo<=hi limit), with proper begin/end gestures.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
p: &Painter,
|
||||||
|
setter: &ParamSetter,
|
||||||
|
id_salt: &str,
|
||||||
|
x: f32,
|
||||||
|
hz: f32,
|
||||||
|
param: &FloatParam,
|
||||||
|
min: f32,
|
||||||
|
max: f32,
|
||||||
|
left: f32,
|
||||||
|
width: f32,
|
||||||
|
top: f32,
|
||||||
|
bottom: f32,
|
||||||
|
color: Color32,
|
||||||
|
) {
|
||||||
|
let hit = Rect::from_min_max(pos2(x - 5.0, top), pos2(x + 5.0, bottom));
|
||||||
|
let resp = ui
|
||||||
|
.interact(hit, ui.id().with(id_salt), Sense::drag())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||||
|
|
||||||
|
if resp.drag_started() {
|
||||||
|
setter.begin_set_parameter(param);
|
||||||
|
}
|
||||||
|
if resp.dragged() {
|
||||||
|
if let Some(pos) = resp.interact_pointer_pos() {
|
||||||
|
let frac = ((pos.x - left) / width).clamp(0.0, 1.0);
|
||||||
|
let new = (DISP_MIN_HZ.ln() + frac * log_span()).exp().clamp(min, max);
|
||||||
|
setter.set_parameter(param, new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.drag_stopped() {
|
||||||
|
setter.end_set_parameter(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
let col = if resp.dragged() || resp.hovered() { palette::TEXT_HOVER } else { color };
|
||||||
|
p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0_f32, col));
|
||||||
|
p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), palette::TEXT_BRIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box).
|
||||||
|
fn set_clamped(setter: &ParamSetter, param: &FloatParam, value: f32, min: f32, max: f32) {
|
||||||
|
setter.begin_set_parameter(param);
|
||||||
|
setter.set_parameter(param, value.clamp(min, max));
|
||||||
|
setter.end_set_parameter(param);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Static gain-curve display: output level vs input level for the channel currently selected in
|
||||||
|
//! the plot. Plots the wet transfer `out = (in + pre_gain) + gain_reduction(...) + makeup` (mix not
|
||||||
|
//! folded in; output clamped at 0 dBFS), plus a live **operating-point fill** under the curve up to
|
||||||
|
//! the channel's current input level — its right edge rides the curve (width = input, height = out).
|
||||||
|
|
||||||
|
use nih_plug::prelude::util;
|
||||||
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, CornerRadius, FontId, Sense, Stroke};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use crate::dsp::compressor::Compressor;
|
||||||
|
use crate::meters::Meters;
|
||||||
|
use crate::params::Bari206Params;
|
||||||
|
use super::palette;
|
||||||
|
|
||||||
|
/// Side length of the square plot.
|
||||||
|
const CURVE_SIZE: f32 = 150.0;
|
||||||
|
/// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS).
|
||||||
|
const FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, meters: &Meters) {
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let ch = selected.min(3);
|
||||||
|
let cp = match ch {
|
||||||
|
0 => ¶ms.low,
|
||||||
|
1 => ¶ms.mid,
|
||||||
|
2 => ¶ms.high,
|
||||||
|
_ => ¶ms.all,
|
||||||
|
};
|
||||||
|
let pre = cp.pre_gain_db.value();
|
||||||
|
let threshold = cp.threshold_db.value();
|
||||||
|
let ratio = cp.ratio.value();
|
||||||
|
let knee = cp.knee_db.value();
|
||||||
|
let low_slope = cp.low_slope.value();
|
||||||
|
let low_curve = cp.low_curve.value();
|
||||||
|
let makeup = cp.makeup_db.value();
|
||||||
|
|
||||||
|
ui.label(format!("Curve: {}", labels[ch]));
|
||||||
|
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
|
||||||
|
|
||||||
|
let inset = 2.0;
|
||||||
|
let (left, right, top, bottom) =
|
||||||
|
(rect.left() + inset, rect.right() - inset, rect.top() + inset, rect.bottom() - inset);
|
||||||
|
let w = right - left;
|
||||||
|
let h = bottom - top;
|
||||||
|
let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w;
|
||||||
|
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h;
|
||||||
|
|
||||||
|
// -6 dBFS reference lines on both axes.
|
||||||
|
let g6 = palette::GRID_FAINT;
|
||||||
|
let x6 = x_for(-6.0);
|
||||||
|
let y6 = y_for(-6.0);
|
||||||
|
p.line_segment([pos2(x6, top), pos2(x6, bottom)], Stroke::new(1.0_f32, g6));
|
||||||
|
p.line_segment([pos2(left, y6), pos2(right, y6)], Stroke::new(1.0_f32, g6));
|
||||||
|
|
||||||
|
// Unity reference (out = in), bottom-left to top-right.
|
||||||
|
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0_f32, palette::GRID));
|
||||||
|
// Threshold marker on the input axis — shifted left by pre-gain (the comp sees in + pre).
|
||||||
|
let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
|
||||||
|
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0_f32, palette::THRESHOLD_LINE));
|
||||||
|
|
||||||
|
// Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
|
||||||
|
let n = 96;
|
||||||
|
let mut pts = Vec::with_capacity(n + 1);
|
||||||
|
for i in 0..=n {
|
||||||
|
let in_db = FLOOR_DB + (i as f32 / n as f32) * -FLOOR_DB; // external input, -60..0
|
||||||
|
let driven = in_db + pre;
|
||||||
|
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve); // signed dB
|
||||||
|
let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
|
||||||
|
pts.push(pos2(x_for(in_db), y_for(out_db)));
|
||||||
|
}
|
||||||
|
// Operating-point fill: shade under the curve from the floor up to the current input level.
|
||||||
|
let driven_now = util::gain_to_db(meters.input_level[ch].load(Ordering::Relaxed));
|
||||||
|
let ext_in = (driven_now - pre).clamp(FLOOR_DB, 0.0); // external input -> curve x
|
||||||
|
let x_now = x_for(ext_in);
|
||||||
|
let fill_col = palette::with_alpha(palette::CURVE, 45);
|
||||||
|
for seg in pts.windows(2) {
|
||||||
|
let a = seg[0];
|
||||||
|
let mut b = seg[1];
|
||||||
|
if a.x >= x_now {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if b.x > x_now {
|
||||||
|
let f = ((x_now - a.x) / (b.x - a.x)).clamp(0.0, 1.0); // clip the last quad at x_now
|
||||||
|
b = pos2(x_now, a.y + (b.y - a.y) * f);
|
||||||
|
}
|
||||||
|
p.add(egui::Shape::convex_polygon(
|
||||||
|
vec![pos2(a.x, bottom), a, b, pos2(b.x, bottom)],
|
||||||
|
fill_col,
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
p.add(egui::Shape::line(pts, Stroke::new(1.6_f32, palette::CURVE)));
|
||||||
|
|
||||||
|
// Operating-point dot, on the curve at the current input.
|
||||||
|
let driven = ext_in + pre;
|
||||||
|
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve);
|
||||||
|
let out_op = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
|
||||||
|
p.circle_filled(pos2(x_now, y_for(out_op)), 3.0, palette::OPERATING_POINT);
|
||||||
|
|
||||||
|
// Corner dB ticks + the -6 dB reference.
|
||||||
|
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||||
|
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||||
|
p.text(pos2(x6 + 2.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-6", FontId::proportional(9.0), palette::TEXT_DIM);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Per-channel level + gain-reduction meters, each with its own latching ceiling/over lamp.
|
||||||
|
//!
|
||||||
|
//! Each channel is a `|L | GR | R|` cluster (output level left/right, mono gain reduction in the
|
||||||
|
//! middle) topped by a lamp. The lamp latches when the channel's output reaches 0 dBFS (a hot /
|
||||||
|
//! "over" warning — useful when pre-gain drives a band hard); for the ALL channel it also lights
|
||||||
|
//! when the output limiter is actually catching peaks. It holds, then clears after `LAMP_HOLD_S`
|
||||||
|
//! or on a click. Fed by the lock-free [`Meters`] state the audio thread publishes each block.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{
|
||||||
|
self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Painter, Rect, Sense,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use super::METER_FLOOR_DB;
|
||||||
|
use crate::meters::{Meters, NUM_CHANNELS};
|
||||||
|
use super::palette;
|
||||||
|
|
||||||
|
/// Full-scale of the gain-reduction bar (fills downward from the top).
|
||||||
|
const GR_FULL_DB: f32 = 24.0;
|
||||||
|
/// Output level (dBFS) at/above which a channel's lamp latches on.
|
||||||
|
const OVER_DB: f32 = 0.0;
|
||||||
|
/// Limiter gain reduction (dB) above which the ALL channel's lamp also latches on.
|
||||||
|
const LAMP_TRIGGER_DB: f32 = 0.1;
|
||||||
|
/// How long a lamp stays lit after the most recent trigger (seconds).
|
||||||
|
const LAMP_HOLD_S: f64 = 3.0;
|
||||||
|
/// Height of the meter panel.
|
||||||
|
const METER_PANEL_H: f32 = 140.0;
|
||||||
|
|
||||||
|
/// GUI-side state for the meter panel: one lamp latch per channel.
|
||||||
|
pub(super) struct MeterState {
|
||||||
|
/// egui time (seconds) of each channel's most recent lamp trigger, while latched on.
|
||||||
|
/// `None` = lamp off (never triggered, expired, or dismissed by a click).
|
||||||
|
ceiling_trigger: [Option<f64>; NUM_CHANNELS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MeterState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { ceiling_trigger: [None; NUM_CHANNELS] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the meter panel: a `|L | GR | R|` cluster + a latching over/ceiling lamp per channel.
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let now = ui.ctx().input(|i| i.time);
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE_RAISED);
|
||||||
|
|
||||||
|
let top = rect.top() + 22.0; // leave a row at the top for the lamps
|
||||||
|
let bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
|
||||||
|
let cell_w = rect.width() / NUM_CHANNELS as f32;
|
||||||
|
// Three bars per cluster, so they're narrower than a two-bar layout.
|
||||||
|
let bar_w = (cell_w * 0.17).min(14.0);
|
||||||
|
let gap = (cell_w * 0.05).min(5.0);
|
||||||
|
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
let cell_left = rect.left() + i as f32 * cell_w;
|
||||||
|
|
||||||
|
// Channel chrome. The four clusters are otherwise identical and told apart only by the
|
||||||
|
// label underneath, which is the one place channel identity is hardest to read. A dark
|
||||||
|
// wash behind the cell plus an accent rule under the bars fixes that without touching
|
||||||
|
// the bars themselves - see `palette`: hue is chrome, functional colour is data.
|
||||||
|
let cell = Rect::from_min_max(
|
||||||
|
pos2(cell_left + 1.0, rect.top()),
|
||||||
|
pos2(cell_left + cell_w - 1.0, rect.bottom()),
|
||||||
|
);
|
||||||
|
p.rect_filled(cell, CornerRadius::ZERO, palette::CHANNELS[i].chrome);
|
||||||
|
p.rect_filled(
|
||||||
|
Rect::from_min_max(pos2(cell.left(), bottom + 2.0), pos2(cell.right(), bottom + 4.0)),
|
||||||
|
CornerRadius::ZERO,
|
||||||
|
palette::CHANNELS[i].edge,
|
||||||
|
);
|
||||||
|
|
||||||
|
let group_w = bar_w * 3.0 + gap * 2.0;
|
||||||
|
let bx = cell_left + (cell_w - group_w) * 0.5;
|
||||||
|
|
||||||
|
// L / R output level (upward); colour warns as it nears 0 dBFS.
|
||||||
|
let l_db = util::gain_to_db(meters.level_l[i].load(Ordering::Relaxed));
|
||||||
|
let r_db = util::gain_to_db(meters.level_r[i].load(Ordering::Relaxed));
|
||||||
|
let l_frac = ((l_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
let r_frac = ((r_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// Mono gain reduction (downward from the top).
|
||||||
|
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
|
||||||
|
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
v_bar(&p, bx, bar_w, top, bottom, l_frac, palette::level_color(l_db), false);
|
||||||
|
v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, palette::SIGNAL_GR, true);
|
||||||
|
v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, palette::level_color(r_db), false);
|
||||||
|
|
||||||
|
p.text(
|
||||||
|
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
|
||||||
|
Align2::CENTER_BOTTOM,
|
||||||
|
labels[i],
|
||||||
|
FontId::proportional(12.0),
|
||||||
|
palette::TEXT_BRIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
|
||||||
|
// output limiter is catching peaks (the true master-ceiling event).
|
||||||
|
let over_db = l_db.max(r_db);
|
||||||
|
let mut triggered = over_db >= OVER_DB;
|
||||||
|
if i == NUM_CHANNELS - 1 {
|
||||||
|
triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB;
|
||||||
|
}
|
||||||
|
if triggered {
|
||||||
|
state.ceiling_trigger[i] = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lamp_center = pos2(cell_left + cell_w * 0.5, rect.top() + 11.0);
|
||||||
|
let lamp_rect = Rect::from_center_size(lamp_center, vec2(18.0, 18.0));
|
||||||
|
let resp = ui
|
||||||
|
.interact(lamp_rect, ui.id().with(("ceiling_lamp", i)), Sense::click())
|
||||||
|
.on_hover_cursor(CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Output reached 0 dBFS — click to clear");
|
||||||
|
if resp.clicked() {
|
||||||
|
state.ceiling_trigger[i] = None;
|
||||||
|
}
|
||||||
|
if let Some(t) = state.ceiling_trigger[i] {
|
||||||
|
if now - t >= LAMP_HOLD_S {
|
||||||
|
state.ceiling_trigger[i] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lamp = if state.ceiling_trigger[i].is_some() {
|
||||||
|
palette::LAMP_ON
|
||||||
|
} else {
|
||||||
|
palette::LAMP_OFF
|
||||||
|
};
|
||||||
|
p.circle_filled(lamp_center, 5.0, lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the
|
||||||
|
/// top (gain reduction) instead of upward from the bottom (level).
|
||||||
|
fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
|
||||||
|
let track = palette::TRACK;
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track);
|
||||||
|
|
||||||
|
let h = (bottom - top) * frac.clamp(0.0, 1.0);
|
||||||
|
let filled = if from_top {
|
||||||
|
Rect::from_min_max(pos2(x, top), pos2(x + w, top + h))
|
||||||
|
} else {
|
||||||
|
Rect::from_min_max(pos2(x, bottom - h), pos2(x + w, bottom))
|
||||||
|
};
|
||||||
|
p.rect_filled(filled, CornerRadius::ZERO, fill);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! egui editor: assembly + control layout.
|
||||||
|
//!
|
||||||
|
//! The aggregator. Builds the editor window and lays out the heading, the meter panel
|
||||||
|
//! ([`meter`]), the rolling plot ([`plot`]), and the (placeholder) per-channel slider columns.
|
||||||
|
//! Each visualiser owns its GUI state and drawing in its submodule; this module wires them
|
||||||
|
//! together and holds the shared [`EditorState`]. When the UI is redesigned the slider columns
|
||||||
|
//! get replaced and the visualisers stay as self-contained widgets.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::{
|
||||||
|
create_egui_editor,
|
||||||
|
egui::{self, Vec2},
|
||||||
|
resizable_window::ResizableWindow,
|
||||||
|
widgets,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::meters::Meters;
|
||||||
|
use crate::params::{Bari206Params, CompressorParams};
|
||||||
|
use crate::Bari206;
|
||||||
|
|
||||||
|
mod crossover;
|
||||||
|
mod gain_curve;
|
||||||
|
mod meter;
|
||||||
|
mod palette;
|
||||||
|
mod plot;
|
||||||
|
|
||||||
|
/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS).
|
||||||
|
const METER_FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// GUI-side editor state (not persisted): the per-widget state for the meter panel and the plot.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct EditorState {
|
||||||
|
meter: meter::MeterState,
|
||||||
|
plot: plot::PlotState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the plugin editor over shared handles to the params and meter state.
|
||||||
|
pub(crate) fn create(params: Arc<Bari206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
|
||||||
|
let egui_state = params.editor_state.clone();
|
||||||
|
create_egui_editor(
|
||||||
|
params.editor_state.clone(),
|
||||||
|
EditorState::default(),
|
||||||
|
|egui_ctx, _| palette::apply_visuals(egui_ctx),
|
||||||
|
move |egui_ctx, setter, state| {
|
||||||
|
// Keep frames coming so the meters animate and the lamp can time out while open.
|
||||||
|
egui_ctx.request_repaint();
|
||||||
|
|
||||||
|
// One column of controls for a single compressor channel (placeholder layout).
|
||||||
|
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
|
||||||
|
// Roughly in signal order: input drive -> low shaper -> compressor -> output.
|
||||||
|
ui.strong(title);
|
||||||
|
ui.label("Pre-gain");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter));
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.detection, setter));
|
||||||
|
ui.label("Low Slope");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.low_slope, setter));
|
||||||
|
ui.label("Low Curve");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.low_curve, setter));
|
||||||
|
ui.label("Threshold");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
|
||||||
|
ui.label("Ratio");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.ratio, setter));
|
||||||
|
ui.label("Knee");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter));
|
||||||
|
ui.label("Attack");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter));
|
||||||
|
ui.label("Release");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter));
|
||||||
|
ui.label("Makeup");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter));
|
||||||
|
ui.label("Mix");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.mix, setter));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resizable window; vertical scroll so every control stays reachable even when the
|
||||||
|
// window is small. (Placeholder layout — the redesign will replace the slider columns.)
|
||||||
|
ResizableWindow::new("editor")
|
||||||
|
.min_size(Vec2::new(480.0, 320.0))
|
||||||
|
.show(egui_ctx, egui_state.as_ref(), |ui| {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.heading(Bari206::NAME);
|
||||||
|
meter::draw(ui, &meters, &mut state.meter);
|
||||||
|
ui.separator();
|
||||||
|
// Gain curve (left, square) beside the scrolling plot (right, fills the rest).
|
||||||
|
let selected = state.plot.selected;
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui.vertical(|ui| gain_curve::draw(ui, ¶ms, selected, &meters));
|
||||||
|
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
crossover::draw(ui, ¶ms, setter);
|
||||||
|
ui.separator();
|
||||||
|
// Global controls stacked vertically so they never overflow sideways.
|
||||||
|
egui::Grid::new("globals").num_columns(2).show(ui, |ui| {
|
||||||
|
ui.label("Look-ahead");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Ceiling");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Lim Release");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.columns(4, |cols| {
|
||||||
|
band_col(&mut cols[0], "LOW", ¶ms.low);
|
||||||
|
band_col(&mut cols[1], "MID", ¶ms.mid);
|
||||||
|
band_col(&mut cols[2], "HIGH", ¶ms.high);
|
||||||
|
band_col(&mut cols[3], "ALL", ¶ms.all);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! The plugin's colour system.
|
||||||
|
//!
|
||||||
|
//! # The rule
|
||||||
|
//!
|
||||||
|
//! **Channel hue is chrome; functional colour is data.** Before picking a colour, ask what the
|
||||||
|
//! pixel is for. Is it showing a *value* — a level, a reduction, a clip? Then it comes from the
|
||||||
|
//! functional palette, and it means the same thing in every channel. Is it telling you *which
|
||||||
|
//! channel you are looking at*? Then it comes from that channel's hue, and it never touches the
|
||||||
|
//! data itself.
|
||||||
|
//!
|
||||||
|
//! This is what keeps the two systems from fighting. An earlier version of this UI used blue,
|
||||||
|
//! green and orange for LOW/MID/HIGH in the crossover *and* for input/output/gain-reduction in
|
||||||
|
//! the scope, so orange meant "the high band" in one widget and "gain reduction" in the one next
|
||||||
|
//! to it.
|
||||||
|
//!
|
||||||
|
//! # The two tiers
|
||||||
|
//!
|
||||||
|
//! 1. **Seeds** — the short, hand-picked list in the first half of this file. This is the part
|
||||||
|
//! you edit. Everything else is derived from it.
|
||||||
|
//! 2. **Derived** — [`Channel`] palettes built from a hue by [`channel`]. Change a hue and every
|
||||||
|
//! tint, edge and strip using it moves with it.
|
||||||
|
//!
|
||||||
|
//! All derivation is `const fn`, so the whole table is computed at compile time and costs nothing
|
||||||
|
//! at runtime. That is also why it uses plain HSV rather than `ecolor::Hsva`, whose conversion is
|
||||||
|
//! not `const`.
|
||||||
|
//!
|
||||||
|
//! # Editing this file
|
||||||
|
//!
|
||||||
|
//! * To recolour a channel, change one `HUE_*` constant. Never hand-edit a derived value.
|
||||||
|
//! * To retune the meter's headroom ramp, edit [`LEVEL_OK`] / [`LEVEL_WARN`] / [`LEVEL_HOT`] and
|
||||||
|
//! the thresholds in [`level_color`]. Both are deliberately literal rather than interpolated,
|
||||||
|
//! so you can set each one by eye without fighting a gradient.
|
||||||
|
//! * To change how loudly channel identity reads, edit the `(saturation, value)` pairs in
|
||||||
|
//! [`channel`].
|
||||||
|
//! * Anything new belongs here and must be reachable from a seed — a colour written at a call
|
||||||
|
//! site is the next magic number.
|
||||||
|
|
||||||
|
use nih_plug_egui::egui::Color32;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// TIER 1 — SEEDS. This is the part to edit.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// --- Channel identity ------------------------------------------------------
|
||||||
|
// Hues in degrees: 0 = red, 120 = green, 240 = blue. Only the hue is chosen here; saturation and
|
||||||
|
// brightness are decided per role by `channel()`, so a channel reads as the same colour whether
|
||||||
|
// it is a 12%-brightness background wash or a full-strength drag handle.
|
||||||
|
//
|
||||||
|
// Keep neighbours far enough apart to survive being dimmed — most chrome lives at low
|
||||||
|
// saturation, where close hues merge. Roughly 60–90 degrees apart works; below ~40 does not.
|
||||||
|
pub const HUE_LOW: f32 = 233.0; // blue
|
||||||
|
pub const HUE_MID: f32 = 20.0; // orange
|
||||||
|
pub const HUE_HIGH: f32 = 155.0; // blueish green
|
||||||
|
pub const HUE_ALL: f32 = 280.0; // violet — the aggregate, distinct from its three inputs
|
||||||
|
|
||||||
|
// --- Functional colours ----------------------------------------------------
|
||||||
|
// Outside the hue system on purpose: these mean the same thing in every channel, so they must
|
||||||
|
// NOT be derived from a channel hue. Red is always "you have hit a limit".
|
||||||
|
|
||||||
|
/// Meter bar with comfortable headroom.
|
||||||
|
pub const LEVEL_OK: Color32 = Color32::from_rgb(90, 200, 110);
|
||||||
|
/// Meter bar approaching full scale.
|
||||||
|
pub const LEVEL_WARN: Color32 = Color32::from_rgb(230, 200, 70);
|
||||||
|
/// Meter bar at or near full scale.
|
||||||
|
pub const LEVEL_HOT: Color32 = Color32::from_rgb(235, 70, 60);
|
||||||
|
|
||||||
|
/// Latched over/ceiling lamp, lit.
|
||||||
|
pub const LAMP_ON: Color32 = Color32::from_rgb(255, 40, 40);
|
||||||
|
/// The same lamp unlit — dark red rather than grey, so it reads as "off", not "absent".
|
||||||
|
pub const LAMP_OFF: Color32 = Color32::from_rgb(40, 12, 12);
|
||||||
|
/// Ceiling-hit tick marks along the top edge of the scope.
|
||||||
|
pub const CLIP: Color32 = Color32::from_rgb(235, 45, 45);
|
||||||
|
|
||||||
|
// Scope traces. The scope is tabbed — one channel at a time — so these never compete with a
|
||||||
|
// channel hue: the tab chrome says which channel, these three say what each line is.
|
||||||
|
pub const SIGNAL_IN: Color32 = Color32::from_rgb(90, 170, 235);
|
||||||
|
pub const SIGNAL_OUT: Color32 = Color32::from_rgb(90, 200, 110);
|
||||||
|
pub const SIGNAL_GR: Color32 = Color32::from_rgb(240, 150, 60);
|
||||||
|
|
||||||
|
/// The transfer curve in the gain-curve display, and its translucent fill.
|
||||||
|
pub const CURVE: Color32 = Color32::from_rgb(120, 200, 160);
|
||||||
|
/// The live operating-point dot riding that curve — near-white, so it reads on top of the fill.
|
||||||
|
pub const OPERATING_POINT: Color32 = Color32::from_rgb(235, 240, 235);
|
||||||
|
/// Threshold marker on the gain curve. Dim on purpose: a reference line, not data.
|
||||||
|
pub const THRESHOLD_LINE: Color32 = Color32::from_rgb(80, 60, 45);
|
||||||
|
|
||||||
|
// --- Neutrals --------------------------------------------------------------
|
||||||
|
// Eight ad-hoc greys collapsed to six named steps. If you need another, add it here rather than
|
||||||
|
// reaching for `from_gray` at a call site.
|
||||||
|
|
||||||
|
/// Data panels — the scope and the gain curve.
|
||||||
|
pub const SURFACE: Color32 = Color32::from_rgb(16, 16, 20);
|
||||||
|
/// The meter panel, one step lighter so the cluster reads as its own surface. These were two
|
||||||
|
/// different blacks by accident before; they are two on purpose now. Set this equal to
|
||||||
|
/// [`SURFACE`] if you would rather they sat flush.
|
||||||
|
pub const SURFACE_RAISED: Color32 = Color32::from_rgb(20, 20, 24);
|
||||||
|
/// The empty part of a meter bar.
|
||||||
|
pub const TRACK: Color32 = Color32::from_rgb(34, 34, 40);
|
||||||
|
|
||||||
|
/// Structural lines — the unity diagonal, panel edges.
|
||||||
|
pub const GRID: Color32 = Color32::from_gray(45);
|
||||||
|
/// Reference lines that should recede — dB gridlines, −6 dB marks.
|
||||||
|
pub const GRID_FAINT: Color32 = Color32::from_gray(38);
|
||||||
|
|
||||||
|
/// Axis numbers and other text that must not compete with the data.
|
||||||
|
pub const TEXT_DIM: Color32 = Color32::from_gray(90);
|
||||||
|
/// Body text — band names on the crossover strip.
|
||||||
|
pub const TEXT: Color32 = Color32::from_gray(160);
|
||||||
|
/// Emphasised text — channel labels, readouts.
|
||||||
|
pub const TEXT_BRIGHT: Color32 = Color32::from_gray(210);
|
||||||
|
/// Hover and drag feedback, where the point is to be unmistakable.
|
||||||
|
pub const TEXT_HOVER: Color32 = Color32::WHITE;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// TIER 2 — DERIVED. Generated from the seeds above; do not hand-edit.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// One channel's colours, all derived from a single hue.
|
||||||
|
pub struct Channel {
|
||||||
|
/// Background wash behind a channel's cell. Very dark — it must associate the bars drawn on
|
||||||
|
/// top of it with a channel without competing with them.
|
||||||
|
pub chrome: Color32,
|
||||||
|
/// Band strip on the crossover widget: between [`Channel::chrome`] and [`Channel::edge`].
|
||||||
|
pub strip: Color32,
|
||||||
|
/// Accent rule under a meter cell, and the scope's tab underline. Bright enough to identify
|
||||||
|
/// at a glance.
|
||||||
|
pub edge: Color32,
|
||||||
|
/// Full strength — drag handles and anything else the user grabs.
|
||||||
|
pub handle: Color32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a channel's palette from its hue.
|
||||||
|
///
|
||||||
|
/// The `(saturation, value)` pairs below decide how loudly channel identity reads. Raise
|
||||||
|
/// `CHROME`'s value and the cell washes get more obvious; lower it and they recede into the
|
||||||
|
/// panel background. `CHROME` is deliberately near the floor: it sits *behind* meter bars.
|
||||||
|
pub const fn channel(hue: f32) -> Channel {
|
||||||
|
const CHROME: (f32, f32) = (0.45, 0.115);
|
||||||
|
const STRIP: (f32, f32) = (0.42, 0.20);
|
||||||
|
const EDGE: (f32, f32) = (0.60, 0.55);
|
||||||
|
const HANDLE: (f32, f32) = (0.55, 0.90);
|
||||||
|
Channel {
|
||||||
|
chrome: hsv(hue, CHROME.0, CHROME.1),
|
||||||
|
strip: hsv(hue, STRIP.0, STRIP.1),
|
||||||
|
edge: hsv(hue, EDGE.0, EDGE.1),
|
||||||
|
handle: hsv(hue, HANDLE.0, HANDLE.1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Indexed by the `LOW`/`MID`/`HIGH`/`ALL` consts in `lib.rs`, so `CHANNELS[i]` lines up with
|
||||||
|
/// `meters.level_l[i]` and friends without a lookup step.
|
||||||
|
pub const CHANNELS: [Channel; 4] = [
|
||||||
|
channel(HUE_LOW),
|
||||||
|
channel(HUE_MID),
|
||||||
|
channel(HUE_HIGH),
|
||||||
|
channel(HUE_ALL),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// HSV to RGB, `const` so [`CHANNELS`] is built at compile time.
|
||||||
|
///
|
||||||
|
/// `h` in degrees (wrapped), `s` and `v` in `0..=1`. The standard sextant algorithm, written out
|
||||||
|
/// here only because `ecolor::Hsva`'s conversion is not a `const fn`.
|
||||||
|
pub const fn hsv(h: f32, s: f32, v: f32) -> Color32 {
|
||||||
|
// Wrap into 0..360. A loop rather than `%` because float remainder is not available in a
|
||||||
|
// const context on this toolchain; hues are small numbers, so this costs nothing.
|
||||||
|
let mut h = h;
|
||||||
|
while h < 0.0 {
|
||||||
|
h += 360.0;
|
||||||
|
}
|
||||||
|
while h >= 360.0 {
|
||||||
|
h -= 360.0;
|
||||||
|
}
|
||||||
|
let sector = h / 60.0;
|
||||||
|
let i = sector as i32;
|
||||||
|
let f = sector - i as f32;
|
||||||
|
let p = v * (1.0 - s);
|
||||||
|
let q = v * (1.0 - s * f);
|
||||||
|
let t = v * (1.0 - s * (1.0 - f));
|
||||||
|
let (r, g, b) = match i {
|
||||||
|
0 => (v, t, p),
|
||||||
|
1 => (q, v, p),
|
||||||
|
2 => (p, v, t),
|
||||||
|
3 => (p, q, v),
|
||||||
|
4 => (t, p, v),
|
||||||
|
_ => (v, p, q),
|
||||||
|
};
|
||||||
|
Color32::from_rgb(to_u8(r), to_u8(g), to_u8(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamping float-to-byte, `const` for the same reason as [`hsv`].
|
||||||
|
const fn to_u8(x: f32) -> u8 {
|
||||||
|
let v = x * 255.0 + 0.5;
|
||||||
|
if v <= 0.0 {
|
||||||
|
0
|
||||||
|
} else if v >= 255.0 {
|
||||||
|
255
|
||||||
|
} else {
|
||||||
|
v as u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meter bar colour for an output level in dB.
|
||||||
|
///
|
||||||
|
/// Functional, not per-channel: −6 dB means the same thing in every band. Thresholds and colours
|
||||||
|
/// are both literal so either can be tuned by eye.
|
||||||
|
pub fn level_color(db: f32) -> Color32 {
|
||||||
|
if db >= -1.0 {
|
||||||
|
LEVEL_HOT
|
||||||
|
} else if db >= -6.0 {
|
||||||
|
LEVEL_WARN
|
||||||
|
} else {
|
||||||
|
LEVEL_OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same colour at `a`/255 opacity, for translucent fills under a trace.
|
||||||
|
///
|
||||||
|
/// Not `const`: `Color32::from_rgba_unmultiplied` is not a `const fn`, and the premultiplied
|
||||||
|
/// constructor that is would push the multiply out to every call site.
|
||||||
|
pub fn with_alpha(c: Color32, a: u8) -> Color32 {
|
||||||
|
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the palette to egui's own widgets, so `ParamSlider`, labels and separators sit in the
|
||||||
|
/// same world as the hand-painted ones instead of on stock egui defaults.
|
||||||
|
///
|
||||||
|
/// Call once when the editor is created. Widget colours are per interaction state: `weak_bg_fill`
|
||||||
|
/// is an unfilled slider track, `bg_fill` the filled portion. Interactive states borrow the ALL
|
||||||
|
/// channel's hue, since egui widgets are not per-band — change that to a dedicated accent seed if
|
||||||
|
/// you would rather they were neutral.
|
||||||
|
pub fn apply_visuals(ctx: &nih_plug_egui::egui::Context) {
|
||||||
|
use nih_plug_egui::egui::{Stroke, Visuals};
|
||||||
|
|
||||||
|
let accent = &CHANNELS[3];
|
||||||
|
let mut v = Visuals::dark();
|
||||||
|
|
||||||
|
v.panel_fill = SURFACE;
|
||||||
|
v.window_fill = SURFACE;
|
||||||
|
v.extreme_bg_color = SURFACE;
|
||||||
|
v.faint_bg_color = SURFACE_RAISED;
|
||||||
|
|
||||||
|
let w = &mut v.widgets;
|
||||||
|
w.noninteractive.bg_fill = SURFACE_RAISED;
|
||||||
|
w.noninteractive.weak_bg_fill = SURFACE_RAISED;
|
||||||
|
w.noninteractive.bg_stroke = Stroke::new(1.0_f32, GRID_FAINT);
|
||||||
|
w.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||||
|
|
||||||
|
w.inactive.bg_fill = TRACK;
|
||||||
|
w.inactive.weak_bg_fill = TRACK;
|
||||||
|
w.inactive.bg_stroke = Stroke::new(1.0_f32, GRID);
|
||||||
|
w.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT_BRIGHT);
|
||||||
|
|
||||||
|
w.hovered.bg_fill = accent.edge;
|
||||||
|
w.hovered.weak_bg_fill = accent.strip;
|
||||||
|
w.hovered.bg_stroke = Stroke::new(1.0_f32, accent.edge);
|
||||||
|
w.hovered.fg_stroke = Stroke::new(1.0_f32, TEXT_HOVER);
|
||||||
|
|
||||||
|
w.active.bg_fill = accent.handle;
|
||||||
|
w.active.weak_bg_fill = accent.edge;
|
||||||
|
w.active.bg_stroke = Stroke::new(1.0_f32, accent.handle);
|
||||||
|
w.active.fg_stroke = Stroke::new(1.0_f32, TEXT_HOVER);
|
||||||
|
|
||||||
|
ctx.set_visuals(v);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector.
|
||||||
|
//!
|
||||||
|
//! Histories for all four channels run continuously (cheap), so switching tabs shows that
|
||||||
|
//! channel's existing history. It's fed by draining the audio thread's scope ring
|
||||||
|
//! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
|
||||||
|
//! rate rather than the editor frame rate. Buckets are folded into `PLOT_N` columns
|
||||||
|
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke};
|
||||||
|
|
||||||
|
use super::METER_FLOOR_DB;
|
||||||
|
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
||||||
|
use super::palette;
|
||||||
|
|
||||||
|
/// Height of the plot panel.
|
||||||
|
const PLOT_PANEL_H: f32 = 150.0;
|
||||||
|
/// Number of columns held in the history ring.
|
||||||
|
const PLOT_N: usize = 256;
|
||||||
|
|
||||||
|
const COLOR_IN: Color32 = palette::SIGNAL_IN;
|
||||||
|
const COLOR_OUT: Color32 = palette::SIGNAL_OUT;
|
||||||
|
const COLOR_GR: Color32 = palette::SIGNAL_GR;
|
||||||
|
|
||||||
|
/// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns.
|
||||||
|
struct PlotHistory {
|
||||||
|
in_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
out_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
gr_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
/// Per-column flag: the output limiter hit the ceiling somewhere in this column.
|
||||||
|
hit: [bool; PLOT_N],
|
||||||
|
write: usize,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlotHistory {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
||||||
|
out_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
||||||
|
gr_db: [[0.0; PLOT_N]; NUM_CHANNELS],
|
||||||
|
hit: [false; PLOT_N],
|
||||||
|
write: 0,
|
||||||
|
len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotHistory {
|
||||||
|
/// Append one column of (in_db, out_db, gr_db) per channel, plus the ceiling-hit flag.
|
||||||
|
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) {
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
self.in_db[i][self.write] = samples[i].0;
|
||||||
|
self.out_db[i][self.write] = samples[i].1;
|
||||||
|
self.gr_db[i][self.write] = samples[i].2;
|
||||||
|
}
|
||||||
|
self.hit[self.write] = hit;
|
||||||
|
self.write = (self.write + 1) % PLOT_N;
|
||||||
|
self.len = (self.len + 1).min(PLOT_N);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
|
||||||
|
/// column being assembled from drained buckets.
|
||||||
|
pub(super) struct PlotState {
|
||||||
|
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
|
||||||
|
pub(super) selected: usize,
|
||||||
|
history: PlotHistory,
|
||||||
|
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
|
||||||
|
window_s: f64,
|
||||||
|
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
|
||||||
|
cursor: Option<u64>,
|
||||||
|
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
||||||
|
col_acc: [(f32, f32, f32); NUM_CHANNELS],
|
||||||
|
/// Ceiling-hit flag accumulated for the column currently being built.
|
||||||
|
col_hit: bool,
|
||||||
|
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
|
||||||
|
col_fill: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlotState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
history: PlotHistory::default(),
|
||||||
|
window_s: 5.0,
|
||||||
|
cursor: None,
|
||||||
|
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
||||||
|
col_hit: false,
|
||||||
|
col_fill: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs
|
||||||
|
/// and flow-speed selector. History for all channels advances every frame regardless of the tab.
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||||
|
// Buckets that make up one column at the current flow speed (may be fractional).
|
||||||
|
let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
|
||||||
|
|
||||||
|
// Drain every bucket produced since the last frame (audio-clocked), folding them into columns.
|
||||||
|
// A fresh cursor starts at "now" so we don't replay stale buckets.
|
||||||
|
{
|
||||||
|
let w0 = meters.scope.write_index();
|
||||||
|
let cursor = state.cursor.get_or_insert(w0);
|
||||||
|
let history = &mut state.history;
|
||||||
|
let col_acc = &mut state.col_acc;
|
||||||
|
let col_hit = &mut state.col_hit;
|
||||||
|
let col_fill = &mut state.col_fill;
|
||||||
|
meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| {
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
col_acc[ch].0 = col_acc[ch].0.max(util::gain_to_db(in_lin[ch]));
|
||||||
|
col_acc[ch].1 = col_acc[ch].1.max(util::gain_to_db(out_lin[ch]));
|
||||||
|
col_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]);
|
||||||
|
}
|
||||||
|
*col_hit |= hit > 0.5;
|
||||||
|
*col_fill += 1.0;
|
||||||
|
while *col_fill >= buckets_per_col {
|
||||||
|
history.push(col_acc, *col_hit);
|
||||||
|
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
*col_hit = false;
|
||||||
|
*col_fill -= buckets_per_col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel tabs + flow-speed selector + legend.
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let speeds = [2.0f64, 5.0, 15.0, 45.0];
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Plot:");
|
||||||
|
for (i, l) in labels.iter().enumerate() {
|
||||||
|
ui.selectable_value(&mut state.selected, i, *l);
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Speed:");
|
||||||
|
let mut speed_changed = false;
|
||||||
|
for &w in &speeds {
|
||||||
|
if ui.selectable_value(&mut state.window_s, w, format!("{w:.0}s")).changed() {
|
||||||
|
speed_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if speed_changed {
|
||||||
|
// Cadence changed: start the history fresh so the time axis is consistent.
|
||||||
|
state.history = PlotHistory::default();
|
||||||
|
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
state.col_hit = false;
|
||||||
|
state.col_fill = 0.0;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.colored_label(COLOR_IN, "in");
|
||||||
|
ui.colored_label(COLOR_OUT, "out");
|
||||||
|
ui.colored_label(COLOR_GR, "GR");
|
||||||
|
});
|
||||||
|
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
|
||||||
|
|
||||||
|
let (top, bottom, left, right) =
|
||||||
|
(rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0);
|
||||||
|
let width = right - left;
|
||||||
|
let y_for_db = |db: f32| -> f32 {
|
||||||
|
let frac = ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
bottom - frac * (bottom - top)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gridlines (dB).
|
||||||
|
for &g in &[0.0f32, -12.0, -24.0, -48.0] {
|
||||||
|
let y = y_for_db(g);
|
||||||
|
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0_f32, palette::GRID_FAINT));
|
||||||
|
p.text(
|
||||||
|
pos2(left + 2.0, y),
|
||||||
|
Align2::LEFT_BOTTOM,
|
||||||
|
format!("{g:.0}"),
|
||||||
|
FontId::proportional(9.0),
|
||||||
|
palette::TEXT_DIM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = state.selected.min(NUM_CHANNELS - 1);
|
||||||
|
let (write, len) = (state.history.write, state.history.len);
|
||||||
|
if len >= 2 {
|
||||||
|
let draw_series = |series: &[f32; PLOT_N], to_db: &dyn Fn(f32) -> f32, color: Color32, fill: bool| {
|
||||||
|
let mut pts = Vec::with_capacity(len);
|
||||||
|
for k in 0..len {
|
||||||
|
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
||||||
|
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32; // newest hugs the right
|
||||||
|
pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx]))));
|
||||||
|
}
|
||||||
|
if fill {
|
||||||
|
// Fill as a strip of per-segment convex quads down to the baseline. A single
|
||||||
|
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
|
||||||
|
// triangles), so build convex pieces — one box per time unit — instead.
|
||||||
|
let fill_col = palette::with_alpha(color, 40);
|
||||||
|
for seg in pts.windows(2) {
|
||||||
|
let (a, b) = (seg[0], seg[1]);
|
||||||
|
p.add(egui::Shape::convex_polygon(
|
||||||
|
vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
|
||||||
|
fill_col,
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.add(egui::Shape::line(pts, Stroke::new(1.5_f32, color)));
|
||||||
|
};
|
||||||
|
draw_series(&state.history.in_db[c], &|db| db, COLOR_IN, true);
|
||||||
|
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
|
||||||
|
// GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline.
|
||||||
|
draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR, false);
|
||||||
|
|
||||||
|
// Ceiling-hit markers: a short red tick at the TOP for any column where the output limiter
|
||||||
|
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of
|
||||||
|
// hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise.
|
||||||
|
let dx = width / (PLOT_N - 1) as f32;
|
||||||
|
let marker = palette::CLIP;
|
||||||
|
for k in 0..len {
|
||||||
|
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
||||||
|
if state.history.hit[idx] {
|
||||||
|
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32;
|
||||||
|
let x = left + pos * width;
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + dx, top + 3.0)), CornerRadius::ZERO, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
mod dsp;
|
||||||
|
mod editor;
|
||||||
|
mod meters;
|
||||||
|
mod params;
|
||||||
|
|
||||||
|
use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
|
||||||
|
use dsp::crossover::Crossover;
|
||||||
|
use dsp::limiter::Limiter;
|
||||||
|
use meters::Meters;
|
||||||
|
use params::{build_settings, Bari206Params};
|
||||||
|
|
||||||
|
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
|
||||||
|
/// gain-gui example feel.)
|
||||||
|
const METER_DECAY_MS: f64 = 150.0;
|
||||||
|
|
||||||
|
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
||||||
|
const LOW: usize = 0;
|
||||||
|
const MID: usize = 1;
|
||||||
|
const HIGH: usize = 2;
|
||||||
|
const ALL: usize = 3;
|
||||||
|
|
||||||
|
/// RMS detector time constants, one per channel, indexed by the consts above.
|
||||||
|
///
|
||||||
|
/// Squaring the detector signal puts a component at twice the fundamental, so a one-pole that
|
||||||
|
/// does not span a cycle leaves the detector rippling and the gain intermodulating with the
|
||||||
|
/// bass. Measured peak-to-peak detector ripple at a 5 ms window was 4.4 dB at 30 Hz and 2.7 dB
|
||||||
|
/// at 50 Hz - squarely in the low band, whose crossover reaches down to 30 Hz. The cost of a
|
||||||
|
/// longer window is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so each
|
||||||
|
/// band gets only as much as its frequency range actually needs:
|
||||||
|
///
|
||||||
|
/// * LOW - 50 ms: 30 Hz ripple drops to 0.46 dB, and slow is the wanted behaviour down here.
|
||||||
|
/// * MID - 20 ms: enough for body without dulling the band that carries most transients.
|
||||||
|
/// * HIGH - 10 ms: nothing up here ripples, so keep it responsive.
|
||||||
|
/// * ALL - 35 ms: full-band, so it must cover the low content too.
|
||||||
|
const RMS_WINDOW_MS: [f32; 4] = [50.0, 20.0, 10.0, 35.0];
|
||||||
|
|
||||||
|
/// Bari 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
||||||
|
///
|
||||||
|
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
||||||
|
/// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
|
||||||
|
/// driven by the 'All' channel (the crossover sums flat).
|
||||||
|
struct Bari206 {
|
||||||
|
params: Arc<Bari206Params>,
|
||||||
|
sample_rate: f32,
|
||||||
|
crossover: Crossover,
|
||||||
|
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
|
||||||
|
comps: [Compressor; 4],
|
||||||
|
/// Output brickwall limiter (final stage).
|
||||||
|
limiter: Limiter,
|
||||||
|
/// Lock-free meter state shared with the editor.
|
||||||
|
meters: Arc<Meters>,
|
||||||
|
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to
|
||||||
|
/// the block length when applied once per block in `process`).
|
||||||
|
meter_decay_weight: f32,
|
||||||
|
|
||||||
|
/// Per-channel max accumulators for the plot bucket currently being built (in/out linear, GR
|
||||||
|
/// dB). Persist across blocks since a bucket spans many samples.
|
||||||
|
scope_in: [f32; 4],
|
||||||
|
scope_out: [f32; 4],
|
||||||
|
scope_gr: [f32; 4],
|
||||||
|
/// Max output-limiter gain reduction seen in the current bucket (for the ceiling-hit marker).
|
||||||
|
scope_hit: f32,
|
||||||
|
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
|
||||||
|
scope_samples: usize,
|
||||||
|
scope_bucket_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
|
||||||
|
const CEILING_HIT_GR_DB: f32 = 0.1;
|
||||||
|
|
||||||
|
impl Default for Bari206 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
params: Arc::new(Bari206Params::default()),
|
||||||
|
sample_rate: 48_000.0,
|
||||||
|
crossover: Crossover::new(),
|
||||||
|
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
||||||
|
limiter: Limiter::new(),
|
||||||
|
meters: Arc::new(Meters::default()),
|
||||||
|
meter_decay_weight: 1.0,
|
||||||
|
scope_in: [0.0; 4],
|
||||||
|
scope_out: [0.0; 4],
|
||||||
|
scope_gr: [0.0; 4],
|
||||||
|
scope_hit: 0.0,
|
||||||
|
scope_samples: 0,
|
||||||
|
scope_bucket_len: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bari206 {
|
||||||
|
fn lookahead_samples(&self) -> usize {
|
||||||
|
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for Bari206 {
|
||||||
|
const NAME: &'static str = "Bari 206";
|
||||||
|
const VENDOR: &'static str = "Novoyuuparosk";
|
||||||
|
const URL: &'static str = env!("CARGO_PKG_HOMEPAGE");
|
||||||
|
const EMAIL: &'static str = "mikkeli@novoyuuparosk.org";
|
||||||
|
|
||||||
|
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 MIDI_INPUT: MidiConfig = MidiConfig::None;
|
||||||
|
const MIDI_OUTPUT: MidiConfig = MidiConfig::None;
|
||||||
|
|
||||||
|
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
|
||||||
|
|
||||||
|
type SysExMessage = ();
|
||||||
|
type BackgroundTask = ();
|
||||||
|
|
||||||
|
fn params(&self) -> Arc<dyn Params> {
|
||||||
|
self.params.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||||
|
editor::create(self.params.clone(), self.meters.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize(
|
||||||
|
&mut self,
|
||||||
|
audio_io_layout: &AudioIOLayout,
|
||||||
|
buffer_config: &BufferConfig,
|
||||||
|
context: &mut impl InitContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
self.sample_rate = buffer_config.sample_rate;
|
||||||
|
let channels = audio_io_layout
|
||||||
|
.main_output_channels
|
||||||
|
.map(NonZeroU32::get)
|
||||||
|
.unwrap_or(2) as usize;
|
||||||
|
|
||||||
|
// Per-block decay so the meters fall ~12 dB over METER_DECAY_MS of silence.
|
||||||
|
self.meter_decay_weight =
|
||||||
|
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
|
||||||
|
|
||||||
|
// Plot bucket length: emit a scope bucket every ~1/BUCKET_HZ seconds.
|
||||||
|
self.scope_bucket_len =
|
||||||
|
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
|
||||||
|
|
||||||
|
for (i, comp) in self.comps.iter_mut().enumerate() {
|
||||||
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS, RMS_WINDOW_MS[i]);
|
||||||
|
}
|
||||||
|
self.crossover.prepare(channels);
|
||||||
|
self.crossover.update(
|
||||||
|
self.sample_rate,
|
||||||
|
self.params.crossover_low_hz.value(),
|
||||||
|
self.params.crossover_high_hz.value(),
|
||||||
|
);
|
||||||
|
self.limiter.prepare(self.sample_rate, channels);
|
||||||
|
|
||||||
|
// Three series stages each with a fixed look-ahead delay: the bands, the 'All' channel,
|
||||||
|
// and the output limiter. Reported once as a constant; see the compressor look-ahead note.
|
||||||
|
let total_latency =
|
||||||
|
self.comps[LOW].latency() + self.comps[ALL].latency() + self.limiter.latency();
|
||||||
|
context.set_latency_samples(total_latency);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.crossover.reset();
|
||||||
|
for comp in &mut self.comps {
|
||||||
|
comp.reset();
|
||||||
|
}
|
||||||
|
self.limiter.reset();
|
||||||
|
// Transport restart / sample-rate change: drop stale meter values to silence and discard
|
||||||
|
// the in-flight plot bucket.
|
||||||
|
self.meters.clear();
|
||||||
|
self.scope_in = [0.0; 4];
|
||||||
|
self.scope_out = [0.0; 4];
|
||||||
|
self.scope_gr = [0.0; 4];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
|
self.scope_samples = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(
|
||||||
|
&mut self,
|
||||||
|
buffer: &mut Buffer,
|
||||||
|
_aux: &mut AuxiliaryBuffers,
|
||||||
|
context: &mut impl ProcessContext<Self>,
|
||||||
|
) -> ProcessStatus {
|
||||||
|
let lookahead = self.lookahead_samples();
|
||||||
|
|
||||||
|
// Crossover coefficients track the frequency params (recomputed per block — cheap).
|
||||||
|
self.crossover.update(
|
||||||
|
self.sample_rate,
|
||||||
|
self.params.crossover_low_hz.value(),
|
||||||
|
self.params.crossover_high_hz.value(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Block-rate settings for the three bands + the 'All' channel.
|
||||||
|
let band_params = [&self.params.low, &self.params.mid, &self.params.high];
|
||||||
|
let mut band_set = [
|
||||||
|
build_settings(&self.params.low, lookahead, self.sample_rate),
|
||||||
|
build_settings(&self.params.mid, lookahead, self.sample_rate),
|
||||||
|
build_settings(&self.params.high, lookahead, self.sample_rate),
|
||||||
|
];
|
||||||
|
let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate);
|
||||||
|
|
||||||
|
// Output limiter settings (block-rate).
|
||||||
|
let ceiling = util::db_to_gain(self.params.output_ceiling_db.value());
|
||||||
|
let limiter_release =
|
||||||
|
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
|
||||||
|
|
||||||
|
// Only do the (cheap) metering work when the editor is actually open.
|
||||||
|
let metering = self.params.editor_state.is_open();
|
||||||
|
// The host keeps calling process() with silence while stopped/paused (FL does), so the
|
||||||
|
// scope is gated on the transport actually playing — otherwise it would scroll silence.
|
||||||
|
let playing = context.transport().playing;
|
||||||
|
let num_samples = buffer.samples();
|
||||||
|
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input level (detector / gain-curve x)
|
||||||
|
let mut gr = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut lim_gr = 0.0f32;
|
||||||
|
|
||||||
|
// Params only change on block boundaries here (we do not split blocks on events), so a
|
||||||
|
// smoother that is idle now stays idle for this whole block. When every smoother feeding
|
||||||
|
// a channel is idle - the overwhelming common case, i.e. nobody is touching a knob -
|
||||||
|
// its per-sample values are constants, so lift them out of the sample loop entirely.
|
||||||
|
// That removes 5 Smoother::next() calls and a db_to_gain per channel per sample.
|
||||||
|
let chan_static = |p: ¶ms::CompressorParams| {
|
||||||
|
!p.pre_gain_db.smoothed.is_smoothing()
|
||||||
|
&& !p.makeup_db.smoothed.is_smoothing()
|
||||||
|
&& !p.mix.smoothed.is_smoothing()
|
||||||
|
&& !p.low_slope.smoothed.is_smoothing()
|
||||||
|
&& !p.low_curve.smoothed.is_smoothing()
|
||||||
|
};
|
||||||
|
let band_static = [
|
||||||
|
chan_static(&self.params.low),
|
||||||
|
chan_static(&self.params.mid),
|
||||||
|
chan_static(&self.params.high),
|
||||||
|
];
|
||||||
|
let all_static = chan_static(&self.params.all);
|
||||||
|
let mut band_pre = [1.0f32; 3];
|
||||||
|
for b in 0..3 {
|
||||||
|
if band_static[b] {
|
||||||
|
band_pre[b] = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
|
||||||
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
||||||
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
||||||
|
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
|
||||||
|
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut all_pre_static = 1.0f32;
|
||||||
|
if all_static {
|
||||||
|
all_pre_static = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
|
||||||
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||||
|
all_set.mix = self.params.all.mix.smoothed.next();
|
||||||
|
all_set.low_slope = self.params.all.low_slope.smoothed.next();
|
||||||
|
all_set.low_curve = self.params.all.low_curve.smoothed.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut in_frame = [0.0f32; 2];
|
||||||
|
let mut band_in = [[0.0f32; 2]; 3];
|
||||||
|
let mut band_out = [[0.0f32; 2]; 3];
|
||||||
|
let mut summed = [0.0f32; 2];
|
||||||
|
let mut out_frame = [0.0f32; 2];
|
||||||
|
let mut lim_frame = [0.0f32; 2];
|
||||||
|
|
||||||
|
for mut frame in buffer.iter_samples() {
|
||||||
|
let n = frame.len().min(2);
|
||||||
|
let r = (n - 1).min(1); // right-channel index (== left when mono)
|
||||||
|
for ch in 0..n {
|
||||||
|
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split each channel into low/mid/high.
|
||||||
|
for ch in 0..n {
|
||||||
|
let [lo, mid, hi] = self.crossover.split(ch, in_frame[ch]);
|
||||||
|
band_in[LOW][ch] = lo;
|
||||||
|
band_in[MID][ch] = mid;
|
||||||
|
band_in[HIGH][ch] = hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
|
||||||
|
summed[..n].fill(0.0);
|
||||||
|
for b in 0..3 {
|
||||||
|
// `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
|
||||||
|
// never reaches, in exchange for a cheaper exp() instead of a general powf().
|
||||||
|
let pre = if band_static[b] {
|
||||||
|
band_pre[b]
|
||||||
|
} else {
|
||||||
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
||||||
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
||||||
|
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
|
||||||
|
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
|
||||||
|
util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next())
|
||||||
|
};
|
||||||
|
for ch in 0..n {
|
||||||
|
band_in[b][ch] *= pre;
|
||||||
|
}
|
||||||
|
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
||||||
|
for ch in 0..n {
|
||||||
|
summed[ch] += band_out[b][ch];
|
||||||
|
}
|
||||||
|
if metering {
|
||||||
|
let in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
|
||||||
|
let out_l = band_out[b][0].abs();
|
||||||
|
let out_r = band_out[b][r].abs();
|
||||||
|
// Wet gain reduction (what the comp computes), independent of the mix.
|
||||||
|
let g = self.comps[b].gain_reduction_db();
|
||||||
|
inp[b] = inp[b].max(in_mono);
|
||||||
|
lvl_l[b] = lvl_l[b].max(out_l);
|
||||||
|
lvl_r[b] = lvl_r[b].max(out_r);
|
||||||
|
gr[b] = gr[b].max(g);
|
||||||
|
if playing {
|
||||||
|
self.scope_in[b] = self.scope_in[b].max(in_mono);
|
||||||
|
self.scope_out[b] = self.scope_out[b].max(out_l.max(out_r));
|
||||||
|
self.scope_gr[b] = self.scope_gr[b].max(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'All' aggregate channel over the summed bands (driven before its compressor).
|
||||||
|
let all_pre = if all_static {
|
||||||
|
all_pre_static
|
||||||
|
} else {
|
||||||
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||||
|
all_set.mix = self.params.all.mix.smoothed.next();
|
||||||
|
all_set.low_slope = self.params.all.low_slope.smoothed.next();
|
||||||
|
all_set.low_curve = self.params.all.low_curve.smoothed.next();
|
||||||
|
util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next())
|
||||||
|
};
|
||||||
|
for ch in 0..n {
|
||||||
|
summed[ch] *= all_pre;
|
||||||
|
}
|
||||||
|
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||||
|
|
||||||
|
// Output brickwall limiter.
|
||||||
|
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
|
||||||
|
|
||||||
|
if metering {
|
||||||
|
let in_mono = summed[0].abs().max(summed[r].abs());
|
||||||
|
let out_l = out_frame[0].abs();
|
||||||
|
let out_r = out_frame[r].abs();
|
||||||
|
let g = self.comps[ALL].gain_reduction_db();
|
||||||
|
inp[ALL] = inp[ALL].max(in_mono);
|
||||||
|
lvl_l[ALL] = lvl_l[ALL].max(out_l);
|
||||||
|
lvl_r[ALL] = lvl_r[ALL].max(out_r);
|
||||||
|
gr[ALL] = gr[ALL].max(g);
|
||||||
|
lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
|
||||||
|
|
||||||
|
// Only advance the scope while the transport is playing, so it freezes (rather than
|
||||||
|
// scrolling silence) when the host is paused/stopped but still calling process().
|
||||||
|
if playing {
|
||||||
|
self.scope_in[ALL] = self.scope_in[ALL].max(in_mono);
|
||||||
|
self.scope_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r));
|
||||||
|
self.scope_gr[ALL] = self.scope_gr[ALL].max(g);
|
||||||
|
self.scope_hit = self.scope_hit.max(self.limiter.gain_reduction_db());
|
||||||
|
|
||||||
|
// Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ).
|
||||||
|
self.scope_samples += 1;
|
||||||
|
if self.scope_samples >= self.scope_bucket_len {
|
||||||
|
let hit = if self.scope_hit > CEILING_HIT_GR_DB { 1.0 } else { 0.0 };
|
||||||
|
self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr, hit);
|
||||||
|
self.scope_in = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_out = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_gr = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
|
self.scope_samples = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ch in 0..n {
|
||||||
|
*frame.get_mut(ch).unwrap() = lim_frame[ch];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish one decimated value per meter for this block. The decay weight is per-sample,
|
||||||
|
// so raise it to the block length to keep the fall time constant independent of buffer size
|
||||||
|
// (we apply it once per block, not once per sample).
|
||||||
|
if metering {
|
||||||
|
let w = self.meter_decay_weight.powi(num_samples as i32);
|
||||||
|
for i in 0..meters::NUM_CHANNELS {
|
||||||
|
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w);
|
||||||
|
meters::decay_store(&self.meters.level_r[i], lvl_r[i], w);
|
||||||
|
meters::decay_store(&self.meters.input_level[i], inp[i], w);
|
||||||
|
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
|
||||||
|
}
|
||||||
|
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessStatus::Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClapPlugin for Bari206 {
|
||||||
|
const CLAP_ID: &'static str = "org.novoyuuparosk.bari-206";
|
||||||
|
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||||
|
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
|
||||||
|
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
|
||||||
|
const CLAP_SUPPORT_URL: Option<&'static str> = None;
|
||||||
|
const CLAP_FEATURES: &'static [ClapFeature] = &[
|
||||||
|
ClapFeature::AudioEffect,
|
||||||
|
ClapFeature::Stereo,
|
||||||
|
ClapFeature::Mono,
|
||||||
|
ClapFeature::Compressor,
|
||||||
|
ClapFeature::Limiter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vst3Plugin for Bari206 {
|
||||||
|
const VST3_CLASS_ID: [u8; 16] = *b"Bari206Multiband";
|
||||||
|
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
|
||||||
|
&[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics];
|
||||||
|
}
|
||||||
|
|
||||||
|
nih_export_clap!(Bari206);
|
||||||
|
nih_export_vst3!(Bari206);
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Lock-free meter state shared from the audio thread to the editor.
|
||||||
|
//!
|
||||||
|
//! Two feeds, both written by `process()` (single producer) and read by the editor (single
|
||||||
|
//! consumer), all wait-free:
|
||||||
|
//!
|
||||||
|
//! * **Bar meters** — decayed scalars per channel ([`Meters::level_l`] etc.), one store per block.
|
||||||
|
//! * **Scrolling plot** — a [`ScopeRing`] of raw buckets clocked at [`BUCKET_HZ`] (independent of
|
||||||
|
//! the GUI frame rate), so the plot's horizontal resolution isn't capped by the ~60 fps repaint.
|
||||||
|
|
||||||
|
use nih_plug::prelude::AtomicF32;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
|
||||||
|
pub const NUM_CHANNELS: usize = 4;
|
||||||
|
|
||||||
|
/// Rate the audio thread emits plot buckets at (Hz). Sets the plot's max horizontal resolution,
|
||||||
|
/// decoupled from the editor frame rate. ~5 ms per bucket.
|
||||||
|
pub const BUCKET_HZ: u32 = 200;
|
||||||
|
|
||||||
|
/// Buckets buffered between GUI drains. At [`BUCKET_HZ`] this is ~2.5 s of slack — far more than
|
||||||
|
/// the frame interval needs; if the GUI ever stalls longer, the oldest buckets are dropped.
|
||||||
|
const RING_N: usize = 512;
|
||||||
|
|
||||||
|
pub struct Meters {
|
||||||
|
/// Left output level per channel as a **linear** peak. Peak-with-decay.
|
||||||
|
pub level_l: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Right output level per channel (== left for mono signals).
|
||||||
|
pub level_r: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Mono **input** level per channel (post pre-gain = what the compressor detects). Drives the
|
||||||
|
/// gain-curve operating-point fill. Peak-with-decay.
|
||||||
|
pub input_level: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
|
||||||
|
/// stereo-linked, so the same gain applies to both channels.
|
||||||
|
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Output limiter gain reduction in **dB (>= 0)** — feeds the ALL channel's ceiling lamp.
|
||||||
|
pub limiter_gr_db: AtomicF32,
|
||||||
|
/// Bucket stream feeding the scrolling in/out/GR plot.
|
||||||
|
pub scope: ScopeRing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Meters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
level_r: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
input_level: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
limiter_gr_db: AtomicF32::new(0.0),
|
||||||
|
scope: ScopeRing::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Meters {
|
||||||
|
/// Zero the bar meters. Called from the plugin's `reset()` (transport restart / sample-rate
|
||||||
|
/// change) so the bars start from silence. The plot ring is left alone — it's continuous and
|
||||||
|
/// reflects the new (silent) buckets as they arrive. Real-time safe.
|
||||||
|
pub fn clear(&self) {
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
self.level_l[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.level_r[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.input_level[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
self.limiter_gr_db.store(0.0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a
|
||||||
|
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
|
||||||
|
/// from flickering while staying responsive to transients.
|
||||||
|
pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
|
||||||
|
let current = meter.load(Ordering::Relaxed);
|
||||||
|
let next = if block_value > current {
|
||||||
|
block_value
|
||||||
|
} else {
|
||||||
|
current * decay_weight + block_value * (1.0 - decay_weight)
|
||||||
|
};
|
||||||
|
meter.store(next, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-free single-producer/single-consumer ring of plot buckets. Each bucket holds a per-channel
|
||||||
|
/// (input level, output level, gain reduction) triple. The producer (audio thread) appends with
|
||||||
|
/// [`push`](ScopeRing::push); the consumer (GUI) reads new buckets with [`drain`](ScopeRing::drain),
|
||||||
|
/// tracking its own cursor. Per-field atomics avoid tearing; the consumer leaves one slot of margin
|
||||||
|
/// from the slot being written, so it never races the producer. If the consumer falls more than the
|
||||||
|
/// ring behind, the oldest buckets are silently dropped (a visual gap at worst).
|
||||||
|
pub struct ScopeRing {
|
||||||
|
/// `slot * NUM_CHANNELS + ch`, indexed by `bucket_index % RING_N`.
|
||||||
|
in_lin: Vec<AtomicF32>,
|
||||||
|
out_lin: Vec<AtomicF32>,
|
||||||
|
gr_db: Vec<AtomicF32>,
|
||||||
|
/// Per-bucket (not per-channel) flag: `1.0` if the output limiter hit the ceiling in this
|
||||||
|
/// bucket, else `0.0`. Indexed by `bucket_index % RING_N`.
|
||||||
|
hit: Vec<AtomicF32>,
|
||||||
|
/// Monotonic count of buckets ever written.
|
||||||
|
write: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScopeRing {
|
||||||
|
fn default() -> Self {
|
||||||
|
let make = || (0..RING_N * NUM_CHANNELS).map(|_| AtomicF32::new(0.0)).collect();
|
||||||
|
Self {
|
||||||
|
in_lin: make(),
|
||||||
|
out_lin: make(),
|
||||||
|
gr_db: make(),
|
||||||
|
hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(),
|
||||||
|
write: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeRing {
|
||||||
|
/// Producer (audio thread): append one bucket of per-channel (in_lin, out_lin, gr_db).
|
||||||
|
pub fn push(
|
||||||
|
&self,
|
||||||
|
in_lin: &[f32; NUM_CHANNELS],
|
||||||
|
out_lin: &[f32; NUM_CHANNELS],
|
||||||
|
gr_db: &[f32; NUM_CHANNELS],
|
||||||
|
hit: f32,
|
||||||
|
) {
|
||||||
|
let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write`
|
||||||
|
let slot = w as usize % RING_N;
|
||||||
|
let base = slot * NUM_CHANNELS;
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed);
|
||||||
|
self.out_lin[base + ch].store(out_lin[ch], Ordering::Relaxed);
|
||||||
|
self.gr_db[base + ch].store(gr_db[ch], Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
self.hit[slot].store(hit, Ordering::Relaxed);
|
||||||
|
// Publish the bucket: the Release pairs with the consumer's Acquire so the stores above are
|
||||||
|
// visible before the new count.
|
||||||
|
self.write.store(w + 1, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer (GUI): call `on_bucket` for each bucket in `*cursor..write`, advancing `cursor`.
|
||||||
|
/// Skips ahead (dropping oldest) if the consumer fell more than the ring behind.
|
||||||
|
pub fn drain(
|
||||||
|
&self,
|
||||||
|
cursor: &mut u64,
|
||||||
|
mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], f32),
|
||||||
|
) {
|
||||||
|
let w = self.write.load(Ordering::Acquire);
|
||||||
|
if *cursor > w {
|
||||||
|
*cursor = w; // counter went backwards (shouldn't happen) — resync
|
||||||
|
}
|
||||||
|
// Stay one slot clear of the slot currently being written.
|
||||||
|
let oldest = w.saturating_sub((RING_N - 1) as u64);
|
||||||
|
if *cursor < oldest {
|
||||||
|
*cursor = oldest;
|
||||||
|
}
|
||||||
|
let mut in_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
let mut out_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
let mut gr_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
while *cursor < w {
|
||||||
|
let slot = *cursor as usize % RING_N;
|
||||||
|
let base = slot * NUM_CHANNELS;
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed);
|
||||||
|
out_buf[ch] = self.out_lin[base + ch].load(Ordering::Relaxed);
|
||||||
|
gr_buf[ch] = self.gr_db[base + ch].load(Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let hit = self.hit[slot].load(Ordering::Relaxed);
|
||||||
|
on_bucket(&in_buf, &out_buf, &gr_buf, hit);
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current write high-water mark (for a fresh consumer to start from "now").
|
||||||
|
pub fn write_index(&self) -> u64 {
|
||||||
|
self.write.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//! Plugin parameters and their layout.
|
||||||
|
//!
|
||||||
|
//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All'
|
||||||
|
//! aggregate channel). `build_settings` translates a channel's params into the per-block
|
||||||
|
//! [`CompressorSettings`] the DSP consumes.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::EguiState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||||
|
|
||||||
|
/// Level-detection mode for a compressor's detector.
|
||||||
|
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||||
|
pub enum DetectionMode {
|
||||||
|
#[id = "peak"]
|
||||||
|
#[name = "Peak"]
|
||||||
|
Peak,
|
||||||
|
#[id = "rms"]
|
||||||
|
#[name = "RMS"]
|
||||||
|
Rms,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Params)]
|
||||||
|
pub struct Bari206Params {
|
||||||
|
#[persist = "editor-state"]
|
||||||
|
pub editor_state: Arc<EguiState>,
|
||||||
|
|
||||||
|
/// Low/Mid crossover frequency.
|
||||||
|
#[id = "xover_lo"]
|
||||||
|
pub crossover_low_hz: FloatParam,
|
||||||
|
/// Mid/High crossover frequency.
|
||||||
|
#[id = "xover_hi"]
|
||||||
|
pub crossover_high_hz: FloatParam,
|
||||||
|
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
||||||
|
#[id = "lookahead"]
|
||||||
|
pub look_ahead_ms: FloatParam,
|
||||||
|
|
||||||
|
/// Output brickwall ceiling (the limiter never lets output exceed this).
|
||||||
|
#[id = "ceiling"]
|
||||||
|
pub output_ceiling_db: FloatParam,
|
||||||
|
/// Output limiter release time.
|
||||||
|
#[id = "lim_rel"]
|
||||||
|
pub limiter_release_ms: FloatParam,
|
||||||
|
|
||||||
|
#[nested(id_prefix = "low", group = "Low")]
|
||||||
|
pub low: CompressorParams,
|
||||||
|
#[nested(id_prefix = "mid", group = "Mid")]
|
||||||
|
pub mid: CompressorParams,
|
||||||
|
#[nested(id_prefix = "high", group = "High")]
|
||||||
|
pub high: CompressorParams,
|
||||||
|
#[nested(id_prefix = "all", group = "All")]
|
||||||
|
pub all: CompressorParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Params)]
|
||||||
|
pub struct CompressorParams {
|
||||||
|
/// Drive into the compressor: scales the signal **before** detection, so it both pushes the
|
||||||
|
/// channel further into compression and feeds the downstream sum/limiter harder. Combined with
|
||||||
|
/// makeup (post-comp), this gives full per-channel input/output gain-staging.
|
||||||
|
#[id = "pregain"]
|
||||||
|
pub pre_gain_db: FloatParam,
|
||||||
|
#[id = "detect"]
|
||||||
|
pub detection: EnumParam<DetectionMode>,
|
||||||
|
#[id = "thresh"]
|
||||||
|
pub threshold_db: FloatParam,
|
||||||
|
#[id = "ratio"]
|
||||||
|
pub ratio: FloatParam,
|
||||||
|
#[id = "knee"]
|
||||||
|
pub knee_db: FloatParam,
|
||||||
|
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
||||||
|
#[id = "lowslope"]
|
||||||
|
pub low_slope: FloatParam,
|
||||||
|
/// Low shaper curvature (−1..1): bipolar mid-bulge, 0 = straight. +bulges up (boost quiet
|
||||||
|
/// middle), − bulges down (suppress). Endpoints (silence + knee) stay fixed.
|
||||||
|
#[id = "lowcurve"]
|
||||||
|
pub low_curve: FloatParam,
|
||||||
|
#[id = "attack"]
|
||||||
|
pub attack_ms: FloatParam,
|
||||||
|
#[id = "release"]
|
||||||
|
pub release_ms: FloatParam,
|
||||||
|
#[id = "makeup"]
|
||||||
|
pub makeup_db: FloatParam,
|
||||||
|
/// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass).
|
||||||
|
#[id = "mix"]
|
||||||
|
pub mix: FloatParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Bari206Params {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
editor_state: EguiState::from_size(760, 520),
|
||||||
|
|
||||||
|
crossover_low_hz: FloatParam::new(
|
||||||
|
"Crossover Lo/Mid",
|
||||||
|
200.0,
|
||||||
|
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||||
|
|
||||||
|
crossover_high_hz: FloatParam::new(
|
||||||
|
"Crossover Mid/Hi",
|
||||||
|
2_500.0,
|
||||||
|
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||||
|
|
||||||
|
look_ahead_ms: FloatParam::new(
|
||||||
|
"Look-ahead",
|
||||||
|
2.0,
|
||||||
|
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
output_ceiling_db: FloatParam::new(
|
||||||
|
"Ceiling",
|
||||||
|
0.0,
|
||||||
|
FloatRange::Linear { min: -24.0, max: 0.0 },
|
||||||
|
)
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
limiter_release_ms: FloatParam::new(
|
||||||
|
"Limiter Release",
|
||||||
|
100.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
low: CompressorParams::default(),
|
||||||
|
mid: CompressorParams::default(),
|
||||||
|
high: CompressorParams::default(),
|
||||||
|
all: CompressorParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompressorParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pre_gain_db: FloatParam::new(
|
||||||
|
"Pre-gain",
|
||||||
|
0.0,
|
||||||
|
FloatRange::Linear { min: -24.0, max: 36.0 },
|
||||||
|
)
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
detection: EnumParam::new("Detection", DetectionMode::Peak),
|
||||||
|
|
||||||
|
threshold_db: FloatParam::new(
|
||||||
|
"Threshold",
|
||||||
|
-18.0,
|
||||||
|
FloatRange::Linear { min: -60.0, max: 0.0 },
|
||||||
|
)
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
ratio: FloatParam::new(
|
||||||
|
"Ratio",
|
||||||
|
2.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
||||||
|
.with_string_to_value(Arc::new(|s| {
|
||||||
|
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||||
|
})),
|
||||||
|
|
||||||
|
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 30.0 })
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
low_slope: FloatParam::new(
|
||||||
|
"Low Slope",
|
||||||
|
1.0,
|
||||||
|
FloatRange::Skewed { min: 0.5, max: 3.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
low_curve: FloatParam::new("Low Curve", 0.0, FloatRange::Linear { min: -1.0, max: 1.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_percentage()),
|
||||||
|
|
||||||
|
attack_ms: FloatParam::new(
|
||||||
|
"Attack",
|
||||||
|
10.0,
|
||||||
|
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
release_ms: FloatParam::new(
|
||||||
|
"Release",
|
||||||
|
100.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -24.0, max: 24.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
mix: FloatParam::new("Mix", 1.0, FloatRange::Linear { min: 0.0, max: 1.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_percentage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
||||||
|
pub fn build_settings(
|
||||||
|
p: &CompressorParams,
|
||||||
|
lookahead: usize,
|
||||||
|
sample_rate: f32,
|
||||||
|
) -> CompressorSettings {
|
||||||
|
CompressorSettings {
|
||||||
|
threshold_db: p.threshold_db.value(),
|
||||||
|
ratio: p.ratio.value(),
|
||||||
|
knee_db: p.knee_db.value(),
|
||||||
|
low_slope: p.low_slope.value(),
|
||||||
|
low_curve: p.low_curve.value(),
|
||||||
|
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
||||||
|
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
||||||
|
makeup_db: 0.0,
|
||||||
|
lookahead_samples: lookahead,
|
||||||
|
use_rms: p.detection.value() == DetectionMode::Rms,
|
||||||
|
mix: p.mix.value(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+355
@@ -0,0 +1,355 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# brand-plugin.sh - rename this plugin, everywhere, in one go.
|
||||||
|
#
|
||||||
|
# check-branding.sh tells you what disagrees. This changes it. They share
|
||||||
|
# branding.conf: this script rewrites that file and every copy of the old values
|
||||||
|
# in tracked files, then runs the checker and reports what it says. A run that
|
||||||
|
# ends with the checker green is a rename that landed.
|
||||||
|
#
|
||||||
|
# Dry run by default:
|
||||||
|
# sh brand-plugin.sh --display "Bari 206"
|
||||||
|
# sh brand-plugin.sh --display "Bari 206" --vendor Novoyuuparosk --apply
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --display NAME the product name, with spaces and capitals. Everything
|
||||||
|
# else that can be derived, is derived from this.
|
||||||
|
# --pkg NAME override the derived Cargo package name.
|
||||||
|
# --vendor NAME --email ADDR --homepage URL
|
||||||
|
# --description D --clap-description D
|
||||||
|
# --struct NAME override the derived Rust type name.
|
||||||
|
# --allow-identity-change permit CLAP_ID / VST3_CLASS_ID to move. See below.
|
||||||
|
# --apply actually write. Without it, nothing is modified.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
# Resolve the repo root: these live in tools/, every path they touch is
|
||||||
|
# repo-relative, and they must behave the same wherever they are invoked from.
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
[ -f tools/branding.conf ] || { printf 'tools/branding.conf not found\n' >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./tools/branding.conf
|
||||||
|
|
||||||
|
OLD_PKG="$PKG"; OLD_DISPLAY="$DISPLAY"; OLD_PLUGIN_NAME="$PLUGIN_NAME"
|
||||||
|
OLD_VENDOR="$VENDOR"; OLD_EMAIL="$EMAIL"; OLD_HOMEPAGE="$HOMEPAGE"; OLD_AUTHOR="$AUTHOR"
|
||||||
|
OLD_DESCRIPTION="$DESCRIPTION"; OLD_CLAP_DESCRIPTION="$CLAP_DESCRIPTION"
|
||||||
|
OLD_CLAP_ID="$CLAP_ID"; OLD_VST3="$VST3_CLASS_ID"
|
||||||
|
OLD_STRUCT="$STRUCT"; OLD_PARAMS_STRUCT="$PARAMS_STRUCT"
|
||||||
|
|
||||||
|
new_display=""; new_pkg=""; new_vendor=""; new_email=""; new_homepage=""
|
||||||
|
new_description=""; new_clap_description=""; new_struct=""; new_author=""
|
||||||
|
new_clap_id=""; new_vst3=""; apply=0; allow_identity=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--display) new_display="${2:?}"; shift 2 ;;
|
||||||
|
--pkg) new_pkg="${2:?}"; shift 2 ;;
|
||||||
|
--vendor) new_vendor="${2:?}"; shift 2 ;;
|
||||||
|
--author) new_author="${2:?}"; shift 2 ;;
|
||||||
|
--email) new_email="${2:?}"; shift 2 ;;
|
||||||
|
--homepage) new_homepage="${2:?}"; shift 2 ;;
|
||||||
|
--description) new_description="${2:?}"; shift 2 ;;
|
||||||
|
--clap-description) new_clap_description="${2:?}"; shift 2 ;;
|
||||||
|
--struct) new_struct="${2:?}"; shift 2 ;;
|
||||||
|
--clap-id) new_clap_id="${2:?}"; shift 2 ;;
|
||||||
|
--vst3-class-id) new_vst3="${2:?}"; shift 2 ;;
|
||||||
|
--allow-identity-change) allow_identity=1; shift ;;
|
||||||
|
--apply) apply=1; shift ;;
|
||||||
|
-h|--help) sed -n '3,25p' "$0"; exit 0 ;;
|
||||||
|
*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Interactive when nothing was passed and there is a terminal to ask at. Every
|
||||||
|
# prompt offers the current value; Enter keeps it, so a run that answers nothing
|
||||||
|
# is a no-op rather than a surprise. Optional fields additionally accept "-" to
|
||||||
|
# clear them.
|
||||||
|
ask() { # ask VARNAME PROMPT CURRENT
|
||||||
|
printf ' %s [%s]: ' "$2" "$3" > /dev/tty
|
||||||
|
IFS= read -r _reply < /dev/tty || _reply=""
|
||||||
|
[ -n "$_reply" ] || _reply="$3"
|
||||||
|
[ "$_reply" = "-" ] && _reply=""
|
||||||
|
eval "$1=\$_reply"
|
||||||
|
}
|
||||||
|
if [ -z "$new_display" ] && [ -t 0 ] && [ -c /dev/tty ]; then
|
||||||
|
printf 'branding this plugin - Enter keeps the current value, "-" clears an optional one\n\n'
|
||||||
|
ask new_display "display name " "$OLD_DISPLAY"
|
||||||
|
ask new_vendor "vendor (org) " "$OLD_VENDOR"
|
||||||
|
ask new_author "cargo author " "$OLD_AUTHOR"
|
||||||
|
ask new_email "email " "$OLD_EMAIL"
|
||||||
|
ask new_homepage "homepage " "$OLD_HOMEPAGE"
|
||||||
|
ask new_description "cargo description" "$OLD_DESCRIPTION"
|
||||||
|
ask new_clap_description "clap description" "$OLD_CLAP_DESCRIPTION"
|
||||||
|
printf '\n'
|
||||||
|
fi
|
||||||
|
[ -n "$new_display" ] || { printf 'nothing to do: pass --display "New Name" (or run interactively)\n' >&2; exit 2; }
|
||||||
|
|
||||||
|
# --- derivation -------------------------------------------------------------
|
||||||
|
# Package name: lowercase, every run of non-alphanumerics becomes one separator,
|
||||||
|
# ends trimmed. Cargo accepts both hyphens and underscores in a package name and
|
||||||
|
# converts to underscores for the built artifact, which nih_plug_xtask already
|
||||||
|
# accounts for; underscores are used here to match the Rust convention.
|
||||||
|
derive_pkg() {
|
||||||
|
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
|
||||||
|
| sed -e 's/[^a-z0-9]\{1,\}/_/g' -e 's/^_//' -e 's/_$//'
|
||||||
|
}
|
||||||
|
# Rust type name: CamelCase, non-alphanumerics dropped entirely.
|
||||||
|
derive_struct() {
|
||||||
|
printf '%s' "$1" | sed -e 's/[^A-Za-z0-9]\{1,\}/ /g' \
|
||||||
|
| awk '{for(i=1;i<=NF;i++) printf "%s%s", toupper(substr($i,1,1)), substr($i,2); print ""}'
|
||||||
|
}
|
||||||
|
[ -n "$new_pkg" ] || new_pkg=$(derive_pkg "$new_display")
|
||||||
|
[ -n "$new_struct" ] || new_struct=$(derive_struct "$new_display")
|
||||||
|
new_params_struct="${new_struct}Params"
|
||||||
|
[ -n "$new_vendor" ] || new_vendor="$OLD_VENDOR"
|
||||||
|
[ -n "$new_author" ] || new_author="$OLD_AUTHOR"
|
||||||
|
[ -n "$new_email" ] || new_email="$OLD_EMAIL"
|
||||||
|
[ -n "$new_homepage" ] || new_homepage="$OLD_HOMEPAGE"
|
||||||
|
[ -n "$new_description" ] || new_description="$OLD_DESCRIPTION"
|
||||||
|
[ -n "$new_clap_description" ] || new_clap_description="$OLD_CLAP_DESCRIPTION"
|
||||||
|
|
||||||
|
# --- validation -------------------------------------------------------------
|
||||||
|
fail=0
|
||||||
|
die() { printf ' INVALID %s\n' "$1"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
case "$new_pkg" in
|
||||||
|
"") die "derived package name is empty" ;;
|
||||||
|
[0-9]*) die "package name \"$new_pkg\" starts with a digit - cargo rejects this outright" ;;
|
||||||
|
esac
|
||||||
|
case "$new_pkg" in
|
||||||
|
test|core|std|alloc|proc_macro)
|
||||||
|
die "package name \"$new_pkg\" collides with a built-in crate" ;;
|
||||||
|
as|break|const|continue|crate|dyn|else|enum|extern|false|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|static|struct|super|trait|true|type|unsafe|use|where|while|async|await|box|do|final|macro|override|priv|typeof|unsized|virtual|yield|try)
|
||||||
|
die "package name \"$new_pkg\" is a Rust keyword - cargo rejects this" ;;
|
||||||
|
esac
|
||||||
|
# bundler.toml uses the package name as a TOML bare key.
|
||||||
|
case "$new_pkg" in *[!a-zA-Z0-9_-]*) die "package name \"$new_pkg\" is not a valid TOML bare key" ;; esac
|
||||||
|
case "$new_struct" in ""|[0-9]*) die "derived type name \"$new_struct\" is not a valid Rust identifier" ;; esac
|
||||||
|
|
||||||
|
# --- frozen identity --------------------------------------------------------
|
||||||
|
identity_note=""
|
||||||
|
if [ -n "$new_clap_id" ] || [ -n "$new_vst3" ]; then
|
||||||
|
if [ "$allow_identity" -eq 0 ]; then
|
||||||
|
die "CLAP_ID / VST3_CLASS_ID are frozen identity; pass --allow-identity-change to move them"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "$new_clap_id" ] || new_clap_id="$OLD_CLAP_ID"
|
||||||
|
[ -n "$new_vst3" ] || new_vst3="$OLD_VST3"
|
||||||
|
|
||||||
|
# VST3_CLASS_ID is a fixed 16-byte array, so it cannot simply be derived. Propose
|
||||||
|
# one from the new name and let a human decide - it is frozen the moment it ships.
|
||||||
|
proposed_vst3=$(printf '%s' "${new_struct}Plugin000000000000" | cut -c1-16)
|
||||||
|
n=$(printf '%s' "$new_vst3" | wc -c | tr -d ' ')
|
||||||
|
[ "$n" -eq 16 ] || die "VST3_CLASS_ID \"$new_vst3\" is $n bytes, must be exactly 16"
|
||||||
|
|
||||||
|
case "$new_clap_id" in
|
||||||
|
*\ *|*[A-Z]*) die "CLAP_ID \"$new_clap_id\" must be lowercase with no spaces" ;;
|
||||||
|
*.*) ;;
|
||||||
|
*) die "CLAP_ID \"$new_clap_id\" should be dotted reverse-DNS" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# CLAP_DESCRIPTION is user-facing: keep development history out of it.
|
||||||
|
for hist in "$CODENAME" "$OLD_DISPLAY" "$OLD_PKG"; do
|
||||||
|
case "$new_clap_description" in
|
||||||
|
*"$hist"*) die "CLAP_DESCRIPTION still mentions \"$hist\" - it is shown to users, who never knew the development name" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- plan -------------------------------------------------------------------
|
||||||
|
printf 'rename plan\n\n'
|
||||||
|
row() { [ "$2" = "$3" ] && printf ' %-18s %s (unchanged)\n' "$1" "$2" || printf ' %-18s %s -> %s\n' "$1" "$2" "$3"; }
|
||||||
|
row "package" "$OLD_PKG" "$new_pkg"
|
||||||
|
row "display" "$OLD_DISPLAY" "$new_display"
|
||||||
|
row "Plugin::NAME" "$OLD_PLUGIN_NAME" "$new_display"
|
||||||
|
row "vendor" "$OLD_VENDOR" "$new_vendor"
|
||||||
|
row "cargo author" "$OLD_AUTHOR" "$new_author"
|
||||||
|
row "email" "$OLD_EMAIL" "$new_email"
|
||||||
|
row "homepage" "$OLD_HOMEPAGE" "$new_homepage"
|
||||||
|
row "description" "$OLD_DESCRIPTION" "$new_description"
|
||||||
|
row "clap desc" "$OLD_CLAP_DESCRIPTION" "$new_clap_description"
|
||||||
|
row "struct" "$OLD_STRUCT" "$new_struct"
|
||||||
|
row "params struct" "$OLD_PARAMS_STRUCT" "$new_params_struct"
|
||||||
|
row "CLAP_ID" "$OLD_CLAP_ID" "$new_clap_id"
|
||||||
|
row "VST3_CLASS_ID" "$OLD_VST3" "$new_vst3"
|
||||||
|
printf '\n repo codename stays %s - fixed at creation, never renamed\n' "$CODENAME"
|
||||||
|
if [ "$new_vst3" = "$OLD_VST3" ]; then
|
||||||
|
printf ' suggested VST3_CLASS_ID for this name (16 bytes): "%s"\n' "$proposed_vst3"
|
||||||
|
printf ' apply with --vst3-class-id "%s" --allow-identity-change, and only before first release\n' "$proposed_vst3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
printf '\n%d problem(s); nothing written.\n' "$fail"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$apply" -eq 0 ]; then
|
||||||
|
printf '\nDry run. Re-run with --apply to write these changes.\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- apply ------------------------------------------------------------------
|
||||||
|
# Three scratch files, one trap: the regenerated site table, the rows carried
|
||||||
|
# through from the old one, and the value-substitution pairs.
|
||||||
|
gen="$(mktemp)"; keep="$(mktemp)"; subst_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$gen" "$keep" "$subst_file"' EXIT
|
||||||
|
|
||||||
|
T="$(printf '\t')"
|
||||||
|
{
|
||||||
|
printf 'req%spkg.cargo%sCargo.toml%sname = "%s"\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'req%spkg.bundler%sbundler.toml%s[%s]\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'opt%spkg.lock%sCargo.lock%sname = "%s"\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'opt%spkg.ci%s.github/workflows/ci.yaml%scargo xtask bundle %s\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'opt%spkg.readme%sREADME.md%scargo xtask bundle %s\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'req%sdisplay.bundler%sbundler.toml%sname = "%s"\n' "$T" "$T" "$T" "$new_display"
|
||||||
|
printf 'opt%sdisplay.notice%sNOTICE%s%s\n' "$T" "$T" "$T" "$new_display"
|
||||||
|
printf 'req%sname.plugin%s%s%sconst NAME: &'"'"'static str = "%s"\n' "$T" "$T" "$LIB_FILE" "$T" "$new_display"
|
||||||
|
printf 'req%svendor.plugin%s%s%sconst VENDOR: &'"'"'static str = "%s"\n' "$T" "$T" "$LIB_FILE" "$T" "$new_vendor"
|
||||||
|
printf 'req%semail.plugin%s%s%sconst EMAIL: &'"'"'static str = "%s"\n' "$T" "$T" "$LIB_FILE" "$T" "$new_email"
|
||||||
|
printf 'req%sauthors.cargo%sCargo.toml%sauthors = ["%s <%s>"]\n' "$T" "$T" "$T" "$new_author" "$new_email"
|
||||||
|
printf 'req%shomepage.cargo%sCargo.toml%shomepage = "%s"\n' "$T" "$T" "$T" "$new_homepage"
|
||||||
|
printf 'req%surl.plugin%s%s%sconst URL: &'"'"'static str = env!("CARGO_PKG_HOMEPAGE")\n' "$T" "$T" "$LIB_FILE" "$T"
|
||||||
|
printf 'req%sdescription.cargo%sCargo.toml%sdescription = "%s"\n' "$T" "$T" "$T" "$new_description"
|
||||||
|
# Narrowest distinctive fragment, not the whole statement: rustfmt wraps a long
|
||||||
|
# CLAP_DESCRIPTION onto its own line, and a pattern spanning the assignment
|
||||||
|
# would then never match a correctly formatted file.
|
||||||
|
printf 'req%sdescription.clap%s%s%sSome("%s")\n' "$T" "$T" "$LIB_FILE" "$T" "$new_clap_description"
|
||||||
|
printf 'req%sclapid.plugin%s%s%sconst CLAP_ID: &'"'"'static str = "%s"\n' "$T" "$T" "$LIB_FILE" "$T" "$new_clap_id"
|
||||||
|
printf 'req%svst3id.plugin%s%s%sconst VST3_CLASS_ID: [u8; 16] = *b"%s"\n' "$T" "$T" "$LIB_FILE" "$T" "$new_vst3"
|
||||||
|
printf 'req%sstruct.def%s%s%sstruct %s {\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sstruct.plugin%s%s%simpl Plugin for %s {\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sstruct.clap%s%s%simpl ClapPlugin for %s {\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sstruct.vst3%s%s%simpl Vst3Plugin for %s {\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sstruct.exportclap%s%s%snih_export_clap!(%s);\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sstruct.exportvst3%s%s%snih_export_vst3!(%s);\n' "$T" "$T" "$LIB_FILE" "$T" "$new_struct"
|
||||||
|
printf 'req%sparams.def%s%s%sstruct %s {\n' "$T" "$T" "$PARAMS_FILE" "$T" "$new_params_struct"
|
||||||
|
printf 'req%sparams.default%s%s%simpl Default for %s {\n' "$T" "$T" "$PARAMS_FILE" "$T" "$new_params_struct"
|
||||||
|
printf 'req%sparams.field%s%s%sparams: Arc<%s>,\n' "$T" "$T" "$LIB_FILE" "$T" "$new_params_struct"
|
||||||
|
printf 'opt%sdeploy.pkg%sdeploy.ps1%s$Package = "%s"\n' "$T" "$T" "$T" "$new_pkg"
|
||||||
|
printf 'opt%sdeploy.vst3%sdeploy.ps1%s$vst3Name = "%s.vst3"\n' "$T" "$T" "$T" "$new_display"
|
||||||
|
printf 'opt%sdeploy.clap%sdeploy.ps1%s$clapName = "%s.clap"\n' "$T" "$T" "$T" "$new_display"
|
||||||
|
} > "$gen"
|
||||||
|
|
||||||
|
# Pass 1 - site-directed. Each owned row in the existing table carries the OLD
|
||||||
|
# resolved literal for its site; the regenerated row carries the new one. Rewriting
|
||||||
|
# by that pair is unambiguous, which plain value substitution is not: AUTHOR
|
||||||
|
# defaults to VENDOR, so both fields can hold the same old string and a global
|
||||||
|
# replace cannot tell which occurrence belongs to which. This must run BEFORE
|
||||||
|
# pass 2, or the global replace consumes the old literals first and leaves the
|
||||||
|
# precise rewrite with nothing to match.
|
||||||
|
sites="tools/branding-sites.tsv"
|
||||||
|
T="$(printf '\t')"
|
||||||
|
if [ -f "$sites" ]; then
|
||||||
|
while IFS="$T" read -r ok_ oo_ of_ op_; do
|
||||||
|
case "$ok_" in ''|\#*) continue ;; esac
|
||||||
|
np_=$(awk -F"$T" -v o="$oo_" '$2==o {print $4; exit}' "$gen")
|
||||||
|
[ -n "$np_" ] || continue
|
||||||
|
[ "$np_" != "$op_" ] || continue
|
||||||
|
[ -f "$of_" ] || continue
|
||||||
|
esc_o=$(printf '%s' "$op_" | sed 's/[][\.*^$/|&]/\\&/g')
|
||||||
|
esc_n=$(printf '%s' "$np_" | sed 's/[\\/&|]/\\&/g')
|
||||||
|
tmp_=$(mktemp)
|
||||||
|
sed "s|$esc_o|$esc_n|g" "$of_" > "$tmp_" && cat "$tmp_" > "$of_"
|
||||||
|
rm -f "$tmp_"
|
||||||
|
done < "$sites"
|
||||||
|
printf ' pass 1: rewrote known sites from the table\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pass 2 - value substitution, for everything the table does not enumerate:
|
||||||
|
# doc comments, use sites in other modules, prose in the README.
|
||||||
|
# Longest values first: a shorter spelling can be a substring of a longer one
|
||||||
|
# (the package name inside a URL, say), and replacing it first would corrupt the
|
||||||
|
# longer match. branding.conf is excluded and rewritten wholesale below, so its
|
||||||
|
# STALE list is never rewritten into meaninglessness.
|
||||||
|
{
|
||||||
|
printf '%s\t%s\n' "$OLD_CLAP_ID" "$new_clap_id"
|
||||||
|
printf '%s\t%s\n' "$OLD_HOMEPAGE" "$new_homepage"
|
||||||
|
printf '%s\t%s\n' "$OLD_CLAP_DESCRIPTION" "$new_clap_description"
|
||||||
|
printf '%s\t%s\n' "$OLD_DESCRIPTION" "$new_description"
|
||||||
|
printf '%s\t%s\n' "$OLD_PARAMS_STRUCT" "$new_params_struct"
|
||||||
|
printf '%s\t%s\n' "$OLD_VST3" "$new_vst3"
|
||||||
|
printf '%s\t%s\n' "$OLD_PLUGIN_NAME" "$new_display"
|
||||||
|
printf '%s\t%s\n' "$OLD_DISPLAY" "$new_display"
|
||||||
|
printf '%s\t%s\n' "$OLD_STRUCT" "$new_struct"
|
||||||
|
printf '%s\t%s\n' "$OLD_EMAIL" "$new_email"
|
||||||
|
printf '%s\t%s\n' "$OLD_AUTHOR" "$new_author"
|
||||||
|
printf '%s\t%s\n' "$OLD_VENDOR" "$new_vendor"
|
||||||
|
printf '%s\t%s\n' "$OLD_PKG" "$new_pkg"
|
||||||
|
} | awk -F'\t' '$1 != $2 { print length($1)"\t"$0 }' | sort -rn | cut -f2- > "$subst_file"
|
||||||
|
|
||||||
|
changed=0
|
||||||
|
git ls-files 2>/dev/null | while IFS= read -r f; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
# branding.conf alone is excluded: it is rewritten wholesale below, and its
|
||||||
|
# STALE list names every retired spelling by design. The site table IS
|
||||||
|
# substituted, so hand-added custom rows track the rename; owned rows are
|
||||||
|
# regenerated afterwards regardless.
|
||||||
|
case "$f" in tools/branding.conf) continue ;; esac
|
||||||
|
before=$(cat "$f")
|
||||||
|
after="$before"
|
||||||
|
while IFS="$(printf '\t')" read -r old new; do
|
||||||
|
[ -n "$old" ] || continue
|
||||||
|
after=$(printf '%s' "$after" | sed "s|$(printf '%s' "$old" | sed 's/[][\.*^$/|&]/\\&/g')|$(printf '%s' "$new" | sed 's/[\\/&|]/\\&/g')|g")
|
||||||
|
done < "$subst_file"
|
||||||
|
if [ "$before" != "$after" ]; then printf '%s\n' "$after" > "$f"; printf ' rewrote %s\n' "$f"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# branding.conf last, with the retired spellings appended to STALE so the next
|
||||||
|
# run of the checker sweeps for anything this pass missed.
|
||||||
|
# Deduplicated: two fields can retire to the same spelling - DISPLAY and
|
||||||
|
# PLUGIN_NAME are commonly identical - and listing it twice makes the checker
|
||||||
|
# report the same drift twice. Note that neither this script nor the checker
|
||||||
|
# contains a branding literal anywhere: both read every name from
|
||||||
|
# branding.conf, so a rename never has to rewrite its own tooling.
|
||||||
|
retired=$(cut -f1 "$subst_file" | awk '!seen[$0]++')
|
||||||
|
sed -e "s|^PKG=.*|PKG=\"$new_pkg\"|" \
|
||||||
|
-e "s|^DISPLAY=.*|DISPLAY=\"$new_display\"|" \
|
||||||
|
-e "s|^PLUGIN_NAME=.*|PLUGIN_NAME=\"$new_display\"|" \
|
||||||
|
-e "s|^VENDOR=.*|VENDOR=\"$new_vendor\"|" \
|
||||||
|
-e "s|^# *AUTHOR=.*|AUTHOR=\"$new_author\"|" \
|
||||||
|
-e "s|^AUTHOR=.*|AUTHOR=\"$new_author\"|" \
|
||||||
|
-e "s|^EMAIL=.*|EMAIL=\"$new_email\"|" \
|
||||||
|
-e "s|^HOMEPAGE=.*|HOMEPAGE=\"$new_homepage\"|" \
|
||||||
|
-e "s|^DESCRIPTION=.*|DESCRIPTION=\"$new_description\"|" \
|
||||||
|
-e "s|^CLAP_DESCRIPTION=.*|CLAP_DESCRIPTION=\"$new_clap_description\"|" \
|
||||||
|
-e "s|^CLAP_ID=.*|CLAP_ID=\"$new_clap_id\"|" \
|
||||||
|
-e "s|^VST3_CLASS_ID=.*|VST3_CLASS_ID=\"$new_vst3\"|" \
|
||||||
|
-e "s|^STRUCT=.*|STRUCT=\"$new_struct\"|" \
|
||||||
|
-e "s|^PARAMS_STRUCT=.*|PARAMS_STRUCT=\"$new_params_struct\"|" \
|
||||||
|
tools/branding.conf > tools/branding.conf.new
|
||||||
|
if [ -n "$retired" ]; then
|
||||||
|
{ sed '$ s/"$//' tools/branding.conf.new; printf '%s"\n' "$retired"; } > tools/branding.conf
|
||||||
|
else
|
||||||
|
# Nothing retired (a no-op rename, or a regeneration): leave STALE untouched
|
||||||
|
# rather than appending a blank line to it on every run.
|
||||||
|
cat tools/branding.conf.new > tools/branding.conf
|
||||||
|
fi
|
||||||
|
rm -f tools/branding.conf.new
|
||||||
|
if [ "$new_author" = "$new_vendor" ]; then
|
||||||
|
# Identical to VENDOR, so re-comment it: the conf should keep showing that this
|
||||||
|
# value derives rather than pinning a duplicate that then has to be maintained.
|
||||||
|
tmp_c=$(mktemp)
|
||||||
|
sed "s|^AUTHOR=.*|# AUTHOR=\"$new_author\"|" tools/branding.conf > "$tmp_c" && cat "$tmp_c" > tools/branding.conf
|
||||||
|
rm -f "$tmp_c"
|
||||||
|
fi
|
||||||
|
printf ' rewrote tools/branding.conf (retired spellings appended to STALE)\n'
|
||||||
|
|
||||||
|
# Regenerate the site table. Rows whose owner this script recognises are
|
||||||
|
# rewritten with the new resolved literals; every other row - anything marked
|
||||||
|
# custom, or added by hand for a file this template does not ship - is carried
|
||||||
|
# through untouched. Clobbering those silently is how a tool stops being trusted.
|
||||||
|
sites="tools/branding-sites.tsv"
|
||||||
|
|
||||||
|
owned=$(cut -f2 "$gen")
|
||||||
|
if [ -f "$sites" ]; then
|
||||||
|
grep '^#' "$sites" > "$keep" 2>/dev/null || : > "$keep"
|
||||||
|
while IFS="$T" read -r k o f pat; do
|
||||||
|
case "$k" in ''|\#*) continue ;; esac
|
||||||
|
printf '%s\n' "$owned" | grep -qxF "$o" || printf '%s%s%s%s%s%s%s\n' "$k" "$T" "$o" "$T" "$f" "$T" "$pat" >> "$keep"
|
||||||
|
done < "$sites"
|
||||||
|
else
|
||||||
|
: > "$keep"
|
||||||
|
fi
|
||||||
|
{ cat "$keep"; cat "$gen"; } > "$sites"
|
||||||
|
printf ' rewrote %s (%s owned rows, %s preserved)\n' "$sites" "$(wc -l < "$gen" | tr -d ' ')" "$(grep -cv '^#' "$keep" 2>/dev/null || echo 0)"
|
||||||
|
|
||||||
|
printf '\nverifying with check-branding.sh\n\n'
|
||||||
|
sh tools/check-branding.sh
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
req pkg.cargo Cargo.toml name = "bari_206"
|
||||||
|
req pkg.bundler bundler.toml [bari_206]
|
||||||
|
opt pkg.lock Cargo.lock name = "bari_206"
|
||||||
|
opt pkg.ci .github/workflows/ci.yaml cargo xtask bundle bari_206
|
||||||
|
opt pkg.readme README.md cargo xtask bundle bari_206
|
||||||
|
req display.bundler bundler.toml name = "Bari 206"
|
||||||
|
opt display.notice NOTICE Bari 206
|
||||||
|
req name.plugin src/lib.rs const NAME: &'static str = "Bari 206"
|
||||||
|
req vendor.plugin src/lib.rs const VENDOR: &'static str = "Novoyuuparosk"
|
||||||
|
req email.plugin src/lib.rs const EMAIL: &'static str = "mikkeli@novoyuuparosk.org"
|
||||||
|
req authors.cargo Cargo.toml authors = ["Mikkeli Matlock <mikkeli@novoyuuparosk.org>"]
|
||||||
|
req homepage.cargo Cargo.toml homepage = "https://github.com/mikkeli/codename-206"
|
||||||
|
req url.plugin src/lib.rs const URL: &'static str = env!("CARGO_PKG_HOMEPAGE")
|
||||||
|
req description.cargo Cargo.toml description = "A VST3/CLAP multiband compressor/limiter with a custom gain-curve display"
|
||||||
|
req description.clap src/lib.rs Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)")
|
||||||
|
req clapid.plugin src/lib.rs const CLAP_ID: &'static str = "org.novoyuuparosk.bari-206"
|
||||||
|
req vst3id.plugin src/lib.rs const VST3_CLASS_ID: [u8; 16] = *b"Bari206Multiband"
|
||||||
|
req struct.def src/lib.rs struct Bari206 {
|
||||||
|
req struct.plugin src/lib.rs impl Plugin for Bari206 {
|
||||||
|
req struct.clap src/lib.rs impl ClapPlugin for Bari206 {
|
||||||
|
req struct.vst3 src/lib.rs impl Vst3Plugin for Bari206 {
|
||||||
|
req struct.exportclap src/lib.rs nih_export_clap!(Bari206);
|
||||||
|
req struct.exportvst3 src/lib.rs nih_export_vst3!(Bari206);
|
||||||
|
req params.def src/params.rs struct Bari206Params {
|
||||||
|
req params.default src/params.rs impl Default for Bari206Params {
|
||||||
|
req params.field src/lib.rs params: Arc<Bari206Params>,
|
||||||
|
opt deploy.pkg deploy.ps1 $Package = "bari_206"
|
||||||
|
opt deploy.vst3 deploy.ps1 $vst3Name = "Bari 206.vst3"
|
||||||
|
opt deploy.clap deploy.ps1 $clapName = "Bari 206.clap"
|
||||||
|
Can't render this file because it contains an unexpected character in line 1 and column 33.
|
@@ -0,0 +1,66 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# branding.conf - this plugin's canonical identity.
|
||||||
|
#
|
||||||
|
# Managed by tools/brand-plugin.sh, read by tools/check-branding.sh. Hand-editing
|
||||||
|
# works and is sometimes the quickest thing, but the helper is what keeps this
|
||||||
|
# file, the source tree and tools/branding-sites.tsv agreeing with each other.
|
||||||
|
# Edit here and you are on the hook for running the checker afterwards.
|
||||||
|
|
||||||
|
# --- derivations ------------------------------------------------------------
|
||||||
|
_derive_pkg() {
|
||||||
|
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
|
||||||
|
| sed -e 's/[^a-z0-9]\{1,\}/_/g' -e 's/^_//' -e 's/_$//'
|
||||||
|
}
|
||||||
|
_derive_struct() {
|
||||||
|
printf '%s' "$1" | sed -e 's/[^A-Za-z0-9]\{1,\}/ /g' \
|
||||||
|
| awk '{for(i=1;i<=NF;i++) printf "%s%s", toupper(substr($i,1,1)), substr($i,2); print ""}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the names ---------------------------------------------------------------
|
||||||
|
PLUGIN_NAME="Bari 206"
|
||||||
|
VENDOR="Novoyuuparosk"
|
||||||
|
EMAIL="mikkeli@novoyuuparosk.org"
|
||||||
|
AUTHOR="Mikkeli Matlock"
|
||||||
|
HOMEPAGE="https://github.com/mikkeli/codename-206"
|
||||||
|
DESCRIPTION="A VST3/CLAP multiband compressor/limiter with a custom gain-curve display"
|
||||||
|
|
||||||
|
# What the plugin does *now*. No codename, no development stage.
|
||||||
|
CLAP_DESCRIPTION="Multiband compressor/limiter (stage 3: 3-band + 'All' channel)"
|
||||||
|
|
||||||
|
# --- frozen identity ---------------------------------------------------------
|
||||||
|
# Hosts key saved projects and presets off these two. Never move them after the
|
||||||
|
# first release: every existing session silently loses the plugin.
|
||||||
|
CLAP_ID="org.novoyuuparosk.bari-206"
|
||||||
|
VST3_CLASS_ID="Bari206Multiband"
|
||||||
|
|
||||||
|
# --- fixed at repo creation --------------------------------------------------
|
||||||
|
# The repo keeps its development codename while the plugin carries the product
|
||||||
|
# name. Recorded only so it can be kept out of user-facing text.
|
||||||
|
CODENAME="codename-206"
|
||||||
|
|
||||||
|
LIB_FILE="src/lib.rs"
|
||||||
|
PARAMS_FILE="src/params.rs"
|
||||||
|
|
||||||
|
# --- derived unless overridden -----------------------------------------------
|
||||||
|
# PKG, STRUCT and PARAMS_STRUCT all derive correctly from PLUGIN_NAME here, so
|
||||||
|
# none is pinned. DISPLAY defaults to PLUGIN_NAME.
|
||||||
|
: "${AUTHOR:=$VENDOR}"
|
||||||
|
: "${DISPLAY:=$PLUGIN_NAME}"
|
||||||
|
: "${PKG:=$(_derive_pkg "$PLUGIN_NAME")}"
|
||||||
|
: "${STRUCT:=$(_derive_struct "$PLUGIN_NAME")}"
|
||||||
|
: "${PARAMS_STRUCT:=${STRUCT}Params}"
|
||||||
|
|
||||||
|
# --- retired spellings -------------------------------------------------------
|
||||||
|
STALE="codename_206
|
||||||
|
Codename 206
|
||||||
|
Codename206
|
||||||
|
Codename206Params
|
||||||
|
Codename206Maxi!
|
||||||
|
206 prototype
|
||||||
|
com.mikkeli.codename-206
|
||||||
|
bari_206
|
||||||
|
Bari 206
|
||||||
|
Bari206
|
||||||
|
Bari206Params
|
||||||
|
Bari206Multiband
|
||||||
|
org.novoyuuparosk.bari-206"
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# check-branding.sh - verify the plugin's branding/identity strings agree everywhere.
|
||||||
|
#
|
||||||
|
# Plugin branding is duplicated across the build manifest, the bundler manifest,
|
||||||
|
# the source, the docs and (once you add one) the deploy script. Nothing makes
|
||||||
|
# them agree, so a rename drifts silently: the bundle builds under one name and
|
||||||
|
# the deploy script looks for another. This asserts they match, and that the
|
||||||
|
# identity constants are legal.
|
||||||
|
#
|
||||||
|
# In a template this matters more than usual: renaming is the first thing you do
|
||||||
|
# with this repo, and it touches every file below. Run this immediately after,
|
||||||
|
# and the STALE machinery will list whatever you missed.
|
||||||
|
#
|
||||||
|
# Usage: sh tools/check-branding.sh (exit 0 = consistent, 1 = drift found)
|
||||||
|
#
|
||||||
|
# The canonical identity lives in branding.conf, shared with brand-plugin.sh.
|
||||||
|
# Edit it there, then run this to find every copy that still holds the old value.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
# Resolve the repo root: these live in tools/, every path they touch is
|
||||||
|
# repo-relative, and they must behave the same wherever they are invoked from.
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
|
if [ ! -f tools/branding.conf ]; then
|
||||||
|
printf 'tools/branding.conf not found\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./tools/branding.conf
|
||||||
|
|
||||||
|
# Every canonical spelling concatenated, for the substring test below.
|
||||||
|
CANONICAL_BLOB="$PKG
|
||||||
|
$DESCRIPTION
|
||||||
|
$CLAP_DESCRIPTION
|
||||||
|
$STRUCT
|
||||||
|
$PARAMS_STRUCT
|
||||||
|
$DISPLAY
|
||||||
|
$PLUGIN_NAME
|
||||||
|
$VENDOR
|
||||||
|
$EMAIL
|
||||||
|
$HOMEPAGE
|
||||||
|
$CLAP_ID
|
||||||
|
$VST3_CLASS_ID"
|
||||||
|
|
||||||
|
pass=0; fail=0; skip=0
|
||||||
|
ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; }
|
||||||
|
bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; }
|
||||||
|
note() { skip=$((skip+1)); printf ' skip %s\n' "$1"; }
|
||||||
|
# Reported for awareness only - never counted, never fails the run.
|
||||||
|
info() { printf ' -- %s\n' "$1"; }
|
||||||
|
|
||||||
|
# has FILE PATTERN -> fixed-string search
|
||||||
|
has() { grep -qF "$2" "$1" 2>/dev/null; }
|
||||||
|
|
||||||
|
check() { # check LABEL FILE LITERAL
|
||||||
|
if [ ! -f "$2" ]; then bad "$1" "missing file: $2"; return; fi
|
||||||
|
if has "$2" "$3"; then ok "$1"; else bad "$1" "$2 does not contain: $3"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Same as check, but a missing file is "not applicable yet" rather than drift.
|
||||||
|
# The template ships without a deploy script; this starts checking once you add one.
|
||||||
|
opt_check() { # opt_check LABEL FILE LITERAL
|
||||||
|
if [ ! -f "$2" ]; then note "$1 ($2 not present)"; return; fi
|
||||||
|
if has "$2" "$3"; then ok "$1"; else bad "$1" "$2 does not contain: $3"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf 'branding check (canonical: %s / "%s")\n\n' "$PKG" "$DISPLAY"
|
||||||
|
|
||||||
|
# --- consistency: every site in the table holds its resolved literal ----------
|
||||||
|
# The table is data, so adding a site is a row rather than a shell edit, and the
|
||||||
|
# same script runs unmodified across every plugin built from this template.
|
||||||
|
SITES="tools/branding-sites.tsv"
|
||||||
|
if [ ! -f "$SITES" ]; then
|
||||||
|
bad "site table" "$SITES not found - run tools/brand-plugin.sh to generate it"
|
||||||
|
else
|
||||||
|
printf 'sites\n'
|
||||||
|
while IFS="$(printf '\t')" read -r kind owner file pattern; do
|
||||||
|
case "$kind" in ''|\#*) continue ;; esac
|
||||||
|
[ -n "$pattern" ] || continue
|
||||||
|
label="$owner"
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
case "$kind" in
|
||||||
|
opt) note "$label ($file not present)" ;;
|
||||||
|
*) bad "$label" "missing file: $file" ;;
|
||||||
|
esac
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if has "$file" "$pattern"; then ok "$label"
|
||||||
|
else bad "$label" "$file does not contain: $pattern"; fi
|
||||||
|
done < "$SITES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- validity: rules the table cannot express --------------------------------
|
||||||
|
printf '\nvalidity\n'
|
||||||
|
|
||||||
|
# VST3 class IDs are a fixed 16-byte array. A wrong length is a compile error,
|
||||||
|
# but saying so here is clearer than reading rustc on it.
|
||||||
|
n=$(printf '%s' "$VST3_CLASS_ID" | wc -c | tr -d ' ')
|
||||||
|
if [ "$n" -eq 16 ]; then ok "VST3_CLASS_ID is 16 bytes"
|
||||||
|
else bad "VST3_CLASS_ID is 16 bytes" "got $n bytes: \"$VST3_CLASS_ID\""; fi
|
||||||
|
|
||||||
|
# CLAP IDs are reverse-DNS by convention and compared verbatim by hosts, so a
|
||||||
|
# stray space or capital is a silent identity change between machines.
|
||||||
|
case "$CLAP_ID" in
|
||||||
|
*\ *|*[A-Z]*) bad "CLAP_ID is reverse-DNS safe" "no spaces or capitals allowed: \"$CLAP_ID\"" ;;
|
||||||
|
*.*) ok "CLAP_ID is reverse-DNS safe" ;;
|
||||||
|
*) bad "CLAP_ID is reverse-DNS safe" "expected dotted reverse-DNS, got: \"$CLAP_ID\"" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# An overridden package name is legitimate - the derivation is sometimes invalid
|
||||||
|
# - but a display rename that forgot the package is not, and looks identical
|
||||||
|
# from here. Reported, never failed.
|
||||||
|
derived_pkg=$(_derive_pkg "$PLUGIN_NAME")
|
||||||
|
[ "$PKG" = "$derived_pkg" ] || info "PKG \"$PKG\" differs from the derivation of PLUGIN_NAME (\"$derived_pkg\") - fine if deliberate"
|
||||||
|
|
||||||
|
# CLAP_DESCRIPTION is shown to users in a host's plugin browser. "Free of
|
||||||
|
# development history" is a judgement a script cannot make, but the failure it
|
||||||
|
# produces is mechanical: a retired name or the repo codename surviving in it.
|
||||||
|
hygiene_hits=""
|
||||||
|
while IFS= read -r s_entry; do
|
||||||
|
[ -n "$s_entry" ] || continue
|
||||||
|
case "$CANONICAL_BLOB" in *"$s_entry"*) continue ;; esac
|
||||||
|
case "$CLAP_DESCRIPTION" in *"$s_entry"*) hygiene_hits="$hygiene_hits \"$s_entry\"" ;; esac
|
||||||
|
done <<HYGIENE_LIST
|
||||||
|
$STALE
|
||||||
|
$CODENAME
|
||||||
|
HYGIENE_LIST
|
||||||
|
if [ -z "$hygiene_hits" ]; then ok "CLAP_DESCRIPTION carries no retired name or codename"
|
||||||
|
else bad "CLAP_DESCRIPTION carries no retired name or codename" "found:$hygiene_hits"; fi
|
||||||
|
|
||||||
|
printf '\nstale spellings\n'
|
||||||
|
# File list for the scan below. Prefer git, so build output and any vendored
|
||||||
|
# tree stay out of it. Fall back to find when this is not a checkout - an
|
||||||
|
# exported tarball, or a CI step that fetched without git metadata - because
|
||||||
|
# `git ls-files` there returns nothing, and an empty list would report every
|
||||||
|
# stale spelling as absent. A check that passes hardest when it can see least
|
||||||
|
# is worse than no check.
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
file_list() { git ls-files; }
|
||||||
|
scan_source="git"
|
||||||
|
else
|
||||||
|
file_list() { find . -path ./.git -prune -o -path ./target -prune -o -type f -print | sed 's|^\./||'; }
|
||||||
|
scan_source="find (not a git checkout)"
|
||||||
|
fi
|
||||||
|
info "scanning tracked files via $scan_source"
|
||||||
|
# Fed by here-doc rather than a pipe on purpose: a piped `while` runs in a
|
||||||
|
# subshell, and the pass/fail counters it increments would be discarded.
|
||||||
|
while IFS= read -r s; do
|
||||||
|
[ -n "$s" ] || continue
|
||||||
|
|
||||||
|
# Still part of a canonical value? Then it is current branding, not a leftover.
|
||||||
|
# This is what lets the list ship pre-seeded: entries arm themselves as you
|
||||||
|
# rename. Substring, not equality, because placeholders nest - "your-domain"
|
||||||
|
# lives inside the canonical CLAP_ID, and "YOUR-USERNAME" inside the homepage.
|
||||||
|
# Over-skipping is the safe direction: a false FAIL on a name you still use
|
||||||
|
# would train you to ignore this section.
|
||||||
|
case "$CANONICAL_BLOB" in
|
||||||
|
*"$s"*) note "\"$s\" (still canonical)"; continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# These two name every retired spelling by definition - the STALE list lives in
|
||||||
|
# branding.conf and this script reads it - so neither can ever be a real hit.
|
||||||
|
hits=$(file_list 2>/dev/null | while read -r f; do
|
||||||
|
case "$f" in tools/check-branding.sh|tools/branding.conf) continue ;; esac
|
||||||
|
[ -f "$f" ] && grep -lF "$s" "$f" 2>/dev/null
|
||||||
|
done)
|
||||||
|
if [ -z "$hits" ]; then ok "no occurrences of \"$s\""
|
||||||
|
else bad "no occurrences of \"$s\"" "still present in: $(echo "$hits" | tr '\n' ' ')"; fi
|
||||||
|
done <<STALE_LIST
|
||||||
|
$STALE
|
||||||
|
STALE_LIST
|
||||||
|
|
||||||
|
# Template scaffolding, not drift: TODOs are legitimate while you are still
|
||||||
|
# building. Reported so a finished plugin does not quietly ship with them.
|
||||||
|
# `grep -c` prints 0 AND exits 1 when there are no matches, so `|| echo 0`
|
||||||
|
# would append a second zero and make the count unparseable. Swallow the
|
||||||
|
# status instead, and default an empty result (missing file) to zero.
|
||||||
|
todos=$(grep -c 'TODO' src/lib.rs 2>/dev/null || :)
|
||||||
|
[ -n "$todos" ] || todos=0
|
||||||
|
printf '\nscaffolding\n'
|
||||||
|
if [ "$todos" -eq 0 ]; then ok "no TODO markers left in src/lib.rs"
|
||||||
|
else info "$todos TODO marker(s) still in src/lib.rs (not a failure)"; fi
|
||||||
|
|
||||||
|
printf '\n%d passed, %d failed' "$pass" "$fail"
|
||||||
|
[ "$skip" -gt 0 ] && printf ', %d skipped' "$skip"
|
||||||
|
printf '\n'
|
||||||
|
[ "$fail" -eq 0 ] || exit 1
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "xtask"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23" }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
fn main() -> nih_plug_xtask::Result<()> {
|
||||||
|
nih_plug_xtask::main()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user