Skip to content

Commit fa64a45

Browse files
committed
fix(sdk-recorder): bound audio startup and leave mixing headroom
1 parent cbe18d6 commit fa64a45

2 files changed

Lines changed: 85 additions & 6 deletions

File tree

‎packages/sdk-recorder/__tests__/audio-mixing.test.ts‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ class Stream {
3030
class Mixer {
3131
static instances: Mixer[] = [];
3232
readonly inputs: Track[] = [];
33+
readonly connections: unknown[] = [];
3334
readonly output = new Track("audio");
3435
readonly destination = { stream: new Stream([this.output]) };
36+
readonly mix = {
37+
gain: { value: 1 },
38+
connect: mock(() => {}),
39+
};
3540
resume = mock(async () => {});
3641
close = mock(async () => {});
3742

@@ -43,16 +48,28 @@ class Mixer {
4348
return this.destination;
4449
}
4550

51+
createGain() {
52+
return this.mix;
53+
}
54+
4655
createMediaStreamSource(stream: Stream) {
4756
return {
48-
connect: () => {
57+
connect: (destination: unknown) => {
4958
this.inputs.push(...stream.getAudioTracks());
59+
this.connections.push(destination);
5060
},
5161
};
5262
}
5363
}
5464

55-
const globals = ["MediaStream", "AudioContext", "MediaRecorder", "navigator"];
65+
const globals = [
66+
"MediaStream",
67+
"AudioContext",
68+
"MediaRecorder",
69+
"navigator",
70+
"setTimeout",
71+
"clearTimeout",
72+
];
5673
const descriptors = globals.map(
5774
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
5875
);
@@ -129,6 +146,9 @@ test("records both audio sources through one track and releases the mixer", asyn
129146
expect(input?.getVideoTracks()).toEqual([video]);
130147
expect(input?.getAudioTracks()).toEqual([mixer?.output]);
131148
expect(mixer?.inputs).toEqual([system, mic]);
149+
expect(mixer?.connections).toEqual([mixer?.mix, mixer?.mix]);
150+
expect(mixer?.mix.gain.value).toBe(0.5);
151+
expect(mixer?.mix.connect).toHaveBeenCalledWith(mixer?.destination);
132152
} finally {
133153
recorder.destroy();
134154
}
@@ -157,6 +177,48 @@ test.each([
157177
},
158178
);
159179

180+
test("times out pending audio startup and releases captured tracks", async () => {
181+
const { video, system, mic } = capture(true, true);
182+
setGlobal(
183+
"AudioContext",
184+
class extends Mixer {
185+
resume = mock(() => new Promise<void>(() => {}));
186+
},
187+
);
188+
let deadline: (() => void) | undefined;
189+
const timeout = mock((callback: () => void, delay: number) => {
190+
expect(delay).toBe(10_000);
191+
deadline = callback;
192+
return 7;
193+
});
194+
const clear = mock(() => {});
195+
setGlobal("setTimeout", timeout);
196+
setGlobal("clearTimeout", clear);
197+
const recorder = new CapRecorder({
198+
publicKey: "test-public-key",
199+
mode: "tab",
200+
camera: { enabled: false },
201+
systemAudio: true,
202+
});
203+
try {
204+
const starting = recorder.start();
205+
await new Promise<void>((resolve) => setImmediate(resolve));
206+
expect(deadline).toBeDefined();
207+
deadline?.();
208+
await expect(starting).rejects.toThrow("Recording audio startup timed out");
209+
expect(recorder.phase).toBe("error");
210+
expect(clear).toHaveBeenCalledWith(7);
211+
expect(video.stop).toHaveBeenCalled();
212+
expect(system.stop).toHaveBeenCalled();
213+
expect(mic.stop).toHaveBeenCalled();
214+
const mixer = Mixer.instances[0];
215+
expect(mixer?.output.stop).toHaveBeenCalled();
216+
expect(mixer?.close).toHaveBeenCalledTimes(1);
217+
} finally {
218+
recorder.destroy();
219+
}
220+
});
221+
160222
test.each(["encoder", "audio"] as const)(
161223
"cleans up both audio sources when %s initialization fails",
162224
async (failure) => {

‎packages/sdk-recorder/src/core/stream-manager.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,29 @@ export async function buildCompositeStream(
123123
const context = new AudioContext();
124124
const destination = context.createMediaStreamDestination();
125125
streams.audioMix = { context, stream: destination.stream };
126+
const mix = context.createGain();
127+
mix.gain.value = 1 / audioTracks.length;
128+
mix.connect(destination);
126129
for (const track of audioTracks) {
127-
context
128-
.createMediaStreamSource(new MediaStream([track]))
129-
.connect(destination);
130+
context.createMediaStreamSource(new MediaStream([track])).connect(mix);
131+
}
132+
let startupTimeout: ReturnType<typeof setTimeout> | undefined;
133+
try {
134+
await Promise.race([
135+
context.resume(),
136+
new Promise<never>((_, reject) => {
137+
startupTimeout = setTimeout(() => {
138+
reject(
139+
new Error(
140+
"Recording audio startup timed out. Check your audio device and try again.",
141+
),
142+
);
143+
}, 10_000);
144+
}),
145+
]);
146+
} finally {
147+
clearTimeout(startupTimeout);
130148
}
131-
await context.resume();
132149

133150
return new MediaStream([
134151
...tracks.filter((track) => track.kind !== "audio"),

0 commit comments

Comments
 (0)