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
54 changes: 40 additions & 14 deletions packages/client/src/helpers/RNSpeechDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { SoundStateChangeHandler } from './sound-detector';
import { videoLoggerSystem } from '../logger';

export class RNSpeechDetector {
private pc1 = new RTCPeerConnection({});
private pc2 = new RTCPeerConnection({});
private readonly pc1 = new RTCPeerConnection({});
private readonly pc2 = new RTCPeerConnection({});
private audioStream: MediaStream | undefined;
private externalAudioStream: MediaStream | undefined;
private isStopped = false;

constructor(externalAudioStream?: MediaStream) {
this.externalAudioStream = externalAudioStream;
Expand All @@ -17,30 +18,31 @@ export class RNSpeechDetector {
*/
public async start(onSoundDetectedStateChanged: SoundStateChangeHandler) {
try {
this.isStopped = false;
const audioStream =
this.externalAudioStream != null
? this.externalAudioStream
: await navigator.mediaDevices.getUserMedia({ audio: true });
this.audioStream = audioStream;

this.pc1.addEventListener('icecandidate', (e) => {
this.pc2.addIceCandidate(e.candidate).catch(() => {
// do nothing
});
});
this.pc2.addEventListener('icecandidate', async (e) => {
this.pc1.addIceCandidate(e.candidate).catch(() => {
// do nothing
});
});
this.pc2.addEventListener('track', (e) => {
const onPc1IceCandidate = (e: RTCPeerConnectionIceEvent) => {
this.forwardIceCandidate(this.pc2, e.candidate);
};
const onPc2IceCandidate = (e: RTCPeerConnectionIceEvent) => {
this.forwardIceCandidate(this.pc1, e.candidate);
};
const onTrackPc2 = (e: RTCTrackEvent) => {
e.streams[0].getTracks().forEach((track) => {
// In RN, the remote track is automatically added to the audio output device
// so we need to mute it to avoid hearing the audio back
// @ts-expect-error _setVolume is a private method in react-native-webrtc
track._setVolume(0);
});
});
};

this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
this.pc2.addEventListener('track', onTrackPc2);

audioStream
.getTracks()
Expand All @@ -55,6 +57,9 @@ export class RNSpeechDetector {
onSoundDetectedStateChanged,
);
return () => {
this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
this.pc2.removeEventListener('track', onTrackPc2);
unsubscribe();
this.stop();
};
Expand All @@ -69,6 +74,9 @@ export class RNSpeechDetector {
* Stops the speech detection and releases all allocated resources.
*/
private stop() {
if (this.isStopped) return;
this.isStopped = true;

this.pc1.close();
this.pc2.close();

Expand Down Expand Up @@ -185,4 +193,22 @@ export class RNSpeechDetector {
this.audioStream.release();
}
}

private forwardIceCandidate(
destination: RTCPeerConnection,
candidate: RTCIceCandidate | null,
) {
if (
this.isStopped ||
!candidate ||
destination.signalingState === 'closed'
) {
return;
}
destination.addIceCandidate(candidate).catch(() => {
// silently ignore the error
const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
logger.info('cannot add ice candidate - ignoring');
});
}
}
52 changes: 52 additions & 0 deletions packages/client/src/helpers/__tests__/RNSpeechDetector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import '../../rtc/__tests__/mocks/webrtc.mocks';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RNSpeechDetector } from '../RNSpeechDetector';

describe('RNSpeechDetector', () => {
// Shared test setup stubs RTCPeerConnection with a vi.fn constructor.
// We keep a typed handle to that constructor to inspect created instances.
let rtcPeerConnectionMockCtor: ReturnType<typeof vi.fn>;

beforeEach(() => {
rtcPeerConnectionMockCtor =
globalThis.RTCPeerConnection as unknown as ReturnType<typeof vi.fn>;
rtcPeerConnectionMockCtor.mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('ignores late ICE candidates after cleanup', async () => {
const stream = {
getTracks: () => [],
} as unknown as MediaStream;
const detector = new RNSpeechDetector(stream);

const cleanup = await detector.start(() => {});
cleanup();

// start() creates two peer connections (pc1 and pc2). We pull them from
// constructor call results to inspect listener wiring and ICE forwarding.
const [pc1, pc2] = rtcPeerConnectionMockCtor.mock.results.map(
(result) => result.value,
);

// Find the registered ICE callback and invoke it manually after cleanup to
// simulate a late ICE event arriving during teardown.
const onIceCandidate = pc1.addEventListener.mock.calls.find(
([eventName]: [string]) => eventName === 'icecandidate',
)?.[1] as ((e: RTCPeerConnectionIceEvent) => void) | undefined;

expect(onIceCandidate).toBeDefined();
onIceCandidate?.({
candidate: { candidate: 'candidate:1 1 UDP 0 127.0.0.1 11111 typ host' },
} as unknown as RTCPeerConnectionIceEvent);

expect(pc1.removeEventListener).toHaveBeenCalledWith(
'icecandidate',
onIceCandidate,
);
expect(pc2.addIceCandidate).not.toHaveBeenCalled();
});
});
Loading