fix(windows): real native exclude + self-echo removal for screen audio
"All apps except selected" previously captured the complement of a frozen app snapshot in INCLUDE mode (missed late-launched apps and system sounds, wasted captures on silent windows). It now opens a single ProcessLoopbackCapture in EXCLUDE mode (AUDIOCLIENT_PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE) of the one chosen app — true system-mix-minus-one, dynamic so apps launched after sharing starts are included. The picker enforces single-selection in exclude mode (the activation params take one target PID). Adds an "Exclude VoiceCat's own audio (prevents echo)" checkbox (default on, entire-desktop only) that routes the desktop capture through the same EXCLUDE path targeting our own process id, killing the whole-device self-echo loop. No C++/ABI changes. Updates voice.md and PROGRESS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ public sealed class AppAudioPickerDialog : Form
|
||||
private readonly RadioButton _rdoAll;
|
||||
private readonly RadioButton _rdoOnly;
|
||||
private readonly RadioButton _rdoExcept;
|
||||
private readonly CheckBox _chkExcludeSelf;
|
||||
private readonly ListView _appList;
|
||||
private readonly Label _lblApps;
|
||||
|
||||
@@ -50,26 +51,42 @@ public sealed class AppAudioPickerDialog : Form
|
||||
_rdoOnly.CheckedChanged += OnModeChanged;
|
||||
_rdoExcept.CheckedChanged += OnModeChanged;
|
||||
|
||||
// ── Self-exclude (echo prevention) ───────────────────────────────────
|
||||
// Captures the whole desktop minus VoiceCat's own playback, so the channel
|
||||
// doesn't echo back the voices you're already hearing. Only applies to the
|
||||
// entire-desktop mode (the per-app modes already exclude this app's tree).
|
||||
_chkExcludeSelf = new CheckBox
|
||||
{
|
||||
Text = "E&xclude VoiceCat's own audio (prevents echo)",
|
||||
Checked = true,
|
||||
Location = new Point(28, 84),
|
||||
Size = new Size(344, 20),
|
||||
TabIndex = 3,
|
||||
};
|
||||
|
||||
// ── App list ────────────────────────────────────────────────────────
|
||||
_lblApps = new Label
|
||||
{
|
||||
Text = "Apps with active audio sessions:",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 90),
|
||||
Location = new Point(12, 112),
|
||||
Visible = false,
|
||||
TabIndex = 3,
|
||||
TabIndex = 4,
|
||||
};
|
||||
|
||||
_appList = new ListView
|
||||
{
|
||||
Location = new Point(12, 112),
|
||||
Size = new Size(360, 180),
|
||||
Location = new Point(12, 134),
|
||||
Size = new Size(360, 158),
|
||||
CheckBoxes = true,
|
||||
View = View.List,
|
||||
Visible = false,
|
||||
TabIndex = 4,
|
||||
TabIndex = 5,
|
||||
FullRowSelect = true,
|
||||
};
|
||||
// Exclude mode supports only one target process tree (WASAPI EXCLUDE takes a single
|
||||
// PID), so enforce a single check while "All apps except selected" is active.
|
||||
_appList.ItemCheck += OnAppItemCheck;
|
||||
|
||||
// ── Buttons ─────────────────────────────────────────────────────────
|
||||
var btnShare = new Button
|
||||
@@ -78,7 +95,7 @@ public sealed class AppAudioPickerDialog : Form
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(216, 308),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 5,
|
||||
TabIndex = 6,
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
@@ -86,7 +103,7 @@ public sealed class AppAudioPickerDialog : Form
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(297, 308),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 6,
|
||||
TabIndex = 7,
|
||||
};
|
||||
btnShare.Click += OnShareClick;
|
||||
|
||||
@@ -94,7 +111,8 @@ public sealed class AppAudioPickerDialog : Form
|
||||
CancelButton = btnCancel;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(384, 348);
|
||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _lblApps, _appList, btnShare, btnCancel]);
|
||||
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _chkExcludeSelf, _lblApps, _appList,
|
||||
btnShare, btnCancel]);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
@@ -121,16 +139,56 @@ public sealed class AppAudioPickerDialog : Form
|
||||
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
|
||||
_lblApps.Visible = showList;
|
||||
_appList.Visible = showList;
|
||||
_lblApps.Text = _rdoExcept.Checked
|
||||
? "App to exclude (everything else is shared):"
|
||||
: "Apps with active audio sessions:";
|
||||
|
||||
// Self-exclude only applies to entire-desktop; the per-app modes already exclude
|
||||
// this app's own tree (Include) or spend the single EXCLUDE slot on the chosen app.
|
||||
_chkExcludeSelf.Enabled = _rdoAll.Checked;
|
||||
|
||||
if (showList && _appList.Items.Count == 0)
|
||||
RefreshAppList();
|
||||
|
||||
// Switching into exclude mode: collapse any multi-selection down to a single item.
|
||||
if (_rdoExcept.Checked)
|
||||
KeepSingleCheck(firstCheckedOnly: true);
|
||||
}
|
||||
|
||||
// In exclude mode the list behaves like radio buttons: checking one item clears the rest.
|
||||
private bool _suppressItemCheck;
|
||||
|
||||
private void OnAppItemCheck(object? sender, ItemCheckEventArgs e)
|
||||
{
|
||||
if (_suppressItemCheck || !_rdoExcept.Checked || e.NewValue != CheckState.Checked)
|
||||
return;
|
||||
|
||||
_suppressItemCheck = true;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
if (item.Index != e.Index && item.Checked)
|
||||
item.Checked = false;
|
||||
_suppressItemCheck = false;
|
||||
}
|
||||
|
||||
// Leave at most one item checked (the first), unchecking the rest.
|
||||
private void KeepSingleCheck(bool firstCheckedOnly)
|
||||
{
|
||||
_suppressItemCheck = true;
|
||||
bool kept = false;
|
||||
foreach (ListViewItem item in _appList.Items)
|
||||
{
|
||||
if (!item.Checked) continue;
|
||||
if (firstCheckedOnly && !kept) { kept = true; continue; }
|
||||
item.Checked = false;
|
||||
}
|
||||
_suppressItemCheck = false;
|
||||
}
|
||||
|
||||
private void OnShareClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_rdoAll.Checked)
|
||||
{
|
||||
ChosenScope = new EntireDesktop();
|
||||
ChosenScope = new EntireDesktop(_chkExcludeSelf.Checked);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,7 +218,4 @@ public sealed class AppAudioPickerDialog : Form
|
||||
? new OnlyApps(checkedPids, checkedNames)
|
||||
: new AllExceptApps(checkedPids, checkedNames);
|
||||
}
|
||||
|
||||
/// <summary>All apps that were visible in the list when the user clicked Share.</summary>
|
||||
public IReadOnlyList<AudioAppInfo> VisibleApps => _apps;
|
||||
}
|
||||
|
||||
@@ -640,7 +640,7 @@ public partial class MainForm : Form
|
||||
|
||||
var scope = picker.ChosenScope;
|
||||
|
||||
if (scope is EntireDesktop)
|
||||
if (scope is EntireDesktop { ExcludeSelf: false })
|
||||
{
|
||||
// Existing whole-device WASAPI loopback path — core handles it.
|
||||
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
||||
@@ -654,7 +654,8 @@ public partial class MainForm : Form
|
||||
}
|
||||
else
|
||||
{
|
||||
// Per-app path: suppress core loopback, C# mixer feeds PCM.
|
||||
// External-feed path: suppress core loopback, C# mixer feeds PCM. Covers the
|
||||
// per-app modes and "entire desktop except VoiceCat" (single EXCLUDE of self).
|
||||
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
|
||||
if (result != VcResult.Ok)
|
||||
{
|
||||
@@ -664,12 +665,13 @@ public partial class MainForm : Form
|
||||
_screenStreamId = streamId;
|
||||
|
||||
_screenMixer = new ProcessAudioMixer();
|
||||
_screenMixer.Start(scope, _client, streamId, picker.VisibleApps);
|
||||
_screenMixer.Start(scope, _client, streamId);
|
||||
|
||||
string desc = scope switch
|
||||
{
|
||||
EntireDesktop => "entire desktop (excluding VoiceCat)",
|
||||
OnlyApps o => $"only {o.Pids.Count} app(s)",
|
||||
AllExceptApps a => $"all except {a.Pids.Count} app(s)",
|
||||
AllExceptApps a => $"all except {a.Names[0]}",
|
||||
_ => "apps",
|
||||
};
|
||||
AddActivity($"Sharing screen audio: {desc}");
|
||||
|
||||
Reference in New Issue
Block a user