Skip to content

consistently clean up audio contexts and graphs - #2029

Open
lukasIO wants to merge 6 commits into
mainfrom
lukas/webaudio-fix
Open

lukasIO wants to merge 6 commits into
mainfrom
lukas/webaudio-fix

Conversation

@lukasIO

@lukasIO lukasIO commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1969. Linear: CLT-2995.

The leak

Every AudioContext, AnalyserNode, MediaStreamAudioSourceNode and MediaStreamAudioDestinationNode the SDK created during a call stayed alive after room.disconnect(). They accumulated over mute/unmute cycles, and a page that joined several rooms kept every graph it had ever built. After this change the heap returns to its pre-call baseline once the room is disconnected and GC runs.

The main sources:

  • detectSilence closed its context on the happy path only, so any throw stranded the context, the analyser and the source node.
  • The cleanup returned by createAudioAnalyser closed the context without disconnecting its nodes, and it was not idempotent.
  • The shared empty audio stream track was never released. It is now refcounted, and its context closes when the last clone is handed back.
  • A room that created its own context for webAudioMix (an object with no audioContext) never closed it.
  • Replacing the underlying track left the old filter nodes of the processor behind, because the processor was not given the new audio context.
  • The iOS dummy audio element and its visibilitychange listener were never torn down, so the listener outlived every room on the page.
  • An autoplay-blocked page could leave resume() pending forever and strand the whole graph. resume() is now raced against a timeout.
  • startAudio() after a disconnect rebuilt the iOS dummy element, its listener and a context that nothing closed. It is now guarded on audioContextReleased.

Audio context teardown is serialized behind a mutex, so a disconnect racing a mute/unmute cycle cannot leave a half-torn-down graph.

Also fixed: a false AudioSilenceDetected

Silence detection read a zero-filled buffer from a context that had never reached running. An autoplay-blocked page therefore reported silence on every track it checked.

This is one half of the fix

The krisp side of the same leak shipped in @livekit/krisp-noise-filter 0.4.4. Users who run noise cancellation need both that version and this release. Neither one alone returns the heap to baseline.

One note on the original report: meet.livekit.io does enable krisp noise cancellation, so what was measured there was the combined SDK and krisp leak, not an SDK-only one.

Why the changeset is minor, not patch

This adds a public export, releaseEmptyAudioStreamTrack, the counterpart to getEmptyAudioStreamTrack. Callers that take a clone of the shared empty track hand it back through this function, and the shared context closes when the count reaches zero.

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.
  • Processors can now be called with audioContext: undefined. A processor must handle that and tear its nodes down, rather than assume a context is always present.
  • Under webAudioMix, every attached element is muted, not only the first one, so a second attached element no longer plays the track twice. Those elements are unmuted and their volume restored when the context goes away.
  • Participant.setAudioContext and LocalAudioTrack.setAudioContext now return a promise. Both are marked @internal, but both appear in the published type declarations.

Verification

  • npx tsc --noEmit clean, pnpm test 870 passed / 1 skipped.
  • New unit tests cover the empty-track refcount, the teardown order in createAudioAnalyser, and the disconnected-room guards.
  • The volume meter in the demo leaked an analyser and an interval of its own. That is fixed here too, because the demo page is what the heap is measured on.

Two gaps, stated plainly:

  • Chrome heap verification is still outstanding. The unit tests assert the teardown calls. They do not prove the browser actually collects the nodes.
  • The startAudio() disconnected-room guard ships without a regression test.

@changeset-bot

changeset-bot Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 74ed7c1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 113.19 KB (+0.78% 🔺)
dist/livekit-client.umd.js 122.32 KB (+0.66% 🔺)

Serialize audio context teardown behind a mutex so a disconnect that races a mute/unmute cycle cannot leave a context, an analyser or a source node behind. Race `resume()` against a timeout in `detectSilence` so an autoplay-blocked page cannot strand the graph on a pending promise, and guard `startAudio` on `audioContextReleased` so calling it after a disconnect no longer builds an iOS dummy element, a listener and a context that nothing closes.

Export `releaseEmptyAudioStreamTrack` so callers can hand back a clone of the shared empty track, and cover the refcount, the teardown order and the disconnected-room guards with unit tests.

The volume meter in the demo never released its analyser or cleared its interval, which leaked the same three node types from the page used to measure the fix.
@lukasIO
lukasIO marked this pull request as ready for review October 4, 2026 22:49
devin-ai-integration[bot]

This comment was marked as resolved.

Abort an audio context acquisition that was queued behind a disconnect, so a late `startAudio` cannot clear the released flag and build a context on a room that nothing tears down again. A connect still acquires normally, which is what lets a reconnect recover.

Disconnect only the web audio edges the track created, walking the chain recorded when the graph was wired. Reading the plugin array instead left the previous plugins attached once `setWebAudioPlugins` had replaced them, and a bare `disconnect()` on a node the application owns took the routing owned by the application down with it.

Reference count the iOS dummy audio element across rooms. It is shared through the DOM, so the first room to disconnect used to remove the element and release the silent track that the other rooms on the page were still playing. Its visibility listener is now per room, because the shared one resumed playback on whichever room happened to create it.

@1egoman 1egoman left a comment

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.

It's hard for me to review this comprehensively in its current state since all the different fixes are quite mixed together. However, I did a pass through it and saw a few potentially unexpected things.


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.

Comment thread examples/demo/demo.ts
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?

Comment on lines +193 to +209
/**
* registers the krisp feature listeners on the given processed track, moving them over from
* a previously observed one. Passing `undefined` just removes the existing listeners.
*/
private observeProcessedTrack(processedTrack: MediaStreamTrack | undefined) {
if (this.observedProcessedTrack === processedTrack) {
return;
}
if (this.observedProcessedTrack) {
this.observedProcessedTrack.removeEventListener(
'enable-lk-krisp-noise-filter',
this.handleKrispNoiseFilterEnable,
);
this.observedProcessedTrack.removeEventListener(
'disable-lk-krisp-noise-filter',
this.handleKrispNoiseFilterDisable,
);

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.

question: In practice, is there any situation where this.observedProcessedTrack would ever not equal this.processor.processedTrack? From what I can tell no, since only this.processor.processedTrack or undefined is ever passed in.

Assuming I am not missing something here - would it be less complex for this to call this.processor?.processedTrack.removeEventListener(...) rather than tracking the this.observedProcessedTrack state separately? And then maybe this method could be renamed something like transferNoiseFilterHandlers() (ie, remove processedTrack)?

Comment on lines +492 to +496
// `stop` is synchronous, so we can't await the teardown of the processor here.
// make sure a failing teardown doesn't surface as an unhandled rejection.
this.processor?.destroy().catch((error) => {
this.log.error('failed to destroy processor', { ...this.logContext, error });
});

@1egoman 1egoman Oct 8, 2026 •

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.

thought: I don't think changing stop to return a promise wouldn't be a breaking change, since track.stop() would still work from a user's perspective, and they currently would have no reason to use the undefined return value.

Given this, would it be worth awaiting this.processor?.destroy() here and bubbling this rejection upwards to the caller?

IMO it still makes sense to add the catch log here, and by keeping this catch as a parallel path that would silence any possible unhandledrejection errors as well.

Comment on lines +249 to +261
// the plugin nodes belong to whoever passed them in and may feed graphs of their own, so only
// the edges this track created come down. A bare `node.disconnect()` would drop the
// application's own routing with them
for (let i = 0; i < this.connectedNodes.length - 1; i += 1) {
try {
this.connectedNodes[i].disconnect(this.connectedNodes[i + 1]);
} catch {
// a plugin node the application already disconnected itself, the rest of the chain still
// has to come apart
}
}
this.connectedNodes = [];
// the source and gain nodes are ours alone, so every remaining edge of theirs is ours to drop

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.

question: I might be misreading this, but I think this.connectedNodes[i].disconnect(...); would never run for the final entry in this.connectedNodes, since the loop runs until i < this.connectedNodes.length - 1? That final entry would only get hit by this.connectedNodes[i + 1].

It's hard for me to be 100% sure that this is intended though without deeper context on how the audio node graph is structured.

Comment thread src/room/Room.ts
Comment on lines -1358 to -1365
/**
* iOS blocks audio element playback if
* - user is not publishing audio themselves and
* - no other audio source is playing
*
* as a workaround, we create an audio element with an empty track, so that
* silent audio is always playing
*/

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: Can you add this comment back in somewhere? IMO this better explains why this is needed in an easier to understand way than the LLM inserted versions do.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AudioWorkletNode, AudioContext, AnalyserNode and MediaStreamAudioSourceNode leak on every mute/unmute cycle

2 participants