Skip to content

feat(pipe): add a pipe_passthrough audio backend that writes the raw encoded stream - #316

Open
JRpersonal wants to merge 3 commits into
devgianlu:masterfrom
JRpersonal:pr/pipe-passthrough
Open

feat(pipe): add a pipe_passthrough audio backend that writes the raw encoded stream#316
JRpersonal wants to merge 3 commits into
devgianlu:masterfrom
JRpersonal:pr/pipe-passthrough

Conversation

@JRpersonal

Copy link
Copy Markdown
Contributor

Adds a passthrough mode to the pipe backend: with audio_output_pipe_passthrough the 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:

  • New config option audio_output_pipe_passthrough (cli + daemon Config), and a Passthrough flag on the pipe output, validated as pipe-backend only.
  • New AudioSourcePassthrough interface (ReadBytes) in output.go.
  • passthroughSource (player): hands out a track's raw Ogg bytes; position is approximated from bytes consumed and SetPositionMs only supports a restart (a mid-page byte seek would corrupt the Ogg stream).
  • SwitchingAudioSource.ReadBytes concatenates consecutive tracks' raw streams into a chained Ogg bitstream the downstream decoder plays continuously.
  • player NewStream: for OGG_VORBIS, when passthrough is on, skip vorbis.New and feed the raw audio stream through passthroughSource.
  • pipeOutput.passthroughLoop writes 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.

@JRpersonal

Copy link
Copy Markdown
Contributor Author

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.

Symptom

On 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:

level=warning msg="passthrough stream cannot seek to 1ms, starting from the beginning"
level=info    msg="loaded track \"...\" (paused: false, position: 1ms, ...)"

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

AppPlayer.play() re-asserts the state's position before starting playback ("seek before play"). That position comes from state.trackPosition(), which advances with the wall clock while the state says playing, so by the time play() reads it, it is routinely a few milliseconds past zero even for a track that should start at the beginning.

In passthrough mode that harmless re-assert was fatal:

  1. SeekMs(1) reaches passthroughSource.SetPositionMs(1).
  2. Any positive target was treated as a mid-stream jump and refused with a hard error (correctly so for genuine jumps: a mid-page byte seek would corrupt the Ogg bitstream, and pretending the seek happened made the reported position lie until the next track).
  3. play() treated the seek error as fatal and returned before Play() ran.
  4. The track stayed loaded but never started, and playback continued past it.

Note the asymmetry that made this hard to spot: the load path (NewStream) already degrades gracefully for a positive mediaPosition in passthrough (it logs the "cannot seek, starting from the beginning" warning and starts at zero), but the play path did not get the same treatment.

Fix

Three small changes, kept deliberately narrow:

  • SetPositionMs now treats a seek that lands within one second of the CURRENT position as the no-op it is. It does not ask the stream to move anywhere, it only re-asserts where playback already is, which is exactly what seek-before-play does. The 1 s tolerance covers the wall-clock drift of trackPosition() without opening the door to real jumps.
  • A genuine jump still fails, but with the exported sentinel ErrPassthroughCannotSeek instead of an anonymous error, so callers can distinguish "cannot honor a jump" from a broken stream. The existing behavior for controllers stays: a refused jump makes the controller snap back to the real position instead of believing a jump the audio never performed.
  • play() degrades gracefully on that sentinel: it logs a warning and starts playback from the stream's actual position instead of not starting at all. For every other seek error it still fails as before. The position report stays truthful either way, because a passthrough source derives PositionMs from the bytes actually consumed.

Tests

player/passthrough_seek_test.go covers the contract:

  • re-asserting the current position is a no-op, both on a fresh stream (the "seek to 1ms" case) and mid-stream (resume after pause),
  • a genuine mid-stream jump is still refused, with errors.Is(err, ErrPassthroughCannotSeek),
  • a seek to zero or below still rewinds to the start (the documented restart escape hatch, e.g. for a Connect transfer that carries a mid-track position).

@JRpersonal
JRpersonal force-pushed the pr/pipe-passthrough branch from 2372893 to 9d9126c Compare August 1, 2026 02:17
@JRpersonal

Copy link
Copy Markdown
Contributor Author

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. audio_output_pipe_passthrough writes the raw encoded Ogg/Vorbis stream to the pipe instead of decoded PCM, for downstream consumers that do their own decoding. Consecutive tracks are concatenated as a chained Ogg by the switching source; normalisation, replay gain and crossfade do not apply (there is no decoded audio to touch), and the option is guarded to the pipe backend. Includes tests for the passthrough read path.

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 play() give up: the track stayed loaded but never played, and the player moved past it, so one Next press audibly skipped two tracks. A seek within one second of the current position is now the no-op it is; a genuine jump still fails, with the exported ErrPassthroughCannotSeek, and play() degrades to starting from the stream's actual position. Details are in the earlier comment above; the reproduction and fix are unchanged, just rebased.

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.

@devgianlu

Copy link
Copy Markdown
Owner

I was considering that perhpas the best approach for this is to define another audio backend instead of having a flag.

@JRpersonal
JRpersonal force-pushed the pr/pipe-passthrough branch from 9d9126c to 8ddc7e2 Compare August 1, 2026 09:10
@JRpersonal JRpersonal changed the title feat(pipe): add audio_output_pipe_passthrough to write the raw encoded stream feat(pipe): add a pipe_passthrough audio backend that writes the raw encoded stream Aug 1, 2026
@JRpersonal

Copy link
Copy Markdown
Contributor Author

Good call, done. The flag is gone; passthrough is now its own backend.

audio_backend: pipe_passthrough shares the pipe driver machinery (one dispatch case), requires audio_output_pipe with a clear error when missing, and the player derives the passthrough mode from the configured backend, so the option plumbing that the flag needed (config key, options field, player option) is deleted rather than moved. README and config_schema.json document the new value. The two fix commits on top are unchanged.

@devgianlu

Copy link
Copy Markdown
Owner

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.
@JRpersonal
JRpersonal force-pushed the pr/pipe-passthrough branch from 8ddc7e2 to 55d5013 Compare August 1, 2026 09:45
@JRpersonal

Copy link
Copy Markdown
Contributor Author

Understood, reworked. pipe_passthrough is now its own driver (output/driver-pipe-passthrough.go): it opens the FIFO and writes the encoded bytes from the passthrough source, and that is all it does. The PCM concepts simply do not exist in it (no transform, no clamping, no crossfade; delay reports zero since the encoded stream is opaque to the daemon). driver-pipe.go is back to being byte-identical with master, zero passthrough references left in it.

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.

2 participants