Skip to content

Commit bc5ae73

Browse files
committed
refactor: isolate host audio probe backend
1 parent 3c89147 commit bc5ae73

9 files changed

Lines changed: 122 additions & 49 deletions

File tree

src/core/command-descriptor/registry.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '../../command-catalog.ts';
66
import type { CommandCapability } from '../capabilities.ts';
77
import type { DaemonRequest } from '../../daemon/types.ts';
8+
import { isAudioProbeSupportedDevice } from '../../kernel/audio-probe-support.ts';
89
import { isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts';
910
import type { CommandDescriptor } from './types.ts';
1011

@@ -47,13 +48,6 @@ const supportsSynthesisGesture = (device: DeviceInfo): boolean =>
4748
device.platform === 'android' || isIosMobileSimulator(device);
4849
const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean =>
4950
device.platform === 'android' || (device.platform === 'ios' && !isTvOsDevice(device));
50-
const isHostSystemAudioProbeDevice = (device: DeviceInfo): boolean =>
51-
device.platform === 'web' ||
52-
(process.platform === 'darwin' &&
53-
(device.platform === 'macos' ||
54-
(device.platform === 'ios' && device.kind === 'simulator') ||
55-
(device.platform === 'android' && device.kind === 'emulator')));
56-
5751
const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined => {
5852
if (device.platform === 'macos')
5953
return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.';
@@ -192,7 +186,7 @@ const RAW_COMMAND_DESCRIPTORS = [
192186
apple: APPLE_SIM_AND_DEVICE,
193187
android: { emulator: true },
194188
linux: LINUX_NONE,
195-
supports: isHostSystemAudioProbeDevice,
189+
supports: isAudioProbeSupportedDevice,
196190
},
197191
batchable: true,
198192
},

src/daemon/audio-probe.ts

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,17 @@ import {
55
normalizeAudioProbeRecord,
66
type AudioProbeResult,
77
} from '../audio-probe-result.ts';
8-
import { startMacOsAudioProbeProcess } from '../platforms/apple/os/macos/helper.ts';
8+
import type { HostAudioProbeBackend } from '../platforms/audio-probe-backend.ts';
99
import { AppError } from '../kernel/errors.ts';
1010
import { sleep } from '../utils/timeouts.ts';
1111
import type { SessionStore } from './session-store.ts';
1212
import type { SessionState } from './types.ts';
1313

14-
const HOST_AUDIO_BACKEND = 'macos-screencapturekit';
15-
1614
export type HostAudioProbeCommand = {
1715
session: SessionState;
1816
sessionName: string;
1917
sessionStore: SessionStore;
18+
backend: HostAudioProbeBackend;
2019
probeAction: 'start' | 'status' | 'stop';
2120
durationMs: number;
2221
bucketMs: number;
@@ -25,20 +24,24 @@ export type HostAudioProbeCommand = {
2524
export async function runHostSystemAudioProbeCommand(
2625
request: HostAudioProbeCommand,
2726
): Promise<AudioProbeResult> {
28-
const { session, probeAction } = request;
27+
const { session, probeAction, backend } = request;
2928
if (probeAction === 'start') {
3029
await stopSessionAudioProbe(session, 'restarted');
3130
const statusPath = path.join(
3231
request.sessionStore.ensureSessionDir(request.sessionName),
3332
'audio-probe.json',
3433
);
35-
const probe = await startMacOsAudioProbeProcess({
34+
const probe = await backend.start({
3635
durationMs: request.durationMs,
3736
bucketMs: request.bucketMs,
3837
statusPath,
3938
});
4039
session.audioProbe = {
41-
platform: 'host-system-audio',
40+
platform: backend.platform,
41+
source: backend.source,
42+
backend: backend.backend,
43+
sourceCount: backend.sourceCount,
44+
notes: backend.notes(session.device),
4245
child: probe.child,
4346
wait: probe.wait,
4447
statusPath,
@@ -75,7 +78,7 @@ export async function stopSessionAudioProbe(
7578
probe.child.kill('SIGTERM');
7679
await probe.wait.catch(() => {});
7780
session.audioProbe = undefined;
78-
return finalizeHostSystemAudioProbeStatus(beforeStop, probe, session.device, reason);
81+
return finalizeHostSystemAudioProbeStatus(beforeStop, probe, reason);
7982
}
8083

8184
async function waitForHostSystemAudioProbeStatus(session: SessionState): Promise<AudioProbeResult> {
@@ -110,7 +113,7 @@ async function readHostSystemAudioProbeStatus(
110113
if (!probe) return undefined;
111114
try {
112115
const raw = await fs.readFile(probe.statusPath, 'utf8');
113-
return normalizeHostSystemAudioProbeData(JSON.parse(raw), probe, session.device);
116+
return normalizeHostSystemAudioProbeData(JSON.parse(raw), probe);
114117
} catch (error) {
115118
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
116119
throw error;
@@ -120,36 +123,34 @@ async function readHostSystemAudioProbeStatus(
120123
function normalizeHostSystemAudioProbeData(
121124
value: unknown,
122125
probe: NonNullable<SessionState['audioProbe']>,
123-
device: SessionState['device'],
124126
): AudioProbeResult {
125127
return normalizeAudioProbeRecord(value, {
126-
source: 'system-audio',
127-
backend: HOST_AUDIO_BACKEND,
128+
source: probe.source,
129+
backend: probe.backend,
128130
durationMs: probe.durationMs,
129131
elapsedMs: Date.now() - probe.startedAt,
130132
bucketMs: probe.bucketMs,
131133
activeFallback: true,
132-
sourceCount: 1,
133-
notes: hostSystemAudioProbeNotes(device),
134+
sourceCount: probe.sourceCount,
135+
notes: probe.notes,
134136
});
135137
}
136138

137139
function finalizeHostSystemAudioProbeStatus(
138140
status: AudioProbeResult | undefined,
139141
probe: NonNullable<SessionState['audioProbe']>,
140-
device: SessionState['device'],
141142
reason: string,
142143
): AudioProbeResult {
143144
const elapsedMs = Math.min(probe.durationMs, Math.max(0, Date.now() - probe.startedAt));
144145
const base =
145146
status ??
146147
emptyAudioProbeResult({
147-
source: 'system-audio',
148-
backend: HOST_AUDIO_BACKEND,
148+
source: probe.source,
149+
backend: probe.backend,
149150
durationMs: probe.durationMs,
150151
bucketMs: probe.bucketMs,
151-
sourceCount: 1,
152-
notes: hostSystemAudioProbeNotes(device),
152+
sourceCount: probe.sourceCount,
153+
notes: probe.notes,
153154
});
154155
return {
155156
...base,
@@ -168,29 +169,15 @@ function buildHostSystemAudioProbeFallback(
168169
): AudioProbeResult {
169170
return emptyAudioProbeResult({
170171
state,
171-
source: 'system-audio',
172-
backend: HOST_AUDIO_BACKEND,
172+
source: request.backend.source,
173+
backend: request.backend.backend,
173174
durationMs: request.durationMs,
174175
bucketMs: request.bucketMs,
175176
sourceCount: 0,
176177
reason,
177178
notes: [
178-
...hostSystemAudioProbeNotes(request.session.device),
179+
...request.backend.notes(request.session.device),
179180
'No active host audio probe is running.',
180181
],
181182
});
182183
}
183-
184-
function hostSystemAudioProbeNotes(device: SessionState['device']): string[] {
185-
const target =
186-
device.platform === 'ios'
187-
? 'iOS simulator'
188-
: device.platform === 'android'
189-
? 'Android emulator'
190-
: 'macOS session';
191-
return [
192-
`Audio probe samples host system audio through ScreenCaptureKit for this ${target}; it is not app-instrumented audio.`,
193-
'Screen Recording permission is required for host system audio capture.',
194-
'Other audible host apps can contribute to the measured buckets.',
195-
];
196-
}

src/daemon/handlers/__tests__/session-close-shutdown.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,14 @@ test('close stops active host audio probe before deleting session', async () =>
409409
}),
410410
audioProbe: {
411411
platform: 'host-system-audio',
412+
source: 'system-audio',
413+
backend: 'macos-screencapturekit',
414+
sourceCount: 1,
415+
notes: [
416+
'Audio probe samples host system audio through ScreenCaptureKit for this macOS session; it is not app-instrumented audio.',
417+
'Screen Recording permission is required for host system audio capture.',
418+
'Other audible host apps can contribute to the measured buckets.',
419+
],
412420
child: { kill, pid: 1234 },
413421
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
414422
statusPath: path.join(os.tmpdir(), 'missing-audio-probe.json'),

src/daemon/handlers/__tests__/session-observability.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,14 @@ test('audio probe stop kills active macOS helper and returns stopped status', as
553553
const kill = vi.fn();
554554
session.audioProbe = {
555555
platform: 'host-system-audio',
556+
source: 'system-audio',
557+
backend: 'macos-screencapturekit',
558+
sourceCount: 1,
559+
notes: [
560+
'Audio probe samples host system audio through ScreenCaptureKit for this macOS session; it is not app-instrumented audio.',
561+
'Screen Recording permission is required for host system audio capture.',
562+
'Other audible host apps can contribute to the measured buckets.',
563+
],
556564
child: { kill, pid: 1234 },
557565
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
558566
statusPath,

src/daemon/handlers/session-observability.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
} from '../../contracts/perf.ts';
1515
import { AppError, normalizeError } from '../../kernel/errors.ts';
1616
import { resolveWebProvider } from '../../platforms/web/provider.ts';
17+
import {
18+
resolveHostAudioProbeBackend,
19+
type HostAudioProbeBackend,
20+
} from '../../platforms/audio-probe-backend.ts';
1721
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
1822
import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from '../types.ts';
1923
import { SessionStore } from '../session-store.ts';
@@ -619,8 +623,9 @@ function resolveNetworkIncludeMode(
619623
async function handleAudioCommand(params: ObservabilityParams): Promise<DaemonResponse> {
620624
const request = resolveAudioCommandRequest(params);
621625
if (!request.ok) return request;
622-
if (usesHostSystemAudioProbe(request.session)) {
623-
return await handleHostSystemAudioCommand(params, request);
626+
const hostBackend = await resolveHostAudioProbeBackend(request.session.device);
627+
if (hostBackend) {
628+
return await handleHostSystemAudioCommand(params, request, hostBackend);
624629
}
625630
const provider = resolveWebProvider();
626631
if (!provider.probeAudio) {
@@ -682,13 +687,10 @@ type ResolvedAudioCommandRequest = Extract<
682687
{ ok: true }
683688
>;
684689

685-
function usesHostSystemAudioProbe(session: SessionState): boolean {
686-
return session.device.platform !== 'web';
687-
}
688-
689690
async function handleHostSystemAudioCommand(
690691
params: ObservabilityParams,
691692
request: ResolvedAudioCommandRequest,
693+
backend: HostAudioProbeBackend,
692694
): Promise<DaemonResponse> {
693695
try {
694696
return {
@@ -697,6 +699,7 @@ async function handleHostSystemAudioCommand(
697699
session: request.session,
698700
sessionName: params.sessionName,
699701
sessionStore: params.sessionStore,
702+
backend,
700703
probeAction: request.probeAction,
701704
durationMs: request.durationMs,
702705
bucketMs: request.bucketMs,

src/daemon/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
AppleXctracePerfCapture,
2626
AppleXctracePerfMode,
2727
} from '../platforms/apple/core/perf-xctrace.ts';
28+
import type { AudioProbeSource } from '../audio-probe-result.ts';
2829
import type {
2930
SnapshotDiagnosticsState,
3031
SnapshotDiagnosticsSummary,
@@ -271,6 +272,10 @@ export type SessionState = {
271272
};
272273
audioProbe?: {
273274
platform: 'host-system-audio';
275+
source: AudioProbeSource;
276+
backend: string;
277+
sourceCount: number;
278+
notes: string[];
274279
child: SessionRecordingProcessChild;
275280
wait: Promise<ExecResult>;
276281
statusPath: string;

src/kernel/audio-probe-support.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DeviceInfo } from './device.ts';
2+
3+
export function isHostSystemAudioProbeDevice(device: DeviceInfo): boolean {
4+
return (
5+
process.platform === 'darwin' &&
6+
(device.platform === 'macos' ||
7+
(device.platform === 'ios' && device.kind === 'simulator') ||
8+
(device.platform === 'android' && device.kind === 'emulator'))
9+
);
10+
}
11+
12+
export function isAudioProbeSupportedDevice(device: DeviceInfo): boolean {
13+
return device.platform === 'web' || isHostSystemAudioProbeDevice(device);
14+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { DeviceInfo } from '../../../../kernel/device.ts';
2+
import type { HostAudioProbeBackend } from '../../../audio-probe-backend.ts';
3+
import { startMacOsAudioProbeProcess } from './helper.ts';
4+
5+
export const macOsScreenCaptureKitAudioProbeBackend = {
6+
platform: 'host-system-audio',
7+
source: 'system-audio',
8+
backend: 'macos-screencapturekit',
9+
sourceCount: 1,
10+
start: startMacOsAudioProbeProcess,
11+
notes: hostSystemAudioProbeNotes,
12+
} as const satisfies HostAudioProbeBackend;
13+
14+
function hostSystemAudioProbeNotes(device: DeviceInfo): string[] {
15+
const target =
16+
device.platform === 'ios'
17+
? 'iOS simulator'
18+
: device.platform === 'android'
19+
? 'Android emulator'
20+
: 'macOS session';
21+
return [
22+
`Audio probe samples host system audio through ScreenCaptureKit for this ${target}; it is not app-instrumented audio.`,
23+
'Screen Recording permission is required for host system audio capture.',
24+
'Other audible host apps can contribute to the measured buckets.',
25+
];
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { AudioProbeSource } from '../audio-probe-result.ts';
2+
import { isHostSystemAudioProbeDevice } from '../kernel/audio-probe-support.ts';
3+
import type { DeviceInfo } from '../kernel/device.ts';
4+
import type { ExecBackgroundResult } from '../utils/exec.ts';
5+
6+
export type HostAudioProbeStartOptions = {
7+
durationMs: number;
8+
bucketMs: number;
9+
statusPath: string;
10+
};
11+
12+
export type HostAudioProbeBackend = {
13+
platform: 'host-system-audio';
14+
source: AudioProbeSource;
15+
backend: string;
16+
sourceCount: number;
17+
start(options: HostAudioProbeStartOptions): Promise<ExecBackgroundResult>;
18+
notes(device: DeviceInfo): string[];
19+
};
20+
21+
export async function resolveHostAudioProbeBackend(
22+
device: DeviceInfo,
23+
): Promise<HostAudioProbeBackend | undefined> {
24+
if (!isHostSystemAudioProbeDevice(device)) return undefined;
25+
const { macOsScreenCaptureKitAudioProbeBackend } =
26+
await import('./apple/os/macos/audio-probe.ts');
27+
return macOsScreenCaptureKitAudioProbeBackend;
28+
}

0 commit comments

Comments
 (0)