From 3c0c6d64ca0cfd81d839128c8503678a16d6db79 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sat, 17 Jan 2026 10:45:24 +0200 Subject: [PATCH] add audio subsystem --- README.md | 5 + build.zig | 11 +- build.zig.zon | 4 +- libs/stb/build.zig | 38 ++++ libs/{stb_image => stb}/build.zig.zon | 0 .../src/root.zig => stb/src/stb_image.zig} | 0 libs/{stb_image => stb}/src/stb_image_impl.c | 0 libs/stb/src/stb_vorbis.zig | 170 ++++++++++++++++++ libs/stb/src/stb_vorbis_impl.c | 3 + libs/stb_image/build.zig | 31 ---- src/assets.zig | 22 ++- src/assets/wood01.ogg | Bin 0 -> 8569 bytes src/assets/wood02.ogg | Bin 0 -> 8672 bytes src/assets/wood03.ogg | Bin 0 -> 9832 bytes src/engine/audio/data.zig | 64 +++++++ src/engine/audio/mixer.zig | 131 ++++++++++++++ src/engine/audio/root.zig | 100 +++++++++++ src/engine/audio/store.zig | 106 +++++++++++ src/engine/root.zig | 11 +- src/game.zig | 11 ++ 20 files changed, 662 insertions(+), 45 deletions(-) create mode 100644 libs/stb/build.zig rename libs/{stb_image => stb}/build.zig.zon (100%) rename libs/{stb_image/src/root.zig => stb/src/stb_image.zig} (100%) rename libs/{stb_image => stb}/src/stb_image_impl.c (100%) create mode 100644 libs/stb/src/stb_vorbis.zig create mode 100644 libs/stb/src/stb_vorbis_impl.c delete mode 100644 libs/stb_image/build.zig create mode 100644 src/assets/wood01.ogg create mode 100644 src/assets/wood02.ogg create mode 100644 src/assets/wood03.ogg create mode 100644 src/engine/audio/data.zig create mode 100644 src/engine/audio/mixer.zig create mode 100644 src/engine/audio/root.zig create mode 100644 src/engine/audio/store.zig diff --git a/README.md b/README.md index 54b01d0..51fb2d6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ zig build -Dtarget=x86_64-windows ## TODO * Use [Skribidi](https://github.com/memononen/Skribidi) instead of fontstash for text rendering +* Support for audio formats (Might not need all of them, haven't decided): + * QOA, maybe [qoa.h](https://github.com/phoboslab/qoa/blob/master/qoa.h)? + * Flac, maybe [dr_flac.h](https://github.com/mackron/dr_libs/blob/master/dr_flac.h)? + * Wav, maybe [dr_wav.h](https://github.com/mackron/dr_libs/blob/master/dr_wav.h)? + * Mp3, maybe [dr_mp3.h](https://github.com/mackron/dr_libs/blob/master/dr_mp3.h)? * Gamepad support. * WASM Support. Currently a build config isn't provided for this target. * Update build config for other platforms to reduce binary size. All of the video and audio drivers aren't needed, only gamepads diff --git a/build.zig b/build.zig index 3693756..004e8b7 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const cimgui = @import("cimgui"); const sokol = @import("sokol"); const builtin = @import("builtin"); @@ -24,7 +23,6 @@ pub fn build(b: *std.Build) !void { .link_libc = true }); - const cimgui_conf = cimgui.getConfig(false); const dep_sokol = b.dependency("sokol", .{ .target = target, .optimize = optimize, @@ -38,6 +36,8 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, })) |dep_cimgui| { + const cimgui = b.lazyImport(@This(), "cimgui").?; + const cimgui_conf = cimgui.getConfig(false); mod_main.addImport("cimgui", dep_cimgui.module(cimgui_conf.module_name)); dep_sokol.artifact("sokol_clib").addIncludePath(dep_cimgui.path(cimgui_conf.include_dir)); } @@ -57,8 +57,9 @@ pub fn build(b: *std.Build) !void { const dep_tiled = b.dependency("tiled", .{}); mod_main.addImport("tiled", dep_tiled.module("tiled")); - const dep_stb_image = b.dependency("stb_image", .{}); - mod_main.addImport("stb_image", dep_stb_image.module("stb_image")); + const dep_stb = b.dependency("stb", .{}); + mod_main.addImport("stb_image", dep_stb.module("stb_image")); + mod_main.addImport("stb_vorbis", dep_stb.module("stb_vorbis")); const dep_fontstash_c = b.dependency("fontstash_c", .{}); mod_main.addIncludePath(dep_fontstash_c.path("src")); @@ -106,7 +107,6 @@ pub fn build(b: *std.Build) !void { .name = "sokol_template", .mod_main = mod_main, .dep_sokol = dep_sokol, - .cimgui_clib_name = cimgui_conf.clib_name, }); } else { try buildNative(b, "sokol_template", mod_main, has_console); @@ -169,7 +169,6 @@ const BuildWasmOptions = struct { name: []const u8, mod_main: *std.Build.Module, dep_sokol: *std.Build.Dependency, - cimgui_clib_name: []const u8, }; fn patchWasmIncludeDirs( diff --git a/build.zig.zon b/build.zig.zon index 24484e4..0aee37a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -28,8 +28,8 @@ .tiled = .{ .path = "./libs/tiled", }, - .stb_image = .{ - .path = "./libs/stb_image", + .stb = .{ + .path = "./libs/stb", }, // .sdl = .{ // .url = "git+https://github.com/allyourcodebase/SDL3.git#f85824b0db782b7d01c60aaad8bcb537892394e8", diff --git a/libs/stb/build.zig b/libs/stb/build.zig new file mode 100644 index 0000000..963b76f --- /dev/null +++ b/libs/stb/build.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const stb_dependency = b.dependency("stb", .{}); + + { + const mod_stb_image = b.addModule("stb_image", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/stb_image.zig"), + .link_libc = true, + }); + + mod_stb_image.addIncludePath(stb_dependency.path(".")); + mod_stb_image.addCSourceFile(.{ + .file = b.path("src/stb_image_impl.c"), + .flags = &.{} + }); + } + + { + const mod_stb_image = b.addModule("stb_vorbis", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/stb_vorbis.zig"), + .link_libc = true, + }); + + mod_stb_image.addIncludePath(stb_dependency.path(".")); + mod_stb_image.addCSourceFile(.{ + .file = b.path("src/stb_vorbis_impl.c"), + .flags = &.{} + }); + } +} diff --git a/libs/stb_image/build.zig.zon b/libs/stb/build.zig.zon similarity index 100% rename from libs/stb_image/build.zig.zon rename to libs/stb/build.zig.zon diff --git a/libs/stb_image/src/root.zig b/libs/stb/src/stb_image.zig similarity index 100% rename from libs/stb_image/src/root.zig rename to libs/stb/src/stb_image.zig diff --git a/libs/stb_image/src/stb_image_impl.c b/libs/stb/src/stb_image_impl.c similarity index 100% rename from libs/stb_image/src/stb_image_impl.c rename to libs/stb/src/stb_image_impl.c diff --git a/libs/stb/src/stb_vorbis.zig b/libs/stb/src/stb_vorbis.zig new file mode 100644 index 0000000..ea25583 --- /dev/null +++ b/libs/stb/src/stb_vorbis.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const assert = std.debug.assert; +const log = std.log.scoped(.stb_vorbis); + +const c = @cImport({ + @cDefine("STB_VORBIS_NO_INTEGER_CONVERSION", {}); + @cDefine("STB_VORBIS_NO_STDIO", {}); + @cDefine("STB_VORBIS_HEADER_ONLY", {}); + @cInclude("stb_vorbis.c"); +}); + +const STBVorbis = @This(); + +pub const Error = error { + NeedMoreData, + InvalidApiMixing, + OutOfMemory, + FeatureNotSupported, + TooManyChannels, + FileOpenFailure, + SeekWithoutLength, + UnexpectedEof, + SeekInvalid, + InvalidSetup, + InvalidStream, + MissingCapturePattern, + InvalidStreamStructureVersion, + ContinuedPacketFlagInvalid, + IncorrectStreamSerialNumber, + InvalidFirstPage, + BadPacketType, + CantFindLastPage, + SeekFailed, + OggSkeletonNotSupported, + + Unknown +}; + +fn errorFromInt(err: c_int) ?Error { + return switch (err) { + c.VORBIS__no_error => null, + c.VORBIS_need_more_data => Error.NeedMoreData, + c.VORBIS_invalid_api_mixing => Error.InvalidApiMixing, + c.VORBIS_outofmem => Error.OutOfMemory, + c.VORBIS_feature_not_supported => Error.FeatureNotSupported, + c.VORBIS_too_many_channels => Error.TooManyChannels, + c.VORBIS_file_open_failure => Error.FileOpenFailure, + c.VORBIS_seek_without_length => Error.SeekWithoutLength, + c.VORBIS_unexpected_eof => Error.UnexpectedEof, + c.VORBIS_seek_invalid => Error.SeekInvalid, + c.VORBIS_invalid_setup => Error.InvalidSetup, + c.VORBIS_invalid_stream => Error.InvalidStream, + c.VORBIS_missing_capture_pattern => Error.MissingCapturePattern, + c.VORBIS_invalid_stream_structure_version => Error.InvalidStreamStructureVersion, + c.VORBIS_continued_packet_flag_invalid => Error.ContinuedPacketFlagInvalid, + c.VORBIS_incorrect_stream_serial_number => Error.IncorrectStreamSerialNumber, + c.VORBIS_invalid_first_page => Error.InvalidFirstPage, + c.VORBIS_bad_packet_type => Error.BadPacketType, + c.VORBIS_cant_find_last_page => Error.CantFindLastPage, + c.VORBIS_seek_failed => Error.SeekFailed, + c.VORBIS_ogg_skeleton_not_supported => Error.OggSkeletonNotSupported, + else => Error.Unknown + }; +} + +handle: *c.stb_vorbis, + +pub fn init(data: []const u8, alloc_buffer: []u8) Error!STBVorbis { + const stb_vorbis_alloc: c.stb_vorbis_alloc = .{ + .alloc_buffer = alloc_buffer.ptr, + .alloc_buffer_length_in_bytes = @intCast(alloc_buffer.len) + }; + var error_code: c_int = -1; + const handle = c.stb_vorbis_open_memory( + data.ptr, + @intCast(data.len), + &error_code, + &stb_vorbis_alloc + ); + if (handle == null) { + return errorFromInt(error_code) orelse Error.Unknown; + } + + return STBVorbis{ + .handle = handle.? + }; +} + +pub fn getMinimumAllocBufferSize(self: STBVorbis) u32 { + const info = self.getInfo(); + return info.setup_memory_required + @max(info.setup_temp_memory_required, info.temp_memory_required); +} + +fn getLastError(self: STBVorbis) ?Error { + const error_code = c.stb_vorbis_get_error(self.handle); + return errorFromInt(error_code); +} + +pub fn seek(self: STBVorbis, sample_number: u32) !void { + const success = c.stb_vorbis_seek(self.handle, sample_number); + if (success != 1) { + return self.getLastError() orelse Error.Unknown; + } +} + +pub fn getStreamLengthInSamples(self: STBVorbis) u32 { + return c.stb_vorbis_stream_length_in_samples(self.handle); +} + +pub fn getStreamLengthInSeconds(self: STBVorbis) f32 { + return c.stb_vorbis_stream_length_in_seconds(self.handle); +} + +pub fn getSamples( + self: STBVorbis, + channels: []const [*]f32, + max_samples_per_channel: u32 +) u32 { + const samples_per_channel = c.stb_vorbis_get_samples_float( + self.handle, + @intCast(channels.len), + @constCast(@ptrCast(channels.ptr)), + @intCast(max_samples_per_channel) + ); + return @intCast(samples_per_channel); +} + +const Frame = struct { + channels: []const [*c]const f32, + samples_per_channel: u32 +}; + +pub fn getFrame(self: STBVorbis) Frame { + var output: [*c][*c]f32 = null; + var channels: c_int = undefined; + const samples_per_channel = c.stb_vorbis_get_frame_float( + self.handle, + &channels, + &output + ); + return Frame{ + .channels = output[0..@intCast(channels)], + .samples_per_channel = @intCast(samples_per_channel) + }; +} + +pub const Info = struct { + sample_rate: u32, + channels: u32, + + setup_memory_required: u32, + setup_temp_memory_required: u32, + temp_memory_required: u32, + + max_frame_size: u32 +}; + +pub fn getInfo(self: STBVorbis) Info { + const info = c.stb_vorbis_get_info(self.handle); + return Info{ + .sample_rate = info.sample_rate, + .channels = @intCast(info.channels), + + .setup_memory_required = info.setup_memory_required, + .setup_temp_memory_required = info.setup_temp_memory_required, + .temp_memory_required = info.temp_memory_required, + + .max_frame_size = @intCast(info.max_frame_size), + }; +} diff --git a/libs/stb/src/stb_vorbis_impl.c b/libs/stb/src/stb_vorbis_impl.c new file mode 100644 index 0000000..4a53456 --- /dev/null +++ b/libs/stb/src/stb_vorbis_impl.c @@ -0,0 +1,3 @@ +#define STB_VORBIS_NO_INTEGER_CONVERSION +#define STB_VORBIS_NO_STDIO +#include "stb_vorbis.c" diff --git a/libs/stb_image/build.zig b/libs/stb_image/build.zig deleted file mode 100644 index 970e6ca..0000000 --- a/libs/stb_image/build.zig +++ /dev/null @@ -1,31 +0,0 @@ -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const mod = b.addModule("stb_image", .{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/root.zig"), - .link_libc = true, - }); - - const stb_dependency = b.dependency("stb", .{}); - mod.addIncludePath(stb_dependency.path(".")); - mod.addCSourceFile(.{ - .file = b.path("src/stb_image_impl.c"), - .flags = &.{} - }); - - { - const tests = b.addTest(.{ - .root_module = mod - }); - - const run_tests = b.addRunArtifact(tests); - - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&run_tests.step); - } -} diff --git a/src/assets.zig b/src/assets.zig index 010e749..8ea3e31 100644 --- a/src/assets.zig +++ b/src/assets.zig @@ -1,5 +1,9 @@ const std = @import("std"); -const Gfx = @import("./engine/graphics.zig"); + +const Math = @import("./engine/math.zig"); +const Engine = @import("./engine/root.zig"); +const Gfx = Engine.Graphics; +const Audio = Engine.Audio; const Assets = @This(); @@ -13,18 +17,28 @@ const FontName = enum { font_id: FontName.EnumArray, -pub fn init() !Assets { +wood01: Audio.Data.Id, + +pub fn init(gpa: std.mem.Allocator) !Assets { + _ = gpa; // autofix const font_id_array: FontName.EnumArray = .init(.{ .regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")), .bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")), .italic = try Gfx.addFont("italic", @embedFile("assets/roboto-font/Roboto-Italic.ttf")), }); + const wood01 = try Audio.load(.{ + .format = .vorbis, + .data = @embedFile("assets/wood01.ogg"), + }); + return Assets{ - .font_id = font_id_array + .font_id = font_id_array, + .wood01 = wood01 }; } -pub fn deinit(self: *Assets) void { +pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void { _ = self; // autofix + _ = gpa; // autofix } diff --git a/src/assets/wood01.ogg b/src/assets/wood01.ogg new file mode 100644 index 0000000000000000000000000000000000000000..be5a4094b6800ca7aa26754fa8a6148cf2ea587c GIT binary patch literal 8569 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∾vaN&U{{sM*MR@@yYem=1=aQwte#YME}J2#QGHQ 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 |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 const Id = enum (u16) { _ }; +}; diff --git a/src/engine/audio/mixer.zig b/src/engine/audio/mixer.zig new file mode 100644 index 0000000..31b0f89 --- /dev/null +++ b/src/engine/audio/mixer.zig @@ -0,0 +1,131 @@ +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 AudioData = @import("./data.zig").Data; +pub const Store = @import("./store.zig"); + +const Mixer = @This(); + +pub const Instance = struct { + data_id: AudioData.Id, + cursor: u32 = 0, +}; + +pub const Command = union(enum) { + play: struct { + data_id: AudioData.Id, + }, + + 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: Command.RingBuffer, + +pub fn init( + gpa: Allocator, + max_instances: u32, + max_commands: 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); + + return Mixer{ + .instances = instances, + .commands = .{ + .items = commands + } + }; +} + +pub fn deinit(self: *Mixer, gpa: Allocator) void { + self.instances.deinit(gpa); + gpa.free(self.commands.items); +} + +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| { + self.instances.appendBounded(.{ + .data_id = opts.data_id + }) catch log.warn("Maximum number of audio instances reached!", .{}); + } + } + } + + assert(num_channels == 1); // TODO: + const sample_rate: u32 = @intCast(saudio.sampleRate()); + + @memset(buffer, 0); + + // var written: u32 = 0; + for (self.instances.items) |*instance| { + const audio_data = store.get(instance.data_id); + const samples = audio_data.streamChannel(buffer[0..num_frames], instance.cursor, 0, sample_rate); + 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/src/engine/audio/root.zig b/src/engine/audio/root.zig new file mode 100644 index 0000000..c384a4d --- /dev/null +++ b/src/engine/audio/root.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const log = std.log.scoped(.audio); +const assert = std.debug.assert; + +const tracy = @import("tracy"); + +const Math = @import("../math.zig"); +const STBVorbis = @import("stb_vorbis"); + +pub const Data = @import("./data.zig").Data; +pub const Store = @import("./store.zig"); +pub const Mixer = @import("./mixer.zig"); + +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); + + 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); +} + +pub const PlayOptions = struct { + id: Data.Id, + delay: f32 = 0 +}; + +pub fn play(opts: PlayOptions) void { + mixer.queue(.{ + .play = .{ + .data_id = opts.id + } + }); +} + +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/src/engine/audio/store.zig b/src/engine/audio/store.zig new file mode 100644 index 0000000..10e3b13 --- /dev/null +++ b/src/engine/audio/store.zig @@ -0,0 +1,106 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const Math = @import("../math.zig"); +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/src/engine/root.zig b/src/engine/root.zig index e479b60..1a7bfe2 100644 --- a/src/engine/root.zig +++ b/src/engine/root.zig @@ -13,6 +13,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 tracy = @import("tracy"); const builtin = @import("builtin"); const STBImage = @import("stb_image"); @@ -148,7 +149,12 @@ fn sokolInit(self: *Engine) !void { } }); - self.assets = try Assets.init(); + try Audio.init(.{ + .allocator = self.allocator, + .logger = .{ .func = sokolLogCallback }, + }); + + self.assets = try Assets.init(self.allocator); self.game = try Game.init(self.allocator, &self.assets); } @@ -156,8 +162,9 @@ fn sokolCleanup(self: *Engine) void { const zone = tracy.initZone(@src(), .{ }); defer zone.deinit(); + Audio.deinit(); self.game.deinit(); - self.assets.deinit(); + self.assets.deinit(self.allocator); Gfx.deinit(); } diff --git a/src/game.zig b/src/game.zig index 02636a3..a3a1836 100644 --- a/src/game.zig +++ b/src/game.zig @@ -9,6 +9,7 @@ const imgui = Engine.imgui; const Vec2 = Engine.Vec2; const rgb = Engine.Math.rgb; const Gfx = Engine.Graphics; +const Audio = Engine.Audio; const Game = @This(); @@ -48,6 +49,12 @@ pub fn tick(self: *Game, frame: Engine.Frame) !void { } dir = dir.normalized(); + if (dir.x != 0 or dir.y != 0) { + Audio.play(.{ + .id = self.assets.wood01 + }); + } + self.player = self.player.add(dir.multiplyScalar(50 * frame.dt)); const regular_font = self.assets.font_id.get(.regular); @@ -77,4 +84,8 @@ pub fn debug(self: *Game) !void { defer imgui.endWindow(); imgui.text("Hello World!\n"); + imgui.textFmt("Audio: {}/{}\n", .{ + Audio.mixer.instances.items.len, + Audio.mixer.instances.capacity + }); }