Commit Graph

43 Commits

Author SHA1 Message Date
Mikkeli Matlock 570dd65fa8 fix: give each band its own RMS window instead of a global 5 ms
5 ms was too short to be an RMS average at all. Squaring the detector signal
puts a component at twice the fundamental, and a one-pole that does not span a
cycle passes it straight through, so the detector ripples and the gain
intermodulates with the bass. Measured peak-to-peak detector ripple at 5 ms:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:19:04 +09:00
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
Mikkeli Matlock c398b86d2b initial commit 2026-06-14 20:31:27 +09:00