Skip to content

[Bug]: The pitch worklet runs twelve idle chains at zero transpose, heard as pops on laptops (fix on a branch) #576

Description

@pywkt

Before reporting

  • I'm on the latest release and the issue still happens.
  • I searched existing issues and didn't find a duplicate.

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, off main at 37a2c5f). 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:

for (let k = 0; k < INPUT_COUNT; k++) buses[k].connect(stNode, 0, k);

The processor decides whether a semitone is in use by whether its input has channels, and takes its bypass path only when none do:

const connected = !!(inputs[k] && inputs[k].length);

The comment there says an unconnected input arrives as an empty array, and tests/js/pitch-shift.test.mjs hands 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, twelve PitchChains 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:

Graph Share of real time
No worklet in the graph 0.5%
Only the unpitched bus wired 0.6%
All 13 buses wired (current main) 13.0%

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/js suites and the full Playwright suite (127) pass locally. Loading the real engine modules in headless Chromium with AudioNode.prototype.connect wrapped: main wires 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

  1. Run StemDeck 0.17.0 from source on one machine (uvicorn, HTTPS so transpose is available) and open it in Chromium on a laptop on the same network.
  2. Import any track with all six stems and open it in the studio. Leave transpose at 0.
  3. Press play on the default (chunked) engine, or on the full-decode engine.
  4. Continuous pops and crackle; fan spins up. Switch to the legacy <audio> path (localStorage.setItem("stemdeck.audioEngine", "0") and reload): clean.
  5. To see the cause without a laptop: in any Chrome, wrap AudioNode.prototype.connect before the studio loads and log connections into the soundtouch-processor node. 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:app over 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

Activity

  1. added
    bugSomething isn't working
    on Sep 5, 2026
  2. pywkt commented on Sep 5, 2026

    @pywkt
    ContributorAuthor

    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.

  3. self-assigned this
    on Sep 6, 2026
  4. thcp commented on Sep 6, 2026

    @thcp
    Collaborator

    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.disconnect in 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 AudioContext runs at whatever rate the output device is set to, and every stem the pipeline produces is 44.1 kHz, fixed at -ar 44100 in app/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-chains
    

    Two 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.

  5. thcp commented on Sep 6, 2026

    @thcp
    Collaborator

    This is on main now, 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 inside swap(). So are all twelve routing
    checks. You are a co-author on the commit.

    I changed one thing inside it. FakeNode.disconnect in 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 on main, 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 main rather 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.

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions