feat(pipe): add a pipe_passthrough audio backend that writes the raw encoded stream - #316
feat(pipe): add a pipe_passthrough audio backend that writes the raw encoded stream#316JRpersonal wants to merge 3 commits into
Conversation
|
I pushed one more commit (2372893) fixing a bug in the passthrough seek handling that took a while to catch in the field. Full story below, since the failure mode is subtle and worth having on record for review. SymptomOn a device playing a playlist through the passthrough pipe, a single "next track" could audibly jump TWO tracks: the skipped-to track was reported as loaded, produced zero audio, and about ten seconds later the player had silently moved on to the track after it. The only log trace was: and then nothing from that track. I could prove the "zero audio" part from the consumer side: the logical Ogg stream that followed the skip carried exactly the NEXT track's full duration in granule positions, and no separate BOS for it, so the skipped-to track never contributed a single audio page. Mechanism
In passthrough mode that harmless re-assert was fatal:
Note the asymmetry that made this hard to spot: the load path ( FixThree small changes, kept deliberately narrow:
Tests
|
2372893 to
9d9126c
Compare
|
Rebased onto current master (bfa4a35), since the branch had gone stale and conflicted with the pipe-output refactor. The PR is now a clean three-commit series; the previous head was 2372893 if you want to compare. 8407d1a — the feature. bd923b4 — seek fix. The daemon re-asserts the state's position before every play, and that position advances with the wall clock, so in passthrough mode the request routinely lands a few milliseconds past the source's actual position. The old hard failure made 9d9126c — page-boundary handover. Replacing the primary passthrough source mid Ogg page spliced the new track into the old track's logical stream: the truncated page's declared length swallowed the new track's BOS and header pages, and a downstream page parser then mis-attributed the new audio (verified on hardware with a page-level trace). The switcher now parks an incoming source while the outgoing one is mid-page and hands over exactly at the page boundary, bounded by the maximum Ogg page size, promoting immediately on EOF or error. Only the passthrough path is affected; the PCM path is untouched. Tests cover mid-page drain, mid-header drain, exact-boundary swap and EOF promotion. All three commits build and test independently. |
|
I was considering that perhpas the best approach for this is to define another audio backend instead of having a flag. |
9d9126c to
8ddc7e2
Compare
|
Good call, done. The flag is gone; passthrough is now its own backend.
|
|
That's basically the same thing, I was referring to having an entirely separate backend from pipe instead of hacking pipe into sending encoded frames. |
…ded stream Add a pipe_passthrough audio backend: instead of decoding the Ogg/Vorbis stream to PCM, the daemon writes the raw encoded bitstream to the named pipe untouched. This lets a downstream consumer with its own decoder (a hardware decoder, another player process, a transcoder) take the audio without a lossy decode/re-encode round trip and without paying for a Vorbis decode on constrained devices. The backend is its own output driver (pipePassthroughOutput in output/driver-pipe-passthrough.go), entirely separate from the pipe driver; the pipe driver is untouched. The new driver contains only what the encoded path needs: it opens the FIFO with the same open/non-blocking semantics as the pipe driver, then serves a loop that reads raw bytes from an AudioSourcePassthrough reader and writes them to the FIFO. Everything else in the pipe driver is about decoded PCM samples - sample format transform, clamping, volume scaling - and has no meaning for an opaque encoded stream, so none of it exists here: DelayMs reports zero (the driver cannot know what the downstream decoder has buffered), Drop is a no-op (nothing is buffered driver-side), SetVolume only forwards the update to the volume channel (the downstream consumer owns volume), and audio_output_pipe_format does not apply. audio_backend "pipe_passthrough" selects the backend and audio_output_pipe must be set, as for pipe; there is no new config key. How the encoded bytes are produced: - A new AudioSourcePassthrough interface (extending AudioSource with ReadBytes) hands out raw encoded bytes. - For Ogg Vorbis tracks the player skips the Vorbis decoder and wraps the decrypted stream in a passthroughSource. The Spotify-specific metadata page (0x81) is still stripped via ExtractMetadataPage, so what reaches the pipe is a clean Vorbis Ogg bitstream. Position is approximated from bytes consumed; seeking is limited to a restart because a mid-page byte seek would corrupt the Ogg framing. - SwitchingAudioSource gains a ReadBytes path: when the current track reaches EOF it switches to the queued source and concatenates the raw streams, which for Ogg yields a chained bitstream that a compliant downstream decoder plays continuously across track boundaries. - Normalisation/replay gain and crossfading are decoder-side sample operations, so neither applies to the raw encoded stream. Crossfade is disabled with a warning when the pipe_passthrough backend is selected; the normalisation factor is simply never applied since the decoder is bypassed. Includes tests for the SwitchingAudioSource passthrough semantics: sequential ReadBytes across a track switch, the track-done notification on EOF, and mixed-source error behaviour.
… from ever starting The daemon re-asserts the state's position before every play (seek before play). That position advances with the wall clock, so in passthrough mode the request routinely lands a few milliseconds past the source's actual position. SetPositionMs treated ANY positive target as a mid-stream jump and failed hard, play() gave up, and the track sat loaded but never playing until the player moved past it: one Next press audibly skipped two tracks (field trace 2026-08-01, "cannot seek to 1ms, starting from the beginning" followed by zero audio pages from that track). Three changes: - SetPositionMs treats a seek that lands within 1 s of the current position as the no-op it is; only a genuine jump still fails. - That failure is now the exported ErrPassthroughCannotSeek, so callers can tell "cannot honor a jump" from a broken stream. - play() degrades gracefully on that sentinel: playback starts from the stream's actual position instead of not starting at all. The position report stays truthful either way, because a passthrough source derives PositionMs from the bytes actually consumed. Covered by passthrough_seek_test.go: no-op re-assert (fresh and mid-stream), genuine jump still refused with the sentinel, restart at <= 0 still rewinds.
In passthrough mode, SetPrimary swapped the primary source at an arbitrary byte offset. The pipe output loop reads in 16 KB chunks and is usually in the middle of an Ogg page when a user skip replaces the source, so the downstream consumer read the new track's BOS and header pages as the tail of the old, now truncated page: the new track was spliced into the old logical Ogg stream and its stream markers were swallowed (field trace 2026-08-01). Only the natural EOF chain was page-aligned. The passthrough source now tracks Ogg page framing on the bytes it serves (parsing each 27-byte header plus segment table, buffering a header that straddles reads) and exposes MidPage/PageBytesRemaining. When SetPrimary replaces a passthrough primary that stands mid-page, the incoming source is parked in pendingPrimary instead of being swapped synchronously: ReadBytes keeps serving the old source with reads capped so they can never start a new page, then promotes the parked source and drops the old one exactly like a synchronous swap would have. A swap requested exactly at a page boundary still switches immediately, and non-passthrough sources keep the old behavior. The drain is bounded to one maximum-size Ogg page (65307 bytes), so a source with broken framing can never stall a handoff; framing loss also degrades MidPage to false, i.e. back to the old immediate swap. If the outgoing source errors or reaches EOF mid-drain, the parked source is promoted immediately, the error is dropped (the source was being replaced anyway), and no done signal is sent because the daemon initiated the transition itself. While a handoff is parked, position calls are routed to the parked source, which is the track the daemon is addressing.
8ddc7e2 to
55d5013
Compare
|
Understood, reworked. |
Adds a passthrough mode to the pipe backend: with
audio_output_pipe_passthroughthe decoder is bypassed and the raw Ogg/Vorbis bitstream is written to the pipe untouched, so a downstream consumer (e.g. a hardware decoder) does the decoding.Motivation: on a weak ARM target that can decode Vorbis natively, decoding to float32 PCM in go-librespot and re-streaming PCM wastes CPU and bandwidth. Handing the original Ogg/Vorbis straight through roughly halves CPU there.
What it changes:
audio_output_pipe_passthrough(cli + daemon Config), and aPassthroughflag on the pipe output, validated as pipe-backend only.AudioSourcePassthroughinterface (ReadBytes) inoutput.go.passthroughSource(player): hands out a track's raw Ogg bytes; position is approximated from bytes consumed andSetPositionMsonly supports a restart (a mid-page byte seek would corrupt the Ogg stream).SwitchingAudioSource.ReadBytesconcatenates consecutive tracks' raw streams into a chained Ogg bitstream the downstream decoder plays continuously.NewStream: forOGG_VORBIS, when passthrough is on, skipvorbis.Newand feed the raw audio stream throughpassthroughSource.pipeOutput.passthroughLoopwrites the raw bytes (no decode, no volume, no format transform).Caveats: volume scaling and normalisation/replay-gain are not applied in passthrough (the stream is untouched); seeking is limited to a restart; pipe backend only; FLAC passthrough is not included.
Builds clean on current
master. Happy to adjust naming or split the change if you'd prefer.