From f80859877c2d3094370df131a1842c1420e6c5b2 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 15 Feb 2026 18:40:46 +0200 Subject: [PATCH] make audio system not global --- engine/src/lib/generational_array_list.zig | 489 +++++++++++++++++++++ engine/src/lib/root.zig | 5 +- engine/src/runtime/audio.zig | 429 ++++++++++++++++++ engine/src/runtime/audio/data.zig | 75 ---- engine/src/runtime/audio/mixer.zig | 146 ------ engine/src/runtime/audio/root.zig | 111 ----- engine/src/runtime/audio/store.zig | 106 ----- engine/src/runtime/engine.zig | 36 +- 8 files changed, 941 insertions(+), 456 deletions(-) create mode 100644 engine/src/lib/generational_array_list.zig create mode 100644 engine/src/runtime/audio.zig delete mode 100644 engine/src/runtime/audio/data.zig delete mode 100644 engine/src/runtime/audio/mixer.zig delete mode 100644 engine/src/runtime/audio/root.zig delete mode 100644 engine/src/runtime/audio/store.zig diff --git a/engine/src/lib/generational_array_list.zig b/engine/src/lib/generational_array_list.zig new file mode 100644 index 0000000..41e14f4 --- /dev/null +++ b/engine/src/lib/generational_array_list.zig @@ -0,0 +1,489 @@ +const std = @import("std"); +const tracy = @import("tracy"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// This array provides: +/// - O(1) insertion +/// - O(1) deletion +/// - Unique IDs for every inserted item (assuming that generation doesn't overflow) +pub fn GenerationalArrayList( + Index: type, + Generation: type, + Item: type +) type { + assert(@bitSizeOf(Generation) % 8 == 0); + assert(@bitSizeOf(Index) % 8 == 0); + + return struct { + const Self = @This(); + + items: [*]Item, + generations: [*]Generation, + unused: [*]u8, + + len: u32, + capacity: u32, + + pub const empty = Self{ + .items = &[_]Item{}, + .generations = &[_]Generation{}, + .unused = &[_]u8{}, + .capacity = 0, + .len = 0, + }; + + pub const Id = packed struct { + pub const Int = @Type(.{ + .int = .{ + .bits = @bitSizeOf(Index) + @bitSizeOf(Generation), + .signedness = .unsigned + } + }); + + generation: Generation, + index: Index, + + // TODO: Maybe `Id.Optional` type should be created to ensure .wrap() and .toOptional() + pub const none = Id{ + .generation = std.math.maxInt(Generation), + .index = std.math.maxInt(Index), + }; + + pub fn format(self: Id, writer: *std.Io.Writer) std.Io.Writer.Error!void { + if (self == Id.none) { + try writer.print("Id({s}){{ .none }}", .{ @typeName(Item) }); + } else { + try writer.print("Id({s}){{ {}, {} }}", .{ @typeName(Item), self.index, self.generation }); + } + } + + pub fn asInt(self: Id) Int { + return @bitCast(self); + } + }; + + pub const ItemWithId = struct { + id: Id, + item: *Item, + }; + + pub const Iterator = struct { + array_list: *Self, + index: Index, + + pub fn nextId(self: *Iterator) ?Id { + while (self.index < self.array_list.len) { + const index = self.index; + self.index += 1; + + // TODO: Inline the `byte_index` calculate for better speed. + // Probably not needed. Idk + if (self.array_list.isUnused(index)) { + continue; + } + + return Id{ + .index = @intCast(index), + .generation = self.array_list.generations[index] + }; + } + + return null; + } + + pub fn nextItem(self: *Iterator) ?*Item { + if (self.nextId()) |id| { + return &self.array_list.items[id.index]; + } + + return null; + } + + pub fn next(self: *Iterator) ?ItemWithId { + if (self.nextId()) |id| { + return ItemWithId{ + .id = id, + .item = &self.array_list.items[id.index] + }; + } + + return null; + } + }; + + pub const Metadata = extern struct { + len: u32, + count: u32 + }; + + fn divCeilGeneration(num: u32) u32 { + return std.math.divCeil(u32, num, @bitSizeOf(Generation)) catch unreachable; + } + + fn divFloorGeneration(num: u32) u32 { + return @divFloor(num, @bitSizeOf(Generation)); + } + + pub fn ensureTotalCapacityPrecise(self: *Self, allocator: Allocator, new_capacity: u32) !void { + if (new_capacity > std.math.maxInt(Index)) { + return error.OutOfIndexSpace; + } + + // TODO: Shrinking is not supported + assert(new_capacity >= self.capacity); + + const unused_bit_array_len = divCeilGeneration(self.capacity); + const new_unused_bit_array_len = divCeilGeneration(new_capacity); + + // TODO: Handle allocation failure case + const new_unused = try allocator.realloc(self.unused[0..unused_bit_array_len], new_unused_bit_array_len); + const new_items = try allocator.realloc(self.items[0..self.capacity], new_capacity); + const new_generations = try allocator.realloc(self.generations[0..self.capacity], new_capacity); + + self.unused = new_unused.ptr; + self.items = new_items.ptr; + self.generations = new_generations.ptr; + self.capacity = new_capacity; + } + + fn growCapacity(current: u32, minimum: u32) u32 { + const init_capacity = @as(comptime_int, @max(1, std.atomic.cache_line / @sizeOf(Item))); + + var new = current; + while (true) { + new +|= new / 2 + init_capacity; + if (new >= minimum) { + return new; + } + } + } + + pub fn ensureTotalCapacity(self: *Self, allocator: Allocator, new_capacity: u32) !void { + if (self.capacity >= new_capacity) return; + + const better_capacity = Self.growCapacity(self.capacity, new_capacity); + try self.ensureTotalCapacityPrecise(allocator, better_capacity); + } + + pub fn clearRetainingCapacity(self: *Self) void { + self.count = 0; + self.len = 0; + } + + pub fn ensureUnusedCapacity(self: *Self, allocator: Allocator, unused_capacity: u32) !void { + try self.ensureTotalCapacity(allocator, self.len + unused_capacity); + } + + fn findFirstUnused(self: *Self) ?Index { + for (0..divCeilGeneration(self.len)) |byte_index| { + if (self.unused[byte_index] != 0) { + const found = @ctz(self.unused[byte_index]) + byte_index * @bitSizeOf(Generation); + if (found < self.len) { + return @intCast(found); + } else { + return null; + } + } + } + + return null; + } + + fn markUnused(self: *Self, index: Index, unused: bool) void { + assert(index < self.len); + + const byte_index = divFloorGeneration(index); + const bit_index = @mod(index, @bitSizeOf(Generation)); + const bit_flag = @as(u8, 1) << @intCast(bit_index); + if (unused) { + self.unused[byte_index] |= bit_flag; + } else { + self.unused[byte_index] &= ~bit_flag; + } + } + + fn isUnused(self: *Self, index: Index) bool { + assert(index < self.len); + + const byte_index = divFloorGeneration(index); + const bit_index = @mod(index, @bitSizeOf(Generation)); + const bit_flag = @as(u8, 1) << @intCast(bit_index); + return (self.unused[byte_index] & bit_flag) != 0; + } + + pub fn insertUndefined(self: *Self, allocator: Allocator) !Id { + var unused_index: Index = undefined; + + if (self.findFirstUnused()) |index| { + unused_index = index; + } else { + try self.ensureUnusedCapacity(allocator, 1); + + unused_index = @intCast(self.len); + self.len += 1; + self.generations[unused_index] = 0; + } + + self.markUnused(unused_index, false); + self.count += 1; + + const id = Id{ + .index = @intCast(unused_index), + .generation = self.generations[unused_index] + }; + + assert(id != Id.none); + + return id; + } + + pub fn insert(self: *Self, allocator: Allocator, item: Item) !Id { + const id = try self.insertUndefined(allocator); + + const new_item_ptr = self.getAssumeExists(id); + new_item_ptr.* = item; + + return id; + } + + pub fn exists(self: *Self, id: Id) bool { + if (id.index >= self.len) { + return false; + } + + if (self.isUnused(id.index)) { + return false; + } + + if (self.generations[id.index] != id.generation) { + return false; + } + + return true; + } + + pub fn removeAssumeExists(self: *Self, id: Id) void { + assert(self.exists(id)); + + self.markUnused(id.index, true); + // TODO: Maybe a log should be shown when a wrap-around occurs? + self.generations[id.index] +%= 1; + self.count -= 1; + } + + pub fn remove(self: *Self, id: Id) bool { + if (!self.exists(id)) { + return false; + } + + self.removeAssumeExists(id); + return true; + } + + pub fn getAssumeExists(self: *Self, id: Id) *Item { + assert(self.exists(id)); + + return &self.items[id.index]; + } + + pub fn get(self: *Self, id: Id) ?*Item { + if (self.exists(id)) { + return self.getAssumeExists(id); + } else { + return null; + } + } + + pub fn iterator(self: *Self) Iterator { + return Iterator{ + .array_list = self, + .index = 0 + }; + } + + pub fn deinit(self: *Self, allocator: Allocator) void { + allocator.free(self.unused[0..divCeilGeneration(self.capacity)]); + allocator.free(self.generations[0..self.capacity]); + allocator.free(self.items[0..self.capacity]); + } + + pub fn clone(self: *Self, allocator: Allocator) !Self { + const items = try allocator.dupe(Item, self.items[0..self.capacity]); + errdefer allocator.free(items); + + const generations = try allocator.dupe(Generation, self.generations[0..self.capacity]); + errdefer allocator.free(generations); + + const unused = try allocator.dupe(u8, self.unused[0..divCeilGeneration(self.capacity)]); + errdefer allocator.free(unused); + + return Self{ + .items = items.ptr, + .generations = generations.ptr, + .unused = unused.ptr, + .len = self.len, + .count = self.count, + .capacity = self.capacity + }; + } + + pub fn getMetadata(self: *Self) Metadata { + return Metadata{ + .len = self.len, + .count = self.count + }; + } + + pub fn write(self: *Self, writer: *std.Io.Writer, endian: std.builtin.Endian) !void { + const zone = tracy.beginZone(@src(), .{ .name = "gen array list write" }); + defer zone.end(); + + try writer.writeSliceEndian(Item, self.items[0..self.len], endian); + try writer.writeSliceEndian(Generation, self.generations[0..self.len], endian); + try writer.writeAll(self.unused[0..divCeilGeneration(self.len)]); + } + + pub fn read( + self: *Self, + allocator: Allocator, + reader: *std.Io.Reader, + endian: std.builtin.Endian, + metadata: Metadata + ) !void { + const zone = tracy.beginZone(@src(), .{ .name = "gen array list read" }); + defer zone.end(); + + try self.ensureTotalCapacity(allocator, metadata.len); + + try reader.readSliceEndian(Item, self.items[0..metadata.len], endian); + try reader.readSliceEndian(Generation, self.generations[0..metadata.len], endian); + try reader.readSliceAll(self.unused[0..divCeilGeneration(metadata.len)]); + + self.len = metadata.len; + self.count = metadata.count; + } + }; +} + +const TestArray = GenerationalArrayList(u24, u8, u32); + +test "insert & remove" { + const expect = std.testing.expect; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + const id1 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id1)); + try expect(array_list.remove(id1)); + try expect(!array_list.exists(id1)); + try expect(!array_list.remove(id1)); + + const id2 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id2)); + try expect(!array_list.exists(id1)); + try expect(id1.index == id2.index); +} + +test "generation wrap around" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + // Grow array list so that at least 1 slot exists + const id1 = try array_list.insert(gpa, 10); + array_list.removeAssumeExists(id1); + + // Artificially increase generation count + array_list.generations[id1.index] = std.math.maxInt(@FieldType(TestArray.Id, "generation")); + + // Check if generation wraps around + const id2 = try array_list.insert(gpa, 10); + array_list.removeAssumeExists(id2); + try expectEqual(id1.index, id2.index); + try expectEqual(0, array_list.generations[id1.index]); +} + +test "iterator" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + // Create array which has a hole + const id1 = try array_list.insert(gpa, 1); + const id2 = try array_list.insert(gpa, 2); + const id3 = try array_list.insert(gpa, 3); + + array_list.removeAssumeExists(id2); + + var iter = array_list.iterator(); + try expectEqual( + TestArray.ItemWithId{ + .id = id1, + .item = array_list.getAssumeExists(id1) + }, + iter.next().? + ); + try expectEqual( + TestArray.ItemWithId{ + .id = id3, + .item = array_list.getAssumeExists(id3) + }, + iter.next().? + ); + try expectEqual(null, iter.next()); +} + +test "read & write" { + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list1: TestArray = .empty; + defer array_list1.deinit(gpa); + + var array_list2: TestArray = .empty; + defer array_list2.deinit(gpa); + + const id1 = try array_list1.insert(gpa, 1); + const id2 = try array_list1.insert(gpa, 2); + const id3 = try array_list1.insert(gpa, 3); + + var buffer: [1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + const native_endian = builtin.cpu.arch.endian(); + + try array_list1.write(&writer, native_endian); + + var reader = std.Io.Reader.fixed(writer.buffered()); + try array_list2.read(gpa, &reader, native_endian, array_list1.getMetadata()); + + try expectEqual(array_list1.getAssumeExists(id1).*, array_list2.getAssumeExists(id1).*); + try expectEqual(array_list1.getAssumeExists(id2).*, array_list2.getAssumeExists(id2).*); + try expectEqual(array_list1.getAssumeExists(id3).*, array_list2.getAssumeExists(id3).*); + try expectEqual(array_list1.count, array_list2.count); +} + +test "clear retaining capacity" { + const expect = std.testing.expect; + const expectEqual = std.testing.expectEqual; + const gpa = std.testing.allocator; + + var array_list: TestArray = .empty; + defer array_list.deinit(gpa); + + const id1 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id1)); + array_list.clearRetainingCapacity(); + + const id2 = try array_list.insert(gpa, 10); + try expect(array_list.exists(id2)); + + try expectEqual(id1, id2); +} diff --git a/engine/src/lib/root.zig b/engine/src/lib/root.zig index 8f07b84..0fca00c 100644 --- a/engine/src/lib/root.zig +++ b/engine/src/lib/root.zig @@ -4,6 +4,7 @@ const log = std.log.scoped(.engine); pub const build_options = @import("build_options"); pub const ImGui = @import("./imgui.zig"); pub const Math = @import("./math.zig"); +pub const GenerationalArrayList = @import("./generational_array_list.zig").GenerationalArrayList; const rgb = Math.rgb; const Rect = Math.Rect; @@ -143,7 +144,7 @@ pub const MouseButton = enum { pub const TextureId = enum(u16) { _ }; pub const FontId = enum(u16) { _ }; -pub const AudioId = enum(u16) { _ }; +pub const SoundId = enum(u16) { _ }; pub const Sprite = struct { texture: TextureId, @@ -227,7 +228,7 @@ pub const GraphicsCommand = union(enum) { pub const AudioCommand = union(enum) { pub const Play = struct { - id: AudioId, + id: SoundId, volume: f32 = 1 }; diff --git a/engine/src/runtime/audio.zig b/engine/src/runtime/audio.zig new file mode 100644 index 0000000..13f64ac --- /dev/null +++ b/engine/src/runtime/audio.zig @@ -0,0 +1,429 @@ +const std = @import("std"); +const log = std.log.scoped(.engine); +const assert = std.debug.assert; + +const tracy = @import("tracy"); + +const Lib = @import("lib"); +const Math = Lib.Math; +const STBVorbis = @import("stb_vorbis"); + +const AudioSystem = @This(); + +const sokol = @import("sokol"); +const saudio = sokol.audio; + +const Sound = union(enum) { + nil, + raw: Raw, + vorbis: Vorbis, + + const Raw = struct { + channels: [][*]f32, + sample_count: u32, + sample_rate: u32, + + fn getSampleCount(self: Raw) u32 { + return self.sample_count; + } + + fn getSampleRate(self: Raw) u32 { + return self.sample_rate; + } + + pub fn streamChannel(self: Raw, opts: StreamOptions) []f32 { + assert(opts.sample_rate == self.sample_rate); // TODO: + assert(opts.channel_index < self.channels.len); // TODO: + + const channel = self.channels[opts.channel_index]; + + var memcpy_len: usize = 0; + if (opts.cursor + opts.buffer.len <= self.sample_count) { + memcpy_len = opts.buffer.len; + } else if (opts.cursor < opts.sample_count) { + memcpy_len = opts.sample_count - opts.cursor; + } + + @memcpy(opts.buffer[0..memcpy_len], channel[opts.cursor..][0..memcpy_len]); + return opts.buffer[0..memcpy_len]; + } + }; + + const Vorbis = struct { + alloc_buffer: []u8, + stb_vorbis: STBVorbis, + + fn init(arena: std.mem.Allocator, data: []const u8, temp_vorbis_alloc_buffer: []u8) Vorbis { + const temp_stb_vorbis = try STBVorbis.init(data, temp_vorbis_alloc_buffer); + const alloc_buffer = try arena.alloc(u8, temp_stb_vorbis.getMinimumAllocBufferSize()); + + // This can't fail because `alloc_buffer` is guarenteed to be big enough + // And there can't be a decode error because `temp_stb_vorbis` was successfully initialized + const stb_vorbis = STBVorbis.init(data, alloc_buffer) catch unreachable; + + return Vorbis{ + .alloc_buffer = alloc_buffer, + .stb_vorbis = stb_vorbis + }; + } + + fn getSampleCount(self: Vorbis) u32 { + return self.stb_vorbis.getStreamLengthInSamples(); + } + + fn getSampleRate(self: Vorbis) u32 { + const info = self.stb_vorbis.getInfo(); + return info.sample_rate; + } + + pub fn streamChannel(self: Vorbis, opts: StreamOptions) []f32 { + _ = self; // autofix + _ = opts; // autofix + @panic("TODO"); + } + }; + + const StreamOptions = struct { + buffer: []f32, + cursor: u32, + channel_index: u32, + sample_rate: u32 + }; + + pub fn streamChannel(self: Sound, opts: StreamOptions) []f32 { + return switch (self) { + .nil => opts.buffer[0..0], + .raw => |raw| raw.streamChannel(opts), + .vorbis => |vorbis| vorbis.streamChannel(opts), + }; + } + + pub fn getSampleCount(self: Sound) u32 { + return switch (self) { + .nil => 0, + .raw => |raw| raw.getSampleCount(), + .vorbis => |vorbis| vorbis.getSampleCount() + }; + } + + pub fn getSampleRate(self: Sound) u32 { + return switch (self) { + .nil => 0, + .raw => |raw| raw.getSampleRate(), + .vorbis => |vorbis| vorbis.getSampleRate() + }; + } + + pub fn getDuration(self: Sound) Lib.Nanoseconds { + const sample_count = self.getSampleCount(); + const sample_rate = self.getSampleRate(); + if (sample_rate == 0) { + return 0; + } + + return @as(Lib.Nanoseconds, sample_count) * std.time.ns_per_s / sample_rate; + } +}; + +const CommandRingBuffer = struct { + // TODO: This ring buffer will work in a single producer single consumer configuration + // For my game this will be good enough + + items: []Lib.AudioCommand, + head: std.atomic.Value(usize), + tail: std.atomic.Value(usize), + + fn init(buffer: []Lib.AudioCommand) CommandRingBuffer { + return CommandRingBuffer{ + .items = buffer, + .head = .init(0), + .tail = .init(0), + }; + } + + pub fn push(self: *CommandRingBuffer, command: Lib.AudioCommand) error{OutOfMemory}!void { + const head = self.head.load(.monotonic); + const tail = self.tail.load(.monotonic); + const next_head = @mod(head + 1, self.items.len); + + // A single slot in the .items array will always not be used. + if (next_head == tail) { + return error.OutOfMemory; + } + + self.items[head] = command; + self.head.store(next_head, .monotonic); + } + + pub fn pop(self: *CommandRingBuffer) ?Lib.AudioCommand { + const head = self.head.load(.monotonic); + const tail = self.tail.load(.monotonic); + + if (head == tail) { + return null; + } + + const result = self.items[tail]; + self.tail.store(@mod(tail + 1, self.items.len), .monotonic); + return result; + } +}; + +/// Be mindful when accessing fields on this struct! +/// This data will be accessed by the audio thread which runs +/// independentaly of the main thread +const ThreadState = struct { + instances: std.ArrayList(SoundInstance), + commands: CommandRingBuffer, + temp_channel_buffer: []f32, + + pub const SoundInstance = struct { + sound_id: Lib.SoundId, + volume: f32 = 0, + cursor: u32 = 0, + }; + + pub fn init( + gpa: std.mem.Allocator, + max_instances: usize, + max_commands: usize, + max_frames_per_channel: usize + ) !ThreadState { + var instances: std.ArrayList(SoundInstance) = try .initCapacity(gpa, max_instances); + errdefer instances.deinit(gpa); + + const command_buffer = try gpa.alloc(Lib.AudioCommand, max_commands); + errdefer gpa.free(command_buffer); + + const temp_channel_buffer = try gpa.alloc(f32, max_frames_per_channel); + errdefer gpa.free(temp_channel_buffer); + + return ThreadState{ + .instances = instances, + .commands = .init(command_buffer), + .temp_channel_buffer = temp_channel_buffer + }; + } + + pub fn deinit(self: *ThreadState, gpa: std.mem.Allocator) void { + self.instances.deinit(gpa); + gpa.free(self.commands.items); + gpa.free(self.temp_channel_buffer); + } +}; + +const SoundsList = Lib.GenerationalArrayList(u8, u8, Sound); + +gpa: std.mem.Allocator, +running: std.atomic.Value(bool), +temp_vorbis_alloc_buffer: []u8, + +sounds_arena: std.heap.ArenaAllocator, +sounds: SoundsList, + +thread_state: ThreadState, + +const Options = struct { + allocator: std.mem.Allocator, + logger: saudio.Logger = .{}, + channels: u32 = 1, + max_vorbis_alloc_buffer_size: u32 = 1 * Math.bytes_per_mib, + buffer_frames: u32 = 2048, + + max_instances: u32 = 64, + max_commands: u32 = 64, +}; + +pub fn init(self: *AudioSystem, opts: Options) !void { + const gpa = opts.allocator; + const temp_vorbis_alloc_buffer = try gpa.alloc(u8, opts.max_vorbis_alloc_buffer_size); + errdefer gpa.free(temp_vorbis_alloc_buffer); + + const thread_state = try ThreadState.init(gpa, + opts.max_instances, + opts.max_instances, + opts.buffer_frames + ); + errdefer thread_state.deinit(gpa); + + self.* = AudioSystem{ + .gpa = gpa, + .running = .init(false), + .temp_vorbis_alloc_buffer = temp_vorbis_alloc_buffer, + .sounds = .empty, + .sounds_arena = std.heap.ArenaAllocator.init(gpa), + .thread_state = thread_state, + }; + + saudio.setup(.{ + .stream_userdata_cb = sokolStreamCallback, + .user_data = self, + .logger = opts.logger, + .num_channels = @intCast(opts.channels), + .buffer_frames = @intCast(opts.buffer_frames), + }); + self.running.store(true, .seq_cst); + + const sample_rate: f32 = @floatFromInt(saudio.sampleRate()); + const max_latency: f32 = @as(f32, @floatFromInt(opts.buffer_frames)) / sample_rate; + + log.debug("Audio:", .{}); + log.debug("- sample_rate: {}", .{saudio.sampleRate()}); + log.debug("- channels: {}", .{saudio.channels()}); + log.debug("- buffer_frames: {}", .{saudio.bufferFrames()}); + log.debug("- max_latency: {D}", .{@as(u64, @intFromFloat(max_latency * std.time.ns_per_s))}); +} + +pub fn deinit(self: *AudioSystem) void { + self.running.store(false, .seq_cst); + + saudio.shutdown(); + + self.thread_state.deinit(self.gpa); + self.gpa.free(self.temp_vorbis_alloc_buffer); + self.sounds_arena.deinit(); + self.sounds.deinit(self.gpa); +} + +pub const LoadOptions = struct { + const PlaybackStyle = enum { + stream, + decode_once, + + // If the decoded size is less than `stream_threshold`, then .decode_once will by default be used. + const stream_threshold = 10 * Math.bytes_per_mib; + }; + + const Format = enum { + vorbis + }; + + format: Format, + data: []const u8, + playback_style: ?PlaybackStyle = null, +}; + +pub fn load(self: *AudioSystem, opts: LoadOptions) !Lib.SoundId { + _ = self; // autofix + _ = opts; // autofix + @panic("TODO"); + + // const id = self.sounds.items.len; + // try self.sounds.ensureUnusedCapacity(self.gpa, 1); + // + // // const list = std.ArrayList(u32).initBuffer(&.{}); + // // list.unused + // + // const arena_allocator = self.sounds_arena.allocator(); + // + // assert(opts.format == .vorbis); + // // const vorbis = try Sound.Vorbis.init(arena_allocator, opts.data, self.temp_vorbis_alloc_buffer); + // + // const PlaybackStyle = LoadOptions.PlaybackStyle; + // const temp_stb_vorbis = try STBVorbis.init(opts.data, self.temp_vorbis_alloc_buffer); + // const info = temp_stb_vorbis.getInfo(); + // const duration_in_samples = temp_stb_vorbis.getStreamLengthInSamples(); + // const decoded_size = info.channels * duration_in_samples * @sizeOf(f32); + // const stream_threshold = PlaybackStyle.stream_threshold; + // const default_playback_style: PlaybackStyle = if (decoded_size < stream_threshold) .decode_once else .stream; + // + // const playback_style = opts.playback_style orelse default_playback_style; + // if (playback_style == .decode_once) { + // const channels = try arena_allocator.alloc([*]f32, info.channels); + // for (channels) |*channel| { + // channel.* = (try arena_allocator.alloc(f32, duration_in_samples)).ptr; + // } + // const samples_decoded = temp_stb_vorbis.getSamples(channels, duration_in_samples); + // assert(samples_decoded == duration_in_samples); + // + // self.sounds.appendAssumeCapacity(Sound{ + // .raw = .{ + // .channels = channels, + // .sample_count = duration_in_samples, + // .sample_rate = info.sample_rate + // } + // }); + // } else { + // self.sounds.appendAssumeCapacity(Sound{ + // .vorbis = try Sound.Vorbis.init(arena_allocator, opts.data, self.temp_vorbis_alloc_buffer) + // }); + // } + // + // return @enumFromInt(id); +} + +pub fn unload(self: *AudioSystem, id: Lib.SoundId) void { + _ = self; // autofix + _ = id; // autofix + @panic("TODO"); +} + +pub fn get(self: *AudioSystem, id: Lib.SoundId) Sound { + return self.sounds.items[@intFromEnum(id)]; +} + +fn sokolStream(self: *AudioSystem, buffer: [*c]f32, num_frames: i32, num_channels: i32) void { + if (!self.running.load(.seq_cst)) { + return; + } + + const zone = tracy.initZone(@src(), .{ }); + defer zone.deinit(); + + const thread_state = &self.thread_state; + while (thread_state.commands.pop()) |command| { + switch (command) { + .play => |opts| { + const volume = @max(opts.volume, 0); + if (volume == 0) { + log.warn("Attempt to play audio with 0 volume", .{}); + continue; + } + + thread_state.instances.appendBounded(.{ + .sound_id = opts.id, + .volume = volume, + }) catch log.warn("Maximum number of audio instances reached!", .{}); + } + } + } + + assert(num_channels == 1); // TODO: + const sample_rate: u32 = @intCast(saudio.sampleRate()); + + assert(thread_state.temp_channel_buffer.len >= num_frames); + + @memset(buffer[0..@intCast(num_frames * num_channels)], 0); + _ = sample_rate; // autofix + + for (thread_state.instances.items) |*instance| { + _ = instance; // autofix + // const sound = self.sounds + // const audio_data = store.get(instance.data_id); + // const samples = audio_data.streamChannel(self.working_buffer[0..num_frames], instance.cursor, 0, sample_rate); + // for (0.., samples) |i, sample| { + // buffer[i] += sample * instance.volume; + // } + // instance.cursor += @intCast(samples.len); + } + + { + // var i: usize = 0; + // while (i < thread_state.instances.items.len) { + // const instance = thread_state.instances.items[i]; + // const audio_data = store.get(instance.data_id); + // const is_complete = instance.cursor == audio_data.getSampleCount(); + // + // if (is_complete) { + // _ = self.instances.swapRemove(i); + // } else { + // i += 1; + // } + // } + } +} + +fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32, user_data: ?*anyopaque) callconv(.c) void { + const audio: *AudioSystem = @alignCast(@ptrCast(user_data)); + audio.sokolStream(buffer, num_frames, num_channels); +} diff --git a/engine/src/runtime/audio/data.zig b/engine/src/runtime/audio/data.zig deleted file mode 100644 index 8962212..0000000 --- a/engine/src/runtime/audio/data.zig +++ /dev/null @@ -1,75 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const STBVorbis = @import("stb_vorbis"); -const Lib = @import("lib"); - -pub const Data = union(enum) { - raw: struct { - channels: [][*]f32, - sample_count: u32, - sample_rate: u32 - }, - vorbis: struct { - alloc_buffer: []u8, - stb_vorbis: STBVorbis, - }, - - pub fn streamChannel( - self: Data, - buffer: []f32, - cursor: u32, - channel_index: u32, - sample_rate: u32 - ) []f32 { - // var result: std.ArrayList(f32) = .initBuffer(buffer); - switch (self) { - .raw => |opts| { - if (opts.sample_rate == sample_rate) { - assert(channel_index < opts.channels.len); // TODO: - const channel = opts.channels[channel_index]; - - var memcpy_len: usize = 0; - if (cursor + buffer.len <= opts.sample_count) { - memcpy_len = buffer.len; - } else if (cursor < opts.sample_count) { - memcpy_len = opts.sample_count - cursor; - } - - @memcpy(buffer[0..memcpy_len], channel[cursor..][0..memcpy_len]); - return buffer[0..memcpy_len]; - } else { - // const in_sample_rate: f32 = @floatFromInt(opts.sample_rate); - // const out_sample_rate: f32 = @floatFromInt(sample_rate); - // const increment = in_sample_rate / out_sample_rate; - // _ = increment; // autofix - unreachable; - } - }, - .vorbis => |opts| { - _ = opts; // autofix - unreachable; - }, - } - // return result.items; - } - - pub fn getSampleCount(self: Data) u32 { - return switch (self) { - .raw => |opts| opts.sample_count, - .vorbis => |opts| opts.stb_vorbis.getStreamLengthInSamples() - }; - } - - pub fn getSampleRate(self: Data) u32 { - return switch (self) { - .raw => |opts| opts.sample_rate, - .vorbis => |opts| blk: { - const info = opts.stb_vorbis.getInfo(); - break :blk info.sample_rate; - } - }; - } - - pub const Id = Lib.AudioId; -}; diff --git a/engine/src/runtime/audio/mixer.zig b/engine/src/runtime/audio/mixer.zig deleted file mode 100644 index ed70048..0000000 --- a/engine/src/runtime/audio/mixer.zig +++ /dev/null @@ -1,146 +0,0 @@ -const std = @import("std"); -const log = std.log.scoped(.audio); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const sokol = @import("sokol"); -const saudio = sokol.audio; - -const Lib = @import("lib"); -const AudioData = @import("./data.zig").Data; -pub const Store = @import("./store.zig"); - -const Mixer = @This(); - -pub const Instance = struct { - data_id: Lib.AudioId, - volume: f32 = 0, - cursor: u32 = 0, -}; - -pub const Command = Lib.AudioCommand; - -pub const RingBuffer = struct { - // TODO: This ring buffer will work in a single producer single consumer configuration - // For my game this will be good enough - - items: []Command, - head: std.atomic.Value(usize) = .init(0), - tail: std.atomic.Value(usize) = .init(0), - - pub fn push(self: *RingBuffer, command: Command) error{OutOfMemory}!void { - const head = self.head.load(.monotonic); - const tail = self.tail.load(.monotonic); - const next_head = @mod(head + 1, self.items.len); - - // A single slot in the .items array will always not be used. - if (next_head == tail) { - return error.OutOfMemory; - } - - self.items[head] = command; - self.head.store(next_head, .monotonic); - } - - pub fn pop(self: *RingBuffer) ?Command { - const head = self.head.load(.monotonic); - const tail = self.tail.load(.monotonic); - - if (head == tail) { - return null; - } - - const result = self.items[tail]; - self.tail.store(@mod(tail + 1, self.items.len), .monotonic); - return result; - } -}; - -// TODO: Tracks - -instances: std.ArrayList(Instance), -commands: RingBuffer, -working_buffer: []f32, - -pub fn init( - gpa: Allocator, - max_instances: u32, - max_commands: u32, - working_buffer_size: u32 -) !Mixer { - var instances = try std.ArrayList(Instance).initCapacity(gpa, max_instances); - errdefer instances.deinit(gpa); - - const commands = try gpa.alloc(Command, max_commands); - errdefer gpa.free(commands); - - const working_buffer = try gpa.alloc(f32, working_buffer_size); - errdefer gpa.free(working_buffer); - - return Mixer{ - .working_buffer = working_buffer, - .instances = instances, - .commands = .{ - .items = commands - } - }; -} - -pub fn deinit(self: *Mixer, gpa: Allocator) void { - self.instances.deinit(gpa); - gpa.free(self.commands.items); - gpa.free(self.working_buffer); -} - -pub fn queue(self: *Mixer, command: Command) void { - self.commands.push(command) catch log.warn("Maximum number of audio commands reached!", .{}); -} - -pub fn stream(self: *Mixer, store: Store, buffer: []f32, num_frames: u32, num_channels: u32) !void { - while (self.commands.pop()) |command| { - switch (command) { - .play => |opts| { - const volume = @max(opts.volume, 0); - if (volume == 0) { - log.warn("Attempt to play audio with 0 volume", .{}); - continue; - } - - self.instances.appendBounded(.{ - .data_id = opts.id, - .volume = volume, - }) catch log.warn("Maximum number of audio instances reached!", .{}); - } - } - } - - assert(num_channels == 1); // TODO: - const sample_rate: u32 = @intCast(saudio.sampleRate()); - - @memset(buffer, 0); - assert(self.working_buffer.len >= num_frames); - - for (self.instances.items) |*instance| { - const audio_data = store.get(instance.data_id); - const samples = audio_data.streamChannel(self.working_buffer[0..num_frames], instance.cursor, 0, sample_rate); - for (0.., samples) |i, sample| { - buffer[i] += sample * instance.volume; - } - instance.cursor += @intCast(samples.len); - } - - { - var i: usize = 0; - while (i < self.instances.items.len) { - const instance = self.instances.items[i]; - const audio_data = store.get(instance.data_id); - const is_complete = instance.cursor == audio_data.getSampleCount(); - - if (is_complete) { - _ = self.instances.swapRemove(i); - } else { - i += 1; - } - } - } -} diff --git a/engine/src/runtime/audio/root.zig b/engine/src/runtime/audio/root.zig deleted file mode 100644 index 747ef52..0000000 --- a/engine/src/runtime/audio/root.zig +++ /dev/null @@ -1,111 +0,0 @@ -const std = @import("std"); -const log = std.log.scoped(.audio); -const assert = std.debug.assert; - -const tracy = @import("tracy"); - -const Math = @import("lib").Math; -const STBVorbis = @import("stb_vorbis"); - -pub const Data = @import("./data.zig").Data; -pub const Store = @import("./store.zig"); -pub const Mixer = @import("./mixer.zig"); - -pub const Command = Mixer.Command; - -const Nanoseconds = @import("lib").Nanoseconds; -const sokol = @import("sokol"); -const saudio = sokol.audio; - -var stopped: bool = true; -var gpa: std.mem.Allocator = undefined; -var store: Store = undefined; -pub var mixer: Mixer = undefined; - -const Options = struct { - allocator: std.mem.Allocator, - logger: saudio.Logger = .{}, - channels: u32 = 1, - max_vorbis_alloc_buffer_size: u32 = 1 * Math.bytes_per_mib, - buffer_frames: u32 = 2048, - max_instances: u32 = 64 -}; - -pub fn init(opts: Options) !void { - gpa = opts.allocator; - - store = try Store.init(.{ - .allocator = opts.allocator, - .max_vorbis_alloc_buffer_size = opts.max_vorbis_alloc_buffer_size, - }); - - mixer = try Mixer.init(gpa, - opts.max_instances, - opts.max_instances, - opts.buffer_frames - ); - - saudio.setup(.{ - .logger = opts.logger, - .stream_cb = sokolStreamCallback, - .num_channels = @intCast(opts.channels), - .buffer_frames = @intCast(opts.buffer_frames) - }); - stopped = false; - - const sample_rate: f32 = @floatFromInt(saudio.sampleRate()); - const audio_latency: f32 = @as(f32, @floatFromInt(opts.buffer_frames)) / sample_rate; - log.debug("Audio latency = {D}", .{@as(u64, @intFromFloat(audio_latency * std.time.ns_per_s))}); -} - -pub fn deinit() void { - stopped = true; - saudio.shutdown(); - mixer.deinit(gpa); - store.deinit(); -} - -pub fn load(opts: Store.LoadOptions) !Data.Id { - return try store.load(opts); -} - -const Info = struct { - sample_count: u32, - sample_rate: u32, - - pub fn getDuration(self: Info) Nanoseconds { - return @as(Nanoseconds, self.sample_count) * std.time.ns_per_s / self.sample_rate; - } -}; - -pub fn getInfo(id: Data.Id) Info { - const data = store.get(id); - return Info{ - .sample_count = data.getSampleCount(), - .sample_rate = data.getSampleRate(), - }; -} - -fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32) callconv(.c) void { - if (stopped) { - return; - } - - const zone = tracy.initZone(@src(), .{ }); - defer zone.deinit(); - - const num_frames_u32: u32 = @intCast(num_frames); - const num_channels_u32: u32 = @intCast(num_channels); - - mixer.stream( - store, - buffer[0..(num_frames_u32 * num_channels_u32)], - num_frames_u32, - num_channels_u32 - ) catch |e| { - log.err("mixer.stream() failed: {}", .{e}); - if (@errorReturnTrace()) |trace| { - std.debug.dumpStackTrace(trace.*); - } - }; -} diff --git a/engine/src/runtime/audio/store.zig b/engine/src/runtime/audio/store.zig deleted file mode 100644 index 8613e7f..0000000 --- a/engine/src/runtime/audio/store.zig +++ /dev/null @@ -1,106 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; - -const Math = @import("lib").Math; -const STBVorbis = @import("stb_vorbis"); - -const AudioData = @import("./data.zig").Data; - -const Store = @This(); - -arena: std.heap.ArenaAllocator, -list: std.ArrayList(AudioData), -temp_vorbis_alloc_buffer: []u8, - -const Options = struct { - allocator: std.mem.Allocator, - max_vorbis_alloc_buffer_size: u32, -}; - -pub fn init(opts: Options) !Store { - const gpa = opts.allocator; - - const temp_vorbis_alloc_buffer = try gpa.alloc(u8, opts.max_vorbis_alloc_buffer_size); - errdefer gpa.free(temp_vorbis_alloc_buffer); - - return Store{ - .arena = std.heap.ArenaAllocator.init(gpa), - .list = .empty, - .temp_vorbis_alloc_buffer = temp_vorbis_alloc_buffer - }; -} - -pub fn deinit(self: *Store) void { - const gpa = self.arena.child_allocator; - gpa.free(self.temp_vorbis_alloc_buffer); - self.list.deinit(gpa); - self.arena.deinit(); -} - -pub const LoadOptions = struct { - const PlaybackStyle = enum { - stream, - decode_once, - - // If the decoded size is less than `stream_threshold`, then .decode_once will by default be used. - const stream_threshold = 10 * Math.bytes_per_mib; - }; - - const Format = enum { - vorbis - }; - - format: Format, - data: []const u8, - playback_style: ?PlaybackStyle = null, -}; - -pub fn load(self: *Store, opts: LoadOptions) !AudioData.Id { - const gpa = self.arena.child_allocator; - - const id = self.list.items.len; - try self.list.ensureUnusedCapacity(gpa, 1); - - const PlaybackStyle = LoadOptions.PlaybackStyle; - const temp_stb_vorbis = try STBVorbis.init(opts.data, self.temp_vorbis_alloc_buffer); - const info = temp_stb_vorbis.getInfo(); - const duration_in_samples = temp_stb_vorbis.getStreamLengthInSamples(); - const decoded_size = info.channels * duration_in_samples * @sizeOf(f32); - const stream_threshold = PlaybackStyle.stream_threshold; - const default_playback_style: PlaybackStyle = if (decoded_size < stream_threshold) .decode_once else .stream; - - const arena_allocator = self.arena.allocator(); - const playback_style = opts.playback_style orelse default_playback_style; - if (playback_style == .decode_once) { - const channels = try arena_allocator.alloc([*]f32, info.channels); - for (channels) |*channel| { - channel.* = (try arena_allocator.alloc(f32, duration_in_samples)).ptr; - } - const samples_decoded = temp_stb_vorbis.getSamples(channels, duration_in_samples); - assert(samples_decoded == duration_in_samples); - - self.list.appendAssumeCapacity(AudioData{ - .raw = .{ - .channels = channels, - .sample_count = duration_in_samples, - .sample_rate = info.sample_rate - } - }); - } else { - const alloc_buffer = try arena_allocator.alloc(u8, temp_stb_vorbis.getMinimumAllocBufferSize()); - const stb_vorbis = STBVorbis.init(opts.data, alloc_buffer) catch unreachable; - - self.list.appendAssumeCapacity(AudioData{ - .vorbis = .{ - .alloc_buffer = alloc_buffer, - .stb_vorbis = stb_vorbis - } - }); - } - - return @enumFromInt(id); -} - -pub fn get(self: Store, id: AudioData.Id) AudioData { - return self.list.items[@intFromEnum(id)]; -} diff --git a/engine/src/runtime/engine.zig b/engine/src/runtime/engine.zig index 6614861..fab3c2d 100644 --- a/engine/src/runtime/engine.zig +++ b/engine/src/runtime/engine.zig @@ -10,7 +10,7 @@ pub const Input = @import("./input.zig"); const ScreenScalar = @import("./screen_scaler.zig"); pub const imgui = @import("./imgui.zig"); pub const Graphics = @import("./graphics.zig"); -pub const Audio = @import("./audio/root.zig"); +const Audio = @import("./audio.zig"); const tracy = @import("tracy"); const builtin = @import("builtin"); const STBImage = @import("stb_image"); @@ -464,6 +464,7 @@ const Engine = @This(); allocator: std.mem.Allocator, graphics: Graphics, +audio: Audio, game_linking: GameLinking, game_state: GameState, @@ -506,6 +507,7 @@ pub fn run(self: *Engine, opts: RunOptions) !void { self.* = Engine{ .allocator = opts.allocator, .graphics = undefined, + .audio = undefined, .game_state = game_state, .game_linking = game_linking, .show_debug = false, @@ -584,7 +586,7 @@ fn sokolInit(self: *Engine) !void { // } }); - try Audio.init(.{ + try self.audio.init(.{ .allocator = self.allocator, .logger = .{ .func = sokolLogCallback }, }); @@ -601,7 +603,7 @@ fn sokolCleanup(self: *Engine) void { self.queued_events.deinit(self.allocator); self.game_state.runGameDeinit(self.game_linking.callbacks.deinit); self.game_state.deinit(); - Audio.deinit(); + self.audio.deinit(); self.graphics.deinit(self.allocator); self.game_linking.deinit(self.allocator); } @@ -661,9 +663,10 @@ fn sokolFrame(self: *Engine) !void { } } - for (tick_result.audio) |command| { - try Audio.mixer.commands.push(command); - } + // TODO: + // for (tick_result.audio) |command| { + // try self.audio.mixer.commands.push(command); + // } } if (self.restart_game) { @@ -749,10 +752,11 @@ fn showDebugWindow(self: *Engine, frame: *Lib.Frame) !void { imgui.textFmt("Draw commands: {}\n", .{ frame.graphics_commands.items.len, }); - imgui.textFmt("Audio instances: {}/{}\n", .{ - Audio.mixer.instances.items.len, - Audio.mixer.instances.capacity - }); + // TODO: + // imgui.textFmt("Audio instances: {}/{}\n", .{ + // Audio.mixer.instances.items.len, + // Audio.mixer.instances.capacity + // }); } } @@ -924,14 +928,14 @@ fn sokolEventCallback(e_ptr: [*c]const sapp.Event, userdata: ?*anyopaque) callco } } -fn sokolCleanupCallback(userdata: ?*anyopaque) callconv(.c) void { - const engine: *Engine = @alignCast(@ptrCast(userdata)); +fn sokolCleanupCallback(user_data: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(user_data)); engine.sokolCleanup(); } -fn sokolInitCallback(userdata: ?*anyopaque) callconv(.c) void { - const engine: *Engine = @alignCast(@ptrCast(userdata)); +fn sokolInitCallback(user_data: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(user_data)); engine.sokolInit() catch |e| { log.err("sokolInit() failed: {}", .{e}); @@ -942,8 +946,8 @@ fn sokolInitCallback(userdata: ?*anyopaque) callconv(.c) void { }; } -fn sokolFrameCallback(userdata: ?*anyopaque) callconv(.c) void { - const engine: *Engine = @alignCast(@ptrCast(userdata)); +fn sokolFrameCallback(user_data: ?*anyopaque) callconv(.c) void { + const engine: *Engine = @alignCast(@ptrCast(user_data)); engine.sokolFrame() catch |e| { log.err("sokolFrame() failed: {}", .{e});