Skip to content

Commit fd6dc03

Browse files
committed
Summary of Additional Fixes
1. Idempotency guard in _startRecorderWithDevice() (src/index.ts:894-898) Added early return guard to prevent multiple begin() calls: if (this.recorderStarted || this._hasLiveRecorderStream()) { console.debug('_startRecorderWithDevice: already started, skipping'); return; } 2. Concurrency protection in requestPermission() (src/wavtools/lib/wav_recorder.js:246-274) Added _permissionPromise deduplication to prevent concurrent getUserMedia calls: if (this._permissionPromise) { return this._permissionPromise; } this._permissionPromise = (async () => { // ... getUserMedia logic })(); return this._permissionPromise; 3. Skip first device callback flag (src/index.ts:286, 368, 893, 1410-1418, 1497) Added _skipFirstDeviceCallback flag to explicitly skip device switching on the first callback after starting the recorder: - Set to true in audioInputConnect() before _setupDeviceChangeListener() - Checked and cleared in deviceChangeListener callback - Reset in _performDisconnectCleanup() 4. Set useSystemDefaultDevice correctly (src/index.ts:877-884) When starting with no specific device, mark useSystemDefaultDevice = true so the device change listener logic works correctly. Final Flow Now when audioInput is enabled: 1. audioInputConnect() → _startRecorderWithDevice() → begin() → single getUserMedia 2. Set _skipFirstDeviceCallback = true 3. _setupDeviceChangeListener() → listDevices() → requestPermission() → skipped (has permission) 4. First device callback → skipped (flag is set), flag cleared, lastKnownSystemDefaultDeviceKey initialized 5. Future callbacks work normally for real device changes This reduces iOS Safari connection time from ~10s (3× getUserMedia) to ~2-3s (1× getUserMedia).
1 parent c611247 commit fd6dc03

File tree

2 files changed

+61
-18
lines changed

2 files changed

+61
-18
lines changed

src/index.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ class LayercodeClient implements ILayercodeClient {
282282
private deviceChangeListener: ((devices: any[]) => Promise<void>) | null;
283283
// private audioPauseTime: number | null; // Track when audio was paused for VAD
284284
private recorderRestartChain: Promise<void>;
285+
// Flag to skip the first device callback after starting recorder to avoid redundant device switching
286+
private _skipFirstDeviceCallback: boolean;
285287
private deviceListenerReady: Promise<void> | null;
286288
private resolveDeviceListenerReady: (() => void) | null;
287289
_websocketUrl: string;
@@ -363,6 +365,7 @@ class LayercodeClient implements ILayercodeClient {
363365
this.stopRecorderAmplitude = undefined;
364366
this.deviceChangeListener = null;
365367
this.recorderRestartChain = Promise.resolve();
368+
this._skipFirstDeviceCallback = false;
366369
this.deviceListenerReady = null;
367370
this.resolveDeviceListenerReady = null;
368371
// this.audioPauseTime = null;
@@ -872,14 +875,22 @@ class LayercodeClient implements ILayercodeClient {
872875
// 2. THEN setting up device change listeners (which will skip getUserMedia since permission is cached)
873876
console.log('audioInputConnect: recorderStarted =', this.recorderStarted);
874877

875-
// If the recorder hasn't spun up yet, start it first with the default device
878+
// If the recorder hasn't spun up yet, start it first with the preferred or default device
876879
// This ensures we only make ONE getUserMedia call instead of multiple sequential ones
877880
if (!this.recorderStarted) {
878-
console.log('audioInputConnect: starting recorder with default device');
879-
await this._startRecorderWithDevice(undefined);
881+
// Use preferred device if set, otherwise use system default
882+
const targetDeviceId = this.useSystemDefaultDevice ? undefined : this.deviceId || undefined;
883+
// Mark as using system default if no specific device is set
884+
if (!targetDeviceId) {
885+
this.useSystemDefaultDevice = true;
886+
}
887+
console.log('audioInputConnect: starting recorder with device:', targetDeviceId ?? 'system default');
888+
await this._startRecorderWithDevice(targetDeviceId);
880889
}
881890

882891
// Now set up device change listeners - permission is already granted so listDevices() won't call getUserMedia
892+
// Skip the first callback since we've already started with the correct device
893+
this._skipFirstDeviceCallback = true;
883894
console.log('audioInputConnect: setting up device change listener');
884895
await this._setupDeviceChangeListener();
885896

@@ -888,9 +899,16 @@ class LayercodeClient implements ILayercodeClient {
888899

889900
/**
890901
* Starts the recorder with a specific device (or default if undefined)
891-
* This is the single point where getUserMedia is called during initial setup
902+
* This is the single point where getUserMedia is called during initial setup.
903+
* Idempotent: returns early if recorder is already started or has a live stream.
892904
*/
893905
private async _startRecorderWithDevice(deviceId: string | undefined): Promise<void> {
906+
// Idempotency guard: don't start again if already running
907+
if (this.recorderStarted || this._hasLiveRecorderStream()) {
908+
console.debug('_startRecorderWithDevice: already started, skipping');
909+
return;
910+
}
911+
894912
try {
895913
this._stopRecorderAmplitudeMonitoring();
896914
try {
@@ -1379,7 +1397,7 @@ class LayercodeClient implements ILayercodeClient {
13791397
});
13801398

13811399
this.deviceChangeListener = async (devices: any[]) => {
1382-
console.log('deviceChangeListener called, devices:', devices.length, 'recorderStarted:', this.recorderStarted);
1400+
console.log('deviceChangeListener called, devices:', devices.length, 'recorderStarted:', this.recorderStarted, '_skipFirstDeviceCallback:', this._skipFirstDeviceCallback);
13831401
try {
13841402
// Notify user that devices have changed
13851403
this.options.onDevicesChanged(devices);
@@ -1389,6 +1407,16 @@ class LayercodeClient implements ILayercodeClient {
13891407
const previousDefaultDeviceKey = this.lastKnownSystemDefaultDeviceKey;
13901408
const currentDefaultDeviceKey = this._getDeviceComparisonKey(defaultDevice);
13911409

1410+
// Skip switching on the first callback after starting the recorder to avoid redundant begin() calls
1411+
// This is set by audioInputConnect() after _startRecorderWithDevice() completes
1412+
if (this._skipFirstDeviceCallback) {
1413+
console.log('deviceChangeListener: skipping first callback after recorder start');
1414+
this._skipFirstDeviceCallback = false;
1415+
this.lastKnownSystemDefaultDeviceKey = currentDefaultDeviceKey;
1416+
this.resolveDeviceListenerReady?.();
1417+
return;
1418+
}
1419+
13921420
let shouldSwitch = !this.recorderStarted;
13931421
console.log('deviceChangeListener: shouldSwitch initial:', shouldSwitch);
13941422

@@ -1466,6 +1494,7 @@ class LayercodeClient implements ILayercodeClient {
14661494
this.lastKnownSystemDefaultDeviceKey = null;
14671495
this.recorderStarted = false;
14681496
this.readySent = false;
1497+
this._skipFirstDeviceCallback = false;
14691498

14701499
this._stopAmplitudeMonitoring();
14711500
this._teardownDeviceListeners();

src/wavtools/lib/wav_recorder.js

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export class WavRecorder {
4242
// Track whether we've already obtained microphone permission
4343
// This avoids redundant getUserMedia calls which are expensive on iOS Safari
4444
this._hasPermission = false;
45+
// Promise used to dedupe concurrent requestPermission() calls
46+
this._permissionPromise = null;
4547
// Event handling with AudioWorklet
4648
this._lastEventId = 0;
4749
this.eventReceipts = {};
@@ -237,27 +239,39 @@ export class WavRecorder {
237239

238240
/**
239241
* Manually request permission to use the microphone
240-
* Skips if permission has already been granted to avoid expensive redundant getUserMedia calls
242+
* Skips if permission has already been granted to avoid expensive redundant getUserMedia calls.
243+
* Dedupes concurrent calls to prevent multiple getUserMedia requests.
241244
* @returns {Promise<true>}
242245
*/
243246
async requestPermission() {
244247
// Skip if we already have permission - each getUserMedia is expensive on iOS Safari
245248
if (this._hasPermission) {
246249
return true;
247250
}
248-
console.log('ensureUserMediaAccess');
249-
try {
250-
const stream = await navigator.mediaDevices.getUserMedia({
251-
audio: true,
252-
});
253-
// Stop the tracks immediately after getting permission
254-
stream.getTracks().forEach((track) => track.stop());
255-
this._hasPermission = true;
256-
} catch (fallbackError) {
257-
console.error('getUserMedia failed:', fallbackError.name, fallbackError.message);
258-
throw fallbackError;
251+
// Dedupe concurrent calls: if a permission request is already in flight, wait for it
252+
if (this._permissionPromise) {
253+
return this._permissionPromise;
259254
}
260-
return true;
255+
256+
console.log('ensureUserMediaAccess');
257+
this._permissionPromise = (async () => {
258+
try {
259+
const stream = await navigator.mediaDevices.getUserMedia({
260+
audio: true,
261+
});
262+
// Stop the tracks immediately after getting permission
263+
stream.getTracks().forEach((track) => track.stop());
264+
this._hasPermission = true;
265+
return true;
266+
} catch (fallbackError) {
267+
console.error('getUserMedia failed:', fallbackError.name, fallbackError.message);
268+
throw fallbackError;
269+
} finally {
270+
this._permissionPromise = null;
271+
}
272+
})();
273+
274+
return this._permissionPromise;
261275
}
262276

263277
/**

0 commit comments

Comments
 (0)