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

@@ -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();