feat(client): add setOnHold to pause and resume a voice conversation - #974
chinmayv095 wants to merge 3 commits into
Conversation
Muting the microphone and zeroing the volume stop the user and the agent being heard, but neither of them stops the agent: the utterance already in flight plays to completion, the audio behind it keeps queueing, and the agent keeps taking turns into a page nobody is looking at. The one piece that stops local playback, output.interrupt(), is not reachable from a Conversation, so apps that hand control back and forth between the agent and something else have to fork the SDK to get at it. setOnHold(true) drops the current utterance, silences audio that keeps arriving, mutes the microphone so nothing nearby starts a turn, and sends user_activity periodically so the agent does not speak up on its own or let the session idle out. Releasing the hold restores the microphone and the volume the caller asked for, and flushes audio that arrived meanwhile so playback resumes from what the agent says next. setVolume and setMicMuted calls made during a hold are applied when it is released rather than reopening a held conversation. Fixes elevenlabs#334
PR SummaryMedium Risk Overview On hold: interrupts in-flight agent audio with a short fade, forces output volume to zero, mutes the mic, and sends On release: restores the caller’s volume and mic state (including Text conversations throw on Reviewed by Cursor Bugbot for commit 5d446c1. Bugbot is set up for automated code reviews on this repo. Configure here. |
handleAudio was the only path gated on the hold, but it is not the only path that reports the agent as speaking. On the WebSocket transport the output worklet posts its own progress events, and audio that arrives during a hold is still fed to it at volume zero, so it reports speaking for buffers nobody can hear. On WebRTC the mode comes from the room's active speaker instead, which the hold never touches at all. Gate the mode in updateMode, where all three sources arrive, rather than at each of them. A transport that plays the agent's track itself is never interrupted by the hold, so the mode it reported while held is remembered and reported when the hold is released; local playback is flushed on release and the worklet reports the drain itself, so there is nothing to restore there.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 12e4d60. Configure here.
…ld starts The hold reports listening itself, and that cleared the record of what the agent was doing, so a live track that was mid-utterance when the hold began came back audible with the mode stuck on listening.

Fixes #334.
setMicMutedstops the user being heard andsetVolume({ volume: 0 })stops the agent being heard, but neither of them stops the agent. The utterance already in flight plays to completion, the audio behind it keeps queueing up, and the agent keeps taking turns into a page nobody is looking at. That is what the presentation app in the issue ran into, and the workaround posted there needs a forked SDK because the one piece that actually stops local playback,output.interrupt(), is not reachable from aConversation.This adds
setOnHold(isOnHold)andisOnHold()to voice conversations. The naming follows the "on hold" framing @kraenhansen suggested on the issue and @hardiksondagar agreed with, and the boolean setter matches the shape ofsetMicMutedrather than introducing apause()/resume()pair.What a hold does
output.interrupt()drops the current utterance, and the output is then silenced so audio that keeps arriving is not played.user_activityevery second, which is how the agent is told the user is busy elsewhere. It is what keeps the agent from speaking up on its own during a long hold, and what stops the session idling out while it is held.Releasing the hold restores the microphone and the volume the caller had asked for, and flushes audio that arrived during the hold so the agent does not resume from the middle of a sentence nobody heard.
Two ordering details worth reading in the diff
interrupt()restores the output's own stored volume once its fade completes, so the value playback should come back to has to be set before that flush fires. Hold therefore sets the zero afterinterrupt(), and release sets the caller's volume before it. Both directions are pinned in the tests by invocation order, because getting this backwards silently un-mutes a held conversation 50ms later.playAudioalready cancels a pending interrupt timeout and postsclearInterrupteditself, so a chunk that arrives during either fade flushes the queue at that moment instead of undoing the hold. That is why a 50ms fade is enough here, instead of the two second defaultinterrupt()uses for a server-side interruption, which is far too slow for a caller that asked for the agent to stop now.Calls made during a hold
setVolumeandsetMicMutedare remembered and applied when the hold is released, rather than taking effect immediately. A volume change bringing a held agent back, or an unrelated unmute reopening the microphone mid-hold, would both defeat the feature. The mic state to return to is read from the input controller at the moment the hold starts, so a hold released without any intervening call leaves the microphone exactly as it found it.onModeChangeno longer reportsspeakingfor audio that arrives during a hold, since the user cannot hear it.onAudiois deliberately left alone: it is the wire-level callback, so consumers doing their own playback or transcript work still see everything that arrives.Transports
On WebSocket the local queue is what plays, so
interrupt()is what stops the agent. On WebRTC playback belongs to LiveKit andinterrupt()is a documented no-op there, so the local silence comes from the output volume path instead. The microphone mute and theuser_activitysuppression are identical on both, and on both the agent is inaudible for the whole hold and stops taking new turns.Scope
@elevenlabs/clientonly, matching the issue's label. Nothing in@elevenlabs/reactis touched. A text conversation has no audio to stop and no microphone to mute, sosetOnHoldthrows there likesetVolumeandsetMicMutedalready do, andisOnHold()is alwaysfalse.Verification
packages/client/src/VoiceConversation.test.tsis new and covers the hold behaviour in 10 tests: muting and silencing on hold, restoring the volume and the microphone on release, a microphone that was already muted staying muted, deferredsetVolume/setMicMuted, a repeatedsetOnHoldbeing a no-op, theuser_activityinterval starting and stopping, the interval being cleared byendSession, no interval for a session that has already ended, and the mode reporting above. All 10 fail againstmainwithout this change. The text-conversation assertion sits next to the existingsetVolumeone inindex.test.ts.turbo check-types lint test --filter=@elevenlabs/client: clean, 250 tests passing.turbo check-types lint test: green exceptconvai-widget-core'sDismissButton.test.ts, which fails identically on a cleanmaincheckout (browser matcher timeout, unrelated to this change).Changeset included as a minor bump for
@elevenlabs/client.