Compare commits

...

34 Commits

Author SHA1 Message Date
Mikkeli Matlock 74dfdc50cc Merge branch 'fix/gitattributes-stray-cr' 2026-09-14 01:41:30 +09:00
Mikkeli Matlock 94d41d5c8a fix: remove a stray CR byte from .gitattributes
The previous commit wrote a literal carriage-return byte into a comment, where
the text was meant to show the two characters backslash-r. Git's content sniffing
treats a lone CR as a sign of binary data, so .gitattributes itself was classed
-text (git ls-files --eol reported i/-text): the one file in the repository
that the new LF policy could not normalise.

The rules themselves were never affected - the byte sat inside a comment, and
deploy.bat and the sources were already checking out with the intended endings.

The comment now says "a trailing CR byte" in words. .gitattributes reports
i/lf w/lf, and `git add --renormalize .` stages nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-09-14 01:41:30 +09:00
Mikkeli Matlock b27ef57676 Merge branch 'chore/lf-line-endings' 2026-09-14 01:39:09 +09:00
Mikkeli Matlock c940f31ec7 chore: pin LF line endings in .gitattributes
The branding checker failed all 29 of its sites on Windows while passing on the
Linux CI runner. Nothing had drifted. Git for Windows installs with
core.autocrlf=true, which checked tools/branding-sites.tsv out as CRLF, and the
checker reads that table with `read` - so every row's last field, the pattern,
gained a trailing \r and matched nothing. Only *.sh was pinned to LF, which is
why the scripts were fine and their data was not.

Set the policy in the repository rather than trusting each machine:

    * text=auto eol=lf       LF in the repo and every working tree, every OS
    *.sh text eol=lf         kept explicit: a CRLF shebang is a hard failure
    *.bat/*.cmd eol=crlf     the one exception; cmd.exe misparses LF batch files

An attribute beats core.autocrlf, so a fresh clone gets LF however its machine
is configured. Verified in a throwaway repo: under autocrlf=true a text file
checks out LF and a .bat checks out CRLF.

No blob changes. Every committed blob was already LF (git ls-files --eol shows
i/lf throughout) and `git add --renormalize .` stages nothing but this file.
The working tree was re-checked-out to match: 25 files CRLF -> LF, deploy.bat
LF -> CRLF. Checker 40 passed / 0 failed, build clean, 20 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-09-14 01:39:09 +09:00
mikkeli a8f1244b00 Merge branch 'ci/validate-workflow-changes'
CI / Build (push) Successful in 30s
Include the workflow file in both path filters so CI changes are self-validating.
2026-09-04 19:33:12 +09:00
mikkeli d107127017 ci: let a workflow change validate itself
Adds .github/workflows/** to both path filters. A broken step, a moved
script path or a wrong runner label now fails on the commit that caused
it, instead of surfacing later against an unrelated source change and
looking like that change's fault.

This session produced exactly that bug: after the branding tools moved
under tools/, the workflow still ran `sh check-branding.sh` at the old
path, and it was caught by reading the file rather than by CI.

The limit is worth stating: this validates the job body, not the trigger
block. Break `on:` and the symptom is silence, which is indistinguishable
from success.

Workflow edits are rare enough that the extra builds cost nothing, and CI
config is the definition of how the project is built and verified — it
belongs on the source side of "source code edits only", not with the docs.

kolmekivi additionally gains the pull_request path filter the other two
already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-09-04 19:33:12 +09:00
mikkeli d51de80de8 Merge branch 'ci/tighten-triggers'
Apply the same source-path filter to pull_request that push already had.
2026-09-04 19:26:35 +09:00
mikkeli 7a92485cf1 ci: path-filter the pull_request trigger too
The push trigger was already restricted to master and to source paths,
but pull_request carried only a branch filter — so a PR touching nothing
but the README would have run a full build. A branch filter constrains
where a workflow fires, not what fires it.

Both filters now list the same six paths. A docs-only change runs nothing,
on either trigger.

Note that .github/workflows/** is deliberately absent from both lists, so
editing this file does not run it. That is the intended trade: fewer
pointless builds, at the cost of a broken workflow surfacing on the next
source change rather than immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-09-04 19:26:35 +09:00
mikkeli 6c0332d79e Merge branch 'fix/f32-literal-fallback'
CI / Build (push) Successful in 24s
Suffix the f32 literals rustc is phasing out, and deny future_incompatible so the
next deferred hard error fails the build immediately.
2026-09-03 08:25:36 +09:00
mikkeli 309a152d58 fix: suffix f32 literals and deny future-incompatible warnings
Sixteen `Stroke::new(1.0, ...)` calls in the editor relied on a numeric
inference fallback that rustc is removing:

  warning: falling back to `f32` as the trait bound `f32: From<f64>` is
  not satisfied
  = warning: this was previously accepted by the compiler but is being
    phased out; it will become a hard error in a future release!

These were never clippy lints - a plain `cargo build` emitted all sixteen
- and they are not style. They are a dated compile break: the code stops
building on whichever release drops the fallback. Applied via `cargo fix`,
since rustc marks the suggestion machine-applicable; the diff is sixteen
literals gaining an `_f32` suffix and nothing else. The values were
already f32, just inferred.

Denying the future_incompatible group makes the next one a build failure
the day it appears rather than something that accumulates unnoticed until
a toolchain bump breaks the build on a day nobody chose. Verified by
reverting one literal: the build then fails with exit 101 instead of
warning.

Pinning rust-toolchain.toml was considered and rejected for this purpose.
It buys reproducibility, but it works against early warning - a pinned
toolchain finds out about the next deferred hard error later, not sooner.

Build warnings drop from 17 to 0. The 15 remaining clippy lints are
genuine style suggestions and are untouched, as is clippy's absence from
CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-09-03 08:25:36 +09:00
mikkeli a62efe44b3 Merge branch 'feat/branding-toolkit'
CI / Build (push) Successful in 21s
Adopt the template's four-file branding toolkit under tools/, with this plugin's
identity as data rather than forked shell.
2026-09-02 16:39:12 +09:00
mikkeli 3a144064e3 feat: adopt the template's branding toolkit
Replaces the single check-branding.sh with the four-file toolkit from
musiikki/nih-plug-template, moved under tools/ alongside where the deploy
scripts will eventually live.

The two scripts are byte-identical to the template's; everything specific
to this plugin lives in tools/branding.conf and tools/branding-sites.tsv.
That is the point of the split: syncing an improvement is now a file copy
rather than a shell merge, which is what the last two syncs had become.

This repo's data differs from the template's in ways that exercise the
design rather than fight it: params live in src/params.rs rather than
src/lib.rs, deploy.ps1 exists so its three rows check instead of skip, and
the CI bundle command carries no --features flag because VST3 is always on
here. All of that is table data now, not forked shell.

Nothing needed overriding: PKG, STRUCT and PARAMS_STRUCT all derive
correctly from PLUGIN_NAME ("Bari 206" -> bari_206 / Bari206 /
Bari206Params). AUTHOR is pinned because this plugin separates the person
who wrote it from the organisation that vends it — a distinction the
template could not express until integrating here surfaced it.

Adopting it required two upstream fixes, both made in the template first
so the scripts stay identical: cargo AUTHOR as a value distinct from
Plugin::VENDOR, and site patterns narrowed to the smallest distinctive
fragment after this repo's rustfmt-wrapped CLAP_DESCRIPTION proved a
statement-spanning pattern cannot match correctly formatted source.

CI is repointed at tools/check-branding.sh, and its path filter widened to
tools/** — the old filter named a file that no longer exists, so a change
to the toolkit would have triggered no CI at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-09-02 16:39:12 +09:00
Mikkeli Matlock 3161aa9388 chore: seeded colours
CI / Build (push) Successful in 19s
2026-09-02 16:31:16 +09:00
Mikkeli Matlock d87934c428 Merge branch 'feat/palette'
CI / Build (push) Successful in 19s
2026-08-31 23:28:26 +09:00
Mikkeli Matlock 1361d7c919 feat: extract a two-tier colour palette
Colour was 35 call sites and ~29 distinct literals spread across four widget
files, with no egui Style or Visuals set at all - so the hand-painted widgets
were hand-coloured while ParamSlider and the labels sat on stock egui defaults,
which is most of why the UI did not read as one thing.

The audit turned up more than duplication. Two different panel blacks
((16,16,20) and (20,20,24)) for the same intended surface; three reds, two
greens and two blues close enough to look like mistakes; eight greys doing
about four jobs. And a genuine collision: blue/green/orange meant LOW/MID/HIGH
in the crossover and input/output/gain-reduction in the scope, so orange meant
"the high band" in one widget and "gain reduction" in the one beside it.

The rule that resolves it, and that the module documents: channel hue is
chrome, functional colour is data. A pixel showing a value comes from the
functional palette and means the same thing in every channel; a pixel saying
which channel you are looking at comes from that channel's hue and never
touches the data. The scope turns out not to need arbitration at all - it is
tabbed, one channel at a time, so its three traces never compete with a hue.

Two tiers. Seeds are a short hand-picked list: four channel hues, the
functional colours, six neutrals. Everything else derives from a hue via
`channel()`, so recolouring a channel is one constant. Derivation is const fn -
including the HSV conversion, written out because ecolor's is not const - so
the whole table is built at compile time and costs nothing at runtime.

Chosen so the hues describe the colours already in use: the derived crossover
strips land within 1-3 units of the literals they replace. The two drag handles
shift by ~17 on one channel, being now consistently saturated with the rest.

Also lands the one deliberate visual change: the meter's four cells were
identical and distinguishable only by the label underneath, which is where
channel identity was hardest to read. They now carry a dark channel wash and an
accent rule, while the bars stay strictly functional.

Visuals are wired from the same seeds in the editor's build closure, so egui's
own widgets stop looking foreign. meter.rs's local level_color is gone; the
palette owns the headroom ramp rather than there being two places to edit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 23:28:26 +09:00
Mikkeli Matlock 6ab9fb9c3f Merge branch 'rename/bari-206'
CI / Build (push) Successful in 20s
2026-08-31 22:55:41 +09:00
Mikkeli Matlock 37f34e1175 rename: Codename 206 -> Bari 206
Retires the working codename now rather than at 1.0, because the two identity
constants a DAW uses to recognise a plugin across sessions can only be changed
for free while nothing has saved a project with it. Verified: zero references
to the plugin across all 69 FL project backups, under any identity string.

Derived spellings:
    Cargo package     codename_206        -> bari_206
    bundle / DAW name Codename 206 |
                      206 prototype       -> Bari 206      (unified; drops "prototype")
    Rust types        Codename206(Params) -> Bari206(Params)
    CLAP_ID           com.mikkeli.codename-206
                                          -> org.novoyuuparosk.bari-206
    VST3_CLASS_ID     Codename206Maxi!    -> Bari206Multiband

CLAP_ID moves to reverse-DNS on a domain actually controlled, and stops
disagreeing with a VENDOR that was already Novoyuuparosk. VST3_CLASS_ID stays
exactly 16 bytes and drops "Maxi", which sat close to Image-Line's Maximus and
Maximizer in the same product category.

Author identity converges on Mikkeli Matlock <mikkeli@novoyuuparosk.org>;
Cargo authors had been carrying a personal gmail address while Plugin::EMAIL
already used the org one. VENDOR stays Novoyuuparosk, which is the org rather
than the person, and copyright stays with the person.

Deliberately unchanged: the repo, its remote, the local directory and the
homepage URL all keep "codename-206". Renaming those buys nothing and costs a
Gitea rename, a remote rewrite and a directory move, so the bare "codename-206"
is current branding rather than residue - and is the one spelling the STALE
list must not contain.

Driven by check-branding.sh rather than by grep: setting the canonical block
listed all 26 sites, including .github/workflows/ci.yaml, which no explicit
check covered. That gap is now closed by a positive check on the CI bundle
command, so a future rename fails in seconds instead of after a full compile.
STALE now carries both the retired and the new spellings, so the next rename
is self-checking too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 22:55:41 +09:00
mikkeli 73edf58b32 Merge branch 'ci/build-gate'
CI / Build (push) Successful in 22s
Add the CI gate migrated from nih-plug-template: branding check, build, test, bundle.
2026-08-31 22:34:31 +09:00
mikkeli 193cee0aef ci: gate pushes on a build, test and bundle
Migrated from musiikki/nih-plug-template, adapted for this repo: the
template gates VST3 behind an opt-in `vst3` feature and so builds twice,
while this plugin always ships VST3 (nih_plug is taken with its default
features), so one build covers both formats.

The branding check runs first because it needs no toolchain and finishes
in under a second, so a half-landed rename fails before a build is spent
on it. The bundle step is included because `cargo build` never exercises
the xtask bundler, and bundler.toml drifting from the crate name stays
invisible until you try to produce an artifact.

Performance is deliberately not gated. `Plugin::process()` cannot be
called from a test — `ProcessContext` requires a `&Transport`, whose
constructor and fields are `pub(crate)` to nih_plug — so any realtime
check would have to target the `src/dsp/` types directly, and a meaningful
one needs an audio deadline rather than a shared CI box.

Verified by running the exact step sequence as the gitea-runner user under
the service's environment: 28 branding checks, 20 unit tests, both bundles
produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-08-31 22:34:31 +09:00
mikkeli 814dd5eacd Merge branch 'chore/relicense-gpl3'
Restore GPL-3.0-or-later, which the VST3 build always required, and add the
ISC attribution owed to the NIH-plug upstream.
2026-08-31 22:33:08 +09:00
mikkeli 466ab478c9 chore: relicense to GPL-3.0-or-later and credit the ISC upstream
Commit 057d630 switched this repo from GPL-3.0 to Apache-2.0, which the
build contradicts: src/lib.rs calls nih_export_vst3! unconditionally and
nih_plug is taken with default features, so every build links vst3-sys —
GPLv3-or-later by its own authors' choice. deploy.ps1 then installs that
binary. The declared licence and the artifact's actual terms disagreed,
and the original GPL-3.0 was correct.

Given the intent to distribute non-profit and open-source, GPL costs
nothing here, so this restores it rather than dropping VST3 to keep a
permissive licence.

Also adds the ISC notice owed to Robbert van der Helm: the plugin
scaffolding derives from the NIH-plug cookiecutter template and example
plugins, whose one condition is that the copyright and permission notice
travel with all copies. The README credited the template under Reference
Material, which is courtesy, not compliance. ISC is GPL-compatible, so the
notice is retained in NOTICE rather than superseded.

Corrects the README's claim that Steinberg's SDK licence must be accepted
before distributing. No SDK source is vendored or redistributed, so there
is nothing to accept at build time. The binding constraint is vst3-sys's
GPLv3; Steinberg's trademark in "VST" is a separate regime that no source
licence resolves.

Copyright in the Irene <irene@novoyuuparosk.org> commits is held by the
repository owner (an agent identity), so no third-party agreement was
needed for this relicense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHt4DykxXaQnyaJfyS8KCn
2026-08-31 22:32:05 +09:00
Mikkeli Matlock 87a6f2e276 Merge branch 'fix/per-band-rms-window' 2026-08-31 22:10:52 +09:00
Mikkeli Matlock 570dd65fa8 fix: give each band its own RMS window instead of a global 5 ms
5 ms was too short to be an RMS average at all. Squaring the detector signal
puts a component at twice the fundamental, and a one-pole that does not span a
cycle passes it straight through, so the detector ripples and the gain
intermodulates with the bass. Measured peak-to-peak detector ripple at 5 ms:

     30 Hz  50 Hz  80 Hz  120 Hz  200 Hz  500 Hz  1 kHz
      4.42   2.72   1.72    1.15    0.69    0.28   0.14   dB

That lands squarely on the low band, whose crossover reaches down to 30 Hz,
and attack is allowed to go to 0 ms so nothing downstream is guaranteed to
mask it. Above ~500 Hz the same window was so short that RMS mode barely
differed from peak, making the mode switch do less than it appeared to.

The fix is per-band rather than one longer constant, because the required
window is set by how low the band reaches and a multiband already knows that.
Cost is response time (95% of a step: ~12 ms at 5 ms, ~117 ms at 50 ms), so
each band gets only what it needs:

    LOW  50 ms   30 Hz ripple 4.42 -> 0.46 dB; slow is wanted down here anyway
    MID  20 ms   body without dulling the band carrying most transients
    HIGH 10 ms   nothing up there ripples, so stay responsive
    ALL  35 ms   full-band, so it has to cover the low content too

RMS_WINDOW_MS moves from a compressor constant to a table in lib.rs indexed by
the band consts, and Compressor::prepare takes the window - the compressor has
no business knowing which band it is.

rms_window_suppresses_low_frequency_ripple pins the property: the LOW window
holds 30 Hz under 1 dB, a 5 ms window is far worse on the same signal (so the
bound cannot pass for the wrong reason), ripple falls monotonically with window
length, and the HIGH band's short window stays clean at 5 kHz.

This changes how the plugin sounds in RMS mode - needs an ear, not just tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 22:10:52 +09:00
Mikkeli Matlock 058d73f6cd Merge branch 'perf/true-peak-early-out' 2026-08-31 21:55:41 +09:00
Mikkeli Matlock 5958b077e2 perf: lift idle per-sample smoothers out of the process loop
Every sample ran 20 Smoother::next() calls and 4 db_to_gain_fast() - one set
per channel for pre-gain, makeup, mix, low slope and low curve - whether or not
anything was actually moving. Measured standalone at ~17 ns/sample, so roughly
8% of the chain, paid permanently for the rare case where a knob is in motion.

Params here only change on block boundaries (we do not split blocks on events),
so a smoother idle at the top of a block stays idle for that block. When every
smoother feeding a channel is idle - i.e. nobody is touching anything, which is
the overwhelming common case - its per-sample values are constants and lift out
of the loop. The moving case keeps the original per-sample path unchanged.

Unlike the true-peak early-out this is an always-on saving, not a
signal-dependent one.

Note this path has no automated coverage: process() needs a host to drive it,
so the DSP tests do not reach it. The logic is small and the moving case is
byte-for-byte the old code, but automation smoothing is worth an ear in FL
before trusting it - move a pre-gain or mix knob and listen for zipper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:55:40 +09:00
Mikkeli Matlock b862f5a971 perf: skip the true-peak evaluation when it provably cannot reach the ceiling
The polyphase evaluation is PHASES*TAPS_PER_PHASE multiply-adds per channel
(96 for stereo) on every sample, unconditionally, and measured as 42 ns/sample
- 17% of the whole chain, and the largest single item in it. It is also the
only part that is pure throughput rather than a serial dependency chain, which
makes it the one worth attacking.

It is skippable. The interpolator is linear, so over its FIR support

    |true peak| <= peak_gain * max|history|,  peak_gain = max_phase sum |c|

and when that bound sits under the ceiling the true peak provably cannot reach
it. The limiter's gain target would be 1.0 for that sample either way, so
feeding the sample peak instead cannot change the outcome - a genuinely loud
neighbour still gets caught by its own evaluation.

- oversampler: split max_true_peak into push (cheap, must run every sample or
  the FIR history develops holes) and evaluate (the expensive half, skippable).
  Expose peak_gain and SUPPORT_LEN so a caller can compute the bound.
- limiter: track the sample peak over the FIR support with the SlidingMax
  already used for the look-ahead window, and skip evaluate when bounded under.

Measured, 48 kHz stereo:
  limiter, quiet 0.1 (skips)            66 -> 31 ns/sample
  limiter, hot 0.95 (always evaluates)  66 -> 72 ns/sample   (no real change)
  FULL CHAIN                           232 -> 195 ns/sample

So this pays off on material that is not pinned to the ceiling and costs
nothing when it is. On a maximizer the hot case is common, so treat the win as
situational rather than a flat 16%.

evaluate_never_exceeds_peak_gain_bound asserts the bound directly against
adversarial input (full-scale square waves, impulses), since the whole
optimisation rests on it, and also asserts it is not vacuously loose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:55:40 +09:00
Mikkeli Matlock 43483dd303 Merge branch 'feat/sync-branding-checker' 2026-08-31 21:25:24 +09:00
Mikkeli Matlock b86d5d3eaf feat: sync branding checker from nih-plug-template
The template grew a considerably more thorough version of the checker this
repo seeded: vendor/email/homepage/description coverage, Rust type names,
CLAP_ID reverse-DNS validation, opt_check so a missing file skips instead
of failing, a git/find fallback so the stale scan cannot pass vacuously
outside a checkout, and a TODO scaffolding report. Take that body verbatim
rather than let two copies drift; fixes belong in the template first.

Three adaptations, all structural rather than stylistic:
- params structs live in src/params.rs here, not src/lib.rs
- CLAP_DESCRIPTION wraps onto a second line, so the const name and its
  value never share one line; match the value alone
- STALE is seeded with this repo's CURRENT spellings. Each is skipped
  while still canonical and arms the moment that value changes, so the
  planned "codename 206" -> "206" rename checks itself: every spelling
  left behind becomes a FAIL naming the files that still hold it.

28 passed, 0 failed, 8 skipped. Verified against drift, not just for a
green run: wrong params name, wrong CLAP_DESCRIPTION, and a simulated
rename each fail with exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:25:23 +09:00
Mikkeli Matlock 17c602bb10 Merge branch 'perf/dsp-hot-path' 2026-08-31 21:19:29 +09:00
Mikkeli Matlock 03d60a157b perf: remove per-sample waste from the compressor/limiter hot path
Found by reading the audio-thread code, not by profiling - no profiler is
set up in this project yet. All four are algorithmic or arithmetic wins,
none change the DSP result; the existing tests still pass.

- oversampler: max_true_peak recomputed its circular-history tap index
  (including a % TAPS_PER_PHASE) inside the phase x channel loop, though
  the index depends only on pos/k - 8x redundant. Hoisted to a per-sample
  precomputed index array. This runs unconditionally every sample (it
  feeds the limiter's true-peak detector, not gated by metering), so it
  was the highest-value fix.

- limiter: the sliding-window max rescanned the entire look-ahead ring
  (~73 samples at 48kHz/1.5ms) every sample - O(window) per sample.
  Replaced with an O(1)-amortized monotonic-deque sliding max, backed by
  preallocated ring arrays and RT-safe. Covered by a new test against a
  naive brute-force reference. Also precomputed the constant true-peak
  margin gain in prepare() instead of a powf every sample.

- compressor: replaced per-sample log10/powf(10, x/20) with the
  equivalent ln/exp forms, mirroring nih-plug's own gain_to_db_fast /
  db_to_gain_fast. Called 4x/sample (3 bands + 'All').

- both: replaced % capacity ring-index math (a real integer division,
  since capacity is not a power of two) with a conditional-subtract
  wrap_add helper. Left the oversampler's % TAPS_PER_PHASE alone - that
  divisor is a compile-time constant LLVM already turns into a multiply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:19:23 +09:00
Mikkeli Matlock eda3d09399 feat: branding consistency checker
Branding is duplicated across Cargo.toml, bundler.toml, deploy.ps1 and
src/lib.rs with nothing keeping them in sync, so a rename drifts silently:
the bundle builds under one name while deploy.ps1 looks for another.

check-branding.sh declares the canonical identity once and asserts every
copy matches, including that VST3_CLASS_ID is exactly 16 bytes. A STALE
list catches leftovers of the old name after a rename. Portable POSIX sh
so it works on Windows (Git Bash) and Linux alike; .gitattributes pins
*.sh to LF so core.autocrlf=true cannot break the shebang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-29 02:23:28 +09:00
irene 057d630c5b Switch license from GPL-3.0 to Apache-2.0
- Replace LICENSE with properly filled Apache-2.0 (Mikkeli Matlock 2026)
- Update Cargo.toml license field
- Add SPDX-License-Identifier headers to all .rs source files
2026-08-21 01:06:46 +09:00
Mikkeli Matlock abc9ea8b4f feat: live operating-point fill + -6 dB refs on the gain curve
Add a per-channel input_level meter (post pre-gain, peak-with-decay) and use it
to shade the gain curve: a translucent fill under the curve from the floor up to
the current input, its right edge riding the curve (width = input, height =
output), plus a dot at the operating point. Add -6 dBFS reference lines on both
axes with a tick label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:19:16 +09:00
Mikkeli Matlock 45735f71f7 feat: bipolar mid-bulge low-curve anchored at silence and the knee
Rework Low Curve from a one-sided saturating bend into a bipolar mid-bulge: the
low region is anchored at BOTH the silence floor and the knee, Low Slope tilts
the straight line between them, and Low Curve (-1..1) bows that line in the
middle (4*t*(1-t), peak +/-12 dB) without moving either endpoint. Positive
bulges up (boost the quiet middle), negative down (suppress). Still serial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:19:04 +09:00
29 changed files with 1702 additions and 182 deletions
+20
View File
@@ -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
+71
View File
@@ -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
Generated
+8 -8
View File
@@ -147,6 +147,14 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "bari_206"
version = "0.1.0"
dependencies = [
"nih_plug",
"nih_plug_egui",
]
[[package]] [[package]]
name = "baseview" name = "baseview"
version = "0.1.0" version = "0.1.0"
@@ -337,14 +345,6 @@ dependencies = [
"objc", "objc",
] ]
[[package]]
name = "codename_206"
version = "0.1.0"
dependencies = [
"nih_plug",
"nih_plug_egui",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
+10 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "codename_206" name = "bari_206"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = ["Mikkeli Matlock <matlockib@gmail.com>"] authors = ["Mikkeli Matlock <mikkeli@novoyuuparosk.org>"]
homepage = "https://github.com/mikkeli/codename-206" homepage = "https://github.com/mikkeli/codename-206"
description = "A VST3/CLAP multiband compressor/limiter with a custom gain-curve display" 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 # VST3 is on by default in nih_plug, and its bindings (vst3-sys) are GPLv3. Any
@@ -17,6 +17,14 @@ members = ["xtask"]
# `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests. # `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests.
crate-type = ["cdylib", "lib"] 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] [dependencies]
# Pinned to the exact commit of the local ./nih-plug reference clone so the build # 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 # is reproducible and matches the examples we read from. Bump the `rev` together
+2 -2
View File
@@ -632,7 +632,7 @@ 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. 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.> <one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author> Copyright (C) 2026 Mikkeli Matlock
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU General Public License as published by
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author> <program> Copyright (C) 2026 Mikkeli Matlock
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.
+37
View File
@@ -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.
+32 -7
View File
@@ -1,4 +1,4 @@
# Codename 206 # Bari 206
*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.* *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
@@ -163,8 +163,8 @@ The project is already scaffolded (NIH-plug + nih_plug_egui, pinned to a fixed g
```powershell ```powershell
# Build + bundle the VST3 and CLAP # Build + bundle the VST3 and CLAP
cargo xtask bundle codename_206 --release cargo xtask bundle bari_206 --release
# Output: target\bundled\Codename 206.vst3 and Codename 206.clap # Output: target\bundled\Bari 206.vst3 and Bari 206.clap
``` ```
### Deployment ### Deployment
@@ -184,7 +184,7 @@ Use the provided script (no need to remember the details):
`deploy.bat` is a double-click wrapper around the same script. `deploy.bat` is a double-click wrapper around the same script.
After deploying, in FL Studio: **Options → Manage plugins → tick "Rescan previously failed After deploying, in FL Studio: **Options → Manage plugins → tick "Rescan previously failed
plugins" → Find installed plugins**, then search for **Codename 206**. (The rescan-failed step 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.) 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 > **Known issue (deferred):** the **CLAP** build shows its name/vendor/type correctly in FL, but
@@ -277,9 +277,34 @@ the editor being open:
Never pass DSP state to the UI directly. 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.
--- ---
+2 -2
View File
@@ -3,5 +3,5 @@
# [package_name] # [package_name]
# name = "Human Readable Plugin Name" # defaults to <package_name> # name = "Human Readable Plugin Name" # defaults to <package_name>
[codename_206] [bari_206]
name = "Codename 206" name = "Bari 206"
+4 -4
View File
@@ -1,6 +1,6 @@
<# <#
.SYNOPSIS .SYNOPSIS
Build Codename 206 and install the VST3 (and CLAP) into the system plugin folders. Build Bari 206 and install the VST3 (and CLAP) into the system plugin folders.
.DESCRIPTION .DESCRIPTION
FL Studio scans `C:\Program Files\Common Files\VST3` by default and ignores FL Studio scans `C:\Program Files\Common Files\VST3` by default and ignores
@@ -25,7 +25,7 @@
param( param(
[switch]$SkipBuild, [switch]$SkipBuild,
[switch]$User, [switch]$User,
[string]$Package = "codename_206" [string]$Package = "bari_206"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -42,8 +42,8 @@ if (-not $SkipBuild) {
if ($LASTEXITCODE -ne 0) { throw "cargo xtask bundle failed (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "cargo xtask bundle failed (exit $LASTEXITCODE)" }
} }
$vst3Name = "Codename 206.vst3" $vst3Name = "Bari 206.vst3"
$clapName = "Codename 206.clap" $clapName = "Bari 206.clap"
$bundled = Join-Path $root "target\bundled" $bundled = Join-Path $root "target\bundled"
$vst3Src = Join-Path $bundled $vst3Name $vst3Src = Join-Path $bundled $vst3Name
$clapSrc = Join-Path $bundled $clapName $clapSrc = Join-Path $bundled $clapName
+1
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Generic second-order IIR biquad, Transposed Direct Form II. //! Generic second-order IIR biquad, Transposed Direct Form II.
//! //!
//! Coefficient formulas are the RBJ Audio EQ Cookbook //! Coefficient formulas are the RBJ Audio EQ Cookbook
+148 -41
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Single full-band feed-forward compressor with look-ahead. //! Single full-band feed-forward compressor with look-ahead.
//! //!
//! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor //! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor
@@ -23,21 +24,36 @@ pub const MAX_LOOKAHEAD_MS: f32 = 5.0;
/// Most channels we ever process in one frame (our audio layouts are mono/stereo). /// Most channels we ever process in one frame (our audio layouts are mono/stereo).
const MAX_CHANNELS: usize = 2; const MAX_CHANNELS: usize = 2;
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels. /// ~ -240 dBFS; keeps the level conversion away from zero without affecting audible levels.
const LEVEL_EPS: f32 = 1e-12; 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-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 /// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
/// the editor gain-curve's display floor. /// the editor gain-curve's display floor.
const LOW_ANCHOR_DB: f32 = -60.0; const LOW_ANCHOR_DB: f32 = -60.0;
/// Exponential curvature (1/dB) for the low shaper at `low_curve` = 1. Bends the low gain toward a /// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
/// bounded saturation so the serial composition doesn't blow up. 0 = straight line. /// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
const LOW_CURVE_K_MAX: f32 = 0.1; /// 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 /// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
/// promoted to a parameter later. /// promoted to a parameter later.
const RMS_WINDOW_MS: f32 = 5.0; /// 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 // 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 // by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its
@@ -112,12 +128,23 @@ impl Compressor {
/// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`, /// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`,
/// where allocation is allowed — never from `process()`. /// where allocation is allowed — never from `process()`.
pub fn prepare(&mut self, sample_rate: f32, num_channels: usize, max_lookahead_ms: f32) { /// `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.sample_rate = sample_rate;
self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize; 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. // +1 so the oldest (output) sample and the newest (write) sample never alias.
self.capacity = self.fixed_delay + 1; self.capacity = self.fixed_delay + 1;
self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate); self.rms_coef = Self::time_to_coef(rms_window_ms, sample_rate);
let channels = num_channels.clamp(1, MAX_CHANNELS); let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels]; self.delay = vec![vec![0.0; self.capacity]; channels];
self.reset(); self.reset();
@@ -161,21 +188,23 @@ impl Compressor {
} }
} }
/// Low-level shaper gain in dB, anchored at the silence floor ([`LOW_ANCHOR_DB`]): 0 at/below /// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
/// the floor, rising with slope `low_slope - 1` and bending toward a bounded saturation set by /// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
/// `low_curve` (0..1; 0 = straight line). This reshapes the level the compressor then sees. /// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
fn low_gain_db(level_db: f32, low_slope: f32, low_curve: f32) -> f32 { /// 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; let d = level_db - LOW_ANCHOR_DB;
if d <= 0.0 { if d <= 0.0 {
return 0.0; return 0.0;
} }
let m = low_slope - 1.0; let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
let k = low_curve * LOW_CURVE_K_MAX; let t = (d / span).min(1.0); // normalized position, clamped at the knee
if k <= 1e-6 { // Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
m * d // straight line (curvature off) let slope_line = (low_slope - 1.0) * span * t;
} else { // Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
(m / k) * (1.0 - (-k * d).exp()) // saturates to m/k (bounded) 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 /// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
@@ -190,7 +219,7 @@ impl Compressor {
low_slope: f32, low_slope: f32,
low_curve: f32, low_curve: f32,
) -> f32 { ) -> f32 {
let low = Self::low_gain_db(level_db, low_slope, low_curve); 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) low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
} }
@@ -226,8 +255,12 @@ impl Compressor {
// 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads // 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. // `l` samples newer than the output, i.e. `l` samples into the output's future.
let out_pos = (self.write_pos + self.capacity - self.fixed_delay) % self.capacity; // `wrap_add` instead of `%`: capacity is rarely a power of two, so a plain modulo forces
let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity; // 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. // 3) Linked peak detector at the look-ahead tap.
let mut peak = 0.0f32; let mut peak = 0.0f32;
@@ -241,7 +274,7 @@ impl Compressor {
// active (regardless of mode) so switching peak<->RMS is seamless. // 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; 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 detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
let level_db = 20.0 * (detector + LEVEL_EPS).log10(); let level_db = (detector + LEVEL_EPS).ln() * LOG10_GAIN_TO_DB;
// Desired attenuation in dB, as a positive quantity. // Desired attenuation in dB, as a positive quantity.
let target = -Self::gain_computer( let target = -Self::gain_computer(
level_db, level_db,
@@ -258,7 +291,7 @@ impl Compressor {
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target); 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; self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0); 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 // 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. // blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
@@ -269,7 +302,19 @@ impl Compressor {
} }
// 6) Advance the write head. // 6) Advance the write head.
self.write_pos = (self.write_pos + 1) % self.capacity; 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
} }
} }
@@ -306,21 +351,22 @@ mod tests {
} }
#[test] #[test]
fn low_shaper_is_serial_into_threshold() { fn low_shaper_serial_slope_and_bipolar_bulge() {
// Serial: the low shaper reshapes the level, then the threshold sees the shaped level. let thr = -18.0;
// 30 dB above the -60 floor, 12 dB below threshold (hard knee, no curve). // Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
let (lvl, thr) = (-30.0, -18.0); // comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
// Unity slope -> just the compressor (below threshold here -> 0). assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
assert_close(Compressor::gain_computer(lvl, thr, 4.0, 0.0, 1.0, 0.0), 0.0, 1e-6);
// Boost (slope 2, straight) lifts -30 by 30 dB to 0 dB -> 18 dB over threshold, comp pulls
// back (1/4 - 1) * 18 = -13.5 -> net 30 - 13.5 = 16.5.
assert_close(Compressor::gain_computer(lvl, 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. // Cut (slope 0.5) -> -15 dB; shaped to -45, still below threshold -> net -15.
assert_close(Compressor::gain_computer(lvl, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3); assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
// Curvature bounds the low gain: slope 2 + full curve saturates the boost (~9.5 dB) so it
// no longer crosses the threshold. // Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
let g = Compressor::gain_computer(lvl, thr, 4.0, 0.0, 2.0, 1.0); let mid = -39.0; // middle of [-60, -18]
assert!((8.0..11.0).contains(&g), "expected bounded low boost ~9.5, got {g}"); 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] #[test]
@@ -347,7 +393,7 @@ mod tests {
// Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp: // 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). // reduction = -0.5 * (−6.02 − −18) = −5.99 dB, so output ≈ 0.5 * 10^(−5.99/20).
let mut comp = Compressor::new(); let mut comp = Compressor::new();
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS);
let set = settings(-18.0, 2.0, 0.0); let set = settings(-18.0, 2.0, 0.0);
let mut out = [0.0f32]; let mut out = [0.0f32];
for _ in 0..SR as usize { for _ in 0..SR as usize {
@@ -367,7 +413,7 @@ mod tests {
fn output_rms(use_rms: bool) -> f32 { fn output_rms(use_rms: bool) -> f32 {
let mut comp = Compressor::new(); let mut comp = Compressor::new();
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); comp.prepare(SR, 1, MAX_LOOKAHEAD_MS, RMS_WINDOW_DEFAULT_MS);
let mut set = settings(-30.0, 4.0, 0.0); let mut set = settings(-30.0, 4.0, 0.0);
set.use_rms = use_rms; set.use_rms = use_rms;
set.attack_coef = Compressor::time_to_coef(1.0, SR); set.attack_coef = Compressor::time_to_coef(1.0, SR);
@@ -401,7 +447,7 @@ mod tests {
// knob must NOT change that (this is what keeps live adjustment from renegotiating // knob must NOT change that (this is what keeps live adjustment from renegotiating
// latency and crashing the host). // latency and crashing the host).
let mut comp = Compressor::new(); let mut comp = Compressor::new();
comp.prepare(SR, 1, 0.5); // small max so the fixed delay is quick to test 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; let d = comp.latency() as usize;
assert!(d > 0); assert!(d > 0);
@@ -423,4 +469,65 @@ mod tests {
} }
} }
} }
/// 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");
}
} }
+1
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank. //! 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 //! Each crossover splits into a low-passed band output and a high-passed remainder that feeds
+145 -15
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Look-ahead brickwall peak limiter (base-rate). //! Look-ahead brickwall peak limiter (base-rate).
//! //!
//! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp //! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp
@@ -8,7 +9,7 @@
//! Detection is stereo-linked (one gain for all channels). This stage limits **sample** peaks at //! 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. //! the base rate; true-peak (inter-sample) limiting via oversampling is a later addition.
use super::oversampler::Oversampler; use super::oversampler::{self, Oversampler};
const MAX_CHANNELS: usize = 2; const MAX_CHANNELS: usize = 2;
/// Fixed look-ahead — also this stage's constant latency contribution. /// Fixed look-ahead — also this stage's constant latency contribution.
@@ -27,32 +28,118 @@ fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
} }
} }
/// 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 { pub struct Limiter {
/// Per-channel audio delay ring. /// Per-channel audio delay ring.
delay: Vec<Vec<f32>>, delay: Vec<Vec<f32>>,
/// Linked `|x|` history, same length as the delay ring (for the sliding maximum). /// O(1)-amortized sliding-window maximum over the linked `|x|` history (replaces an O(window)
peaks: Vec<f32>, /// full-rescan every sample).
sliding_max: SlidingMax,
capacity: usize, capacity: usize,
write_pos: usize, write_pos: usize,
fixed_delay: usize, fixed_delay: usize,
/// Current smoothed gain (<= 1). /// Current smoothed gain (<= 1).
gain: f32, gain: f32,
attack_coef: 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. /// 4× interpolator for true-peak (inter-sample) detection.
oversampler: Oversampler, 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 { impl Default for Limiter {
fn default() -> Self { fn default() -> Self {
Self { Self {
delay: Vec::new(), delay: Vec::new(),
peaks: Vec::new(), sliding_max: SlidingMax::new(),
capacity: 0, capacity: 0,
write_pos: 0, write_pos: 0,
fixed_delay: 0, fixed_delay: 0,
gain: 1.0, gain: 1.0,
attack_coef: 0.0, attack_coef: 0.0,
margin_gain: 1.0,
oversampler: Oversampler::new(), oversampler: Oversampler::new(),
support_max: SlidingMax::new(),
} }
} }
} }
@@ -67,9 +154,11 @@ impl Limiter {
self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize; self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize;
self.capacity = self.fixed_delay + 1; self.capacity = self.fixed_delay + 1;
self.attack_coef = time_to_coef(ATTACK_MS, sample_rate); 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); let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels]; self.delay = vec![vec![0.0; self.capacity]; channels];
self.peaks = vec![0.0; self.capacity]; self.sliding_max.prepare(self.capacity);
self.support_max.prepare(oversampler::SUPPORT_LEN);
self.oversampler.prepare(channels); self.oversampler.prepare(channels);
self.reset(); self.reset();
} }
@@ -78,7 +167,8 @@ impl Limiter {
for ch in &mut self.delay { for ch in &mut self.delay {
ch.iter_mut().for_each(|s| *s = 0.0); ch.iter_mut().for_each(|s| *s = 0.0);
} }
self.peaks.iter_mut().for_each(|p| *p = 0.0); self.sliding_max.reset();
self.support_max.reset();
self.oversampler.reset(); self.oversampler.reset();
self.write_pos = 0; self.write_pos = 0;
self.gain = 1.0; self.gain = 1.0;
@@ -107,24 +197,37 @@ impl Limiter {
for &x in &input[..n] { for &x in &input[..n] {
sample_peak = sample_peak.max(x.abs()); sample_peak = sample_peak.max(x.abs());
} }
let peak = sample_peak.max(self.oversampler.max_true_peak(&input[..n]));
// Target a hair below the ceiling so the (slightly under-read) true peak stays under it. // Target a hair below the ceiling so the (slightly under-read) true peak stays under it.
let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0); 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. // Write into the ring.
for ch in 0..n { for ch in 0..n {
self.delay[ch][self.write_pos] = input[ch]; self.delay[ch][self.write_pos] = input[ch];
} }
self.peaks[self.write_pos] = peak;
// Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample // 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 // (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. // exceed the ceiling, and the gain has pre-dropped for any louder sample still to come.
let mut window_max = 0.0f32; let window_max = self.sliding_max.push(peak);
for &p in &self.peaks {
window_max = window_max.max(p);
}
let target = if window_max > target_ceiling { let target = if window_max > target_ceiling {
target_ceiling / window_max target_ceiling / window_max
} else { } else {
@@ -139,12 +242,14 @@ impl Limiter {
}; };
// Output the delayed sample, clamped to the ceiling as the hard guarantee. // Output the delayed sample, clamped to the ceiling as the hard guarantee.
let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago // 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 { for ch in 0..n {
output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling); output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling);
} }
self.write_pos = (self.write_pos + 1) % self.capacity; self.write_pos = wrap_add(self.write_pos, 1, self.capacity);
} }
} }
@@ -159,6 +264,31 @@ mod tests {
time_to_coef(50.0, SR) 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] #[test]
fn never_exceeds_ceiling_on_spikes() { fn never_exceeds_ceiling_on_spikes() {
// Mostly silence with occasional large spikes — output must never exceed the ceiling. // Mostly silence with occasional large spikes — output must never exceed the ceiling.
+2 -1
View File
@@ -1,4 +1,5 @@
//! DSP building blocks for Codename 206. // 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 //! 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) //! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector)
+94 -8
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! 4× polyphase interpolation for **true-peak (inter-sample) detection only**. //! 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 //! A band-limited signal can overshoot its sample values between samples, so the digital sample
@@ -16,6 +17,9 @@
const PHASES: usize = 4; const PHASES: usize = 4;
const TAPS_PER_PHASE: usize = 12; 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; const MAX_CHANNELS: usize = 2;
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES` /// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
@@ -52,17 +56,32 @@ fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] {
coeffs 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 { pub struct Oversampler {
coeffs: [[f32; TAPS_PER_PHASE]; PHASES], coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples. /// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
history: Vec<[f32; TAPS_PER_PHASE]>, history: Vec<[f32; TAPS_PER_PHASE]>,
pos: usize, 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 { impl Default for Oversampler {
fn default() -> Self { fn default() -> Self {
let coeffs = build_coefficients();
Self { Self {
coeffs: build_coefficients(), peak_gain: build_peak_gain(&coeffs),
coeffs,
history: Vec::new(), history: Vec::new(),
pos: 0, pos: 0,
} }
@@ -89,30 +108,63 @@ impl Oversampler {
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the /// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
/// 4× grid (the reconstructed samples are not kept). /// 4× grid (the reconstructed samples are not kept).
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 { /// 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 n = input.len().min(self.history.len());
let slot = self.pos % TAPS_PER_PHASE; let slot = self.pos % TAPS_PER_PHASE;
for ch in 0..n { for ch in 0..n {
self.history[ch][slot] = input[ch]; 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; let mut peak = 0.0f32;
for ch in 0..n { for ch in 0..n {
let hist = &self.history[ch]; let hist = &self.history[ch];
for phase in &self.coeffs { for phase in &self.coeffs {
let mut acc = 0.0f32; let mut acc = 0.0f32;
for (k, &c) in phase.iter().enumerate() { for (&c, &i) in phase.iter().zip(idx.iter()) {
// k = 0 is the newest sample, increasing k goes back in time. acc += c * hist[i];
let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
acc += c * hist[idx];
} }
peak = peak.max(acc.abs()); peak = peak.max(acc.abs());
} }
} }
self.pos += 1;
peak 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)] #[cfg(test)]
@@ -146,4 +198,38 @@ mod tests {
} }
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}"); 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})");
}
} }
+13 -11
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Draggable crossover handles — replaces the two plain crossover sliders. //! 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 //! A horizontal log-frequency strip split into LOW / MID / HIGH by two draggable handles, plus a
@@ -9,7 +10,8 @@ use nih_plug_egui::egui::{
self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke, self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke,
}; };
use crate::params::Codename206Params; use crate::params::Bari206Params;
use super::palette;
/// Height of the handle strip. /// Height of the handle strip.
const GRAPH_H: f32 = 54.0; const GRAPH_H: f32 = 54.0;
@@ -29,7 +31,7 @@ fn fmt_hz(hz: f32) -> String {
} }
} }
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, setter: &ParamSetter) { pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, setter: &ParamSetter) {
let lo = params.crossover_low_hz.value(); let lo = params.crossover_low_hz.value();
let hi = params.crossover_high_hz.value(); let hi = params.crossover_high_hz.value();
// Each param's own min/max (normalized 0/1 map to the range ends). // Each param's own min/max (normalized 0/1 map to the range ends).
@@ -54,19 +56,19 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, setter: &Param
let xhi = x_for(hi); let xhi = x_for(hi);
// Three band regions. // Three band regions.
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, Color32::from_rgb(28, 38, 52)); 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, Color32::from_rgb(30, 48, 36)); 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, Color32::from_rgb(52, 38, 30)); 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| { let band_label = |cx: f32, t: &str| {
p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), Color32::from_gray(160)); 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((left + xlo) * 0.5, "LOW");
band_label((xlo + xhi) * 0.5, "MID"); band_label((xlo + xhi) * 0.5, "MID");
band_label((xhi + right) * 0.5, "HIGH"); band_label((xhi + right) * 0.5, "HIGH");
handle(ui, &p, setter, "xover_lo", xlo, lo, &params.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, Color32::from_rgb(120, 170, 230)); handle(ui, &p, setter, "xover_lo", xlo, lo, &params.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, palette::CHANNELS[0].handle);
handle(ui, &p, setter, "xover_hi", xhi, hi, &params.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, Color32::from_rgb(230, 150, 120)); handle(ui, &p, setter, "xover_hi", xhi, hi, &params.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. // Number boxes (double-click to type). Clamped to the same dynamic limits.
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -128,9 +130,9 @@ fn handle(
setter.end_set_parameter(param); setter.end_set_parameter(param);
} }
let col = if resp.dragged() || resp.hovered() { Color32::WHITE } else { color }; 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, col)); 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), Color32::from_gray(220)); 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). /// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box).
+56 -15
View File
@@ -1,19 +1,24 @@
//! Static gain-curve display (visualisation only): output level vs input level for the channel // SPDX-License-Identifier: GPL-3.0-or-later
//! currently selected in the plot. Plots the full channel transfer for the wet path: //! Static gain-curve display: output level vs input level for the channel currently selected in
//! `out = (in + pre_gain) + gain_reduction(in + pre_gain) + makeup`. Mix is not folded in (the //! the plot. Plots the wet transfer `out = (in + pre_gain) + gain_reduction(...) + makeup` (mix not
//! curve shows the wet/100% path, matching the GR-meter convention); output is clamped at 0 dBFS. //! 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_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke}; 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::dsp::compressor::Compressor;
use crate::params::Codename206Params; use crate::meters::Meters;
use crate::params::Bari206Params;
use super::palette;
/// Side length of the square plot. /// Side length of the square plot.
const CURVE_SIZE: f32 = 150.0; const CURVE_SIZE: f32 = 150.0;
/// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS). /// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS).
const FLOOR_DB: f32 = -60.0; const FLOOR_DB: f32 = -60.0;
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usize) { pub(super) fn draw(ui: &mut egui::Ui, params: &Bari206Params, selected: usize, meters: &Meters) {
let labels = ["LOW", "MID", "HIGH", "ALL"]; let labels = ["LOW", "MID", "HIGH", "ALL"];
let ch = selected.min(3); let ch = selected.min(3);
let cp = match ch { let cp = match ch {
@@ -33,7 +38,7 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz
ui.label(format!("Curve: {}", labels[ch])); ui.label(format!("Curve: {}", labels[ch]));
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover()); let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
let p = ui.painter_at(rect); let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20)); p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
let inset = 2.0; let inset = 2.0;
let (left, right, top, bottom) = let (left, right, top, bottom) =
@@ -43,11 +48,18 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz
let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w; let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w;
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h; 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. // Unity reference (out = in), bottom-left to top-right.
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0, Color32::from_gray(45))); 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). // 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)); let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, Color32::from_rgb(80, 60, 45))); 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.) // Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
let n = 96; let n = 96;
@@ -59,9 +71,38 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz
let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0); let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
pts.push(pos2(x_for(in_db), y_for(out_db))); pts.push(pos2(x_for(in_db), y_for(out_db)));
} }
p.add(egui::Shape::line(pts, Stroke::new(1.6, Color32::from_rgb(120, 200, 160)))); // 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));
// Corner dB ticks. let ext_in = (driven_now - pre).clamp(FLOOR_DB, 0.0); // external input -> curve x
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), Color32::from_gray(90)); let x_now = x_for(ext_in);
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), Color32::from_gray(90)); 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);
} }
+26 -19
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Per-channel level + gain-reduction meters, each with its own latching ceiling/over lamp. //! 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 //! Each channel is a `|L | GR | R|` cluster (output level left/right, mono gain reduction in the
@@ -14,6 +15,7 @@ use std::sync::atomic::Ordering;
use super::METER_FLOOR_DB; use super::METER_FLOOR_DB;
use crate::meters::{Meters, NUM_CHANNELS}; use crate::meters::{Meters, NUM_CHANNELS};
use super::palette;
/// Full-scale of the gain-reduction bar (fills downward from the top). /// Full-scale of the gain-reduction bar (fills downward from the top).
const GR_FULL_DB: f32 = 24.0; const GR_FULL_DB: f32 = 24.0;
@@ -46,7 +48,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
let (rect, _) = let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover()); ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
let p = ui.painter_at(rect); let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24)); 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 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 bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
@@ -57,6 +59,22 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
for i in 0..NUM_CHANNELS { for i in 0..NUM_CHANNELS {
let cell_left = rect.left() + i as f32 * cell_w; 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 group_w = bar_w * 3.0 + gap * 2.0;
let bx = cell_left + (cell_w - group_w) * 0.5; let bx = cell_left + (cell_w - group_w) * 0.5;
@@ -70,16 +88,16 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed); let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0); let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
v_bar(&p, bx, bar_w, top, bottom, l_frac, level_color(l_db), false); 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, Color32::from_rgb(240, 150, 60), true); 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, level_color(r_db), false); v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, palette::level_color(r_db), false);
p.text( p.text(
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0), pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
Align2::CENTER_BOTTOM, Align2::CENTER_BOTTOM,
labels[i], labels[i],
FontId::proportional(12.0), FontId::proportional(12.0),
Color32::from_gray(200), palette::TEXT_BRIGHT,
); );
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the // Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
@@ -108,9 +126,9 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
} }
} }
let lamp = if state.ceiling_trigger[i].is_some() { let lamp = if state.ceiling_trigger[i].is_some() {
Color32::from_rgb(255, 40, 40) palette::LAMP_ON
} else { } else {
Color32::from_rgb(40, 12, 12) palette::LAMP_OFF
}; };
p.circle_filled(lamp_center, 5.0, lamp); p.circle_filled(lamp_center, 5.0, lamp);
} }
@@ -119,7 +137,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the /// 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). /// 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) { fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
let track = Color32::from_rgb(34, 34, 40); let track = palette::TRACK;
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, 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 h = (bottom - top) * frac.clamp(0.0, 1.0);
@@ -130,14 +148,3 @@ fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Co
}; };
p.rect_filled(filled, CornerRadius::ZERO, fill); p.rect_filled(filled, CornerRadius::ZERO, fill);
} }
/// Level-bar colour: green below -6 dB, yellow approaching, red near 0 dBFS.
fn level_color(db: f32) -> Color32 {
if db >= -1.0 {
Color32::from_rgb(235, 70, 60)
} else if db >= -6.0 {
Color32::from_rgb(230, 200, 70)
} else {
Color32::from_rgb(90, 200, 110)
}
}
+8 -6
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! egui editor: assembly + control layout. //! egui editor: assembly + control layout.
//! //!
//! The aggregator. Builds the editor window and lays out the heading, the meter panel //! The aggregator. Builds the editor window and lays out the heading, the meter panel
@@ -16,12 +17,13 @@ use nih_plug_egui::{
use std::sync::Arc; use std::sync::Arc;
use crate::meters::Meters; use crate::meters::Meters;
use crate::params::{Codename206Params, CompressorParams}; use crate::params::{Bari206Params, CompressorParams};
use crate::Codename206; use crate::Bari206;
mod crossover; mod crossover;
mod gain_curve; mod gain_curve;
mod meter; mod meter;
mod palette;
mod plot; mod plot;
/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS). /// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS).
@@ -35,12 +37,12 @@ struct EditorState {
} }
/// Build the plugin editor over shared handles to the params and meter state. /// Build the plugin editor over shared handles to the params and meter state.
pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> { pub(crate) fn create(params: Arc<Bari206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
let egui_state = params.editor_state.clone(); let egui_state = params.editor_state.clone();
create_egui_editor( create_egui_editor(
params.editor_state.clone(), params.editor_state.clone(),
EditorState::default(), EditorState::default(),
|_, _| {}, |egui_ctx, _| palette::apply_visuals(egui_ctx),
move |egui_ctx, setter, state| { move |egui_ctx, setter, state| {
// Keep frames coming so the meters animate and the lamp can time out while open. // Keep frames coming so the meters animate and the lamp can time out while open.
egui_ctx.request_repaint(); egui_ctx.request_repaint();
@@ -78,13 +80,13 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
.min_size(Vec2::new(480.0, 320.0)) .min_size(Vec2::new(480.0, 320.0))
.show(egui_ctx, egui_state.as_ref(), |ui| { .show(egui_ctx, egui_state.as_ref(), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(Codename206::NAME); ui.heading(Bari206::NAME);
meter::draw(ui, &meters, &mut state.meter); meter::draw(ui, &meters, &mut state.meter);
ui.separator(); ui.separator();
// Gain curve (left, square) beside the scrolling plot (right, fills the rest). // Gain curve (left, square) beside the scrolling plot (right, fills the rest).
let selected = state.plot.selected; let selected = state.plot.selected;
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
ui.vertical(|ui| gain_curve::draw(ui, &params, selected)); ui.vertical(|ui| gain_curve::draw(ui, &params, selected, &meters));
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot)); ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
}); });
ui.separator(); ui.separator();
+265
View File
@@ -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);
}
+11 -9
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector. //! 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 //! Histories for all four channels run continuously (cheap), so switching tabs shows that
@@ -11,15 +12,16 @@ use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontI
use super::METER_FLOOR_DB; use super::METER_FLOOR_DB;
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS}; use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
use super::palette;
/// Height of the plot panel. /// Height of the plot panel.
const PLOT_PANEL_H: f32 = 150.0; const PLOT_PANEL_H: f32 = 150.0;
/// Number of columns held in the history ring. /// Number of columns held in the history ring.
const PLOT_N: usize = 256; const PLOT_N: usize = 256;
const COLOR_IN: Color32 = Color32::from_rgb(90, 170, 235); const COLOR_IN: Color32 = palette::SIGNAL_IN;
const COLOR_OUT: Color32 = Color32::from_rgb(90, 200, 110); const COLOR_OUT: Color32 = palette::SIGNAL_OUT;
const COLOR_GR: Color32 = Color32::from_rgb(240, 150, 60); const COLOR_GR: Color32 = palette::SIGNAL_GR;
/// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns. /// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns.
struct PlotHistory { struct PlotHistory {
@@ -155,7 +157,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
let (rect, _) = let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover()); ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover());
let p = ui.painter_at(rect); let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20)); p.rect_filled(rect, CornerRadius::ZERO, palette::SURFACE);
let (top, bottom, left, right) = let (top, bottom, left, right) =
(rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0); (rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0);
@@ -168,13 +170,13 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
// Gridlines (dB). // Gridlines (dB).
for &g in &[0.0f32, -12.0, -24.0, -48.0] { for &g in &[0.0f32, -12.0, -24.0, -48.0] {
let y = y_for_db(g); let y = y_for_db(g);
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, Color32::from_gray(40))); p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0_f32, palette::GRID_FAINT));
p.text( p.text(
pos2(left + 2.0, y), pos2(left + 2.0, y),
Align2::LEFT_BOTTOM, Align2::LEFT_BOTTOM,
format!("{g:.0}"), format!("{g:.0}"),
FontId::proportional(9.0), FontId::proportional(9.0),
Color32::from_gray(90), palette::TEXT_DIM,
); );
} }
@@ -192,7 +194,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
// Fill as a strip of per-segment convex quads down to the baseline. A single // 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 // concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
// triangles), so build convex pieces — one box per time unit — instead. // triangles), so build convex pieces — one box per time unit — instead.
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40); let fill_col = palette::with_alpha(color, 40);
for seg in pts.windows(2) { for seg in pts.windows(2) {
let (a, b) = (seg[0], seg[1]); let (a, b) = (seg[0], seg[1]);
p.add(egui::Shape::convex_polygon( p.add(egui::Shape::convex_polygon(
@@ -202,7 +204,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
)); ));
} }
} }
p.add(egui::Shape::line(pts, Stroke::new(1.5, color))); 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.in_db[c], &|db| db, COLOR_IN, true);
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true); draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
@@ -213,7 +215,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of // 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. // 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 dx = width / (PLOT_N - 1) as f32;
let marker = Color32::from_rgb(235, 45, 45); let marker = palette::CLIP;
for k in 0..len { for k in 0..len {
let idx = (write + PLOT_N - len + k) % PLOT_N; let idx = (write + PLOT_N - len + k) % PLOT_N;
if state.history.hit[idx] { if state.history.hit[idx] {
+92 -25
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
use nih_plug::prelude::*; use nih_plug::prelude::*;
use std::sync::Arc; use std::sync::Arc;
@@ -10,7 +11,7 @@ use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
use dsp::crossover::Crossover; use dsp::crossover::Crossover;
use dsp::limiter::Limiter; use dsp::limiter::Limiter;
use meters::Meters; use meters::Meters;
use params::{build_settings, Codename206Params}; use params::{build_settings, Bari206Params};
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's /// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
/// gain-gui example feel.) /// gain-gui example feel.)
@@ -22,13 +23,28 @@ const MID: usize = 1;
const HIGH: usize = 2; const HIGH: usize = 2;
const ALL: usize = 3; const ALL: usize = 3;
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel. /// 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 → /// 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 /// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
/// driven by the 'All' channel (the crossover sums flat). /// driven by the 'All' channel (the crossover sums flat).
struct Codename206 { struct Bari206 {
params: Arc<Codename206Params>, params: Arc<Bari206Params>,
sample_rate: f32, sample_rate: f32,
crossover: Crossover, crossover: Crossover,
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`]. /// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
@@ -56,10 +72,10 @@ struct Codename206 {
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling. /// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
const CEILING_HIT_GR_DB: f32 = 0.1; const CEILING_HIT_GR_DB: f32 = 0.1;
impl Default for Codename206 { impl Default for Bari206 {
fn default() -> Self { fn default() -> Self {
Self { Self {
params: Arc::new(Codename206Params::default()), params: Arc::new(Bari206Params::default()),
sample_rate: 48_000.0, sample_rate: 48_000.0,
crossover: Crossover::new(), crossover: Crossover::new(),
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()], comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
@@ -76,14 +92,14 @@ impl Default for Codename206 {
} }
} }
impl Codename206 { impl Bari206 {
fn lookahead_samples(&self) -> usize { fn lookahead_samples(&self) -> usize {
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize (self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
} }
} }
impl Plugin for Codename206 { impl Plugin for Bari206 {
const NAME: &'static str = "206 prototype"; const NAME: &'static str = "Bari 206";
const VENDOR: &'static str = "Novoyuuparosk"; const VENDOR: &'static str = "Novoyuuparosk";
const URL: &'static str = env!("CARGO_PKG_HOMEPAGE"); const URL: &'static str = env!("CARGO_PKG_HOMEPAGE");
const EMAIL: &'static str = "mikkeli@novoyuuparosk.org"; const EMAIL: &'static str = "mikkeli@novoyuuparosk.org";
@@ -139,8 +155,8 @@ impl Plugin for Codename206 {
self.scope_bucket_len = self.scope_bucket_len =
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1); ((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
for comp in &mut self.comps { for (i, comp) in self.comps.iter_mut().enumerate() {
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS); comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS, RMS_WINDOW_MS[i]);
} }
self.crossover.prepare(channels); self.crossover.prepare(channels);
self.crossover.update( self.crossover.update(
@@ -211,9 +227,47 @@ impl Plugin for Codename206 {
let num_samples = buffer.samples(); let num_samples = buffer.samples();
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS]; let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
let mut lvl_r = [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 gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32; 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: &params::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 in_frame = [0.0f32; 2];
let mut band_in = [[0.0f32; 2]; 3]; let mut band_in = [[0.0f32; 2]; 3];
let mut band_out = [[0.0f32; 2]; 3]; let mut band_out = [[0.0f32; 2]; 3];
@@ -239,14 +293,20 @@ impl Plugin for Codename206 {
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum. // Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
summed[..n].fill(0.0); summed[..n].fill(0.0);
for b in 0..3 { for b in 0..3 {
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next()); // `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
for ch in 0..n { // never reaches, in exchange for a cheaper exp() instead of a general powf().
band_in[b][ch] *= pre; 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].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.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_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.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]); self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
for ch in 0..n { for ch in 0..n {
summed[ch] += band_out[b][ch]; summed[ch] += band_out[b][ch];
@@ -257,6 +317,7 @@ impl Plugin for Codename206 {
let out_r = band_out[b][r].abs(); let out_r = band_out[b][r].abs();
// Wet gain reduction (what the comp computes), independent of the mix. // Wet gain reduction (what the comp computes), independent of the mix.
let g = self.comps[b].gain_reduction_db(); 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_l[b] = lvl_l[b].max(out_l);
lvl_r[b] = lvl_r[b].max(out_r); lvl_r[b] = lvl_r[b].max(out_r);
gr[b] = gr[b].max(g); gr[b] = gr[b].max(g);
@@ -269,14 +330,18 @@ impl Plugin for Codename206 {
} }
// 'All' aggregate channel over the summed bands (driven before its compressor). // 'All' aggregate channel over the summed bands (driven before its compressor).
let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next()); let all_pre = if all_static {
for ch in 0..n { all_pre_static
summed[ch] *= all_pre; } else {
}
all_set.makeup_db = self.params.all.makeup_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.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next(); all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.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); self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
// Output brickwall limiter. // Output brickwall limiter.
@@ -287,6 +352,7 @@ impl Plugin for Codename206 {
let out_l = out_frame[0].abs(); let out_l = out_frame[0].abs();
let out_r = out_frame[r].abs(); let out_r = out_frame[r].abs();
let g = self.comps[ALL].gain_reduction_db(); 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_l[ALL] = lvl_l[ALL].max(out_l);
lvl_r[ALL] = lvl_r[ALL].max(out_r); lvl_r[ALL] = lvl_r[ALL].max(out_r);
gr[ALL] = gr[ALL].max(g); gr[ALL] = gr[ALL].max(g);
@@ -327,6 +393,7 @@ impl Plugin for Codename206 {
for i in 0..meters::NUM_CHANNELS { 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_l[i], lvl_l[i], w);
meters::decay_store(&self.meters.level_r[i], lvl_r[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.gain_reduction_db[i], gr[i], w);
} }
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w); meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
@@ -336,8 +403,8 @@ impl Plugin for Codename206 {
} }
} }
impl ClapPlugin for Codename206 { impl ClapPlugin for Bari206 {
const CLAP_ID: &'static str = "com.mikkeli.codename-206"; const CLAP_ID: &'static str = "org.novoyuuparosk.bari-206";
const CLAP_DESCRIPTION: Option<&'static str> = const CLAP_DESCRIPTION: Option<&'static str> =
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)"); Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL); const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
@@ -351,11 +418,11 @@ impl ClapPlugin for Codename206 {
]; ];
} }
impl Vst3Plugin for Codename206 { impl Vst3Plugin for Bari206 {
const VST3_CLASS_ID: [u8; 16] = *b"Codename206Maxi!"; const VST3_CLASS_ID: [u8; 16] = *b"Bari206Multiband";
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
&[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics]; &[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics];
} }
nih_export_clap!(Codename206); nih_export_clap!(Bari206);
nih_export_vst3!(Codename206); nih_export_vst3!(Bari206);
+6
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Lock-free meter state shared from the audio thread to the editor. //! 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 //! Two feeds, both written by `process()` (single producer) and read by the editor (single
@@ -26,6 +27,9 @@ pub struct Meters {
pub level_l: [AtomicF32; NUM_CHANNELS], pub level_l: [AtomicF32; NUM_CHANNELS],
/// Right output level per channel (== left for mono signals). /// Right output level per channel (== left for mono signals).
pub level_r: [AtomicF32; NUM_CHANNELS], 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 /// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
/// stereo-linked, so the same gain applies to both channels. /// stereo-linked, so the same gain applies to both channels.
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS], pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
@@ -40,6 +44,7 @@ impl Default for Meters {
Self { Self {
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)), level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
level_r: 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)), gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
limiter_gr_db: AtomicF32::new(0.0), limiter_gr_db: AtomicF32::new(0.0),
scope: ScopeRing::default(), scope: ScopeRing::default(),
@@ -55,6 +60,7 @@ impl Meters {
for i in 0..NUM_CHANNELS { for i in 0..NUM_CHANNELS {
self.level_l[i].store(0.0, Ordering::Relaxed); self.level_l[i].store(0.0, Ordering::Relaxed);
self.level_r[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.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
} }
self.limiter_gr_db.store(0.0, Ordering::Relaxed); self.limiter_gr_db.store(0.0, Ordering::Relaxed);
+6 -4
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Plugin parameters and their layout. //! Plugin parameters and their layout.
//! //!
//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All' //! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All'
@@ -22,7 +23,7 @@ pub enum DetectionMode {
} }
#[derive(Params)] #[derive(Params)]
pub struct Codename206Params { pub struct Bari206Params {
#[persist = "editor-state"] #[persist = "editor-state"]
pub editor_state: Arc<EguiState>, pub editor_state: Arc<EguiState>,
@@ -71,7 +72,8 @@ pub struct CompressorParams {
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut). /// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
#[id = "lowslope"] #[id = "lowslope"]
pub low_slope: FloatParam, pub low_slope: FloatParam,
/// Low shaper curvature (0 = straight line, 1 = max bend toward a bounded saturation). /// 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"] #[id = "lowcurve"]
pub low_curve: FloatParam, pub low_curve: FloatParam,
#[id = "attack"] #[id = "attack"]
@@ -85,7 +87,7 @@ pub struct CompressorParams {
pub mix: FloatParam, pub mix: FloatParam,
} }
impl Default for Codename206Params { impl Default for Bari206Params {
fn default() -> Self { fn default() -> Self {
Self { Self {
editor_state: EguiState::from_size(760, 520), editor_state: EguiState::from_size(760, 520),
@@ -182,7 +184,7 @@ impl Default for CompressorParams {
.with_smoother(SmoothingStyle::Linear(20.0)) .with_smoother(SmoothingStyle::Linear(20.0))
.with_value_to_string(formatters::v2s_f32_rounded(2)), .with_value_to_string(formatters::v2s_f32_rounded(2)),
low_curve: FloatParam::new("Low Curve", 0.0, FloatRange::Linear { min: 0.0, max: 1.0 }) low_curve: FloatParam::new("Low Curve", 0.0, FloatRange::Linear { min: -1.0, max: 1.0 })
.with_smoother(SmoothingStyle::Linear(20.0)) .with_smoother(SmoothingStyle::Linear(20.0))
.with_value_to_string(formatters::v2s_f32_percentage(0)) .with_value_to_string(formatters::v2s_f32_percentage(0))
.with_string_to_value(formatters::s2v_f32_percentage()), .with_string_to_value(formatters::s2v_f32_percentage()),
+355
View File
@@ -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
+29
View File
@@ -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.
+66
View File
@@ -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"
+188
View File
@@ -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
+1
View File
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
fn main() -> nih_plug_xtask::Result<()> { fn main() -> nih_plug_xtask::Result<()> {
nih_plug_xtask::main() nih_plug_xtask::main()
} }