33 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
Mikkeli Matlock c398b86d2b initial commit 2026-06-14 20:31:27 +09:00