diff --git a/AGENTS.md b/AGENTS.md index e9d90b0..7cb284e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,12 @@ cmake --build --preset skeleton ctest --preset skeleton ``` +> **Windows gotcha — always run `ctest` and built binaries via PowerShell, not Git Bash.** +> MinGW-built executables fail in Git Bash with exit code `0xc0000139` +> (STATUS_ENTRYPOINT_NOT_FOUND) even though the file exists and is marked executable. +> PowerShell runs them correctly. Use the PowerShell tool (not Bash) for any `ctest`, +> `voicecat-server.exe`, or `vccli.exe` invocation on Windows. + Other presets: `release` (optimized + tests), `server-release` (optimized + stripped, deployment-shaped), `windows-client` (DLL for C# app), `apple-dev`/`apple-ios`/ `apple-ios-sim` (Apple platform scaffolding). See [`docs/building.md`](docs/building.md) diff --git a/CLAUDE.md b/CLAUDE.md index 6620cea..e95c6ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,9 +29,11 @@ cmake --preset dev cmake --build --preset dev # Run the tests (21 behavior tests — grows per milestone) +# NOTE on Windows: run ctest via PowerShell, NOT Git Bash — MinGW binaries fail in Git Bash +# with exit 0xc0000139 (STATUS_ENTRYPOINT_NOT_FOUND). PowerShell runs them correctly. ctest --preset dev # or: ctest --test-dir build/dev --output-on-failure -# Run the binaries (Windows adds .exe; Linux/macOS no extension) +# Run the binaries — same Windows rule: use PowerShell, not Git Bash ./build/dev/bin/vccli # headless test client ./build/dev/bin/voicecat-server --help ./build/dev/bin/voicecat-server --name "My Server" diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs index e1a96c9..f156034 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs @@ -4,8 +4,11 @@ partial class MainForm { private System.ComponentModel.IContainer components = null!; - // Menu bar + // Menu bar + toolbar private MenuStrip menuStrip = null!; + private ToolStrip toolStrip = null!; + private ToolStripButton tsbJoinVoice = null!; + private ToolStripButton tsbScreenShare = null!; // Status bar private Label lblStatus = null!; @@ -20,23 +23,20 @@ partial class MainForm private Label lblUsers = null!; private ListBox lstUsers = null!; - // Right panel: chat transcript, compose row, activity log + // Right panel: unified log, compose row, output volume private TableLayoutPanel tblRight = null!; - private Label lblChat = null!; - private RichTextBox rtbChat = null!; + private Label lblLog = null!; + private RichTextBox rtbLog = null!; private TableLayoutPanel tblCompose = null!; - private ComboBox cboScope = null!; private TextBox txtCompose = null!; private Button btnSend = null!; - private Label lblActivity = null!; - private ListBox lstActivity = null!; + private Label lblOutputVolume = null!; + private TrackBar trkOutputVolume = null!; // Voice control panel (docked Bottom) private Panel pnlVoice = null!; private FlowLayoutPanel flpVoiceTop = null!; private FlowLayoutPanel flpVoiceBottom = null!; - private Button btnMicToggle = null!; - private Button btnScreenShareToggle = null!; private CheckBox chkMute = null!; private CheckBox chkDeafen = null!; private RadioButton radioVad = null!; @@ -70,19 +70,16 @@ partial class MainForm lblUsers = new Label(); lstUsers = new ListBox(); tblRight = new TableLayoutPanel(); - lblChat = new Label(); - rtbChat = new RichTextBox(); + lblLog = new Label(); + rtbLog = new RichTextBox(); tblCompose = new TableLayoutPanel(); - cboScope = new ComboBox(); txtCompose = new TextBox(); btnSend = new Button(); - lblActivity = new Label(); - lstActivity = new ListBox(); + lblOutputVolume = new Label(); + trkOutputVolume = new TrackBar(); pnlVoice = new Panel(); flpVoiceTop = new FlowLayoutPanel(); flpVoiceBottom = new FlowLayoutPanel(); - btnMicToggle = new Button(); - btnScreenShareToggle = new Button(); chkMute = new CheckBox(); chkDeafen = new CheckBox(); radioVad = new RadioButton(); @@ -97,6 +94,10 @@ partial class MainForm pbLevel = new ProgressBar(); lblVadThreshold = new Label(); trkVadThreshold = new TrackBar(); + menuStrip = new MenuStrip(); + toolStrip = new ToolStrip(); + tsbJoinVoice = new ToolStripButton(); + tsbScreenShare = new ToolStripButton(); // ── Status label ────────────────────────────────────────────────────── lblStatus.AccessibleName = "Connection status"; @@ -144,78 +145,72 @@ partial class MainForm splitLeft.Panel2.Controls.Add(lstUsers); splitLeft.Panel2.Controls.Add(lblUsers); - // ── Chat transcript ─────────────────────────────────────────────────── - lblChat.Text = "Chat:"; - lblChat.AutoSize = true; - lblChat.Padding = new Padding(2, 2, 2, 1); + // ── Unified log ─────────────────────────────────────────────────────── + lblLog.Text = "Chat & Activity:"; + lblLog.AutoSize = true; + lblLog.Padding = new Padding(2, 2, 2, 1); - rtbChat.AccessibleName = "Chat transcript"; - rtbChat.AccessibleDescription = "History of channel and private messages."; - rtbChat.Dock = DockStyle.Fill; - rtbChat.ReadOnly = true; - rtbChat.ScrollBars = RichTextBoxScrollBars.Vertical; - rtbChat.BackColor = SystemColors.Window; - rtbChat.TabIndex = 0; + rtbLog.AccessibleName = "Chat and activity log"; + rtbLog.AccessibleDescription = "Combined history of chat messages (normal) and activity events (gray)."; + rtbLog.Dock = DockStyle.Fill; + rtbLog.ReadOnly = true; + rtbLog.ScrollBars = RichTextBoxScrollBars.Vertical; + rtbLog.BackColor = SystemColors.Window; + rtbLog.TabIndex = 0; // ── Compose row ─────────────────────────────────────────────────────── - cboScope.AccessibleName = "Send to"; - cboScope.AccessibleDescription = - "Choose Channel to send to everyone, or a specific user for a private message."; - cboScope.DropDownStyle = ComboBoxStyle.DropDownList; - cboScope.Dock = DockStyle.Fill; - cboScope.TabIndex = 1; - txtCompose.AccessibleName = "Message text"; - txtCompose.AccessibleDescription = "Type your message. Press Enter or click Send to send."; + txtCompose.AccessibleDescription = "Type your channel message. Press Enter or click Send to send."; txtCompose.Dock = DockStyle.Fill; - txtCompose.TabIndex = 2; + txtCompose.TabIndex = 1; btnSend.Text = "&Send"; btnSend.Dock = DockStyle.Fill; - btnSend.TabIndex = 3; + btnSend.TabIndex = 2; - tblCompose.ColumnCount = 3; + tblCompose.ColumnCount = 2; tblCompose.RowCount = 1; tblCompose.Dock = DockStyle.Fill; - tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 165F)); tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F)); tblCompose.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); tblCompose.Padding = new Padding(0, 3, 0, 0); - tblCompose.Controls.Add(cboScope, 0, 0); - tblCompose.Controls.Add(txtCompose, 1, 0); - tblCompose.Controls.Add(btnSend, 2, 0); + tblCompose.Controls.Add(txtCompose, 0, 0); + tblCompose.Controls.Add(btnSend, 1, 0); - // ── Activity log ────────────────────────────────────────────────────── - lblActivity.Text = "Activity:"; - lblActivity.AutoSize = true; - lblActivity.Padding = new Padding(2, 4, 2, 1); + // ── Output volume row ───────────────────────────────────────────────── + lblOutputVolume.Text = "Output volume:"; + lblOutputVolume.AutoSize = true; + lblOutputVolume.Padding = new Padding(2, 6, 4, 0); - lstActivity.AccessibleName = "Activity log"; - lstActivity.AccessibleDescription = - "Record of joins, leaves, talk-state changes, and server messages."; - lstActivity.Dock = DockStyle.Fill; - lstActivity.HorizontalScrollbar = true; - lstActivity.TabIndex = 4; + trkOutputVolume.AccessibleName = "Output volume"; + trkOutputVolume.AccessibleDescription = "Global playback volume for all incoming audio."; + trkOutputVolume.Minimum = 0; + trkOutputVolume.Maximum = 100; + trkOutputVolume.Value = 80; + trkOutputVolume.TickFrequency = 10; + trkOutputVolume.SmallChange = 1; + trkOutputVolume.LargeChange = 10; + trkOutputVolume.Dock = DockStyle.Fill; + trkOutputVolume.TabIndex = 3; // ── Right table layout ──────────────────────────────────────────────── tblRight.ColumnCount = 1; tblRight.RowCount = 5; tblRight.Dock = DockStyle.Fill; tblRight.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); - tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); - tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F)); - tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); - tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 120F)); - tblRight.Controls.Add(lblChat, 0, 0); - tblRight.Controls.Add(rtbChat, 0, 1); + tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 0: lblLog + tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); // row 1: rtbLog + tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F)); // row 2: compose + tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // row 3: vol label + tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 45F)); // row 4: vol slider + tblRight.Controls.Add(lblLog, 0, 0); + tblRight.Controls.Add(rtbLog, 0, 1); tblRight.Controls.Add(tblCompose, 0, 2); - tblRight.Controls.Add(lblActivity, 0, 3); - tblRight.Controls.Add(lstActivity, 0, 4); + tblRight.Controls.Add(lblOutputVolume, 0, 3); + tblRight.Controls.Add(trkOutputVolume, 0, 4); // ── Main split ──────────────────────────────────────────────────────── - // SplitterDistance set in OnLoad — see MainForm.cs. splitMain.Dock = DockStyle.Fill; splitMain.Panel1MinSize = 150; splitMain.TabIndex = 1; @@ -223,26 +218,6 @@ partial class MainForm splitMain.Panel2.Controls.Add(tblRight); // ── Voice control panel ─────────────────────────────────────────────── - // Top row: mic start/stop, mute/deafen, VAD/PTT - btnMicToggle.Text = "&Join Voice"; - btnMicToggle.AutoSize = true; - btnMicToggle.Margin = new Padding(0, 2, 6, 0); - btnMicToggle.TabIndex = 0; - - // Screen-audio share — independent of mic voice (can share without joining voice and - // vice versa). The core's WASAPI loopback path (VOICECAT_HAS_LOOPBACK, always on for - // the windows-client preset) captures the default render endpoint; see docs/voice.md - // §9. Whole-device loopback inherently re-captures this app's own incoming voice mix — - // an accepted self-echo characteristic, not a bug. - btnScreenShareToggle.Text = "Share Screen &Audio"; - btnScreenShareToggle.AccessibleName = "Share screen audio"; - btnScreenShareToggle.AccessibleDescription = - "Start or stop sharing your computer's audio (desktop/system audio) with the channel. " + - "Independent of the microphone. Captures everything playing through your default speakers."; - btnScreenShareToggle.AutoSize = true; - btnScreenShareToggle.Margin = new Padding(0, 2, 12, 0); - btnScreenShareToggle.TabIndex = 10; - chkMute.Text = "&Mute mic"; chkMute.AutoSize = true; chkMute.Enabled = false; @@ -291,8 +266,6 @@ partial class MainForm flpVoiceTop.Height = 34; flpVoiceTop.AutoSize = false; flpVoiceTop.Padding = new Padding(4, 2, 4, 0); - flpVoiceTop.Controls.Add(btnMicToggle); - flpVoiceTop.Controls.Add(btnScreenShareToggle); flpVoiceTop.Controls.Add(chkMute); flpVoiceTop.Controls.Add(chkDeafen); flpVoiceTop.Controls.Add(lblMode); @@ -330,12 +303,12 @@ partial class MainForm pbLevel.Maximum = 100; pbLevel.Margin = new Padding(0, 6, 0, 0); pbLevel.Style = ProgressBarStyle.Continuous; - pbLevel.TabStop = false; // informational, not actionable + pbLevel.TabStop = false; lblVadThreshold.Text = "Sensitivity:"; lblVadThreshold.AutoSize = true; lblVadThreshold.Margin = new Padding(12, 5, 4, 0); - lblVadThreshold.Visible = true; // shown when VAD mode active + lblVadThreshold.Visible = true; trkVadThreshold.AccessibleName = "VAD sensitivity"; trkVadThreshold.AccessibleDescription = @@ -343,7 +316,7 @@ partial class MainForm "Range 1–100; default 25."; trkVadThreshold.Minimum = 1; trkVadThreshold.Maximum = 100; - trkVadThreshold.Value = 76; // maps to ~0.024 (≈ default 0.025 threshold) + trkVadThreshold.Value = 76; trkVadThreshold.TickFrequency = 10; trkVadThreshold.SmallChange = 1; trkVadThreshold.LargeChange = 10; @@ -369,8 +342,23 @@ partial class MainForm pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first pnlVoice.Controls.Add(flpVoiceTop); // Top — added last + // ── Toolbar ─────────────────────────────────────────────────────────── + tsbJoinVoice.Text = "Join Voice"; + tsbJoinVoice.DisplayStyle = ToolStripItemDisplayStyle.Text; + tsbJoinVoice.CheckOnClick = false; + + tsbScreenShare.Text = "Share Screen Audio"; + tsbScreenShare.DisplayStyle = ToolStripItemDisplayStyle.Text; + tsbScreenShare.CheckOnClick = false; + + toolStrip.Dock = DockStyle.Top; + toolStrip.Items.Add(tsbJoinVoice); + toolStrip.Items.Add(new ToolStripSeparator()); + toolStrip.Items.Add(tsbScreenShare); + toolStrip.TabIndex = 1; + toolStrip.AccessibleName = "Toolbar"; + // ── Menu strip ──────────────────────────────────────────────────────── - menuStrip = new MenuStrip(); menuStrip.AccessibleName = "Main menu"; menuStrip.Dock = DockStyle.Top; menuStrip.TabIndex = 0; @@ -380,12 +368,12 @@ partial class MainForm AutoScaleMode = AutoScaleMode.Font; ClientSize = new Size(960, 680); MinimumSize = new Size(700, 540); - KeyPreview = true; // form sees KeyDown/KeyUp before focused control (needed for PTT) - // Controls added in reverse docking priority: Fill first, then Bottom, then Top. - Controls.Add(splitMain); // DockStyle.Fill - Controls.Add(pnlVoice); // DockStyle.Bottom - Controls.Add(lblStatus); // DockStyle.Top - Controls.Add(menuStrip); // DockStyle.Top (placed at the very top) + KeyPreview = true; + Controls.Add(splitMain); // DockStyle.Fill + Controls.Add(pnlVoice); // DockStyle.Bottom + Controls.Add(lblStatus); // DockStyle.Top + Controls.Add(toolStrip); // DockStyle.Top (above status) + Controls.Add(menuStrip); // DockStyle.Top (topmost) StartPosition = FormStartPosition.CenterScreen; Text = "VoiceCat"; } diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 24e69d8..269a39d 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -4,8 +4,6 @@ namespace VoiceCat.App.Forms; /// /// Post-auth main window. Owns the VoiceCatClient for its entire lifetime. -/// Phase E: channel tree, user list, chat. Phase F: voice controls, device pickers, PTT, -/// per-user tuning. /// public partial class MainForm : Form { @@ -28,6 +26,13 @@ public partial class MainForm : Form private bool _serverMuted; private bool _serverDeafened; + // Private message windows keyed by the other user's ID + private readonly Dictionary _pmWindows = []; + + // Voice menu items (kept as fields so we can update their text/state) + private ToolStripMenuItem _miJoinVoice = null!; + private ToolStripMenuItem _miScreenShare = null!; + public MainForm(VoiceCatClient client, uint selfUserId, string nickname) { InitializeComponent(); @@ -41,7 +46,6 @@ public partial class MainForm : Form _client.LevelChanged += OnLevelChanged; _pumpTimer.Tick += (_, _) => _client.PumpEvents(); - // M5: load own permissions and build permission-aware menus. _ownPermissions = SafeGetPermissions(); BuildMenus(); BuildChannelContextMenu(); @@ -49,6 +53,9 @@ public partial class MainForm : Form _pumpTimer.Start(); + // Apply initial output volume + _client.SetOutputVolume(trkOutputVolume.Value / 100f); + // Channel tree tvChannels.DoubleClick += TvChannels_DoubleClick; tvChannels.KeyDown += TvChannels_KeyDown; @@ -59,9 +66,14 @@ public partial class MainForm : Form txtCompose.KeyDown += TxtCompose_KeyDown; btnSend.Click += (_, _) => SendText(); + // Toolbar voice buttons + tsbJoinVoice.Click += BtnMicToggle_Click; + tsbScreenShare.Click += BtnScreenShareToggle_Click; + + // Output volume slider + trkOutputVolume.Scroll += TrkOutputVolume_Scroll; + // Voice controls - btnMicToggle.Click += BtnMicToggle_Click; - btnScreenShareToggle.Click += BtnScreenShareToggle_Click; chkMute.CheckedChanged += (_, _) => ApplySelfMute(); chkDeafen.CheckedChanged += (_, _) => ApplySelfMute(); radioVad.CheckedChanged += RadioVad_CheckedChanged; @@ -77,7 +89,7 @@ public partial class MainForm : Form KeyUp += MainForm_KeyUp; Deactivate += (_, _) => { - if (_micStreamId != 0) _client.SetPushToTalk(false); // release PTT on focus loss + if (_micStreamId != 0) _client.SetPushToTalk(false); }; BootstrapFromServer(); @@ -101,7 +113,6 @@ public partial class MainForm : Form } RefreshChannelTree(); RefreshUserList(); - RebuildScopeCombo(); UpdateStatusLabel(); AddActivity($"Connected to server as {_nickname}"); } @@ -114,7 +125,7 @@ public partial class MainForm : Form LoadInputDevices(); } - // ── Menu / context menu builders (M5) ───────────────────────────────────── + // ── Menu / context menu builders ───────────────────────────────────────── private PermissionsInfo SafeGetPermissions() { @@ -124,17 +135,40 @@ public partial class MainForm : Form private void BuildMenus() { - if (!_ownPermissions.CanAdminAccounts) return; + // Voice menu — always visible + var voiceMenu = new ToolStripMenuItem("&Voice"); - var adminMenu = new ToolStripMenuItem("&Admin"); - var miAccounts = new ToolStripMenuItem("&Server accounts..."); - miAccounts.Click += (_, _) => + _miJoinVoice = new ToolStripMenuItem("&Join Voice"); + _miJoinVoice.Click += BtnMicToggle_Click; + voiceMenu.DropDownItems.Add(_miJoinVoice); + + _miScreenShare = new ToolStripMenuItem("Share Screen &Audio"); + _miScreenShare.Click += BtnScreenShareToggle_Click; + voiceMenu.DropDownItems.Add(_miScreenShare); + + menuStrip.Items.Add(voiceMenu); + + // Messages menu — always visible + var messagesMenu = new ToolStripMenuItem("&Messages"); + var miNewPm = new ToolStripMenuItem("&New Private Message..."); + miNewPm.ShortcutKeys = Keys.Control | Keys.P; + miNewPm.Click += (_, _) => OpenNewPmDialog(); + messagesMenu.DropDownItems.Add(miNewPm); + menuStrip.Items.Add(messagesMenu); + + // Admin menu — only if permitted + if (_ownPermissions.CanAdminAccounts) { - using var dlg = new AccountsDialog(_client); - dlg.ShowDialog(this); - }; - adminMenu.DropDownItems.Add(miAccounts); - menuStrip.Items.Add(adminMenu); + var adminMenu = new ToolStripMenuItem("&Admin"); + var miAccounts = new ToolStripMenuItem("&Server accounts..."); + miAccounts.Click += (_, _) => + { + using var dlg = new AccountsDialog(_client); + dlg.ShowDialog(this); + }; + adminMenu.DropDownItems.Add(miAccounts); + menuStrip.Items.Add(adminMenu); + } } private void BuildChannelContextMenu() @@ -154,9 +188,7 @@ public partial class MainForm : Form } if (canCreate) - { ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel()); - } if (hasSelection && isAdmin) { @@ -185,18 +217,18 @@ public partial class MainForm : Form { ctx.Items.Add(new ToolStripSeparator()); + var miPm = new ToolStripMenuItem("Send &Private Message"); + miPm.Click += (_, _) => OpenPmWindow(user.Id); + ctx.Items.Add(miPm); + + ctx.Items.Add(new ToolStripSeparator()); + if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin) - { ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser()); - } if (_ownPermissions.CanKick || _ownPermissions.IsAdmin) - { ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser()); - } if (_ownPermissions.CanBan || _ownPermissions.IsAdmin) - { ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser()); - } ctx.Items.Add(new ToolStripSeparator()); @@ -274,7 +306,6 @@ public partial class MainForm : Form } RefreshChannelTree(); RefreshUserList(); - RebuildScopeCombo(); } private void HandleUserJoined(VoiceCatEvent ev) @@ -282,8 +313,8 @@ public partial class MainForm : Form var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId, false, false, false, false); _users[ev.UserId] = user; + RefreshChannelTree(); RefreshUserList(); - RebuildScopeCombo(); if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId) AddActivity($"{user.Nickname} joined the channel"); } @@ -294,9 +325,11 @@ public partial class MainForm : Form bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId; _users.Remove(ev.UserId); _talkingUsers.Remove(ev.UserId); + RefreshChannelTree(); RefreshUserList(); - RebuildScopeCombo(); if (wasHere) AddActivity($"{user.Nickname} left the channel"); + if (_pmWindows.TryGetValue(ev.UserId, out var pmWin)) + pmWin.AppendActivity($"{user.Nickname} disconnected from server"); } private void HandleUserUpdated() @@ -314,7 +347,6 @@ public partial class MainForm : Form } RefreshChannelTree(); RefreshUserList(); - RebuildScopeCombo(); } private void HandleJoinResult(VoiceCatEvent ev) @@ -322,9 +354,6 @@ public partial class MainForm : Form if (ev.Result == VcResult.Ok) { _currentChannelId = ev.ChannelId; - // The authoritative UserEvent::UPDATED broadcast also reflects this move, but it - // may arrive after this result — patch our own entry now for instant, flicker-free - // feedback. The later UPDATED is idempotent (sets the same channel). if (_users.TryGetValue(_selfUserId, out var self)) _users[_selfUserId] = self with { ChannelId = ev.ChannelId }; RefreshChannelTree(); @@ -354,16 +383,19 @@ public partial class MainForm : Form .LocalDateTime.ToString("HH:mm") : DateTime.Now.ToString("HH:mm"); string sender = GetNickname(ev.UserId); - // For a private message, ev.ChannelId carries target_id (the recipient user_id). - // When the server relays our own private message back to us, label it with the - // recipient; an incoming private message just shows "(private)". - string prefix = ev.TextScope == VcTextScope.Private - ? (ev.UserId == _selfUserId ? $"(private to {GetNickname(ev.ChannelId)}) " : "(private) ") - : ""; - rtbChat.AppendText($"[{time}] {prefix}{sender}: {ev.Text ?? ""}\n"); - rtbChat.ScrollToCaret(); - if (ev.TextScope == VcTextScope.Private && ev.UserId != _selfUserId) - AddActivity($"Private message from {sender}"); + + if (ev.TextScope == VcTextScope.Private) + { + // For our own outgoing PM, ev.ChannelId carries the recipient user ID. + uint otherUserId = ev.UserId == _selfUserId ? ev.ChannelId : ev.UserId; + var win = GetOrOpenPmWindow(otherUserId); + bool isSelf = ev.UserId == _selfUserId; + win.AppendMessage(time, isSelf, sender, ev.Text ?? ""); + } + else + { + AppendChat(time, sender, ev.Text ?? ""); + } } private void HandleTalkState(VoiceCatEvent ev) @@ -409,8 +441,12 @@ public partial class MainForm : Form _screenStreamId = 0; txtCompose.Enabled = false; btnSend.Enabled = false; - btnMicToggle.Enabled = false; - btnScreenShareToggle.Enabled = false; + tsbJoinVoice.Enabled = false; + tsbScreenShare.Enabled = false; + _miJoinVoice.Enabled = false; + _miScreenShare.Enabled = false; + foreach (var win in _pmWindows.Values.ToList()) win.Close(); + _pmWindows.Clear(); } // ── Level meter ─────────────────────────────────────────────────────────── @@ -438,7 +474,8 @@ public partial class MainForm : Form if (!byParent.TryGetValue(parentId, out var kids)) return; foreach (var ch in kids.OrderBy(c => c.Name)) { - var label = ch.Name; + int count = _users.Values.Count(u => u.ChannelId == ch.Id); + var label = $"{ch.Name} ({count})"; if (ch.PasswordProtected) label += " [password]"; if (ch.Id == _currentChannelId) label += " ►"; var node = new TreeNode(label) { Tag = ch.Id }; @@ -479,34 +516,7 @@ public partial class MainForm : Form lstUsers.Items.Add(new UserListItem(user.Id, label)); } lstUsers.EndUpdate(); - } - - private void RebuildScopeCombo() - { - uint prevTarget = (cboScope.SelectedItem is ScopeItem prev && - prev.Scope == VcTextScope.Private) - ? prev.TargetId : 0u; - - cboScope.Items.Clear(); - cboScope.Items.Add(new ScopeItem("Channel", VcTextScope.Channel, 0)); - foreach (var u in _users.Values.OrderBy(u => u.Nickname)) - { - if (u.Id == _selfUserId) continue; - cboScope.Items.Add(new ScopeItem($"Private: {u.Nickname}", VcTextScope.Private, u.Id)); - } - - if (prevTarget != 0) - { - for (int i = 1; i < cboScope.Items.Count; i++) - { - if (cboScope.Items[i] is ScopeItem si && si.TargetId == prevTarget) - { - cboScope.SelectedIndex = i; - return; - } - } - } - if (cboScope.Items.Count > 0) cboScope.SelectedIndex = 0; + UpdateStatusLabel(); } private void UpdateStatusLabel() @@ -536,7 +546,6 @@ public partial class MainForm : Form cboInputDevice.Items.Clear(); foreach (var d in devices) cboInputDevice.Items.Add(d); - // Restore selection or pick default if (prevDevice is not null) { for (int i = 0; i < cboInputDevice.Items.Count; i++) @@ -548,7 +557,6 @@ public partial class MainForm : Form } } } - // Select default device for (int i = 0; i < cboInputDevice.Items.Count; i++) { if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault) @@ -570,18 +578,11 @@ public partial class MainForm : Form if (result == VcResult.Ok) { _micStreamId = streamId; - // Apply selected device if not default if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev) _client.SetInputDevice(streamId, dev.Id); - // Apply current mode _client.SetInputMode(CurrentInputMode()); if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider()); - btnMicToggle.Text = "Leave &Voice"; - chkMute.Enabled = true; - chkDeafen.Enabled = true; - radioVad.Enabled = true; - radioPtt.Enabled = true; - radioAlwaysOn.Enabled = true; + SetVoiceJoinedState(true); AddActivity("Joined voice — microphone active"); } else @@ -591,27 +592,29 @@ public partial class MainForm : Form } else { - _client.SetPushToTalk(false); // release PTT if held + _client.SetPushToTalk(false); _client.StopStream(_micStreamId); _micStreamId = 0; pbLevel.Value = 0; - btnMicToggle.Text = "&Join Voice"; - chkMute.Enabled = false; - chkDeafen.Enabled = false; - radioVad.Enabled = false; - radioPtt.Enabled = false; - radioAlwaysOn.Enabled = false; + SetVoiceJoinedState(false); AddActivity("Left voice"); } } + private void SetVoiceJoinedState(bool joined) + { + tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice"; + _miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice"; + chkMute.Enabled = joined; + chkDeafen.Enabled = joined; + radioVad.Enabled = joined; + radioPtt.Enabled = joined; + radioAlwaysOn.Enabled = joined; + } + private void ApplySelfMute() => _client.SetSelfMute(chkMute.Checked, chkDeafen.Checked); - // Screen-audio share — independent of mic voice. The core bypasses VAD/PTT/self-mute/ - // server-mute for non-MIC kinds (client.cpp on_capture_frame), so no input-mode or mute - // state applies here. WASAPI loopback captures the default render endpoint (whole-device, - // not process-specific — docs/voice.md §9). private void BtnScreenShareToggle_Click(object? sender, EventArgs e) { if (_screenStreamId == 0) @@ -620,7 +623,8 @@ public partial class MainForm : Form if (result == VcResult.Ok) { _screenStreamId = streamId; - btnScreenShareToggle.Text = "Stop Screen &Audio"; + tsbScreenShare.Text = "Stop Screen Audio"; + _miScreenShare.Text = "Stop Screen &Audio"; AddActivity("Started sharing screen audio"); } else @@ -632,11 +636,15 @@ public partial class MainForm : Form { _client.StopStream(_screenStreamId); _screenStreamId = 0; - btnScreenShareToggle.Text = "Share Screen &Audio"; + tsbScreenShare.Text = "Share Screen Audio"; + _miScreenShare.Text = "Share Screen &Audio"; AddActivity("Stopped sharing screen audio"); } } + private void TrkOutputVolume_Scroll(object? sender, EventArgs e) => + _client.SetOutputVolume(trkOutputVolume.Value / 100f); + private void RadioVad_CheckedChanged(object? sender, EventArgs e) { if (!radioVad.Checked) return; @@ -662,7 +670,7 @@ public partial class MainForm : Form if (_micStreamId != 0) { _client.SetInputMode(VcInputMode.PushToTalk); - _client.SetPushToTalk(false); // start released + _client.SetPushToTalk(false); } } @@ -682,7 +690,6 @@ public partial class MainForm : Form _client.SetVadThreshold(VadThresholdFromSlider()); } - // threshold = 0.1 × (1 − (value−1) / 99): slider=1→0.1 (least sensitive), slider=100→0.001 private float VadThresholdFromSlider() => 0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f); @@ -713,7 +720,6 @@ public partial class MainForm : Form private void MainForm_KeyDown(object? sender, KeyEventArgs e) { if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return; - // Don't intercept PTT key while user is typing in a text control if (ActiveControl is TextBox or RichTextBox) return; _client.SetPushToTalk(true); lblPttKey.Text = $"({_pttKey} ▶)"; @@ -765,6 +771,48 @@ public partial class MainForm : Form JoinChannelRequest(channelId); } + // ── Private messaging ───────────────────────────────────────────────────── + + private PrivateMessageForm GetOrOpenPmWindow(uint userId) + { + if (!_pmWindows.TryGetValue(userId, out var win) || win.IsDisposed) + { + string nick = GetNickname(userId); + win = new PrivateMessageForm(_client, userId, nick, _selfUserId); + win.FormClosed += (_, _) => _pmWindows.Remove(userId); + _pmWindows[userId] = win; + win.Show(this); + } + else + { + if (win.WindowState == FormWindowState.Minimized) + win.WindowState = FormWindowState.Normal; + win.BringToFront(); + } + return win; + } + + private void OpenPmWindow(uint userId) => GetOrOpenPmWindow(userId); + + private void OpenNewPmDialog() + { + var others = _users.Values + .Where(u => u.Id != _selfUserId) + .OrderBy(u => u.Nickname) + .ToList(); + + if (others.Count == 0) + { + MessageBox.Show(this, "No other users are connected to the server.", + "New Private Message", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using var dlg = new UserPickerDialog(others); + if (dlg.ShowDialog(this) == DialogResult.OK) + OpenPmWindow(dlg.SelectedUserId); + } + // ── M5: Moderation helpers ──────────────────────────────────────────────── private void UpdateSelfServerMuteState(bool muted, bool deafened) @@ -796,14 +844,8 @@ public partial class MainForm : Form if (channel is null) return; var editInfo = new ChannelEditInfo( - channel.Id, - channel.ParentId, - channel.Name, - channel.Topic, - channel.PasswordProtected, - null, - channel.MaxUsers, - 0, + channel.Id, channel.ParentId, channel.Name, channel.Topic, + channel.PasswordProtected, null, channel.MaxUsers, 0, new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10)); using var dlg = new ChannelEditDialog(_channels, editInfo); @@ -876,7 +918,6 @@ public partial class MainForm : Form { var user = SelectedUser(); if (user is null) return; - // The C ABI does not expose a user's current permissions, so start unchecked. var current = new PermissionsInfo(false, false, false, false, false, false); using var dlg = new PermissionsDialog(user.Nickname, current); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; @@ -908,32 +949,29 @@ public partial class MainForm : Form { string msg = txtCompose.Text.Trim(); if (string.IsNullOrEmpty(msg)) return; - - var scope = VcTextScope.Channel; - uint targetId = _currentChannelId; - if (cboScope.SelectedItem is ScopeItem { Scope: VcTextScope.Private } si) - { - scope = VcTextScope.Private; - targetId = si.TargetId; - } - - if (scope == VcTextScope.Channel && _currentChannelId == 0) return; - - _client.SendText(scope, targetId, msg); + if (_currentChannelId == 0) return; + _client.SendText(VcTextScope.Channel, _currentChannelId, msg); txtCompose.Clear(); - // No optimistic echo: the server relays our own message back to us (it no longer - // excludes the sender), so HandleTextMessage renders it through the same path as - // every other message. Echoing here too would double it. } - // ── Utility ─────────────────────────────────────────────────────────────── + // ── Log helpers ─────────────────────────────────────────────────────────── + + private void AppendChat(string time, string sender, string text) + { + rtbLog.AppendText($"[{time}] {sender}: {text}\n"); + rtbLog.ScrollToCaret(); + } private void AddActivity(string text) { - string entry = $"[{DateTime.Now:HH:mm}] {text}"; - lstActivity.Items.Add(entry); - if (lstActivity.Items.Count > 200) lstActivity.Items.RemoveAt(0); - lstActivity.TopIndex = lstActivity.Items.Count - 1; + string entry = $"[{DateTime.Now:HH:mm}] {text}\n"; + int selStart = rtbLog.TextLength; + rtbLog.AppendText(entry); + rtbLog.Select(selStart, entry.Length); + rtbLog.SelectionColor = Color.Gray; + rtbLog.Select(rtbLog.TextLength, 0); + rtbLog.SelectionColor = rtbLog.ForeColor; + rtbLog.ScrollToCaret(); } private string GetNickname(uint userId) @@ -949,9 +987,8 @@ public partial class MainForm : Form _pumpTimer.Stop(); _client.LevelChanged -= OnLevelChanged; _client.EventReceived -= OnEvent; - // Stop any active local streams before tearing down — the core stops loopback in - // stream_stop/destroy, but explicit stops ensure clean StreamStop protocol messages - // go out before Disconnect closes the control channel. + foreach (var win in _pmWindows.Values.ToList()) win.Close(); + _pmWindows.Clear(); if (_screenStreamId != 0) _client.StopStream(_screenStreamId); if (_micStreamId != 0) _client.StopStream(_micStreamId); _client.Disconnect(); @@ -961,13 +998,6 @@ public partial class MainForm : Form // ── Private types ───────────────────────────────────────────────────────── - private sealed class ScopeItem(string display, VcTextScope scope, uint targetId) - { - public VcTextScope Scope { get; } = scope; - public uint TargetId { get; } = targetId; - public override string ToString() => display; - } - private sealed class UserListItem(uint userId, string display) { public uint UserId { get; } = userId; diff --git a/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs b/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs new file mode 100644 index 0000000..18fecc2 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PrivateMessageForm.cs @@ -0,0 +1,115 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Modeless window for a single private message conversation. +/// Created by MainForm and shown non-modally; MainForm routes incoming PMs here. +/// +public sealed class PrivateMessageForm : Form +{ + private readonly VoiceCatClient _client; + private readonly uint _userId; + private readonly uint _selfUserId; + + private readonly RichTextBox _rtbHistory; + private readonly TextBox _txtCompose; + private readonly Button _btnSend; + + public PrivateMessageForm(VoiceCatClient client, uint userId, string nickname, uint selfUserId) + { + _client = client; + _userId = userId; + _selfUserId = selfUserId; + + Text = $"Private Message — {nickname}"; + ClientSize = new Size(480, 360); + MinimumSize = new Size(320, 240); + StartPosition = FormStartPosition.Manual; + + _rtbHistory = new RichTextBox + { + ReadOnly = true, + Dock = DockStyle.Fill, + ScrollBars = RichTextBoxScrollBars.Vertical, + BackColor = SystemColors.Window, + AccessibleName = "Message history", + }; + + var composePanel = new TableLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 34, + ColumnCount = 2, + RowCount = 1, + Padding = new Padding(0, 3, 0, 0), + }; + composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + composePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F)); + composePanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + + _txtCompose = new TextBox + { + Dock = DockStyle.Fill, + AccessibleName = "Message text", + }; + _btnSend = new Button + { + Text = "&Send", + Dock = DockStyle.Fill, + }; + + composePanel.Controls.Add(_txtCompose, 0, 0); + composePanel.Controls.Add(_btnSend, 1, 0); + + Controls.Add(_rtbHistory); + Controls.Add(composePanel); + + _txtCompose.KeyDown += (_, e) => + { + if (e.KeyCode == Keys.Enter) { Send(); e.Handled = e.SuppressKeyPress = true; } + }; + _btnSend.Click += (_, _) => Send(); + } + + // Called by MainForm when a PM message arrives (or we send one and the server echoes it). + public void AppendMessage(string time, bool isSelf, string sender, string text) + { + if (IsDisposed) return; + string line = $"[{time}] {sender}: {text}\n"; + int selStart = _rtbHistory.TextLength; + _rtbHistory.AppendText(line); + if (isSelf) + { + _rtbHistory.Select(selStart, line.Length); + _rtbHistory.SelectionColor = Color.Gray; + _rtbHistory.Select(_rtbHistory.TextLength, 0); + _rtbHistory.SelectionColor = _rtbHistory.ForeColor; + } + _rtbHistory.ScrollToCaret(); + } + + // Called by MainForm for status events (e.g., the other user disconnected). + public void AppendActivity(string text) + { + if (IsDisposed) return; + string line = $"[{DateTime.Now:HH:mm}] {text}\n"; + int selStart = _rtbHistory.TextLength; + _rtbHistory.AppendText(line); + _rtbHistory.Select(selStart, line.Length); + _rtbHistory.SelectionColor = Color.Gray; + _rtbHistory.Select(_rtbHistory.TextLength, 0); + _rtbHistory.SelectionColor = _rtbHistory.ForeColor; + _rtbHistory.ScrollToCaret(); + } + + private void Send() + { + string msg = _txtCompose.Text.Trim(); + if (string.IsNullOrEmpty(msg)) return; + _client.SendText(VcTextScope.Private, _userId, msg); + _txtCompose.Clear(); + // No optimistic echo: the server relays our own message back, so AppendMessage is + // called from MainForm.HandleTextMessage when the echo arrives. + } +} diff --git a/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs b/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs new file mode 100644 index 0000000..a66e8a5 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/UserPickerDialog.cs @@ -0,0 +1,99 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Simple modal dialog for picking one user from a list of all server users. +/// Used by "New Private Message" to let the user send a PM to anyone on the server. +/// +public sealed class UserPickerDialog : Form +{ + private readonly ListBox _lstUsers; + private readonly Button _btnOk; + private readonly Button _btnCancel; + + private readonly List _users; + + public uint SelectedUserId { get; private set; } + + public UserPickerDialog(List users) + { + _users = users; + + Text = "New Private Message"; + ClientSize = new Size(280, 320); + MinimumSize = new Size(220, 240); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + + var lbl = new Label + { + Text = "Select a user:", + AutoSize = true, + Padding = new Padding(6, 6, 6, 2), + Dock = DockStyle.Top, + }; + + _lstUsers = new ListBox + { + Dock = DockStyle.Fill, + AccessibleName = "User list", + }; + foreach (var u in users) + _lstUsers.Items.Add(new UserItem(u.Id, u.Nickname)); + + var btnPanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.RightToLeft, + Height = 40, + Padding = new Padding(4), + AutoSize = false, + }; + + _btnCancel = new Button + { + Text = "Cancel", + DialogResult = DialogResult.Cancel, + AutoSize = true, + }; + _btnOk = new Button + { + Text = "OK", + DialogResult = DialogResult.OK, + AutoSize = true, + Enabled = false, + }; + AcceptButton = _btnOk; + CancelButton = _btnCancel; + + btnPanel.Controls.Add(_btnCancel); + btnPanel.Controls.Add(_btnOk); + + Controls.Add(_lstUsers); + Controls.Add(lbl); + Controls.Add(btnPanel); + + _lstUsers.SelectedIndexChanged += (_, _) => + _btnOk.Enabled = _lstUsers.SelectedItem is UserItem; + _lstUsers.DoubleClick += (_, _) => + { + if (_lstUsers.SelectedItem is UserItem) DialogResult = DialogResult.OK; + }; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (DialogResult == DialogResult.OK && _lstUsers.SelectedItem is UserItem item) + SelectedUserId = item.UserId; + base.OnFormClosing(e); + } + + private sealed class UserItem(uint userId, string nickname) + { + public uint UserId { get; } = userId; + public override string ToString() => nickname; + } +} diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index 5f3234d..edae603 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -77,6 +77,9 @@ internal static partial class NativeMethods [LibraryImport(LibName)] internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened); + [LibraryImport(LibName)] + internal static partial VcResult vc_set_output_volume(nint c, float gain); + [LibraryImport(LibName)] internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId, float gain, int muted, int noiseReduction); diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs index dbfc612..aa6b9f2 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -204,6 +204,9 @@ public sealed class VoiceCatClient : IDisposable public VcResult SetSelfMute(bool micMuted, bool deafened) => NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0); + public VcResult SetOutputVolume(float gain) => + NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); + public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) => NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, muted ? 1 : 0, noiseReduction ? 1 : 0); diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 3986fa4..2069fbb 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -374,6 +374,10 @@ VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold); VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */); VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened); +/* Global playback volume applied after mixing all remote streams. gain 0.0 = silent, + * 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. */ +VC_API vc_result vc_set_output_volume(vc_client* c, float gain); + /* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10: * gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */ VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp index 41e6681..62b1803 100644 --- a/core/src/audio/audio_engine.cpp +++ b/core/src/audio/audio_engine.cpp @@ -659,8 +659,11 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) { } } - for (ma_uint32 i = 0; i < frames * pb_channels; ++i) - out[i] = static_cast(std::clamp(mix[i], -32768, 32767)); + const float ovol = output_volume_.load(std::memory_order_relaxed); + for (ma_uint32 i = 0; i < frames * pb_channels; ++i) { + int32_t s = static_cast(static_cast(mix[i]) * ovol); + out[i] = static_cast(std::clamp(s, -32768, 32767)); + } #else (void)out; (void)frames; diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h index d8cd553..ba4ee83 100644 --- a/core/src/audio/audio_engine.h +++ b/core/src/audio/audio_engine.h @@ -163,6 +163,9 @@ class AudioEngine { // Called by the net thread when a decoded voice frame arrives for a remote stream. void push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f); + // Global output volume applied after mixing all streams (default 1.0). Thread-safe. + void set_output_volume(float gain) { output_volume_.store(gain, std::memory_order_relaxed); } + // Per-stream receive-side controls (safe from any thread). void set_stream_gain(uint32_t ssrc, float gain); // 0.0–2.0, default 1.0 void set_stream_mute(uint32_t ssrc, bool mute); @@ -337,7 +340,8 @@ class AudioEngine { AudioParams params_{}; CaptureCallback capture_cb_; - std::atomic running_{false}; + std::atomic running_{false}; + std::atomic output_volume_{1.0f}; // Capture-side frame accumulators: miniaudio fires the capture (and loopback) callback at // whatever period the hardware/driver chooses — commonly 480 samples (10 ms) on WASAPI diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index d090a4e..e9f356b 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -1365,6 +1365,11 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) { return VC_OK; } +vc_result vc_client::set_output_volume(float gain) { + audio_engine_.set_output_volume(gain < 0.0f ? 0.0f : gain); + return VC_OK; +} + vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, bool noise_reduction) { if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; diff --git a/core/src/core/client.h b/core/src/core/client.h index e1fa74f..7b90d38 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -54,6 +54,7 @@ struct vc_client { vc_result set_vad_threshold(float threshold); vc_result set_push_to_talk(bool active); vc_result set_self_mute(bool mic_muted, bool deafened); + vc_result set_output_volume(float gain); vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, bool noise_reduction); vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id, diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 87fd913..e2f66f6 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -117,6 +117,11 @@ vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened) { return c->set_self_mute(mic_muted != 0, deafened != 0); } +vc_result vc_set_output_volume(vc_client* c, float gain) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->set_output_volume(gain); +} + vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain, int muted, int noise_reduction) { if (c == nullptr) return VC_ERR_INVALID_ARG;