Skip to content

Audio Utilities: Peak Normalization, Stereo Mixing, and Sample Conversion

This article collects small DSP and audio-buffer operations that commonly sit around a processing chain: peak normalization, stereo and mid/side transforms, sample-format conversion, and planar/interleaved layout conversion. These operations are independent of the filters and transforms described elsewhere in the DSP documentation.

Peak normalization

absmaxof returns the largest sample magnitude in a finite expression:

\[ \operatorname{peak}(x) = \max_i |x_i|. \]

Dividing by that value gives sample-peak normalization to a linear peak of \(1.0\) (0 dBFS for conventional normalized floating-point audio):

const float peak = absmaxof(audio);
if (peak > 0.0f)
    audio /= peak;

Guard the operation. Reducing an empty expression is invalid, and dividing silent audio by a zero peak produces invalid floating-point values. This is peak normalization, not RMS or loudness normalization; it does not make signals equally loud.

To normalize to another peak, multiply the result or include the target in the scale factor:

constexpr float target_peak = 0.8f;
const float peak = absmaxof(audio);
if (peak > 0.0f)
    audio *= target_peak / peak;

Linked stereo normalization

Use one shared peak across channels to preserve their relative level and stereo balance. concatenate creates a lazy sequence over both channels without copying them:

const float peak = absmaxof(concatenate(left, right));
if (peak > 0.0f)
{
    left /= peak;
    right /= peak;
}

Normalizing the channels independently can change a deliberately asymmetric stereo image. As with a single channel, validate or sanitize NaN and infinity values before applying a normalization policy if input data may contain them.

Stereo mixing and mid/side conversion

mixdown_stereo returns a lazy expression of paired output samples. A \(2\times2\) coefficient matrix maps left and right inputs to two output channels:

\[ \begin{aligned} y_0 &= m_{00}L + m_{01}R, \\ y_1 &= m_{10}L + m_{11}R. \end{aligned} \]

Use unpack to write those two output components to channel buffers.

L/R to M/S

matrix_halfsum_halfdiff implements the conventional level-preserving mid/side encoding:

\[ M = \frac{L+R}{2}, \qquad S = \frac{L-R}{2}. \]
unpack(mid, side) =
    mixdown_stereo(left, right, matrix_halfsum_halfdiff());

M/S to L/R

matrix_sum_diff computes its first output as the sum and its second as the difference. Applied to mid/side inputs, it reconstructs left/right:

\[ L = M + S, \qquad R = M - S. \]
unpack(left, right) =
    mixdown_stereo(mid, side, matrix_sum_diff());

matrix_sum_diff() applied to left/right inputs instead produces \(L+R\) and \(L-R\); it is not a safe, level-preserving downmix matrix. Keep headroom when using unscaled sums. The half-sum/half-difference and sum/difference pair is reversible for floating-point buffers; integer buffers can lose precision in the half-scaled step.

Stereo to mono and custom matrices

For an averaged mono downmix, which preserves the level of identical left and right channels, use:

mono = (left + right) * 0.5f;

An unscaled sum is also available through simple expression arithmetic or mixdown, but identical in-phase channels gain 6 dB and can exceed normalized sample range:

mono = left + right;

Custom two-output transforms use an f64x2x2 matrix. Each row contains the coefficients of one output channel:

const f64x2x2 matrix{
    f64x2{ 0.8, 0.2 },
    f64x2{ 0.2, 0.8 },
};
unpack(out_left, out_right) = mixdown_stereo(left, right, matrix);

Input and output channels should have matching lengths. mixdown_stereo is lazy, so the transform is evaluated when its result is assigned or otherwise materialized.

Audio sample conversion

convert_sample and the buffer convert overload rescale and clamp between KFR's native audio sample formats. Integer formats use the symmetric full-scale range \(\pm(2^{N-1}-1)\), while floating-point formats use a scale of one:

Runtime format C++ storage type Nominal full-scale range
audio_sample_type::i16 i16 / int16_t \(-32767\) to \(32767\)
audio_sample_type::i24 i24 \(-8388607\) to \(8388607\)
audio_sample_type::i32 i32 / int32_t \(-2147483647\) to \(2147483647\)
audio_sample_type::f32 f32 / float \(-1\) to \(1\) nominally
audio_sample_type::f64 f64 / double \(-1\) to \(1\) nominally

audio_sample_type identifies a format at runtime. audio_sample_bit_depth returns its bit depth, and audio_sample_is_float reports whether it is floating point. i24 is KFR's packed three-byte sample type; use i24, not an int32_t buffer, for 24-bit sample storage.

Converting known formats

When source and destination types are known at compile time, size is the number of individual samples:

const float input[] = { 0.0f, 0.25f, -0.25f, 1.0f, -1.0f };
int16_t output[std::size(input)];

convert(output, input, std::size(input));

For a scalar conversion, specify only the destination type when the source can be deduced:

const i24 pcm24 = convert_sample<i24>(1.0f);

Conversion to an integer format clamps out-of-range floating-point input before storing it. It does not add dither, so use an audio quantization/dithering stage when that is required. Same-type conversion returns its input unchanged.

Converting a runtime-selected format

If a decoder or external API reports the input format at runtime, keep the destination type fixed:

const void* input = encoded_samples;
univector<float> output(sample_count);

if (input_type != audio_sample_type::unknown)
    convert(output.data(), input, input_type, sample_count);

The reverse form selects the destination format at runtime:

univector<float> input = samples;
std::vector<std::byte> output(sample_count * 2); // i16 storage

convert(output.data(), audio_sample_type::i16, input.data(), sample_count);

The pointer must refer to storage matching the declared runtime format. Invalid or unknown runtime formats have no fallback conversion. Use separate non-overlapping source and destination buffers; the conversion functions do not promise in-place or overlapping-buffer behavior.

Interleaving and deinterleaving

interleave converts planar channel buffers to channel-interleaved storage. deinterleave performs the inverse. Both can convert the sample type while changing layout.

For \(C\) channels and \(N\) frames, the functions use:

\[ \begin{aligned} ext{interleaved}[iC + ch] &= \text{planar}[ch][i], \\ ext{planar}[ch][i] &= \text{interleaved}[iC + ch]. \end{aligned} \]

The final size parameter means samples per channel, or audio frames; it is not the total interleaved sample count.

Planar stereo to interleaved PCM16

const float* channels[] = { left.data(), right.data() };
univector<int16_t> interleaved(left.size() * 2);

interleave(interleaved.data(), channels, 2, left.size());

Interleaved PCM24 to planar float

float* channels[] = { left.data(), right.data() };

deinterleave(channels, pcm24_interleaved, 2, frame_count);

Here pcm24_interleaved has type const i24*. For a source type known only at runtime, convert it to a known typed buffer first, then interleave or deinterleave it.

Convenience overloads also work with rectangular univector2d planar buffers:

univector2d<float> planar;
planar.push_back(left);
planar.push_back(right);

univector<float> interleaved = interleave(planar);

All planar channels must have the same frame count, and an explicit destination buffer must be sized for exactly channels * frames samples. These layout helpers do not validate mismatched shapes, insufficient capacity, null pointers, or overlap.

Waveforms and test signals

phasor creates a lazy phase ramp in normalized cycles \([0,1)\). Its three-argument form accepts frequency and sample rate in Hz, plus an initial phase in cycles. The one-argument phasor form interprets its frequency as cycles per sample.

constexpr float sample_rate = 48000.0f;
univector<float, 480> sine_440 =
    sinenorm(phasor<float>(440.0f, sample_rate));

The waveform functions operate element-wise on phases. The radian forms are sine, square, triangle, sawtooth, and isawtooth. Their normalized-cycle counterparts are sinenorm, squarenorm, trianglenorm, sawtoothnorm, and isawtoothnorm. The normalized forms wrap their phase into \([0,1)\); use the radian forms when the input is already in radians.

auto phase = phasor<float>(0.01f); // 0.01 cycles per sample
univector<float, 256> triangle_wave = trianglenorm(phase);
univector<float, 256> saw_wave = sawtoothnorm(phase);

unitimpulse produces one at index zero and zero afterward, which is useful for measuring impulse responses. jaehne and swept create finite swept-sine test signals with an explicit amplitude and sample count:

univector<float, 1024> impulse = unitimpulse<float>();
univector<float> test_chirp = jaehne<float>(0.5f, 48000);
univector<float> test_sweep = swept<float>(0.5f, 48000);

These APIs generate mathematical waveforms and test signals. The headers do not establish an anti-aliasing strategy such as BLEP/BLAMP correction or oversampling. In particular, discontinuous square and sawtooth waves, and the corners in triangle waves, can alias; choose a separately designed anti-aliased oscillator when that is a requirement.

Waveshaping and frequency weighting

The waveshaper functions return lazy, memoryless expressions. waveshaper_hardclip clamps samples to a symmetric threshold. waveshaper_tanh applies a normalized hyperbolic-tangent curve, while waveshaper_saturate_I and waveshaper_saturate_II apply normalized Type-I and Type-II saturation curves. saturate_I and saturate_II expose the underlying odd-symmetric bounded curves directly.

audio = waveshaper_hardclip(audio, 0.8);
audio = waveshaper_tanh(audio, 2.5);
audio = waveshaper_saturate_I(audio, 3.0);

waveshaper_poly evaluates an odd polynomial \(c_1x+c_3x^3+c_5x^5+\ldots\), allowing a custom memoryless curve:

// x - 0.25x³ + 0.05x⁵
audio = waveshaper_poly(audio, 1.0, -0.25, 0.05);

These simple nonlinearities are useful for experiments and controlled effects, but they are not an analogue-circuit model and do not provide anti-aliasing. Avoid a zero drive value for the normalized tanh and saturation wrappers because their normalization divides by the response at that drive.

aweighting, bweighting, and cweighting evaluate A-, B-, and C-weighting frequency-response gains. Their input is frequency in Hz and their output is a linear magnitude, normalized to one at 1 kHz—not a dB value and not a time-domain weighting filter. Apply them to a matching frequency axis or spectrum magnitude:

univector<float> frequencies = linspace(20.0f, 20000.0f, 512, false, ctrue);
univector<float> a_gain = aweighting(frequencies);
univector<float> weighted_magnitude = magnitude * a_gain;

See also