Skip to content

Audio Buffers, PCM Samples, and File Formats

KFR keeps the representation used for DSP separate from the representation used on disk. audio_data holds normalized floating-point samples for processing. audiofile_format describes an encoded audio stream: its container, codec, sample representation, rate, layout, and metadata. The decoder and encoder APIs described in Reading and Writing Audio Files connect the two.

Include <kfr/audio.hpp> for the complete audio I/O API.

Frames, channels, and layouts

A frame is one sample from every channel at one instant. A stereo buffer with 48,000 frames therefore contains 48,000 left samples and 48,000 right samples, or 96,000 scalar samples in total. In an audio_data object, size and capacity are measured in frames, while .total_samples() returns the scalar-sample count.

KFR provides two aliases for the two common layouts:

Type Storage Best fit
audio_data_planar One contiguous buffer per channel: \(L_0, L_1, \ldots\) and \(R_0, R_1, \ldots\). Per-channel DSP and independent channel processing.
audio_data_interleaved One contiguous buffer ordered by frame: \(L_0, R_0, L_1, R_1, \ldots\). File I/O, device callbacks, and APIs that already use interleaved buffers.

Both layouts store fbase values, which are normalized floating-point samples (float or double, depending on the KFR build). Integer PCM is only used at an I/O boundary and is converted with samples_load and samples_store.

The maximum channel count is max_audio_channels (16 by default). Defining KFR_MAX_AUDIO_CHANNELS changes it at build time; the supported range is 2 through 64.

Example: planar processing, interleaved output

Planar storage lets each channel participate directly in KFR expressions. This example creates one second of silent stereo audio, applies a gain to each channel, and makes an interleaved copy for an encoder or audio device.

#include <kfr/audio.hpp>
using namespace kfr;

constexpr size_t sample_rate = 48000;

audio_data_planar channels(2, sample_rate, 0.0f);

// Generate or load samples into channels.channel(0) and channels.channel(1).
for (size_t ch = 0; ch < channels.channel_count(); ++ch)
    channels.channel(ch) *= 0.5f;

audio_data_interleaved output = channels; // Copies and interleaves samples

The conversion constructor is a data conversion, not a different view of the same storage. Constructing audio_data_planar from an interleaved buffer deinterleaves it; constructing audio_data_interleaved from a planar buffer interleaves it.

Constructing and owning audio buffers

The allocating constructors take (channels, frames). Their memory is SIMD-friendly aligned; planar buffers also align each channel independently. The two-argument form deliberately leaves samples uninitialized. Pass a value or call .fill(fbase) when silence or another known initial value is required.

audio_data_planar scratch(2, 4096);            // Samples are uninitialized
audio_data_planar silence(2, 4096, 0.0f);      // Every sample is zero
audio_data_interleaved signal(2, 4096, 0.25f); // Every sample is 0.25

signal.multiply(0.5f); // Scales every channel in place

The default constructor creates an empty buffer. .clear() sets its frame count to zero while retaining the allocation; .reset() returns it to the default empty state and releases owned storage when no other view refers to it. .resize(size_t, fbase) initializes a newly appended region, whereas .resize(size_t) does not.

Wrapping external storage

The view constructors avoid a copy. For interleaved audio, pass one pointer, the channel count, and the frame count. For planar audio, pass a span of one pointer per channel. These constructors are non-owning: the caller must keep the wrapped memory alive.

#include <array>
#include <vector>

std::vector<fbase> device_buffer(2 * 1024);
audio_data_interleaved device_audio(device_buffer.data(), 2, 1024);

std::vector<fbase> left(1024);
std::vector<fbase> right(1024);
std::array<fbase*, 2> planes = { left.data(), right.data() };
audio_data_planar planar_audio(planes, left.size());

An overload with a callable finalizer makes the buffer own external storage. The finalizer runs after the last buffer or view sharing it is destroyed.

fbase* samples = new fbase[2 * 1024];
audio_data_interleaved audio(samples, 2, 1024,
                             [samples] { delete[] samples; });

Copies of the same layout are shallow: they copy the pointers and shared ownership state, so modifying samples through either object modifies the same storage. The same is true for slices. Make an explicitly allocated buffer and copy its samples when independent storage is needed.

Accessing samples and channels

.channel(size_t) returns a one-dimensional KFR expression for a channel. For audio_data_planar, it is a contiguous univector_ref; for audio_data_interleaved, it is a strided_channel that visits every channels-th element. The strided form is still suitable for ordinary expression assignments and DSP operations, but it is not contiguous.

audio_data_interleaved stereo(2, 4, 0.0f);
stereo.interlaved() = univector<fbase>{ 1, 10, 2, 20, 3, 30, 4, 40 };

// Left and right are strided views: { 1, 2, 3, 4 } and { 10, 20, 30, 40 }.
stereo.channel(0) *= 0.5f;
stereo.channel(1) *= 0.25f;

.interlaved() is available only for the interleaved layout and returns a contiguous view containing size * channels scalars. .pointers() is available only for the planar layout and returns the per-channel pointer array for APIs that need it.

The two overloads of .for_channel(Fn &&) are primarily storage traversal helpers. For planar data they call the callback for each logical channel. For interleaved data they call it once for the whole interleaved buffer, not once per channel. Use .channel(size_t) when an operation must address individual interleaved channels.

Views, capacity, and stream assembly

.slice(size_t, size_t) returns a shallow frame-range view. Its requested length is clamped to the available frames, and the returned view's position advances by start. .truncate(size_t) is the convenient slice(0, length) form.

audio_data_planar recording(2, 48000, 0.0f);
recording.position = 96000; // This block starts at frame 96000 in its stream.

audio_data_planar first_100_ms = recording.truncate(4800);
audio_data_planar next_100_ms  = recording.slice(4800, 4800);
// first_100_ms.position == 96000; next_100_ms.position == 100800

Growing .resize(size_t) or .reserve(size_t) can reallocate the backing store, invalidating pointers and views into it. Reserve capacity before creating long-lived views. .slice_past_end(size_t) is an advanced helper for a producer that wants a writable view after the current end: it reserves enough storage and returns the view, but it does not grow the parent buffer's size. After filling the returned view, grow the parent to commit those frames.

.append(const audio_data<IsInterleaved> &) and .prepend(const audio_data<IsInterleaved> &) copy frames and can convert the source layout. The buffers must represent the same number of channels; matching channel counts are a caller precondition. prepend also decreases the destination's position by the number of inserted frames, while append leaves it unchanged.

Measuring a buffer

.stat() returns an audio_stat with two aggregate measurements:

  • peak is the largest absolute scalar sample across every channel.
  • rms is the root-mean-square value across every scalar sample and channel.

.is_silent(fbase) tests whether every sample lies in the inclusive range \([-threshold, threshold]\). The default threshold is \(10^{-5}\); a sample exactly equal to the threshold is silent. .find_peak() returns a frame index. It chooses the frame with the largest sum of absolute channel values, which is useful for locating a multichannel transient rather than a single-channel maximum.

audio_data_planar channels(2, 4, 0.0f);
channels.channel(1)[2] = 0.5f;

const audio_stat level = channels.stat();
if (!channels.is_silent())
    std::cout << "peak frame: " << channels.find_peak()
              << ", peak: " << level.peak << '\n';

Describing an encoded stream with audiofile_format

audiofile_format describes audio outside the floating-point DSP buffer. A decoder returns the detected format when it opens a file; an encoder uses a format supplied by the caller. Its main fields are:

Field Meaning
container audiofile_container, such as WAVE, W64, RF64, FLAC, CAF, AIFF, or MP3.
codec audiofile_codec, such as linear PCM, IEEE floating point, FLAC, ALAC, or MP3.
endianness and bit_depth audiofile_endianness and the encoded sample representation.
channels and sample_rate Channel count and rate in hertz.
speakers A speaker_arrangement that records a known channel order when one is available.
total_frames Total frame count when it is known.
metadata A metadata_map of string key/value pairs.

For example, this describes 16-bit, little-endian stereo PCM in a WAVE container:

audiofile_format format;
format.container   = audiofile_container::wave;
format.codec       = audiofile_codec::lpcm;
format.endianness  = audiofile_endianness::little;
format.bit_depth   = 16;
format.channels    = 2;
format.sample_rate = 48000;
format.speakers    = speaker_arrangement::Stereo;

if (!format.valid())
    throw std::runtime_error("invalid audio format");

.valid() checks the basic channel, rate, codec, and bit-depth constraints. It does not establish that every container accepts every codec, so select a supported combination from Audio Format Support. .bytes_per_pcm_frame() returns \(channels \times \lceil bit\_depth / 8 \rceil\) for encoded PCM storage.

.sample_type() maps supported linear PCM and IEEE float formats to audio_sample_type (i16, i24, i32, f32, or f64), or unknown where there is no direct memory sample type. .sample_type_lpcm() performs the integer-depth part of that mapping without considering the codec. audio_sample_bit_depth and audio_sample_is_float are useful when code needs to inspect such a runtime tag.

Use arrangement_speakers to obtain the ordered speaker list for a declared arrangement, or arrangement_for_channels(size_t) to choose KFR's default predefined arrangement for a channel count.

Converting PCM data

samples_load converts a contiguous encoded sample sequence to normalized fbase. Its planar overload deinterleaves an encoded buffer while it converts. The corresponding samples_store overloads interleave planar floating-point channels into an encoded buffer. Runtime overloads accept an audio_sample_type and std::byte* when the sample representation is known only after parsing a format.

For integer output, conversion clamps the floating-point value to \([-1, 1]\) before scaling and rounding. The positive endpoint maps to the largest positive integer value. Pass true for swap_bytes when the encoded byte order differs from the host representation.

audio_quantization adds dither before integer quantization. Construct it with the target bit depth and an audio_dithering method: none, rectangular, or triangular. The dither scale is one quantization step (\(1 / 2^{bit\_depth}\)). Use this overload when manually reducing integer bit depth; ordinary file encoding selects its dither behavior through audio_encoding_options.

audio_data_planar source(2, 256, 0.0f);
std::vector<i16> pcm(source.total_samples());

audio_quantization quantization(16, audio_dithering::triangular);
samples_store(pcm.data(), source.pointers(), source.channels, source.size,
              quantization);

Modern and legacy format APIs

For new code, use audiofile_format with audio_decoder and audio_encoder. The older audio_format and audio_format_and_length types belong to the previous reader/writer API. They expose only a channel count, sample type, sample rate, W64 flag, and sample length, and cannot represent modern container, codec, metadata, or speaker information. Migrate old code to the current decoder and encoder API rather than introducing the legacy types into a new pipeline.

See also