feat(windows): per-app screen-audio sharing (include/exclude)
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

Adds "only selected apps" and "all apps except selected" screen-audio
modes to the Windows client, alongside the existing entire-desktop path.

Per-app capture uses WASAPI process loopback (AUDCLNT_ACTIVATIONTYPE_
PROCESS_LOOPBACK) via ProcessLoopbackCapture, mixed by ProcessAudioMixer
and fed to the core through vc_stream_feed_pcm (external_feed=1 so the
core skips its own loopback device).

Init must pass AUDCLNT_STREAMFLAGS_LOOPBACK | EVENTCALLBACK |
AUTOCONVERTPCM; the LOOPBACK flag is what makes the virtual endpoint
deliver rendered audio (without it every buffer is flagged SILENT) and
AUTOCONVERTPCM resamples the app's native format to 48k s16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 00:19:38 +02:00
parent 4f89d2d32d
commit e806b698ec
11 changed files with 1064 additions and 24 deletions

View File

@@ -0,0 +1,166 @@
using VoiceCat.App.Audio;
namespace VoiceCat.App.Forms;
/// <summary>
/// Modal dialog for selecting which apps' audio to share.
/// Returns the chosen <see cref="AppAudioScope"/> via <see cref="ChosenScope"/>,
/// or <see cref="DialogResult.Cancel"/> if the user dismissed without sharing.
/// </summary>
public sealed class AppAudioPickerDialog : Form
{
private readonly RadioButton _rdoAll;
private readonly RadioButton _rdoOnly;
private readonly RadioButton _rdoExcept;
private readonly ListView _appList;
private readonly Label _lblApps;
// Snapshot taken when the dialog opens (refresh on open, not on every check change).
private IReadOnlyList<AudioAppInfo> _apps = [];
public AppAudioScope? ChosenScope { get; private set; }
public AppAudioPickerDialog()
{
// ── Radio buttons ───────────────────────────────────────────────────
_rdoAll = new RadioButton
{
Text = "&Entire desktop",
Checked = true,
Location = new Point(12, 12),
Size = new Size(360, 20),
TabIndex = 0,
};
_rdoOnly = new RadioButton
{
Text = "&Only selected apps",
Location = new Point(12, 36),
Size = new Size(360, 20),
TabIndex = 1,
};
_rdoExcept = new RadioButton
{
Text = "All apps e&xcept selected",
Location = new Point(12, 60),
Size = new Size(360, 20),
TabIndex = 2,
};
_rdoAll.CheckedChanged += OnModeChanged;
_rdoOnly.CheckedChanged += OnModeChanged;
_rdoExcept.CheckedChanged += OnModeChanged;
// ── App list ────────────────────────────────────────────────────────
_lblApps = new Label
{
Text = "Apps with active audio sessions:",
AutoSize = true,
Location = new Point(12, 90),
Visible = false,
TabIndex = 3,
};
_appList = new ListView
{
Location = new Point(12, 112),
Size = new Size(360, 180),
CheckBoxes = true,
View = View.List,
Visible = false,
TabIndex = 4,
FullRowSelect = true,
};
// ── Buttons ─────────────────────────────────────────────────────────
var btnShare = new Button
{
Text = "&Share",
DialogResult = DialogResult.OK,
Location = new Point(216, 308),
Size = new Size(75, 27),
TabIndex = 5,
};
var btnCancel = new Button
{
Text = "&Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(297, 308),
Size = new Size(75, 27),
TabIndex = 6,
};
btnShare.Click += OnShareClick;
AcceptButton = btnShare;
CancelButton = btnCancel;
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(384, 348);
Controls.AddRange([_rdoAll, _rdoOnly, _rdoExcept, _lblApps, _appList, btnShare, btnCancel]);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Share App Audio";
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
RefreshAppList();
}
private void RefreshAppList()
{
_apps = AudioSessionEnumerator.GetAudioApps();
_appList.Items.Clear();
foreach (var app in _apps)
_appList.Items.Add(new ListViewItem($"{app.DisplayName} (PID {app.Pid})") { Tag = app.Pid });
}
private void OnModeChanged(object? sender, EventArgs e)
{
bool showList = _rdoOnly.Checked || _rdoExcept.Checked;
_lblApps.Visible = showList;
_appList.Visible = showList;
if (showList && _appList.Items.Count == 0)
RefreshAppList();
}
private void OnShareClick(object? sender, EventArgs e)
{
if (_rdoAll.Checked)
{
ChosenScope = new EntireDesktop();
return;
}
var checkedPids = new List<int>();
var checkedNames = new List<string>();
foreach (ListViewItem item in _appList.CheckedItems)
{
if (item.Tag is int pid)
{
checkedPids.Add(pid);
checkedNames.Add(item.Text);
}
}
if (checkedPids.Count == 0)
{
MessageBox.Show(
"Select at least one app, or choose 'Entire desktop'.",
"No apps selected",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
DialogResult = DialogResult.None; // prevent close
return;
}
ChosenScope = _rdoOnly.Checked
? 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;
}

View File

@@ -1,3 +1,4 @@
using VoiceCat.App.Audio;
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
@@ -22,6 +23,7 @@ public partial class MainForm : Form
// Voice state
private uint _micStreamId; // 0 = not started
private uint _screenStreamId; // 0 = not sharing screen audio
private ProcessAudioMixer? _screenMixer; // non-null only in per-app capture mode
private Keys _pttKey = Keys.F8;
private bool _serverMuted;
private bool _serverDeafened;
@@ -442,6 +444,7 @@ public partial class MainForm : Form
_currentChannelId = 0;
_micStreamId = 0;
_screenStreamId = 0;
_screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null;
txtCompose.Enabled = false;
btnSend.Enabled = false;
tsbJoinVoice.Enabled = false;
@@ -622,29 +625,73 @@ public partial class MainForm : Form
{
if (_screenStreamId == 0)
{
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
if (result == VcResult.Ok)
{
_screenStreamId = streamId;
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
AddActivity("Started sharing screen audio");
}
else
{
AddActivity($"Failed to start screen audio: {result}");
}
StartScreenAudio();
}
else
{
_client.StopStream(_screenStreamId);
_screenStreamId = 0;
tsbScreenShare.Text = "Share Screen Audio";
_miScreenShare.Text = "Share Screen &Audio";
AddActivity("Stopped sharing screen audio");
StopScreenAudio();
}
}
private void StartScreenAudio()
{
using var picker = new AppAudioPickerDialog();
if (picker.ShowDialog(this) != DialogResult.OK || picker.ChosenScope == null) return;
var scope = picker.ChosenScope;
if (scope is EntireDesktop)
{
// Existing whole-device WASAPI loopback path — core handles it.
var (result, streamId) = _client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start screen audio: {result}");
return;
}
_screenStreamId = streamId;
AddActivity("Sharing screen audio: entire desktop");
}
else
{
// Per-app path: suppress core loopback, C# mixer feeds PCM.
var (result, streamId) = _client.StartStreamExternalFeed(VcStreamKind.ScreenAudio, "App audio");
if (result != VcResult.Ok)
{
AddActivity($"Failed to start screen audio: {result}");
return;
}
_screenStreamId = streamId;
_screenMixer = new ProcessAudioMixer();
_screenMixer.Start(scope, _client, streamId, picker.VisibleApps);
string desc = scope switch
{
OnlyApps o => $"only {o.Pids.Count} app(s)",
AllExceptApps a => $"all except {a.Pids.Count} app(s)",
_ => "apps",
};
AddActivity($"Sharing screen audio: {desc}");
}
tsbScreenShare.Text = "Stop Screen Audio";
_miScreenShare.Text = "Stop Screen &Audio";
}
private void StopScreenAudio()
{
_screenMixer?.Stop();
_screenMixer?.Dispose();
_screenMixer = null;
_client.StopStream(_screenStreamId);
_screenStreamId = 0;
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);
@@ -1019,7 +1066,7 @@ public partial class MainForm : Form
_client.EventReceived -= OnEvent;
foreach (var win in _pmWindows.Values.ToList()) win.Close();
_pmWindows.Clear();
if (_screenStreamId != 0) _client.StopStream(_screenStreamId);
if (_screenStreamId != 0) { _screenMixer?.Stop(); _screenMixer?.Dispose(); _screenMixer = null; _client.StopStream(_screenStreamId); }
if (_micStreamId != 0) _client.StopStream(_micStreamId);
_client.Disconnect();
_client.Dispose();