Skip to content

feat: Gate worklet output at startup to prevent underrun clicks - #87

Open
bhj wants to merge 1 commit into
cutterbl:masterfrom
bhj:feat/startup-output-gate
Open

feat: Gate worklet output at startup to prevent underrun clicks#87
bhj wants to merge 1 commit into
cutterbl:masterfrom
bhj:feat/startup-output-gate

Conversation

@bhj

@bhj bhj commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Hey again :)

This came up while trying to switch my bespoke phase vocoder implementation over to v2.1.1. Can't say I noticed the transients myself, but it's plausible. It does add ~3ms of latency which seemed like a reasonable tradeoff for correctness.

Model used: Anthropic Fable 5

A bursty stretch stage refills the output buffer one hop at a time while the render thread drains one block per quantum, so the buffer's trough rides at exactly one render block. Wherever the stage's production schedule comes up a few frames short, the block is zero-filled — an audible click of up to full signal amplitude (measured: 1-8 sample dropouts).

SoundTouchProcessorBase now supports holding extraction for startupHoldBlocks render blocks after the output buffer first fills a block. Held blocks are emitted before any real output, so the added silence is inaudible, and the unconsumed frames become a permanent cushion under the trough. The phase vocoder worklet holds one block: a 128-frame cushion (16x the worst measured shortfall) for one render block (~2.9 ms) of added latency. Warmup blocks no longer count as underruns, so the metric now reports only real faults.

Measured A/B (real pipeline, gate vs none) across the full fftSize/overlapFactor grid at 44.1/48/96 kHz: group delay shifts by exactly 128 frames with output otherwise bit-identical, and hop-aligned pitch ratios drop from 9-21 underruns per 20 s to zero at every functional configuration. Arbitrary (unaligned) ratios are a sustained stretch/transposer rate mismatch that outruns any fixed cushion — the gate only delays onset there; matching the transposer to the stage's realized tempo is the actual fix for that case. Configs with hop <= 128 starve with or without the gate because PhaseVocoder.process() handles at most one hop per call (unlike Stretch, which loops until input is exhausted) — a pre-existing defect to fix separately. The default of 0 leaves the other worklets' timing unchanged.

Highlighting the two other issues the model called out:

Arbitrary (unaligned) ratios are a sustained stretch/transposer rate mismatch that outruns any fixed cushion — the gate only delays onset there; matching the transposer to the stage's realized tempo is the actual fix for that case

Already a PR for this with #85

Configs with hop <= 128 starve with or without the gate because PhaseVocoder.process() handles at most one hop per call (unlike Stretch, which loops until input is exhausted) — a pre-existing defect to fix separately.

I'll look at creating a separate PR for this - not sure how invasive it may end up being.

A bursty stretch stage refills the output buffer one hop at a time
while the render thread drains one block per quantum, so the buffer's
trough rides at exactly one render block. Wherever the stage's
production schedule comes up a few frames short, the block is
zero-filled — an audible click of up to full signal amplitude
(measured: 1-8 sample dropouts).

SoundTouchProcessorBase now supports holding extraction for
startupHoldBlocks render blocks after the output buffer first fills a
block. Held blocks are emitted before any real output, so the added
silence is inaudible, and the unconsumed frames become a permanent
cushion under the trough. The phase vocoder worklet holds one block: a
128-frame cushion (16x the worst measured shortfall) for one render
block (~2.9 ms) of added latency. Warmup blocks no longer count as
underruns, so the metric now reports only real faults.

Measured A/B (real pipeline, gate vs none) across the full
fftSize/overlapFactor grid at 44.1/48/96 kHz: group delay shifts by
exactly 128 frames with output otherwise bit-identical, and
hop-aligned pitch ratios drop from 9-21 underruns per 20 s to zero at
every functional configuration. Arbitrary (unaligned) ratios are a
sustained stretch/transposer rate mismatch that outruns any fixed
cushion — the gate only delays onset there; matching the transposer to
the stage's realized tempo is the actual fix for that case. Configs
with hop <= 128 starve with or without the gate because
PhaseVocoder.process() handles at most one hop per call (unlike
Stretch, which loops until input is exhausted) — a pre-existing defect
to fix separately. The default of 0 leaves the other worklets' timing
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant