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
20 changes: 20 additions & 0 deletions .changeset/release-audio-contexts-and-graphs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'livekit-client': minor
---

Release the audio contexts and web audio graphs the SDK creates. Previously they accumulated over mute/unmute cycles. They are now collected after `room.disconnect()`.

- `detectSilence` tears its context down on the throw path as well as the happy one.
- The cleanup from `createAudioAnalyser` disconnects its nodes before it closes the context, and it is now idempotent.
- The shared empty audio stream track is refcounted. `releaseEmptyAudioStreamTrack` is a new export, and its context closes once the last clone is handed back.
- If `webAudioMix` is an object without an `audioContext`, a room now closes the context it created for itself.
- When the underlying track is replaced, audio processors are passed the audio context. This lets them rebuild their filter nodes on unmute. Previously they left the old nodes behind.
- The iOS dummy audio element and its `visibilitychange` listener are torn down on disconnect. Previously the listener outlived every room on the page.

This also fixes a false `AudioSilenceDetected`. Silence detection read a zero-filled buffer from a context that never reached `running`. An autoplay-blocked page therefore reported silence on every track it checked.

Three further behavior changes to be aware of:

- A track retained across rooms loses its audio processor. `room.disconnect({ stopTracks: false })` detaches the track from the audio context, which stops the processor. Call `setProcessor` again after you republish the track.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This might be worth calling out in whatever docs surfaces mention TrackProcessor and associated behavior.

- Under `webAudioMix`, every attached element is muted, not only the first one. A second attached element no longer plays the track twice. When the audio context goes away, those elements are unmuted again and their volume is restored.
- `Participant.setAudioContext` and `LocalAudioTrack.setAudioContext` now return a promise. Both are marked `@internal`, but both appear in the published type declarations.
20 changes: 17 additions & 3 deletions examples/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ let startTime: number;

let streamReaderAbortController: AbortController | undefined;

let stopLocalVolumeMeter: (() => void) | undefined;

let localDataTracks: Array<LocalDataTrack> = [];
let remoteDataTracks: Array<RemoteDataTrack> = [];

Expand Down Expand Up @@ -307,17 +309,26 @@ const appActions = {
const track = pub.track;

if (isLocalTrack(track) && isAudioTrack(track)) {
const { calculateVolume } = createAudioAnalyser(track);
const { calculateVolume, cleanup } = createAudioAnalyser(track);

setInterval(() => {
const interval = setInterval(() => {
$('local-volume')?.setAttribute('value', calculateVolume().toFixed(4));
}, 200);
stopLocalVolumeMeter?.();
stopLocalVolumeMeter = () => {
clearInterval(interval);
cleanup().catch(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Should this log the caught error?

};
}
renderParticipant(room.localParticipant);
updateButtonsForPublishState();
renderScreenShare(room);
})
.on(RoomEvent.LocalTrackUnpublished, () => {
.on(RoomEvent.LocalTrackUnpublished, (pub) => {
if (pub.kind === Track.Kind.Audio) {
stopLocalVolumeMeter?.();
stopLocalVolumeMeter = undefined;
}
renderParticipant(room.localParticipant);
updateButtonsForPublishState();
renderScreenShare(room);
Expand Down Expand Up @@ -1013,6 +1024,9 @@ function handleRoomDisconnect(reason?: DisconnectReason) {
});
renderScreenShare(currentRoom);

stopLocalVolumeMeter?.();
stopLocalVolumeMeter = undefined;

localDataTracks = [];
renderLocalDataTracks();

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
isSVCCodec,
isVideoCodec,
isVideoTrack,
releaseEmptyAudioStreamTrack,
supportsAV1,
supportsAdaptiveStream,
supportsAudioOutputSelection,
Expand Down Expand Up @@ -145,6 +146,7 @@ export {
getEmptyVideoStreamTrack,
getLogger,
isBrowserSupported,
releaseEmptyAudioStreamTrack,
setLogExtension,
setLogLevel,
supportsAV1,
Expand Down
116 changes: 116 additions & 0 deletions src/room/Room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,119 @@ describe('stream state updates', () => {
expect(participantEvents).not.toHaveBeenCalled();
});
});

describe('audio context ownership', () => {
class StubAudioContext {
state: AudioContextState = 'running';

closed = false;

async close() {
this.closed = true;
this.state = 'closed';
}

async resume() {
this.state = 'running';
}
}

const created: StubAudioContext[] = [];

function stubAudioContext() {
created.length = 0;
vi.stubGlobal(
'AudioContext',
class extends StubAudioContext {
constructor() {
super();
created.push(this);
}
},
);
}

/** `acquireAudioContext` and `releaseAudioContext` are private */
function internals(room: Room) {
return room as unknown as {
acquireAudioContext: () => Promise<void>;
releaseAudioContext: (retained?: unknown[]) => Promise<void>;
audioContext?: StubAudioContext;
ownsAudioContext: boolean;
localParticipant: { setAudioContext: (ctx: AudioContext | undefined) => Promise<void> };
};
}

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

it('closes a context it created for a webAudioMix object that supplies none', async () => {
stubAudioContext();
const room = internals(new Room({ webAudioMix: {} }));

await room.acquireAudioContext();
expect(created).toHaveLength(1);
expect(room.ownsAudioContext).toBe(true);
expect(room.audioContext).toBe(created[0]);

await room.releaseAudioContext();
expect(created[0].closed).toBe(true);
expect(room.audioContext).toBeUndefined();
});

it('leaves a supplied context open', async () => {
stubAudioContext();
const provided = new StubAudioContext();
const room = internals(
new Room({ webAudioMix: { audioContext: provided as unknown as AudioContext } }),
);

await room.acquireAudioContext();
expect(room.ownsAudioContext).toBe(false);
expect(created).toHaveLength(0);

await room.releaseAudioContext();
expect(provided.closed).toBe(false);
});

it('closes the context even when a retained track fails to detach', async () => {
stubAudioContext();
const room = internals(new Room({ webAudioMix: {} }));
await room.acquireAudioContext();

const retained = { setAudioContext: () => Promise.reject(new Error('detach failed')) };
await room.releaseAudioContext([retained]);

expect(created[0].closed).toBe(true);
});

it('does not build a context for an acquire queued behind a release', async () => {
stubAudioContext();
const room = internals(new Room({ webAudioMix: {} }));
await room.acquireAudioContext();
expect(created).toHaveLength(1);

// `startAudio` reads `audioContextReleased` before it waits on the mutex, so its acquire can
// already be queued behind a disconnect by the time the flag is set
const released = room.releaseAudioContext();
const acquired = room.acquireAudioContext();
await Promise.all([released, acquired]);

expect(created).toHaveLength(1);
expect(created[0].closed).toBe(true);
expect(room.audioContext).toBeUndefined();
});

it('closes the outgoing context even when a participant fails to move off it', async () => {
stubAudioContext();
const room = internals(new Room({ webAudioMix: {} }));
await room.acquireAudioContext();

vi.spyOn(room.localParticipant, 'setAudioContext').mockRejectedValue(new Error('move failed'));
await room.releaseAudioContext();

expect(created[0].closed).toBe(true);
});
});
Loading
Loading