feat(windows): per-app screen-audio sharing (include/exclude)
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:
166
clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs
Normal file
166
clients/windows/VoiceCat.App/Forms/AppAudioPickerDialog.cs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user