この記事はC# Advent Calendar 2025 19日目の記事です。
N.Mです。昨日に引き続きC#の記事です。昨日の記事でローカルビルドしたNAudioを用いて解決した、WPFのWebView2と音声の話です。
はじめに
最近、WPFで作ったアプリをいじっていたところ、Discordなどで画面共有した際にWebView2で開いているページの音声が流れないという問題が発生しました。
WebView2というのは、ChromiumベースのウェブブラウザをWPFアプリで使用できるようにするコントロールで、MicrosoftからNuGetでパッケージが配布されています。1
この記事ではその問題の原因とどう解決したかを紹介しようと思います。
経緯
自分はDiscordで身内にゲーム画面を配信する用のWebアプリ、NM_MicDisplay for Webを作っています。2
このWebアプリの映像を画面共有する際、Chromeなどの通常のブラウザだとアドレスバーなどが邪魔なので、WPFで簡易ブラウザを作り、そのブラウザのウィンドウを画面共有しています。
最近、ゲームからの音声入力をWebアプリ側でキャプチャし3、そのままWebアプリから流すループバック機能を追加しました。4 しかし、その修正以降、配信を見ている友人から「ゲームの音声が聞こえない」という報告をいただきました。それで、今回の問題を調査、修正しようと思いました。
原因
Windowsの音量ミキサーを確認すると、画像のようにWPFのアプリではなく、"Microsoft Edge WebView2"というアプリが存在し、この音量を変えるとWPFで表示しているWebView2からの音量が確かに変わります。原因としては WPFアプリのプロセスとWebView2で音声を流すプロセスが別々になっており、画面共有でこの別になっているWebView2プロセスからの音声を取得できていない ことにあるようです。
WPFアプリのプロセスとWebView2のプロセスの構成の詳細は、Microsoftからの公式のドキュメントにも記載があります。確かにWPFのアプリ(ホストアプリプロセス)とWebView2で音声を流すプロセス(レンダラープロセス)が別々になっているようです。
解決方法
今回解決方法として採用したのが、 WebView2のプロセスから流れる音声をキャプチャし、そのままWPFアプリのプロセスから流す という方法です。
MicrosoftよりプロセスIDからそのプロセスで流されている音声をキャプチャするサンプル(Application loopback audio capture)が公開されています。このサンプルをもとにNAudioといった音声処理ライブラリで、同様の機能が実装されています。
NAudioの場合、正式リリースされているバージョンにはこの機能がまだ組み込まれていないので、昨日あげた記事:[C#] ローカルでビルドしたNAudioをNuGetで利用する にあるように、ローカルでNAudioをビルドして使えるようにする必要があります。
具体的には以下のような流れで処理を行います。音声周りの処理はNAudioで実現できます。
- 以下の条件を満たすWebView2で音声を流すプロセスを探し、そのプロセスIDを取得する
- 音声を流しているプロセスである
- プロセスの名前が
msedgewebview2である - そのプロセスがWPFアプリのプロセスの子孫にあたる(このWPFアプリで使用しているWebView2のプロセスかどうかを判別するために、この確認も必要です。)
- プロセスIDを用いて、プロセスから流されている音声をキャプチャする
- WPFのアプリでキャプチャした音声をそのまま再生する
今回の方法だと、WebView2からの音声とWPFアプリからの音声が同時に流れているので、同じ音声が重なって聞こえます。遅延によって響いているような聞こえ方になります。WebView2からの音声をミュートにすると、WPFアプリからも音声が聞こえなくなってしまいます。
自分の目的では許容できる範囲なので放置しています... (良い方法がある方はコメントをいただけると後学のために助かります。)
環境
- OS: Windows 11 Home (24H2)
- Visual Studioのバージョン: Visual Studio Community 2022 (Ver. 17.12.3)
- .NETのバージョン: .NET 9.0
- WebView2パッケージのバージョン: 1.0.3595.46
- ローカルビルドで使用するNAudioのコミットID: 808fc387ea45cec5b220a71d8582fa4d47d1ac0f
- System.Managementのバージョン: 10.0.0
プロセスの親子関係を調べるのに、System.Managementを使用しており、.NET 9.0ではnugetでSystem.Managementのパッケージをインストールする必要があります。
サンプル
サンプルリポジトリをGitHubにご用意しました。README.mdをご覧の上、ビルドしていただければ動作するはずです。
HexagramNM/ScreenSharableWebView2
解決のためのソースコード
具体的なソースコードを以下に示します。何をやっているかはコメントを参照してください。
using System.Diagnostics;
using System.Management;
using System.Windows;
using NAudio.Wave;
using NAudio.CoreAudioApi;
namespace ScreenSharableWebView2
{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
// WPFアプリのプロセスID
int currentProcessId = Process.GetCurrentProcess().Id;
// WebView2プロセスの名前
const string webviewProcessName = "msedgewebview2";
// WebView2プロセスからの音声をキャプチャするためのオブジェクト (NAudio)
WasapiCapture? webviewAudioCapture = null;
// WPFアプリから流す音声データを格納するバッファオブジェクト (NAudio)
BufferedWaveProvider? waveProvider = null;
// WPFアプリからwaveProviderにある音声を再生するためのオブジェクト (NAudio)
WasapiOut? waveOut = null;
// AudioRedirectを再度実行するためのタイマー
System.Timers.Timer? redirectAudioTimer = null;
public MainWindow()
{
InitializeComponent();
AudioRedirect();
// 中略(WPF内コントロールやイベントの設定)
}
// WebView2プロセスからの音声データを取得した際に呼び出されるイベントで、
// 取得した音声データをそのままWPFで再生する音声データ (waveProvider) に追加しています。
private void ReceivedCaptureAudio(object sender, WaveInEventArgs e)
{
waveProvider?.AddSamples(e.Buffer, 0, e.BytesRecorded);
}
// 引数で指定したIDのプロセスがWPFアプリのプロセスの子孫にあたるかをチェックし、
// 子孫にあたるならtrueを、そうでなければfalseを返します。
// 親プロセスのIDを取得する方法は以下のページを参考にしています。
//
// 【C#】親プロセスのプロセスIDを取得する(祖父プロセスIDも)
// https://umateku.com/archives/3300
private bool IsDescendantsProcess(int processId)
{
int checkingProcessId = processId;
while (true)
{
string query = $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId = {checkingProcessId}";
ManagementObjectSearcher searcher = new ManagementObjectSearcher("root\\CIMV2", query);
bool foundParent = false;
foreach(ManagementObject obj in searcher.Get())
{
int parentProcessId = Convert.ToInt32(obj["ParentProcessId"]);
// 最終的に親プロセスIDが0になるので、その場合にチェックを終了します。
// (そうしないと無限ループになってしまいます。)
if (parentProcessId <= 0)
{
break;
}
foundParent = true;
if (currentProcessId == parentProcessId)
{
return true;
}
else
{
checkingProcessId = parentProcessId;
}
break;
}
if (!foundParent)
{
break;
}
}
return false;
}
// 今回のWebView2プロセスの音声をキャプチャし、WPFアプリで再生できるよう設定するための関数です。
// MainWindowクラスのコンストラクタで呼び出します。
private async void AudioRedirect()
{
redirectAudioTimer?.Dispose();
redirectAudioTimer = null;
// この部分でWebView2で音声を流すプロセスを探しています。
// スピーカーなど音声を再生するデバイスを1つずつ見ていき、そのデバイスに音声を渡しているプロセスの中からWebView2プロセスを探しています。
MMDeviceEnumerator enumerator = new MMDeviceEnumerator();
MMDeviceCollection devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
foreach (MMDevice device in devices)
{
SessionCollection sessions = device.AudioSessionManager.Sessions;
for (int i = 0; i < sessions.Count; i++)
{
AudioSessionControl session = sessions[i];
try
{
Process process = Process.GetProcessById((int)session.GetProcessID);
if (IsDescendantsProcess((int)session.GetProcessID)
&& process.ProcessName.IndexOf(webviewProcessName, StringComparison.OrdinalIgnoreCase) != -1)
{
// WebView2で音声を流すプロセスが見つかったら、WebView2プロセスから音声をキャプチャする設定や、
// キャプチャした音声をWPFで再生する設定を行います。
MMDevice speakerDevice = new MMDeviceEnumerator().GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
webviewAudioCapture = await WasapiCapture.CreateForProcessCaptureAsync((int)session.GetProcessID, true);
waveProvider = new BufferedWaveProvider(webviewAudioCapture.WaveFormat);
waveOut = new WasapiOut(speakerDevice, AudioClientShareMode.Shared, true, 50);
waveProvider.DiscardOnBufferOverflow = true;
waveProvider.BufferDuration = TimeSpan.FromMilliseconds(100.0);
waveOut.Init(waveProvider);
waveOut.Play();
webviewAudioCapture.DataAvailable += ReceivedCaptureAudio;
webviewAudioCapture.StartRecording();
return;
}
}
catch (Exception)
{
continue;
}
}
}
// WebView2で音声を流すプロセスが見つからなかった場合は、タイマーを設定し、
// 1秒後再度AudioRedirectの処理が行われるようにしておきます。
redirectAudioTimer = new System.Timers.Timer(1000);
redirectAudioTimer.Elapsed += (s, e) => { AudioRedirect(); };
redirectAudioTimer.AutoReset = false;
redirectAudioTimer.Enabled = true;
redirectAudioTimer.Start();
}
// ESCキーが押された場合などに呼び出す、アプリケーションを終了させるための処理で、
// webviewAudioCaptureやwaveOutを停止しておきます。
private void CloseWindow()
{
webviewAudioCapture?.StopRecording();
waveOut?.Stop();
waveOut?.Dispose();
Close();
}
}
}
補足:プロセスからの音声のキャプチャ
プロセスからの音声のキャプチャはNAudio.CoreAudioApiにあるWasapiCapture.CreateForProcessCaptureAsyncメソッドを使用します。
このメソッドにプロセスIDと、子プロセスからの音声も含めるかどうかのフラグを引数に渡して呼び出す5 と、キャプチャするためのWasapiCaptureオブジェクトが生成されます。このオブジェクトにはDataAvailableイベントがあるので、キャプチャされた音声データを取得した際の処理をそのイベントに登録します。今回はReceivedCaptureAudioメソッドをイベントに設定しています。イベント設定後、WasapiCaptureオブジェクトのStartRecordingメソッドを呼び出すことでプロセスからの音声キャプチャが開始され、StopRecordingメソッドを呼び出すことでキャプチャを終了します。
補足:タイマーでAudioRedirectの処理を再度行えるようにしている理由
WebView2で音声を流すプロセスが見つからなかった場合は、再度AudioRedirectの処理を実行するタイマーを設定しています。
この理由としては、WebView2で表示しているWebページで音声が流れるまではWebView2で音声を流すプロセスが生成されず、WPFアプリ起動時にそのプロセスが見つからない可能性が高いからです。Webページで音声が流れ、WebView2で音声を流すプロセスが生成されたタイミングで、後からAudioRedirectの処理が実行できるようタイマーを仕込んでいます。
まとめ
以上、WPFアプリのプロセスとWebView2のプロセスが分離されていることによって、画面共有時にWebView2の音声が聞こえないという話でした。
このWPFアプリのプロセスとWebView2のプロセスが分離されていることは、今回のような音声だけでなくグラフィックスあたりでも気をつける必要がありそうです。
例えば、WPFアプリにあるWebView2のグラフィックスをどのGPUで処理させるかをWindowsのグラフィックス設定で設定する際は、WPFのexeファイルに対して設定するのではダメで、C:\Program Files (x86)\Microsoft\EdgeWebView\Application\142.0.3595.94\msedgewebview2.exeに対して設定する必要がありました。
-
WPFにもデフォルトのWebViewコントロールはあるのですが、こちらはかなり古いEdgeに準拠したものです。JavaScriptの言語仕様が古く、今のWebアプリも対応していないと自分が認識しているので、WebView2を使っています。 ↩
-
バーチャル背景用に作っていたのですが、外部のウィンドウの映像を流しつつ、自分のカメラ映像も映るように改造していました。このあたりの詳細は以下の記事で紹介しています。
↩ -
PS5やSwitch2などのゲーム機の場合はキャプチャボードからの音声入力をキャプチャしています。PCゲームについてはVB-Audio Virtual Cableという仮想の音声ケーブルに一旦音声を流し、そのケーブルから出てくる音声をキャプチャしています。 ↩
-
それまではWPFの簡易ブラウザ側にループバックの機能をつけていました。しかし、ブラウザとWebアプリそれぞれで設定が必要になり面倒だったので、Webアプリに機能を集約することにしました。 ↩
-
WasapiCapture.CreateForProcessCaptureAsyncは非同期メソッドなので、awaitでオブジェクト生成が完了するまで待機させる必要があります。 ↩
