Skip to content

fix: dangling audio elements when user ends the call - #161

Open
adrivelasco wants to merge 1 commit into
VapiAI:mainfrom
adrivelasco:fix/audio-element-leak-on-user-end-call
Open

fix: dangling audio elements when user ends the call#161
adrivelasco wants to merge 1 commit into
VapiAI:mainfrom
adrivelasco:fix/audio-element-leak-on-user-end-call

Conversation

@adrivelasco

@adrivelasco adrivelasco commented May 22, 2026

Copy link
Copy Markdown

Summary

I've been experiencing issues in my app where after having many Vapi-powered conversations in a row, the bot goes to silence. I was able to consistently reproduce this on Safari only.

Root cause: The SDK creates an <audio data-participant-id="..."> element per remote participant via buildAudioPlayer and only removes it when Daily's participant-left event fires (destroyAudioPlayer).

When the user ends the call via vapi.stop()call.destroy(), Daily tears down the local client before the server delivers participant-left for the bot, so destroyAudioPlayer never runs for it. The element stays in the DOM with a dead MediaStream attached on srcObject, and a new one is appended on the next call. Safari degrades audibly once enough accumulate.

Reproduce steps

  1. Start a call with vapi.start(...).
  2. End the call from the client side (call vapi.stop()). Do not let the bot end it.
  3. Inspect the DOM: a stranded <audio data-participant-id="..."> element remains in <body> with a dead MediaStream on srcObject.
  4. Repeat the start/stop cycle several times. Each call appends a new dangling audio element.
  5. After a handful of cycles (like 3), assistant audio becomes choppy/quieter/cuts out, and teardown produces audible buffer-flush noise. Chrome tolerates the accumulation longer but eventually exhibits the same symptoms.

Fix

  • Add teardownAudioPlayer(player) that explicitly nulls srcObject (releasing the MediaStream reference so the playback graph is freed immediately instead of waiting on GC) and removes the element from the DOM.
  • Add destroyAllAudioPlayers() that sweeps every audio[data-participant-id] element through the same teardown.
  • Call the sweep in both cleanup() (covers the bot-ends-call path via left-meetingcleanup) and stop() (covers the user-ends-call path).
  • Existing destroyAudioPlayer(participantId) now routes through teardownAudioPlayer too, so cleanup behavior is identical across all three call sites.

The sweep is idempotent — if participant-left already removed an element, the selector finds nothing.

The SDK creates an <audio data-participant-id="..."> element per remote
participant via buildAudioPlayer and only removes it when Daily's
participant-left event fires (destroyAudioPlayer).

When the user ends the call via vapi.stop() -> call.destroy(), Daily
tears down the local client before the server delivers participant-left
for the bot, so destroyAudioPlayer never runs for it. The element stays
in the DOM with a dead MediaStream attached on srcObject, and a new one
is appended on the next call. Safari degrades audibly once enough
accumulate.

Sweeps every audio[data-participant-id] element on teardown and
explicitly releases the MediaStream reference (srcObject = null) so the
playback graph is freed immediately instead of waiting on GC. Runs in
both cleanup() and stop() so it covers the bot-ends-call path
(left-meeting -> cleanup) and the user-ends-call path (stop).
@adrivelasco adrivelasco changed the title fix: stranded audio elements when user ends the call fix: dangling audio elements when user ends the call May 22, 2026
veltson-vapi added a commit that referenced this pull request Aug 14, 2026
…169)

* feat: expose the assistant audio element for playback volume control

## Problem

The SDK creates an `<audio>` element per remote track in `buildAudioPlayer`
and discards the reference, so there is no supported way to reach it. There is
no way to set playback volume at all: `volume-level` only reports measured
loudness, and `setMuted` affects the user's microphone.

Consumers work around this by querying the DOM for
`audio[data-participant-id]`, which relies on an internal detail and can match
an orphaned element from a previous call (#161). Requested in #37.

## What this adds

- `setVolume(volume)` for assistant playback volume, 0 to 1. The value is
  remembered, so it can be set before the track arrives and it survives across
  calls and mid-call track replacement. Out-of-range values are clamped and
  non-finite values ignored, since the DOM rejects both rather than saturating.
- `getAudioPlayer()` returning the element, or null before its track arrives.
- An `audio` event carrying the element the moment it exists, mirroring how
  `video` already emits one line above in the same handler.
- An `error` of type `audio-start-failed` when the browser blocks playback,
  which is usually autoplay policy and is recoverable by prompting for a
  gesture. Previously this surfaced only as an unhandled rejection.

Pairing a getter with an event matches `getLocalAudioLevel` alongside
`local-volume-level`.

## Lifecycle correctness

`buildAudioPlayer` awaits `play()`, which pends for the whole of media startup,
so several players can be in flight, they can finish out of order, and the call
or the participant can go away underneath them. Each build now takes a sequence
number and records its participant while pending, so that:

- a player built for a call that has since ended or been replaced is discarded
- a teardown invalidates only the departing participant's builds, so an
  unrelated participant leaving cannot cost us the assistant's audio
- a build that started before the one currently attached cannot overwrite it
- a superseded element is removed when its replacement is adopted, which
  matters because renegotiation reuses the participant id and both elements
  would otherwise match the selector teardown uses
- the reference is dropped in `stop()`, `cleanup()` and on `participant-left`,
  so `setVolume` can never write to a torn-down player

Consumer emits are routed through `emitToConsumer`, because a listener that
threw previously propagated out of the `track-started` handler and skipped
`sendAppMessage('playable')`, stalling the call. This applied to the existing
`video` emit too.

`handleTrackStarted` is extracted so the initial and reconnect paths share it
rather than being two byte-identical copies.

## Not addressed

The orphaned elements themselves (#161). This ensures the new API never hands
back an orphan, but does not stop them accumulating. Relatedly, `playable` is
still not sent when playback genuinely fails, which is unchanged behavior and a
question about what that signal means server-side.

## Testing

46 tests, `tsc`, `npm run build` and `npm run test:example` all pass. No
dependency, lockfile, tsconfig, packaging or CI changes.

The two functions that touch the DOM are reached through thin private wrappers
so the lifecycle logic around them is covered in the existing node test
environment without adding a DOM dependency. Every fix above was mutation
tested: reverting it fails the suite. Still uncovered, as on main: the DOM
calls themselves.

Also verified by hand on two live calls, confirming a volume set before the
call is applied to the real element and persists into the next call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: trim comments to the ones that carry non-obvious rationale

The change raised comment density in vapi.ts from 8% to 12% and used
multi-line paragraph blocks where the file's own style is short single lines.

Removed comments that restate the code (teardown nulling fields, the guard
condition in handleTrackStarted, method docs that repeat the method name) and
comments duplicated between production and tests. Compressed the rest, keeping
only what a reader cannot get from the code: why builds are tracked
individually, why the emit is guarded and not routed to the error event, why
volume is set before play(), why the DOMException fixture is not a real
DOMException.

vapi.ts is now 10%, and the test file 6%, against main's 8%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: veltsonbastien <bastienveltson@outlook.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: shubham-vapiai <shubham+github@vapi.ai>
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.

1 participant