Snapcast.Stream (Snapcast v0.4.0)

Copy Markdown View Source

Decodes one source and paces it out as timestamped WireChunks.

ffmpeg is used purely as a decoder/transcoder: it floods output into a buffer as fast as it likes (bounded by -re). The pacer owns the audio timeline and emits a chunk once realtime is within bufferMs of its play time. Because the server assigns the timestamps (not arrival order), there is no producer/consumer drift; the bufferMs lead absorbs all jitter.

Three transports:

  • :pcm — raw little-endian PCM, sliced into fixed chunk_ms chunks; chunk N is stamped start + N * chunk_ms.
  • :flac — ffmpeg encodes FLAC; the FLAC header becomes the flac CodecHeader and the audio is split on whole FLAC frame boundaries (Snapcast.Flac), one frame per WireChunk, stamped from the cumulative sample count. Halves the wire data rate of lossless music.
  • :opus — ffmpeg encodes Opus in an Ogg container (forced to 48kHz/16-bit/stereo); the Ogg packets are extracted (Snapcast.Ogg), one Opus packet per WireChunk, and a synthesised opus CodecHeader is sent. For compressed/lossy sources (podcasts, audiobooks, radio) where re-encoding to FLAC would be wasteful.

Encoded transports (:flac, :opus) send their CodecHeader once per client; :pcm carries its WAV CodecHeader via the session on stream assignment.

source is a binary path/URL, or a 0-arity function returning one (resolved when the stream starts, e.g. for short-lived signed URLs).

Summary

Functions

Returns a specification to start this module under a supervisor.

The next timestamp this stream will use (µs), so a successor can continue the timeline.

PCM payload as snapcast expects it on the wire for the given bit depth.

Functions

attach(stream, session_pid)

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

high_water(stream)

The next timestamp this stream will use (µs), so a successor can continue the timeline.

start_link(opts)

stop(stream)

wire_pcm(pcm, arg2)

PCM payload as snapcast expects it on the wire for the given bit depth.

Snapcast has no packed 3-byte PCM format: its shared SampleFormat forces a 4-byte sample for 24-bit (common/sample_format.cpp sets sample_size_ = 4 when bits == 24) — S24_LE carried in a 32-bit word. Crucially, the client's software volume scales each 4-byte sample as an int32_t (player.cpp adjustVolume<int32_t> for sampleSize() == 4), so the 24-bit value must be sign-extended into a valid little-endian int32: the low 3 bytes carry the sample (what the audio backend reads as 24-bit), the high byte is the sign. ffmpeg emits packed 3-byte s24le, so we widen each sample to a sign-extended 32-bit word here.

Sending packed 3-byte samples while advertising 24 bits produced static (frame size mismatch); zero-padding the high byte instead produced garbled audio (negative samples read as large positive int32s once volume ≠ 1.0). 16- and 32-bit already match snapcast's sample width and pass through untouched.