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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A command-line front end to the library: encode a WAVE file, decode a FLAC//! file, describe one, or check one.//!//! Both encoding and decoding write to a file rather than a pipe, because both//! finish by going back to fix up a header. An encoder cannot know a stream's//! MD5, its frame sizes or its length until it has seen all of it, and a WAVE//! header states the size of data that has not been written yet.
const std = @import("std");const Io = std.Io;
const build = @import("build.zig.zon");const flac = @import("flac");
const usage = \\zig-flac -- encode and decode FLAC audio \\ \\Usage: \\ zig-flac encode [options] <input.wav> <output.flac> \\ zig-flac decode [options] <input.flac> <output.wav> \\ zig-flac info <input.flac> \\ zig-flac test <input.flac>... \\ \\Encoding options: \\ -0 .. -8 How hard to look for a good predictor. -0 is \\ quickest, -5 the default, -8 the smallest. \\ --ogg Write an Ogg FLAC stream rather than a native one. \\ Implied by an output name ending .oga or .ogg. \\ --block-size <n> Interchannel samples per frame. \\ --padding <n> Bytes of padding to reserve for later tagging. \\ --seek-points <n> Reserve this many seek points. \\ --tag NAME=VALUE Add a Vorbis comment field. May be repeated. \\ --no-tags Do not write a Vorbis comment block at all. \\ \\Decoding options: \\ --no-verify Do not check the stream's MD5 against the audio. \\ \\Native FLAC and Ogg FLAC are told apart by what is at the front of the \\file, so decode, info and test take either without being told which. \\ \\Other: \\ -h, --help This. \\ --version Print the version and exit. \\;
pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io;
var stderr_buffer: [4096]u8 = undefined; var stderr_writer = Io.File.stderr().writer(io, &stderr_buffer); const stderr = &stderr_writer.interface; defer stderr.flush() catch {};
var stdout_buffer: [4096]u8 = undefined; var stdout_writer = Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_writer.interface; defer stdout.flush() catch {};
var args = init.minimal.args.iterate(); defer args.deinit(); _ = args.skip();
const command = args.next() orelse { try stderr.writeAll(usage); try stderr.flush(); std.process.exit(2); };
if (std.mem.eql(u8, command, "-h") or std.mem.eql(u8, command, "--help")) { try stdout.writeAll(usage); return; } if (std.mem.eql(u8, command, "--version")) { try stdout.print("zig-flac {s}\n", .{build.version}); return; }
if (std.mem.eql(u8, command, "encode")) return encode(gpa, io, stdout, stderr, &args); if (std.mem.eql(u8, command, "decode")) return decode(gpa, io, stdout, stderr, &args); if (std.mem.eql(u8, command, "info")) return info(gpa, io, stdout, stderr, &args); if (std.mem.eql(u8, command, "test")) return check(gpa, io, stdout, stderr, &args);
return fatal(stderr, "'{s}' is not a command; try --help", .{command});}
/// A FLAC stream out of a file, in whichever container it turned out to be in.////// Both containers produce the same frames, so everything downstream of this --/// writing a WAVE file, checking an MD5, listing metadata -- is the same code/// for either. Which one a file holds is decided by what is at the front of it:/// `OggS` is a page, anything else is a native stream, which is also how the/// decoder for a native stream gets to keep stepping over an ID3v2 tag.const Source = union(enum) { native: flac.Decoder, ogg: flac.ogg.Decoder,
/// `page_buffer` is only used by the Ogg side, and has to outlive the /// source either way. fn init( gpa: std.mem.Allocator, input: *Io.Reader, page_buffer: *[flac.ogg.page_buffer_size]u8, options: flac.DecodeOptions, ) !Source { const front = input.peek(4) catch |err| switch (err) { // Too short to be either, so let the native decoder say so. error.EndOfStream => return .{ .native = try .init(gpa, input, options) }, error.ReadFailed => return error.ReadFailed, }; if (std.mem.eql(u8, front, &flac.ogg_capture_pattern)) return .{ .ogg = try .init(gpa, input, page_buffer, .{ .flac = options }) }; return .{ .native = try .init(gpa, input, options) }; }
fn deinit(s: *Source) void { switch (s.*) { inline else => |*d| d.deinit(), } }
fn decoder(s: *Source) *flac.Decoder { return switch (s.*) { .native => |*d| d, .ogg => |*d| &d.flac, }; }
fn next(s: *Source) !?flac.Frame { return switch (s.*) { inline else => |*d| d.next(), }; }
fn finish(s: *Source) !void { return switch (s.*) { inline else => |*d| d.finish(), }; }};
fn fatal(stderr: *Io.Writer, comptime fmt: []const u8, args: anytype) noreturn { stderr.print("zig-flac: " ++ fmt ++ "\n", args) catch {}; stderr.flush() catch {}; std.process.exit(1);}
fn nextValue(stderr: *Io.Writer, args: anytype, name: []const u8) []const u8 { return args.next() orelse fatal(stderr, "{s} wants a value", .{name});}
fn parseCount(stderr: *Io.Writer, name: []const u8, text: []const u8) u32 { return std.fmt.parseInt(u32, text, 10) catch fatal(stderr, "{s} wants a number, got '{s}'", .{ name, text });}
fn encode(gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: *Io.Writer, args: anytype) !void { var options: flac.EncodeOptions = .{}; var tags: std.ArrayList([]const u8) = .empty; defer tags.deinit(gpa); var write_tags = true; var container: ?enum { native, ogg } = null; var padding_given = false; var input_path: ?[]const u8 = null; var output_path: ?[]const u8 = null;
while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--ogg")) { container = .ogg; } else if (arg.len == 2 and arg[0] == '-' and arg[1] >= '0' and arg[1] <= '8') { options.level = @enumFromInt(arg[1] - '0'); } else if (std.mem.eql(u8, arg, "--block-size")) { options.block_size = parseCount(stderr, arg, nextValue(stderr, args, arg)); } else if (std.mem.eql(u8, arg, "--padding")) { options.padding = parseCount(stderr, arg, nextValue(stderr, args, arg)); padding_given = true; } else if (std.mem.eql(u8, arg, "--seek-points")) { options.seek_points = parseCount(stderr, arg, nextValue(stderr, args, arg)); } else if (std.mem.eql(u8, arg, "--tag")) { const tag = nextValue(stderr, args, arg); if (std.mem.indexOfScalar(u8, tag, '=') == null) fatal(stderr, "--tag wants NAME=VALUE, got '{s}'", .{tag}); try tags.append(gpa, tag); } else if (std.mem.eql(u8, arg, "--no-tags")) { write_tags = false; } else if (arg.len > 0 and arg[0] == '-' and arg.len > 1) { fatal(stderr, "unknown option '{s}'", .{arg}); } else if (input_path == null) { input_path = arg; } else if (output_path == null) { output_path = arg; } else { fatal(stderr, "unexpected extra argument '{s}'", .{arg}); } } const from = input_path orelse fatal(stderr, "encode wants an input file", .{}); const to = output_path orelse fatal(stderr, "encode wants an output file", .{}); // An output named .oga or .ogg means an Ogg stream was wanted, whether or // not `--ogg` said so: a native stream under one of those names is a file // nothing will play. const use_ogg = container == .ogg or std.mem.endsWith(u8, to, ".oga") or std.mem.endsWith(u8, to, ".ogg");
const source = Io.Dir.cwd().openFile(io, from, .{}) catch |err| fatal(stderr, "cannot open '{s}': {t}", .{ from, err }); defer source.close(io); var read_buffer: [64 * 1024]u8 = undefined; var source_reader: Io.File.Reader = .init(source, io, &read_buffer);
var wave: flac.wav.Reader = flac.wav.Reader.init(&source_reader.interface) catch |err| fatal(stderr, "'{s}': {t}", .{ from, err });
const destination = Io.Dir.cwd().createFile(io, to, .{}) catch |err| fatal(stderr, "cannot create '{s}': {t}", .{ to, err }); defer destination.close(io); var write_buffer: [64 * 1024]u8 = undefined; var destination_writer: Io.File.Writer = .init(destination, io, &write_buffer);
const vendor = "zig-flac " ++ build.version; const comment: flac.VorbisComment = .{ .vendor = vendor, .fields = tags.items }; const blocks = [_]flac.Block{.{ .body = .{ .vorbis_comment = comment } }}; if (write_tags) options.metadata = &blocks; if (wave.format.total_samples == 0) options.seek_points = 0; // A seek table points at byte offsets in a native stream. Ogg has a granule // position on every page instead, which is both more accurate and already // there, so the mapping has no use for one. if (use_ogg) options.seek_points = 0; // Padding exists so that tags can be rewritten in place. In an Ogg stream a // padding block is a packet inside a page whose checksum covers it, so // growing a tag into it means rewriting pages anyway -- there is nothing for // the padding to buy. A caller who asks for it anyway still gets it. if (use_ogg and !padding_given) options.padding = 0;
const config: flac.Config = .{ .sample_rate = wave.format.sample_rate, .channels = wave.format.channels, .bits_per_sample = wave.format.bits_per_sample, .total_samples = wave.format.total_samples, };
const chunk = try gpa.alloc(i32, 4096 * @as(usize, config.channels)); defer gpa.free(chunk);
var streaminfo: flac.StreamInfo = undefined; var written: u64 = undefined;
if (use_ogg) { // RFC 3533 wants a serial number "created randomly", and the library has // no source of entropy of its own -- it takes a writer rather than an // `Io` -- so it is asked for here. var serial_bytes: [4]u8 = undefined; io.random(&serial_bytes);
var e: flac.ogg.Encoder = flac.ogg.Encoder.init(gpa, &destination_writer.interface, config, .{ .serial_number = std.mem.readInt(u32, &serial_bytes, .little), .flac = options, }) catch |err| fatal(stderr, "'{s}': {t}", .{ to, err }); defer e.deinit();
while (true) { const count = wave.read(chunk) catch |err| fatal(stderr, "'{s}': {t}", .{ from, err }); if (count == 0) break; e.writeInterleaved(chunk[0..count]) catch |err| fatal(stderr, "'{s}': {t}", .{ to, err }); } try e.finish();
// The streaminfo block lives inside the first page, so putting the real // one back means writing that page again -- its checksum covers the // block. The mapping allows leaving the placeholder, which is what a // stream written to a pipe does, but this one can seek. try destination_writer.seekTo(0); try e.writeFirstPage(&destination_writer.interface); try destination_writer.interface.flush();
streaminfo = e.flac.streaminfo; written = e.bytesWritten(); } else { var e: flac.Encoder = flac.Encoder.init(gpa, &destination_writer.interface, config, options) catch |err| fatal(stderr, "'{s}': {t}", .{ to, err }); defer e.deinit();
while (true) { const count = wave.read(chunk) catch |err| fatal(stderr, "'{s}': {t}", .{ from, err }); if (count == 0) break; e.writeInterleaved(chunk[0..count]) catch |err| fatal(stderr, "'{s}': {t}", .{ to, err }); } try e.finish();
// Go back and replace the streaminfo block, and the seek table if one // was reserved, now that both are known. try destination_writer.seekTo(flac.streaminfo_body_offset); try e.streaminfo.write(&destination_writer.interface); if (e.seek_table_offset) |offset| { try destination_writer.seekTo(offset); for (e.seek_points) |point| { try destination_writer.interface.writeInt(u64, point.sample, .big); try destination_writer.interface.writeInt(u64, point.offset, .big); try destination_writer.interface.writeInt(u16, point.frame_samples, .big); } } try destination_writer.interface.flush();
streaminfo = e.streaminfo; written = e.bytesWritten(); }
const raw = streaminfo.total_samples * config.channels * streaminfo.bytesPerSample(); try stdout.print("{s}: {d} samples, {d} Hz, {d} channel{s}, {d}-bit -> {s}{s} ({B:.1}, {d:.1}% of {B:.1})\n", .{ from, streaminfo.total_samples, config.sample_rate, config.channels, if (config.channels == 1) "" else "s", config.bits_per_sample, to, if (use_ogg) " (Ogg)" else "", written, if (raw == 0) 0 else 100 * @as(f64, @floatFromInt(written)) / @as(f64, @floatFromInt(raw)), raw, });}
fn decode(gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: *Io.Writer, args: anytype) !void { var verify = true; var input_path: ?[]const u8 = null; var output_path: ?[]const u8 = null;
while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--no-verify")) { verify = false; } else if (arg.len > 1 and arg[0] == '-') { fatal(stderr, "unknown option '{s}'", .{arg}); } else if (input_path == null) { input_path = arg; } else if (output_path == null) { output_path = arg; } else { fatal(stderr, "unexpected extra argument '{s}'", .{arg}); } } const from = input_path orelse fatal(stderr, "decode wants an input file", .{}); const to = output_path orelse fatal(stderr, "decode wants an output file", .{});
const source = Io.Dir.cwd().openFile(io, from, .{}) catch |err| fatal(stderr, "cannot open '{s}': {t}", .{ from, err }); defer source.close(io); var read_buffer: [64 * 1024]u8 = undefined; var source_reader: Io.File.Reader = .init(source, io, &read_buffer);
const page_buffer = try gpa.create([flac.ogg.page_buffer_size]u8); defer gpa.destroy(page_buffer); var source_stream: Source = Source.init(gpa, &source_reader.interface, page_buffer, .{ .verify_md5 = verify, }) catch |err| fatal(stderr, "'{s}': {t}", .{ from, err }); defer source_stream.deinit(); const d = source_stream.decoder();
if (d.streaminfo.bits_per_sample % 8 != 0) { fatal(stderr, "'{s}' is {d}-bit, which no WAVE file can hold", .{ from, d.streaminfo.bits_per_sample, }); }
const destination = Io.Dir.cwd().createFile(io, to, .{}) catch |err| fatal(stderr, "cannot create '{s}': {t}", .{ to, err }); defer destination.close(io); var write_buffer: [64 * 1024]u8 = undefined; var destination_writer: Io.File.Writer = .init(destination, io, &write_buffer); const out = &destination_writer.interface;
var format: flac.wav.Format = .{ .sample_rate = d.streaminfo.sample_rate, .channels = d.streaminfo.channels, .bits_per_sample = d.streaminfo.bits_per_sample, .total_samples = d.streaminfo.total_samples, }; try flac.wav.writeHeader(out, format);
const chunk = try gpa.alloc(i32, @as(usize, d.streaminfo.max_block_size) * d.streaminfo.channels); defer gpa.free(chunk);
var decoded: u64 = 0; while (source_stream.next() catch |err| fatal(stderr, "'{s}': {t}", .{ from, err })) |frame| { const block = frame.header.block_size; var at: usize = 0; for (0..block) |sample| { for (frame.channels) |channel| { chunk[at] = channel[sample]; at += 1; } } try flac.wav.writeSamples(out, format, chunk[0..at]); decoded += block; } source_stream.finish() catch |err| fatal(stderr, "'{s}': {t}", .{ from, err });
// The header was written before a byte of audio was, so its two size // fields go back in now that the audio is all there. format.total_samples = decoded; const sizes = try flac.wav.sizes(format); try destination_writer.seekTo(flac.wav.riff_size_offset); try out.writeInt(u32, sizes.riff, .little); try destination_writer.seekTo(flac.wav.data_size_offset); try out.writeInt(u32, sizes.data, .little); try out.flush();
try stdout.print("{s}: {d} samples, {d} Hz, {d} channel{s}, {d}-bit -> {s}{s}\n", .{ from, decoded, format.sample_rate, format.channels, if (format.channels == 1) "" else "s", format.bits_per_sample, to, if (verify) " (MD5 verified)" else "", });}
fn info(gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: *Io.Writer, args: anytype) !void { const path = args.next() orelse fatal(stderr, "info wants a file", .{}); if (args.next()) |extra| fatal(stderr, "unexpected extra argument '{s}'", .{extra});
const file = Io.Dir.cwd().openFile(io, path, .{}) catch |err| fatal(stderr, "cannot open '{s}': {t}", .{ path, err }); defer file.close(io); var read_buffer: [64 * 1024]u8 = undefined; var file_reader: Io.File.Reader = .init(file, io, &read_buffer);
// Everything is read through a decoder rather than straight out of the // metadata blocks, because in an Ogg stream those blocks are packets and // finding them is the container's job. const page_buffer = try gpa.create([flac.ogg.page_buffer_size]u8); defer gpa.destroy(page_buffer); var source: Source = Source.init(gpa, &file_reader.interface, page_buffer, .{}) catch |err| fatal(stderr, "'{s}': {t}", .{ path, err }); defer source.deinit();
const blocks = source.decoder().blocks; const streaminfo = blocks[0].body.streaminfo; try stdout.print("{s}\n", .{path}); switch (source) { .native => try stdout.print(" container native FLAC\n", .{}), .ogg => |d| try stdout.print( " container Ogg FLAC, mapping {d}.{d}, serial 0x{x:0>8}\n", .{ d.identification.major, d.identification.minor, d.serial_number }, ), } try stdout.print(" sample rate {d} Hz\n", .{streaminfo.sample_rate}); try stdout.print(" channels {d}\n", .{streaminfo.channels}); try stdout.print(" bits per sample {d}\n", .{streaminfo.bits_per_sample}); try stdout.print(" total samples {d}", .{streaminfo.total_samples}); if (streaminfo.total_samples != 0 and streaminfo.sample_rate != 0) { const seconds = @as(f64, @floatFromInt(streaminfo.total_samples)) / @as(f64, @floatFromInt(streaminfo.sample_rate)); try stdout.print(" ({d:.2} s)", .{seconds}); } try stdout.print("\n block size {d}", .{streaminfo.min_block_size}); if (streaminfo.min_block_size != streaminfo.max_block_size) { try stdout.print(" to {d}", .{streaminfo.max_block_size}); } try stdout.print("\n frame size {d} to {d} bytes\n", .{ streaminfo.min_frame_size, streaminfo.max_frame_size, }); try stdout.print(" MD5 {x}\n", .{&streaminfo.md5});
for (blocks[1..]) |block| switch (block.body) { .streaminfo => {}, .padding => |n| try stdout.print(" padding {d} bytes\n", .{n}), .application => |a| try stdout.print(" application {s}, {d} bytes\n", .{ a.id, a.data.len }), .seek_table => |points| { var real: usize = 0; for (points) |point| real += @intFromBool(!point.isPlaceholder()); try stdout.print(" seek table {d} points ({d} placeholder)\n", .{ points.len, points.len - real, }); }, .vorbis_comment => |comment| { try stdout.print(" vendor {s}\n", .{comment.vendor}); for (comment.fields) |field| try stdout.print(" tag {s}\n", .{field}); }, .cuesheet => |sheet| try stdout.print(" cuesheet {d} tracks{s}\n", .{ sheet.tracks.len, if (sheet.is_cd) ", CD-DA" else "", }), .picture => |picture| try stdout.print(" picture {t}, {s}, {d}x{d}, {d} bytes\n", .{ picture.kind, picture.media_type, picture.width, picture.height, picture.data.len, }), .unknown => |u| try stdout.print(" block type {d} {d} bytes\n", .{ u.block_type, u.data.len }), };}
fn check(gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: *Io.Writer, args: anytype) !void { var any = false; var failed = false; while (args.next()) |path| { any = true; if (checkOne(gpa, io, stdout, path)) |_| {} else |err| { failed = true; try stdout.print("{s}: FAILED ({t})\n", .{ path, err }); } } if (!any) fatal(stderr, "test wants at least one file", .{}); try stdout.flush(); if (failed) std.process.exit(1);}
fn checkOne(gpa: std.mem.Allocator, io: Io, stdout: *Io.Writer, path: []const u8) !void { const file = try Io.Dir.cwd().openFile(io, path, .{}); defer file.close(io); var read_buffer: [64 * 1024]u8 = undefined; var file_reader: Io.File.Reader = .init(file, io, &read_buffer);
const page_buffer = try gpa.create([flac.ogg.page_buffer_size]u8); defer gpa.destroy(page_buffer); var source: Source = try .init(gpa, &file_reader.interface, page_buffer, .{}); defer source.deinit();
var frames: u64 = 0; var samples: u64 = 0; while (try source.next()) |frame| { frames += 1; samples += frame.header.block_size; } try source.finish();
const streaminfo = source.decoder().streaminfo; try stdout.print("{s}: OK, {d} samples in {d} frames, MD5 {s}{s}\n", .{ path, samples, frames, // The mapping lets an Ogg stream leave the MD5 unknown, and a // single-pass encoder does, so saying it was verified would be a lie. if (std.mem.allEqual(u8, &streaminfo.md5, 0)) "not stated" else "verified", if (source == .ogg) " (Ogg)" else "", });}