defmodule AirPlay.Source do
@moduledoc """
Decode a library track to the PCM format AirPlay/RAOP wants: 44.1 kHz, 16-bit,
stereo, little-endian, via `ffmpeg`. Returns the raw interleaved s16le bytes,
which `AirPlay.Alac` chops into 352-sample frames.
"""
@sample_rate 44_100
@channels 2
@doc "Decode `path` to interleaved s16le stereo @44.1kHz, or `{:error, reason}`."
@spec pcm(String.t()) :: {:ok, binary()} | {:error, term()}
def pcm(path) do
args = [
"-v",
"quiet",
"-i",
path,
# Audio-only: ignore any embedded cover-art (video) stream.
"-map",
"0:a:0",
"-vn",
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-ar",
Integer.to_string(@sample_rate),
"-ac",
Integer.to_string(@channels),
"-"
]
case System.cmd("ffmpeg", args, stderr_to_stdout: false) do
{pcm, 0} -> {:ok, pcm}
{_, code} -> {:error, {:ffmpeg_exit, code}}
end
rescue
e -> {:error, e}
end
@doc """
Build the ffmpeg argument list for *streaming* decode (used by
`AirPlay.Decoder`), emitting interleaved s16le stereo @44.1kHz on stdout.
Options:
* `:start_seconds` — input seek offset (fast `-ss` before `-i`)
`-re` makes ffmpeg read its input at native rate, so it produces PCM at ~1×
real time rather than as fast as it can decode — that's the backpressure that
keeps the decoder's buffer (and the BEAM mailbox) bounded for long files.
"""
@spec stream_args(String.t(), keyword()) :: [String.t()]
def stream_args(path, opts \\ []) do
seek =
case Keyword.get(opts, :start_seconds, 0) do
n when is_integer(n) and n > 0 -> ["-ss", Integer.to_string(n)]
_ -> []
end
reconnect =
if http_source?(path),
do: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"],
else: []
seek ++
reconnect ++
[
"-nostdin",
"-re",
"-hide_banner",
"-loglevel",
"error",
"-i",
path,
# Decode only the first audio stream and drop any video. Music files
# routinely carry an embedded cover-art (mjpeg) stream; without this some
# ffmpeg builds (e.g. FreeBSD's) fail the raw s16le output instead of
# ignoring it.
"-map",
"0:a:0",
"-vn",
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-ar",
Integer.to_string(@sample_rate),
"-ac",
Integer.to_string(@channels),
"-"
]
end
@doc "Split PCM into `frames`-sample stereo chunks (last one zero-padded)."
@spec frames(binary(), pos_integer()) :: [binary()]
def frames(pcm, frames \\ 352) do
chunk = frames * @channels * 2
do_chunk(pcm, chunk, [])
end
defp do_chunk(<<>>, _chunk, acc), do: Enum.reverse(acc)
defp do_chunk(bin, chunk, acc) when byte_size(bin) >= chunk do
<<c::binary-size(^chunk), rest::binary>> = bin
do_chunk(rest, chunk, [c | acc])
end
defp do_chunk(bin, chunk, acc) do
pad = chunk - byte_size(bin)
Enum.reverse([bin <> :binary.copy(<<0>>, pad) | acc])
end
defp http_source?(source) when is_binary(source) do
case URI.parse(source) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> true
_ -> false
end
end
defp http_source?(_source), do: false
end