Skip to content

feat(player): stream stems on desktop via the 5s-chunk engine (#261) - #264

Merged
thcp merged 3 commits into
mainfrom
feat/desktop-streaming-chunked-engine
Jul 13, 2026
Merged

feat(player): stream stems on desktop via the 5s-chunk engine (#261)#264
thcp merged 3 commits into
mainfrom
feat/desktop-streaming-chunked-engine

Conversation

@thcp

@thcp thcp commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #261.

Problem

Desktop playback decodes every stem in full before it starts (~420 MB, slow preload). The "buffering issue" that blocked streaming in the past was NOT streaming itself - it was the legacy path that plays N HTML <audio> elements through WaveSurfer Multitrack, which underruns on Safari/WKWebView due to the HTTP/1.1 6-connection-per-origin cap (documented in audioEngine.js).

Key insight

Streaming is already solved and shipping on mobile: chunkedAudioEngine.js fetches WAV in 5 s HTTP Range windows and schedules them on AudioBufferSourceNodes with a 12 s lookahead - glitch-free, first audio after ~1 chunk, ~28 MB RAM, no length cap. It just wasn't wired on desktop, and stubbed out loop / VU / visuals. This PR promotes it to the desktop player and fills those three stubs.

Changes

  • chunkedAudioEngine.js: setLoop (scheduler jumps to loop.start when the playhead crosses loop.end; lookahead capped at loop.end); per-stem AnalyserNode (gain -> analyser -> master) + getAnalyser for live VU.
  • player.js: engineMode() -> chunked is the default. stemdeck.audioEngine = "fulldecode" opts into the old full-decode engine, "0" into the legacy <audio> path. RAM cap now only gates full-decode. On the chunked path: lane mini-waves + energy baseline from peaks.json, VU meters from the engine's live analysers (overview waveforms already came from peaks.json).
  • mixer.js: renderRealMiniWaveFromPeaks.

Full-decode stays as an opt-in fallback; nothing is removed.

Verify

  • node --check passes on all JS (CI's gate).
  • Manual (desktop app + web) on a multi-minute 6-stem track:
    • Playback starts within ~1 chunk (Range requests in the network tab), not after a full preload.
    • Loop (the [Feature]: Exact timestamp input for loop start/end (manual seconds/milliseconds entry) #246 timestamp inputs) loops audio between the points.
    • VU meters move per stem; mute/solo/volume reflected.
    • Overview + lane mini-waves + energy baselines render (from peaks.json).
    • Seek + speed (SoundTouch and tape-effect fallback) work; no gaps on seek.
    • A long track that used to hit the RAM cap now streams instead of the choppy <audio> path.
    • localStorage stemdeck.audioEngine = "fulldecode" -> old path still works.

Follow-up (not here)

Compressed-opus streaming (the issue's own suggestion) to cut bytes ~10x for slow LAN.

thcp added 2 commits July 13, 2026 16:52
Desktop previously decoded every stem in full before playback (~420 MB / slow
preload). The Range-based chunked engine (chunkedAudioEngine.js) already streams
glitch-free on mobile: 5s HTTP-Range windows scheduled on AudioBufferSourceNodes,
first audio after ~1 chunk, ~28 MB RAM, no length cap. This promotes it to the
desktop player as the default, closing its three feature stubs so there's no
regression vs the full-decode engine:

- chunkedAudioEngine: implement setLoop (scheduler jumps to loop.start on
  crossing loop.end, and caps lookahead at loop.end); add a per-stem AnalyserNode
  (gain -> analyser -> master) + getAnalyser for live VU.
- player.js: engineMode() selects chunked by default; "fulldecode" and "0"
  (legacy <audio>) remain opt-in via the stemdeck.audioEngine flag. The RAM cap
  now only gates the full-decode engine. On the chunked path, drive lane
  mini-waves + the energy baseline from peaks.json and VU meters from the
  engine's live analysers (overview waveforms already come from peaks.json).
- mixer.js: renderRealMiniWaveFromPeaks (peaks-based lane mini-wave).

The original "buffering issue" was the N-<audio>-element multitrack path
(HTTP/1.1 6-connection-cap underruns on Safari/WKWebView); the chunked engine
avoids it by construction. Full-decode stays as a fallback.
…ks fallback)

Self-review of the chunked-engine promotion found four gaps:

- Loop-start chunk was evicted as playback advanced, so every loop pass paid a
  refetch gap. Pin it against both eviction sites while a loop is active, and
  warm it as the playhead approaches loop.end (deduped by the chunk cache).
- A transient all-stems fetch failure cached an empty chunk forever, leaving
  playback permanently silent past that point. Drop empty results so the
  scheduler retries.
- Loop jumps scheduled with the cold-start 50 ms lead. Cached (sync) starts now
  use 10 ms, making loop wraps near-seamless; the async path keeps 50 ms.
- Legacy jobs without peaks.json lost all waveform visuals on the streaming
  path. The chunked branch now falls back to the full-decode engine in that
  case (honoring the backend's documented "degrades to client-side decode"
  contract), unless the track exceeds the decode RAM cap - then it keeps
  streaming audio with placeholder waveforms.
@thcp

thcp commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review pass for state-of-the-art within scope; hardened four things: (1) the loop-start chunk is now pinned in the chunk cache and pre-warmed as the playhead approaches loop.end, so loop passes replay from cache with no refetch gap; (2) an all-stems fetch failure no longer caches permanent silence - empty chunk results are dropped so the scheduler retries after a transient network blip; (3) cached starts schedule with a 10 ms lead instead of 50 ms, making loop wraps near-seamless (cold/async starts keep 50 ms headroom); (4) legacy jobs without peaks.json fall back to the full-decode engine for visuals (the backend's documented degradation path), unless the track exceeds the decode RAM cap, in which case audio still streams and waveforms stay placeholder.

Known remaining limitation, deliberate for this scope: the loop wrap is rAF-detected (up to ~1 frame overshoot + 10 ms schedule lead), same detection model as the full-decode engine. True sample-accurate loop splicing (pre-scheduling the loop-start chunk to start exactly at loop.end on the audio clock) is a follow-up, as is opus-compressed streaming.

…table fix)

pip-audit newly flags torch 2.6.0 for PYSEC-2026-2286 (torch.load weights_only
deserialization -> arbitrary code execution, HIGH; fixed in 2.10.0). The exploit
requires an attacker-controlled .pth checkpoint. StemDeck never calls torch.load
on untrusted input: demucs loads only its official model weights from the
trusted torch-hub source, and users submit audio, not checkpoints. torch is
pinned <2.7 (torchaudio 2.7+ dropped the writer demucs needs), so 2.10.0 is not
adoptable yet. Documented alongside the existing ignored torch advisories.
@thcp
thcp marked this pull request as ready for review July 13, 2026 17:02
@thcp
thcp merged commit a857f60 into main Jul 13, 2026
8 checks passed
@thcp
thcp deleted the feat/desktop-streaming-chunked-engine branch July 13, 2026 17:02
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.

[Feature]: Stream audio instead of competely loading every stem before playback

1 participant