AudioProxy.Peaks (audio_proxy v0.4.0)

Copy Markdown View Source

Raw s16le PCM in, waveform peaks out (API doc §3.3).

ffmpeg does the decoding, the trim and the downmix — see AudioProxy.Ffmpeg.Command's peaks note — and hands over interleaved 16-bit little-endian samples. This module does the arithmetic: reduce those samples to pts min/max pairs and serialize them in one of audiowaveform's two formats, which is the schema decision recorded in CLAUDE.md. There is nothing to invent here; peaks.js and the rest of that ecosystem already read these bytes.

Streaming, not buffering

feed/2 consumes chunks as they arrive and keeps only the reduced result: four integers per pixel for stereo, two for mono, plus at most one partial frame of carry-over. A ten-minute source is tens of megabytes of PCM and a few kilobytes of peaks, and only the second number is ever resident.

That is why the reducer is built from a sample count rather than from the stream (new/2): bucket boundaries have to be known before the first sample arrives, and the count comes from AudioProxy.Ffprobe running ahead of the decode. samples_per_pixel is ceil(frames / count), so the last bucket may be short.

When the probe and the decode disagree

They can, by a frame or two — a duration in the container header is not a promise about how many samples a decoder will produce. Both directions are absorbed rather than reported:

  • more samples than expected fold into the final bucket, which is why that bucket alone never closes on width;
  • fewer leave trailing buckets empty, and those serialize as 0, 0.

So length is always exactly the pts that was asked for, whatever the decoder did, which is what makes the output shape a function of the URL.

Chunk boundaries are not sample boundaries

A port hands over whatever the OS had, so a chunk can end mid-sample or mid-frame. The leftover bytes are carried into the next feed/2, and the reduction is therefore independent of how the stream was cut — the property test that random-chunks the same PCM is the statement of it.

iex> pcm = <<100::little-signed-16, -200::little-signed-16, 50::little-signed-16>>
iex> AudioProxy.Peaks.new(3, count: 3) |> AudioProxy.Peaks.feed(pcm)
...> |> AudioProxy.Peaks.finish() |> Map.fetch!(:data)
[100, 100, -200, -200, 50, 50]

Summary

Types

The finished reduction, ready to serialize.

t()

A reduction in progress. Opaque — build with new/2, drive with feed/2.

Functions

Folds a chunk of interleaved s16le PCM into the reduction.

Closes the reduction and returns the audiowaveform-shaped result.

A reducer for frames frames of PCM.

A reducer configured from options — the pts and ch half of the URL — plus what only the probe knows.

Serializes for format — the pk_fmt half of the URL.

audiowaveform's binary serialization: a little-endian v2 header followed by the same integers as int16.

audiowaveform's JSON serialization: the keys of result/0, verbatim.

Types

result()

@type result() :: %{
  version: 2,
  channels: 1 | 2,
  sample_rate: pos_integer(),
  samples_per_pixel: pos_integer(),
  bits: 16,
  length: pos_integer(),
  data: [integer()]
}

The finished reduction, ready to serialize.

data is interleaved min/max per channel per pixel, so it holds length * 2 * channels integers, exactly as audiowaveform's JSON does.

t()

@opaque t()

A reduction in progress. Opaque — build with new/2, drive with feed/2.

Functions

feed(state, data)

@spec feed(t(), binary()) :: t()

Folds a chunk of interleaved s16le PCM into the reduction.

Any trailing bytes that do not complete a frame are carried to the next call, so chunk boundaries do not have to fall anywhere in particular.

finish(state)

@spec finish(t()) :: result()

Closes the reduction and returns the audiowaveform-shaped result.

The bucket in progress is closed if anything reached it, and the tail is padded to count pairs. A partial final frame — the decoder stopped mid-sample — is discarded rather than being read as a sample it is not.

new(frames, opts)

@spec new(
  non_neg_integer(),
  keyword()
) :: t()

A reducer for frames frames of PCM.

A frame is one sample per channel, so frames is the sample count of one channel and not the number of integers on the wire.

Options:

  • :count — how many min/max pairs to produce (pts); required.
  • :channels — 1 or 2, matching the -ac the decode was given. Defaults to 1.
  • :sample_rate — carried into the serialized output, where consumers use it to turn a pixel index into a time. Defaults to 0, meaning unknown.

new(frames, options, sample_rate)

A reducer configured from options — the pts and ch half of the URL — plus what only the probe knows.

serialize(result, atom)

@spec serialize(result(), AudioProxy.Options.peak_format()) :: binary()

Serializes for format — the pk_fmt half of the URL.

to_dat(result)

@spec to_dat(result()) :: binary()

audiowaveform's binary serialization: a little-endian v2 header followed by the same integers as int16.

The header is version, flags, sample rate, samples per pixel, length and channel count, in that order — six 32-bit fields, and then the data. A consumer decoding this and the JSON above gets identical pairs, which is the round-trip the spec asks for.

to_json(result)

@spec to_json(result()) :: binary()

audiowaveform's JSON serialization: the keys of result/0, verbatim.

iex> pcm = <<-3000::little-signed-16, 9000::little-signed-16>>
iex> AudioProxy.Peaks.new(2, count: 1, sample_rate: 8000)
...> |> AudioProxy.Peaks.feed(pcm) |> AudioProxy.Peaks.finish()
...> |> AudioProxy.Peaks.to_json() |> JSON.decode!()
%{"version" => 2, "channels" => 1, "sample_rate" => 8000,
  "samples_per_pixel" => 2, "bits" => 16, "length" => 1,
  "data" => [-3000, 9000]}