Skip to content
Open
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
35 changes: 35 additions & 0 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,41 @@ Terminates the active call session.
#### `activeCall: E2ECall | null`
Getter that returns the current active call object.

An active call also exposes local audio controls:

```typescript
call.mute();
call.unmute();
call.setMuted(true);
console.log(call.muted);

await call.switchInputDevice('microphone-device-id');
await call.setOutputDevice('speaker-device-id');

const metrics = await call.getStatsSnapshot();
console.log(metrics.localAudioLevel);
```

`mute()` keeps the microphone track alive and toggles its `enabled` property;
ending a call stops the track. Switching microphones uses
`RTCRtpSender.replaceTrack()` and stops the previous track after replacement
succeeds. If replacement fails, the previous track remains active and the new
track is cleaned up.

`setOutputDevice()` requires the browser's `HTMLMediaElement.setSinkId()` API.
Some browsers do not provide it, and supported browsers may require HTTPS and
output-device permission. The method rejects with a descriptive error when
selection is unavailable or denied without detaching the current audio.

`getStatsSnapshot()` reports an optional local RMS audio level when
`AudioContext` is available. Browser support, autoplay policies, and device
permissions can vary; unsupported Web Audio environments return no level
instead of interrupting the call. It also reports an optional
`remoteAudioLevel` when `RTCPeerConnection.getStats()` exposes an inbound audio
level; remote metrics are browser-dependent and are not guaranteed. Call cleanup closes meter contexts,
detaches/removes remote audio elements, clears peer handlers, and stops media
tracks.

---

### Events
Expand Down
48 changes: 48 additions & 0 deletions service/src/webrtc/audioSink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ function makeFakeAudioElement() {
setAttribute: jest.fn((name: string, value: string) => { attributes[name] = value; }),
getAttribute: (name: string) => attributes[name],
play: jest.fn().mockResolvedValue(undefined),
remove: jest.fn(),
srcObject: null as unknown,
};
}
Expand All @@ -16,9 +17,11 @@ describe('AudioSink', () => {
let fakeAudioEl: ReturnType<typeof makeFakeAudioElement>;
let createElement: jest.Mock;
let appendChild: jest.Mock;
let originalHTMLMediaElement: unknown;

beforeEach(() => {
jest.useFakeTimers();
originalHTMLMediaElement = (globalThis as any).HTMLMediaElement;
fakeAudioEl = makeFakeAudioElement();
createElement = jest.fn().mockReturnValue(fakeAudioEl);
appendChild = jest.fn();
Expand All @@ -32,6 +35,11 @@ describe('AudioSink', () => {
afterEach(() => {
jest.useRealTimers();
delete (globalThis as any).document;
if (originalHTMLMediaElement) {
(globalThis as any).HTMLMediaElement = originalHTMLMediaElement;
} else {
delete (globalThis as any).HTMLMediaElement;
}
});

it('attach() creates an autoplaying <audio> element, sets the stream, and appends it to the DOM', async () => {
Expand Down Expand Up @@ -71,8 +79,48 @@ describe('AudioSink', () => {
sink.detach();

expect(fakeAudioEl.srcObject).toBeNull();
expect(fakeAudioEl.remove).toHaveBeenCalled();

// detach() again should be a no-op and not throw.
expect(() => sink.detach()).not.toThrow();
});

it('setOutputDevice() delegates to setSinkId when supported', async () => {
const setSinkId = jest.fn().mockResolvedValue(undefined);
const mediaElement = class {};
(mediaElement.prototype as any).setSinkId = jest.fn();
(globalThis as any).HTMLMediaElement = mediaElement;
(fakeAudioEl as any).setSinkId = setSinkId;
const sink = new AudioSink(new Logger('test'));
await sink.attach({} as MediaStream, 'remote');

await sink.setOutputDevice('speaker-1');

expect(setSinkId).toHaveBeenCalledWith('speaker-1');
});

it('setOutputDevice() rejects clearly when the browser lacks setSinkId', async () => {
(globalThis as any).HTMLMediaElement = class {};
const sink = new AudioSink(new Logger('test'));
await sink.attach({} as MediaStream, 'remote');

await expect(sink.setOutputDevice('speaker-1')).rejects.toThrow(
'Output device selection is not supported by this browser.',
);
});

it('setOutputDevice() wraps permission or device failures without detaching audio', async () => {
const setSinkId = jest.fn().mockRejectedValue(new Error('permission denied'));
const mediaElement = class {};
(mediaElement.prototype as any).setSinkId = jest.fn();
(globalThis as any).HTMLMediaElement = mediaElement;
(fakeAudioEl as any).setSinkId = setSinkId;
const sink = new AudioSink(new Logger('test'));
await sink.attach({} as MediaStream, 'remote');

await expect(sink.setOutputDevice('speaker-1')).rejects.toThrow(
'Unable to select the output device.',
);
expect(fakeAudioEl.srcObject).not.toBeNull();
});
});
31 changes: 30 additions & 1 deletion service/src/webrtc/audioSink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class AudioSink {
constructor(private logger: Logger) {}

public async attach(stream: MediaStream, tag: string): Promise<void> {
this.detach();
this.logger.log('Adding remote audio track');
this.audioEl = document.createElement('audio');
this.audioEl.setAttribute('autoplay', 'true');
Expand All @@ -31,9 +32,37 @@ export class AudioSink {
document.body.appendChild(this.audioEl);
}

public async setOutputDevice(deviceId: string): Promise<void> {
if (!this.audioEl) {
throw new Error('No remote audio element is available for output device selection.');
}

// setSinkId is not available in every browser and may also require a
// secure context and permission for the selected output device.
if (typeof HTMLMediaElement === 'undefined' || !('setSinkId' in HTMLMediaElement.prototype)) {
throw new Error('Output device selection is not supported by this browser.');
}

const audioElement = this.audioEl as HTMLAudioElement & {
setSinkId: (sinkId: string) => Promise<void>;
};
try {
await audioElement.setSinkId(deviceId);
} catch (error) {
this.logger.log('Failed to set output device:', error);
const outputDeviceError = new Error(
'Unable to select the output device. Check browser permissions, device availability, and secure-context requirements.',
);
(outputDeviceError as Error & { cause?: unknown }).cause = error;
throw outputDeviceError;
}
}

public detach(): void {
if (this.audioEl) {
this.audioEl.srcObject = null;
const audioElement = this.audioEl;
audioElement.srcObject = null;
audioElement.remove();
this.audioEl = undefined;
}
}
Expand Down
125 changes: 124 additions & 1 deletion service/src/webrtc/peer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { callEvents, WebRtcSignalPayload } from './types';
// ---------------------------------------------------------------------------
const mockAttach = jest.fn().mockResolvedValue(undefined);
const mockDetach = jest.fn();
let lastAudioContext: FakeAudioContext | undefined;
jest.mock('./audioSink', () => ({
AudioSink: jest.fn().mockImplementation(() => ({
attach: mockAttach,
Expand All @@ -17,11 +18,12 @@ import { Peer } from './peer';

interface FakeTrack {
kind: string;
enabled: boolean;
stop: jest.Mock;
}

function makeFakeTrack(kind = 'audio'): FakeTrack {
return { kind, stop: jest.fn() };
return { kind, enabled: true, stop: jest.fn() };
}

function makeFakeStream(tracks: FakeTrack[]) {
Expand All @@ -44,12 +46,38 @@ class FakeRTCPeerConnection {
public addIceCandidate = jest.fn().mockResolvedValue(undefined);
public close = jest.fn();
public addTrack = jest.fn();
public getSenders = jest.fn();
public getStats = jest.fn().mockResolvedValue(new Map());

constructor(public config: unknown) {}
}

class FakeAudioContext {
public source = {
connect: jest.fn(),
disconnect: jest.fn(),
};
public analyser = {
fftSize: 4,
getByteTimeDomainData: jest.fn((samples: Uint8Array) => {
samples.fill(128);
samples.set([128, 160, 96, 128]);
return samples;
}),
};
public close = jest.fn().mockResolvedValue(undefined);

constructor() {
lastAudioContext = this;
}

public createMediaStreamSource = jest.fn(() => this.source);
public createAnalyser = jest.fn(() => this.analyser);
}

function installWebRtcGlobals() {
(globalThis as any).RTCPeerConnection = FakeRTCPeerConnection;
(globalThis as any).AudioContext = FakeAudioContext;
(globalThis as any).RTCSessionDescription = class {
type: string; sdp: string;
constructor(init: { type: string; sdp: string }) { this.type = init.type; this.sdp = init.sdp; }
Expand All @@ -70,6 +98,8 @@ function uninstallWebRtcGlobals() {
delete (globalThis as any).RTCSessionDescription;
delete (globalThis as any).RTCIceCandidate;
delete (globalThis as any).navigator;
delete (globalThis as any).AudioContext;
lastAudioContext = undefined;
}

async function createPeer(subs: Map<callEvents, Set<Function>> = new Map()): Promise<{ peer: Peer; pc: FakeRTCPeerConnection; sendSignal: jest.Mock }> {
Expand Down Expand Up @@ -202,12 +232,105 @@ describe('Peer', () => {
expect(mockAttach).toHaveBeenCalledWith(remoteStream, 'remote');
});

it('mute controls toggle the local audio track without stopping it', async () => {
const { peer } = await createPeer();
const localStream = await (globalThis as any).navigator.mediaDevices.getUserMedia.mock.results[0].value;
const localTrack = localStream.getAudioTracks()[0] as FakeTrack;

expect(peer.muted).toBe(false);

peer.mute();
expect(localTrack.enabled).toBe(false);
expect(peer.muted).toBe(true);
expect(localTrack.stop).not.toHaveBeenCalled();

peer.mute();
expect(localTrack.enabled).toBe(false);

peer.setMuted(false);
expect(localTrack.enabled).toBe(true);
expect(peer.muted).toBe(false);

peer.unmute();
expect(localTrack.enabled).toBe(true);
expect(localTrack.stop).not.toHaveBeenCalled();
});

it('switchInputDevice replaces and stops the local audio track while preserving mute state', async () => {
const { peer, pc } = await createPeer();
const getUserMedia = (globalThis as any).navigator.mediaDevices.getUserMedia;
const oldStream = await getUserMedia.mock.results[0].value;
const oldTrack = oldStream.getAudioTracks()[0] as FakeTrack;
const newTrack = makeFakeTrack('audio');
const newStream = makeFakeStream([newTrack]);
const replaceTrack = jest.fn().mockResolvedValue(undefined);
pc.getSenders.mockReturnValue([{ track: oldTrack, replaceTrack }]);
getUserMedia.mockResolvedValueOnce(newStream);

peer.mute();
await peer.switchInputDevice('mic-2');

expect(getUserMedia).toHaveBeenLastCalledWith({
audio: { deviceId: { exact: 'mic-2' } },
video: false,
});
expect(replaceTrack).toHaveBeenCalledWith(newTrack);
expect(newTrack.enabled).toBe(false);
expect(oldTrack.stop).toHaveBeenCalledTimes(1);
expect(peer.muted).toBe(true);
});

it('switchInputDevice stops the new track and preserves the old track when replacement fails', async () => {
const { peer, pc } = await createPeer();
const getUserMedia = (globalThis as any).navigator.mediaDevices.getUserMedia;
const oldStream = await getUserMedia.mock.results[0].value;
const oldTrack = oldStream.getAudioTracks()[0] as FakeTrack;
const newTrack = makeFakeTrack('audio');
const replaceTrack = jest.fn().mockRejectedValue(new Error('replace failed'));
pc.getSenders.mockReturnValue([{ track: oldTrack, replaceTrack }]);
getUserMedia.mockResolvedValueOnce(makeFakeStream([newTrack]));

await expect(peer.switchInputDevice('mic-3')).rejects.toThrow('replace failed');

expect(newTrack.stop).toHaveBeenCalledTimes(1);
expect(oldTrack.stop).not.toHaveBeenCalled();
expect(peer.muted).toBe(false);
});

it('getStatsSnapshot() returns local and remote audio levels and closes the meter on dispose', async () => {
const { peer, pc } = await createPeer();
pc.getStats.mockResolvedValue(new Map([
['inbound-audio', { type: 'inbound-rtp', kind: 'audio', audioLevel: 0.4 }],
]));

const stats = await peer.getStatsSnapshot();
const audioContext = lastAudioContext!;

expect(stats.localAudioLevel).toBeCloseTo(Math.sqrt(0.125 / 256), 5);
expect(stats.remoteAudioLevel).toBe(0.4);
expect(audioContext.source.connect).toHaveBeenCalled();

peer.dispose();

expect(audioContext.source.disconnect).toHaveBeenCalled();
expect(audioContext.close).toHaveBeenCalled();
});

it('getStatsSnapshot() fails safe when AudioContext is unavailable', async () => {
const { peer } = await createPeer();
delete (globalThis as any).AudioContext;

await expect(peer.getStatsSnapshot()).resolves.toEqual({});
});

it('dispose() stops local tracks, detaches audio, and closes the connection', async () => {
const { peer, pc } = await createPeer();

peer.dispose();

expect(mockDetach).toHaveBeenCalled();
expect(pc.close).toHaveBeenCalled();
expect(() => peer.dispose()).not.toThrow();
expect(pc.close).toHaveBeenCalledTimes(1);
});
});
Loading