Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { toHelperRect } from "../native-bridge/helperCoordinates";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
readMicrophoneDefaulted,
readWebcamFormat,
readWebcamUnavailable,
terminateNativeWindowsCapture,
Expand Down Expand Up @@ -474,6 +475,18 @@ let currentRecordingSession: RecordingSession | null = null;
export interface RecordingPrefs {
micEnabled: boolean;
micDeviceId: string | null;
/**
* The microphone's LABEL, carried beside its id because the native Windows
* helper selects by name and Chromium selects by id.
*
* Without it, a HUD rebuilt for a new recording restored the id and had to
* re-derive the name from its own `enumerateDevices()` — which needs a full
* getUserMedia permission round-trip first, and an auto-started recording
* beat it. The request then went out with no name at all, and the helper
* answers that by recording the Windows default endpoint instead of the
* microphone the user picked (getopenscreen/openscreen#404).
*/
micDeviceName: string | null;
camEnabled: boolean;
camDeviceId: string | null;
systemAudioEnabled: boolean;
Expand All @@ -482,6 +495,7 @@ export interface RecordingPrefs {
let recordingPrefs: RecordingPrefs = {
micEnabled: false,
micDeviceId: null,
micDeviceName: null,
camEnabled: false,
camDeviceId: null,
systemAudioEnabled: false,
Expand Down Expand Up @@ -2459,6 +2473,17 @@ export function registerIpcHandlers(
// whose camera is working perfectly.
const webcamUnavailable =
request.webcam.enabled && readWebcamUnavailable(nativeWindowsCaptureOutput);
// Same shape as the camera notice: the helper records the Windows
// default input rather than failing, so this take is usable but is
// almost certainly the wrong microphone.
const microphoneDefaulted =
request.audio.microphone.enabled && readMicrophoneDefaulted(nativeWindowsCaptureOutput);
if (microphoneDefaulted) {
console.warn("[native-wgc] recording the default input; the microphone was not named", {
deviceId: request.audio.microphone.deviceId,
deviceName: request.audio.microphone.deviceName,
});
}
if (webcamUnavailable) {
console.warn("[native-wgc] recording without a camera; the helper could not open it", {
deviceId: request.webcam.deviceId,
Expand All @@ -2473,6 +2498,7 @@ export function registerIpcHandlers(
helperPath,
videoEncoderSelection: encoderSelection?.video ?? null,
webcamUnavailable,
microphoneDefaulted,
};
} catch (error) {
console.error("Failed to start native Windows recording:", error);
Expand Down
84 changes: 64 additions & 20 deletions electron/native/wgc-capture/src/wasapi_loopback_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,50 @@ std::wstring normalizeDeviceName(const std::wstring& value) {
return result;
}

/**
* Does `needle` appear in `haystack` as whole words?
*
* Plain containment is what let a requested "Micro" answer for
* "Microphone (Logitech StreamCam)" -- the request is inside the name, just not
* as a word -- and a requested "Logi" for "Logitech". Either resolved an
* endpoint nobody asked for, and silenced the fallback warning by doing so.
*
* Both sides are normalized, so a boundary is the start of the string, the end,
* or a space.
*/
bool containsAsWords(const std::wstring& haystack, const std::wstring& needle) {
if (haystack.empty() || needle.empty()) {
return false;
}
size_t pos = haystack.find(needle);
while (pos != std::wstring::npos) {
const bool startsOnBoundary = pos == 0 || haystack[pos - 1] == L' ';
const size_t after = pos + needle.size();
const bool endsOnBoundary = after == haystack.size() || haystack[after] == L' ';
if (startsOnBoundary && endsOnBoundary) {
return true;
}
pos = haystack.find(needle, pos + 1);
}
return false;
}

/**
* How well a candidate endpoint answers a requested name, or 0 for "not this
* one" -- which the caller must treat as a real answer.
*
* Only decisive matches count: equal once normalized, or one containing the
* other, which is the ordinary case since Chromium appends USB ids to what the
* driver reports.
*
* A further tier used to score shared WORDS, to bridge names differing more than
* that. It bridged endpoints that were not the same device -- a requested
* "micro" matched the "microphone" that opens nearly every Windows endpoint
* name, so asking for a microphone that does not exist quietly recorded
* whichever one sorted first, and the fallback warning could not fire because a
* device HAD been resolved (getopenscreen/openscreen#404). Returning 0 is what
* makes that warning reachable.
*/
int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candidateId, const std::wstring& requestedName) {
const std::wstring candidate = normalizeDeviceName(candidateName);
const std::wstring id = normalizeDeviceName(candidateId);
Expand All @@ -75,31 +119,14 @@ int scoreDeviceName(const std::wstring& candidateName, const std::wstring& candi
if (candidate == requested) {
return 1000;
}
if (!candidate.empty() && (candidate.find(requested) != std::wstring::npos || requested.find(candidate) != std::wstring::npos)) {
if (containsAsWords(candidate, requested) || containsAsWords(requested, candidate)) {
return 900;
}
if (!id.empty() && (id.find(requested) != std::wstring::npos || requested.find(id) != std::wstring::npos)) {
if (containsAsWords(id, requested) || containsAsWords(requested, id)) {
return 800;
}

int score = 0;
size_t pos = 0;
while (pos < requested.size()) {
const size_t end = requested.find(L' ', pos);
const std::wstring word = requested.substr(pos, end == std::wstring::npos ? std::wstring::npos : end - pos);
if (word.size() > 1 && word != L"microphone" && word != L"mic" && word != L"audio" && word != L"input") {
if (candidate.find(word) != std::wstring::npos) {
score += 100;
} else if (id.find(word) != std::wstring::npos) {
score += 50;
}
}
if (end == std::wstring::npos) {
break;
}
pos = end + 1;
}
return score;
return 0;
}

std::wstring getDeviceFriendlyName(IMMDevice* device) {
Expand Down Expand Up @@ -169,6 +196,23 @@ bool WasapiLoopbackCapture::initialize(WasapiCaptureEndpoint endpoint, const std
}

if (!device_) {
// A particular microphone was asked for and nothing here could find it,
// so the recording is about to capture whatever Windows calls the default
// input. Worth saying out loud, and keyed on the OUTCOME rather than on
// which lookup failed: the caller sends an empty name when it could not
// resolve one in time, but a name that simply matches no endpoint lands
// in exactly the same place. Either way the take sounds like the wrong
// microphone with nothing explaining why (getopenscreen/openscreen#404).
const bool wantedAParticularMicrophone =
endpoint == WasapiCaptureEndpoint::Microphone &&
((!deviceId.empty() && deviceId != L"default") || !deviceName.empty());
if (wantedAParticularMicrophone) {
std::cerr << "{\"event\":\"warning\",\"code\":\"microphone-defaulted\","
"\"message\":\"The requested microphone could not be resolved; "
"capturing the default input\"}"
<< std::endl;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const EDataFlow flow =
endpoint == WasapiCaptureEndpoint::SystemLoopback ? eRender : eCapture;
hr = deviceEnumerator_->GetDefaultAudioEndpoint(flow, eConsole, &device_);
Expand Down
25 changes: 25 additions & 0 deletions electron/recording/nativeWindowsCaptureStop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
readMicrophoneDefaulted,
readStoppedPath,
readWebcamFormat,
readWebcamUnavailable,
Expand Down Expand Up @@ -99,6 +100,30 @@ describe("readWebcamUnavailable", () => {
});
});

describe("readMicrophoneDefaulted", () => {
// The helper keys the event on the OUTCOME — it ended up on the default input
// — not on which lookup failed, so both routes to that fallback land here.
it("sees the fallback when no microphone name was supplied", () => {
const output =
'{"event":"warning","code":"microphone-defaulted","message":"The requested microphone could not be resolved; capturing the default input"}\n' +
"Recording started\n";
expect(readMicrophoneDefaulted(output)).toBe(true);
});

it("sees it when a supplied name matched no endpoint", () => {
const output =
"WARNING: Could not resolve microphone by name; using default capture endpoint\n" +
'{"event":"warning","code":"microphone-defaulted","message":"The requested microphone could not be resolved; capturing the default input"}\n';
expect(readMicrophoneDefaulted(output)).toBe(true);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("is false when the requested microphone was found", () => {
const output =
'{"event":"audio-format","schemaVersion":2,"microphone":true,"microphoneDeviceName":"Microphone (Logitech PRO X)"}\n';
expect(readMicrophoneDefaulted(output)).toBe(false);
});
});

describe("readWebcamFormat", () => {
it("reads the negotiated camera format", () => {
const output =
Expand Down
13 changes: 13 additions & 0 deletions electron/recording/nativeWindowsCaptureStop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ export function readWebcamUnavailable(output: string) {
return output.includes('"code":"webcam-unavailable"');
}

/**
* Did the helper record the Windows default input instead of the microphone
* that was asked for?
*
* It falls back rather than failing, which is right — a take with the wrong
* microphone still holds the screen and the moment. But it used to fall back in
* silence, and the only symptom was a recording that sounded wrong
* (getopenscreen/openscreen#404).
*/
export function readMicrophoneDefaulted(output: string) {
return output.includes('"code":"microphone-defaulted"');
}

/**
* Index of the `}` that closes the object starting at `start`, or -1.
*
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"test:wgc-window:win": "node scripts/test-windows-wgc-helper.mjs --window",
"test:wgc-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio",
"test:wgc-mic:win": "node scripts/test-windows-wgc-helper.mjs --microphone",
"test:wgc-mic-selection:win": "node scripts/test-windows-microphone-selection.mjs",
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
Expand Down
Loading
Loading