fix(core): give the chorus and phaser LFOs a phase, and unwire them on dispose - #3183
Conversation
34684f5 to
ee9f522
Compare
a0e3a75 to
ba14a9b
Compare
terencecho
left a comment
There was a problem hiding this comment.
LGTM pending Preflight green. Real behavioral fix, not a refactor — both parts (phase determinism + dispose leak) load-bearing with strong pin tests.
Phase fix at audioFxGraph.ts:98-125 (lfoSource helper). OLD: chorus/phaser LFOs used OscillatorNode, which has no settable initial phase — start() at currentTime always fires from phase 0, so every mid-play rebuild snapped the chorus back to sweep-peak wherever the playhead sat. Also, the phaser's lfo.type was set only via apply() running AFTER start(), so the initial oscillator was silently OscillatorNode's default (sine) regardless of p.type — the base comment acknowledges "the declared default ('Triangular') was silently a sine."
NEW: both LFOs are looping AudioBufferSource — 1-second cycle at sampleRate frames, playbackRate.value = speed (speed reads as Hz), and start(currentTime, offset) where offset = (((elapsed * speed) % 1) + 1) % 1 * (length / sampleRate). Chorus wave hardcoded "sine" at :349; phaser wave computed once at build (String(p.type) === "1" ? "sine" : "triangle") at :395; shapeOf now includes ~${p.type} at :559 so a waveform change forces a rebuild rather than a silent no-op update().
Dispose fix at audioFxGraph.ts:130-138 (retireLfo). OLD: chorus/phaser disposers called lfo.stop() inside a try/catch but omitted lfo from the .disconnect() sweep — oscillator remained wired to depth and via it to dl.delayTime / stage .frequency. Inaudible (shell disconnected upstream) but reachable; editing session accumulated dead LFOs. NEW: retireLfo(src) stops + disconnects; both disposers call it FIRST at :376 and :451. Same intent as wa-19c's __hfDispose worklet-port pattern, different primitive (BufferSource has no port).
Pin tests (all in audioFxGraph.test.ts):
- Phase determinism —
elapsed=3.5, speed=2 → offset 0;elapsed=3.6 → offset 0.2. - Zero-phase-for-render pin.
- Waveform discriminator — samples buffer at 1/8-cycle: sine=√½≈0.707, triangle=0.5 (kills both a "default-to-sine" and "default-to-triangle" regression).
- Rebuild-on-waveform-change —
update()returnsfalsewhenp.typechanges. - Dispose leak —
lfo.disconnected === trueper chorus/phaser (iterated["chorus","phaser"]). - End-to-end:
audioFx.test.ts"hands a rebuilt graph the playhead it happens at"threadselapsedthroughattachElementFxChainon structural rebuild.
OLD-assumption counterfactuals:
- "OscillatorNode
start()from phase 0 is fine" → failselapsed=3.6 → offset≈0.2(OscillatorNode has nostart(when, offset)—startArgs[1]would beundefined). - "Leaving
lfo.typeunset untilapply()runs is fine" → the 1/8-cycle sample would read 0.707 for bothtype:"0"andtype:"1", failing the triangle assertion. - "Shell is disconnected, so leaving
lfooff the disconnect list is fine" → fails the explicitlfo.disconnected === truecheck.
Cross-fix: shapeOf including ~${p.type} for phaser means waveform-change forces rebuild → forces dispose() → exercises retireLfo. Good coupling, tested. elapsed threading is behavior-neutral for non-modulated effects (frame?.elapsed ?? 0 fallback at audioFx.ts:189).
CI: Preflight (lint+format) red. format:check failing — likely on the new buildInjectedArtifact.ts / wavChunks.ts / wavChunks.test.ts files or modified helpers. Needs a bun run format push. Downstream player-perf / preview-regression / regression fail-fast on preflight, so this is the only blocker.
Nits:
ctx.currentTimetypeof-guard at:123is dead code in production (real BaseAudioContext always exposescurrentTime); belt-and-braces for the test path — worth a one-line comment naming the fake-test motivation, or drop it and let fakes setcurrentTime = 0.shapeOf's~${p.type}bypassesnormalizeAudioFxParams— numeric vs stringtypevalues would produce different shape strings though identical graphs; consumers normalize before calling, low risk.
— Review by tai (pr-review)
miga-heygen
left a comment
There was a problem hiding this comment.
Review: fix(core): give the chorus and phaser LFOs a phase, and unwire them on dispose — #3183
Verdict: LGTM
Cousin work to #3175's worklet lifecycle fix — same class of leak (stopped but not disconnected), solved with the same discipline but for oscillator-based LFOs.
LFO phase implementation is correct. lfoSource replaces OscillatorNode (whose phase is always 0 and cannot be set) with a looping AudioBufferSourceNode containing one hand-built waveform cycle. Phase control comes from start(when, offset) where offset is a position in seconds into the 1-second buffer.
Waveform math verified:
- Sine:
Math.sin(2π × phase)— starts at 0, peaks at +1 at 1/4 cycle. MatchesOscillatorNodeconvention. - Triangle:
4 × |((phase + 0.75) % 1) - 0.5| - 1— starts at 0, correct piecewise linear shape.
Offset calculation uses double-modulo (((elapsed * speed) % 1) + 1) % 1 to safely handle negative values. playbackRate directly reads in Hz (1-second buffer at 1x = 1 Hz), so the speed knob needs no mapping.
Dispose is complete. retireLfo does both halves: stop() (try/catch for already-stopped) then disconnect(). Both chorus and phaser dispose handlers call it. No nodes left wired.
Tests are thorough: waveform shape at 1/8 cycle (triangle vs sine discrimination), phase offset at whole and partial cycle counts, LFO unwiring on dispose for both chorus AND phaser, zero-phase for renders, and mid-play rebuild getting the correct playhead.
RIFF chunk refactor is a clean SSOT extraction — riffChunks generator shared between audioFxRender.ts and audioVolumeEnvelope.ts, policy-free (yields chunks, holds no interpretation). Build script consolidation is similarly well-typed.
No issues found.
Review by Miga
🤖 Generated with Claude Code
ba14a9b to
dd9ec94
Compare
ee9f522 to
8e56ee6
Compare
dd9ec94 to
ddca11a
Compare
8e56ee6 to
f5cda77
Compare
ddca11a to
325bf65
Compare
f5cda77 to
7f7d597
Compare
325bf65 to
b2b6d7e
Compare
7f7d597 to
35799ef
Compare
# Conflicts: # packages/studio/src/components/editor/propertyPanelAudioFxGroup.test.tsx # packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx # packages/studio/src/components/editor/propertyPanelFxSection.tsx # skills-manifest.json # skills/hyperframes-audio/SKILL.md # skills/hyperframes-audio/references/attributes.md # skills/hyperframes-audio/scripts/carve.mjs
Fallow audit reportFound 7 findings. Duplication (4)
Health (3)
Generated by fallow. |
lfo.start()with no argument puts an OscillatorNode at phase zero at attach time. Offline that is clip-relative, but preview rebuilds the graph on any shape change — and a seek or scrub does the same. So a chorus attached 3.6 s into a clip started its sweep from the top there: preview disagreed with the render, and with itself across an edit.An OscillatorNode's phase cannot be set, so the modulator is now one cycle of the waveform in a looping
AudioBufferSourceNode, wherestart(when, offset)is a phase control.It got the before/after listen through the real engine path, swapping only the injected runtime: chorus 76.5 dB down, Triangular phaser 63.7, Sinusoidal 88.1, a bare 440 Hz tone 87.6. Worst single sample 0.00027 — inaudible, as expected, since a render builds at position 0.
Also fixes a leak the cleanup list pointed at: both LFOs were stopped but never disconnected, so every rebuild that dropped a modulated effect left a modulator wired to what it drove. And folds the two artifact build scripts, which were 50 lines each differing in five names.
🤖 Generated with Claude Code