Something went wrong. Try again.
A pure Zig PipeWire client library that speaks the native wire protocol to play audio and manage the graph, with no libpipewire and no libc.
Something went wrong. Try again.
6.6 kB · 169 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A single-producer, single-consumer ring of interleaved `f32` audio frames.//!//! The application thread writes and the real-time thread reads, so neither ever//! blocks or allocates. The capacity is rounded up to a power of two so that the//! indices can be masked rather than divided, and the head and tail are free//! running counters — the difference between them is the fill level, which stays//! correct across wraparound.
const std = @import("std");
pub const Ring = struct { /// `frames * channels` samples. samples: []f32, /// Frames of capacity; always a power of two. capacity: u32, channels: u32,
/// Frames written, only advanced by the producer. head: std.atomic.Value(u32) = .init(0), /// Frames read, only advanced by the consumer. tail: std.atomic.Value(u32) = .init(0),
/// Frames the consumer wanted but did not get, for reporting underruns. underruns: std.atomic.Value(u32) = .init(0), /// Set by the first write. Reads before that are not counted as underruns: /// silence played before the producer has offered anything is the stream /// starting up, not the producer falling behind. started: std.atomic.Value(bool) = .init(false),
pub fn init(gpa: std.mem.Allocator, channels: u32, min_frames: u32) !Ring { const capacity = std.math.ceilPowerOfTwo(u32, @max(min_frames, 2)) catch return error.OutOfMemory; const samples = try gpa.alloc(f32, capacity * channels); @memset(samples, 0); return .{ .samples = samples, .capacity = capacity, .channels = channels }; }
pub fn deinit(r: *Ring, gpa: std.mem.Allocator) void { gpa.free(r.samples); }
/// Frames currently readable. pub fn filled(r: *const Ring) u32 { return r.head.load(.acquire) -% r.tail.load(.acquire); }
/// Frames the producer may write without overwriting unread data. pub fn writable(r: *const Ring) u32 { return r.capacity - r.filled(); }
/// Producer side. Copies as many whole frames of `interleaved` as fit and /// returns how many frames that was. pub fn write(r: *Ring, interleaved: []const f32) u32 { const ch = r.channels; const want: u32 = @intCast(interleaved.len / ch); const n = @min(want, r.writable()); if (n == 0) return 0;
const head = r.head.load(.monotonic); const start = head & (r.capacity - 1); const first = @min(n, r.capacity - start);
@memcpy(r.samples[start * ch ..][0 .. first * ch], interleaved[0 .. first * ch]); if (n > first) { @memcpy(r.samples[0 .. (n - first) * ch], interleaved[first * ch ..][0 .. (n - first) * ch]); } // Publish the data before the index that makes it visible. r.head.store(head +% n, .release); r.started.store(true, .release); return n; }
/// Consumer side. De-interleaves up to `frames` into `planes`, one plane per /// channel, zero-filling any shortfall so a starved stream outputs silence /// rather than stale samples. Returns the number of real frames delivered. pub fn readPlanar(r: *Ring, planes: []const []f32, frames: u32) u32 { const ch = r.channels; std.debug.assert(planes.len == ch);
const avail = @min(frames, r.filled()); const tail = r.tail.load(.monotonic);
var i: u32 = 0; while (i < avail) : (i += 1) { const src = ((tail +% i) & (r.capacity - 1)) * ch; for (planes, 0..) |plane, c| plane[i] = r.samples[src + c]; } for (planes) |plane| @memset(plane[avail..frames], 0);
if (avail > 0) r.tail.store(tail +% avail, .release); if (avail < frames and r.started.load(.acquire)) { _ = r.underruns.fetchAdd(frames - avail, .monotonic); } return avail; }};
const testing = std.testing;
test "writes and planar reads round-trip" { var r = try Ring.init(testing.allocator, 2, 8); defer r.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 8), r.capacity); try testing.expectEqual(@as(u32, 8), r.writable());
const in = [_]f32{ 1, -1, 2, -2, 3, -3 }; try testing.expectEqual(@as(u32, 3), r.write(&in)); try testing.expectEqual(@as(u32, 3), r.filled());
var left: [4]f32 = undefined; var right: [4]f32 = undefined; const planes = [_][]f32{ &left, &right }; // Asking for more than is buffered pads with silence and counts an underrun, // now that the producer has written at least once. try testing.expectEqual(@as(u32, 3), r.readPlanar(&planes, 4)); try testing.expectEqualSlices(f32, &.{ 1, 2, 3, 0 }, &left); try testing.expectEqualSlices(f32, &.{ -1, -2, -3, 0 }, &right); try testing.expectEqual(@as(u32, 1), r.underruns.load(.monotonic)); try testing.expectEqual(@as(u32, 0), r.filled());}
test "silence before the first write is not an underrun" { var r = try Ring.init(testing.allocator, 1, 4); defer r.deinit(testing.allocator);
var out: [4]f32 = undefined; const planes = [_][]f32{&out}; try testing.expectEqual(@as(u32, 0), r.readPlanar(&planes, 4)); try testing.expectEqual(@as(u32, 0), r.underruns.load(.monotonic));
_ = r.write(&.{1}); try testing.expectEqual(@as(u32, 1), r.readPlanar(&planes, 4)); try testing.expectEqual(@as(u32, 3), r.underruns.load(.monotonic));}
test "the ring refuses to overwrite unread frames" { var r = try Ring.init(testing.allocator, 1, 4); defer r.deinit(testing.allocator);
try testing.expectEqual(@as(u32, 4), r.write(&.{ 1, 2, 3, 4, 5, 6 })); try testing.expectEqual(@as(u32, 0), r.writable()); try testing.expectEqual(@as(u32, 0), r.write(&.{7}));}
test "reads and writes stay aligned across the wrap point" { var r = try Ring.init(testing.allocator, 1, 4); defer r.deinit(testing.allocator);
var out: [3]f32 = undefined; const planes = [_][]f32{&out};
// Walk the ring around several times; each pass leaves the indices offset // from the start of the buffer. var expected: f32 = 0; for (0..10) |_| { var in: [3]f32 = undefined; for (&in) |*v| { v.* = expected; expected += 1; } try testing.expectEqual(@as(u32, 3), r.write(&in)); try testing.expectEqual(@as(u32, 3), r.readPlanar(&planes, 3)); try testing.expectEqualSlices(f32, &in, &out); }}