Something went wrong. Try again.
A sans-IO RIFF/WAVE reader and writer for Zig: 8, 16, 24 and 32 bit PCM and 32 and 64 bit float, in and out.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! How samples are laid out in a `data` chunk, and the conversion between//! those bytes and the numbers a program would rather work in.//!//! Nothing here reads or writes a stream: `encode` and `decode` are slice to//! slice, so the same code serves a file, a socket and a buffer somebody else//! owns.
const std = @import("std");
/// The `wFormatTag` of integer PCM.pub const format_pcm: u16 = 0x0001;/// The `wFormatTag` of IEEE floating point PCM.pub const format_ieee_float: u16 = 0x0003;/// The `wFormatTag` that says the real one is in the extension's sub-format/// GUID, which is where a file with more than two channels, or more than/// sixteen bits, is supposed to put it.pub const format_extensible: u16 = 0xFFFE;
/// How samples are laid out in the `data` chunk.////// Every one of these is little-endian, which is what `WAVE` means; the/// big-endian `RIFX` variant is a different container and is not read here.pub const Format = enum { /// 8 bit unsigned, centred on 128. WAVE's only unsigned width, which is a /// historical accident rather than a pattern. u8, /// 16 bit signed. What everything reads. s16, /// 24 bit signed, packed three bytes to a sample with no padding. s24, /// 32 bit signed. s32, /// 32 bit IEEE float, nominally in [-1, 1]. f32, /// 64 bit IEEE float, nominally in [-1, 1]. f64,
pub fn bytesPerSample(f: Format) u8 { return switch (f) { .u8 => 1, .s16 => 2, .s24 => 3, .s32, .f32 => 4, .f64 => 8, }; }
pub fn bitsPerSample(f: Format) u16 { return @as(u16, f.bytesPerSample()) * 8; }
pub fn isFloat(f: Format) bool { return switch (f) { .f32, .f64 => true, else => false, }; }
/// The `wFormatTag` a header carries for this format. pub fn tag(f: Format) u16 { return if (f.isFloat()) format_ieee_float else format_pcm; }
/// The format a `fmt ` chunk describes, or `null` for one this library /// does not decode — A-law, µ-law, ADPCM, and every other compressed /// payload the tag registry names. /// /// `t` is the tag after an extensible header's sub-format GUID has been /// resolved, since the first two bytes of that GUID are the tag the /// header would have carried had it not been extensible. pub fn fromTag(t: u16, bits: u16) ?Format { return switch (t) { format_pcm => switch (bits) { 8 => .u8, 16 => .s16, 24 => .s24, 32 => .s32, else => null, }, format_ieee_float => switch (bits) { 32 => .f32, 64 => .f64, else => null, }, else => null, }; }};
/// The sample types `encode` and `decode` convert to and from.////// `f32` and `f64` are normalized: full scale is ±1, whatever the file's/// width. `i32` is the sample as the file itself holds it — an `.s16` file/// hands back values in [-32768, 32767], and `Format.bitsPerSample` is what/// says so — which is what a lossless path wants, since nothing is scaled and/// nothing is rounded. A float *file* read as `i32`, or written from one, is/// scaled as though it were `.s32`, there being no narrower width in the file/// to take the scale from.pub fn isSampleType(comptime S: type) bool { return S == f32 or S == f64 or S == i32;}
fn assertSampleType(comptime S: type) void { if (!isSampleType(S)) @compileError( "samples are f32, f64 or i32, not " ++ @typeName(S), );}
/// Decode `in`, which holds samples in `format`, into `out`.////// `in` must be exactly `out.len * format.bytesPerSample()` bytes. Returns/// `out`.pub fn decode(comptime S: type, format: Format, in: []const u8, out: []S) []S { comptime assertSampleType(S); std.debug.assert(in.len == out.len * format.bytesPerSample()); switch (format) { .u8 => for (out, 0..) |*o, i| { // Unsigned, centred on 128, so the signed value is 128 below it. o.* = fromInt(S, 8, @as(i32, in[i]) - 128); }, .s16 => for (out, 0..) |*o, i| { o.* = fromInt(S, 16, std.mem.readInt(i16, in[i * 2 ..][0..2], .little)); }, .s24 => for (out, 0..) |*o, i| { // No `readInt(i24, ...)` on a three byte slice, so the three bytes // are assembled and the sign carried down from bit 23. const b = in[i * 3 ..][0..3]; const raw: u32 = @as(u32, b[0]) | (@as(u32, b[1]) << 8) | (@as(u32, b[2]) << 16); o.* = fromInt(S, 24, @as(i32, @bitCast(raw << 8)) >> 8); }, .s32 => for (out, 0..) |*o, i| { o.* = fromInt(S, 32, std.mem.readInt(i32, in[i * 4 ..][0..4], .little)); }, .f32 => for (out, 0..) |*o, i| { o.* = fromFloat(S, @as(f32, @bitCast(std.mem.readInt(u32, in[i * 4 ..][0..4], .little)))); }, .f64 => for (out, 0..) |*o, i| { o.* = fromFloat(S, @as(f64, @bitCast(std.mem.readInt(u64, in[i * 8 ..][0..8], .little)))); }, } return out;}
/// Encode `in` into `out` as samples in `format`.////// `out` must be exactly `in.len * format.bytesPerSample()` bytes. Returns/// `out`.////// Samples outside the format's range are clipped rather than wrapped./// Wrapping a sample that went a hair over full scale turns a moment of/// loudness into a full-scale discontinuity, which is a click; a clipped/// sample is not one.pub fn encode(comptime S: type, format: Format, in: []const S, out: []u8) []u8 { comptime assertSampleType(S); std.debug.assert(out.len == in.len * format.bytesPerSample()); switch (format) { .u8 => for (in, 0..) |s, i| { out[i] = @intCast(toInt(S, 8, s) + 128); }, .s16 => for (in, 0..) |s, i| { std.mem.writeInt(i16, out[i * 2 ..][0..2], @intCast(toInt(S, 16, s)), .little); }, .s24 => for (in, 0..) |s, i| { const v: u32 = @bitCast(toInt(S, 24, s)); out[i * 3 ..][0..3].* = .{ @truncate(v), @truncate(v >> 8), @truncate(v >> 16), }; }, .s32 => for (in, 0..) |s, i| { std.mem.writeInt(i32, out[i * 4 ..][0..4], toInt(S, 32, s), .little); }, .f32 => for (in, 0..) |s, i| { std.mem.writeInt(u32, out[i * 4 ..][0..4], @bitCast(toFloat(f32, S, s)), .little); }, .f64 => for (in, 0..) |s, i| { std.mem.writeInt(u64, out[i * 8 ..][0..8], @bitCast(toFloat(f64, S, s)), .little); }, } return out;}
/// An integer sample of `bits` bits, as `S`.fn fromInt(comptime S: type, comptime bits: u8, value: i32) S { if (S == i32) return value; return @as(S, @floatFromInt(value)) / fullScale(S, bits);}
/// A floating point sample, as `S`.fn fromFloat(comptime S: type, value: anytype) S { if (S == i32) return quantize(32, value); return @floatCast(value);}
/// `S` as an integer sample of `bits` bits, saturating.fn toInt(comptime S: type, comptime bits: u8, sample: S) i32 { if (S == i32) return @intCast(std.math.clamp(@as(i64, sample), minOf(bits), maxOf(bits))); return quantize(bits, sample);}
/// The extremes of a signed integer of `bits` bits, as `i64` because the/// positive one does not fit an `i32` when `bits` is 32.fn minOf(comptime bits: u8) i64 { return -(@as(i64, 1) << (bits - 1));}
fn maxOf(comptime bits: u8) i64 { return (@as(i64, 1) << (bits - 1)) - 1;}
/// `S` as a floating point sample.fn toFloat(comptime F: type, comptime S: type, sample: S) F { if (S == i32) return @as(F, @floatFromInt(sample)) / fullScale(F, 32); return @floatCast(sample);}
/// Full scale for `bits` bits: `2^(bits-1)`, which is the magnitude of the/// *negative* extreme, because that is the value an integer of that many bits/// actually has. Dividing by it maps the negative extreme to exactly −1 and/// the positive extreme to one step short of +1, which is the pair of/// conventions that does not wrap on the way back.fn fullScale(comptime F: type, comptime bits: u8) F { return @floatFromInt(@as(i64, 1) << (bits - 1));}
/// Scale a float sample to a signed integer of `bits` bits, saturating.////// The arithmetic is done in `f64` whatever the sample was, which is exact/// for an `f32` and is what makes the clamp safe: the largest `i32` is not a/// representable `f32`, so clamping a full-scale sample in `f32` rounds it/// *up* past the maximum and `@intFromFloat` then has nowhere to put it.////// A NaN becomes zero rather than an extreme, since `@intFromFloat` on a NaN/// is undefined behaviour and silence is the only sample a value that is not/// a number can honestly become.fn quantize(comptime bits: u8, sample: anytype) i32 { const v: f64 = sample; if (std.math.isNan(v)) return 0; const low: f64 = @floatFromInt(minOf(bits)); const high: f64 = @floatFromInt(maxOf(bits)); const scaled = @round(std.math.clamp(v, -1, 1) * fullScale(f64, bits)); return @intFromFloat(std.math.clamp(scaled, low, high));}
const testing = std.testing;
test "a format describes its own width and tag" { try testing.expectEqual(@as(u8, 1), Format.u8.bytesPerSample()); try testing.expectEqual(@as(u8, 3), Format.s24.bytesPerSample()); try testing.expectEqual(@as(u16, 24), Format.s24.bitsPerSample()); try testing.expectEqual(format_pcm, Format.s24.tag()); try testing.expectEqual(format_ieee_float, Format.f64.tag()); try testing.expect(!Format.s32.isFloat()); try testing.expect(Format.f32.isFloat());}
test "a tag and a width name a format, or nothing" { try testing.expectEqual(Format.u8, Format.fromTag(format_pcm, 8).?); try testing.expectEqual(Format.s32, Format.fromTag(format_pcm, 32).?); try testing.expectEqual(Format.f32, Format.fromTag(format_ieee_float, 32).?); try testing.expectEqual(Format.f64, Format.fromTag(format_ieee_float, 64).?); // A width the tag does not have, a width nothing has, and µ-law. try testing.expectEqual(@as(?Format, null), Format.fromTag(format_ieee_float, 16)); try testing.expectEqual(@as(?Format, null), Format.fromTag(format_pcm, 12)); try testing.expectEqual(@as(?Format, null), Format.fromTag(0x0007, 8));}
test "quantising saturates instead of wrapping" { try testing.expectEqual(@as(i32, -32768), quantize(16, @as(f32, -1.0))); try testing.expectEqual(@as(i32, 32767), quantize(16, @as(f32, 1.0))); try testing.expectEqual(@as(i32, -32768), quantize(16, @as(f32, -4.0))); try testing.expectEqual(@as(i32, 32767), quantize(16, @as(f32, 4.0))); try testing.expectEqual(@as(i32, 0), quantize(16, @as(f32, 0.0))); try testing.expectEqual(@as(i32, -8388608), quantize(24, @as(f32, -1.0))); try testing.expectEqual(@as(i32, 8388607), quantize(24, @as(f32, 1.0))); try testing.expectEqual(@as(i32, 2147483647), quantize(32, @as(f64, 1.0))); // Not a number, and the infinities that clamp before they are scaled. try testing.expectEqual(@as(i32, 0), quantize(16, @as(f32, std.math.nan(f32)))); try testing.expectEqual(@as(i32, 32767), quantize(16, std.math.inf(f32))); try testing.expectEqual(@as(i32, -32768), quantize(16, -std.math.inf(f32)));}
test "floats encode to each integer width at the extremes" { const in = [_]f32{ 0.0, 0.5, -0.5, 1.0, -1.0 };
var eight: [in.len]u8 = undefined; _ = encode(f32, .u8, &in, &eight); try testing.expectEqualSlices(u8, &.{ 128, 192, 64, 255, 0 }, &eight);
var s16: [in.len * 2]u8 = undefined; _ = encode(f32, .s16, &in, &s16); try testing.expectEqual(@as(i16, 16384), std.mem.readInt(i16, s16[2..4], .little)); try testing.expectEqual(@as(i16, -16384), std.mem.readInt(i16, s16[4..6], .little)); try testing.expectEqual(@as(i16, 32767), std.mem.readInt(i16, s16[6..8], .little)); try testing.expectEqual(@as(i16, -32768), std.mem.readInt(i16, s16[8..10], .little));
var s24: [in.len * 3]u8 = undefined; _ = encode(f32, .s24, &in, &s24); try testing.expectEqual(@as(i24, 4194304), std.mem.readInt(i24, s24[3..6], .little)); try testing.expectEqual(@as(i24, -8388608), std.mem.readInt(i24, s24[12..15], .little));
var f: [in.len * 4]u8 = undefined; _ = encode(f32, .f32, &in, &f); try testing.expectEqual(@as(f32, -0.5), @as(f32, @bitCast(std.mem.readInt(u32, f[8..12], .little))));}
test "integer samples keep the file's own width" { // An .s16 file holds i16 values, and reading it as i32 is a widening and // nothing else: no scaling, no rounding, nothing to argue about. const bytes = [_]u8{ 0x00, 0x80, 0xFF, 0x7F, 0x00, 0x00 }; var out: [3]i32 = undefined; _ = decode(i32, .s16, &bytes, &out); try testing.expectEqualSlices(i32, &.{ -32768, 32767, 0 }, &out);
var back: [6]u8 = undefined; _ = encode(i32, .s16, &out, &back); try testing.expectEqualSlices(u8, &bytes, &back);
// And a sample too big for the width clips rather than wrapping. const loud = [_]i32{ 40000, -40000 }; var clipped: [4]u8 = undefined; _ = encode(i32, .s16, &loud, &clipped); try testing.expectEqual(@as(i16, 32767), std.mem.readInt(i16, clipped[0..2], .little)); try testing.expectEqual(@as(i16, -32768), std.mem.readInt(i16, clipped[2..4], .little));}
test "every format decodes its own extremes to the same three numbers" { inline for ([_]Format{ .u8, .s16, .s24, .s32, .f32, .f64 }) |format| { const samples = [_]f64{ -1.0, 0.0, 1.0 }; var bytes: [samples.len * 8]u8 = undefined; const width = format.bytesPerSample(); _ = encode(f64, format, &samples, bytes[0 .. samples.len * width]);
var out: [samples.len]f64 = undefined; _ = decode(f64, format, bytes[0 .. samples.len * width], &out);
try testing.expectEqual(@as(f64, -1.0), out[0]); try testing.expectEqual(@as(f64, 0.0), out[1]); // +1 is one step short of full scale everywhere but the float // formats, which hold it exactly. if (format.isFloat()) { try testing.expectEqual(@as(f64, 1.0), out[2]); } else { const step = 1.0 / @as(f64, @floatFromInt(@as(i64, 1) << @intCast(format.bitsPerSample() - 1))); try testing.expectApproxEqAbs(@as(f64, 1.0), out[2], step); try testing.expect(out[2] < 1.0); } }}
test "an 8 bit file is unsigned and centred on 128" { const bytes = [_]u8{ 0, 128, 255 }; var out: [3]f32 = undefined; _ = decode(f32, .u8, &bytes, &out); try testing.expectEqual(@as(f32, -1.0), out[0]); try testing.expectEqual(@as(f32, 0.0), out[1]); try testing.expect(out[2] > 0.99 and out[2] < 1.0);}
test "decoding what was encoded is a fixed point" { // Every value a 16 bit file can hold, out and back. This is the property // the fuzz target checks over arbitrary bytes; here it is exhaustive. var i: i32 = -32768; while (i <= 32767) : (i += 1) { var bytes: [2]u8 = undefined; std.mem.writeInt(i16, &bytes, @intCast(i), .little);
var as_float: [1]f32 = undefined; _ = decode(f32, .s16, &bytes, &as_float); var again: [2]u8 = undefined; _ = encode(f32, .s16, &as_float, &again); try testing.expectEqualSlices(u8, &bytes, &again); }}