From 045617b7636a23a689c9fa65c1b8580798c454fd Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Sun, 16 Aug 2026 19:27:39 +0300 Subject: [PATCH] refactor audio subsystem --- assets/impact-sounds/License.txt | 23 + .../impact-sounds/footstep_concrete_000.ogg | Bin 0 -> 5808 bytes build.zig | 3 +- src/app.zig | 25 +- src/platform/audio.zig | 573 +++++++++++++----- src/platform/imgui.zig | 56 ++ src/platform/root.zig | 1 + src/platform/slot_map.zig | 6 +- 8 files changed, 526 insertions(+), 161 deletions(-) create mode 100644 assets/impact-sounds/License.txt create mode 100644 assets/impact-sounds/footstep_concrete_000.ogg diff --git a/assets/impact-sounds/License.txt b/assets/impact-sounds/License.txt new file mode 100644 index 0000000..4890fe5 --- /dev/null +++ b/assets/impact-sounds/License.txt @@ -0,0 +1,23 @@ + + + Impact Sounds (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 19-12-2019 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Request: http://request.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/impact-sounds/footstep_concrete_000.ogg b/assets/impact-sounds/footstep_concrete_000.ogg new file mode 100644 index 0000000000000000000000000000000000000000..163cc56468f1734fe694351e7ff984699afa8b86 GIT binary patch literal 5808 zcmeG=dsx#)wi8}S5D_6fMK)281OiJSP_WX5NF)gaBs`h~(~^)N0YOYai;vyN8w9E$ zA_k-o5`>6UsNiE;FFrs7K@oZQeq*cM)~nlETesc$C1}09{kHe}`rZF_m<%)LoS8Fo z9&^t8;?}NB0A^sW>_|3stS<&+pgd3;*UQB+1p+dQ?3^RnU?M(>ibH0;F33z29Lb}0 z1>5iW`B!C^xoIh1NWqikX86a+*W%a7#K}emcose|fD#xG7!ZUf#%1Toa^+HmG$)UQ zu$m8l5!^&pLSlHdG$%(|5Cs7%<2W2fY+^!q!utFi34X0KM=BTPNhNr30X|BUElnuM z$;-f}%hzY)6BYSuWq9JIO`H7lkglI1FJB^C@1M3ln}o77ZHq)-nT!G$xG*IKKcKw0 zGYJ4Y08%|kwzcUjl3v|HET~sAk={T+MZLOWz$LAUGWe>&Qzec7SO6svThltr${Y(@ z>*8G_Zw`~ihVI9sE8}m6VNZR#EG?()a9dW+7|9DWpKl3e3TRA1366_2;&MW%3v9~V zvJpNR>fG@ z!^XJ=Ydi-Y?uBmUDzT$ukmbusZS``vl<8P_poGT+QM+)gZrn(>`{=l5+*5MAL-2SI zBO#HK1WUf~y^Yf8jrr3XxAE&sQjTupA1z6lDG@#|5xJDk)$h06dX7}%(eWtIyfm!t z9!a~zO?x*?YhGO1X$B;SNbp$I5|XNw)s~*wC2Q!*IMA0p(nB8Uu`!ZBi?Kj<1Da); zw*OVr`E}CYue6LIA3%e$JiEdD>;_K;*Ym8Lj5bPm2tb|Exa0vjHA+D}t5^YRR9@sc zb4esZlG`M~f^5FLr;nNDvG!z9Qk$+#d8|?)igs?BYE>npK z+9@)MMG2R6X~C?tF>!(f@(VKb&_6_KR9hV@?^MaTP%V<*Gzz!?=+8?ugJg|PQR()jf#7J>cRd}vT_q8XRLeTDdKHCAFA zp^Z@Mk@t~pZ8K$vr1Pk*S;g)sTFrI!*5?%(PmTwm*v@FhuNtE%o2*!;s`MEqetJG| zgp4$ur^l8*K6Kh{r40pIF*z7oadq!(lcFn(nWgWY68P%XTsBE>@+hcOykuvDm8;Q7 zO6%prr2Wt<-c;NZ&kG9|kH1kb{I`3&y%((Gfqn@=;Yo=}?`_QH@5#S*q(nHAe`|W% zt?B9yoJ)UgSg$ddb_BMUlp|$>%=VVmwudm!+Uiq-RhFDQ2tI!yZ zY1a*RJwE=s^|j|j$vt6(p2LuPzTr9j6!(?Tn|jEcXQnfTk3)gxF`VC2008|vZK93$ zNIchXk{dk9^-D?&|MwmPv6EEJ1QiN)Hvp~x+}E{T-fU|x-WgUAk%3{IaUWQ-{L9AH zHC1Ihm#$*{ose+e&Z4*eBC7;<`K};yu_AFt^+5?U!mn?TOL)+ZhSwejw73G>vnY3t zxqGxZ^gN`nCIAPA8g)Pd6D66; zG%AcFf4uw#ulQh8+#9@TR1PhX^T#>;XNCVr;CCeeO&nzle@^rkg0Gn}6a~ue(NuWW z6RI+e|7697ZZQtGc}^=9XwVa{yaNWKURxD`4?xZegLSHD7y-qp-z5pVcATbj` zJUsczuHi0v<|_QErGZepcY zW(LCx6uTuN_K*xV*>-it5F6k;otw2^V7`o;`$_Xk5QchcHGc;`-1{g_b zM#9N_p>sI`Gr;I#h7ctQr*})0GC1#e7uh_~G?wVNBk z)a$r`Y(IS+*OIVX#qPlCxLlGYP%aLIKOUx}w9^7Sh1p)3T6{k2p(mY9nQc_q6Z9@H zz3ITEAvum(MS*#8Ec;wNgn8>Fkgl0|>i>re=J@+}*SUJ1(*aHj}YCWQy@`a~b#b!IwtHOw0yXyp%2kGS$j9zzHu#Vz% zAq?H3Qwu6deRrXPAPgQwux1`XuNLg^>vL&XM}S((g0Qq8b}xj%qhyiU#~`eGz$L4i zL2O7tuvrI#Ne>j!-IIc}&faCMRVoD5**mpF%?%vHCBUAhIAHL(OIj!8p`h0O)b}0< zevmi|+QL#vb;qCp8V*)kRqZ#aR*YE!T6id+dAUS*yCyk_TCvU^N~2^D(3ei%m;tst zGhhcv!FMCHNXOWAAU!xVwI+lfGJ-%82w?<=bL$xyskuI;A-sOgk=t*nkKdWtpsp2W zQMkYun$c(B3(XGQeRxW6ph`Z>VjWz$%5An;H^V{`dU?@Oe8ez|y{`~Mvg#OdJpfFu zvb;QFHgx%lcWCs;RZMnF>}q(up^TA`22dD`>A{JNF&N`V6pMFZph61r5d{g0CCt!3 z>nLXA&o)0>-v8)`P0{n(L#yh(>Gmr<`6G+BbmaMr(~;PHmBqJ*EJXNJivvFGf+Fp> zuE8s-+AvWVk6Tz>xG-xnqwJ+eSx>uEPouCey)De^De=${3V}C&R1`F z@2$s?d+j{qc6tRIS|U!2oM+H7UJPHX)EZW`zDye4+~92>Y8hX3>FT;~*SgJb2a6R3 zGhOV3T0_fsd7+X=|5(xCmKj&J@+Lcez4fZt*i|)3UiVaCu+xspDGJ~!aEp}*)8~r+(z)iGx?_;V6E8!okZUZS#46MTU{wp zTjwM$FVV|HH!59Hx4VQaw`6p-&zyQ*MO7(C!rd>a|IGY$g?-ui;9AfKFzl=`boA0ie z|KiTfpH5d#sU~}N=z#Mq5s&&ySxmcEs;zR%-2WdPL2BG?AIEMN z_*Ya^-8)uiVw-q*M=m zGIHioZ|>&;seCez@*#cmt$Q2N_eqXk`OHFC8vfCLEpT>|ahmSuiv(!!BKpdC))&s! zor*PE*|Y6(-E*fSw}(d8seY6%+O?H)ltVZ3*nfSr(S9C|)+m2V!6vT;AE3ZSG#X72 zqc`Hy)14gB($iHiruBJ(W-ga}T8fcc688|cHgsN1C6&r#BAzCM&&WP>g%(5U6r_sV z4UPwR?V71hYwhZ9B!PTq+L|L*J~(*jhy38-ttsi|y??RatK7OJr7QWx%e|kBO8kE8_Yf!vPD<915~IaP``ZIBosOWS++E z<6MlC?_c%=pK;^NDb04ho>VGoF(fUlwn|DGCyIL+5r5Tv!iZ1NU444&oyZl}!hf38 z{zRI(dcyCtzYF2rvjKN}-xG+l=Ra<(sb_DAuQEef0*{RATer-Q1`Zf%&WX=Uy_DEB z6ILNm=4f^*K wZQ~wf@a?|qKg9a~RsQ1I6Vd6i`F{%8I`d1X<= 0 and a_index < input.len) input[@intCast(a_index)] else input[@intCast(b_index)]; + const b = input[@intCast(b_index)]; + const c = if (c_index >= 0 and c_index < input.len) input[@intCast(c_index)] else input[@intCast(b_index)]; + const d = if (d_index >= 0 and d_index < input.len) input[@intCast(d_index)] else input[@intCast(b_index)]; + + const t = (elapsed * @as(f32, @floatFromInt(input.len))) - @as(f32, @floatFromInt(b_index)); + + const c0 = b; + const c1 = (c - a) * 0.5; + const c2 = a - (b * 2.5) + (c * 2.0) - (d * 0.5); + const c3 = ((b - c) * 1.5) + ((d - a) * 0.5); + output[i] = (((((c3 * t) + c2) * t) + c1) * t) + c0; + } +} + +fn resampleIfNeeded(gpa: Allocator, samples: *[]f32, sample_rate: u32) !void { + const new_sample_count = samples.len * g_sample_rate / sample_rate; + if (new_sample_count != samples.len) { + const new_samples = try gpa.alloc(f32, new_sample_count); + hermiteResample(samples.*, new_samples); + gpa.free(samples.*); + samples.* = new_samples; + } +} + +fn bufferDataToSamples(data: BufferData) !Buffer.Samples { + const self = &g_state; + return switch (data) { + .raw => |raw| { + var samples = try self.gpa.dupe(f32, raw.samples_mono); + errdefer self.gpa.free(samples); + + try resampleIfNeeded(self.gpa, &samples, raw.sample_rate); + + return Buffer.Samples{ + .len = samples.len, + .left = samples.ptr, + .right = null + }; + }, + .vorbis => |vorbis_data| { + const vorbis = try STBVorbis.init(vorbis_data, self.vorbis_alloc_buffer); + const sample_count = vorbis.getStreamLengthInSamples(); + + const info = vorbis.getInfo(); + if (info.channels == 1) { + var samples = try self.gpa.alloc(f32, sample_count); + errdefer self.gpa.free(samples); + + const result = vorbis.getSamples(&.{ samples.ptr }, sample_count); + assert(result == sample_count); + + try resampleIfNeeded(self.gpa, &samples, info.sample_rate); + + return .{ + .len = samples.len, + .left = samples.ptr, + .right = null + }; + } else if (info.channels >= 2) { + var left_samples = try self.gpa.alloc(f32, sample_count); + errdefer self.gpa.free(left_samples); + + var right_samples = try self.gpa.alloc(f32, sample_count); + errdefer self.gpa.free(right_samples); + + const result = vorbis.getSamples(&.{ left_samples.ptr, right_samples.ptr }, sample_count); + assert(result == sample_count); + + try resampleIfNeeded(self.gpa, &left_samples, info.sample_rate); + try resampleIfNeeded(self.gpa, &right_samples, info.sample_rate); + + return Buffer.Samples{ + .len = left_samples.len, + .left = left_samples.ptr, + .right = right_samples.ptr + }; + } else { + return Buffer.Samples{ + .len = 0, + .left = &.{}, + .right = null + }; + } + } + }; +} + +pub fn addBuffer(data: BufferData) BufferId { + const self = &g_state; + + if (self.buffers.items.len == self.buffers.capacity) { + log.warn("Max audio buffers reached, limit: {}", .{self.buffers.capacity}); + return .nil; + } + + const samples = bufferDataToSamples(data) catch |e| { + log.err("Failed to add buffer: {}", .{e}); return .nil; }; - var phase_increment: f32 = 0; - if (opts.frequency) |frequency| { - phase_increment = frequency * 2 * std.math.pi / getSampleRate(); - } - - var duration_frames: u32 = 0; - if (opts.duration_ns) |duration_ns| { - duration_frames = @intCast(duration_ns * @as(u64, @intCast(saudio.sampleRate())) / std.time.ns_per_s); - } - - const sound = self.sounds.getAssumeExists(id); - sound.* = Sound{ - .cb = opts.cb, - .userdata = opts.userdata, - .adsr = opts.adsr, - .looping = opts.looping, - .volume = opts.volume, - .playing = opts.playing, - .duration_frames = duration_frames, - .phase_increment = phase_increment + const buffer_id = BufferId{ .index = @intCast(self.buffers.items.len) }; + const buffer = self.buffers.addOneAssumeCapacity(); + buffer.* = Buffer{ + .samples = samples }; - return id; + assert(buffer_id != BufferId.nil); + + return buffer_id; } -pub fn deinitSound(id: Sound.Id) void { - var self = &g_state; +pub fn addBus(opts: Bus) BusId { + const self = &g_state; - if (self.sounds.get(id)) |sound| { - self.mutex.lock(self.io) catch return; - defer self.mutex.unlock(self.io); + if (self.buses.items.len == self.buses.capacity) { + log.warn("Max audio buses reached, limit: {}", .{self.buses.capacity}); + return .nil; + } - _ = sound; // autofix - self.sounds.removeAssumeExists(id); + const bus_id = BusId{ .index = @intCast(self.buses.items.len) }; + const bus = self.buses.addOneAssumeCapacity(); + bus.* = opts; + + if (bus.parent_id != null and bus.parent_id == BusId.nil) { + bus.parent_id = null; + } + + if (bus.parent_id == null and main_bus != BusId.nil) { + bus.parent_id = main_bus; + } + + assert(bus_id != BusId.nil); + + return bus_id; +} + +fn busMultipliedVolume(id: BusId) f32 { + const self = &g_state; + + var result: f32 = 1; + var iter: ?BusId = id; + while (iter) |current| { + const bus = &self.buses.items[current.index]; + result *= bus.volume; + iter = bus.parent_id; + } + return result; +} + +const PlayOptions = struct { + buffer: BufferId, + // TODO: adsr: ?ADSR = null, + volume: f32 = 1, + loop: bool = false, + bus: ?BusId = null +}; + +pub fn play(opts: PlayOptions) SoundId { + const self = &g_state; + + var mutex = &self.thread_state.mutex; + var sounds = &self.thread_state.sounds; + + mutex.lock(self.io) catch |e| { + log.warn("Failed to lock mutex: {}", .{e}); + return .nil; + }; + defer mutex.unlock(self.io); + + const bus = opts.bus orelse main_bus; + if (bus == BusId.nil) { + return .nil; + } + + if (opts.buffer == BufferId.nil) { + return .nil; + } + + if (sounds.unusedCapacity() == 0) { + log.warn("Max sounds reached, limit: {}", .{sounds.slots.capacity}); + return .nil; + } + + const sound_id = sounds.insertAssumeCapacity(); + const sound = sounds.getAssumeExists(sound_id); + sound.* = Sound{ + .cursor = 0, + .volume = opts.volume, + .loop = opts.loop, + .buffer = opts.buffer, + .bus = bus, + }; + + return sound_id; +} + +pub fn stop(id: SoundId) void { + const self = &g_state; + + var mutex = &self.thread_state.mutex; + var sounds = &self.thread_state.sounds; + + mutex.lock(self.io) catch |e| { + log.warn("Failed to lock mutex: {}", .{e}); + return; + }; + defer mutex.unlock(self.io); + + _ = sounds.remove(id); +} + +pub fn getBus(id: BusId) ?*Bus { + const self = &g_state; + + if (id == .nil) { + return null; + } + return &self.buses.items[id.index]; +} + +pub fn showDebug() void { + const self = &g_state; + + if (ImGUI.beginWindow(.{ + .name = "audio", + .size = .init(200, 200) + })) { + defer ImGUI.endWindow(); + + const sounds = &self.thread_state.sounds; + const buses = &self.buses; + + ImGUI.text("Sounds: {}/{}", .{sounds.count(), sounds.slots.capacity}); + + if(ImGUI.beginTable("buses", 3, 0)) { + defer ImGUI.endTable(); + + ImGUI.tableSetupColumn("ID (Label)", 0); + ImGUI.tableSetupColumn("Volume", 0); + ImGUI.tableSetupColumn("Multiplied volume", 0); + ImGUI.tableHeadersRow(); + + for (0.., buses.items) |i, *bus| { + ImGUI.pushID(.{ .int = @intCast(i) }); + defer ImGUI.popID(); + + ImGUI.tableNextRow(); + + ImGUI.tableNextColumn(); + ImGUI.text("{} ({s})", .{i, bus.label orelse "-"}); + + ImGUI.tableNextColumn(); + _ = ImGUI.inputF32Drag(.{ + .label = "", + .value = &bus.volume, + .ex = .{ + .min = 0, + .max = 1, + .speed = 0.01 + } + }); + + ImGUI.tableNextColumn(); + ImGUI.text("{}", .{busMultipliedVolume(.{ .index = @intCast(i) })}); + } + } } } -pub fn setPlaying(id: Sound.Id, playing: bool) void { - var self = &g_state; - - if (self.sounds.get(id)) |sound| { - self.mutex.lock(self.io) catch return; - defer self.mutex.unlock(self.io); - - sound.playing = playing; - } -} - -pub fn getPlaying(id: Sound.Id) bool { - var self = &g_state; - - if (self.sounds.get(id)) |sound| { - return sound.playing; - } else { - return false; - } -} - -fn bumpSoundCursor(sound: *Sound, count: u32) void { - if (sound.duration_frames > 0) { - sound.frame_index += count; - sound.frame_index = @mod(sound.frame_index, sound.duration_frames); - } - - if (sound.phase_increment > 0) { - sound.phase += sound.phase_increment * @as(f32, @floatFromInt(count)); - sound.phase = @mod(sound.phase, 2 * std.math.pi); - } -} - -fn sokolStream(buffer: [*c]f32, num_frames: u32, num_channels: u32) !void { +fn sokolStream(output_buffer: [*c]f32, num_frames: u32, num_channels: u32) !void { const self = &g_state; if (!self.running.load(.seq_cst)) { return; } - try self.mutex.lock(self.io); - defer self.mutex.unlock(self.io); + const mutex = &self.thread_state.mutex; + const sounds = &self.thread_state.sounds; + const buses = self.buses; - assert(num_channels == 1); - assert(self.temp_buffer.len >= num_frames); + try mutex.lock(self.io); + defer mutex.unlock(self.io); - @memset(buffer[0..num_frames], 0); + assert(num_channels == 2); + @memset(output_buffer[0..(num_frames*2)], 0); - var iter = self.sounds.iterator(); - while (iter.next()) |id| { - const sound = self.sounds.getAssumeExists(id); - if (!sound.playing) { - continue; - } + var sound_iter = sounds.iterator(); + while (sound_iter.next()) |sound_id| { + const sound = sounds.getAssumeExists(sound_id); + const buffer = &self.buffers.items[sound.buffer.index]; - var samples = std.ArrayList(f32).initBuffer(self.temp_buffer[0..num_frames]); - switch (sound.cb) { - .block => |cb| { - cb(sound, &samples); - bumpSoundCursor(sound, @intCast(samples.items.len)); - }, - .sample => |cb| { - samples.expandToCapacity(); - for (samples.items) |*sample| { - sample.* = cb(sound); - bumpSoundCursor(sound, 1); + const bus_id = sound.bus; + const bus = buses.items[bus_id.index]; + _ = bus; // autofix + const bus_volume = busMultipliedVolume(bus_id); + + const volume = sound.volume * bus_volume; + + const left_channel = buffer.samples.left; + const right_channel = buffer.samples.right orelse buffer.samples.left; + + var output_index: usize = 0; + while (output_index < num_frames) { + while (sound.cursor < buffer.samples.len and output_index < num_frames) { + const left = left_channel[sound.cursor]; + const right = right_channel[sound.cursor]; + sound.cursor += 1; + + output_buffer[2*output_index+0] += left * volume; + output_buffer[2*output_index+1] += right * volume; + output_index += 1; + } + + if (!sound.loop) { + break; + } else { + if (sound.cursor == buffer.samples.len) { + sound.cursor = 0; } } } - for (0..samples.items.len) |i| { - buffer[i] += samples.items[i] * sound.volume * self.volume; + if (!sound.loop and sound.cursor == buffer.samples.len) { + sounds.removeAssumeExists(sound_id); } } } diff --git a/src/platform/imgui.zig b/src/platform/imgui.zig index 27a4132..ec6b515 100644 --- a/src/platform/imgui.zig +++ b/src/platform/imgui.zig @@ -503,3 +503,59 @@ pub fn collapsingHeader(label: []const u8) bool { ig.ImGuiTreeNodeFlags_None ); } + +pub fn beginTable(id: [*c]const u8, columns: u32, flags: ig.ImGuiTableFlags) bool { + if (isDisabled()) { + return false; + } + + return ig.igBeginTable(id, @intCast(columns), flags); +} + +pub fn endTable() void { + if (isDisabled()) { + return; + } + + ig.igEndTable(); +} + +pub fn tableNextColumn() void { + if (isDisabled()) { + return; + } + + _ = ig.igTableNextColumn(); +} + +pub fn tableNextRow() void { + if (isDisabled()) { + return; + } + + _ = ig.igTableNextRow(); +} + +pub fn tableSetColumnIndex(index: usize) void { + if (isDisabled()) { + return; + } + + _ = ig.igTableSetColumnIndex(@intCast(index)); +} + +pub fn tableSetupColumn(label: [*c]const u8, flags: ig.ImGuiTableColumnFlags) void { + if (isDisabled()) { + return; + } + + ig.igTableSetupColumn(label, flags); +} + +pub fn tableHeadersRow() void { + if (isDisabled()) { + return; + } + + ig.igTableHeadersRow(); +} diff --git a/src/platform/root.zig b/src/platform/root.zig index 720bdf3..8780ff7 100644 --- a/src/platform/root.zig +++ b/src/platform/root.zig @@ -141,6 +141,7 @@ fn PlatformType(App: type) type { Gfx.flush(.{}); Gfx.showDebug(); + Audio.showDebug(); ImGUI.endFrame(); Gfx.endFrame(); diff --git a/src/platform/slot_map.zig b/src/platform/slot_map.zig index 3d4dea9..62003ad 100644 --- a/src/platform/slot_map.zig +++ b/src/platform/slot_map.zig @@ -129,7 +129,11 @@ pub fn SlotMapType(Index: type, Generation: type, Value: type) type { pub fn unusedCapacity(self: *Self) usize { const capacity = @min(self.slots.capacity, std.math.maxInt(Index)); - return capacity - self.slots.items.len + self.hole_count; + return capacity - self.count(); + } + + pub fn count(self: *Self) usize { + return self.slots.items.len - self.hole_count; } pub fn insertAssumeCapacity(self: *Self) Id {