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;