Something went wrong. Try again.
A FLAC encoder and decoder for Zig, written against RFC 9639, for native FLAC streams and FLAC in Ogg
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! How fast the library encodes and decodes, and where the encoder's time//! goes.//!//! Everything is timed in process and against memory, so what comes out is the//! codec rather than the filesystem: the audio is generated or read once, the//! encoder writes to a buffer, and the decoder reads that buffer back. Each//! measurement is the fastest of several runs, which is the statistic least//! disturbed by whatever else the machine is doing.//!//! ```console//! $ zig build benchmark # generated audio//! $ zig build benchmark -- --file song.wav//! $ zig build benchmark -- --seconds 60 --repeat 5//! $ zig build benchmark -- --ablate # what costs what//! ```//!//! `--ablate` is the interesting one. Rather than sampling a profile, it turns//! one part of the search off at a time and reports what that saved: the//! difference between a run with linear prediction and one without is what//! linear prediction costs, and so on for the stereo decision and the Rice//! partition search. It measures the thing a change would actually alter.//!//! Speeds are given as multiples of real time, since that is the number that//! decides whether a codec is fast enough for what it is being asked to do,//! and in megabytes of PCM per second, which is the one that scales.
const std = @import("std");const flac = @import("flac");
const Io = std.Io;
const usage = \\Usage: zig build benchmark -- [options] \\ \\ --file <path> Read a WAVE file instead of generating audio. \\ --seconds <n> Seconds of audio to generate. Default 30. \\ --repeat <n> Runs per measurement; the fastest is reported. Default 3. \\ --levels <list> Comma-separated levels to measure. Default 0,2,5,8. \\ --ablate Also report what each part of the search costs. \\ --decode Only measure decoding, at level 5. \\;
pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io;
var path: ?[]const u8 = null; var seconds: u32 = 30; var repeat: u32 = 3; var levels_text: []const u8 = "0,2,5,8"; var ablate = false; var decode_only = false;
var args = init.minimal.args.iterate(); defer args.deinit(); _ = args.skip(); while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--file")) { path = args.next() orelse return fail("--file wants a path"); } else if (std.mem.eql(u8, arg, "--seconds")) { seconds = try std.fmt.parseInt(u32, args.next() orelse return fail("--seconds wants a number"), 10); } else if (std.mem.eql(u8, arg, "--repeat")) { repeat = try std.fmt.parseInt(u32, args.next() orelse return fail("--repeat wants a number"), 10); } else if (std.mem.eql(u8, arg, "--levels")) { levels_text = args.next() orelse return fail("--levels wants a list"); } else if (std.mem.eql(u8, arg, "--ablate")) { ablate = true; } else if (std.mem.eql(u8, arg, "--decode")) { decode_only = true; } else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) { std.debug.print("{s}", .{usage}); return; } else { std.debug.print("{s}", .{usage}); return fail("unexpected argument"); } }
var stdout_buffer: [8192]u8 = undefined; var stdout_writer = Io.File.stdout().writer(io, &stdout_buffer); const out = &stdout_writer.interface; defer out.flush() catch {};
const audio = if (path) |p| try loadWave(gpa, io, p) else try generate(gpa, seconds); defer gpa.free(audio.samples);
const interchannel = audio.samples.len / audio.config.channels; const pcm_bytes = audio.samples.len * ((audio.config.bits_per_sample + 7) / 8); const duration: f64 = @as(f64, @floatFromInt(interchannel)) / @as(f64, @floatFromInt(audio.config.sample_rate));
try out.print("{s}\n", .{audio.name}); try out.print(" {d} Hz, {d} channel{s}, {d}-bit, {d} samples ({d:.1} s, {B:.1} of PCM)\n\n", .{ audio.config.sample_rate, audio.config.channels, if (audio.config.channels == 1) "" else "s", audio.config.bits_per_sample, interchannel, duration, pcm_bytes, });
// Room for a stream that compresses to nothing at all, which verbatim // subframes and a metadata block or two can just about manage. const room = pcm_bytes + pcm_bytes / 4 + 65536; const buffer = try gpa.alloc(u8, room); defer gpa.free(buffer);
if (decode_only) { const size = try encodeOnce(gpa, buffer, audio, .{ .level = .@"5", .padding = 0 }); const nanoseconds = try measureDecode(gpa, io, buffer[0..size], repeat, .{}); try out.print("decode: ", .{}); try report(out, nanoseconds, duration, pcm_bytes); return; }
try out.print("{s:<9} {s:>10} {s:>8} {s:>12} {s:>10}\n", .{ "level", "size", "ratio", "encode", "MB/s" }); var it = std.mem.splitScalar(u8, levels_text, ','); while (it.next()) |text| { const number = std.fmt.parseInt(u4, std.mem.trim(u8, text, " "), 10) catch return fail("--levels wants numbers from 0 to 8"); if (number > 8) return fail("--levels wants numbers from 0 to 8"); const level: flac.Level = @fromBackingInt(@intCast(number));
const options: flac.EncodeOptions = .{ .level = level, .padding = 0 }; const size = try encodeOnce(gpa, buffer, audio, options); const nanoseconds = try measureEncode(gpa, io, buffer, audio, options, repeat);
try out.print("-{d:<8} {d:>10} {d:>7.2}% ", .{ number, size, 100 * @as(f64, @floatFromInt(size)) / @as(f64, @floatFromInt(pcm_bytes)), }); try report(out, nanoseconds, duration, pcm_bytes); }
const size = try encodeOnce(gpa, buffer, audio, .{ .level = .@"5", .padding = 0 }); const decode_ns = try measureDecode(gpa, io, buffer[0..size], repeat, .{}); try out.print("\ndecode -5: ", .{}); try report(out, decode_ns, duration, pcm_bytes); const no_md5 = try measureDecode(gpa, io, buffer[0..size], repeat, .{ .verify_md5 = false }); try out.print(" without the MD5 check: ", .{}); try report(out, no_md5, duration, pcm_bytes);
if (ablate) try ablation(gpa, io, out, buffer, audio, repeat, duration, pcm_bytes);}
/// Turn one part of the encoder's search off at a time and report what it was/// costing. Each row is a whole encode with that one thing changed, so the/// difference from the baseline is what that thing takes.fn ablation( gpa: std.mem.Allocator, io: Io, out: *Io.Writer, buffer: []u8, audio: Audio, repeat: u32, duration: f64, pcm_bytes: usize,) !void { const base: flac.EncodeOptions = .{ .level = .@"5", .padding = 0 }; const baseline = try measureEncode(gpa, io, buffer, audio, base, repeat); const baseline_size = try encodeOnce(gpa, buffer, audio, base);
const Case = struct { name: []const u8, options: flac.EncodeOptions }; var cases: [5]Case = .{ .{ .name = "no linear prediction", .options = .{ .level = .@"5", .padding = 0, .max_lpc_order = 0 } }, .{ .name = "no stereo search", .options = .{ .level = .@"5", .padding = 0, .stereo = .independent } }, .{ .name = "one Rice partition", .options = .{ .level = .@"5", .padding = 0, .max_partition_order = 0 } }, .{ .name = "no fixed predictors", .options = .{ .level = .@"5", .padding = 0 } }, .{ .name = "every LPC order", .options = .{ .level = .@"5", .padding = 0, .lpc_orders_costed = 0 } }, }; // There is no option to turn the fixed predictors off, and there should not // be: they are the fallback every other model is measured against. The row // is dropped rather than faked. const measured = cases[0..3] ++ cases[4..5];
try out.print("\nablation, from -5 ({d:.2} s, {d} bytes):\n", .{ @as(f64, @floatFromInt(baseline)) / std.time.ns_per_s, baseline_size, }); for (measured) |case| { const nanoseconds = try measureEncode(gpa, io, buffer, audio, case.options, repeat); const size = try encodeOnce(gpa, buffer, audio, case.options); const saved = @as(f64, @floatFromInt(@as(i64, @intCast(baseline)) - @as(i64, @intCast(nanoseconds)))) / @as(f64, @floatFromInt(baseline)); try out.print(" {s:<22} {d:>10} bytes ({d:>6.2}% size) {d:>6.1}% time ", .{ case.name, size, 100 * (@as(f64, @floatFromInt(size)) - @as(f64, @floatFromInt(baseline_size))) / @as(f64, @floatFromInt(baseline_size)), -100 * saved, }); try report(out, nanoseconds, duration, pcm_bytes); }}
fn report(out: *Io.Writer, nanoseconds: u64, duration: f64, pcm_bytes: usize) !void { const elapsed = @as(f64, @floatFromInt(nanoseconds)) / std.time.ns_per_s; const realtime = duration / elapsed; const throughput = @as(f64, @floatFromInt(pcm_bytes)) / elapsed / (1000 * 1000); try out.print("{d:>8.3} s {d:>8.0}x {d:>8.1} MB/s\n", .{ elapsed, realtime, throughput });}
const Audio = struct { name: []const u8, config: flac.Config, samples: []i32,};
/// A monotonic clock that does not run while the machine is asleep, which is/// the only kind worth measuring against.fn nowNs(io: Io) i128 { return Io.Timestamp.now(io, .awake).nanoseconds;}
fn measureEncode( gpa: std.mem.Allocator, io: Io, buffer: []u8, audio: Audio, options: flac.EncodeOptions, repeat: u32,) !u64 { var best: u64 = std.math.maxInt(u64); for (0..repeat) |_| { const start = nowNs(io); _ = try encodeOnce(gpa, buffer, audio, options); best = @min(best, @as(u64, @intCast(nowNs(io) - start))); } return best;}
fn encodeOnce( gpa: std.mem.Allocator, buffer: []u8, audio: Audio, options: flac.EncodeOptions,) !usize { var out: Io.Writer = .fixed(buffer); var e: flac.Encoder = try .init(gpa, &out, audio.config, options); defer e.deinit(); try e.writeInterleaved(audio.samples); try e.finish();
// Put the finished streaminfo block back, so that what is decoded next is // what a real file would hold. var patched: Io.Writer = .fixed(buffer[flac.streaminfo_body_offset..]); try e.streaminfo.write(&patched); return @intCast(e.bytesWritten());}
fn measureDecode( gpa: std.mem.Allocator, io: Io, stream: []const u8, repeat: u32, options: flac.DecodeOptions,) !u64 { var best: u64 = std.math.maxInt(u64); for (0..repeat) |_| { const start = nowNs(io); var input: Io.Reader = .fixed(stream); var d: flac.Decoder = try .init(gpa, &input, options); defer d.deinit(); var sink: i64 = 0; while (try d.next()) |frame| { // Touch a sample per frame so that nothing above it can be elided. sink +%= frame.channels[0][0]; } try d.finish(); best = @min(best, @as(u64, @intCast(nowNs(io) - start))); std.mem.doNotOptimizeAway(sink); } return best;}
/// Something with a bit of everything in it: a tone a linear predictor can/// follow, an envelope that keeps restarting, a low drone, and a little noise/// that nothing can predict.fn generate(gpa: std.mem.Allocator, seconds: u32) !Audio { const sample_rate = 44100; const count = sample_rate * @as(usize, seconds); const samples = try gpa.alloc(i32, count * 2);
var prng: std.Random.DefaultPrng = .init(0x666c6163); const random = prng.random(); const notes = [_]f64{ 220, 246.94, 261.63, 293.66, 329.63, 392.0, 440.0, 523.25 };
for (0..count) |i| { const t = @as(f64, @floatFromInt(i)) / sample_rate; const beat = @mod(t, 0.5); const note = notes[@as(usize, @intFromFloat(t / 0.5)) % notes.len]; const envelope = @exp(-3 * beat);
var value = 0.40 * @sin(2 * std.math.pi * note * t) * envelope; value += 0.18 * @sin(2 * std.math.pi * note * 2 * t + 2 * @sin(2 * std.math.pi * 5 * t)) * envelope; value += 0.09 * @sin(2 * std.math.pi * note * 3.01 * t) * @exp(-6 * beat); value += 0.05 * @sin(2 * std.math.pi * 55 * t) * @exp(-1.2 * @mod(t, 1.0)); value += 0.004 * (random.float(f64) - 0.5); const other = value * (1 + 0.02 * @sin(2 * std.math.pi * 0.7 * t));
samples[i * 2] = @intFromFloat(std.math.clamp(value, -1, 1) * 32000); samples[i * 2 + 1] = @intFromFloat(std.math.clamp(other, -1, 1) * 32000); }
return .{ .name = "generated audio", .config = .{ .sample_rate = sample_rate, .channels = 2, .bits_per_sample = 16 }, .samples = samples, };}
fn loadWave(gpa: std.mem.Allocator, io: Io, path: []const u8) !Audio { const file = try Io.Dir.cwd().openFile(io, path, .{}); defer file.close(io); var read_buffer: [64 * 1024]u8 = undefined; var reader: Io.File.Reader = .init(file, io, &read_buffer);
var wave: flac.wav.Reader = try .init(&reader.interface); var samples: std.ArrayList(i32) = .empty; errdefer samples.deinit(gpa);
var chunk: [16384]i32 = undefined; while (true) { const count = try wave.read(&chunk); if (count == 0) break; try samples.appendSlice(gpa, chunk[0..count]); }
return .{ .name = path, .config = .{ .sample_rate = wave.format.sample_rate, .channels = wave.format.channels, .bits_per_sample = wave.format.bits_per_sample, }, .samples = try samples.toOwnedSlice(gpa), };}
fn fail(message: []const u8) error{BadUsage} { std.debug.print("benchmark: {s}\n", .{message}); return error.BadUsage;}