Compare commits

...

32 Commits

Author SHA1 Message Date
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
Mikkeli Matlock a4c542b2d9 feat: serial low-level shaper (Low Slope + Low Curve) before the compressor
Add a per-channel below-threshold shaper composed in series ahead of the comp:
gain = low_shape(level) + comp(level + low_shape(level)). The compressor's
threshold now sees the shaped level, so a Low Slope boost lifts quiet material
up into compression (and a cut pulls it out). Anchored at the -60 dB silence
floor. Low Curve bends the shaper toward a bounded saturation so the serial
composition doesn't blow up (0 = straight line).

gain_computer split into comp_gain_db + low_gain_db and composed; shared with
the editor gain-curve display. Slider order rearranged to read in signal order
(pre-gain -> low shaper -> compressor -> output). Defaults (slope 1, curve 0)
reproduce the plain compressor; 17 tests pass.

Known: the bipolar behaviour isn't final yet (milestone commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:53:40 +09:00
Mikkeli Matlock 123703d34d feat: raise max knee width to 30 dB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:17 +09:00
Mikkeli Matlock b1477f7ec6 feat: static gain-curve display for the selected channel
Add editor/gain_curve.rs: a square panel beside the scrolling plot showing the
channel transfer (out vs in, -60..0 dB) for whichever channel the plot tab
selects. Plots the full wet path — out = (in + pre_gain) + gain_reduction + makeup
— using the shared Compressor::gain_computer (now pub) so it matches the DSP and
the GR meter. Unity-reference diagonal + threshold marker; mix not folded in.

Update README structure/status to reflect the editor/ widget module and the
completed Stage 6 visualisers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:08 +09:00
Mikkeli Matlock a8be2179c3 feat: ceiling-hit markers as top-edge ticks on the rolling plot
Flag each plot bucket with whether the output limiter hit the ceiling
(limiter GR > 0.1 dB), carried through ScopeRing as a per-bucket hit field and
folded into history columns. The editor draws a short red tick at the top of any
hit column (one column wide, so runs merge into segments and a lone hit is a
dot); nothing otherwise. Global marker, shown on every channel tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:16:33 +09:00
Mikkeli Matlock 42c5f7dcd2 feat: per-channel dry/wet mix (parallel compression) replacing bypass
Replace the per-channel bypass toggle with a smoothed dry/wet `mix` (0..100%,
default 100%). The blend is applied at the compressor output:
  out = delayed_input * ((1 - mix) + mix * wet_gain)
Dry and wet share the same delayed input, so it's phase-aligned (parallel
compression, no comb filtering). mix=0 is bit-identical to the old bypass.

The detector now runs even at mix 0, so the GR meter shows the wet gain
reduction regardless of mix, while the level/plot out trace reads the mixed
output. "Bands at 0% = simple full-band comp via All" still holds.

Update README + parameter docs (bypass -> mix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:25:38 +09:00
Mikkeli Matlock eee94379bc docs: note the crossover automation caveat (editor-only lo<=hi limit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:57:20 +09:00
Mikkeli Matlock 3aeb09957a feat: draggable crossover handles with dynamic lo<=hi limit
Replace the two plain crossover sliders with a horizontal log-frequency strip
(LOW/MID/HIGH) and two draggable handles, plus number boxes (double-click to
type). Handles and boxes enforce a dynamic limit so lo/mid never crosses
mid/hi, on top of each param's own range. Lives in the editor/ widget module.

Known limitation: the lo<=hi limit is editor-only, so host automation can write
the two params past each other (briefly inverting the mid band). The DSP clamps
to monotonic, but FL's automation can misbehave once inverted. Left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:55:30 +09:00
Mikkeli Matlock 514fd964f6 fix: freeze the scope when the transport is stopped
FL keeps calling process() with silence while stopped/paused, so the scope
scrolled silence instead of holding. Gate the plot's bucket advance on
context.transport().playing, so it freezes on stop/pause and resumes on play.
Bar meters still fall to silence as before. Update README thread-safety notes
for the ScopeRing feed and transport gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:25:08 +09:00
Mikkeli Matlock 9a60b0ea72 feat: decouple plot resolution from frame rate via a 200 Hz ring buffer
Feed the scrolling plot from a lock-free SPSC ScopeRing instead of sampling one
atomic per egui frame, so horizontal resolution is set by the audio-clocked
bucket rate (~200 Hz) rather than the ~60 fps repaint. process() accumulates a
bucket every sample_rate/BUCKET_HZ samples (peak-preserving, spanning blocks)
and pushes it; the editor drains all new buckets each frame and folds them into
PLOT_N columns. Fast transients between frames are no longer dropped, and the
plot is now audio-clocked (freezes on pause, falls to silence on stop/reset).

Drop the per-frame plot_* atomics (the per-channel lamp now reads the decayed
bar level). Also fix the area fill: render it as a strip of per-segment convex
quads instead of one concave polygon, which egui fan-filled from a corner and
left stray triangles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:53:00 +09:00
Mikkeli Matlock fe4033b772 feat: translucent area fill under the in/out plot traces
Draw a translucent area from each level line down to the plot baseline so the
input and output traces read more clearly. The gain-reduction trace stays a
plain line (it hangs from the 0 dB line, where a fill would read oddly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:03:30 +09:00
Mikkeli Matlock 0c2e1597a1 feat: per-channel ceiling lamps in the meter widget
Replace the single global ceiling lamp with one lamp per channel cluster.
Each latches when that channel's output reaches 0 dBFS (a hot/over warning,
handy when pre-gain drives a band); the ALL channel additionally lights on a
real output-limiter catch. Same latch/hold/click-to-clear behavior, now per
channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:02:39 +09:00
Mikkeli Matlock eb2499bad3 refactor: split editor.rs into editor/ widget modules
Move the egui editor from a single editor.rs into an editor/ module: mod.rs
(aggregator: create(), EditorState, layout, placeholder slider columns),
meter.rs (|L|GR|R| meters + ceiling lamp, owns MeterState), and plot.rs
(rolling in/out/GR plot, owns PlotState + PlotHistory). Each visualiser owns
its GUI state; the aggregator composes them. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:01:50 +09:00
Mikkeli Matlock 3ae860188e docs: restore planned editor/widgets layout as a future UI-redesign target
Keep the widget-module breakdown (meter/plot/gain_curve/crossover) documented
as the intended split for when editor.rs is redesigned, clearly marked as
deferred rather than current.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:17:51 +09:00
Mikkeli Matlock 411b27fcf3 docs: update README for Stage 6 metering, pre-gain, and module split
Reflect actual code: per-channel pre-gain drive in signal flow + params,
makeup range -24..+24, lib.rs split into params/editor/meters, lock-free
atomic meters (not a mutex), and Stage 6 progress (|L|GR|R| meters, latching
ceiling lamp, rolling in/out/GR plot). Overview/Goals unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:56:31 +09:00
Mikkeli Matlock 4c5165b0bc feat: 3-bar meters, latching ceiling lamp, and rolling in/out/GR plot
Meters: per-channel |L | GR | R| layout (narrower bars). Ceiling lamp now
latches on a limiter catch and holds, clearing after 3s or on click.

Plot: per-channel selectable scrolling in/out/gain-reduction scope under the
meters, fed by new raw block-peak atomics (separate from the decayed bar
atomics). Histories for all four channels run continuously, so switching tabs
keeps each channel's history. Scroll is time-based with a flow-speed selector
(2/5/15/45 s window) so the window length is accurate regardless of frame rate,
with peak-preserving downsampling between columns.

reset() now zeroes the meters so transport restart shows silence rather than
stale values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:53:06 +09:00
Mikkeli Matlock 9a54413bfa feat: allow makeup down to -24 dB on all channels
Extend the per-channel makeup range floor from -12 to -24 dB so makeup can
attenuate as well as boost, giving more post-compressor gain-staging headroom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:14:15 +09:00
Mikkeli Matlock f8f652f2c7 fix(deploy): verify CLAP and VST3 install freshness
The script only checked the VST3 existed, so a plugin loaded in the DAW (which
locks its binary and makes the copy fail silently) left a STALE install that
passed verification. Compare install vs build timestamps for both formats and
warn when stale ("loaded in your DAW").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:13:24 +09:00
Mikkeli Matlock a420d5dd39 feat: per-channel meters, ceiling lamp, and pre-gain drive
Stage 6 begins. Add lock-free Meters (atomics shared audio->GUI) with a
peak-with-decay ballistic, published once per block and gated on the editor
being open. Editor draws a per-channel level + gain-reduction meter panel and
a ceiling lamp fed by the output limiter. Compressor/Limiter expose
gain_reduction_db() for this.

Also add a smoothed per-channel pre-gain applied before each compressor (and
before the All compressor), driving the signal into compression and on into
the limiter for a compressed semi-distortion. Pairs with makeup for full
per-channel input/output gain-staging. Compressor DSP untouched; 16 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:13:14 +09:00
Mikkeli Matlock 8c550da92e refactor: split lib.rs into params and editor modules
Move parameter structs, defaults, and build_settings into src/params.rs;
move the egui editor into src/editor.rs. lib.rs now holds only the plugin
shell, DSP wiring, and process(). No behavior change (16/16 tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:57:18 +09:00
Mikkeli Matlock 20c5a17a61 Stage 4b: true-peak limiting via 4x polyphase oversampling
Upgrades the brickwall limiter from sample-peak to true-peak (inter-sample).

- src/dsp/oversampler.rs: 4x polyphase windowed-sinc (4 phases x 12 taps, Blackman,
  each phase normalized to unity DC). Detection-only: max_true_peak() returns the
  inter-sample max magnitude and discards the upsampled samples; the audio path is
  untouched. Built in prepare(), no realtime allocation. Cost ~ one base-rate FIR
  per channel; its small group delay is absorbed by the limiter look-ahead, so no
  added reported latency.
- src/dsp/limiter.rs: detector peak = max(sample_peak, oversampler.max_true_peak());
  targets a 0.3 dB margin under the ceiling to cover the 4x detection residual.
- 16 unit tests (2 new: detects ~3 dB fs/4 inter-sample overshoot; preserves DC
  amplitude). README/docs updated: Stage 4 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:50:06 +09:00
Mikkeli Matlock 263d5e688f fix: resizable, scrollable editor so all controls stay reachable
The global controls were one non-wrapping horizontal row that pushed Ceiling off
the right edge, and the window was fixed-size, so some controls (Ceiling, the full
'All' column) couldn't be reached. Wrapped the editor in a ResizableWindow, moved
the global controls into a vertical label|slider grid, and put everything in a
vertical ScrollArea. Placeholder layout still (Stage 6 replaces it), just usable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:49:46 +09:00
Mikkeli Matlock f6c45123fa Stage 4a: base-rate look-ahead brickwall limiter
Adds the output limiter stage after the 'All' channel. Guarantees the output
never exceeds the ceiling: fixed 1.5 ms look-ahead, stereo-linked sliding-max
peak detection over the look-ahead window -> gain = ceiling/window_max, decoupled
smoothing (fast attack / user release), and a final clamp as the hard guarantee.

- src/dsp/limiter.rs: Limiter (sample-peak; true-peak via oversampling is 4b)
- src/lib.rs: wired as final stage; new globals output_ceiling_db (-24..0) and
  limiter_release_ms; latency now the constant three-stage total (bands+All+limiter);
  two UI sliders added to the global row
- 14 unit tests (4 new: ceiling guarantee on spikes, loud-sine limiting,
  transparency below ceiling, latency)
- README/docs updated (Stage 4 split into 4a done / 4b oversampling)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:45:26 +09:00
Mikkeli Matlock ada0b00313 docs: note VST3 metadata display issue in FL as a known/deferred issue
CLAP shows name/vendor/type correctly in FL; VST3 still shows stale/missing
metadata (suspected FL caching by the unchanged VST3_CLASS_ID). Deferred — use
CLAP meanwhile; likely fix is regenerating the class ID and/or clearing FL's DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:42:22 +09:00
Mikkeli Matlock d776e564fd Denormals: rely on NIH-plug's FTZ, drop redundant in-code flush
Investigated the planned global FTZ for the IIR filters and found NIH-plug already
handles it: process_wrapper wraps process()/reset() in a ScopedFtz guard that
enables CPU Flush-To-Zero (x86 MXCSR bit 15 / AArch64 FPCR bit 24, via inline asm,
restored on drop) on the vst3, clap, and standalone paths. SSE is baseline on
x86_64 so FTZ is always active for our build.

So adding our own guard would just duplicate the framework. Instead, removed the
now-redundant flush_denormal() from compressor.rs (the biquads and envelope/RMS
tails already relied on this FTZ) for a single consistent story, and rewrote the
README 'Denormal flushing' note to document that the framework handles it (FTZ,
not DAZ — sufficient for our feed-forward IIR).

No functional change; 10 unit tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:34:56 +09:00
Mikkeli Matlock 1d939535be Stage 3: 3-band LR4 crossover + per-band compressors into the 'All' channel
Splits the input into low/mid/high with a Linkwitz-Riley 24 dB/oct crossover,
compresses each band, sums them, then runs the sum through a fourth 'All'
compressor. Bypassing the three bands collapses the plugin to a simple full-band
comp driven by 'All' (the crossover sums flat in magnitude).

- src/dsp/biquad.rs: generic RBJ biquad (Transposed Direct Form II), LP/HP/AP
- src/dsp/crossover.rs: 3-band LR4 filterbank; lower band all-pass-compensated at
  the higher crossover so the bands sum to flat magnitude (an all-pass, not a
  bit-exact null — that only holds for linear-phase FIR). Mirrors nih-plug's
  crossover plugin design.
- src/lib.rs: 4 Compressor instances (low/mid/high/all) + Crossover; params
  restructured to 4 nested CompressorParams (id_prefix low/mid/high/all) plus
  global crossover_low_hz/crossover_high_hz/look_ahead_ms; 4-column lo|mid|hi|all
  egui UI; latency = two series stages (bands + all), constant, reported once
- 10 unit tests (adds biquad LP/AP magnitude, crossover flat-magnitude
  reconstruction, band-split sanity)
- README: Stage 3 marked done; corrected the 'sum flat' expectation to flat
  magnitude (IIR LR sums to an all-pass, not a time-domain null)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:04:24 +09:00
Mikkeli Matlock 0f66e9638c Add peak/RMS detection switch; sweep docs to match code
- Compressor: switchable peak / RMS detection (EnumParam<DetectionMode> in lib.rs
  -> use_rms bool in CompressorSettings; DSP stays framework-agnostic). RMS is a
  one-pole running mean of the linked squared level with a hardcoded 5 ms window,
  updated whenever active so peak<->RMS switching is seamless. New unit test
  (RMS compresses a sine less than peak); 6 tests total.
- README: reconciled Implementation Order checklists with actual progress
  (Stages 1-2 done; look-ahead/latency + basic UI pulled forward), annotated the
  project structure (implemented vs planned), and corrected the Latency and
  Denormal-flushing notes to match the code (set_latency_samples once / constant
  latency; in-code denormal flush). Overview and goals left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:02:23 +09:00
Mikkeli Matlock 8d9eaeaffc Stage 2: full-band compressor with constant-latency look-ahead
Implements the single full-band feed-forward compressor (the engine that will be
reused per band + for the 'All' channel). Design follows Giannoulis et al. 2012:
log-domain gain computer with a quadratic soft knee feeding a smooth decoupled
peak detector for attack/release ballistics. Stereo-linked peak detection.

Look-ahead uses a fixed audio delay with a constant reported latency (set once in
initialize); the knob only moves the detector tap within that delay. This avoids
renegotiating latency from process(), which crashed FL Studio when the look-ahead
was adjusted during playback.

- src/dsp/compressor.rs: Compressor + CompressorSettings, RT-safe (no alloc in
  process; buffers sized in prepare; envelope denormals flushed in-code)
- src/lib.rs: nested CompressorParams (threshold/ratio/knee/attack/release/makeup/
  bypass) + global look-ahead; egui ParamSlider grid; latency reported once
- 5 unit tests (static curve, knee continuity, steady-state convergence, constant
  latency); Cargo.toml lib crate-type added so tests link
- README: 'All' channel architecture already documented; look-ahead spec updated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:33:31 +09:00
Mikkeli Matlock d9dc61cf90 plugin metadata update 2026-06-14 21:34:06 +09:00
Mikkeli Matlock e9337473b9 minor desc change 2026-06-14 21:24:43 +09:00
Mikkeli Matlock d0734ff803 Scaffold NIH-plug project: full-band gain VST3/CLAP with one-slider egui GUI
Sets up the Rust/NIH-plug build (pinned to nih-plug rev f36931f) producing
VST3 and CLAP bundles via 'cargo xtask bundle'. Implements the first landmark:
a full-band gain plugin with a single egui ParamSlider.

- Cargo workspace + xtask bundler, .cargo alias, bundler.toml
- src/lib.rs: Codename206 plugin (gain param, egui editor)
- deploy.ps1/.bat: build + install to system VST3/CLAP folders (real copy,
  not a junction, for FL Studio); -User flag for a no-admin dev loop
- LICENSE: GPL-3.0 (required by NIH-plug's VST3 bindings)
- README: corrected architecture for the 'All' aggregate channel (4th comp/lim
  stack on the summed bands; all-bands-bypassed = simple full-band comp)
- .gitignore excludes /target and the local nih-plug + _template reference clones

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:22:34 +09:00
25 changed files with 6234 additions and 83 deletions
+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --release --"
+7
View File
@@ -0,0 +1,7 @@
/target
# Local NIH-plug reference clone ("deluxe kit") and cookiecutter template.
# Kept out of version control; the build depends on nih-plug via a pinned git rev,
# not on these working copies. Clone them locally for reading the examples.
/nih-plug
/_template
Generated
+2546
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "codename_206"
version = "0.1.0"
edition = "2021"
authors = ["Mikkeli Matlock <matlockib@gmail.com>"]
homepage = "https://github.com/mikkeli/codename-206"
description = "A VST3/CLAP multiband compressor/limiter with a custom gain-curve display"
# VST3 is on by default in nih_plug, and its bindings (vst3-sys) are GPLv3. Any
# distributed VST3 binary built with them must therefore be GPL-3.0-or-later
# (or covered by a commercial Steinberg licence). CLAP carries no such requirement.
license = "GPL-3.0-or-later"
[workspace]
members = ["xtask"]
[lib]
# `cdylib` is the plugin binary; `lib` (rlib) lets `cargo test` link the unit tests.
crate-type = ["cdylib", "lib"]
[dependencies]
# Pinned to the exact commit of the local ./nih-plug reference clone so the build
# is reproducible and matches the examples we read from. Bump the `rev` together
# with `git -C nih-plug pull` when you want to update.
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23", features = ["assert_process_allocs"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23" }
[profile.release]
lto = "thin"
strip = "symbols"
[profile.profiling]
inherits = "release"
debug = true
strip = "none"
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+175 -83
View File
@@ -1,6 +1,6 @@
# Codename 206 # Codename 206
*Called 206 because the Peugeot 206 has a 'maxi' variant. Subdued lineage to the FL Studio plugin 'maximiser'* *Called 206 because the Peugeot 206 has a 'maxi' variant. You'll know this is a Maximizer knockoff if you can follow that trail of thoughts.*
Multiband Compressor / Limiter VST3 — Project Plan Multiband Compressor / Limiter VST3 — Project Plan
## Overview ## Overview
@@ -10,6 +10,7 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
**Goals:** **Goals:**
- 3-band (configurable crossover points) compressor/limiter - 3-band (configurable crossover points) compressor/limiter
- An 'All' aggregate channel: a 4th comp/lim stack on the summed bands, so bypassing all bands turns the plugin into a simple full-band compressor (mirrors FL's Maximizer)
- Look-ahead brickwall output limiter with true-peak detection - Look-ahead brickwall output limiter with true-peak detection
- Real-time gain reduction metering per band - Real-time gain reduction metering per band
- Custom gain curve visualiser - Custom gain curve visualiser
@@ -33,13 +34,23 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
``` ```
Input Input
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq) └─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
├─ Band 1 (low) → look-ahead delay → compressor VCA → gain stage ├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix)
├─ Band 2 (mid) → look-ahead delay → compressor VCA → gain stage ├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
└─ Band 3 (high) → look-ahead delay → compressor VCA → gain stage └─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
└─ Sum → output brickwall limiter (true-peak, 4x oversampled) → output
Sum of bands ◄─────────────────────────────────────────────────────------┘
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
└─ output brickwall limiter (true-peak, 4x oversampled) → output
``` ```
The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduction is already ramping when the transient arrives. The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduction is already ramping when the transient arrives.
**The 'All' aggregate channel** (mirrors FL's Maximizer): the three bands are summed and the
result passes through a *fourth*, full-band compressor/limiter stack before the output limiter.
Because the LR4 filterbank sums phase-coherently flat, **bypassing all three bands leaves the
summed signal identical to the input** — so the plugin collapses into a plain single-band
compressor/limiter driven entirely by the 'All' channel. That makes "multiband off = simple comp"
a first-class mode, not an afterthought.
--- ---
@@ -48,122 +59,190 @@ The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduc
### Crossover Filterbank ### Crossover Filterbank
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency - Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
- LR4 = two cascaded biquads (Butterworth LP or HP) - LR4 = two cascaded biquads (Butterworth LP or HP)
- Bands sum phase-coherently back to flat - Bands sum phase-coherently to flat **magnitude** (the sum is an all-pass; lower bands get an all-pass at each later crossover to match phase — not a bit-exact time-domain null)
- Crossover frequencies are user-adjustable parameters - Crossover frequencies are user-adjustable parameters
### Per-Band Compressor ### Per-Band Compressor
- Level detection: switchable RMS / peak, with configurable window - **Pre-gain (drive)**: scales the band *before* the detector, so it pushes harder into compression and feeds the sum/limiter hotter — a mild "compressed semi-distortion" without a dedicated saturator. Applied in the wiring (the compressor itself is untouched). Pairs with makeup for full input/output gain-staging
- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later)
- Gain computer: threshold, ratio, soft knee - Gain computer: threshold, ratio, soft knee
- Attack / release envelopes (logarithmic ballistics) - Attack / release envelopes (logarithmic ballistics)
- Makeup gain per band - Makeup gain per band (24…+24 dB — attenuates as well as boosts)
- Look-ahead: circular delay buffer on the audio path; detector reads ahead - Look-ahead: circular delay buffer on the audio path; detector reads ahead
### 'All' Aggregate Channel
- Structurally **identical to a per-band compressor** — reuse the same comp/lim code/params, just fed the summed signal instead of a filtered band
- Runs after the three bands are summed, before the output brickwall limiter
- Bands have a per-channel dry/wet **mix** (parallel compression); at 0% (or all three dry) the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim
- Has its own look-ahead; the plugin reports a single **constant** total latency (the fixed band + 'All' look-ahead), set once — see Latency below
### Output Limiter ### Output Limiter
- True-peak brickwall (ceiling = 0 dBFS or user-defined) - Brickwall, ceiling = 0 dBFS or user-defined (`output_ceiling`). Look-ahead + sliding-max peak detection + a ceiling clamp guarantee the output never exceeds the ceiling
- 4x oversampling for inter-sample peak detection - Short attack (≤ 0.1 ms), auto-release (release time user-set)
- Short attack (≤ 0.1 ms), auto-release - **True-peak**: 4× polyphase oversampling estimates the inter-sample peak (detection only — the upsampled signal is discarded); the limiter targets a 0.3 dB margin under the ceiling to cover the 4× residual
### Latency ### Latency
- Look-ahead duration must be reported via `Plugin::latency()` for DAW compensation - Reported via `context.set_latency_samples()` in `initialize()`**never** from `process()`; renegotiating latency mid-stream crashes some hosts (FL included)
- Reported latency is a **constant** (the max look-ahead); the look-ahead control only moves the detector tap within that fixed delay
- All bands use equal delay to preserve phase alignment - All bands use equal delay to preserve phase alignment
--- ---
## Parameters ## Parameters
### Global ### Global
- `input_gain` — pre-gain before filterbank (dB)
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0) - `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
- `look_ahead_ms` — look-ahead time (010 ms) - `limiter_release_ms` — output limiter release time
- `look_ahead_ms` — look-ahead time (05 ms). Reported latency is **constant** (the max look-ahead); the knob only moves the detector tap within that fixed delay, so it is safe to adjust during playback (changing reported latency mid-stream crashes some hosts, FL included)
- `crossover_low_hz` — low/mid crossover frequency - `crossover_low_hz` — low/mid crossover frequency
- `crossover_high_hz` — mid/high crossover frequency - `crossover_high_hz` — mid/high crossover frequency
### Per Band (× 3, use a `#[nested]` params struct)
> **Crossover automation caveat:** the lo ≤ hi limit is enforced in the **editor only** (the two
> are independent params). Host automation writes them directly, so it can drive lo past hi and
> momentarily invert the mid band. The DSP clamps to a monotonic split so it won't break audio,
> but FL's automation can misbehave once inverted. Not fixed by design — just don't automate the
> two across each other.
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
- `pre_gain_db` — drive into the compressor (24…+36 dB, smoothed)
- `detection` — peak / RMS level detection
- `low_slope` — low-level shaper slope at the silence floor (1 = unity, >1 fans up/boost, <1 fans down/cut). **Serial**: reshapes the level *before* the threshold, so a boost can lift quiet material up into compression
- `low_curve` — bends the low shaper toward a bounded saturation (0% = straight line) so the serial composition doesn't run away
- `threshold_db` - `threshold_db`
- `ratio` — 1.0 (off) to ∞ (limiting) - `ratio` — 1.0 (off) to ∞ (limiting)
- `knee_db` — soft knee width
- `attack_ms` - `attack_ms`
- `release_ms` - `release_ms`
- `knee_db`soft knee width - `makeup_db`makeup gain (24…+24 dB)
- `makeup_gain_db` - `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel
- `bypass` — per-band bypass
The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum.
--- ---
## Project Structure ## Project Structure
Target layout (✅ = exists today; the rest is planned):
``` ```
src/ src/
lib.rs # Plugin entry point, implements Plugin trait lib.rs # Plugin trait + DSP wiring + process()
params.rs # Params struct with NIH-plug #[id] attributes params.rs # Params structs, defaults, build_settings()
editor.rs # ✅ egui editor: meter panel + rolling plot (drawn via Painter) + slider columns
meters.rs # ✅ lock-free Meters (atomics): decayed bar values + raw plot feed
dsp/ dsp/
mod.rs mod.rs # ✅ module declarations
crossover.rs # LR4 filterbank (biquad chains) compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay
compressor.rs # Per-band compressor + look-ahead crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation
limiter.rs # Output true-peak brickwall limiter biquad.rs # ✅ generic biquad (Transposed Direct Form II)
biquad.rs # Generic biquad filter (Direct Form II transposed) limiter.rs # ✅ look-ahead brickwall limiter (true-peak via oversampler)
delay.rs # Circular buffer for look-ahead delay lines oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
oversampler.rs # 4x oversampler for true-peak detection
editor/
mod.rs # egui editor setup via nih_plug_egui
widgets/
gain_curve.rs # Custom egui Widget: gain curve display
band_meter.rs # Per-band gain reduction meter
level_meter.rs# Input/output level meter
``` ```
The editor lives in an `editor/` module — one file per visualiser widget (each owns its GUI
state), with `mod.rs` as the aggregator/layout. Drawn directly with egui's `Painter`.
```
src/
editor/
mod.rs # aggregator: create(), EditorState, layout, placeholder slider columns
meter.rs # |L | GR | R| level + gain-reduction bars + per-channel ceiling lamp
plot.rs # rolling in/out/GR scope (200 Hz ring feed) + ceiling-hit markers
crossover.rs # log-freq strip with draggable crossover handles + number boxes
gain_curve.rs # static gain-curve display (out vs in) for the selected channel
```
Remaining UI work: replace the placeholder per-channel slider columns in `mod.rs` with the real
layout.
Deferred until the redesign — no need to split prematurely while the layout is still a placeholder.
--- ---
## Build Steps ## Build Steps
```bash The project is already scaffolded (NIH-plug + nih_plug_egui, pinned to a fixed git rev in
# Install Rust (if not already) `Cargo.toml`). You do **not** need the Steinberg VST3 SDK — NIH-plug bundles its own bindings.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
**Prerequisites (Windows):**
# Clone NIH-plug cookiecutter or start fresh - Rust stable (`rustup``winget install Rustlang.Rustup`)
cargo new --lib my_maximizer - Visual Studio 2022 with the "Desktop development with C++" workload (provides the MSVC linker)
cd my_maximizer
```powershell
# Add dependencies to Cargo.toml: # Build + bundle the VST3 and CLAP
# nih-plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["assert_process_allocs"] } cargo xtask bundle codename_206 --release
# nih-plug-egui = { git = "https://github.com/robbert-vdh/nih-plug" } # Output: target\bundled\Codename 206.vst3 and Codename 206.clap
# Build and bundle
cargo xtask bundle my_maximizer --release
# Output: target/bundled/my_maximizer.vst3
``` ```
### Deployment
FL Studio scans `C:\Program Files\Common Files\VST3` by default, **ignores directory junctions**
(so a symlinked bundle is invisible to its scanner), and caches failed scans. So deployment must
copy a *real* bundle into a folder FL scans, then FL must be told to rescan failed plugins.
Use the provided script (no need to remember the details):
```powershell
.\deploy.ps1 # build, then copy to the global VST3/CLAP folders (one UAC prompt)
.\deploy.ps1 -SkipBuild # reinstall the last build without rebuilding
.\deploy.ps1 -User # copy to %LOCALAPPDATA%\Programs\Common\VST3 instead (no admin) — best for a dev loop
```
`deploy.bat` is a double-click wrapper around the same script.
After deploying, in FL Studio: **Options → Manage plugins → tick "Rescan previously failed
plugins" → Find installed plugins**, then search for **Codename 206**. (The rescan-failed step
is essential — without it FL silently skips a plugin it has seen before.)
> **Known issue (deferred):** the **CLAP** build shows its name/vendor/type correctly in FL, but
> the **VST3** still displays stale/missing metadata there. Suspected cause is FL caching the VST3
> by its unchanged `VST3_CLASS_ID`. Likely fix is to regenerate that class ID (and/or clear FL's
> plugin DB); low priority for now — use the CLAP build meanwhile.
--- ---
## Implementation Order ## Implementation Order
Work through these stages in order — each stage produces a loadable, audible plugin. Work through these stages in order — each stage produces a loadable, audible plugin.
**Status (2026-06-25):** Stages 14 done — the full signal chain works: 3-band LR4 crossover →
per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
ceiling-hit markers), **draggable crossover handles**, and a **static gain-curve display**. **Next:
replace the placeholder slider columns with the real UI layout.**
### Stage 1 — Skeleton plugin ### Stage 1 — Skeleton plugin
- [ ] NIH-plug "passthrough" compiling and loading in DAW - [x] NIH-plug "passthrough" compiling and loading in DAW
- [ ] `Params` struct with all parameters declared (no DSP yet) - [ ] `Params` struct with all parameters declared *(partial — compressor + look-ahead params done; global `input_gain`/`output_ceiling` and crossover params pending)*
- [ ] `process()` passes audio through untouched - [x] `process()` passes audio through untouched *(since superseded by the compressor)*
- [ ] Verify plugin loads and parameters appear in DAW - [x] Verify plugin loads and parameters appear in DAW *(verified in FL Studio)*
### Stage 2 — Single-band compressor (no look-ahead, no UI) ### Stage 2 — Single-band (full-band) compressor ✅
- [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed - [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed *(deferred to Stage 3 — not needed for the full-band comp)*
- [ ] Implement basic RMS level detector - [x] Level detector — switchable **peak / RMS** (RMS window hardcoded for now)
- [ ] Implement gain computer (threshold, ratio, knee) - [x] Implement gain computer (threshold, ratio, soft knee)
- [ ] Implement attack/release envelope on gain reduction - [x] Implement attack/release envelope (smooth decoupled peak detector)
- [ ] Wire into `process()`, test with a sine sweep - [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency)
### Stage 3 — Crossover filterbank ### Stage 3 — Crossover filterbank
- [ ] Implement LR4 LP and HP biquad chains in `crossover.rs` - [x] Implement LR4 LP/HP biquad chains in `crossover.rs` (+ generic `biquad.rs`, Transposed Direct Form II)
- [ ] Verify bands sum flat (null test: sum vs dry should be silence) - [x] Verify bands sum flat — for IIR LR4 the sum is an **all-pass** (flat *magnitude*, phase-shifted), not a bit-exact null; lower bands get an all-pass at each later crossover to phase-match. Tested via `bands_sum_to_flat_magnitude`
- [ ] Apply per-band compressor to each band - [x] Per-band bypass — a bypassed band passes its delayed dry band; with all three bypassed the 'All' channel sees the flat-magnitude reconstruction = the simple-comp mode
- [ ] Sum bands back to output - [x] Apply per-band compressor to each band
### Stage 4 — Look-ahead + brickwall limiter - [x] Sum bands back together
- [ ] Implement `delay.rs` circular buffer - [x] Run the summed signal through the 'All' channel compressor before output
- [ ] Wire look-ahead: detector reads N samples ahead of VCA ### Stage 4 — Output brickwall limiter + oversampler ✅
- [ ] Report latency via `Plugin::latency()` - [x] Look-ahead delay (circular buffer) — inside `compressor.rs` and `limiter.rs`, no separate `delay.rs`
- [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc) - [x] Wire look-ahead: detector reads N samples ahead of the VCA
- [ ] Implement brickwall output limiter with true-peak detection - [x] Report latency — `context.set_latency_samples()` once; constant three-stage total (bands + 'All' + limiter)
### Stage 5 — Basic egui UI - [x] Brickwall output limiter (`limiter.rs`): look-ahead + sliding-max + ceiling clamp guarantee
- [ ] Add `nih_plug_egui` editor - [x] `oversampler.rs` — 4× polyphase windowed-sinc, detection-only (returns the inter-sample max)
- [ ] Knobs / sliders for all parameters - [x] True-peak limiting: limiter peak = max(sample, inter-sample); targets a 0.3 dB margin under the ceiling for the 4× residual
- [ ] Per-band bypass toggles ### Stage 5 — Basic egui UI *(basic version done early)*
- [ ] Confirm UI controls update DSP in real time - [x] Add `nih_plug_egui` editor
- [x] Sliders for all current parameters (`ParamSlider` grid)
- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle)
- [x] Confirm UI controls update DSP in real time
### Stage 6 — Custom visualisations ### Stage 6 — Custom visualisations
- [ ] `level_meter.rs` — input/output RMS + peak meters - [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
- [ ] `band_meter.rs` — per-band gain reduction meters (vertical bars) - [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
- [ ] `gain_curve.rs` — static gain curve display per band (threshold/ratio/knee) - [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
- [ ] Draggable crossover handles on a frequency display - [x] Static gain-curve display (out vs in; includes pre-gain + makeup) for the selected channel
- [x] Draggable crossover handles on a log-frequency display (with number boxes)
- [ ] Replace the placeholder slider columns with the real UI
--- ---
## Key Implementation Notes ## Key Implementation Notes
@@ -174,15 +253,28 @@ must be pre-allocated in `initialize()`. Use `assert_process_allocs` feature fla
development to catch violations. development to catch violations.
### Denormal flushing ### Denormal flushing
Add `#[cfg(target_arch = "x86_64")] std::arch::x86_64::_MM_SET_FLUSH_ZERO_MODE(...)` in Handled by the framework — no plugin code needed. NIH-plug wraps `process()` and `reset()` in
`initialize()`, or add a small DC offset (1e-25) to filter inputs. `process_wrapper`, which enables the CPU's **Flush-To-Zero** mode for the duration via its
`ScopedFtz` guard (x86 `MXCSR` bit 15 / AArch64 `FPCR` bit 24, set with inline asm and restored
on drop). FTZ has a fixed threshold at the normal/subnormal boundary (~759 dB for f32), so the
decaying envelope/RMS tails and all the IIR filter state are flushed to zero automatically,
far below audibility. We therefore do **not** set the register ourselves or flush values in code.
(Note: NIH-plug sets FTZ but not DAZ; for our feed-forward IIR work FTZ on results is sufficient.)
### Parameter smoothing ### Parameter smoothing
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise. NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
### Thread safety ### Thread safety
Params are atomics. The editor and audio thread communicate only through params and Params are atomics. The editor and audio thread communicate only through params and a shared
`Arc<Mutex<...>>` meter data. Never pass DSP state to the UI directly. `Arc<Meters>` (`meters.rs`) — never a mutex on the audio path. Two lock-free feeds, both gated on
the editor being open:
- **Bar meters** — decayed atomic scalars, one store per block; the editor reads them each frame.
- **Scrolling plot** — a single-producer/single-consumer `ScopeRing` of buckets clocked at
~200 Hz, so the plot's horizontal resolution is decoupled from the ~60 fps repaint. The editor
drains all new buckets each frame. The scope is **transport-gated** (advances only while playing)
so it freezes rather than scrolling silence when the host is stopped/paused.
Never pass DSP state to the UI directly.
### VST3 licensing ### VST3 licensing
You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries. You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries.
+7
View File
@@ -0,0 +1,7 @@
# Metadata for NIH-plug's `cargo xtask bundle <package>` bundler.
#
# [package_name]
# name = "Human Readable Plugin Name" # defaults to <package_name>
[codename_206]
name = "Codename 206"
+5
View File
@@ -0,0 +1,5 @@
@echo off
REM Double-click wrapper for deploy.ps1 (build + install to system VST3/CLAP folders).
REM Pass-through args, e.g.: deploy.bat -User / deploy.bat -SkipBuild
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
pause
+95
View File
@@ -0,0 +1,95 @@
<#
.SYNOPSIS
Build Codename 206 and install the VST3 (and CLAP) into the system plugin folders.
.DESCRIPTION
FL Studio scans `C:\Program Files\Common Files\VST3` by default and ignores
directory junctions, so deployment must place a REAL bundle copy there. That
folder needs admin, so the copy step self-elevates (one UAC prompt).
After deploying, in FL Studio: Options -> Manage plugins ->
tick "Rescan previously failed plugins" -> "Find installed plugins".
.PARAMETER SkipBuild
Install the existing target\bundled output without rebuilding.
.PARAMETER User
Install to the per-user folder %LOCALAPPDATA%\Programs\Common\VST3 instead of
the global one. No admin required; add that path in FL's plugin search paths.
.EXAMPLE
.\deploy.ps1 # build + install to global VST3/CLAP (UAC prompt)
.\deploy.ps1 -SkipBuild # reinstall last build
.\deploy.ps1 -User # no-admin install for a fast dev loop
#>
param(
[switch]$SkipBuild,
[switch]$User,
[string]$Package = "codename_206"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
# rustup installs cargo here; make sure it's reachable even in a fresh shell.
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (Test-Path $cargoBin) { $env:Path = "$cargoBin;$env:Path" }
if (-not $SkipBuild) {
Write-Host "Building $Package (release)..." -ForegroundColor Cyan
Push-Location $root
try { cargo xtask bundle $Package --release } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "cargo xtask bundle failed (exit $LASTEXITCODE)" }
}
$vst3Name = "Codename 206.vst3"
$clapName = "Codename 206.clap"
$bundled = Join-Path $root "target\bundled"
$vst3Src = Join-Path $bundled $vst3Name
$clapSrc = Join-Path $bundled $clapName
if (-not (Test-Path $vst3Src)) { throw "Bundle not found: $vst3Src (build first)" }
if ($User) {
$vst3Dst = Join-Path $env:LOCALAPPDATA "Programs\Common\VST3"
$clapDst = Join-Path $env:LOCALAPPDATA "Programs\Common\CLAP"
New-Item -ItemType Directory -Force -Path $vst3Dst, $clapDst | Out-Null
Write-Host "Installing (per-user, no admin)..." -ForegroundColor Cyan
Remove-Item (Join-Path $vst3Dst $vst3Name) -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item $vst3Src -Destination $vst3Dst -Recurse -Force
if (Test-Path $clapSrc) { Copy-Item $clapSrc -Destination $clapDst -Force }
Write-Host "Installed to $vst3Dst" -ForegroundColor Green
Write-Host "Add that folder to FL's plugin search paths if you haven't." -ForegroundColor Yellow
}
else {
$vst3Dst = Join-Path $env:CommonProgramFiles "VST3"
$clapDst = Join-Path $env:CommonProgramFiles "CLAP"
Write-Host "Installing to $vst3Dst (requires admin - accept the UAC prompt)..." -ForegroundColor Cyan
$cmd = @"
New-Item -ItemType Directory -Force -Path '$clapDst' | Out-Null
Remove-Item -LiteralPath '$vst3Dst\$vst3Name' -Recurse -Force -ErrorAction SilentlyContinue
Copy-Item -LiteralPath '$vst3Src' -Destination '$vst3Dst' -Recurse -Force
if (Test-Path -LiteralPath '$clapSrc') { Copy-Item -LiteralPath '$clapSrc' -Destination '$clapDst' -Force }
"@
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
Start-Process powershell -Verb RunAs -Wait -ArgumentList '-NoProfile','-EncodedCommand',$enc
}
# Verify each bundle was actually refreshed. A plugin currently loaded in a DAW keeps its
# binary locked, so the copy fails silently and leaves a STALE install — re-scanning then runs
# old code. Compare install vs build timestamps to catch exactly that.
function Test-Installed($label, $src, $dst) {
if (-not (Test-Path $src)) { return } # nothing was built for this format
if (-not (Test-Path $dst)) { throw "$label install verification failed: $dst not found" }
$srcT = (Get-Item $src).LastWriteTime
$dstT = (Get-Item $dst).LastWriteTime
if ($dstT -lt $srcT) {
Write-Warning "$label is STALE (installed $dstT < built $srcT). It's almost certainly loaded in your DAW (file locked). Close the plugin/DAW and re-run deploy."
}
else {
Write-Host "$label installed OK -> $dst" -ForegroundColor Green
}
}
Test-Installed "VST3" (Join-Path $vst3Src "Contents\x86_64-win\$vst3Name") (Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name")
Test-Installed "CLAP" $clapSrc (Join-Path $clapDst $clapName)
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
+148
View File
@@ -0,0 +1,148 @@
//! Generic second-order IIR biquad, Transposed Direct Form II.
//!
//! Coefficient formulas are the RBJ Audio EQ Cookbook
//! (<https://www.w3.org/TR/audio-eq-cookbook/>), prenormalised by `a0`. Scalar `f32`; we run
//! one filter per channel rather than SIMD to match the rest of the per-channel DSP.
use std::f32::consts;
/// Butterworth Q (= 1/√2). Two cascaded Butterworth sections make a 4th-order Linkwitz-Riley.
pub const NEUTRAL_Q: f32 = consts::FRAC_1_SQRT_2;
/// Prenormalised biquad coefficients `[b0, b1, b2, a1, a2]` (already divided by `a0`).
#[derive(Clone, Copy)]
pub struct BiquadCoefficients {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
}
impl Default for BiquadCoefficients {
fn default() -> Self {
Self::identity()
}
}
impl BiquadCoefficients {
/// Passes the signal through unchanged.
pub fn identity() -> Self {
Self { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
}
pub fn lowpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 - cos_w0) / 2.0) / a0,
b1: (1.0 - cos_w0) / a0,
b2: ((1.0 - cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
pub fn highpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: ((1.0 + cos_w0) / 2.0) / a0,
b1: -(1.0 + cos_w0) / a0,
b2: ((1.0 + cos_w0) / 2.0) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
pub fn allpass(sample_rate: f32, frequency: f32, q: f32) -> Self {
let (cos_w0, alpha) = Self::omega(sample_rate, frequency, q);
let a0 = 1.0 + alpha;
Self {
b0: (1.0 - alpha) / a0,
b1: (-2.0 * cos_w0) / a0,
b2: (1.0 + alpha) / a0,
a1: (-2.0 * cos_w0) / a0,
a2: (1.0 - alpha) / a0,
}
}
/// Shared intermediate terms: `(cos ω0, α)`.
fn omega(sample_rate: f32, frequency: f32, q: f32) -> (f32, f32) {
let w0 = consts::TAU * (frequency / sample_rate);
(w0.cos(), w0.sin() / (2.0 * q))
}
}
/// A biquad filter holding its two state variables.
#[derive(Clone, Copy, Default)]
pub struct Biquad {
coefficients: BiquadCoefficients,
s1: f32,
s2: f32,
}
impl Biquad {
/// Replace the coefficients (keeps the state — fine for smooth coefficient changes).
pub fn set_coefficients(&mut self, coefficients: BiquadCoefficients) {
self.coefficients = coefficients;
}
/// Process one sample (Transposed Direct Form II).
#[inline]
pub fn process(&mut self, x: f32) -> f32 {
let c = &self.coefficients;
let y = c.b0 * x + self.s1;
self.s1 = c.b1 * x - c.a1 * y + self.s2;
self.s2 = c.b2 * x - c.a2 * y;
y
}
/// Clear the filter state.
pub fn reset(&mut self) {
self.s1 = 0.0;
self.s2 = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f32 = 48_000.0;
fn magnitude_at(mut coeffs_filter: Biquad, freq: f32) -> f32 {
use std::f32::consts::TAU;
let n = 16_000usize;
let mut acc = 0.0f64;
for i in 0..n {
let x = (TAU * freq * i as f32 / SR).sin();
let y = coeffs_filter.process(x);
if i >= n - 8_000 {
acc += (y * y) as f64;
}
}
// RMS of a unit sine is 1/√2; divide it out to get the magnitude response.
((acc / 8_000.0).sqrt() as f32) * std::f32::consts::SQRT_2
}
#[test]
fn lowpass_passes_dc_blocks_highs() {
let lp = {
let mut b = Biquad::default();
b.set_coefficients(BiquadCoefficients::lowpass(SR, 1_000.0, NEUTRAL_Q));
b
};
assert!((magnitude_at(lp, 100.0) - 1.0).abs() < 0.05); // ~passband
assert!(magnitude_at(lp, 12_000.0) < 0.05); // ~stopband
}
#[test]
fn allpass_is_unity_magnitude() {
for &f in &[100.0, 1_000.0, 8_000.0] {
let mut b = Biquad::default();
b.set_coefficients(BiquadCoefficients::allpass(SR, 2_000.0, NEUTRAL_Q));
assert!((magnitude_at(b, f) - 1.0).abs() < 0.02, "allpass not flat at {f} Hz");
}
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Single full-band feed-forward compressor with look-ahead.
//!
//! Design follows Giannoulis, Massberg & Reiss, "Digital Dynamic Range Compressor
//! Design — A Tutorial and Analysis" (JAES 2012):
//!
//! * a **log-domain gain computer** with a quadratic **soft knee**, and
//! * a **smooth, decoupled peak detector** for the attack/release ballistics
//! (their preferred topology — avoids the artefacts of naive branching smoothers).
//!
//! Detection is **stereo-linked** (the control signal is `max(|ch|)` across channels)
//! so a single gain is applied to every channel and the stereo image is preserved.
//!
//! Look-ahead is a per-channel delay line on the audio path: the output sample is the
//! input from `L` samples ago, while the gain is computed from the *current* input —
//! so the gain reduction leads the audio by `L` samples. `L` is the plugin's latency.
/// Maximum look-ahead. This is also the **fixed** latency the plugin reports: the audio is
/// always delayed by this much and the latency is reported once, so the look-ahead knob can be
/// adjusted during playback without ever renegotiating latency with the host (which crashes
/// some DAWs, FL included). The knob only moves where the detector taps within this window.
pub const MAX_LOOKAHEAD_MS: f32 = 5.0;
/// Most channels we ever process in one frame (our audio layouts are mono/stereo).
const MAX_CHANNELS: usize = 2;
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
const LEVEL_EPS: f32 = 1e-12;
/// Silence-floor anchor for the below-threshold shaping: at/below this level the gain change is 0
/// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
/// the editor gain-curve's display floor.
const LOW_ANCHOR_DB: f32 = -60.0;
/// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
/// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
/// both ends (silence floor and the knee), so it never moves those anchors.
const LOW_BULGE_MAX_DB: f32 = 12.0;
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
/// promoted to a parameter later.
const RMS_WINDOW_MS: f32 = 5.0;
// Denormals (the exponentially-decaying envelope/RMS tails and the IIR filter state) are handled
// by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its
// `ScopedFtz` guard (x86 MXCSR / AArch64 FPCR). So no per-value flushing is needed here.
/// Per-block compressor settings. Cheap to copy; rebuilt each process block from params.
#[derive(Clone, Copy)]
pub struct CompressorSettings {
pub threshold_db: f32,
pub ratio: f32,
pub knee_db: f32,
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
/// Reshapes the level the compressor sees (serial), anchored at the floor.
pub low_slope: f32,
/// Low shaper curvature, 0..1 (0 = straight line, 1 = max bend toward bounded saturation).
pub low_curve: f32,
/// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]).
pub attack_coef: f32,
/// One-pole coefficient for the release ramp.
pub release_coef: f32,
pub makeup_db: f32,
/// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`.
/// This does NOT change the reported latency — the audio delay is always `fixed_delay`.
pub lookahead_samples: usize,
/// `true` = RMS detection (running power average), `false` = naive sample peak.
pub use_rms: bool,
/// Dry/wet blend, 0..=1. 1 = fully compressed (incl. makeup), 0 = dry passthrough (bypass).
/// Parallel: dry and wet share the same delayed input, so the mix is phase-aligned.
pub mix: f32,
}
pub struct Compressor {
sample_rate: f32,
/// Per-channel circular delay line, each `capacity` samples long.
delay: Vec<Vec<f32>>,
capacity: usize,
write_pos: usize,
/// Constant audio delay applied to every sample == the reported plugin latency.
fixed_delay: usize,
/// RMS detector state: running mean of the squared (linked) level, plus its coefficient.
mean_sq: f32,
rms_coef: f32,
/// Smooth decoupled peak-detector state, in dB of attenuation (signed: usually >= 0, but can go
/// negative = boost when `low_slope < 1`). The `max()` recurrence makes cut fast / boost slow.
y1: f32, // release branch (peak-with-decay)
yl: f32, // attack-smoothed output
}
impl Default for Compressor {
fn default() -> Self {
Self {
sample_rate: 48_000.0,
delay: Vec::new(),
capacity: 0,
write_pos: 0,
fixed_delay: 0,
mean_sq: 0.0,
rms_coef: 0.0,
y1: 0.0,
yl: 0.0,
}
}
}
impl Compressor {
pub fn new() -> Self {
Self::default()
}
/// Allocate delay buffers for the worst-case look-ahead. Call from `initialize()`,
/// where allocation is allowed — never from `process()`.
pub fn prepare(&mut self, sample_rate: f32, num_channels: usize, max_lookahead_ms: f32) {
self.sample_rate = sample_rate;
self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize;
// +1 so the oldest (output) sample and the newest (write) sample never alias.
self.capacity = self.fixed_delay + 1;
self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate);
let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels];
self.reset();
}
/// Clear all state. Real-time safe (no allocation).
pub fn reset(&mut self) {
for ch in self.delay.iter_mut() {
ch.iter_mut().for_each(|s| *s = 0.0);
}
self.write_pos = 0;
self.mean_sq = 0.0;
self.y1 = 0.0;
self.yl = 0.0;
}
/// Convert an attack/release time in milliseconds to a one-pole smoothing coefficient.
///
/// Convention: after `time_ms`, a step response reaches ~63% (1 1/e) of its target.
/// 0 ms (or less) yields coefficient 0 = instantaneous.
pub fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
if time_ms <= 0.0 {
0.0
} else {
(-1.0 / (time_ms * 0.001 * sample_rate)).exp()
}
}
/// Pure compressor transfer (threshold / ratio / quadratic soft knee). Returns gain reduction
/// in dB (<= 0) for an input `level_db`.
fn comp_gain_db(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
let over = level_db - threshold_db;
if knee_db > 0.0 && 2.0 * over.abs() <= knee_db {
let x = over + knee_db * 0.5; // 0..knee
slope * x * x / (2.0 * knee_db)
} else if over > 0.0 {
slope * over
} else {
0.0
}
}
/// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
/// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
/// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
/// bulges up (boost the quiet middle), negative down (suppress). Reshapes the level the
/// compressor then sees.
fn low_gain_db(level_db: f32, threshold_db: f32, low_slope: f32, low_curve: f32) -> f32 {
let d = level_db - LOW_ANCHOR_DB;
if d <= 0.0 {
return 0.0;
}
let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
let t = (d / span).min(1.0); // normalized position, clamped at the knee
// Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
let slope_line = (low_slope - 1.0) * span * t;
// Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
let bulge = low_curve * LOW_BULGE_MAX_DB * 4.0 * t * (1.0 - t);
slope_line + bulge
}
/// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
/// threshold sees the shaped level. Returns total gain in dB (signed: negative = cut, positive
/// = boost). `gain = low + comp(level + low)`. Shared with the editor's gain-curve display —
/// single source of truth.
pub fn gain_computer(
level_db: f32,
threshold_db: f32,
ratio: f32,
knee_db: f32,
low_slope: f32,
low_curve: f32,
) -> f32 {
let low = Self::low_gain_db(level_db, threshold_db, low_slope, low_curve);
low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
}
/// The plugin's fixed reported latency in samples (the constant audio delay).
pub fn latency(&self) -> u32 {
self.fixed_delay as u32
}
/// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering.
/// This is the smoothed detector output `yl`, so it tracks the visible needle, not the
/// instantaneous static curve.
pub fn gain_reduction_db(&self) -> f32 {
self.yl
}
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
///
/// `input` and `output` are short stack slices (one value per channel), so this
/// performs no allocation. Detection is linked across the provided channels.
///
/// The audio is always delayed by `fixed_delay`; `set.lookahead_samples` (0..=fixed_delay)
/// only chooses how far *ahead* of the output the detector taps, so changing it never
/// alters latency.
pub fn process(&mut self, input: &[f32], output: &mut [f32], set: &CompressorSettings) {
debug_assert_eq!(input.len(), output.len());
let n = input.len().min(self.delay.len());
let l = set.lookahead_samples.min(self.fixed_delay);
// 1) Write the current input into the delay lines.
for ch in 0..n {
self.delay[ch][self.write_pos] = input[ch];
}
// 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads
// `l` samples newer than the output, i.e. `l` samples into the output's future.
let out_pos = (self.write_pos + self.capacity - self.fixed_delay) % self.capacity;
let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity;
// 3) Linked peak detector at the look-ahead tap.
let mut peak = 0.0f32;
for ch in 0..n {
peak = peak.max(self.delay[ch][det_pos].abs());
}
// 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
// reflects the wet gain reduction regardless of the dry/wet blend.
// RMS = running mean of the linked squared level over a fixed window. Updated whenever
// active (regardless of mode) so switching peak<->RMS is seamless.
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
// Desired attenuation in dB, as a positive quantity.
let target = -Self::gain_computer(
level_db,
set.threshold_db,
set.ratio,
set.knee_db,
set.low_slope,
set.low_curve,
);
// Smooth, decoupled peak detector (Giannoulis eq. 1718) on the attenuation:
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
// yl = attack-smoothed y1
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0);
// 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the
// blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
let mix = set.mix.clamp(0.0, 1.0);
let blend = (1.0 - mix) + mix * wet_gain;
for ch in 0..n {
output[ch] = self.delay[ch][out_pos] * blend;
}
// 6) Advance the write head.
self.write_pos = (self.write_pos + 1) % self.capacity;
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f32 = 48_000.0;
fn assert_close(a: f32, b: f32, tol: f32) {
assert!((a - b).abs() <= tol, "expected {a} ≈ {b} (tol {tol})");
}
fn settings(threshold_db: f32, ratio: f32, knee_db: f32) -> CompressorSettings {
CompressorSettings {
threshold_db,
ratio,
knee_db,
attack_coef: Compressor::time_to_coef(1.0, SR),
release_coef: Compressor::time_to_coef(1.0, SR),
makeup_db: 0.0,
lookahead_samples: 0,
use_rms: false,
mix: 1.0,
low_slope: 1.0,
low_curve: 0.0,
}
}
#[test]
fn below_threshold_is_untouched() {
// -30 dB input, -20 dB threshold -> no reduction.
assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0, 1.0, 0.0), 0.0);
}
#[test]
fn low_shaper_serial_slope_and_bipolar_bulge() {
let thr = -18.0;
// Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
// comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
// Cut (slope 0.5) -> -15 dB; shaped to -45, still below threshold -> net -15.
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
// Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
let mid = -39.0; // middle of [-60, -18]
let flat = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 0.0);
let up = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 1.0);
let down = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, -1.0);
assert!(up > flat && flat > down, "bipolar bulge expected: {down} < {flat} < {up}");
// Endpoints are unaffected by curvature (silence anchored).
assert_close(Compressor::gain_computer(-60.0, thr, 4.0, 0.0, 1.0, 1.0), 0.0, 1e-6);
}
#[test]
fn above_knee_follows_ratio() {
// 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction.
let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0, 1.0, 0.0);
assert_close(r, -7.5, 1e-4);
}
#[test]
fn knee_is_continuous_with_linear_region() {
// At the upper knee edge the soft-knee and linear formulas must agree.
let (t, ratio, knee) = (0.0, 4.0, 6.0);
let edge = t + knee / 2.0;
let knee_val = Compressor::gain_computer(edge, t, ratio, knee, 1.0, 0.0);
let linear_val = (1.0 / ratio - 1.0) * (edge - t);
assert_close(knee_val, linear_val, 1e-4);
// At the lower edge there is still no reduction.
assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee, 1.0, 0.0), 0.0, 1e-6);
}
#[test]
fn steady_state_matches_static_curve() {
// Constant 0.5 (-6.02 dBFS) into a -18 dB / 2:1 / hard-knee comp:
// reduction = -0.5 * (6.02 18) = 5.99 dB, so output ≈ 0.5 * 10^(5.99/20).
let mut comp = Compressor::new();
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS);
let set = settings(-18.0, 2.0, 0.0);
let mut out = [0.0f32];
for _ in 0..SR as usize {
comp.process(&[0.5], &mut out, &set);
}
let level_db = 20.0 * 0.5f32.log10();
let reduction = (1.0 / 2.0 - 1.0) * (level_db - (-18.0));
let expected = 0.5 * 10.0f32.powf(reduction / 20.0);
assert_close(out[0], expected, 1e-3);
}
#[test]
fn rms_compresses_a_sine_less_than_peak() {
// For a sine, RMS level is ~3 dB below the peak (A/√2), so RMS detection sees a lower
// level and applies less gain reduction -> louder output than peak detection.
use std::f32::consts::PI;
fn output_rms(use_rms: bool) -> f32 {
let mut comp = Compressor::new();
comp.prepare(SR, 1, MAX_LOOKAHEAD_MS);
let mut set = settings(-30.0, 4.0, 0.0);
set.use_rms = use_rms;
set.attack_coef = Compressor::time_to_coef(1.0, SR);
set.release_coef = Compressor::time_to_coef(50.0, SR);
let (amp, freq) = (0.5f32, 2000.0f32);
let total = SR as usize;
let mut out = [0.0f32];
let (mut acc, mut cnt) = (0.0f64, 0u32);
for i in 0..total {
let x = amp * (2.0 * PI * freq * i as f32 / SR).sin();
comp.process(&[x], &mut out, &set);
if i >= total - 4800 {
// measure RMS over the last 0.1 s, after settling
acc += (out[0] * out[0]) as f64;
cnt += 1;
}
}
(acc / cnt as f64).sqrt() as f32
}
assert!(
output_rms(true) > output_rms(false),
"RMS detection should compress a sine less than peak"
);
}
#[test]
fn latency_is_constant_regardless_of_lookahead() {
// Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead
// knob must NOT change that (this is what keeps live adjustment from renegotiating
// latency and crashing the host).
let mut comp = Compressor::new();
comp.prepare(SR, 1, 0.5); // small max so the fixed delay is quick to test
let d = comp.latency() as usize;
assert!(d > 0);
for &l in &[0usize, d / 2, d] {
comp.reset();
let mut set = settings(0.0, 1.0, 0.0);
set.mix = 0.0; // dry passthrough -> isolate the delay behaviour
set.lookahead_samples = l;
let mut out = [0.0f32];
for n in 0..(d + 5) {
let x = if n == 0 { 1.0 } else { 0.0 };
comp.process(&[x], &mut out, &set);
if n == d {
assert_close(out[0], 1.0, 1e-6); // impulse always emerges after `d`, any L
} else {
assert_close(out[0], 0.0, 1e-6);
}
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank.
//!
//! Each crossover splits into a low-passed band output and a high-passed remainder that feeds
//! the next crossover. Because higher bands pass through more filters, lower bands are phase-
//! compensated with an all-pass at every *later* crossover frequency so the three bands sum back
//! to flat **magnitude** (the sum is an all-pass of the input — phase-shifted, not bit-identical,
//! which is inherent to IIR Linkwitz-Riley). Approach mirrors NIH-plug's `crossover` plugin.
//!
//! For 3 bands there are two crossovers (low/mid at `f_lo`, mid/high at `f_hi`); only the low
//! band needs compensation (one all-pass at `f_hi`).
use super::biquad::{Biquad, BiquadCoefficients, NEUTRAL_Q};
/// Mono/stereo only, matching the plugin's audio layouts.
const MAX_CHANNELS: usize = 2;
/// One channel's worth of filter state for the 3-band split.
#[derive(Clone, Copy, Default)]
struct BandSplitter {
lp_lo: [Biquad; 2], // LR4 low-pass at f_lo (two cascaded Butterworth)
hp_lo: [Biquad; 2], // LR4 high-pass at f_lo
lp_hi: [Biquad; 2], // LR4 low-pass at f_hi
hp_hi: [Biquad; 2], // LR4 high-pass at f_hi
ap_low: Biquad, // all-pass at f_hi, phase-compensates the low band
}
impl BandSplitter {
/// Split one sample into `[low, mid, high]`.
fn split(&mut self, x: f32) -> [f32; 3] {
// Crossover at f_lo: low-passed band + high-passed remainder.
let mut lp = x;
for f in &mut self.lp_lo {
lp = f.process(lp);
}
let mut hp = x;
for f in &mut self.hp_lo {
hp = f.process(hp);
}
// Low band is phase-compensated for the f_hi crossover the upper bands pass through.
let low = self.ap_low.process(lp);
// Crossover at f_hi splits the remainder into mid + high.
let mut mid = hp;
for f in &mut self.lp_hi {
mid = f.process(mid);
}
let mut high = hp;
for f in &mut self.hp_hi {
high = f.process(high);
}
[low, mid, high]
}
fn set_coefficients(
&mut self,
lp_lo: BiquadCoefficients,
hp_lo: BiquadCoefficients,
lp_hi: BiquadCoefficients,
hp_hi: BiquadCoefficients,
ap_low: BiquadCoefficients,
) {
for f in &mut self.lp_lo {
f.set_coefficients(lp_lo);
}
for f in &mut self.hp_lo {
f.set_coefficients(hp_lo);
}
for f in &mut self.lp_hi {
f.set_coefficients(lp_hi);
}
for f in &mut self.hp_hi {
f.set_coefficients(hp_hi);
}
self.ap_low.set_coefficients(ap_low);
}
fn reset(&mut self) {
for f in self
.lp_lo
.iter_mut()
.chain(&mut self.hp_lo)
.chain(&mut self.lp_hi)
.chain(&mut self.hp_hi)
{
f.reset();
}
self.ap_low.reset();
}
}
pub struct Crossover {
channels: usize,
splitters: [BandSplitter; MAX_CHANNELS],
}
impl Default for Crossover {
fn default() -> Self {
Self {
channels: 2,
splitters: [BandSplitter::default(); MAX_CHANNELS],
}
}
}
impl Crossover {
pub fn new() -> Self {
Self::default()
}
/// Set the active channel count and clear state. Call from `initialize()`.
pub fn prepare(&mut self, channels: usize) {
self.channels = channels.clamp(1, MAX_CHANNELS);
self.reset();
}
/// Recompute and apply crossover coefficients. Cheap enough to call once per block.
/// Frequencies are clamped to a valid range and forced monotonic (`f_lo <= f_hi`).
pub fn update(&mut self, sample_rate: f32, low_hz: f32, high_hz: f32) {
let max_hz = sample_rate * 0.49;
let f_lo = low_hz.clamp(20.0, max_hz);
let f_hi = high_hz.clamp(f_lo, max_hz);
let lp_lo = BiquadCoefficients::lowpass(sample_rate, f_lo, NEUTRAL_Q);
let hp_lo = BiquadCoefficients::highpass(sample_rate, f_lo, NEUTRAL_Q);
let lp_hi = BiquadCoefficients::lowpass(sample_rate, f_hi, NEUTRAL_Q);
let hp_hi = BiquadCoefficients::highpass(sample_rate, f_hi, NEUTRAL_Q);
let ap_low = BiquadCoefficients::allpass(sample_rate, f_hi, NEUTRAL_Q);
for s in &mut self.splitters {
s.set_coefficients(lp_lo, hp_lo, lp_hi, hp_hi, ap_low);
}
}
pub fn reset(&mut self) {
for s in &mut self.splitters {
s.reset();
}
}
/// Split one sample of `channel` into `[low, mid, high]`.
#[inline]
pub fn split(&mut self, channel: usize, x: f32) -> [f32; 3] {
self.splitters[channel].split(x)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
#[test]
fn bands_sum_to_flat_magnitude() {
// LR4 bands sum to an all-pass: the magnitude is flat at every frequency (including the
// crossovers), even though the time-domain signal is phase-shifted (so it is NOT a
// bit-exact null — that only holds for linear-phase FIR crossovers).
let mut xo = Crossover::new();
xo.prepare(1);
xo.update(SR, 200.0, 2_500.0);
for &f in &[50.0, 200.0, 1_000.0, 2_500.0, 9_000.0] {
xo.reset();
let n = 24_000usize;
let (mut in_acc, mut out_acc) = (0.0f64, 0.0f64);
for i in 0..n {
let x = (TAU * f * i as f32 / SR).sin();
let [lo, mid, hi] = xo.split(0, x);
let y = lo + mid + hi;
if i >= n - 8_000 {
in_acc += (x * x) as f64;
out_acc += (y * y) as f64;
}
}
let ratio = (out_acc / in_acc).sqrt() as f32;
assert!(
(ratio - 1.0).abs() < 0.06,
"reconstruction not flat at {f} Hz: {ratio}"
);
}
}
#[test]
fn bands_are_actually_split() {
// Sanity: the low band should keep lows and reject highs; the high band vice versa.
fn band_energy(band: usize, freq: f32) -> f64 {
let mut xo = Crossover::new();
xo.prepare(1);
xo.update(SR, 200.0, 2_500.0);
let n = 24_000usize;
let mut acc = 0.0f64;
for i in 0..n {
let x = (TAU * freq * i as f32 / SR).sin();
let bands = xo.split(0, x);
if i >= n - 8_000 {
acc += (bands[band] * bands[band]) as f64;
}
}
acc
}
assert!(band_energy(0, 50.0) > band_energy(0, 9_000.0) * 100.0); // low band: lows >> highs
assert!(band_energy(2, 9_000.0) > band_energy(2, 50.0) * 100.0); // high band: highs >> lows
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Look-ahead brickwall peak limiter (base-rate).
//!
//! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp
//! down *before* a peak reaches the output (click-free), driven by a **sliding maximum** over the
//! look-ahead window so the reduction is fully in place in time. A final clamp at the ceiling is
//! the hard guarantee against any residual from smoothing lag or float error.
//!
//! Detection is stereo-linked (one gain for all channels). This stage limits **sample** peaks at
//! the base rate; true-peak (inter-sample) limiting via oversampling is a later addition.
use super::oversampler::Oversampler;
const MAX_CHANNELS: usize = 2;
/// Fixed look-ahead — also this stage's constant latency contribution.
const LOOKAHEAD_MS: f32 = 1.5;
/// Near-instant attack; the look-ahead gives it time to act before the peak arrives.
const ATTACK_MS: f32 = 0.05;
/// The 4× true-peak detector can still under-read by a few tenths of a dB near Nyquist, so we
/// target a hair below the ceiling to keep the actual inter-sample peak under it.
const TRUE_PEAK_MARGIN_DB: f32 = 0.3;
fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 {
if time_ms <= 0.0 {
0.0
} else {
(-1.0 / (time_ms * 0.001 * sample_rate)).exp()
}
}
pub struct Limiter {
/// Per-channel audio delay ring.
delay: Vec<Vec<f32>>,
/// Linked `|x|` history, same length as the delay ring (for the sliding maximum).
peaks: Vec<f32>,
capacity: usize,
write_pos: usize,
fixed_delay: usize,
/// Current smoothed gain (<= 1).
gain: f32,
attack_coef: f32,
/// 4× interpolator for true-peak (inter-sample) detection.
oversampler: Oversampler,
}
impl Default for Limiter {
fn default() -> Self {
Self {
delay: Vec::new(),
peaks: Vec::new(),
capacity: 0,
write_pos: 0,
fixed_delay: 0,
gain: 1.0,
attack_coef: 0.0,
oversampler: Oversampler::new(),
}
}
}
impl Limiter {
pub fn new() -> Self {
Self::default()
}
/// Allocate buffers. Call from `initialize()` (allocation allowed).
pub fn prepare(&mut self, sample_rate: f32, num_channels: usize) {
self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize;
self.capacity = self.fixed_delay + 1;
self.attack_coef = time_to_coef(ATTACK_MS, sample_rate);
let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels];
self.peaks = vec![0.0; self.capacity];
self.oversampler.prepare(channels);
self.reset();
}
pub fn reset(&mut self) {
for ch in &mut self.delay {
ch.iter_mut().for_each(|s| *s = 0.0);
}
self.peaks.iter_mut().for_each(|p| *p = 0.0);
self.oversampler.reset();
self.write_pos = 0;
self.gain = 1.0;
}
/// Constant reported latency (the fixed look-ahead delay).
pub fn latency(&self) -> u32 {
self.fixed_delay as u32
}
/// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a
/// positive dB amount for the ceiling lamp / metering.
pub fn gain_reduction_db(&self) -> f32 {
-20.0 * self.gain.max(1e-9).log10()
}
/// Limit one frame in place: `input[ch]` -> `output[ch]`.
///
/// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a
/// release time. Output is guaranteed `|y| <= ceiling`.
pub fn process(&mut self, input: &[f32], output: &mut [f32], ceiling: f32, release_coef: f32) {
let n = input.len().min(self.delay.len());
// Detector = max of the sample peak and the 4× true-peak (inter-sample) estimate.
let mut sample_peak = 0.0f32;
for &x in &input[..n] {
sample_peak = sample_peak.max(x.abs());
}
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.
let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0);
// Write into the ring.
for ch in 0..n {
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
// (the one we output now) is in this window, `ceiling / window_max` applied to it can never
// exceed the ceiling, and the gain has pre-dropped for any louder sample still to come.
let mut window_max = 0.0f32;
for &p in &self.peaks {
window_max = window_max.max(p);
}
let target = if window_max > target_ceiling {
target_ceiling / window_max
} else {
1.0
};
// Decoupled smoothing: fast attack down, slow release up.
self.gain = if target < self.gain {
self.attack_coef * self.gain + (1.0 - self.attack_coef) * target
} else {
release_coef * self.gain + (1.0 - release_coef) * target
};
// Output the delayed sample, clamped to the ceiling as the hard guarantee.
let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago
for ch in 0..n {
output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling);
}
self.write_pos = (self.write_pos + 1) % self.capacity;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn release() -> f32 {
time_to_coef(50.0, SR)
}
#[test]
fn never_exceeds_ceiling_on_spikes() {
// Mostly silence with occasional large spikes — output must never exceed the ceiling.
let mut lim = Limiter::new();
lim.prepare(SR, 1);
let ceiling = 1.0;
let mut out = [0.0f32];
for i in 0..10_000 {
let x = if i % 500 == 0 { 5.0 } else { 0.01 };
lim.process(&[x], &mut out, ceiling, release());
assert!(out[0].abs() <= ceiling + 1e-6, "overshoot at {i}: {}", out[0]);
}
}
#[test]
fn limits_loud_sine_to_ceiling() {
// A sine well above the ceiling settles to ~ceiling, not silenced.
let mut lim = Limiter::new();
lim.prepare(SR, 1);
let ceiling = 1.0;
let (amp, freq) = (2.0f32, 1_000.0);
let mut out = [0.0f32];
let mut max_tail = 0.0f32;
let total = SR as usize;
for i in 0..total {
let x = amp * (TAU * freq * i as f32 / SR).sin();
lim.process(&[x], &mut out, ceiling, release());
if i >= total - 4_800 {
max_tail = max_tail.max(out[0].abs());
}
}
assert!(max_tail <= ceiling + 1e-6, "exceeded ceiling: {max_tail}");
assert!(max_tail > 0.9, "over-attenuated: {max_tail}");
}
#[test]
fn transparent_below_ceiling() {
// A signal under the ceiling passes through unattenuated (just delayed).
let mut lim = Limiter::new();
lim.prepare(SR, 1);
let ceiling = 1.0;
let (amp, freq) = (0.5f32, 1_000.0);
let mut out = [0.0f32];
let mut max_tail = 0.0f32;
let total = SR as usize / 2;
for i in 0..total {
let x = amp * (TAU * freq * i as f32 / SR).sin();
lim.process(&[x], &mut out, ceiling, release());
if i >= total - 4_800 {
max_tail = max_tail.max(out[0].abs());
}
}
assert!((max_tail - amp).abs() < 1e-3, "not transparent: {max_tail}");
}
#[test]
fn latency_is_the_lookahead() {
let mut lim = Limiter::new();
lim.prepare(SR, 1);
let expected = (LOOKAHEAD_MS * 0.001 * SR).ceil() as u32;
assert_eq!(lim.latency(), expected);
assert!(expected > 0);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! DSP building blocks for Codename 206.
//!
//! The signal chain: a `crossover` filterbank splits into bands, each band (plus the summed
//! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector)
//! is the final stage. `biquad` is the shared filter primitive the crossover is built from.
pub mod biquad;
pub mod compressor;
pub mod crossover;
pub mod limiter;
pub mod oversampler;
+149
View File
@@ -0,0 +1,149 @@
//! 4× polyphase interpolation for **true-peak (inter-sample) detection only**.
//!
//! A band-limited signal can overshoot its sample values between samples, so the digital sample
//! peak under-reads the real (post-DAC) peak. We reconstruct the 4× grid with a polyphase
//! windowed-sinc interpolator and report only the maximum magnitude found — the interpolated
//! samples themselves are discarded. The audio path is untouched; this just feeds a better peak
//! estimate into the limiter.
//!
//! Speed: the prototype `PHASES * TAPS_PER_PHASE`-tap low-pass is split into `PHASES` sub-filters
//! of `TAPS_PER_PHASE` taps, each run at the base rate (no zero-stuffed multiplies). Cost is
//! `PHASES * TAPS_PER_PHASE` MACs per input sample per channel — about one base-rate FIR.
//!
//! The interpolation point sits at the centre of the tap window, so the estimate lags the input by
//! ~`TAPS_PER_PHASE/2` samples. That is far smaller than the limiter's look-ahead, which absorbs it
//! — so this adds no reported latency.
const PHASES: usize = 4;
const TAPS_PER_PHASE: usize = 12;
const MAX_CHANNELS: usize = 2;
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
/// sub-filters, each normalised to unity DC gain so reconstruction preserves amplitude.
fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] {
use std::f32::consts::PI;
let n = PHASES * TAPS_PER_PHASE;
let center = (n as f32 - 1.0) / 2.0;
let mut proto = [0.0f32; PHASES * TAPS_PER_PHASE];
for (m, p) in proto.iter_mut().enumerate() {
// Sinc low-pass at the base-rate Nyquist (cutoff = 1/PHASES of the oversampled rate).
let x = (m as f32 - center) / PHASES as f32;
let sinc = if x.abs() < 1e-7 { 1.0 } else { (PI * x).sin() / (PI * x) };
// Blackman window.
let t = m as f32 / (n as f32 - 1.0);
let window = 0.42 - 0.5 * (2.0 * PI * t).cos() + 0.08 * (4.0 * PI * t).cos();
*p = sinc * window;
}
let mut coeffs = [[0.0f32; TAPS_PER_PHASE]; PHASES];
for (phase, row) in coeffs.iter_mut().enumerate() {
let mut sum = 0.0;
for (k, c) in row.iter_mut().enumerate() {
*c = proto[k * PHASES + phase];
sum += *c;
}
if sum.abs() > 1e-12 {
for c in row.iter_mut() {
*c /= sum; // unity DC per phase -> amplitude-preserving
}
}
}
coeffs
}
pub struct Oversampler {
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
history: Vec<[f32; TAPS_PER_PHASE]>,
pos: usize,
}
impl Default for Oversampler {
fn default() -> Self {
Self {
coeffs: build_coefficients(),
history: Vec::new(),
pos: 0,
}
}
}
impl Oversampler {
pub fn new() -> Self {
Self::default()
}
pub fn prepare(&mut self, num_channels: usize) {
let channels = num_channels.clamp(1, MAX_CHANNELS);
self.history = vec![[0.0; TAPS_PER_PHASE]; channels];
self.reset();
}
pub fn reset(&mut self) {
for ch in &mut self.history {
*ch = [0.0; TAPS_PER_PHASE];
}
self.pos = 0;
}
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
/// 4× grid (the reconstructed samples are not kept).
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
let n = input.len().min(self.history.len());
let slot = self.pos % TAPS_PER_PHASE;
for ch in 0..n {
self.history[ch][slot] = input[ch];
}
let mut peak = 0.0f32;
for ch in 0..n {
let hist = &self.history[ch];
for phase in &self.coeffs {
let mut acc = 0.0f32;
for (k, &c) in phase.iter().enumerate() {
// k = 0 is the newest sample, increasing k goes back in time.
let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
acc += c * hist[idx];
}
peak = peak.max(acc.abs());
}
}
self.pos += 1;
peak
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
#[test]
fn detects_inter_sample_overshoot() {
// A unit sine at fs/4 phased so every sample lands at ±0.707 while the true peak is 1.0
// (a classic ~3 dB inter-sample overshoot). The detector must see well above 0.707.
let mut os = Oversampler::new();
os.prepare(1);
let mut detected = 0.0f32;
for n in 0..2_000 {
let x = (PI * n as f32 / 2.0 + PI / 4.0).sin(); // sin(πn/2 + π/4)
detected = detected.max(os.max_true_peak(&[x]));
}
assert!(detected > 0.9, "missed inter-sample peak: {detected}");
assert!(detected < 1.1, "implausible overshoot: {detected}");
}
#[test]
fn preserves_amplitude_of_constant() {
// Unity-DC normalisation: a constant signal reconstructs at its own level.
let mut os = Oversampler::new();
os.prepare(1);
let mut last = 0.0f32;
for _ in 0..200 {
last = os.max_true_peak(&[0.5]);
}
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}");
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Draggable crossover handles — replaces the two plain crossover sliders.
//!
//! A horizontal log-frequency strip split into LOW / MID / HIGH by two draggable handles, plus a
//! number box per crossover (double-click to type an exact value). The handles enforce a dynamic
//! limit: lo/mid can never exceed mid/hi (and vice-versa), on top of each param's own range.
use nih_plug::prelude::*;
use nih_plug_egui::egui::{
self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke,
};
use crate::params::Codename206Params;
/// Height of the handle strip.
const GRAPH_H: f32 = 54.0;
/// Displayed frequency axis (log), independent of the params' own ranges.
const DISP_MIN_HZ: f32 = 20.0;
const DISP_MAX_HZ: f32 = 20_000.0;
fn log_span() -> f32 {
DISP_MAX_HZ.ln() - DISP_MIN_HZ.ln()
}
fn fmt_hz(hz: f32) -> String {
if hz >= 1000.0 {
format!("{:.2} kHz", hz / 1000.0)
} else {
format!("{:.0} Hz", hz)
}
}
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, setter: &ParamSetter) {
let lo = params.crossover_low_hz.value();
let hi = params.crossover_high_hz.value();
// Each param's own min/max (normalized 0/1 map to the range ends).
let lo_min = params.crossover_low_hz.preview_plain(0.0);
let lo_max = params.crossover_low_hz.preview_plain(1.0);
let hi_min = params.crossover_high_hz.preview_plain(0.0);
let hi_max = params.crossover_high_hz.preview_plain(1.0);
// Dynamic limits so the two never cross: lo <= hi.
let lo_upper = lo_max.min(hi);
let hi_lower = hi_min.max(lo);
ui.label("Crossover");
let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), GRAPH_H), Sense::hover());
let p = ui.painter_at(rect);
let (left, right, top, bottom) =
(rect.left() + 2.0, rect.right() - 2.0, rect.top() + 2.0, rect.bottom() - 2.0);
let width = right - left;
let x_for = |hz: f32| left + (hz.max(1.0).ln() - DISP_MIN_HZ.ln()) / log_span() * width;
let xlo = x_for(lo);
let xhi = x_for(hi);
// Three band regions.
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, Color32::from_rgb(28, 38, 52));
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(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, Color32::from_rgb(52, 38, 30));
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));
};
band_label((left + xlo) * 0.5, "LOW");
band_label((xlo + xhi) * 0.5, "MID");
band_label((xhi + right) * 0.5, "HIGH");
handle(ui, &p, setter, "xover_lo", xlo, lo, &params.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, Color32::from_rgb(120, 170, 230));
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));
// Number boxes (double-click to type). Clamped to the same dynamic limits.
ui.horizontal(|ui| {
ui.label("Lo/Mid");
let mut v = lo as f64;
if ui
.add(DragValue::new(&mut v).range(lo_min as f64..=lo_upper as f64).speed(0.5).suffix(" Hz"))
.changed()
{
set_clamped(setter, &params.crossover_low_hz, v as f32, lo_min, lo_upper);
}
ui.add_space(16.0);
ui.label("Mid/Hi");
let mut v = hi as f64;
if ui
.add(DragValue::new(&mut v).range(hi_lower as f64..=hi_max as f64).speed(2.0).suffix(" Hz"))
.changed()
{
set_clamped(setter, &params.crossover_high_hz, v as f32, hi_lower, hi_max);
}
});
}
/// One draggable vertical handle. Drives `param` from the pointer's x (log-frequency), clamped to
/// `[min, max]` (which already encodes the dynamic lo<=hi limit), with proper begin/end gestures.
#[allow(clippy::too_many_arguments)]
fn handle(
ui: &egui::Ui,
p: &Painter,
setter: &ParamSetter,
id_salt: &str,
x: f32,
hz: f32,
param: &FloatParam,
min: f32,
max: f32,
left: f32,
width: f32,
top: f32,
bottom: f32,
color: Color32,
) {
let hit = Rect::from_min_max(pos2(x - 5.0, top), pos2(x + 5.0, bottom));
let resp = ui
.interact(hit, ui.id().with(id_salt), Sense::drag())
.on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if resp.drag_started() {
setter.begin_set_parameter(param);
}
if resp.dragged() {
if let Some(pos) = resp.interact_pointer_pos() {
let frac = ((pos.x - left) / width).clamp(0.0, 1.0);
let new = (DISP_MIN_HZ.ln() + frac * log_span()).exp().clamp(min, max);
setter.set_parameter(param, new);
}
}
if resp.drag_stopped() {
setter.end_set_parameter(param);
}
let col = if resp.dragged() || resp.hovered() { Color32::WHITE } else { color };
p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0, col));
p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), Color32::from_gray(220));
}
/// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box).
fn set_clamped(setter: &ParamSetter, param: &FloatParam, value: f32, min: f32, max: f32) {
setter.begin_set_parameter(param);
setter.set_parameter(param, value.clamp(min, max));
setter.end_set_parameter(param);
}
+106
View File
@@ -0,0 +1,106 @@
//! Static gain-curve display: output level vs input level for the channel currently selected in
//! the plot. Plots the wet transfer `out = (in + pre_gain) + gain_reduction(...) + makeup` (mix not
//! folded in; output clamped at 0 dBFS), plus a live **operating-point fill** under the curve up to
//! the channel's current input level — its right edge rides the curve (width = input, height = out).
use nih_plug::prelude::util;
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke};
use std::sync::atomic::Ordering;
use crate::dsp::compressor::Compressor;
use crate::meters::Meters;
use crate::params::Codename206Params;
/// Side length of the square plot.
const CURVE_SIZE: f32 = 150.0;
/// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS).
const FLOOR_DB: f32 = -60.0;
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usize, meters: &Meters) {
let labels = ["LOW", "MID", "HIGH", "ALL"];
let ch = selected.min(3);
let cp = match ch {
0 => &params.low,
1 => &params.mid,
2 => &params.high,
_ => &params.all,
};
let pre = cp.pre_gain_db.value();
let threshold = cp.threshold_db.value();
let ratio = cp.ratio.value();
let knee = cp.knee_db.value();
let low_slope = cp.low_slope.value();
let low_curve = cp.low_curve.value();
let makeup = cp.makeup_db.value();
ui.label(format!("Curve: {}", labels[ch]));
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
let inset = 2.0;
let (left, right, top, bottom) =
(rect.left() + inset, rect.right() - inset, rect.top() + inset, rect.bottom() - inset);
let w = right - left;
let h = bottom - top;
let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w;
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h;
// -6 dBFS reference lines on both axes.
let g6 = Color32::from_gray(38);
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, g6));
p.line_segment([pos2(left, y6), pos2(right, y6)], Stroke::new(1.0, g6));
// 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)));
// Threshold marker on the input axis — shifted left by pre-gain (the comp sees in + pre).
let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, Color32::from_rgb(80, 60, 45)));
// Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
let n = 96;
let mut pts = Vec::with_capacity(n + 1);
for i in 0..=n {
let in_db = FLOOR_DB + (i as f32 / n as f32) * -FLOOR_DB; // external input, -60..0
let driven = in_db + pre;
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve); // signed dB
let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
pts.push(pos2(x_for(in_db), y_for(out_db)));
}
// Operating-point fill: shade under the curve from the floor up to the current input level.
let driven_now = util::gain_to_db(meters.input_level[ch].load(Ordering::Relaxed));
let ext_in = (driven_now - pre).clamp(FLOOR_DB, 0.0); // external input -> curve x
let x_now = x_for(ext_in);
let fill_col = Color32::from_rgba_unmultiplied(120, 200, 160, 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, Color32::from_rgb(120, 200, 160))));
// 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, Color32::from_rgb(235, 240, 235));
// Corner dB ticks + the -6 dB reference.
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), Color32::from_gray(90));
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), Color32::from_gray(90));
p.text(pos2(x6 + 2.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-6", FontId::proportional(9.0), Color32::from_gray(80));
}
+143
View File
@@ -0,0 +1,143 @@
//! Per-channel level + gain-reduction meters, each with its own latching ceiling/over lamp.
//!
//! Each channel is a `|L | GR | R|` cluster (output level left/right, mono gain reduction in the
//! middle) topped by a lamp. The lamp latches when the channel's output reaches 0 dBFS (a hot /
//! "over" warning — useful when pre-gain drives a band hard); for the ALL channel it also lights
//! when the output limiter is actually catching peaks. It holds, then clears after `LAMP_HOLD_S`
//! or on a click. Fed by the lock-free [`Meters`] state the audio thread publishes each block.
use nih_plug::prelude::*;
use nih_plug_egui::egui::{
self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Painter, Rect, Sense,
};
use std::sync::atomic::Ordering;
use super::METER_FLOOR_DB;
use crate::meters::{Meters, NUM_CHANNELS};
/// Full-scale of the gain-reduction bar (fills downward from the top).
const GR_FULL_DB: f32 = 24.0;
/// Output level (dBFS) at/above which a channel's lamp latches on.
const OVER_DB: f32 = 0.0;
/// Limiter gain reduction (dB) above which the ALL channel's lamp also latches on.
const LAMP_TRIGGER_DB: f32 = 0.1;
/// How long a lamp stays lit after the most recent trigger (seconds).
const LAMP_HOLD_S: f64 = 3.0;
/// Height of the meter panel.
const METER_PANEL_H: f32 = 140.0;
/// GUI-side state for the meter panel: one lamp latch per channel.
pub(super) struct MeterState {
/// egui time (seconds) of each channel's most recent lamp trigger, while latched on.
/// `None` = lamp off (never triggered, expired, or dismissed by a click).
ceiling_trigger: [Option<f64>; NUM_CHANNELS],
}
impl Default for MeterState {
fn default() -> Self {
Self { ceiling_trigger: [None; NUM_CHANNELS] }
}
}
/// Draw the meter panel: a `|L | GR | R|` cluster + a latching over/ceiling lamp per channel.
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
let labels = ["LOW", "MID", "HIGH", "ALL"];
let now = ui.ctx().input(|i| i.time);
let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24));
let top = rect.top() + 22.0; // leave a row at the top for the lamps
let bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
let cell_w = rect.width() / NUM_CHANNELS as f32;
// Three bars per cluster, so they're narrower than a two-bar layout.
let bar_w = (cell_w * 0.17).min(14.0);
let gap = (cell_w * 0.05).min(5.0);
for i in 0..NUM_CHANNELS {
let cell_left = rect.left() + i as f32 * cell_w;
let group_w = bar_w * 3.0 + gap * 2.0;
let bx = cell_left + (cell_w - group_w) * 0.5;
// L / R output level (upward); colour warns as it nears 0 dBFS.
let l_db = util::gain_to_db(meters.level_l[i].load(Ordering::Relaxed));
let r_db = util::gain_to_db(meters.level_r[i].load(Ordering::Relaxed));
let l_frac = ((l_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
let r_frac = ((r_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
// Mono gain reduction (downward from the top).
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
v_bar(&p, bx, bar_w, top, bottom, l_frac, 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 + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, level_color(r_db), false);
p.text(
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
Align2::CENTER_BOTTOM,
labels[i],
FontId::proportional(12.0),
Color32::from_gray(200),
);
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
// output limiter is catching peaks (the true master-ceiling event).
let over_db = l_db.max(r_db);
let mut triggered = over_db >= OVER_DB;
if i == NUM_CHANNELS - 1 {
triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB;
}
if triggered {
state.ceiling_trigger[i] = Some(now);
}
let lamp_center = pos2(cell_left + cell_w * 0.5, rect.top() + 11.0);
let lamp_rect = Rect::from_center_size(lamp_center, vec2(18.0, 18.0));
let resp = ui
.interact(lamp_rect, ui.id().with(("ceiling_lamp", i)), Sense::click())
.on_hover_cursor(CursorIcon::PointingHand)
.on_hover_text("Output reached 0 dBFS — click to clear");
if resp.clicked() {
state.ceiling_trigger[i] = None;
}
if let Some(t) = state.ceiling_trigger[i] {
if now - t >= LAMP_HOLD_S {
state.ceiling_trigger[i] = None;
}
}
let lamp = if state.ceiling_trigger[i].is_some() {
Color32::from_rgb(255, 40, 40)
} else {
Color32::from_rgb(40, 12, 12)
};
p.circle_filled(lamp_center, 5.0, lamp);
}
}
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the
/// top (gain reduction) instead of upward from the bottom (level).
fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
let track = Color32::from_rgb(34, 34, 40);
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track);
let h = (bottom - top) * frac.clamp(0.0, 1.0);
let filled = if from_top {
Rect::from_min_max(pos2(x, top), pos2(x + w, top + h))
} else {
Rect::from_min_max(pos2(x, bottom - h), pos2(x + w, bottom))
};
p.rect_filled(filled, CornerRadius::ZERO, fill);
}
/// 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)
}
}
+116
View File
@@ -0,0 +1,116 @@
//! egui editor: assembly + control layout.
//!
//! The aggregator. Builds the editor window and lays out the heading, the meter panel
//! ([`meter`]), the rolling plot ([`plot`]), and the (placeholder) per-channel slider columns.
//! Each visualiser owns its GUI state and drawing in its submodule; this module wires them
//! together and holds the shared [`EditorState`]. When the UI is redesigned the slider columns
//! get replaced and the visualisers stay as self-contained widgets.
use nih_plug::prelude::*;
use nih_plug_egui::{
create_egui_editor,
egui::{self, Vec2},
resizable_window::ResizableWindow,
widgets,
};
use std::sync::Arc;
use crate::meters::Meters;
use crate::params::{Codename206Params, CompressorParams};
use crate::Codename206;
mod crossover;
mod gain_curve;
mod meter;
mod plot;
/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS).
const METER_FLOOR_DB: f32 = -60.0;
/// GUI-side editor state (not persisted): the per-widget state for the meter panel and the plot.
#[derive(Default)]
struct EditorState {
meter: meter::MeterState,
plot: plot::PlotState,
}
/// Build the plugin editor over shared handles to the params and meter state.
pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
let egui_state = params.editor_state.clone();
create_egui_editor(
params.editor_state.clone(),
EditorState::default(),
|_, _| {},
move |egui_ctx, setter, state| {
// Keep frames coming so the meters animate and the lamp can time out while open.
egui_ctx.request_repaint();
// One column of controls for a single compressor channel (placeholder layout).
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
// Roughly in signal order: input drive -> low shaper -> compressor -> output.
ui.strong(title);
ui.label("Pre-gain");
ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter));
ui.add(widgets::ParamSlider::for_param(&p.detection, setter));
ui.label("Low Slope");
ui.add(widgets::ParamSlider::for_param(&p.low_slope, setter));
ui.label("Low Curve");
ui.add(widgets::ParamSlider::for_param(&p.low_curve, setter));
ui.label("Threshold");
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
ui.label("Ratio");
ui.add(widgets::ParamSlider::for_param(&p.ratio, setter));
ui.label("Knee");
ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter));
ui.label("Attack");
ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter));
ui.label("Release");
ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter));
ui.label("Makeup");
ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter));
ui.label("Mix");
ui.add(widgets::ParamSlider::for_param(&p.mix, setter));
};
// Resizable window; vertical scroll so every control stays reachable even when the
// window is small. (Placeholder layout — the redesign will replace the slider columns.)
ResizableWindow::new("editor")
.min_size(Vec2::new(480.0, 320.0))
.show(egui_ctx, egui_state.as_ref(), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(Codename206::NAME);
meter::draw(ui, &meters, &mut state.meter);
ui.separator();
// Gain curve (left, square) beside the scrolling plot (right, fills the rest).
let selected = state.plot.selected;
ui.horizontal_top(|ui| {
ui.vertical(|ui| gain_curve::draw(ui, &params, selected, &meters));
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
});
ui.separator();
crossover::draw(ui, &params, setter);
ui.separator();
// Global controls stacked vertically so they never overflow sideways.
egui::Grid::new("globals").num_columns(2).show(ui, |ui| {
ui.label("Look-ahead");
ui.add(widgets::ParamSlider::for_param(&params.look_ahead_ms, setter));
ui.end_row();
ui.label("Ceiling");
ui.add(widgets::ParamSlider::for_param(&params.output_ceiling_db, setter));
ui.end_row();
ui.label("Lim Release");
ui.add(widgets::ParamSlider::for_param(&params.limiter_release_ms, setter));
ui.end_row();
});
ui.separator();
ui.columns(4, |cols| {
band_col(&mut cols[0], "LOW", &params.low);
band_col(&mut cols[1], "MID", &params.mid);
band_col(&mut cols[2], "HIGH", &params.high);
band_col(&mut cols[3], "ALL", &params.all);
});
});
});
},
)
}
+226
View File
@@ -0,0 +1,226 @@
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector.
//!
//! Histories for all four channels run continuously (cheap), so switching tabs shows that
//! channel's existing history. It's fed by draining the audio thread's scope ring
//! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
//! rate rather than the editor frame rate. Buckets are folded into `PLOT_N` columns
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
use nih_plug::prelude::*;
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke};
use super::METER_FLOOR_DB;
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
/// Height of the plot panel.
const PLOT_PANEL_H: f32 = 150.0;
/// Number of columns held in the history ring.
const PLOT_N: usize = 256;
const COLOR_IN: Color32 = Color32::from_rgb(90, 170, 235);
const COLOR_OUT: Color32 = Color32::from_rgb(90, 200, 110);
const COLOR_GR: Color32 = Color32::from_rgb(240, 150, 60);
/// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns.
struct PlotHistory {
in_db: [[f32; PLOT_N]; NUM_CHANNELS],
out_db: [[f32; PLOT_N]; NUM_CHANNELS],
gr_db: [[f32; PLOT_N]; NUM_CHANNELS],
/// Per-column flag: the output limiter hit the ceiling somewhere in this column.
hit: [bool; PLOT_N],
write: usize,
len: usize,
}
impl Default for PlotHistory {
fn default() -> Self {
Self {
in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
out_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
gr_db: [[0.0; PLOT_N]; NUM_CHANNELS],
hit: [false; PLOT_N],
write: 0,
len: 0,
}
}
}
impl PlotHistory {
/// Append one column of (in_db, out_db, gr_db) per channel, plus the ceiling-hit flag.
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) {
for i in 0..NUM_CHANNELS {
self.in_db[i][self.write] = samples[i].0;
self.out_db[i][self.write] = samples[i].1;
self.gr_db[i][self.write] = samples[i].2;
}
self.hit[self.write] = hit;
self.write = (self.write + 1) % PLOT_N;
self.len = (self.len + 1).min(PLOT_N);
}
}
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
/// column being assembled from drained buckets.
pub(super) struct PlotState {
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
pub(super) selected: usize,
history: PlotHistory,
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
window_s: f64,
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
cursor: Option<u64>,
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
col_acc: [(f32, f32, f32); NUM_CHANNELS],
/// Ceiling-hit flag accumulated for the column currently being built.
col_hit: bool,
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
col_fill: f64,
}
impl Default for PlotState {
fn default() -> Self {
Self {
selected: 0,
history: PlotHistory::default(),
window_s: 5.0,
cursor: None,
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
col_hit: false,
col_fill: 0.0,
}
}
}
/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs
/// and flow-speed selector. History for all channels advances every frame regardless of the tab.
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
// Buckets that make up one column at the current flow speed (may be fractional).
let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
// Drain every bucket produced since the last frame (audio-clocked), folding them into columns.
// A fresh cursor starts at "now" so we don't replay stale buckets.
{
let w0 = meters.scope.write_index();
let cursor = state.cursor.get_or_insert(w0);
let history = &mut state.history;
let col_acc = &mut state.col_acc;
let col_hit = &mut state.col_hit;
let col_fill = &mut state.col_fill;
meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| {
for ch in 0..NUM_CHANNELS {
col_acc[ch].0 = col_acc[ch].0.max(util::gain_to_db(in_lin[ch]));
col_acc[ch].1 = col_acc[ch].1.max(util::gain_to_db(out_lin[ch]));
col_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]);
}
*col_hit |= hit > 0.5;
*col_fill += 1.0;
while *col_fill >= buckets_per_col {
history.push(col_acc, *col_hit);
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
*col_hit = false;
*col_fill -= buckets_per_col;
}
});
}
// Channel tabs + flow-speed selector + legend.
let labels = ["LOW", "MID", "HIGH", "ALL"];
let speeds = [2.0f64, 5.0, 15.0, 45.0];
ui.horizontal(|ui| {
ui.label("Plot:");
for (i, l) in labels.iter().enumerate() {
ui.selectable_value(&mut state.selected, i, *l);
}
ui.separator();
ui.label("Speed:");
let mut speed_changed = false;
for &w in &speeds {
if ui.selectable_value(&mut state.window_s, w, format!("{w:.0}s")).changed() {
speed_changed = true;
}
}
if speed_changed {
// Cadence changed: start the history fresh so the time axis is consistent.
state.history = PlotHistory::default();
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
state.col_hit = false;
state.col_fill = 0.0;
}
ui.separator();
ui.colored_label(COLOR_IN, "in");
ui.colored_label(COLOR_OUT, "out");
ui.colored_label(COLOR_GR, "GR");
});
let (rect, _) =
ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover());
let p = ui.painter_at(rect);
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
let (top, bottom, left, right) =
(rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0);
let width = right - left;
let y_for_db = |db: f32| -> f32 {
let frac = ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
bottom - frac * (bottom - top)
};
// Gridlines (dB).
for &g in &[0.0f32, -12.0, -24.0, -48.0] {
let y = y_for_db(g);
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, Color32::from_gray(40)));
p.text(
pos2(left + 2.0, y),
Align2::LEFT_BOTTOM,
format!("{g:.0}"),
FontId::proportional(9.0),
Color32::from_gray(90),
);
}
let c = state.selected.min(NUM_CHANNELS - 1);
let (write, len) = (state.history.write, state.history.len);
if len >= 2 {
let draw_series = |series: &[f32; PLOT_N], to_db: &dyn Fn(f32) -> f32, color: Color32, fill: bool| {
let mut pts = Vec::with_capacity(len);
for k in 0..len {
let idx = (write + PLOT_N - len + k) % PLOT_N;
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32; // newest hugs the right
pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx]))));
}
if fill {
// Fill as a strip of per-segment convex quads down to the baseline. A single
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
// triangles), so build convex pieces — one box per time unit — instead.
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
for seg in pts.windows(2) {
let (a, b) = (seg[0], seg[1]);
p.add(egui::Shape::convex_polygon(
vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
fill_col,
Stroke::NONE,
));
}
}
p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
};
draw_series(&state.history.in_db[c], &|db| db, COLOR_IN, true);
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
// GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline.
draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR, false);
// Ceiling-hit markers: a short red tick at the TOP for any column where the output limiter
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of
// hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise.
let dx = width / (PLOT_N - 1) as f32;
let marker = Color32::from_rgb(235, 45, 45);
for k in 0..len {
let idx = (write + PLOT_N - len + k) % PLOT_N;
if state.history.hit[idx] {
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32;
let x = left + pos * width;
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + dx, top + 3.0)), CornerRadius::ZERO, marker);
}
}
}
}
+365
View File
@@ -0,0 +1,365 @@
use nih_plug::prelude::*;
use std::sync::Arc;
mod dsp;
mod editor;
mod meters;
mod params;
use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
use dsp::crossover::Crossover;
use dsp::limiter::Limiter;
use meters::Meters;
use params::{build_settings, Codename206Params};
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
/// gain-gui example feel.)
const METER_DECAY_MS: f64 = 150.0;
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
const LOW: usize = 0;
const MID: usize = 1;
const HIGH: usize = 2;
const ALL: usize = 3;
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
///
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
/// 'All' compressor → output. Bypassing low+mid+high collapses it to a plain full-band comp
/// driven by the 'All' channel (the crossover sums flat).
struct Codename206 {
params: Arc<Codename206Params>,
sample_rate: f32,
crossover: Crossover,
/// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`].
comps: [Compressor; 4],
/// Output brickwall limiter (final stage).
limiter: Limiter,
/// Lock-free meter state shared with the editor.
meters: Arc<Meters>,
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to
/// the block length when applied once per block in `process`).
meter_decay_weight: f32,
/// Per-channel max accumulators for the plot bucket currently being built (in/out linear, GR
/// dB). Persist across blocks since a bucket spans many samples.
scope_in: [f32; 4],
scope_out: [f32; 4],
scope_gr: [f32; 4],
/// Max output-limiter gain reduction seen in the current bucket (for the ceiling-hit marker).
scope_hit: f32,
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
scope_samples: usize,
scope_bucket_len: usize,
}
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
const CEILING_HIT_GR_DB: f32 = 0.1;
impl Default for Codename206 {
fn default() -> Self {
Self {
params: Arc::new(Codename206Params::default()),
sample_rate: 48_000.0,
crossover: Crossover::new(),
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
limiter: Limiter::new(),
meters: Arc::new(Meters::default()),
meter_decay_weight: 1.0,
scope_in: [0.0; 4],
scope_out: [0.0; 4],
scope_gr: [0.0; 4],
scope_hit: 0.0,
scope_samples: 0,
scope_bucket_len: 1,
}
}
}
impl Codename206 {
fn lookahead_samples(&self) -> usize {
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
}
}
impl Plugin for Codename206 {
const NAME: &'static str = "206 prototype";
const VENDOR: &'static str = "Novoyuuparosk";
const URL: &'static str = env!("CARGO_PKG_HOMEPAGE");
const EMAIL: &'static str = "mikkeli@novoyuuparosk.org";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::None;
const MIDI_OUTPUT: MidiConfig = MidiConfig::None;
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
editor::create(self.params.clone(), self.meters.clone())
}
fn initialize(
&mut self,
audio_io_layout: &AudioIOLayout,
buffer_config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
self.sample_rate = buffer_config.sample_rate;
let channels = audio_io_layout
.main_output_channels
.map(NonZeroU32::get)
.unwrap_or(2) as usize;
// Per-block decay so the meters fall ~12 dB over METER_DECAY_MS of silence.
self.meter_decay_weight =
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
// Plot bucket length: emit a scope bucket every ~1/BUCKET_HZ seconds.
self.scope_bucket_len =
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
for comp in &mut self.comps {
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
}
self.crossover.prepare(channels);
self.crossover.update(
self.sample_rate,
self.params.crossover_low_hz.value(),
self.params.crossover_high_hz.value(),
);
self.limiter.prepare(self.sample_rate, channels);
// Three series stages each with a fixed look-ahead delay: the bands, the 'All' channel,
// and the output limiter. Reported once as a constant; see the compressor look-ahead note.
let total_latency =
self.comps[LOW].latency() + self.comps[ALL].latency() + self.limiter.latency();
context.set_latency_samples(total_latency);
true
}
fn reset(&mut self) {
self.crossover.reset();
for comp in &mut self.comps {
comp.reset();
}
self.limiter.reset();
// Transport restart / sample-rate change: drop stale meter values to silence and discard
// the in-flight plot bucket.
self.meters.clear();
self.scope_in = [0.0; 4];
self.scope_out = [0.0; 4];
self.scope_gr = [0.0; 4];
self.scope_hit = 0.0;
self.scope_samples = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
_aux: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let lookahead = self.lookahead_samples();
// Crossover coefficients track the frequency params (recomputed per block — cheap).
self.crossover.update(
self.sample_rate,
self.params.crossover_low_hz.value(),
self.params.crossover_high_hz.value(),
);
// Block-rate settings for the three bands + the 'All' channel.
let band_params = [&self.params.low, &self.params.mid, &self.params.high];
let mut band_set = [
build_settings(&self.params.low, lookahead, self.sample_rate),
build_settings(&self.params.mid, lookahead, self.sample_rate),
build_settings(&self.params.high, lookahead, self.sample_rate),
];
let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate);
// Output limiter settings (block-rate).
let ceiling = util::db_to_gain(self.params.output_ceiling_db.value());
let limiter_release =
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
// Only do the (cheap) metering work when the editor is actually open.
let metering = self.params.editor_state.is_open();
// The host keeps calling process() with silence while stopped/paused (FL does), so the
// scope is gated on the transport actually playing — otherwise it would scroll silence.
let playing = context.transport().playing;
let num_samples = buffer.samples();
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input level (detector / gain-curve x)
let mut gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32;
let mut in_frame = [0.0f32; 2];
let mut band_in = [[0.0f32; 2]; 3];
let mut band_out = [[0.0f32; 2]; 3];
let mut summed = [0.0f32; 2];
let mut out_frame = [0.0f32; 2];
let mut lim_frame = [0.0f32; 2];
for mut frame in buffer.iter_samples() {
let n = frame.len().min(2);
let r = (n - 1).min(1); // right-channel index (== left when mono)
for ch in 0..n {
in_frame[ch] = *frame.get_mut(ch).unwrap();
}
// Split each channel into low/mid/high.
for ch in 0..n {
let [lo, mid, hi] = self.crossover.split(ch, in_frame[ch]);
band_in[LOW][ch] = lo;
band_in[MID][ch] = mid;
band_in[HIGH][ch] = hi;
}
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
summed[..n].fill(0.0);
for b in 0..3 {
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next());
for ch in 0..n {
band_in[b][ch] *= pre;
}
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();
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
for ch in 0..n {
summed[ch] += band_out[b][ch];
}
if metering {
let in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
let out_l = band_out[b][0].abs();
let out_r = band_out[b][r].abs();
// Wet gain reduction (what the comp computes), independent of the mix.
let g = self.comps[b].gain_reduction_db();
inp[b] = inp[b].max(in_mono);
lvl_l[b] = lvl_l[b].max(out_l);
lvl_r[b] = lvl_r[b].max(out_r);
gr[b] = gr[b].max(g);
if playing {
self.scope_in[b] = self.scope_in[b].max(in_mono);
self.scope_out[b] = self.scope_out[b].max(out_l.max(out_r));
self.scope_gr[b] = self.scope_gr[b].max(g);
}
}
}
// 'All' aggregate channel over the summed bands (driven before its compressor).
let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next());
for ch in 0..n {
summed[ch] *= all_pre;
}
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();
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
// Output brickwall limiter.
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
if metering {
let in_mono = summed[0].abs().max(summed[r].abs());
let out_l = out_frame[0].abs();
let out_r = out_frame[r].abs();
let g = self.comps[ALL].gain_reduction_db();
inp[ALL] = inp[ALL].max(in_mono);
lvl_l[ALL] = lvl_l[ALL].max(out_l);
lvl_r[ALL] = lvl_r[ALL].max(out_r);
gr[ALL] = gr[ALL].max(g);
lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
// Only advance the scope while the transport is playing, so it freezes (rather than
// scrolling silence) when the host is paused/stopped but still calling process().
if playing {
self.scope_in[ALL] = self.scope_in[ALL].max(in_mono);
self.scope_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r));
self.scope_gr[ALL] = self.scope_gr[ALL].max(g);
self.scope_hit = self.scope_hit.max(self.limiter.gain_reduction_db());
// Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ).
self.scope_samples += 1;
if self.scope_samples >= self.scope_bucket_len {
let hit = if self.scope_hit > CEILING_HIT_GR_DB { 1.0 } else { 0.0 };
self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr, hit);
self.scope_in = [0.0; meters::NUM_CHANNELS];
self.scope_out = [0.0; meters::NUM_CHANNELS];
self.scope_gr = [0.0; meters::NUM_CHANNELS];
self.scope_hit = 0.0;
self.scope_samples = 0;
}
}
}
for ch in 0..n {
*frame.get_mut(ch).unwrap() = lim_frame[ch];
}
}
// Publish one decimated value per meter for this block. The decay weight is per-sample,
// so raise it to the block length to keep the fall time constant independent of buffer size
// (we apply it once per block, not once per sample).
if metering {
let w = self.meter_decay_weight.powi(num_samples as i32);
for i in 0..meters::NUM_CHANNELS {
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w);
meters::decay_store(&self.meters.level_r[i], lvl_r[i], w);
meters::decay_store(&self.meters.input_level[i], inp[i], w);
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
}
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
}
ProcessStatus::Normal
}
}
impl ClapPlugin for Codename206 {
const CLAP_ID: &'static str = "com.mikkeli.codename-206";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Multiband compressor/limiter (stage 3: 3-band + 'All' channel)");
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Stereo,
ClapFeature::Mono,
ClapFeature::Compressor,
ClapFeature::Limiter,
];
}
impl Vst3Plugin for Codename206 {
const VST3_CLASS_ID: [u8; 16] = *b"Codename206Maxi!";
const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] =
&[Vst3SubCategory::Fx, Vst3SubCategory::Dynamics];
}
nih_export_clap!(Codename206);
nih_export_vst3!(Codename206);
+173
View File
@@ -0,0 +1,173 @@
//! Lock-free meter state shared from the audio thread to the editor.
//!
//! Two feeds, both written by `process()` (single producer) and read by the editor (single
//! consumer), all wait-free:
//!
//! * **Bar meters** — decayed scalars per channel ([`Meters::level_l`] etc.), one store per block.
//! * **Scrolling plot** — a [`ScopeRing`] of raw buckets clocked at [`BUCKET_HZ`] (independent of
//! the GUI frame rate), so the plot's horizontal resolution isn't capped by the ~60 fps repaint.
use nih_plug::prelude::AtomicF32;
use std::sync::atomic::{AtomicU64, Ordering};
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
pub const NUM_CHANNELS: usize = 4;
/// Rate the audio thread emits plot buckets at (Hz). Sets the plot's max horizontal resolution,
/// decoupled from the editor frame rate. ~5 ms per bucket.
pub const BUCKET_HZ: u32 = 200;
/// Buckets buffered between GUI drains. At [`BUCKET_HZ`] this is ~2.5 s of slack — far more than
/// the frame interval needs; if the GUI ever stalls longer, the oldest buckets are dropped.
const RING_N: usize = 512;
pub struct Meters {
/// Left output level per channel as a **linear** peak. Peak-with-decay.
pub level_l: [AtomicF32; NUM_CHANNELS],
/// Right output level per channel (== left for mono signals).
pub level_r: [AtomicF32; NUM_CHANNELS],
/// Mono **input** level per channel (post pre-gain = what the compressor detects). Drives the
/// gain-curve operating-point fill. Peak-with-decay.
pub input_level: [AtomicF32; NUM_CHANNELS],
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
/// stereo-linked, so the same gain applies to both channels.
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
/// Output limiter gain reduction in **dB (>= 0)** — feeds the ALL channel's ceiling lamp.
pub limiter_gr_db: AtomicF32,
/// Bucket stream feeding the scrolling in/out/GR plot.
pub scope: ScopeRing,
}
impl Default for Meters {
fn default() -> Self {
Self {
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
level_r: std::array::from_fn(|_| AtomicF32::new(0.0)),
input_level: std::array::from_fn(|_| AtomicF32::new(0.0)),
gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
limiter_gr_db: AtomicF32::new(0.0),
scope: ScopeRing::default(),
}
}
}
impl Meters {
/// Zero the bar meters. Called from the plugin's `reset()` (transport restart / sample-rate
/// change) so the bars start from silence. The plot ring is left alone — it's continuous and
/// reflects the new (silent) buckets as they arrive. Real-time safe.
pub fn clear(&self) {
for i in 0..NUM_CHANNELS {
self.level_l[i].store(0.0, Ordering::Relaxed);
self.level_r[i].store(0.0, Ordering::Relaxed);
self.input_level[i].store(0.0, Ordering::Relaxed);
self.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
}
self.limiter_gr_db.store(0.0, Ordering::Relaxed);
}
}
/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
/// from flickering while staying responsive to transients.
pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
let current = meter.load(Ordering::Relaxed);
let next = if block_value > current {
block_value
} else {
current * decay_weight + block_value * (1.0 - decay_weight)
};
meter.store(next, Ordering::Relaxed);
}
/// Lock-free single-producer/single-consumer ring of plot buckets. Each bucket holds a per-channel
/// (input level, output level, gain reduction) triple. The producer (audio thread) appends with
/// [`push`](ScopeRing::push); the consumer (GUI) reads new buckets with [`drain`](ScopeRing::drain),
/// tracking its own cursor. Per-field atomics avoid tearing; the consumer leaves one slot of margin
/// from the slot being written, so it never races the producer. If the consumer falls more than the
/// ring behind, the oldest buckets are silently dropped (a visual gap at worst).
pub struct ScopeRing {
/// `slot * NUM_CHANNELS + ch`, indexed by `bucket_index % RING_N`.
in_lin: Vec<AtomicF32>,
out_lin: Vec<AtomicF32>,
gr_db: Vec<AtomicF32>,
/// Per-bucket (not per-channel) flag: `1.0` if the output limiter hit the ceiling in this
/// bucket, else `0.0`. Indexed by `bucket_index % RING_N`.
hit: Vec<AtomicF32>,
/// Monotonic count of buckets ever written.
write: AtomicU64,
}
impl Default for ScopeRing {
fn default() -> Self {
let make = || (0..RING_N * NUM_CHANNELS).map(|_| AtomicF32::new(0.0)).collect();
Self {
in_lin: make(),
out_lin: make(),
gr_db: make(),
hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(),
write: AtomicU64::new(0),
}
}
}
impl ScopeRing {
/// Producer (audio thread): append one bucket of per-channel (in_lin, out_lin, gr_db).
pub fn push(
&self,
in_lin: &[f32; NUM_CHANNELS],
out_lin: &[f32; NUM_CHANNELS],
gr_db: &[f32; NUM_CHANNELS],
hit: f32,
) {
let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write`
let slot = w as usize % RING_N;
let base = slot * NUM_CHANNELS;
for ch in 0..NUM_CHANNELS {
self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed);
self.out_lin[base + ch].store(out_lin[ch], Ordering::Relaxed);
self.gr_db[base + ch].store(gr_db[ch], Ordering::Relaxed);
}
self.hit[slot].store(hit, Ordering::Relaxed);
// Publish the bucket: the Release pairs with the consumer's Acquire so the stores above are
// visible before the new count.
self.write.store(w + 1, Ordering::Release);
}
/// Consumer (GUI): call `on_bucket` for each bucket in `*cursor..write`, advancing `cursor`.
/// Skips ahead (dropping oldest) if the consumer fell more than the ring behind.
pub fn drain(
&self,
cursor: &mut u64,
mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], f32),
) {
let w = self.write.load(Ordering::Acquire);
if *cursor > w {
*cursor = w; // counter went backwards (shouldn't happen) — resync
}
// Stay one slot clear of the slot currently being written.
let oldest = w.saturating_sub((RING_N - 1) as u64);
if *cursor < oldest {
*cursor = oldest;
}
let mut in_buf = [0.0f32; NUM_CHANNELS];
let mut out_buf = [0.0f32; NUM_CHANNELS];
let mut gr_buf = [0.0f32; NUM_CHANNELS];
while *cursor < w {
let slot = *cursor as usize % RING_N;
let base = slot * NUM_CHANNELS;
for ch in 0..NUM_CHANNELS {
in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed);
out_buf[ch] = self.out_lin[base + ch].load(Ordering::Relaxed);
gr_buf[ch] = self.gr_db[base + ch].load(Ordering::Relaxed);
}
let hit = self.hit[slot].load(Ordering::Relaxed);
on_bucket(&in_buf, &out_buf, &gr_buf, hit);
*cursor += 1;
}
}
/// Current write high-water mark (for a fresh consumer to start from "now").
pub fn write_index(&self) -> u64 {
self.write.load(Ordering::Acquire)
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Plugin parameters and their layout.
//!
//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All'
//! aggregate channel). `build_settings` translates a channel's params into the per-block
//! [`CompressorSettings`] the DSP consumes.
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
use crate::dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
/// Level-detection mode for a compressor's detector.
#[derive(Enum, PartialEq, Clone, Copy)]
pub enum DetectionMode {
#[id = "peak"]
#[name = "Peak"]
Peak,
#[id = "rms"]
#[name = "RMS"]
Rms,
}
#[derive(Params)]
pub struct Codename206Params {
#[persist = "editor-state"]
pub editor_state: Arc<EguiState>,
/// Low/Mid crossover frequency.
#[id = "xover_lo"]
pub crossover_low_hz: FloatParam,
/// Mid/High crossover frequency.
#[id = "xover_hi"]
pub crossover_high_hz: FloatParam,
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
#[id = "lookahead"]
pub look_ahead_ms: FloatParam,
/// Output brickwall ceiling (the limiter never lets output exceed this).
#[id = "ceiling"]
pub output_ceiling_db: FloatParam,
/// Output limiter release time.
#[id = "lim_rel"]
pub limiter_release_ms: FloatParam,
#[nested(id_prefix = "low", group = "Low")]
pub low: CompressorParams,
#[nested(id_prefix = "mid", group = "Mid")]
pub mid: CompressorParams,
#[nested(id_prefix = "high", group = "High")]
pub high: CompressorParams,
#[nested(id_prefix = "all", group = "All")]
pub all: CompressorParams,
}
#[derive(Params)]
pub struct CompressorParams {
/// Drive into the compressor: scales the signal **before** detection, so it both pushes the
/// channel further into compression and feeds the downstream sum/limiter harder. Combined with
/// makeup (post-comp), this gives full per-channel input/output gain-staging.
#[id = "pregain"]
pub pre_gain_db: FloatParam,
#[id = "detect"]
pub detection: EnumParam<DetectionMode>,
#[id = "thresh"]
pub threshold_db: FloatParam,
#[id = "ratio"]
pub ratio: FloatParam,
#[id = "knee"]
pub knee_db: FloatParam,
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
#[id = "lowslope"]
pub low_slope: FloatParam,
/// Low shaper curvature (1..1): bipolar mid-bulge, 0 = straight. +bulges up (boost quiet
/// middle), bulges down (suppress). Endpoints (silence + knee) stay fixed.
#[id = "lowcurve"]
pub low_curve: FloatParam,
#[id = "attack"]
pub attack_ms: FloatParam,
#[id = "release"]
pub release_ms: FloatParam,
#[id = "makeup"]
pub makeup_db: FloatParam,
/// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass).
#[id = "mix"]
pub mix: FloatParam,
}
impl Default for Codename206Params {
fn default() -> Self {
Self {
editor_state: EguiState::from_size(760, 520),
crossover_low_hz: FloatParam::new(
"Crossover Lo/Mid",
200.0,
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
)
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
crossover_high_hz: FloatParam::new(
"Crossover Mid/Hi",
2_500.0,
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
)
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
look_ahead_ms: FloatParam::new(
"Look-ahead",
2.0,
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
)
.with_unit(" ms")
.with_value_to_string(formatters::v2s_f32_rounded(2)),
output_ceiling_db: FloatParam::new(
"Ceiling",
0.0,
FloatRange::Linear { min: -24.0, max: 0.0 },
)
.with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
limiter_release_ms: FloatParam::new(
"Limiter Release",
100.0,
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
)
.with_unit(" ms")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
low: CompressorParams::default(),
mid: CompressorParams::default(),
high: CompressorParams::default(),
all: CompressorParams::default(),
}
}
}
impl Default for CompressorParams {
fn default() -> Self {
Self {
pre_gain_db: FloatParam::new(
"Pre-gain",
0.0,
FloatRange::Linear { min: -24.0, max: 36.0 },
)
.with_smoother(SmoothingStyle::Linear(20.0))
.with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
detection: EnumParam::new("Detection", DetectionMode::Peak),
threshold_db: FloatParam::new(
"Threshold",
-18.0,
FloatRange::Linear { min: -60.0, max: 0.0 },
)
.with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
ratio: FloatParam::new(
"Ratio",
2.0,
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
)
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
.with_string_to_value(Arc::new(|s| {
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
})),
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 30.0 })
.with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
low_slope: FloatParam::new(
"Low Slope",
1.0,
FloatRange::Skewed { min: 0.5, max: 3.0, factor: FloatRange::skew_factor(-1.0) },
)
.with_smoother(SmoothingStyle::Linear(20.0))
.with_value_to_string(formatters::v2s_f32_rounded(2)),
low_curve: FloatParam::new("Low Curve", 0.0, FloatRange::Linear { min: -1.0, max: 1.0 })
.with_smoother(SmoothingStyle::Linear(20.0))
.with_value_to_string(formatters::v2s_f32_percentage(0))
.with_string_to_value(formatters::s2v_f32_percentage()),
attack_ms: FloatParam::new(
"Attack",
10.0,
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
)
.with_unit(" ms")
.with_value_to_string(formatters::v2s_f32_rounded(2)),
release_ms: FloatParam::new(
"Release",
100.0,
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
)
.with_unit(" ms")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -24.0, max: 24.0 })
.with_smoother(SmoothingStyle::Linear(20.0))
.with_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)),
mix: FloatParam::new("Mix", 1.0, FloatRange::Linear { min: 0.0, max: 1.0 })
.with_smoother(SmoothingStyle::Linear(20.0))
.with_value_to_string(formatters::v2s_f32_percentage(0))
.with_string_to_value(formatters::s2v_f32_percentage()),
}
}
}
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
pub fn build_settings(
p: &CompressorParams,
lookahead: usize,
sample_rate: f32,
) -> CompressorSettings {
CompressorSettings {
threshold_db: p.threshold_db.value(),
ratio: p.ratio.value(),
knee_db: p.knee_db.value(),
low_slope: p.low_slope.value(),
low_curve: p.low_curve.value(),
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
makeup_db: 0.0,
lookahead_samples: lookahead,
use_rms: p.detection.value() == DetectionMode::Rms,
mix: p.mix.value(),
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "xtask"
version = "0.1.0"
edition = "2021"
[dependencies]
nih_plug_xtask = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "f36931f7af4646065488a9845d8f8c2f95252c23" }
+3
View File
@@ -0,0 +1,3 @@
fn main() -> nih_plug_xtask::Result<()> {
nih_plug_xtask::main()
}