Skip to main content

lib/air_play/source.ex

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