Repository navigation
[Bug]: The pitch worklet runs twelve idle chains at zero transpose, heard as pops on laptops (fix on a branch) #576
Description
Activity
Likely the same cause as #575, which reports crackle at 96 kHz and silence at 192 kHz on Windows. The idle chains' cost scales with the output sample rate: measured 13% of real time at 44.1 kHz, 53% at 96 kHz, and 202% at 192 kHz on
main, against under 2% with the fix at every rate.Thanks for this. The diagnosis was right, and I pulled your branch as written. The lane count, the arriving and leaving pair, the deferred wiring when the worklet resolves, and the reorder inside
swap()are all yours, and so are the twelve routing checks. You are a co-author on the commit. It is #577.I reproduced your numbers independently before touching anything. Same channel counts,
[1,1,1,1,1,1,2,1,1,1,1,1,1], and the same shape on the cost curve. One thing I found while checking your work that is worth telling you: at zero transpose the old graph was not only wasting cycles, it was changing the signal. A 440 Hz tone through the real processor came out differing from the input by up to 0.5 full scale. With your fix it is sample for sample identical. That was not in either report.I made one change inside your commit.
FakeNode.disconnectin the routing test ignored its arguments and cleared every connection on the node, so your twelve new checks would have passed a selective disconnect and a blanket one alike. It now matches the real overloads and throws on a connection that is not there. Your checks are unchanged, they can just fail now.The second commit
There is one on the branch you have not seen. Your work made an older problem visible underneath it.
The
AudioContextruns at whatever rate the output device is set to, and every stem the pipeline produces is 44.1 kHz, fixed at-ar 44100inapp/pipeline/runner.py. So a 192 kHz device upsamples on decode and then does four times the DSP on interpolated samples carrying nothing the originals did not. With your fix in, an idle graph is cheap at any rate, but one active pitch chain still costs 2.1% of real time at 44.1 kHz and 28.2% at 192. Decoded buffers scale the same way, 606 MB against 2.6 GB for a five minute six stem track.That is #578. The fix reads the device rate from a throwaway context and, only above 48 kHz, builds the real one with
{ sampleRate: 48000 }. A cap rather than a pin, so 44.1 and 48 are left exactly as they are.If you have time
git fetch origin fix/idle-pitch-chains git checkout fix/idle-pitch-chainsTwo things I would value your eyes on.
Your T490 is almost certainly at 44.1 or 48 kHz, so the cap should be a complete no-op there. If playback changes at all on that machine, the cap is firing when it should not, and I want to know.
The other is the handover you built. Stepping lanes through keys during playback, and a lane leaving a bus mid-note. The chain tail now drains through a path that never actually ran before your fix, since every chain used to be permanently fed.
No rush, and thank you for doing the hard part. If you would rather send future work as a pull request than as a branch in an issue, tell me and I will sort the permissions out.
This is on
mainnow, in #577. Your branch went in essentially as you wrote it:
the per-bus lane count, the arriving and leaving pair, the deferred wiring when
the worklet resolves, and the reorder insideswap(). So are all twelve routing
checks. You are a co-author on the commit.I changed one thing inside it.
FakeNode.disconnectin the routing test ignored
its arguments and cleared every connection on the node, so your new checks would
have passed a selective disconnect and a blanket one alike. It now matches the
real overloads and throws on a connection that is not there. Your checks are
untouched, they can just fail now.Two things came out of checking your work that are worth telling you.
The first is that this was not only wasting cycles. Rendering a 440 Hz tone at
zero transpose through the real processor, the output differed from the input by
up to 0.5 full scale with every bus wired. It is sample for sample identical
now. Neither report mentioned it, and I would not have gone looking.The second is that your ladder pointed at something underneath. The cost climbing
with the output rate is not only the idle chains: the AudioContext runs at
whatever rate the output device is set to, and every stem the pipeline produces
is 44.1 kHz. So a 192 kHz device was doing four times the DSP on interpolated
samples and holding 2.6 GB of decoded buffers for a five minute track instead of
606 MB. That is #578, and the second commit in #577 caps the graph at 48 kHz.
Your fix is what made it visible, since with twelve chains burning the budget
there was no way to see the smaller thing behind them.@wastedbits confirmed your commit on a real 24-bit/192 kHz Windows machine, and I
reproduced #575 here by forcing the context rate: silent onmain, clean on the
branch. Worth knowing that your fix alone is what makes 192 kHz playable. The
rate cap is an optimisation on top, not the thing that stops the silence.It is on
mainrather than in a release, so there is nothing to download yet. It
will be in the next one.On pull requests: they were restricted to collaborators, which is why you had to
post a branch in an issue. If you want to send work directly next time, say so
and I will sort the permissions out. Thanks for this one. The diagnosis was the
hard part and you had it before you wrote a line of the fix.- added a commit that references this issue
on Sep 7, 2026
Before reporting
What happened?
On a laptop client, both Web Audio engines pop and crackle continuously and the fan runs for the whole track, even with nothing transposed. The legacy per-stem
<audio>path is clean. I expected the default engine to play a 7-stem track without audible glitches.I tracked it down, and I have a fix on a branch. This repo currently limits pull requests to collaborators, so I cannot open one: https://github.com/pywkt/stemdeck/tree/fix/idle-pitch-chains (single commit,
32bd0ed, offmainat37a2c5f). Happy to open it as a PR if you allow it, or please pull it directly.Cause. Both engines wire all thirteen semitone buses into the SoundTouch worklet at construction:
The processor decides whether a semitone is in use by whether its input has channels, and takes its bypass path only when none do:
The comment there says an unconnected input arrives as an empty array, and
tests/js/pitch-shift.test.mjshands unlisted inputs[]by construction. Chrome does not do that for a bus that is wired but has nothing playing into it: it hands the processor one channel of silence. So all twelve pitch inputs read as live, twelvePitchChains are built on the first render quantum, and each runs WSOLA, the anti-alias filter and the resampler on silence for the whole track. The bypass never engages, and a chain is only dropped after 256 blocks of an empty input, which never arrives. A side effect: the playhead runs ahead of the sound by the worklet's priming latency, because the engines assume none when no lane is transposed.Measured in headless Chromium 151, offline render of 60 s, seven stems through the real
soundtouch-processor.js, no transpose, tempo 1:main)Channel counts the processor is handed on
main, with audio on bus 6 only:[1,1,1,1,1,1,2,1,1,1,1,1,1].Fix. Wire a pitch bus into the worklet only while a lane is routed to it (wired before the lane connects, unwired once the last lane leaves). The unpitched bus stays wired for the click. This is the handover the processor's own design describes; in Chrome it never happened because every chain was already warm. The routing test gains 12 checks that walk the graph for which inputs are wired as lanes arrive, share a key and leave.
Tested: all
tests/jssuites and the full Playwright suite (127) pass locally. Loading the real engine modules in headless Chromium withAudioNode.prototype.connectwrapped:mainwires all 13 inputs from load; the branch wires[0]at load,[0, 2]after a +2 lane,[-5, 0]after moving it to -5,[0]when it returns, identically on both engines. On the laptop below, playback is clean afterwards and stepping individual lanes through keys during playback hands over without a gap or click.Steps to reproduce
uvicorn, HTTPS so transpose is available) and open it in Chromium on a laptop on the same network.<audio>path (localStorage.setItem("stemdeck.audioEngine", "0")and reload): clean.AudioNode.prototype.connectbefore the studio loads and log connections into thesoundtouch-processornode. All 13 inputs are wired at load. Or render offline through the processor with all 13 inputs wired vs one, and compare render time.Operating system
Linux
StemDeck version
v0.17.0 (37a2c5f)
How did you install it?
From source
Logs / screenshots
Client: ThinkPad T490, Intel i7-8565U, Arch Linux, Chromium, Plasma 6.
Server: Ubuntu 22.04, RTX 3060,
uv run uvicorn app.main:appover HTTPS with a self-signed certificate. Separation queue idle during every test.Fix branch: https://github.com/pywkt/stemdeck/tree/fix/idle-pitch-chains
Commit: pywkt@32bd0ed