From e8e94c8f70d94b0d5cfe79a78f391139a6837a07 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Sun, 5 Apr 2026 22:27:03 -0500 Subject: [PATCH] fix: add audio data to replay buffer size on the UI closes #41 --- src/audio/audio_replay_buffer.zig | 47 ++++++++++++ src/state/actor.zig | 4 +- src/state/audio_state.zig | 120 +++++++++++++++++------------- src/state/state.zig | 16 +++- src/state/user_settings_state.zig | 1 + src/ui/draw_left_column.zig | 39 +++++++--- 6 files changed, 158 insertions(+), 69 deletions(-) diff --git a/src/audio/audio_replay_buffer.zig b/src/audio/audio_replay_buffer.zig index 64c49af..cd0de9b 100644 --- a/src/audio/audio_replay_buffer.zig +++ b/src/audio/audio_replay_buffer.zig @@ -120,6 +120,28 @@ fn replay_retention_samples(self: *Self) i64 { return self.replay_seconds * SAMPLE_RATE; } +const TestUtil = struct { + fn create_audio_capture_data( + allocator: Allocator, + timestamp_ns: i128, + samples_per_channel: usize, + value: f32, + ) !*AudioCaptureData { + const pcm = try allocator.alloc(f32, samples_per_channel * CHANNELS); + defer allocator.free(pcm); + @memset(pcm, value); + + return AudioCaptureData.init( + allocator, + "speaker", + pcm, + timestamp_ns, + SAMPLE_RATE, + CHANNELS, + ); + } +}; + test "addData - encodes audio before export and exposes packet timing" { const allocator = std.testing.allocator; const sample_rate: u32 = 48_000; @@ -187,3 +209,28 @@ test "trimPackets - drops encoded packets outside replay window" { const first_packet = iter.next() orelse return error.ExpectedEncodedPacket; try std.testing.expect(first_packet.data.*.pts + first_packet.data.*.duration > expected_min_start); } + +test "size tracks encoded packet bytes as audio is added and trimmed" { + const allocator = std.testing.allocator; + const samples_per_chunk: usize = 48_000; + const base_ns: i128 = 3 * std.time.ns_per_s; + const trimmed_replay_seconds: u32 = 1; + + var replay_buffer = try Self.init(allocator, 10); + defer replay_buffer.deinit(); + + const first_chunk = try TestUtil.create_audio_capture_data(allocator, base_ns, samples_per_chunk, 0.1); + try replay_buffer.add_data(first_chunk); + try std.testing.expectEqual(18_222, replay_buffer.size); + try std.testing.expectEqual(45, replay_buffer.len); + + const second_chunk = try TestUtil.create_audio_capture_data(allocator, base_ns + std.time.ns_per_s, samples_per_chunk, 0.2); + try replay_buffer.add_data(second_chunk); + try replay_buffer.finalize(); + try std.testing.expectEqual(49_401, replay_buffer.size); + try std.testing.expectEqual(98, replay_buffer.len); + + replay_buffer.set_replay_seconds(trimmed_replay_seconds); + try std.testing.expectEqual(28_779, replay_buffer.size); + try std.testing.expectEqual(48, replay_buffer.len); +} diff --git a/src/state/actor.zig b/src/state/actor.zig index d736b1b..9b18de2 100644 --- a/src/state/actor.zig +++ b/src/state/actor.zig @@ -154,7 +154,7 @@ pub const Actor = struct { if (_self.state.user_settings.settings.restore_capture_source_on_startup and _self.state.user_settings.settings.start_replay_buffer_on_startup) { - _self.start_record() catch |err| { + _self.handle_action(.start_record) catch |err| { log.err("[capture_startup] start_record error: {}", .{err}); }; } @@ -460,7 +460,7 @@ pub const Actor = struct { ); self.ui_mutex.lock(); defer self.ui_mutex.unlock(); - self.state.replay_buffer.size = video_replay_buffer.size; + self.state.replay_buffer.video_size = video_replay_buffer.size; self.state.replay_buffer.seconds = video_replay_buffer.get_seconds(); } } diff --git a/src/state/audio_state.zig b/src/state/audio_state.zig index 355a1fb..4178d69 100644 --- a/src/state/audio_state.zig +++ b/src/state/audio_state.zig @@ -45,7 +45,7 @@ pub const AudioState = struct { audio_capture: *AudioCapture, // TODO: Convert devices to a ArrayHashMap. /// This is a list of all currently available audio devices. - devices: std.ArrayList(AudioDeviceViewModel), + devices: Mutex(std.ArrayList(AudioDeviceViewModel)), audio_replay_buffer: Mutex(?*AudioReplayBuffer) = .init(null), capture_thread: ?std.Thread = null, @@ -53,7 +53,7 @@ pub const AudioState = struct { return .{ .allocator = allocator, .audio_capture = audio_capture, - .devices = try .initCapacity(allocator, 0), + .devices = .init(try .initCapacity(allocator, 0)), }; } @@ -70,8 +70,11 @@ pub const AudioState = struct { } } - self.clear_devices(); - self.devices.deinit(self.allocator); + var locked_devices = self.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap_ptr(); + clear_devices(devices); + devices.deinit(self.allocator); } pub fn handle_action(self: *Self, actor: *Actor, action: Actions) !void { @@ -87,13 +90,21 @@ pub const AudioState = struct { var available_devices = try self.audio_capture.get_available_devices(self.allocator); defer available_devices.deinit(); - actor.ui_mutex.lock(); - defer actor.ui_mutex.unlock(); - self.clear_devices(); - errdefer self.clear_devices(); + var user_settings = blk: { + actor.ui_mutex.lock(); + defer actor.ui_mutex.unlock(); + break :blk try actor.state.user_settings.settings.clone(self.allocator); + }; + defer user_settings.deinit(self.allocator); + + var locked_devices = self.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap_ptr(); + clear_devices(devices); + errdefer clear_devices(devices); for (available_devices.devices.items) |device| { - const persisted_settings = actor.state.user_settings.settings.audio_devices.map.get(device.id); + const persisted_settings = user_settings.audio_devices.map.get(device.id); const device_copy = try AudioDeviceViewModel.init(self.allocator, .{ .id = device.id, .name = device.name, @@ -103,22 +114,23 @@ pub const AudioState = struct { .gain = if (persisted_settings) |settings| settings.gain else 1.0, }); errdefer device_copy.deinit(); - try self.devices.append(self.allocator, device_copy); + try devices.append(self.allocator, device_copy); } - try self.update_selected_devices(); + try self.update_selected_devices(devices); }, .toggle_audio_device => |device_id| { defer self.allocator.free(device_id); { - actor.ui_mutex.lock(); - defer actor.ui_mutex.unlock(); + var locked_devices = self.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap_ptr(); - for (self.devices.items) |*device| { + for (devices.items) |*device| { if (!std.mem.eql(u8, device.id, device_id)) continue; device.selected = !device.selected; - try self.update_selected_devices(); + try self.update_selected_devices(devices); try actor.dispatch(.{ .user_settings = .{ @@ -137,10 +149,11 @@ pub const AudioState = struct { const payload = _action.payload; defer _action.deinit(); { - actor.ui_mutex.lock(); - defer actor.ui_mutex.unlock(); + var locked_devices = self.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap_ptr(); - for (self.devices.items) |*device| { + for (devices.items) |*device| { if (!std.mem.eql(u8, device.id, payload.device_id)) continue; device.gain = std.math.clamp(payload.gain, AUDIO_GAIN_MIN, AUDIO_GAIN_MAX); try actor.dispatch(.{ @@ -170,6 +183,30 @@ pub const AudioState = struct { else => {}, } }, + .start_record => { + const replay_seconds = blk: { + actor.ui_mutex.lock(); + defer actor.ui_mutex.unlock(); + break :blk actor.state.user_settings.settings.replay_seconds; + }; + var replay_buffer_locked = self.audio_replay_buffer.lock(); + defer replay_buffer_locked.unlock(); + const ptr = replay_buffer_locked.unwrap_ptr(); + if (ptr.* != null) { + log.warn("[handle_action] start_record - previous buffer was not destroyed", .{}); + ptr.*.?.deinit(); + } + ptr.* = try AudioReplayBuffer.init(self.allocator, replay_seconds); + }, + .stop_record => { + var locked = self.audio_replay_buffer.lock(); + defer locked.unlock(); + + if (locked.unwrap()) |audio_replay_buffer| { + audio_replay_buffer.deinit(); + locked.set(null); + } + }, else => {}, } } @@ -201,11 +238,11 @@ pub const AudioState = struct { /// Communicates with the audio capture interface and tells it which audio devices /// were selected. - fn update_selected_devices(self: *Self) !void { + fn update_selected_devices(self: *Self, devices: *std.ArrayList(AudioDeviceViewModel)) !void { var selected_devices = try std.ArrayList(SelectedAudioDevice).initCapacity(self.allocator, 0); defer selected_devices.deinit(self.allocator); - for (self.devices.items) |device| { + for (devices.items) |device| { if (!device.selected) continue; try selected_devices.append(self.allocator, .{ .id = device.id, @@ -216,28 +253,14 @@ pub const AudioState = struct { try self.audio_capture.update_selected_devices(selected_devices.items); } - pub fn clear_devices(self: *Self) void { - for (self.devices.items) |device| { + pub fn clear_devices(devices: *std.ArrayList(AudioDeviceViewModel)) void { + for (devices.items) |device| { device.deinit(); } - self.devices.clearRetainingCapacity(); + devices.clearRetainingCapacity(); } fn capture_thread_handler(self: *Self, actor: *Actor) !void { - const replay_seconds = blk: { - actor.ui_mutex.lock(); - defer actor.ui_mutex.unlock(); - break :blk actor.state.user_settings.settings.replay_seconds; - }; - - { - var replay_buffer_locked = self.audio_replay_buffer.lock(); - defer replay_buffer_locked.unlock(); - const ptr = replay_buffer_locked.unwrap_ptr(); - assert(ptr.* == null); - ptr.* = try AudioReplayBuffer.init(self.allocator, replay_seconds); - } - while (true) { const data = self.audio_capture.receive_data() catch |err| { if (err == ChanError.Closed) { @@ -248,15 +271,12 @@ pub const AudioState = struct { return err; }; - const gain = blk: { - actor.ui_mutex.lock(); - defer actor.ui_mutex.unlock(); + data.gain = blk: { + var locked_devices = self.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap_ptr(); - if (!actor.state.is_recording_video) { - break :blk null; - } - - for (self.devices.items) |device| { + for (devices.items) |device| { if (std.mem.eql(u8, device.id, data.id)) { break :blk device.gain; } @@ -269,13 +289,6 @@ pub const AudioState = struct { break :blk 1.0; }; - if (gain) |device_gain| { - data.gain = device_gain; - } else { - data.deinit(); - continue; - } - var replay_buffer_locked = self.audio_replay_buffer.lock(); defer replay_buffer_locked.unlock(); if (replay_buffer_locked.unwrap()) |replay_buffer| { @@ -283,6 +296,9 @@ pub const AudioState = struct { data.deinit(); return err; }; + actor.ui_mutex.lock(); + defer actor.ui_mutex.unlock(); + actor.state.replay_buffer.audio_size = replay_buffer.size; } else { data.deinit(); } diff --git a/src/state/state.zig b/src/state/state.zig index d3a4a64..5970566 100644 --- a/src/state/state.zig +++ b/src/state/state.zig @@ -7,13 +7,21 @@ const UserSettingsState = @import("./user_settings_state.zig").UserSettingsState const AudioDeviceType = @import("../capture/audio/audio_capture.zig").AudioDeviceType; const AudioState = @import("./audio_state.zig").AudioState; -// TODO: add audio size const ReplayBufferViewModel = struct { - size: u64 = 0, + video_size: u64 = 0, + audio_size: u64 = 0, seconds: u64 = 0, - pub fn size_in_mb(self: *const @This()) f64 { - const mb = @as(f64, @floatFromInt(self.size)) / (1024.0 * 1024.0); + pub fn size_in_mb(self: *const @This(), size_type: enum { total, audio, video }) f64 { + return switch (size_type) { + .total => _size_in_mb(self.audio_size + self.video_size), + .audio => _size_in_mb(self.audio_size), + .video => _size_in_mb(self.video_size), + }; + } + + fn _size_in_mb(size: u64) f64 { + const mb = @as(f64, @floatFromInt(size)) / (1024.0 * 1024.0); return mb; } }; diff --git a/src/state/user_settings_state.zig b/src/state/user_settings_state.zig index 3e45052..5d0ab0c 100644 --- a/src/state/user_settings_state.zig +++ b/src/state/user_settings_state.zig @@ -38,6 +38,7 @@ pub const UserSettingsState = struct { const Self = @This(); allocator: Allocator, + // TODO: Use mutex here instead of ui_mutex. settings: UserSettings = .{}, pub fn init(allocator: Allocator) !Self { diff --git a/src/ui/draw_left_column.zig b/src/ui/draw_left_column.zig index 37727d5..0d9a310 100644 --- a/src/ui/draw_left_column.zig +++ b/src/ui/draw_left_column.zig @@ -39,7 +39,11 @@ fn device_type_label(device_type: AudioDeviceType) []const u8 { fn draw_audio_device_selector(allocator: std.mem.Allocator, actor: *Actor) !void { var selected_count: usize = 0; var first_selected_name: ?[]const u8 = null; - for (actor.state.audio.devices.items) |device| { + var locked_devices = actor.state.audio.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap(); + + for (devices.items) |device| { if (!device.selected) continue; selected_count += 1; if (first_selected_name == null) { @@ -66,12 +70,12 @@ fn draw_audio_device_selector(allocator: std.mem.Allocator, actor: *Actor) !void if (c.ImGui_BeginCombo("##Audio Sources", preview_text.ptr, 0)) { defer c.ImGui_EndCombo(); - if (actor.state.audio.devices.items.len == 0) { + if (devices.items.len == 0) { c.ImGui_Text("No audio devices found"); return; } - for (actor.state.audio.devices.items) |device| { + for (devices.items) |device| { const item_label = try std.fmt.allocPrintSentinel(allocator, "[{s}] {s}{s}##audio-device-{s}", .{ device_type_label(device.device_type), device.name, @@ -95,13 +99,16 @@ fn draw_audio_device_selector(allocator: std.mem.Allocator, actor: *Actor) !void } fn draw_selected_audio_source_gain_sliders(allocator: std.mem.Allocator, actor: *Actor) !void { + var locked_devices = actor.state.audio.devices.lock(); + defer locked_devices.unlock(); + const devices = locked_devices.unwrap(); var selected_total: usize = 0; - for (actor.state.audio.devices.items) |device| { + for (devices.items) |device| { if (device.selected) selected_total += 1; } var rendered_count: usize = 0; - for (actor.state.audio.devices.items) |device| { + for (devices.items) |device| { if (!device.selected) continue; rendered_count += 1; @@ -261,12 +268,22 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_EndDisabled(); c.ImGui_Dummy(.{ .x = 0, .y = GROUP_SPACING }); - const replay_text = try std.fmt.allocPrintSentinel(allocator, "Time: {}s", .{actor.state.replay_buffer.seconds}, 0); - defer allocator.free(replay_text); - const size_text = try std.fmt.allocPrintSentinel(allocator, "Size: {d:.2}MB", .{actor.state.replay_buffer.size_in_mb()}, 0); - defer allocator.free(size_text); - c.ImGui_Text(replay_text); - c.ImGui_Text(size_text); + c.ImGui_Text( + "%ds / %.2fMB", + actor.state.replay_buffer.seconds, + actor.state.replay_buffer.size_in_mb(.total), + ); + if (c.ImGui_BeginItemTooltip()) { + c.ImGui_Text( + "Audio: %.2fMB", + actor.state.replay_buffer.size_in_mb(.audio), + ); + c.ImGui_Text( + "Video: %.2fMB", + actor.state.replay_buffer.size_in_mb(.video), + ); + c.ImGui_EndTooltip(); + } } if (c.ImGui_BeginTabItem("Settings", null, 0)) {