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.
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
@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.
@opaque 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.
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.
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.
@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-acthe 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.
@spec new(non_neg_integer(), AudioProxy.Options.t(), pos_integer()) :: t()
A reducer configured from options — the pts and ch half of the URL —
plus what only the probe knows.
@spec serialize(result(), AudioProxy.Options.peak_format()) :: binary()
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.
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.
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]}