feat(native): add window-scoped process audio (#93)

Add opt-in Windows 11 target-process audio capture, one AudioEncoder Opus stream, and one shared Pion audio track beside native video.
This commit is contained in:
AAA authored and GitHub committed 2026-08-21 02:44:46 +08:00
1 parent ce3cbe2efd
commit 6cd99545e6
25 files changed
+1516 -109

No files matched your search

+19 -17
View File
@@ -2,8 +2,8 @@
Date: 2026-08-21
Status: Accepted for the browser hint; native Windows implementation is a
planned P1 input.
Status: Accepted. The browser hint and opt-in Native Windows P1 slice are
source-complete; Native remains unpackaged, default-off, and undeployed.
## Context
@@ -35,8 +35,9 @@ Windows 11 desktop (build 22000 or newer) and is not a Windows 10 fallback.
3. The native sender's next audio slice is an explicit opt-in
`window-process-audio` mode on Windows 11. The local capture session owns
one selected target PID/process tree, one WGC/DXGI video source, and one
WASAPI loopback audio source. Process IDs, titles, paths, device identity,
and PCM stay local; they do not enter signaling, URLs, logs, or storage.
WASAPI loopback audio source. Process IDs, creation tokens, titles, paths,
device identity, and PCM stay local; they do not enter signaling, URLs,
logs, or storage.
4. If the target process exits, produces no render stream, or the loopback
activation is denied, the sender reports `audio unavailable` or `audio
silent` and stops/asks the user. It never widens to whole-system audio.
@@ -48,7 +49,7 @@ Windows 11 desktop (build 22000 or newer) and is not a Windows 10 fallback.
This is an interface boundary, not a new framework or a current wire change:
```text
NativeCaptureTarget { pid: uint32, includeProcessTree: true }
NativeCaptureTarget { pid: uint32, creationTime: uint64, includeProcessTree: true }
NativeCaptureSession.start(target)
-> VideoFrames(WGC/DXGI) + AudioPcm(WASAPI process loopback)
-> existing native sender timeline/encoder/fanout
@@ -66,7 +67,8 @@ The first product-wiring slice is deliberately smaller than the complete
native capture boundary. It adds an explicit Windows 11
`window-process-audio` input while leaving the current Web video capture and
codec path unchanged. A small Windows helper resolves the selected target PID
locally and sends timestamped PCM through the authenticated loopback bridge.
and creation time locally, revalidates both immediately before activation, and
sends timestamped PCM through the authenticated loopback bridge.
The existing Chrome/Edge sender UI uses `AudioEncoder` with `codec: "opus"`,
48 kHz, two channels, and 20 ms frames, then returns the encoded Opus packets to
the existing Go sender. This reuses the current browser prerequisite and avoids
@@ -84,8 +86,8 @@ QPC origin when native video replaces the Web source.
Expected implementation size is about **500--800 new LOC**, excluding the
browser/Windows SDK and existing video/MF fixture code: Windows loopback
capture and local IPC (250--400), Go audio envelope/timeline/track (150--250),
and UI lifecycle/status plus focused tests (100--150). This is a planned P1
input, not current product behavior or a deployment switch.
and UI lifecycle/status plus focused tests (100--150). The landed slice keeps
that boundary and is not a deployment switch.
The two source clocks are both converted from 100-ns QPC: WGC
`SystemRelativeTime` and WASAPI `IAudioCaptureClient::GetBuffer`'s
@@ -101,7 +103,8 @@ or `AUDCLNT_E_SERVICE_NOT_RUNNING`, protected-content silence, and a missing
render stream produce `audio unavailable` or `audio silent`. There is no retry
to whole-system loopback. No administrator privilege is required, but the
helper must run in the interactive user's session and honor Windows consent and
privacy settings. PID, title, path, device identity, and PCM remain local.
privacy settings. PID, creation token, title, path, device identity, and PCM
remain local.
The first smoke is one direct Viewer: a test window emits a known tone and
visual marker, while an independent voice process and notification emit
@@ -109,7 +112,9 @@ different markers. Require an Opus inbound track and rendered video/audio,
monotonic QPC timestamps, and a bounded A/V offset; verify the unrelated
markers are absent and that target exit/silence stops or asks without widening
capture. SFU/TURN, second Viewer, endurance, and the native WGC video swap are
follow-up gates.
follow-up gates. On 2026-08-21, 100 20-ms chunks isolated a 440 Hz target from
an independent 880 Hz process by 4017.8x. Chrome 151 then received one audio
track and 495 inbound Opus packets while video decoded/rendered 296 frames.
## Consequences
@@ -122,10 +127,7 @@ follow-up gates.
## Follow-Up TODO
- Add the Windows-only WGC + WASAPI process-loopback fixture beside the existing
Media Foundation H.264 fixture, then land the bounded audio-only P1 slice.
- Feed its timestamped PCM/video pair into the existing native sender only
after the isolation and sync checks pass; native WGC video replacement stays
a separate follow-up.
- Retain a short matrix result in `docs/research/`; do not enable the mode by
default or deploy it from this ADR alone.
- Package the helper beside the sender and keep explicit target selection
default-off.
- Keep native WGC video, a second Viewer, SFU/TURN, endurance, and real-game
A/V sync as separate follow-ups.
+1 -1
View File
@@ -30,7 +30,7 @@ Last updated: 2026-08-21
- C+B quality reparenting is edge-local and cooldown-bound; no score, timer, or global parent penalty.
- Flagship media is UDP; HTTPS/WSS stays TLS/TCP; the old release remains rollback-only.
- Treat settings as ceilings and degradation as unclassified. Use correlated Host A+B/Viewer C and one-variable evidence; never force AV1, infer by UA, or create a composite score.
- System capture can cause film-audio loss/self-echo; Web cannot isolate processes. Diagnose A/B/C and sync; Windows 11 native game-tree audio is a candidate, Windows 10 unresolved. See `docs/research/browser-screen-audio-quality.md`.
- Web capture cannot isolate process audio. ADR-0008 Windows 11 native audio is opt-in/source-only: opaque local selection, no system-mix fallback or exposed PID, and one shared Opus track; one direct Viewer received 495 packets. Packaging, game A/V sync, other routes, and Windows 10 remain open.
- Viewer-local names and the opt-in Web participant roster remain control-plane-only. Web Host names are deployed in the current release and stay socket/localStorage-only; Native remains outside the capability boundary.
- Do not add scene detection, dynamic-FPS control, or forced AV1 without negotiation, encode, game, CPU/GPU, and sender evidence.
- ADR-0002 grant/public access, the v3 room-password migration, and Web Host display names are deployed. Names/presence remain session-only without account or roster tables.
@@ -208,6 +208,24 @@ known target tone/visual marker and independent voice/notification markers;
SFU/TURN, a second Viewer, endurance, and native WGC video replacement remain
later gates.
### Retained P1 Result (2026-08-21)
The source-complete Windows helper emits process-tree-only 48 kHz stereo s16 in
20-ms chunks. Chrome 151 encodes the chunks once with `AudioEncoder`/Opus, and
Go packetizes the same encoded stream once into a shared Pion track. The UI
receives an opaque memory-only target ID. PID and the enumeration-time process
creation token are revalidated locally and never enter public signaling, a URL,
storage, or diagnostics.
One bounded run played 440 Hz in the selected target and 880 Hz in an
independent process. Across 100 chunks, amplitudes were 0.240225 and 0.0000598
(4017.8x); no system-mix fallback was used. One direct Viewer then received one
audio track and 495 inbound Opus packets while video decoded/rendered 296
frames. There was no fatal or encoder error. The sole failed assertion was the
existing generic Pion two-second outbound snapshot, which also missed fresh
deltas in earlier video loopbacks. This is functional evidence, not packaging,
real-game sync, second-Viewer, SFU/TURN, or endurance evidence.
Run one bounded matrix rather than a full route Cartesian product: exact
production and current `main` on Windows Chrome/Edge for tab/window/monitor,
audio selected/unselected, and a simultaneous voice call; then the native
+2 -2
View File
@@ -42,6 +42,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
- Host A+B and authenticated P2P Viewer C are sanitized, generation-bound, read-only, and fail closed for stale, ambiguous, or SFU-fed evidence; no raw media metadata is retained.
- The `a11a73d` built-in TURN canary is rejected: local allocation passed, but direct Host/Pion Viewer failed before forced relay. Full rollback restored STUN-only client ICE and zero allocations.
- Chrome 151 loopbacks decoded VP8 299 and H.264 opt-in 299/298 rendered at 1280x720 without fatal/encoder errors. Hardware attribution, Pion timing, multi-viewer/endurance/public/native proof remain open; production stays VP8.
- Native Win11 audio is source-only/default-off. Smoke isolated target 4018x; one Viewer got 495 Opus packets. Packaging, game sync, Win10, and other routes remain open.
- Access protocol/config/HTTP/storage/SQLite/signaling focused tests pass, including commit-first teardown and v1 rollback.
- Source adds selected Host ingress and peer last-mile for every enabled room; focused routing/config tests pass with Peer ICE STUN-only.
- `61a87ae` Host names, `d1a4fd4` window-audio hint, and `cf149df` all-room routing are active; the stale restart assertion is historical only.
@@ -52,7 +53,6 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
- The final deployment proves process/config health, not a retained SFU frame, retry/failback, selected UDP pair, admission rescue, edge cap, quality, or resource deltas; game-share load/blur remains unclassified.
- Android Chrome/iOS Safari Viewer leaves remain unverified and conservatively leaf-only. Mobile Web Host is unsupported; native senders are planned only.
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
- Real audio and heterogeneous clients remain unverified. System capture can lose film audio or echo voice calls; Web has no process isolation. Diagnose A/B/C and sync; gate Windows 11 game audio; Windows 10 unresolved.
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
- ADR-0006 has one Viewer proof; two-edge/FIFO, physical hardware, endurance, public, packaged-native, and browser-diversity proof remain open.
- Browser fanout is host two/viewer one; any accepted endpoint relay stays capped at two downstream edges.
@@ -74,7 +74,7 @@ evacuation. Native source accepts increasing capture timestamps shorter than
duration; VP8 and explicit H.264 Viewer decode/render are recorded. Hardware
preference is not physical proof. Next prove fresh Pion
outbound diagnostics before viewer 2/FIFO/hardware/endurance/deploy.
Audio A/B/C may proceed without displacing P0.
Package Native audio and test one real-game A/V pair later without displacing P0.
Ordinary Peer ICE stays STUN-only; participant-wide TURN is removed. The
selected-edge tuple is configured in production, but Host ingress still needs one
forced-relay canary before any media-success claim. Retained performance/resource
+20
View File
@@ -0,0 +1,20 @@
# Windows Process-Audio Helper
This Windows 11 helper is the local input for Native sender process-tree audio.
It enumerates visible top-level windows locally and captures only the explicitly
selected process plus its children through WASAPI application loopback. It has
no whole-system fallback and no network code.
Build it outside the repository, then place both executables in the same output
directory (or set `SCREENER_PROCESS_AUDIO_HELPER` for a development run):
```powershell
$out = Join-Path ([IO.Path]::GetTempPath()) 'screener-native-audio'
./native/process-audio-helper/build.ps1 -OutputDirectory $out
```
The implementation follows Microsoft's MIT-licensed Application Loopback
sample contract without copying its WIL/Media Foundation fixture machinery:
- <https://github.com/microsoft/Windows-classic-samples/tree/main/Samples/ApplicationLoopback>
- <https://learn.microsoft.com/en-us/windows/win32/api/audioclientactivationparams/ns-audioclientactivationparams-audioclient_activation_params>
+40
View File
@@ -0,0 +1,40 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$OutputDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$helperDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $helperDirectory '..\..'))
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
$repositoryPrefix = $repositoryRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if ($outputPath.Equals($repositoryRoot, [StringComparison]::OrdinalIgnoreCase) -or
$outputPath.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'OutputDirectory must be outside the repository.'
}
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$installationPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if ([string]::IsNullOrWhiteSpace($installationPath)) {
throw 'Visual Studio C++ Build Tools were not found.'
}
$developerCommand = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
$sourcePath = Join-Path $helperDirectory 'main.cpp'
$executablePath = Join-Path $outputPath 'screener-process-audio.exe'
$objectPath = Join-Path $outputPath 'main.obj'
New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
$compile = @(
'call "{0}" -arch=x64 -host_arch=x64 >nul' -f $developerCommand
'cl.exe /nologo /std:c++20 /EHsc /W4 /WX /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /D_WIN32_WINNT=0x0A00 /DNTDDI_VERSION=0x0A00000A "{0}" /Fo:"{1}" /Fe:"{2}" /link ole32.lib mmdevapi.lib runtimeobject.lib user32.lib' -f $sourcePath, $objectPath, $executablePath
) -join ' && '
& cmd.exe /d /s /c $compile
if ($LASTEXITCODE -ne 0) {
throw "Process-audio helper compilation failed with exit code $LASTEXITCODE."
}
Write-Output $executablePath
+334
View File
@@ -0,0 +1,334 @@
#define NOMINMAX
#include <windows.h>
#include <audioclient.h>
#include <audioclientactivationparams.h>
#include <fcntl.h>
#include <io.h>
#include <mmdeviceapi.h>
#include <wrl.h>
#include <wrl/implements.h>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
using Microsoft::WRL::ComPtr;
using Microsoft::WRL::FtmBase;
using Microsoft::WRL::Make;
using Microsoft::WRL::RuntimeClass;
using Microsoft::WRL::RuntimeClassFlags;
using Microsoft::WRL::ClassicCom;
namespace {
constexpr UINT32 kSampleRate = 48'000;
constexpr UINT16 kChannels = 2;
constexpr UINT16 kBytesPerSample = 2;
constexpr UINT32 kFramesPerChunk = 960;
constexpr DWORD kBytesPerChunk = kFramesPerChunk * kChannels * kBytesPerSample;
constexpr UINT64 kChunkDuration100ns = 200'000;
constexpr size_t kMaxWindows = 100;
class ActivationHandler final : public RuntimeClass<
RuntimeClassFlags<ClassicCom>, FtmBase, IActivateAudioInterfaceCompletionHandler> {
public:
explicit ActivationHandler(HANDLE completed) : completed_(completed) {}
STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* operation) override {
ComPtr<IUnknown> activated;
HRESULT activationResult = E_UNEXPECTED;
result_ = operation->GetActivateResult(&activationResult, &activated);
if (SUCCEEDED(result_)) result_ = activationResult;
if (SUCCEEDED(result_)) result_ = activated.As(&client_);
SetEvent(completed_);
return S_OK;
}
HRESULT Result() const { return result_; }
ComPtr<IAudioClient> Client() const { return client_; }
private:
HANDLE completed_ = nullptr;
HRESULT result_ = E_UNEXPECTED;
ComPtr<IAudioClient> client_;
};
struct WindowTarget {
DWORD pid;
UINT64 creationTime;
std::string title;
};
HRESULT ReadProcessCreationTime(HANDLE process, UINT64* creationTime) {
FILETIME created{}, exited{}, kernel{}, user{};
if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) {
return HRESULT_FROM_WIN32(GetLastError());
}
ULARGE_INTEGER value{};
value.LowPart = created.dwLowDateTime;
value.HighPart = created.dwHighDateTime;
if (value.QuadPart == 0) return HRESULT_FROM_WIN32(ERROR_INVALID_DATA);
*creationTime = value.QuadPart;
return S_OK;
}
std::string Utf8(const std::wstring& value) {
if (value.empty()) return {};
const int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
if (size <= 0) return {};
std::string result(static_cast<size_t>(size), '\0');
if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size, nullptr, nullptr) != size) {
return {};
}
return result;
}
std::string JsonString(const std::string& value) {
static constexpr char hex[] = "0123456789abcdef";
std::string result = "\"";
for (const unsigned char character : value) {
switch (character) {
case '\\': result += "\\\\"; break;
case '"': result += "\\\""; break;
case '\b': result += "\\b"; break;
case '\f': result += "\\f"; break;
case '\n': result += "\\n"; break;
case '\r': result += "\\r"; break;
case '\t': result += "\\t"; break;
default:
if (character < 0x20) {
result += "\\u00";
result += hex[character >> 4];
result += hex[character & 0xf];
} else {
result.push_back(static_cast<char>(character));
}
}
}
result += '"';
return result;
}
BOOL CALLBACK CollectWindow(HWND window, LPARAM parameter) {
auto* targets = reinterpret_cast<std::vector<WindowTarget>*>(parameter);
if (targets->size() >= kMaxWindows) return FALSE;
if (!IsWindowVisible(window) || GetWindow(window, GW_OWNER) != nullptr) return TRUE;
const int length = GetWindowTextLengthW(window);
if (length <= 0 || length > 512) return TRUE;
DWORD pid = 0;
GetWindowThreadProcessId(window, &pid);
if (pid == 0 || pid == GetCurrentProcessId()) return TRUE;
std::wstring title(static_cast<size_t>(length) + 1, L'\0');
const int copied = GetWindowTextW(window, title.data(), length + 1);
if (copied <= 0) return TRUE;
title.resize(static_cast<size_t>(copied));
std::string utf8 = Utf8(title);
if (utf8.empty()) return TRUE;
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (process == nullptr) return TRUE;
UINT64 creationTime = 0;
const HRESULT result = ReadProcessCreationTime(process, &creationTime);
CloseHandle(process);
if (SUCCEEDED(result)) targets->push_back({pid, creationTime, std::move(utf8)});
return TRUE;
}
int ListWindows() {
std::vector<WindowTarget> targets;
if (!EnumWindows(CollectWindow, reinterpret_cast<LPARAM>(&targets))) return 2;
std::cout << '[';
for (size_t index = 0; index < targets.size(); ++index) {
if (index != 0) std::cout << ',';
std::cout << "{\"pid\":" << targets[index].pid << ",\"creationTime\":"
<< targets[index].creationTime << ",\"title\":"
<< JsonString(targets[index].title) << '}';
}
std::cout << ']';
return std::cout.good() ? 0 : 2;
}
void PutUint32BE(BYTE* output, UINT32 value) {
for (int index = 3; index >= 0; --index) {
output[index] = static_cast<BYTE>(value & 0xff);
value >>= 8;
}
}
void PutUint64BE(BYTE* output, UINT64 value) {
for (int index = 7; index >= 0; --index) {
output[index] = static_cast<BYTE>(value & 0xff);
value >>= 8;
}
}
HRESULT WriteAll(HANDLE output, const BYTE* data, DWORD size) {
while (size > 0) {
DWORD written = 0;
if (!WriteFile(output, data, size, &written, nullptr) || written == 0) {
return HRESULT_FROM_WIN32(GetLastError());
}
data += written;
size -= written;
}
return S_OK;
}
HRESULT WritePCM(HANDLE output, UINT64 timestamp100ns, const BYTE* data) {
std::array<BYTE, 28> header{};
header[0] = 'S'; header[1] = 'P'; header[2] = 'C'; header[3] = 'M';
header[4] = 1;
PutUint64BE(header.data() + 8, timestamp100ns);
PutUint64BE(header.data() + 16, kChunkDuration100ns);
PutUint32BE(header.data() + 24, kBytesPerChunk);
HRESULT result = WriteAll(output, header.data(), static_cast<DWORD>(header.size()));
return SUCCEEDED(result) ? WriteAll(output, data, kBytesPerChunk) : result;
}
HRESULT ActivateProcessLoopback(DWORD pid, HANDLE completed, ComPtr<IAudioClient>* client) {
AUDIOCLIENT_ACTIVATION_PARAMS parameters{};
parameters.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;
parameters.ProcessLoopbackParams.TargetProcessId = pid;
parameters.ProcessLoopbackParams.ProcessLoopbackMode =
PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;
PROPVARIANT variant{};
variant.vt = VT_BLOB;
variant.blob.cbSize = sizeof(parameters);
variant.blob.pBlobData = reinterpret_cast<BYTE*>(&parameters);
auto handler = Make<ActivationHandler>(completed);
if (!handler) return E_OUTOFMEMORY;
ComPtr<IActivateAudioInterfaceAsyncOperation> operation;
HRESULT result = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,
__uuidof(IAudioClient), &variant,
handler.Get(), &operation);
if (FAILED(result)) return result;
if (WaitForSingleObject(completed, 10'000) != WAIT_OBJECT_0) return HRESULT_FROM_WIN32(WAIT_TIMEOUT);
result = handler->Result();
if (SUCCEEDED(result)) *client = handler->Client();
return result;
}
HRESULT Capture(DWORD pid, UINT64 expectedCreationTime) {
HANDLE process = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (process == nullptr) return HRESULT_FROM_WIN32(GetLastError());
HANDLE completed = CreateEventW(nullptr, FALSE, FALSE, nullptr);
HANDLE sampleReady = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (completed == nullptr || sampleReady == nullptr) {
const HRESULT result = HRESULT_FROM_WIN32(GetLastError());
if (completed != nullptr) CloseHandle(completed);
if (sampleReady != nullptr) CloseHandle(sampleReady);
CloseHandle(process);
return result;
}
UINT64 creationTime = 0;
HRESULT result = ReadProcessCreationTime(process, &creationTime);
if (SUCCEEDED(result) && creationTime != expectedCreationTime) {
result = HRESULT_FROM_WIN32(ERROR_INVALID_DATA);
}
ComPtr<IAudioClient> client;
if (SUCCEEDED(result)) result = ActivateProcessLoopback(pid, completed, &client);
ComPtr<IAudioCaptureClient> capture;
WAVEFORMATEX format{};
format.wFormatTag = WAVE_FORMAT_PCM;
format.nChannels = kChannels;
format.nSamplesPerSec = kSampleRate;
format.wBitsPerSample = kBytesPerSample * 8;
format.nBlockAlign = kChannels * kBytesPerSample;
format.nAvgBytesPerSec = kSampleRate * format.nBlockAlign;
if (SUCCEEDED(result)) {
result = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK |
AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
0, 0, &format, nullptr);
}
if (SUCCEEDED(result)) result = client->GetService(IID_PPV_ARGS(&capture));
if (SUCCEEDED(result)) result = client->SetEventHandle(sampleReady);
if (SUCCEEDED(result)) result = client->Start();
std::vector<BYTE> pending;
size_t consumed = 0;
UINT64 nextTimestamp = 0;
HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE);
const HANDLE waits[] = {process, sampleReady};
while (SUCCEEDED(result)) {
const DWORD wait = WaitForMultipleObjects(2, waits, FALSE, 1'000);
if (wait == WAIT_OBJECT_0) {
result = HRESULT_FROM_WIN32(ERROR_PROCESS_ABORTED);
break;
}
if (wait == WAIT_TIMEOUT) continue;
if (wait != WAIT_OBJECT_0 + 1) {
result = HRESULT_FROM_WIN32(GetLastError());
break;
}
UINT32 frames = 0;
while (SUCCEEDED(result = capture->GetNextPacketSize(&frames)) && frames > 0) {
BYTE* data = nullptr;
DWORD flags = 0;
UINT64 devicePosition = 0;
UINT64 qpcPosition = 0;
result = capture->GetBuffer(&data, &frames, &flags, &devicePosition, &qpcPosition);
if (FAILED(result)) break;
const size_t bytes = static_cast<size_t>(frames) * format.nBlockAlign;
if ((flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) {
capture->ReleaseBuffer(frames);
continue;
}
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) {
pending.clear();
consumed = 0;
}
if (pending.size() == consumed) {
pending.clear();
consumed = 0;
nextTimestamp = qpcPosition;
}
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0) {
pending.insert(pending.end(), bytes, 0);
} else {
pending.insert(pending.end(), data, data + bytes);
}
capture->ReleaseBuffer(frames);
while (pending.size() - consumed >= kBytesPerChunk) {
result = WritePCM(output, nextTimestamp, pending.data() + consumed);
if (FAILED(result)) break;
consumed += kBytesPerChunk;
nextTimestamp += kChunkDuration100ns;
}
if (FAILED(result)) break;
if (consumed > kBytesPerChunk * 4) {
pending.erase(pending.begin(), pending.begin() + static_cast<ptrdiff_t>(consumed));
consumed = 0;
}
}
}
if (client) client->Stop();
CloseHandle(sampleReady);
CloseHandle(completed);
CloseHandle(process);
return result;
}
} // namespace
int wmain(int argc, wchar_t** argv) {
if (argc == 2 && std::wstring(argv[1]) == L"--list") return ListWindows();
if (argc != 4 || std::wstring(argv[1]) != L"--capture") return 2;
wchar_t* pidEnd = nullptr;
const unsigned long parsedPID = wcstoul(argv[2], &pidEnd, 10);
wchar_t* creationEnd = nullptr;
const unsigned long long parsedCreationTime = wcstoull(argv[3], &creationEnd, 10);
if (parsedPID == 0 || pidEnd == argv[2] || *pidEnd != L'\0' ||
parsedCreationTime == 0 || creationEnd == argv[3] || *creationEnd != L'\0') return 2;
if (_setmode(_fileno(stdout), _O_BINARY) == -1) return 2;
HRESULT result = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(result)) return 2;
result = Capture(static_cast<DWORD>(parsedPID), static_cast<UINT64>(parsedCreationTime));
CoUninitialize();
if (FAILED(result)) std::cerr << "process_audio_capture_failed hresult=0x" << std::hex << result << '\n';
return SUCCEEDED(result) ? 0 : 2;
}
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/pion/rtcp v1.2.17
github.com/pion/rtp v1.10.5
github.com/pion/webrtc/v4 v4.2.18
golang.org/x/sys v0.41.0
)
require (
@@ -27,6 +28,5 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
+113 -9
View File
@@ -16,6 +16,7 @@ import (
"github.com/TNTcraftHIM/Screener/native/sender/internal/media"
"github.com/TNTcraftHIM/Screener/native/sender/internal/remote"
"github.com/TNTcraftHIM/Screener/native/sender/internal/windowaudio"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
@@ -59,12 +60,16 @@ type App struct {
configTimeout time.Duration
activeViewers int
waitingViewers int
audioProvider windowaudio.Provider
audioTargets map[string]windowaudio.Target
audioTarget *windowaudio.Target
}
type startRequest struct {
ServerURL string `json:"serverUrl"`
Password string `json:"password"`
Codec string `json:"codec,omitempty"`
ServerURL string `json:"serverUrl"`
Password string `json:"password"`
Codec string `json:"codec,omitempty"`
AudioTargetID string `json:"audioTargetId,omitempty"`
}
type encoderConfig struct {
@@ -75,6 +80,12 @@ type encoderConfig struct {
FPS int `json:"fps"`
Bitrate int `json:"bitrate"`
EncoderInstances int `json:"encoderInstances"`
Audio bool `json:"audio"`
}
type localAudioTarget struct {
ID string `json:"id"`
Title string `json:"title"`
}
func New() (*App, error) {
@@ -88,6 +99,8 @@ func New() (*App, error) {
attachTimeout: mediaAttachTimeout,
configTimeout: mediaConfigTimeout,
codec: media.CodecVP8,
audioProvider: windowaudio.NewProvider(),
audioTargets: make(map[string]windowaudio.Target),
}, nil
}
@@ -144,6 +157,8 @@ func (app *App) ServeHTTP(response http.ResponseWriter, request *http.Request) {
app.handleStart(response, request)
case "/api/stop":
app.handleStop(response, request)
case "/api/audio-targets":
app.handleAudioTargets(response, request)
case "/media":
app.handleMedia(response, request)
default:
@@ -188,6 +203,16 @@ func (app *App) handleStart(response http.ResponseWriter, request *http.Request)
return
}
app.mu.Lock()
var audioTarget *windowaudio.Target
if input.AudioTargetID != "" {
selected, ok := app.audioTargets[input.AudioTargetID]
if !ok {
app.mu.Unlock()
writeJSON(response, http.StatusBadRequest, map[string]string{"error": "selected audio target is no longer available"})
return
}
audioTarget = &selected
}
if app.starting || app.session != nil {
app.mu.Unlock()
writeJSON(response, http.StatusConflict, map[string]string{"error": "A sharing session is already active"})
@@ -205,6 +230,7 @@ func (app *App) handleStart(response http.ResponseWriter, request *http.Request)
BaseURL: baseURL,
HostAdmissionPassword: strings.TrimSpace(input.Password),
Codec: codec,
EnableAudio: audioTarget != nil,
OnEvent: func(event remote.Event) { app.emit(generation, event) },
})
app.mu.Lock()
@@ -219,6 +245,7 @@ func (app *App) handleStart(response http.ResponseWriter, request *http.Request)
app.sessionCancel = startCancel
app.room = room
app.codec = codec
app.audioTarget = audioTarget
app.attachTimer = time.AfterFunc(app.attachTimeout, func() {
app.mu.Lock()
expired := app.generation == generation && app.session == session && app.mediaConn == nil
@@ -254,6 +281,35 @@ func (app *App) handleStop(response http.ResponseWriter, request *http.Request)
response.WriteHeader(http.StatusNoContent)
}
func (app *App) handleAudioTargets(response http.ResponseWriter, request *http.Request) {
if !app.authorizeAPI(response, request, http.MethodPost) {
return
}
targets, err := app.audioProvider.List(request.Context())
if err != nil {
writeJSON(response, http.StatusOK, map[string]any{
"supported": false, "targets": []localAudioTarget{},
"reason": "Windows 11 process audio helper is unavailable",
})
return
}
localTargets := make([]localAudioTarget, 0, len(targets))
resolved := make(map[string]windowaudio.Target, len(targets))
for _, target := range targets {
id, tokenErr := newOpaqueToken()
if tokenErr != nil {
writeJSON(response, http.StatusInternalServerError, map[string]string{"error": "create local audio target identity failed"})
return
}
resolved[id] = target
localTargets = append(localTargets, localAudioTarget{ID: id, Title: target.Title})
}
app.mu.Lock()
app.audioTargets = resolved
app.mu.Unlock()
writeJSON(response, http.StatusOK, map[string]any{"supported": true, "targets": localTargets})
}
func (app *App) handleMedia(response http.ResponseWriter, request *http.Request) {
if !exactRequest(request, app.expectedHost, app.origin) || request.Method != http.MethodGet {
http.Error(response, "forbidden", http.StatusForbidden)
@@ -285,7 +341,7 @@ func (app *App) handleMedia(response http.ResponseWriter, request *http.Request)
app.releaseMediaClaim(generation, claimID, nil)
return
}
connection.SetReadLimit(media.MaxFrameBytes + media.FrameHeaderBytes)
connection.SetReadLimit(media.MaxMediaPayload + media.EnvelopeHeaderBytes)
app.mu.Lock()
if app.generation != generation || app.session != session || app.mediaClaimID != claimID {
app.mu.Unlock()
@@ -332,7 +388,8 @@ func (app *App) handleMedia(response http.ResponseWriter, request *http.Request)
return
}
var config encoderConfig
if err = decodeLocalPayload(payload, &config); err != nil || !validEncoderConfigForCodec(config, session.Codec()) {
if err = decodeLocalPayload(payload, &config); err != nil ||
!validEncoderConfigForCodec(config, session.Codec()) || config.Audio != session.AudioEnabled() {
session.Fail(errors.New("local encoder configuration is invalid"))
_ = connection.Close(websocket.StatusPolicyViolation, "invalid encoder config")
return
@@ -341,6 +398,39 @@ func (app *App) handleMedia(response http.ResponseWriter, request *http.Request)
session.Fail(fmt.Errorf("acknowledge local encoder configuration: %w", err))
return
}
app.mu.Lock()
target := app.audioTarget
app.mu.Unlock()
captureContext, cancelCapture := context.WithCancel(app.ctx)
audioDone := make(chan struct{})
if target != nil {
_ = app.writeLocal(connection, map[string]string{"kind": "audio-state", "state": "starting"})
go func(selected windowaudio.Target) {
defer close(audioDone)
captureErr := app.audioProvider.Capture(captureContext, selected, func(chunk windowaudio.PCMChunk) error {
envelope, encodeErr := media.EncodePacket(media.Packet{
Kind: media.KindPCM, Timestamp100ns: chunk.Timestamp100ns,
Duration100ns: chunk.Duration100ns, Data: chunk.Data,
})
if encodeErr != nil {
return encodeErr
}
return app.writeLocalBinary(connection, envelope)
})
if captureErr != nil && captureContext.Err() == nil {
_ = app.writeLocal(connection, map[string]string{
"kind": "audio-state", "state": "unavailable",
"message": "Target audio stopped; system audio was not substituted",
})
}
}(*target)
} else {
close(audioDone)
}
defer func() {
cancelCapture()
<-audioDone
}()
for {
messageType, payload, err = connection.Read(app.ctx)
if err != nil {
@@ -354,13 +444,18 @@ func (app *App) handleMedia(response http.ResponseWriter, request *http.Request)
_ = connection.Close(websocket.StatusUnsupportedData, "binary frames required")
return
}
frame, decodeErr := media.DecodeFrame(payload)
packet, decodeErr := media.DecodePacket(payload)
if decodeErr != nil {
session.Fail(fmt.Errorf("decode local encoded frame: %w", decodeErr))
_ = connection.Close(websocket.StatusPolicyViolation, "invalid encoded frame")
session.Fail(fmt.Errorf("decode local encoded media: %w", decodeErr))
_ = connection.Close(websocket.StatusPolicyViolation, "invalid encoded media")
return
}
if err = session.WriteFrame(frame); err != nil {
if packet.Kind != media.KindVideo && packet.Kind != media.KindOpus {
session.Fail(errors.New("local media bridge received an invalid encoded media kind"))
_ = connection.Close(websocket.StatusPolicyViolation, "encoded media required")
return
}
if err = session.WriteMedia(packet); err != nil {
return
}
}
@@ -426,6 +521,14 @@ func (app *App) writeLocal(connection *websocket.Conn, value any) error {
return wsjson.Write(ctx, connection, value)
}
func (app *App) writeLocalBinary(connection *websocket.Conn, payload []byte) error {
app.writeMu.Lock()
defer app.writeMu.Unlock()
ctx, cancel := context.WithTimeout(app.ctx, localWriteTimeout)
defer cancel()
return connection.Write(ctx, websocket.MessageBinary, payload)
}
func (app *App) releaseMediaClaim(generation, claimID uint64, connection *websocket.Conn) {
app.mu.Lock()
defer app.mu.Unlock()
@@ -465,6 +568,7 @@ func (app *App) stopActiveGeneration(expectedGeneration uint64) {
}
app.room = remote.Room{}
app.codec = media.CodecVP8
app.audioTarget = nil
app.activeViewers = 0
app.waitingViewers = 0
app.mu.Unlock()
+69 -9
View File
@@ -3,7 +3,6 @@ package app
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"net/http"
@@ -15,6 +14,7 @@ import (
"github.com/TNTcraftHIM/Screener/native/sender/internal/media"
"github.com/TNTcraftHIM/Screener/native/sender/internal/remote"
"github.com/TNTcraftHIM/Screener/native/sender/internal/windowaudio"
"github.com/coder/websocket"
)
@@ -62,6 +62,49 @@ func TestDecodeLocalPayloadIsStrictAndSingleValued(t *testing.T) {
}
}
func TestAudioTargetListingKeepsProcessIdentityBehindAnOpaqueLocalID(t *testing.T) {
application, err := New()
if err != nil {
t.Fatal(err)
}
application.audioProvider = fakeAudioProvider{targets: []windowaudio.Target{{
PID: 424_242, CreationTime: 987_654_321, Title: "Game Window",
}}}
if _, err = application.Start(); err != nil {
t.Fatal(err)
}
defer application.Close()
request, err := http.NewRequest(http.MethodPost, application.origin+"/api/audio-targets", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Origin", application.origin)
request.Header.Set(processTokenHeader, application.token)
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var result struct {
Supported bool `json:"supported"`
Targets []localAudioTarget `json:"targets"`
}
if json.NewDecoder(response.Body).Decode(&result) != nil || !result.Supported || len(result.Targets) != 1 {
t.Fatalf("audio target response = %+v", result)
}
if result.Targets[0].ID == "" || result.Targets[0].Title != "Game Window" ||
strings.Contains(result.Targets[0].ID, "424242") || strings.Contains(result.Targets[0].ID, "987654321") {
t.Fatalf("audio target was not locally anonymized: %+v", result.Targets[0])
}
application.mu.Lock()
resolved := application.audioTargets[result.Targets[0].ID]
application.mu.Unlock()
if resolved.PID != 424_242 || resolved.CreationTime != 987_654_321 {
t.Fatal("opaque target did not resolve in process memory")
}
}
func TestStaleSessionGenerationCannotMutateOrStopTheCurrentSession(t *testing.T) {
application := &App{generation: 7, activeViewers: 1, waitingViewers: 2}
application.emit(6, remote.Event{Kind: "viewer-count", ActiveViewers: 9, WaitingViewers: 9})
@@ -193,19 +236,26 @@ func TestConfiguredMediaFramesWithCaptureJitterReachFanout(t *testing.T) {
application, events, terminal := startObservedTestApplication(t)
connection := openConfiguredMedia(t, application)
payload := make([]byte, media.FrameHeaderBytes+4)
payload[0] = 1
binary.BigEndian.PutUint64(payload[1:9], 1_000_000)
binary.BigEndian.PutUint64(payload[9:17], 33_333)
copy(payload[media.FrameHeaderBytes:], []byte{0x10, 0x00, 0x00, 0x00})
payload, err := media.EncodePacket(media.Packet{
Kind: media.KindVideo, KeyFrame: true, Timestamp100ns: 10_000_000,
Duration100ns: 333_330, Data: []byte{0x10, 0x00, 0x00, 0x00},
})
if err != nil {
t.Fatal(err)
}
writeContext, cancelWrite := context.WithTimeout(context.Background(), time.Second)
err := connection.Write(writeContext, websocket.MessageBinary, payload)
err = connection.Write(writeContext, websocket.MessageBinary, payload)
cancelWrite()
if err != nil {
t.Fatalf("write encoded frame: %v", err)
}
payload[0] = 2
binary.BigEndian.PutUint64(payload[1:9], 1_033_000)
payload, err = media.EncodePacket(media.Packet{
Kind: media.KindVideo, Timestamp100ns: 10_330_000,
Duration100ns: 333_330, Data: []byte{0x10, 0x00, 0x00, 0x00},
})
if err != nil {
t.Fatal(err)
}
writeContext, cancelWrite = context.WithTimeout(context.Background(), time.Second)
err = connection.Write(writeContext, websocket.MessageBinary, payload)
cancelWrite()
@@ -638,3 +688,13 @@ func appRoomResponse(origin string) map[string]any {
"viewerPolicy": "private-link", "viewerGrantExpiresAt": expiresAt.Format(time.RFC3339), "expiresAt": expiresAt.Format(time.RFC3339),
}
}
type fakeAudioProvider struct{ targets []windowaudio.Target }
func (provider fakeAudioProvider) List(context.Context) ([]windowaudio.Target, error) {
return provider.targets, nil
}
func (fakeAudioProvider) Capture(context.Context, windowaudio.Target, func(windowaudio.PCMChunk) error) error {
return nil
}
+141 -8
View File
@@ -12,6 +12,10 @@ const FRAME_DURATION = Math.round(1_000_000 / FPS);
const MAX_ENCODER_QUEUE = 2;
const MAX_SOCKET_BUFFER = 512 * 1024;
const MAX_FRAME_BYTES = 1 << 20;
const ENVELOPE_HEADER_BYTES = 20;
const MEDIA_KIND_VIDEO = 1;
const MEDIA_KIND_PCM = 2;
const MEDIA_KIND_OPUS = 3;
const fragment = new URLSearchParams(location.hash.slice(1));
const processToken = fragment.get("token") || "";
@@ -21,6 +25,8 @@ const elements = {
serverURL: document.querySelector("#server-url"),
password: document.querySelector("#password"),
codec: document.querySelector("#codec"),
audioTarget: document.querySelector("#audio-target"),
refreshAudio: document.querySelector("#refresh-audio"),
start: document.querySelector("#start"),
stop: document.querySelector("#stop"),
preview: document.querySelector("#preview"),
@@ -36,11 +42,14 @@ const elements = {
bridgeDiagnostic: document.querySelector("#bridge-diagnostic"),
helperDiagnostic: document.querySelector("#helper-diagnostic"),
edgeDiagnostic: document.querySelector("#edge-diagnostic"),
audioStatus: document.querySelector("#audio-status"),
};
let stream = null;
let processorReader = null;
let encoder = null;
let audioEncoder = null;
let silentAudioChunks = 0;
let mediaSocket = null;
let sharing = false;
let stopping = false;
@@ -71,6 +80,8 @@ elements.copy.addEventListener("click", async () => {
showError("无法复制链接,请直接打开邀请链接");
}
});
elements.refreshAudio.addEventListener("click", () => void loadAudioTargets());
if (processToken) void loadAudioTargets();
async function startSharing() {
if (sharing || stopping || startInFlight) return;
@@ -83,6 +94,13 @@ async function startSharing() {
elements.start.disabled = true;
const generation = ++startGeneration;
const codec = selectedCodec();
const audioTargetId = elements.audioTarget.value;
if (audioTargetId && (!("AudioEncoder" in window) || !("AudioData" in window))) {
showError("当前浏览器缺少 WebCodecs Opus 编码支持,请关闭目标进程音频或使用当前版 Chrome / Edge");
startInFlight = false;
elements.start.disabled = false;
return;
}
setStatus("选择屏幕");
try {
const capturedStream = await navigator.mediaDevices.getDisplayMedia({
@@ -111,6 +129,7 @@ async function startSharing() {
serverUrl: elements.serverURL.value,
password: elements.password.value,
codec,
audioTargetId,
});
assertCurrentStart(generation);
if (pendingStartGeneration === generation) pendingStartGeneration = 0;
@@ -120,7 +139,8 @@ async function startSharing() {
elements.inviteLink.textContent = room.inviteUrl;
elements.invite.hidden = false;
await connectMediaSocket(generation, codec);
if (audioTargetId) await startAudioEncoder(generation);
await connectMediaSocket(generation, codec, Boolean(audioTargetId));
assertCurrentStart(generation);
const activeEncoder = await startEncoder(generation, codec);
assertCurrentStart(generation);
@@ -139,7 +159,7 @@ async function startSharing() {
}
}
async function connectMediaSocket(generation, codec) {
async function connectMediaSocket(generation, codec, audio) {
const url = new URL("/media", location.href);
url.protocol = "ws:";
const socket = new WebSocket(url, [`screener.token.${processToken}`]);
@@ -166,6 +186,7 @@ async function connectMediaSocket(generation, codec) {
fps: FPS,
bitrate: BITRATE,
encoderInstances: 1,
audio,
}));
await accepted;
assertCurrentStart(generation, socket);
@@ -275,12 +296,13 @@ function sendEncodedChunk(generation, activeEncoder, chunk) {
}
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
const message = new ArrayBuffer(17 + data.byteLength);
const view = new DataView(message);
view.setUint8(0, chunk.type === "key" ? 1 : 2);
view.setBigUint64(1, BigInt(Math.max(0, chunk.timestamp)), false);
view.setBigUint64(9, BigInt(chunk.duration || FRAME_DURATION), false);
new Uint8Array(message, 17).set(data);
const message = mediaEnvelope(
MEDIA_KIND_VIDEO,
chunk.type === "key" ? 1 : 0,
BigInt(Math.max(0, chunk.timestamp)) * 10n,
BigInt(chunk.duration || FRAME_DURATION) * 10n,
data,
);
mediaSocket.send(message);
metrics.encoderOutputs += 1;
metrics.encodedBytes += data.byteLength;
@@ -293,6 +315,10 @@ function sendEncodedChunk(generation, activeEncoder, chunk) {
function handleLocalEvent(generation, socket, event) {
if (generation !== startGeneration || socket !== mediaSocket) return;
if (event.data instanceof ArrayBuffer) {
handlePCM(generation, event.data);
return;
}
if (typeof event.data !== "string") return;
const message = parseLocalMessage(event.data);
if (!message) return;
@@ -305,6 +331,11 @@ function handleLocalEvent(generation, socket, event) {
updateDiagnostics();
}
if (message.kind === "warning" && message.message) showError(message.message);
if (message.kind === "audio-state") {
elements.audioStatus.textContent = message.state === "unavailable"
? "不可用(未回退系统声音)"
: "正在连接目标进程";
}
if (message.kind === "fatal") {
void failSharing(generation, message.message || "分享会话已失败并停止");
}
@@ -347,6 +378,12 @@ async function stopSharing(expectedGeneration = startGeneration) {
rememberError(error);
}
encoder = null;
try {
if (audioEncoder && audioEncoder.state !== "closed") audioEncoder.close();
} catch (error) {
rememberError(error);
}
audioEncoder = null;
const socket = mediaSocket;
mediaSocket = null;
try {
@@ -365,6 +402,7 @@ async function stopSharing(expectedGeneration = startGeneration) {
} finally {
frameNumber = 0;
forceKeyFrame = true;
silentAudioChunks = 0;
captureSettings = null;
nativeDiagnostics = null;
metrics = emptyMetrics();
@@ -379,6 +417,7 @@ async function stopSharing(expectedGeneration = startGeneration) {
elements.start.disabled = startInFlight;
showViewerCounts(0, 0);
setStatus("未开始");
elements.audioStatus.textContent = "关闭";
stopping = false;
}
}
@@ -446,6 +485,100 @@ async function localRequest(path, body) {
return result;
}
async function loadAudioTargets() {
elements.refreshAudio.disabled = true;
const previous = elements.audioTarget.value;
try {
const result = await localRequest("/api/audio-targets");
elements.audioTarget.replaceChildren(new Option("关闭", ""));
for (const target of Array.isArray(result.targets) ? result.targets : []) {
if (target?.id && target?.title) elements.audioTarget.add(new Option(target.title, target.id));
}
if ([...elements.audioTarget.options].some((option) => option.value === previous)) {
elements.audioTarget.value = previous;
}
elements.audioTarget.disabled = !result.supported;
elements.audioStatus.textContent = result.supported ? "关闭" : "Windows 11 helper 不可用";
} catch (error) {
elements.audioTarget.disabled = true;
elements.audioStatus.textContent = readableError(error, "窗口列表不可用");
} finally {
elements.refreshAudio.disabled = false;
}
}
async function startAudioEncoder(generation) {
const config = { codec: "opus", sampleRate: 48_000, numberOfChannels: 2, bitrate: 128_000 };
const support = await AudioEncoder.isConfigSupported(config);
assertCurrentStart(generation);
if (!support.supported) throw new Error("当前浏览器不支持 WebCodecs Opus 编码");
let active;
active = new AudioEncoder({
output: (chunk) => sendEncodedAudio(generation, active, chunk),
error: (error) => void failSharing(generation, `音频编码器失败:${error.message}`),
});
audioEncoder = active;
active.configure(support.config);
elements.audioStatus.textContent = "等待目标进程音频";
}
function handlePCM(generation, message) {
if (generation !== startGeneration || !audioEncoder || audioEncoder.state !== "configured") return;
if (audioEncoder.encodeQueueSize >= 4) return;
try {
const view = new DataView(message);
if (message.byteLength <= ENVELOPE_HEADER_BYTES || view.getUint8(0) !== 1 ||
view.getUint8(1) !== MEDIA_KIND_PCM || view.getUint8(2) !== 0 || view.getUint8(3) !== 0) {
throw new Error("本地 PCM envelope 无效");
}
const data = new Uint8Array(message, ENVELOPE_HEADER_BYTES);
if (data.byteLength % 4 !== 0) throw new Error("本地 PCM 帧长度无效");
let audible = false;
for (const byte of data) if (byte !== 0) { audible = true; break; }
silentAudioChunks = audible ? 0 : silentAudioChunks + 1;
elements.audioStatus.textContent = silentAudioChunks >= 100
? "目标进程当前无声(未回退系统声音)"
: "目标进程树音频";
const audio = new AudioData({
format: "s16", sampleRate: 48_000, numberOfChannels: 2,
numberOfFrames: data.byteLength / 4,
timestamp: Number(view.getBigUint64(4, false) / 10n), data,
});
audioEncoder.encode(audio);
audio.close();
} catch (error) {
void failSharing(generation, readableError(error, "处理目标进程音频失败"));
}
}
function sendEncodedAudio(generation, activeEncoder, chunk) {
if (generation !== startGeneration || stopping || audioEncoder !== activeEncoder ||
!mediaSocket || mediaSocket.readyState !== WebSocket.OPEN) return;
if (chunk.byteLength < 1 || chunk.byteLength > 64 * 1024 ||
mediaSocket.bufferedAmount > MAX_SOCKET_BUFFER) {
void failSharing(generation, "本地音频桥接超过安全上限");
return;
}
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
mediaSocket.send(mediaEnvelope(
MEDIA_KIND_OPUS, 0, BigInt(Math.max(0, chunk.timestamp)) * 10n,
BigInt(chunk.duration || 20_000) * 10n, data,
));
}
function mediaEnvelope(kind, flags, timestamp100ns, duration100ns, data) {
const message = new ArrayBuffer(ENVELOPE_HEADER_BYTES + data.byteLength);
const view = new DataView(message);
view.setUint8(0, 1);
view.setUint8(1, kind);
view.setUint8(2, flags);
view.setBigUint64(4, timestamp100ns, false);
view.setBigUint64(12, duration100ns, false);
new Uint8Array(message, ENVELOPE_HEADER_BYTES).set(data);
return message;
}
function showViewerCounts(activeValue, waitingValue) {
const active = boundedCount(activeValue);
const waiting = boundedCount(waitingValue);
+9 -2
View File
@@ -45,8 +45,15 @@
<option value="h264">H.264 (hardware preference)</option>
</select>
</label>
<label>
<span>目标进程音频</span>
<span class="select-row">
<select id="audio-target"><option value="">关闭</option></select>
<button id="refresh-audio" class="icon-button" type="button" title="刷新窗口列表" aria-label="刷新窗口列表">↻</button>
</span>
</label>
<p class="audio-note">请在浏览器画面选择器中选择同一目标。音频只包含所选进程树,失败时不会改用系统混音。</p>
<button id="start" class="button primary" type="button">开始分享</button>
<p class="notice">当前 canary 仅发送画面,不含音频。</p>
<p id="error" class="error" role="alert"></p>
<details>
<summary>技术信息</summary>
@@ -57,7 +64,7 @@
<div><dt>本地桥接</dt><dd id="bridge-diagnostic">尚无数据</dd></div>
<div><dt>Helper</dt><dd id="helper-diagnostic">尚无数据</dd></div>
<div><dt>出边</dt><dd id="edge-diagnostic">无活动媒体边</dd></div>
<div><dt>音频</dt><dd>当前 canary 未启用,电影与视频音质仍待验收</dd></div>
<div><dt>音频</dt><dd id="audio-status">关闭</dd></div>
</dl>
</details>
</aside>
+5 -3
View File
@@ -16,8 +16,8 @@
* { box-sizing: border-box; }
html, body { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #101310; }
button, input { font: inherit; letter-spacing: 0; }
button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
button, input, select { font: inherit; letter-spacing: 0; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
.app-header {
display: flex;
@@ -44,7 +44,9 @@ video { display: block; width: 100%; height: 100%; object-fit: contain; }
.controls { display: grid; align-content: start; gap: 18px; padding: 32px 24px; border-left: 1px solid var(--border); background: var(--surface); }
label { display: grid; gap: 8px; color: var(--muted); font-size: 13px; }
input { width: 100%; min-height: 44px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 6px; color: #f1f3f1; background: #101310; }
input, select { width: 100%; min-width: 0; min-height: 44px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 6px; color: #f1f3f1; background: #101310; }
.select-row { display: grid; grid-template-columns: minmax(0, 1fr) 44px; gap: 8px; }
.audio-note { margin: -8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.button, .icon-button { min-height: 44px; border: 1px solid transparent; border-radius: 6px; color: inherit; cursor: pointer; }
.button:disabled { cursor: not-allowed; opacity: .5; }
.primary { color: #07150c; font-weight: 700; background: var(--green); }
+178
View File
@@ -0,0 +1,178 @@
package media
import (
"context"
"errors"
"fmt"
"math"
"sync"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
)
const (
audioClockRate = 48_000
audioQueueDepth = 16
OpusSDPFmtpLine = "minptime=10;useinbandfec=1"
)
type AudioMetrics struct {
PacketsWritten uint64 `json:"packetsWritten"`
SourceRTPPacketsWritten uint64 `json:"sourceRtpPacketsWritten"`
SourceRTPBytesWritten uint64 `json:"sourceRtpBytesWritten"`
LastSourceTimestamp100ns uint64 `json:"lastSourceTimestamp100ns"`
}
type AudioFanout struct {
track *webrtc.TrackLocalStaticRTP
writer rtpWriter
queue chan Packet
ctx context.Context
cancel context.CancelFunc
done chan struct{}
closeOnce sync.Once
fatalOnce sync.Once
onFatal func(error)
metricsMu sync.Mutex
metrics AudioMetrics
}
func NewAudioFanout(onFatal func(error)) (*AudioFanout, error) {
capability := webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus, ClockRate: audioClockRate, Channels: 2, SDPFmtpLine: OpusSDPFmtpLine,
}
track, err := webrtc.NewTrackLocalStaticRTP(capability, "audio", "screener")
if err != nil {
return nil, fmt.Errorf("create shared Opus track: %w", err)
}
return newAudioFanout(track, track, onFatal), nil
}
func newAudioFanout(track *webrtc.TrackLocalStaticRTP, writer rtpWriter, onFatal func(error)) *AudioFanout {
ctx, cancel := context.WithCancel(context.Background())
fanout := &AudioFanout{
track: track, writer: writer, queue: make(chan Packet, audioQueueDepth),
ctx: ctx, cancel: cancel, done: make(chan struct{}), onFatal: onFatal,
}
go fanout.writeLoop()
return fanout
}
func (fanout *AudioFanout) Track() *webrtc.TrackLocalStaticRTP { return fanout.track }
func (fanout *AudioFanout) Push(packet Packet) error {
if packet.Kind != KindOpus {
return errors.New("shared audio fanout requires Opus")
}
if err := validatePacket(packet); err != nil {
return err
}
select {
case <-fanout.ctx.Done():
return errors.New("shared audio fanout is closed")
case fanout.queue <- packet:
return nil
default:
return errors.New("shared audio queue exceeded its bound")
}
}
func (fanout *AudioFanout) Snapshot() AudioMetrics {
fanout.metricsMu.Lock()
defer fanout.metricsMu.Unlock()
return fanout.metrics
}
func (fanout *AudioFanout) Close() {
fanout.stop()
<-fanout.done
}
func (fanout *AudioFanout) writeLoop() {
defer close(fanout.done)
timeline := newAudioTimeline()
for {
select {
case <-fanout.ctx.Done():
return
case packet := <-fanout.queue:
packets, err := timeline.Packetize(packet)
if err != nil {
fanout.fail(err)
return
}
var sourceBytes uint64
for _, rtpPacket := range packets {
if err = fanout.writer.WriteRTP(rtpPacket); err != nil {
fanout.fail(errors.New("write shared Opus RTP packet failed"))
return
}
sourceBytes += uint64(rtpPacket.MarshalSize())
}
fanout.metricsMu.Lock()
fanout.metrics.PacketsWritten++
fanout.metrics.SourceRTPPacketsWritten += uint64(len(packets))
fanout.metrics.SourceRTPBytesWritten += sourceBytes
fanout.metrics.LastSourceTimestamp100ns = packet.Timestamp100ns
fanout.metricsMu.Unlock()
}
}
}
func (fanout *AudioFanout) fail(err error) {
fanout.fatalOnce.Do(func() {
fanout.stop()
if fanout.onFatal != nil {
go func() { <-fanout.done; fanout.onFatal(err) }()
}
})
}
func (fanout *AudioFanout) stop() { fanout.closeOnce.Do(fanout.cancel) }
type audioTimeline struct {
packetizer rtp.Packetizer
hasPrevious bool
previousTimestamp uint64
}
func newAudioTimeline() *audioTimeline {
return &audioTimeline{packetizer: rtp.NewPacketizer(
1200, 111, 0, &codecs.OpusPayloader{}, rtp.NewRandomSequencer(), audioClockRate,
)}
}
func (timeline *audioTimeline) Packetize(packet Packet) ([]*rtp.Packet, error) {
if timeline.hasPrevious {
if packet.Timestamp100ns <= timeline.previousTimestamp {
return nil, errors.New("encoded audio timestamp did not increase")
}
delta, err := audioSamples(packet.Timestamp100ns - timeline.previousTimestamp)
if err != nil {
return nil, err
}
if delta == 0 {
delta = 1
}
timeline.packetizer.SkipSamples(delta)
}
packets := timeline.packetizer.Packetize(packet.Data, 0)
if len(packets) == 0 {
return nil, errors.New("Opus access unit exceeded the RTP payload bound")
}
timeline.hasPrevious = true
timeline.previousTimestamp = packet.Timestamp100ns
return packets, nil
}
func audioSamples(value100ns uint64) (uint32, error) {
seconds := value100ns / 10_000_000
remainder := value100ns % 10_000_000
samples := seconds*audioClockRate + (remainder*audioClockRate+5_000_000)/10_000_000
if samples > math.MaxUint32 {
return 0, errors.New("encoded audio timestamp gap is too large")
}
return uint32(samples), nil
}
@@ -0,0 +1,56 @@
package media
import (
"testing"
"time"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
)
func TestAudioFanoutUsesOneOpusTimeline(t *testing.T) {
written := make(chan *rtp.Packet, 2)
fanout := newAudioFanout(nil, rtpWriterFunc(func(packet *rtp.Packet) error {
written <- packet.Clone()
return nil
}), nil)
defer fanout.Close()
for _, timestamp := range []uint64{100_000, 300_000} {
if err := fanout.Push(Packet{
Kind: KindOpus, Timestamp100ns: timestamp, Duration100ns: 200_000,
Data: []byte{0xf8, 0xff, 0xfe},
}); err != nil {
t.Fatal(err)
}
}
first := awaitAudioPacket(t, written)
second := awaitAudioPacket(t, written)
if delta := second.Timestamp - first.Timestamp; delta != 960 {
t.Fatalf("Opus RTP timestamp delta = %d, want 960", delta)
}
}
func TestOpusTrackCapabilityIsBrowserCompatible(t *testing.T) {
fanout, err := NewAudioFanout(nil)
if err != nil {
t.Fatal(err)
}
defer fanout.Close()
codec := fanout.Track().Codec()
if codec.MimeType != webrtc.MimeTypeOpus || codec.ClockRate != 48_000 || codec.Channels != 2 ||
codec.SDPFmtpLine != OpusSDPFmtpLine {
t.Fatalf("Opus capability = %+v", codec)
}
}
func awaitAudioPacket(t *testing.T, packets <-chan *rtp.Packet) *rtp.Packet {
t.Helper()
select {
case packet := <-packets:
return packet
case <-time.After(time.Second):
t.Fatal("timed out waiting for shared Opus RTP")
return nil
}
}
+104
View File
@@ -0,0 +1,104 @@
package media
import (
"encoding/binary"
"errors"
"time"
)
const (
EnvelopeVersion = 1
EnvelopeHeaderBytes = 20
MaxMediaPayload = 1 << 20
MaxPCMBytes = 48_000 * 2 * 2 / 10 // 100 ms of 48 kHz stereo s16.
flagKeyFrame = 1 << 0
)
type Kind uint8
const (
KindVideo Kind = 1
KindPCM Kind = 2
KindOpus Kind = 3
)
type Packet struct {
Kind Kind
KeyFrame bool
Timestamp100ns uint64
Duration100ns uint64
Data []byte
}
func EncodePacket(packet Packet) ([]byte, error) {
if err := validatePacket(packet); err != nil {
return nil, err
}
payload := make([]byte, EnvelopeHeaderBytes+len(packet.Data))
payload[0] = EnvelopeVersion
payload[1] = byte(packet.Kind)
if packet.KeyFrame {
payload[2] = flagKeyFrame
}
binary.BigEndian.PutUint64(payload[4:12], packet.Timestamp100ns)
binary.BigEndian.PutUint64(payload[12:20], packet.Duration100ns)
copy(payload[EnvelopeHeaderBytes:], packet.Data)
return payload, nil
}
func DecodePacket(payload []byte) (Packet, error) {
if len(payload) <= EnvelopeHeaderBytes || len(payload)-EnvelopeHeaderBytes > MaxMediaPayload {
return Packet{}, errors.New("media envelope is empty, truncated, or too large")
}
if payload[0] != EnvelopeVersion || payload[3] != 0 || payload[2]&^byte(flagKeyFrame) != 0 {
return Packet{}, errors.New("media envelope header is invalid")
}
packet := Packet{
Kind: Kind(payload[1]),
KeyFrame: payload[2]&flagKeyFrame != 0,
Timestamp100ns: binary.BigEndian.Uint64(payload[4:12]),
Duration100ns: binary.BigEndian.Uint64(payload[12:20]),
Data: append([]byte(nil), payload[EnvelopeHeaderBytes:]...),
}
if err := validatePacket(packet); err != nil {
return Packet{}, err
}
return packet, nil
}
func (packet Packet) VideoFrame() (Frame, error) {
if packet.Kind != KindVideo {
return Frame{}, errors.New("media envelope is not encoded video")
}
return Frame{
KeyFrame: packet.KeyFrame,
TimestampMicros: packet.Timestamp100ns / 10,
DurationMicros: packet.Duration100ns / 10,
Data: packet.Data,
}, nil
}
func validatePacket(packet Packet) error {
if len(packet.Data) == 0 || len(packet.Data) > MaxMediaPayload || packet.Duration100ns == 0 ||
packet.Duration100ns > uint64(time.Second/(100*time.Nanosecond)) {
return errors.New("media envelope violates the bounded media contract")
}
switch packet.Kind {
case KindVideo:
if packet.Duration100ns < 10 || packet.Duration100ns%10 != 0 {
return errors.New("video envelope timestamp unit is invalid")
}
case KindPCM:
if packet.KeyFrame || len(packet.Data) > MaxPCMBytes || len(packet.Data)%4 != 0 {
return errors.New("PCM envelope is invalid")
}
case KindOpus:
if packet.KeyFrame || len(packet.Data) > 64<<10 {
return errors.New("Opus envelope is invalid")
}
default:
return errors.New("media envelope kind is invalid")
}
return nil
}
+3 -29
View File
@@ -2,12 +2,10 @@ package media
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sync"
"time"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
@@ -15,10 +13,8 @@ import (
)
const (
FrameHeaderBytes = 17
MaxFrameBytes = 1 << 20
queueCapacity = 8
videoClockRate = 90_000
queueCapacity = 8
videoClockRate = 90_000
)
// Codec selects the encoded representation carried by the native bridge.
@@ -135,7 +131,7 @@ func (fanout *Fanout) Track() *webrtc.TrackLocalStaticRTP {
}
func (fanout *Fanout) Push(frame Frame) error {
if len(frame.Data) == 0 || len(frame.Data) > MaxFrameBytes || frame.DurationMicros == 0 {
if len(frame.Data) == 0 || len(frame.Data) > MaxMediaPayload || frame.DurationMicros == 0 {
return errors.New("encoded frame violates the bounded media contract")
}
result, err := fanout.queue.Push(frame)
@@ -283,25 +279,3 @@ func microsToSamples(micros uint64) (uint32, error) {
}
return uint32(samples), nil
}
func DecodeFrame(payload []byte) (Frame, error) {
if len(payload) <= FrameHeaderBytes || len(payload)-FrameHeaderBytes > MaxFrameBytes {
return Frame{}, errors.New("encoded frame is empty, truncated, or too large")
}
frame := Frame{
TimestampMicros: binary.BigEndian.Uint64(payload[1:9]),
DurationMicros: binary.BigEndian.Uint64(payload[9:17]),
Data: append([]byte(nil), payload[FrameHeaderBytes:]...),
}
switch payload[0] {
case 1:
frame.KeyFrame = true
case 2:
default:
return Frame{}, errors.New("encoded frame type is invalid")
}
if frame.DurationMicros == 0 || frame.DurationMicros > uint64(time.Second/time.Microsecond) {
return Frame{}, errors.New("encoded frame duration is invalid")
}
return frame, nil
}
+13 -9
View File
@@ -1,7 +1,6 @@
package media
import (
"encoding/binary"
"errors"
"runtime"
"sync"
@@ -207,14 +206,19 @@ func TestFanoutCloseDropsBacklogWithoutLateWritesOrFatal(t *testing.T) {
}
}
func TestDecodeFrame(t *testing.T) {
payload := make([]byte, FrameHeaderBytes+3)
payload[0] = 1
binary.BigEndian.PutUint64(payload[1:9], 123)
binary.BigEndian.PutUint64(payload[9:17], 33_333)
copy(payload[17:], []byte{1, 2, 3})
frame, err := DecodeFrame(payload)
func TestTypedEnvelopePreservesVideoTimeline(t *testing.T) {
payload, err := EncodePacket(Packet{
Kind: KindVideo, KeyFrame: true, Timestamp100ns: 1_230,
Duration100ns: 333_330, Data: []byte{1, 2, 3},
})
if err != nil {
t.Fatal(err)
}
packet, err := DecodePacket(payload)
if err != nil {
t.Fatal(err)
}
frame, err := packet.VideoFrame()
if err != nil {
t.Fatal(err)
}
+22 -5
View File
@@ -97,20 +97,37 @@ func newPeer(session *Session, peerID string, slot int, configuration webrtc.Con
}
}
})
sender, err := connection.AddTrack(session.fanout.Track())
videoSender, err := connection.AddTrack(session.fanout.Track())
if err != nil {
_ = connection.Close()
return nil, errors.New("bind shared encoded track failed")
}
var audioSender *webrtc.RTPSender
if session.audioFanout != nil {
audioSender, err = connection.AddTrack(session.audioFanout.Track())
if err != nil {
_ = connection.Close()
return nil, errors.New("bind shared Opus track failed")
}
}
if session.codec == media.CodecH264 {
transceivers := connection.GetTransceivers()
if len(transceivers) != 1 || len(session.codecPreferences) == 0 ||
transceivers[0].SetCodecPreferences(session.codecPreferences) != nil {
var videoTransceiver *webrtc.RTPTransceiver
for _, transceiver := range connection.GetTransceivers() {
if transceiver.Kind() == webrtc.RTPCodecTypeVideo {
videoTransceiver = transceiver
break
}
}
if videoTransceiver == nil || len(session.codecPreferences) == 0 ||
videoTransceiver.SetCodecPreferences(session.codecPreferences) != nil {
_ = connection.Close()
return nil, errors.New("configure H.264 codec preferences failed")
}
}
go peer.readRTCP(sender)
go peer.readRTCP(videoSender)
if audioSender != nil {
go peer.readRTCP(audioSender)
}
return peer, nil
}
+50 -12
View File
@@ -42,18 +42,20 @@ var (
)
type Event struct {
Kind string `json:"kind"`
Message string `json:"message,omitempty"`
ActiveViewers int `json:"activeViewers,omitempty"`
WaitingViewers int `json:"waitingViewers,omitempty"`
Media *media.Metrics `json:"media,omitempty"`
Peers []PeerDiagnostics `json:"peers,omitempty"`
Kind string `json:"kind"`
Message string `json:"message,omitempty"`
ActiveViewers int `json:"activeViewers,omitempty"`
WaitingViewers int `json:"waitingViewers,omitempty"`
Media *media.Metrics `json:"media,omitempty"`
Audio *media.AudioMetrics `json:"audio,omitempty"`
Peers []PeerDiagnostics `json:"peers,omitempty"`
}
type StartOptions struct {
BaseURL *url.URL
HostAdmissionPassword string
Codec media.Codec
EnableAudio bool
OnEvent func(Event)
}
@@ -82,6 +84,7 @@ type Session struct {
peers map[string]*peer
admission *viewerAdmission
fanout *media.Fanout
audioFanout *media.AudioFanout
codec media.Codec
peerAPI *webrtc.API
codecPreferences []webrtc.RTPCodecParameters
@@ -185,6 +188,13 @@ func Start(parent context.Context, options StartOptions) (*Session, Room, error)
cancel()
return nil, Room{}, err
}
if options.EnableAudio {
session.audioFanout, err = media.NewAudioFanout(func(fatalErr error) { session.fail(fatalErr) })
if err != nil {
session.close()
return nil, Room{}, err
}
}
session.peerAPI, session.codecPreferences, err = newPeerAPI(codec)
if err != nil {
session.close()
@@ -228,18 +238,38 @@ func (session *Session) resetSignalingConnection() {
}
}
func (session *Session) WriteFrame(frame media.Frame) error {
if err := session.fanout.Push(frame); err != nil {
session.fail(fmt.Errorf("write encoded frame: %w", err))
return err
func (session *Session) WriteMedia(packet media.Packet) error {
switch packet.Kind {
case media.KindVideo:
frame, err := packet.VideoFrame()
if err == nil {
err = session.fanout.Push(frame)
}
if err != nil {
session.fail(fmt.Errorf("write encoded video: %w", err))
return err
}
return nil
case media.KindOpus:
if session.audioFanout == nil {
return errors.New("encoded audio was not enabled for this session")
}
if err := session.audioFanout.Push(packet); err != nil {
session.fail(fmt.Errorf("write encoded audio: %w", err))
return err
}
return nil
default:
return errors.New("local sender supplied an unsupported encoded media kind")
}
return nil
}
func (session *Session) Codec() media.Codec {
return session.codec
}
func (session *Session) AudioEnabled() bool { return session.audioFanout != nil }
func (session *Session) Stop() {
session.close()
}
@@ -587,7 +617,12 @@ func (session *Session) diagnosticsLoop() {
return diagnostics[left].Slot < diagnostics[right].Slot
})
metrics := session.fanout.Snapshot()
session.emit(Event{Kind: "diagnostics", Media: &metrics, Peers: diagnostics})
var audio *media.AudioMetrics
if session.audioFanout != nil {
value := session.audioFanout.Snapshot()
audio = &value
}
session.emit(Event{Kind: "diagnostics", Media: &metrics, Audio: audio, Peers: diagnostics})
}
}
}
@@ -664,6 +699,9 @@ func (session *Session) close() {
session.peers = make(map[string]*peer)
session.mu.Unlock()
session.fanout.Close()
if session.audioFanout != nil {
session.audioFanout.Close()
}
for _, peer := range peers {
peer.close()
}
@@ -0,0 +1,25 @@
package windowaudio
import (
"context"
"errors"
)
var ErrUnavailable = errors.New("Windows 11 process audio is unavailable")
type Target struct {
PID uint32
CreationTime uint64
Title string
}
type PCMChunk struct {
Timestamp100ns uint64
Duration100ns uint64
Data []byte
}
type Provider interface {
List(context.Context) ([]Target, error)
Capture(context.Context, Target, func(PCMChunk) error) error
}
@@ -0,0 +1,15 @@
//go:build !windows
package windowaudio
import "context"
type unsupportedProvider struct{}
func NewProvider() Provider { return unsupportedProvider{} }
func (unsupportedProvider) List(context.Context) ([]Target, error) { return nil, ErrUnavailable }
func (unsupportedProvider) Capture(context.Context, Target, func(PCMChunk) error) error {
return ErrUnavailable
}
@@ -0,0 +1,145 @@
//go:build windows
package windowaudio
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"golang.org/x/sys/windows"
)
const (
maxListBytes = 64 << 10
maxTargets = 100
helperHeaderSize = 28
)
type windowsProvider struct{ helper string }
type helperTarget struct {
PID uint32 `json:"pid"`
CreationTime uint64 `json:"creationTime"`
Title string `json:"title"`
}
func NewProvider() Provider {
helper := strings.TrimSpace(os.Getenv("SCREENER_PROCESS_AUDIO_HELPER"))
if helper == "" {
if executable, err := os.Executable(); err == nil {
helper = filepath.Join(filepath.Dir(executable), "screener-process-audio.exe")
}
}
return &windowsProvider{helper: helper}
}
func (provider *windowsProvider) available() bool {
version := windows.RtlGetVersion()
if version.MajorVersion < 10 || version.BuildNumber < 22_000 || provider.helper == "" {
return false
}
info, err := os.Stat(provider.helper)
return err == nil && !info.IsDir()
}
func (provider *windowsProvider) List(ctx context.Context) ([]Target, error) {
if !provider.available() {
return nil, ErrUnavailable
}
command := exec.CommandContext(ctx, provider.helper, "--list")
stdout, err := command.StdoutPipe()
if err != nil {
return nil, ErrUnavailable
}
command.Stderr = io.Discard
if err = command.Start(); err != nil {
return nil, ErrUnavailable
}
payload, readErr := io.ReadAll(io.LimitReader(stdout, maxListBytes+1))
if (readErr != nil || len(payload) > maxListBytes) && ctx.Err() == nil {
_ = command.Process.Kill()
}
waitErr := command.Wait()
if readErr != nil || waitErr != nil || len(payload) > maxListBytes {
return nil, ErrUnavailable
}
var listed []helperTarget
if json.Unmarshal(payload, &listed) != nil || len(listed) > maxTargets {
return nil, ErrUnavailable
}
targets := make([]Target, 0, len(listed))
for _, candidate := range listed {
title := strings.TrimSpace(candidate.Title)
if candidate.PID == 0 || candidate.CreationTime == 0 || title == "" || len(title) > 512 {
continue
}
targets = append(targets, Target{
PID: candidate.PID, CreationTime: candidate.CreationTime, Title: title,
})
}
return targets, nil
}
func (provider *windowsProvider) Capture(ctx context.Context, target Target, onChunk func(PCMChunk) error) error {
if !provider.available() || target.PID == 0 || target.CreationTime == 0 || onChunk == nil {
return ErrUnavailable
}
command := exec.CommandContext(ctx, provider.helper, "--capture",
strconv.FormatUint(uint64(target.PID), 10), strconv.FormatUint(target.CreationTime, 10))
stdout, err := command.StdoutPipe()
if err != nil {
return ErrUnavailable
}
command.Stderr = io.Discard
if err = command.Start(); err != nil {
return ErrUnavailable
}
started := false
for {
header := make([]byte, helperHeaderSize)
_, readErr := io.ReadFull(stdout, header)
if readErr != nil {
err = readErr
break
}
length := binary.BigEndian.Uint32(header[24:28])
if string(header[:4]) != "SPCM" || header[4] != 1 || header[5] != 0 ||
header[6] != 0 || header[7] != 0 || length == 0 || length > 48_000*2*2/10 || length%4 != 0 {
err = errors.New("process audio helper returned an invalid PCM header")
break
}
data := make([]byte, length)
if _, readErr = io.ReadFull(stdout, data); readErr != nil {
err = readErr
break
}
started = true
if err = onChunk(PCMChunk{
Timestamp100ns: binary.BigEndian.Uint64(header[8:16]),
Duration100ns: binary.BigEndian.Uint64(header[16:24]),
Data: data,
}); err != nil {
_ = command.Process.Kill()
break
}
}
if err != nil && ctx.Err() == nil {
_ = command.Process.Kill()
}
_ = command.Wait()
if ctx.Err() != nil {
return nil
}
if err != nil || !started {
return errors.New("target-process audio capture stopped")
}
return errors.New("target process exited")
}
@@ -0,0 +1,100 @@
//go:build windows
package windowaudio
import (
"context"
"encoding/binary"
"flag"
"os"
"testing"
"time"
)
const testCreationTime = uint64(123456)
var (
captureHelperPID = flag.String("capture", "", "run as the process-audio capture test helper")
listHelper = flag.Bool("list", false, "run as the process-audio list test helper")
)
func TestMain(main *testing.M) {
flag.Parse()
if *listHelper {
_, _ = os.Stdout.Write(make([]byte, maxListBytes+1))
select {}
}
if *captureHelperPID != "" {
if len(flag.Args()) != 1 || flag.Args()[0] != "123456" {
os.Exit(3)
}
header := make([]byte, helperHeaderSize)
copy(header, "SPCM")
header[4] = 1
binary.BigEndian.PutUint64(header[16:24], 200_000)
binary.BigEndian.PutUint32(header[24:28], 4)
_, _ = os.Stdout.Write(header)
_, _ = os.Stdout.Write(make([]byte, 4))
_, _ = os.Stdout.Write(make([]byte, helperHeaderSize))
select {}
}
os.Exit(main.Run())
}
func TestCapturePassesIdentityAndKillsHelperBeforeWaitingOnInvalidHeader(t *testing.T) {
provider := &windowsProvider{helper: os.Args[0]}
if !provider.available() {
t.Skip("requires Windows 11")
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
chunks := 0
go func() {
result <- provider.Capture(ctx, Target{PID: 1, CreationTime: testCreationTime}, func(PCMChunk) error {
chunks++
return nil
})
}()
select {
case err := <-result:
if err == nil {
t.Fatal("Capture returned nil after an invalid helper header")
}
if chunks != 1 {
t.Fatalf("Capture delivered %d chunks; process identity was not passed to the helper", chunks)
}
case <-time.After(2 * time.Second):
cancel()
<-result
t.Fatal("Capture waited on a running helper after an invalid header")
}
cancel()
}
func TestListKillsHelperBeforeWaitingOnOversizeOutput(t *testing.T) {
provider := &windowsProvider{helper: os.Args[0]}
if !provider.available() {
t.Skip("requires Windows 11")
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
_, err := provider.List(ctx)
result <- err
}()
select {
case err := <-result:
if err == nil {
t.Fatal("List accepted oversized helper output")
}
case <-time.After(2 * time.Second):
cancel()
<-result
t.Fatal("List waited on a running helper after oversized output")
}
cancel()
}
+33 -2
View File
@@ -119,6 +119,9 @@ interface ViewerSnapshot {
videoHeight: number;
videoCurrentTime: number;
renderedFrames: number;
audioTrackCount: number;
audioPacketsReceived: number;
audioBytesReceived: number;
}
class CdpConnection {
@@ -274,6 +277,7 @@ async function main(): Promise<void> {
const chromePath = requiredEnvironmentPath("SCREENER_NATIVE_GATE_CHROME");
const goPath = process.env.SCREENER_NATIVE_GATE_GO?.trim() || "go";
const gateCodec = process.env.SCREENER_NATIVE_GATE_CODEC?.trim() === "h264" ? "h264" : "vp8";
const audioTargetTitle = process.env.SCREENER_NATIVE_GATE_AUDIO_TARGET_TITLE?.trim() || "";
const accessKey = randomBytes(9).toString("base64url");
const report: GateReport = {
schemaVersion: 1,
@@ -283,7 +287,9 @@ async function main(): Promise<void> {
limitations: [
"The source is a real animated Chrome tab captured through getDisplayMedia, not a game workload.",
"This run proves one WebCodecs object, not one physical or hardware encoder.",
"The loopback run does not prove public STUN, restrictive networks, TURN, audio, packaging, or endurance.",
audioTargetTitle
? "The loopback run does not prove public STUN, restrictive networks, TURN, packaging, or endurance."
: "The loopback run does not prove public STUN, restrictive networks, TURN, audio, packaging, or endurance.",
],
};
let server: ScreenerServer | null = null;
@@ -366,10 +372,22 @@ async function main(): Promise<void> {
await createPage(cdp, animatedSourceUrl());
const activeSenderPage = await createPage(cdp, launchUrl, senderProbe());
senderPage = activeSenderPage;
let audioTargetId = "";
if (audioTargetTitle) {
await waitForSample(
(deadline) => evaluate<string>(cdp!, activeSenderPage,
`([...document.querySelector('#audio-target').options].find((option) => option.text.includes(${JSON.stringify(audioTargetTitle)}))?.value || '')`, deadline),
Boolean,
5_000,
);
audioTargetId = await evaluate<string>(cdp, activeSenderPage,
`([...document.querySelector('#audio-target').options].find((option) => option.text.includes(${JSON.stringify(audioTargetTitle)}))?.value || '')`, Date.now() + 2_000);
}
await evaluate<void>(cdp, activeSenderPage, `(() => {
document.querySelector('#server-url').value = ${JSON.stringify(baseUrl)};
document.querySelector('#password').value = ${JSON.stringify(accessKey)};
document.querySelector('#codec').value = ${JSON.stringify(gateCodec)};
document.querySelector('#audio-target').value = ${JSON.stringify(audioTargetId)};
document.querySelector('#start').click();
})()`, Date.now() + 5_000);
await waitForSenderStart(
@@ -464,6 +482,10 @@ async function main(): Promise<void> {
sameBridge: retainsFirstBridgeSend(sender),
oneEncoderObject: sender.encoderInstances === 1,
criticalErrors: sender.fatalEvents === 0 && sender.encoderErrors === 0,
...(audioTargetTitle ? {
audioTrack: viewer.audioTrackCount === 1,
audioInbound: viewer.audioPacketsReceived > beforeViewer.audioPacketsReceived,
} : {}),
};
report.stages["viewer-media"] = {
...checks,
@@ -471,6 +493,7 @@ async function main(): Promise<void> {
byteDelta: viewer.bytesReceived - beforeViewer.bytesReceived,
decodedDelta: viewer.framesDecoded - beforeViewer.framesDecoded,
renderedDelta: viewer.renderedFrames - beforeViewer.renderedFrames,
audioPacketDelta: viewer.audioPacketsReceived - beforeViewer.audioPacketsReceived,
pionPacketDelta: (sender.peers[0]?.packetsSent ?? 0) - (beforeSender.peers[0]?.packetsSent ?? 0),
pcOrdinal: viewer.identity.pcOrdinal,
pionSlot: sender.peers[0]?.slot ?? -1,
@@ -648,6 +671,7 @@ function viewerProbe(): string {
consistent: true, pendingOffer: null,
pcs: [], boundPcOrdinal: 0, boundConnectionOrdinal: 0, boundSocketOrdinal: 0,
boundAuthGeneration: 0, trackCount: 0, trackOrdinal: 0, trackPcOrdinal: 0,
audioTrackCount: 0,
renderedFrames: 0, observedVideo: null };
const connectionOrdinal = (value) => {
let index = state.connectionIds.indexOf(value);
@@ -733,6 +757,7 @@ function viewerProbe(): string {
state.pcs.push(this);
this.__gatePcOrdinal = state.pcs.length;
this.addEventListener('track', (event) => {
if (event.track?.kind === 'audio') { state.audioTrackCount += 1; return; }
if (event.track?.kind !== 'video') return;
state.trackCount += 1; state.trackOrdinal = state.trackCount;
state.trackPcOrdinal = this.__gatePcOrdinal;
@@ -762,6 +787,7 @@ function viewerProbe(): string {
}
const pc = state.pcs[state.boundPcOrdinal - 1];
let packetsReceived = 0, bytesReceived = 0, framesDecoded = 0;
let audioPacketsReceived = 0, audioBytesReceived = 0;
if (pc) {
const stats = await pc.getStats();
stats.forEach((entry) => {
@@ -770,6 +796,10 @@ function viewerProbe(): string {
bytesReceived += finite(entry.bytesReceived);
framesDecoded += finite(entry.framesDecoded);
}
if (entry.type === 'inbound-rtp' && entry.kind === 'audio' && entry.isRemote !== true) {
audioPacketsReceived += finite(entry.packetsReceived);
audioBytesReceived += finite(entry.bytesReceived);
}
});
}
const activeOrdinal = state.activeConnectionId === null ? 0 :
@@ -787,7 +817,8 @@ function viewerProbe(): string {
iceConnectionState: pc?.iceConnectionState || 'none', packetsReceived, bytesReceived, framesDecoded,
videoReadyState: finite(video?.readyState), videoWidth: finite(video?.videoWidth),
videoHeight: finite(video?.videoHeight), videoCurrentTime: finite(video?.currentTime),
renderedFrames: state.renderedFrames };
renderedFrames: state.renderedFrames, audioTrackCount: state.audioTrackCount,
audioPacketsReceived, audioBytesReceived };
}
const finite = (value) => Number.isFinite(value) ? value : 0;
Object.defineProperty(globalThis, '__NATIVE_VIEWER_GATE__', {