Last active
May 31, 2026 19:00
-
-
Save ForeverZer0/afea88e8d0f49ea34e872103202aa1b6 to your computer and use it in GitHub Desktop.
An allocation-free QOA (Quite OK Audio Format) decoder that streams signed 16-bit PCM samples (interleaved) into a buffer. (Zig 0.15.1 or higher)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// The MIT License (MIT) | |
| /// | |
| /// Copyright (c) 2026, Eric Freed | |
| /// | |
| /// Permission is hereby granted, free of charge, to any person obtaining a copy | |
| /// of this software and associated documentation files (the "Software"), to deal | |
| /// in the Software without restriction, including without limitation the rights | |
| /// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| /// copies of the Software, and to permit persons to whom the Software is | |
| /// furnished to do so, subject to the following conditions: | |
| /// | |
| /// The above copyright notice and this permission notice shall be included in | |
| /// all copies or substantial portions of the Software. | |
| /// | |
| /// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| /// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| /// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| /// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| /// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| /// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
| /// THE SOFTWARE. | |
| const std = @import("std"); | |
| const Reader = std.Io.Reader; | |
| const Allocator = std.mem.Allocator; | |
| const assert = std.debug.assert; | |
| /// The number of slices in a standard QOA frame. | |
| /// The last frame in the track is an exception, and may contain less. | |
| const slices_per_frame = 256; | |
| /// The number of samples per frame slice. | |
| const samples_per_slice = 20; | |
| /// The maximum number of PCM frames per channel. | |
| const max_samples_per_channel = slices_per_frame * samples_per_slice; // 5120 | |
| /// A table that maps each of the scale factors and quantized | |
| /// residuals to their unscaled & dequantized version. | |
| const dequant_table = [16][8]i32{ | |
| // zig fmt: off | |
| .{ 1, -1, 3, -3, 5, -5, 7, -7}, | |
| .{ 5, -5, 18, -18, 32, -32, 49, -49}, | |
| .{ 16, -16, 53, -53, 95, -95, 147, -147}, | |
| .{ 34, -34, 113, -113, 203, -203, 315, -315}, | |
| .{ 63, -63, 210, -210, 378, -378, 588, -588}, | |
| .{ 104, -104, 345, -345, 621, -621, 966, -966}, | |
| .{ 158, -158, 528, -528, 950, -950, 1477, -1477}, | |
| .{ 228, -228, 760, -760, 1368, -1368, 2128, -2128}, | |
| .{ 316, -316, 1053, -1053, 1895, -1895, 2947, -2947}, | |
| .{ 422, -422, 1405, -1405, 2529, -2529, 3934, -3934}, | |
| .{ 548, -548, 1828, -1828, 3290, -3290, 5117, -5117}, | |
| .{ 696, -696, 2320, -2320, 4176, -4176, 6496, -6496}, | |
| .{ 868, -868, 2893, -2893, 5207, -5207, 8099, -8099}, | |
| .{1064, -1064, 3548, -3548, 6386, -6386, 9933, -9933}, | |
| .{1286, -1286, 4288, -4288, 7718, -7718, 12005, -12005}, | |
| .{1536, -1536, 5120, -5120, 9216, -9216, 14336, -14336}, | |
| // zig fmt: on | |
| }; | |
| /// Allocation-free QOA (Quite OK Audio Format) decoder implementation. | |
| /// Streams decoded PCM samples to a user-supplied buffer. | |
| /// | |
| /// [*] `max_channels`: The maximum number of channels to support (1-8). | |
| /// | |
| /// The size of the struct grows by ~5 kB for each channel. | |
| pub fn QoaDecoder(max_channels: comptime_int) type { | |
| return struct { | |
| const Self = @This(); | |
| /// The number of discrete audio channels multiplexed within the stream. | |
| channels: u8, | |
| /// The playback frequency of the audio stream in frames per second (Hz). | |
| sample_rate: u32, | |
| /// Total number of multichannel PCM frames. | |
| /// Use `frames * channels` for the total number of samples. | |
| frames: u64, | |
| /// Index of the current PCM frame. | |
| current_frame: u64, | |
| /// Reference to the source reader object. | |
| reader: *Reader, | |
| /// A least mean square filter used for sample prediction. | |
| lms: [max_channels]Lms, | |
| /// Internal cache that contains the data for one full QOA frame. | |
| cache: struct { | |
| /// Fixed length buffer containing the interleaved PCM samples. | |
| buffer: [max_samples_per_channel * max_channels]i16 = undefined, | |
| /// The number of samples that can be drained from the cache. | |
| available: usize = 0, | |
| /// The offset into the buffer to begin draining from. | |
| offset: usize = 0, | |
| } = .{}, | |
| /// Error set used by the decoder. | |
| pub const DecodeError = error{ | |
| /// The data was not in the correct format is corrupted. | |
| InvalidData, | |
| } || Reader.Error; | |
| /// Initializes the decoder with the given reader as the audio source. | |
| /// Asserts that the reader has a buffer of at least 8 bytes in size. | |
| /// | |
| /// [*] `reader`: The audio source in QOA format. | |
| pub fn init(reader: *Reader) !Self { | |
| assert(reader.buffer.len >= @sizeOf(u64)); | |
| // qoaf | |
| const magic = 0x716F6166; | |
| if (try reader.takeInt(u32, .big) != magic) { | |
| return error.InvalidData; | |
| } | |
| const frames = try reader.takeInt(u32, .big); | |
| // Peek the first frame header | |
| const value = try reader.peekInt(u64, .big); | |
| const frame_header = try FrameHeader(max_channels).init(value); | |
| return .{ | |
| .frames = frames, | |
| .channels = frame_header.channels, | |
| .sample_rate = frame_header.sample_rate, | |
| .current_frame = 0, | |
| .reader = reader, | |
| .lms = blk: { | |
| var array: [max_channels]Lms = undefined; | |
| for (&array) |*lms| lms.* = .{}; | |
| break :blk array; | |
| }, | |
| }; | |
| } | |
| /// Decodes interleaved signed 16-bit integer PCM samples directly into `samples`. | |
| /// The decoder will fill the buffer up to its capacity based on available stream data. | |
| /// | |
| /// [*] `ptr`: The type erased pointer to the decoder implementation. | |
| /// [*] `num_frames`: The desired number of multi-channel frames to read. | |
| /// [*] `samples`: The destination buffer for the decoded samples. | |
| /// | |
| /// Returns the number of *frames* successfully decoded. | |
| pub fn decode(self: *Self, num_frames: usize, samples: [*]i16) DecodeError!usize { | |
| var remaining_frames = num_frames; | |
| var dest = samples; | |
| while (remaining_frames > 0) { | |
| if (self.cache.available == 0) { | |
| self.cache.offset = 0; | |
| const frame_count = self.decodeFrame() catch |e| { | |
| if (e == error.EndOfStream) break; | |
| return e; | |
| }; | |
| self.cache.available = frame_count * self.channels; | |
| if (self.cache.available == 0) break; | |
| } | |
| const count = @min(self.cache.available, remaining_frames * self.channels); | |
| const source: []i16 = self.cache.buffer[self.cache.offset..][0..count]; | |
| @memcpy(dest, source); | |
| dest += count; | |
| self.cache.offset += count; | |
| self.cache.available -= count; | |
| const read_frames = @divExact(count, self.channels); | |
| remaining_frames -= read_frames; | |
| self.current_frame += read_frames; | |
| } | |
| return num_frames - remaining_frames; | |
| } | |
| /// Decodes a full QOA frame and caches the decoded samples into an internal buffer. | |
| /// Returns the number of PCM frames decoded. | |
| fn decodeFrame(self: *Self) !u16 { | |
| const header = try FrameHeader(max_channels).read(self.reader); | |
| var sample_data = self.cache.buffer[0 .. header.channels * header.samples]; | |
| for (0..header.channels) |ch| { | |
| try self.lms[ch].read(self.reader); | |
| } | |
| var sample_index: u16 = 0; | |
| while (sample_index < header.samples) : (sample_index += samples_per_slice) { | |
| for (0..header.channels) |c| { | |
| var slice = try self.reader.takeInt(u64, .big); | |
| const scalefactor = (slice >> 60) & 0xf; | |
| const slice_start = sample_index * header.channels + c; | |
| const slice_end = @min(sample_index + samples_per_slice, header.samples) * header.channels + c; | |
| var si = slice_start; | |
| while (si < slice_end) : (si += header.channels) { | |
| const predicted = self.lms[c].predict(); | |
| const quantized = (slice >> 57) & 0x7; | |
| const dequantized = dequant_table[@intCast(scalefactor)][@intCast(quantized)]; | |
| const reconstructed: i16 = @intCast(std.math.clamp(predicted + dequantized, std.math.minInt(i16), std.math.maxInt(i16))); | |
| sample_data[si] = reconstructed; | |
| slice <<= 3; | |
| self.lms[c].update(reconstructed, dequantized); | |
| } | |
| } | |
| } | |
| return header.samples; | |
| } | |
| }; | |
| } | |
| /// A least mean square filter, which predicts the next sample based on | |
| /// the previous 4 reconstructed samples. It does so by continuously | |
| /// adjusting 4 weights based on the residual of the previous prediction. | |
| const Lms = struct { | |
| /// The number of previous samples to track. | |
| const len = 4; | |
| /// Array of history values. | |
| history: [len]i32 = @splat(0), | |
| /// Array of weights values. | |
| weights: [len]i32 = @splat(0), | |
| /// Reads and processes the history/weights values from the stream. | |
| /// | |
| /// [*] `stream`: The stream to read from. | |
| fn read(lms: *Lms, stream: *Reader) !void { | |
| for (&lms.history) |*h| { | |
| h.* = try stream.takeInt(i16, .big); | |
| } | |
| for (&lms.weights) |*w| { | |
| w.* = try stream.takeInt(i16, .big); | |
| } | |
| } | |
| /// Computes and returns the next prediction value. | |
| fn predict(lms: Lms) i32 { | |
| var prediction: i32 = 0; | |
| for (lms.weights, lms.history) |w, h| { | |
| prediction += w * h; | |
| } | |
| return prediction >> 13; | |
| } | |
| /// Updates the filter a new sample and residual. | |
| /// | |
| /// [*] `sample`: The value of the sample. | |
| /// [*] `residual`: The residual (difference) value. | |
| fn update(lms: *Lms, sample: i16, residual: i32) void { | |
| const delta = residual >> 4; | |
| for (0..len) |i| { | |
| lms.weights[i] += if (lms.history[i] < 0) -delta else delta; | |
| } | |
| for (0..len - 1) |i| { | |
| lms.history[i] = lms.history[i + 1]; | |
| } | |
| lms.history[len - 1] = sample; | |
| } | |
| }; | |
| /// Describes the per-frame header. | |
| fn FrameHeader(max_channels: comptime_int) type { | |
| return struct { | |
| /// The number of discrete audio channels multiplexed within the stream. | |
| channels: u8, | |
| /// The playback frequency of the audio stream in frames per second (Hz). | |
| sample_rate: u24, | |
| /// The number of samples per channel in this frame. | |
| samples: u16, | |
| /// The size of the frame, including the header. | |
| size: u16, | |
| /// Initializes and validates the frame header from an integer. | |
| /// | |
| /// [*] `value`: The raw value of the header, as an integer. | |
| /// | |
| /// Returns the header, or an error if data is invalid. | |
| pub fn init(value: u64) error{InvalidData}!@This() { | |
| const channels: u8 = @intCast((value >> 56) & 0x0000FF); | |
| const sample_rate: u24 = @intCast((value >> 32) & 0xFFFFFF); | |
| const samples: u16 = @intCast((value >> 16) & 0x00FFFF); | |
| const size: u16 = @intCast((value) & 0x00FFFF); | |
| const data_size = size - @sizeOf(u64) - Lms.len * 4 * channels; | |
| const num_slices = data_size / 8; | |
| const max_total_samples = num_slices * samples_per_slice; | |
| if (channels == 0 or | |
| channels > max_channels or | |
| samples * channels > max_total_samples) | |
| { | |
| return error.InvalidData; | |
| } | |
| return .{ | |
| .channels = channels, | |
| .sample_rate = sample_rate, | |
| .samples = samples, | |
| .size = size, | |
| }; | |
| } | |
| /// Reads and validates the frame header from the stream. | |
| /// | |
| /// [*] `stream`: The stream to read from. | |
| /// | |
| /// Returns the header, or an error if data is invalid. | |
| pub fn read(reader: *Reader) !@This() { | |
| const value = try reader.takeInt(u64, .big); | |
| return .init(value); | |
| } | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment