Skip to content

feat: IR envelope shaping — Predelay, Length/Decay, and a drag-on-graph editor - #113

Open
Shayshez wants to merge 9 commits into
tone-3000:mainfrom
Shayshez:feature/ir-envelope-shaping
Open

Shayshez wants to merge 9 commits into
tone-3000:mainfrom
Shayshez:feature/ir-envelope-shaping

Conversation

@Shayshez

@Shayshez Shayshez commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds shaping controls for IR blocks: a Predelay knob, a 2-segment Attack/Decay
envelope over the IR's detected content length (Space Designer-style), and a
fully interactive graph to shape it by ear, backed by precision-entry chips
for landing exact values.

Screenshot 2026-09-10 at 12 55 03
  • Predelay: up to 1s of silence before the wet signal enters the IR's
    convolver (real-time DSP stage, own knob in the Input rail).
  • Content length + envelope: native detection of an IR's real extent
    (RMS-threshold based, independent of the file's raw duration), plus a
    2-segment Attack/Decay envelope over it - Init Level ramps up to unity at a
    peak position (Attack Length, a fraction of the trimmed total), then Decay
    continues from that peak to the trimmed end (Decay Level). Both segments
    have their own Curve shape parameter.
  • Real waveform visualization: per-column min/max peaks rendered from
    the IR's actual audio, auto-fit to the detected content length rather
    than the file's raw duration - a 20ms cab capture with a long silent tail
    displays at a legible scale instead of being squeezed into a few pixels.
    The envelope graph and its interactive points/curves render directly on
    top of this.
  • Drag-on-graph editor: three draggable points (Init/Peak/End) plus
    draggable curve segments directly on the waveform, mirroring the EQ
    editor's own dot-dragging conventions (hover rings, Shift-drag fine mode,
    Alt-click/double-click reset, a floating value readout during drag). An
    under-curve fill, a faint background time grid, and a left-edge predelay
    indicator round out the visualization - all within the same fixed-window
    coordinate system the original Length knob used.
  • Precision chips: Init/A Len/A Crv/D Len/D Lvl/D Crv as click-to-type
    chips (shares its pattern with the EQ's own Freq/Gain/Q chips, extracted
    into a common EditableChip).

A DSP tuning note worth flagging in review

The Curve parameter's normalized-to-exponent mapping got two passes after
by-ear testing surfaced a real oversensitivity: a drag of about 1/5 of the
graph's height was enough to push the effective exponent to a point where
~91% of a segment's entire dB change lands in its last 30% - audibly an
abrupt "choke" nothing on the graph visually suggested. Fixed by (1) cubing
the normalized input before exponentiating, which roughly doubles the drag
distance needed to reach a given curve near center while leaving both
extremes unchanged, and (2) lowering the ceiling itself (kCurveMax
6.0 -> 4.0), since real reverb/room IRs already carry their own natural
decay and the envelope's dB drop adds to that rather than replacing it -
even the old ceiling's intentional full-throw extreme read as a hard gate
once stacked on real material. Both changes are native/TS pairs kept in
sync by hand (ProcessorModelLoader.cpp / decayEnvelope.ts), and existing
curve-shape tests are unaffected since they compare against the (unchanged)
extremes.

Test plan

  • npm run lint / npm run build (UI) - clean
  • ./script/test-dsp.sh - 150/150 tests pass, including new
    IrContentLengthTest and IrDecayTest coverage (truncation, dry-path
    isolation, tail attenuation, curve shaping, state-restore round trip)
  • Full plugin build (Standalone/AU/VST3/AAX/CLAP/LV2) - clean
  • Demo video: https://youtu.be/fIMWWxBkLzY

🤖 Generated with Claude Code

Shayshez and others added 9 commits September 10, 2026 11:07
Delays the wet signal before it reaches the convolver, so an IR's tail
starts later without touching the loaded impulse response itself
(padding the IR would force a full convolver rebuild with its silent
install fade on every live change, and inflate the uniform engine's
kernel-length-bound CPU cost and its short/long classification).

Always runs at kChainBaseSampleRate, inside the block's base-rate
island, right before Convolution::process() - the same rate
convolution itself always runs at regardless of oversampling. A
preallocated ring buffer (sized once for the 1s max in prepare(), zero
allocation after) is read with linear interpolation against a smoothed
delay-in-samples target, so live changes glide instead of stepping.
The live-change ramp (10ms) was tuned by ear.

Range 0-1000ms, wired through setBlockParam (normalized 0..1, same
convention as inputGain/outputGain/mix) and persisted the same way.
Predelay pushes an IR's audible tail out in time, so the reported host
tail length now accounts for it too (refreshIrTailLength).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHdgnDsQcrquik8wG7d6d
DoesNotAffectDryPath: at mix=0, comparing two full processor runs
against each other (predelay off vs. max) isolates predelay's own
contribution from the rest of the signal path - a direct comparison
against the raw synthetic input is invalid, since the global input
gate (applied before the chain) isn't a pure passthrough for noise.
Max diff is floating-point rounding noise (~-101dB), not a leak.

DelaysOnlyTheWetPath: at mix=1, confirms the wet onset shifts by
exactly the requested predelay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHdgnDsQcrquik8wG7d6d
New "Pre" knob (0-1000ms) between Mix and the output rail, IR blocks
only - same KnobControl pattern, drag/fine/double-click/Alt-reset
behavior, and optimistic-state sync as In/Mix/Out. predelayMsScale
formats the live readout as "340 ms" during drag, matching the
existing gainDbScale-style scale convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoHdgnDsQcrquik8wG7d6d
Adds a 2-segment Attack/Decay envelope over an IR's detected content
length (Space Designer-style), plus the content-length detection/trim
it builds on:

- Content length: RMS-threshold based detection of an IR's real extent
  (irContentLengthMs), independent of the file's raw duration.
- Envelope: Init Level (level at sample 0) ramps via the Attack segment
  up to unity/0dB at a peak position (Attack Length, a fraction of the
  total trimmed length), then the Decay segment continues from that
  peak to the trimmed end (Decay Level). Both segments have their own
  Curve shape parameter. All six values travel together through
  setBlockIrDecay (not the per-parameter setBlockParam path) since the
  rebuild happens off-thread and a drag on one value can't be allowed
  to clobber another's in-flight value.
- Rebuilds the convolver via prepareIrShapeRebuild on a background
  thread, same pool as model loading.

Covered by ir_content_length_tests.cpp (detection sanity against real
IR assets) and ir_decay_tests.cpp (truncation, dry-path isolation,
tail attenuation, curve shaping, and state-restore round trip).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
Front-end for the DSP envelope feature: a static waveform display for
the block card, then a fully interactive drag-on-graph editor on top
of it.

- decayEnvelope.ts / WaveformDisplay.tsx: client-side mirror of the
  native envelope math for drawing the curve, plus the min/max
  waveform polygon and cut-line overlay. WaveformDisplay itself stays
  display-only (used for the small info-panel thumbnail).
- useIrWaveform.ts: fetches per-block waveform peaks, refetching on
  both activeModelId and the modelLoading true->false edge (the
  latter closes a real race - see the hook's own comment).
- IrEnvelopeGraph.tsx: the interactive editor replacing the waveform
  in the block's main view. Three draggable points (Init/Peak/End)
  plus draggable curve segments, each with hit-testing mirroring
  BlockEqView's own dot pattern (oversized invisible hit target under
  a small visible dot/line), hover rings, a floating value readout
  during drag, Shift-drag fine mode, and Alt-click/double-click reset.
  Includes an under-curve fill (area between the curve and the
  waveform below it), a faint background time grid, and a left-edge
  predelay indicator - all sharing the same fixed-window coordinate
  system the old standalone Length knob used, so dragging never
  rescales the backdrop.
- EditableChip.tsx: extracted out of BlockEqView.tsx (zero behavior
  change there) so the envelope row's six precision-entry chips
  (Init/A Len/A Crv/D Len/D Lvl/D Crv) share the same click-to-type
  pattern as the EQ's Freq/Gain/Q chips - graph for by-ear shaping,
  chips for landing an exact value.
- chain.ts / knobScale.ts / useChainActions.ts / useChainState.ts /
  Plugin.tsx: BlockParams fields, display scales, and the
  setBlockIrDecay action wiring the graph/chips commit through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
…sitivity

A drag of about 1/5 of the graph's height (curveNormalized moving from
0.5 to ~0.7) was already enough to push the exponent to k~2, at which
point ~91% of the segment's entire dB change lands in its last 30% -
close to a step function, and audibly an abrupt "choke" nothing on the
coarsely-sampled graph visually suggested. Two compounding causes: the
straight kCurveMax^(2*(c-0.5)) mapping has a steep slope right at
center (dk/dc = 2*ln(kCurveMax) =~ 3.6), and fraction^k is applied
against dB's own huge dynamic range, so even a modest k deviation from
1 concentrates almost the whole transition into a sliver of the
segment.

Fix: cube (curveNormalized - 0.5) before exponentiating, in both
ProcessorModelLoader.cpp's shapeCurveNormalized and
decayEnvelope.ts's own copy (kept in sync by hand, as before). This
spreads sensitivity unevenly across the knob's travel - landing a
given k near center now takes roughly double the drag distance it
used to - while leaving both extremes (curveNormalized=0 and =1)
mapped to exactly the same k as before, so the existing curve-shape
tests (comparing Curve=0 against Curve=1) are unaffected. kCurveMax
itself stays at 6.0: lowering it instead would have permanently capped
how extreme the curve can go at full knob throw, not just tamed the
center.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
…r top

- Curve segments (attack/decay hit-paths) had Alt-click reset wired up
  but no onDoubleClick, unlike the three points (Init/Peak/End), which
  had both. Added, mirroring the points exactly - same resetTarget
  call either way.
- The floating drag readout always offset above its anchor
  (translateY -140%). Fine everywhere except near the top of the graph
  (dragging Init toward unity, or Peak, which always sits there) where
  that pushed the chip above the card's own overflow:hidden boundary.
  Flips to offset below instead when the anchor is in the top 20% of
  the graph - standard tooltip edge-avoidance, no change to the SVG/
  viewBox geometry needed since this is a plain HTML overlay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
…aterial

The center-sensitivity remap (previous commit) fixed how much drag it
takes to move away from linear, but left the ceiling itself untouched
- at full deliberate knob-throw, kCurveMax=6.0 still meant 99.93% of a
segment's entire dB change landed in its last 30% (end-slope 6x
steeper than linear). By ear, that's a real problem specifically for
real IR material: reverb/room captures already carry their own
natural decay, and the envelope's imposed dB drop *adds* to that (dB
being logarithmic, multiplying amplitudes is adding dB) rather than
replacing it - stacking an already-steep artificial cutoff on top of
whatever the IR's own tail was doing read as a hard gate, not a
usable creative curve, even at an intentional full-throw setting.

kCurveMax=4.0 keeps a real, audibly-different extreme available at
full throw (4x steeper than linear) while landing roughly an order of
magnitude less compressed at the 30%-into-the-segment mark (0.81% vs.
0.07%). This is an independent lever from the center-sensitivity
remap - one controls how much drag reaches a given curve, the other
how extreme the curve can get at all - so it doesn't undo or interact
with that fix, just tames the other end. Existing curve-shape tests
(which compare Curve=0 against Curve=1, not against a specific k)
are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
Value only now ("-6dB", "240ms") instead of "Init -6dB" - less
cluttered, and the shorter text also gives the near-top edge-flip
fix (previous session) more effective margin, without needing any
further positioning-logic changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153YRkpUTsAdrZdgtbbsnLW
@woodybury

Copy link
Copy Markdown
Member

@Shayshez this is pretty wild will def give it a proper look, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants