Current section
Files
Jump to
Current section
Files
lib/snapcast/protocol.ex
defmodule Snapcast.Protocol do
@moduledoc """
Snapcast binary wire protocol — encode/decode (a pure-Elixir snapcast SERVER).
Every message is a 26-byte **little-endian** base header followed by `size`
payload bytes:
type:uint16 id:uint16 refersTo:uint16
sent.sec:int32 sent.usec:int32
received.sec:int32 received.usec:int32
size:uint32
Strings/JSON payloads are `uint32` length-prefixed. Message types:
1 CodecHeader, 2 WireChunk, 3 ServerSettings, 4 Time, 5 Hello, 7 ClientInfo,
8 Error. (See snapcast `common/message/*.hpp`.)
The server owns the audio timeline: it stamps each WireChunk with the server-clock
time at which that chunk should play, and the client plays it `bufferMs` later on
its sync-corrected clock — so there is no producer/consumer pacing drift.
"""
@type tv :: {integer(), integer()}
@t_codec_header 1
@t_wire_chunk 2
@t_server_settings 3
@t_time 4
@t_hello 5
@t_client_info 7
@header_size 26
def header_size, do: @header_size
# --- encoding (server -> client) -------------------------------------------
@doc "Frame a message: 26-byte base header + payload."
def frame(type, payload, opts \\ []) when is_integer(type) and is_binary(payload) do
{ss, su} = Keyword.get(opts, :sent, {0, 0})
{rs, ru} = Keyword.get(opts, :received, {0, 0})
id = Keyword.get(opts, :id, 0)
refers = Keyword.get(opts, :refers_to, 0)
<<type::little-16, id::little-16, refers::little-16, ss::little-signed-32,
su::little-signed-32, rs::little-signed-32, ru::little-signed-32,
byte_size(payload)::little-32>> <> payload
end
@doc "ServerSettings (type 3): bufferMs / latency / volume% / muted, JSON payload."
def server_settings(buffer_ms, latency, volume, muted, opts \\ []) do
json =
Jason.encode!(%{
"bufferMs" => buffer_ms,
"latency" => latency,
"volume" => volume,
"muted" => muted
})
frame(@t_server_settings, json_payload(json), opts)
end
@doc "CodecHeader (type 1): codec name + codec-specific header payload."
def codec_header(codec, header_payload, opts \\ [])
when is_binary(codec) and is_binary(header_payload) do
payload =
<<byte_size(codec)::little-32, codec::binary, byte_size(header_payload)::little-32,
header_payload::binary>>
frame(@t_codec_header, payload, opts)
end
@doc "CodecHeader (type 1) for the `pcm` codec: codec name + 44-byte WAV header."
def codec_header_pcm(rate, bits, channels, opts \\ []) do
codec_header("pcm", wav_header(rate, bits, channels), opts)
end
@doc """
CodecHeader (type 1) for Snapcast's raw `opus` codec.
The 12-byte pseudo-header (`OpusDecoder::setHeader`) is `id:u32, rate:u32, bits:u16,
channels:u16` written in **native byte order** — snapcast's `SWAP_*` macros are no-ops
on little-endian, which every real client is — so the fields go out little-endian, not
big-endian. The id `0x4F505553` ("OPUS") therefore lands on the wire as `53 55 50 4F`.
"""
def codec_header_opus(rate, bits, channels, opts \\ []) do
codec_header(
"opus",
<<0x4F505553::little-32, rate::little-32, bits::little-16, channels::little-16>>,
opts
)
end
@doc "CodecHeader (type 1) for the `flac` codec. Payload is FLAC stream metadata."
def codec_header_flac(flac_metadata, opts \\ []) when is_binary(flac_metadata) do
codec_header("flac", flac_metadata, opts)
end
@doc "WireChunk (type 2): playout timestamp (server clock) + codec payload."
def wire_chunk({ts_sec, ts_usec}, payload_binary, opts \\ []) when is_binary(payload_binary) do
payload =
<<ts_sec::little-signed-32, ts_usec::little-signed-32, byte_size(payload_binary)::little-32,
payload_binary::binary>>
frame(@t_wire_chunk, payload, opts)
end
@doc """
Time response (type 4). The client computes its clock offset from the round trip,
so the server must reply with `latency = serverReceived - clientSent` (in the
payload) and set the base header `sent` to the server time at send.
"""
def time_response({lat_sec, lat_usec}, opts \\ []) do
frame(@t_time, <<lat_sec::little-signed-32, lat_usec::little-signed-32>>, opts)
end
# Standard 44-byte little-endian WAV/RIFF header (snapcast's pcm CodecHeader).
@doc false
def wav_header(rate, bits, channels) do
byte_rate = div(rate * bits * channels, 8)
block_align = div(channels * bits, 8)
"RIFF" <>
<<36::little-32>> <>
"WAVE" <>
"fmt " <>
<<16::little-32, 1::little-16, channels::little-16, rate::little-32, byte_rate::little-32,
block_align::little-16, bits::little-16>> <>
"data" <> <<0::little-32>>
end
defp json_payload(json), do: <<byte_size(json)::little-32, json::binary>>
# --- decoding (client -> server) -------------------------------------------
@doc """
Peel one full message off a receive buffer. Returns `{:ok, message, rest}` once a
whole header+payload is present, or `:incomplete`. `message` is a map with the
base header fields plus a decoded `:body` for known types.
"""
def decode(<<header::binary-size(@header_size), tail::binary>> = buffer) do
<<type::little-16, id::little-16, refers::little-16, ss::little-signed-32,
su::little-signed-32, rs::little-signed-32, ru::little-signed-32, size::little-32>> = header
if byte_size(tail) >= size do
payload = binary_part(tail, 0, size)
rest = binary_part(tail, size, byte_size(tail) - size)
msg = %{
type: type,
id: id,
refers_to: refers,
sent: {ss, su},
received: {rs, ru},
size: size,
body: decode_body(type, payload)
}
{:ok, msg, rest}
else
:incomplete
end
rescue
_ -> {:error, buffer}
end
def decode(_buffer), do: :incomplete
defp decode_body(@t_hello, <<len::little-32, json::binary-size(len), _::binary>>),
do: {:hello, safe_json(json)}
defp decode_body(@t_client_info, <<len::little-32, json::binary-size(len), _::binary>>),
do: {:client_info, safe_json(json)}
defp decode_body(@t_time, <<ls::little-signed-32, lu::little-signed-32, _::binary>>),
do: {:time, {ls, lu}}
defp decode_body(_type, payload), do: {:raw, payload}
defp safe_json(json) do
case Jason.decode(json) do
{:ok, map} -> map
_ -> %{}
end
end
end