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:
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:
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:
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:
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:
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;