Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-birds-barge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Support adaptive interruption gating for realtime models without server-side turn detection.
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ describe('interruption updateOptions reconnect', () => {
function createHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
5 changes: 3 additions & 2 deletions agents/src/inference/interruption/interruption_stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export class InterruptionStreamSentinel {
return { type: 'overlap-speech-started', speechDuration, startedAt, userSpeakingSpan };
}

static overlapSpeechEnded(endedAt: number): OverlapSpeechEnded {
return { type: 'overlap-speech-ended', endedAt };
static overlapSpeechEnded(endedAt: number, agentEnded = false): OverlapSpeechEnded {
return { type: 'overlap-speech-ended', endedAt, agentEnded };
}

static flush(): Flush {
Expand Down Expand Up @@ -287,6 +287,7 @@ export class InterruptionStreamBase {
type: 'overlapping_speech',
detectedAt: chunk.endedAt,
isInterruption: false,
agentEnded: chunk.agentEnded,
overlapStartedAt: this.overlapSpeechStartedAt,
speechInput: e.speechInput,
probabilities: e.probabilities,
Expand Down
8 changes: 8 additions & 0 deletions agents/src/inference/interruption/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ export interface OverlappingSpeechEvent {
type: 'overlapping_speech';
detectedAt: number;
isInterruption: boolean;
/**
* True when the overlap ended because the agent finished speaking rather than the user.
* The user may still be talking, so `isInterruption` (always false here) is inconclusive
* and must not be treated as a confirmed backchannel verdict.
*/
agentEnded?: boolean;
totalDurationInS: number;
predictionDurationInS: number;
detectionDelayInS: number;
Expand Down Expand Up @@ -66,6 +72,8 @@ export interface OverlapSpeechEnded {
type: 'overlap-speech-ended';
/** Absolute timestamp (ms) when overlap speech ended, used as the non-interruption event timestamp. */
endedAt: number;
/** Whether the overlap ended because agent speech ended, not because user speech ended. */
agentEnded?: boolean;
}

export interface Flush {
Expand Down
1 change: 1 addition & 0 deletions agents/src/inference/interruption/ws_transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export function createWsTransport(
type: 'overlapping_speech',
detectedAt: Date.now(),
isInterruption: true,
agentEnded: false,
totalDurationInS: entry.totalDurationInS,
predictionDurationInS: entry.predictionDurationInS,
overlapStartedAt: overlapSpeechStartedAt,
Expand Down
55 changes: 47 additions & 8 deletions agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export class AgentActivity implements RecognitionHooks {
private readonly closeAbort = new AbortController();
private interruptionDetector?: AdaptiveInterruptionDetector;
private isInterruptionDetectionEnabled: boolean;
private interruptionDetected = false;
private isInterruptionByAudioActivityEnabled: boolean;
private isDefaultInterruptionByAudioActivityEnabled: boolean;

Expand Down Expand Up @@ -331,6 +332,7 @@ export class AgentActivity implements RecognitionHooks {
this.onError(ev);

private readonly onInterruptionOverlappingSpeech = (ev: OverlappingSpeechEvent): void => {
this.interruptionDetected = ev.isInterruption;
this.agentSession.emit(AgentSessionEventTypes.OverlappingSpeech, ev);
};

Expand Down Expand Up @@ -1381,6 +1383,7 @@ export class AgentActivity implements RecognitionHooks {
this.agentSession._userSpeakingSpan,
);
}
this.interruptionDetected = false;

if (this.falseInterruptionTimer) {
// cancel the timer when user starts speaking but leave the paused state unchanged
Expand Down Expand Up @@ -1445,6 +1448,17 @@ export class AgentActivity implements RecognitionHooks {
}
}

onBackchannelConfirmed(): void {
if (
this.isInterruptionDetectionEnabled &&
this.realtimeSession !== undefined &&
this.turnDetection !== 'manual' &&
this.turnDetection !== 'realtime_llm'
) {
this.realtimeSession.clearAudio();
}
}

private interruptByAudioActivity(options?: { ignoreUserTranscriptUntil?: number }): void {
if (!this.isInterruptionByAudioActivityEnabled) {
return;
Expand Down Expand Up @@ -1880,6 +1894,22 @@ export class AgentActivity implements RecognitionHooks {
}
}

if (
!this.stt &&
this.turnDetection !== 'manual' &&
this.llm instanceof RealtimeModel &&
!this.llm.capabilities.turnDetection &&
this.isInterruptionDetectionEnabled &&
(info.backchannelOverAgent ||
(!this.interruptionDetected &&
this._currentSpeech !== undefined &&
!this._currentSpeech.interrupted))
) {
this.cancelPreemptiveGeneration();
this.realtimeSession?.clearAudio();
return false;
}

const oldTask = this._userTurnCompletedTask;
this._userTurnCompletedTask = this.createSpeechTask({
taskFn: () => this.userTurnCompleted(info, oldTask),
Expand Down Expand Up @@ -4371,16 +4401,25 @@ export class AgentActivity implements RecognitionHooks {
private resolveInterruptionDetector(): AdaptiveInterruptionDetector | undefined {
const agentInterruptionDetection = this.agent.turnHandling?.interruption?.mode;
const sessionInterruptionDetection = this.agentSession.interruptionDetection;
if (
!(

let canGatekeep: boolean;
if (this.llm instanceof RealtimeModel) {
// Realtime commits turns manually; barge-in withholds the commit, so no STT is needed.
canGatekeep = !this.llm.capabilities.turnDetection;
} else {
// The STT pipeline gatekeeps by holding and flushing transcripts.
canGatekeep = !!(
this.stt &&
this.stt.capabilities.alignedTranscript &&
this.stt.capabilities.streaming &&
this.vad !== undefined &&
this.turnDetection !== 'manual' &&
this.turnDetection !== 'realtime_llm' &&
!(this.llm instanceof RealtimeModel)
)
this.stt.capabilities.streaming
);
}

if (
!canGatekeep ||
this.vad === undefined ||
this.turnDetection === 'manual' ||
this.turnDetection === 'realtime_llm'
) {
if (
agentInterruptionDetection === 'adaptive' ||
Expand Down
29 changes: 26 additions & 3 deletions agents/src/voice/audio_recognition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export interface EndOfTurnInfo {
* caller drives its own `generateReply` (e.g. leaving a voicemail).
*/
skipReply?: boolean;
/** The turn's speech overlapped agent speech and was classified a backchannel. */
backchannelOverAgent?: boolean;
}

type EndOfTurnMetrics = {
Expand Down Expand Up @@ -139,6 +141,7 @@ export interface PreemptiveGenerationInfo {

export interface RecognitionHooks {
onInterruption: (ev: OverlappingSpeechEvent) => void;
onBackchannelConfirmed: () => void;
onStartOfSpeech: (ev: VADEvent) => void;
onVADInferenceDone: (ev: VADEvent) => void;
onEndOfSpeech: (ev: VADEvent) => void;
Expand Down Expand Up @@ -373,6 +376,8 @@ export class AudioRecognition {
private isAgentSpeaking: boolean;
private agentSpeechStartedAt?: number;
private interruptionDetected?: boolean;
private overlapInCurrentTurn = false;
private turnBackchannelOverAgent = false;
private interruptionStreamChannel?: StreamChannel<InterruptionSentinel | AudioFrame>;
private closed = false;

Expand Down Expand Up @@ -772,7 +777,7 @@ export class AudioRecognition {
// so it does not emit a synthetic `isInterruption: false` event following a real
// interruption.
if (priorIgnoreUserTranscriptUntil === undefined) {
this.onEndOfOverlapSpeech(Date.now());
this.onEndOfOverlapSpeech(Date.now(), undefined, true);
}
await this.flushHeldTranscripts(endCooldown);
}
Expand All @@ -784,6 +789,8 @@ export class AudioRecognition {
if (!this.endpointing.overlapping) {
this.endpointing.onStartOfSpeech(startedAt, true);
}
this.turnBackchannelOverAgent = false;
this.overlapInCurrentTurn = true;
this.trySendInterruptionSentinel(
InterruptionStreamSentinel.overlapSpeechStarted(
speechDuration,
Expand All @@ -795,15 +802,17 @@ export class AudioRecognition {
}

/** End interruption inference when overlap speech ends. */
async onEndOfOverlapSpeech(endedAt: number, userSpeakingSpan?: Span) {
async onEndOfOverlapSpeech(endedAt: number, userSpeakingSpan?: Span, agentEnded = false) {
if (!this.isInterruptionEnabled) {
return;
}
if (userSpeakingSpan && userSpeakingSpan.isRecording()) {
userSpeakingSpan.setAttribute(traceTypes.ATTR_IS_INTERRUPTION, 'false');
}

return this.trySendInterruptionSentinel(InterruptionStreamSentinel.overlapSpeechEnded(endedAt));
return this.trySendInterruptionSentinel(
InterruptionStreamSentinel.overlapSpeechEnded(endedAt, agentEnded),
);
}

/**
Expand Down Expand Up @@ -1222,6 +1231,8 @@ export class AudioRecognition {
const ctx = this.userTurnContext(span);
this.endpointing.onStartOfSpeech(speechStartTime, this.isAgentSpeaking);
this.interruptionDetected = undefined;
this.turnBackchannelOverAgent = false;
this.overlapInCurrentTurn = this.isAgentSpeaking;
otelContext.with(ctx, () => {
this.hooks.onStartOfSpeech({
type: VADEventType.START_OF_SPEECH,
Expand Down Expand Up @@ -1317,6 +1328,13 @@ export class AudioRecognition {

this.interruptionDetected = ev.isInterruption;

if (this.overlapInCurrentTurn && !ev.agentEnded) {
this.turnBackchannelOverAgent = !ev.isInterruption;
if (!ev.isInterruption && !this.speaking) {
this.hooks.onBackchannelConfirmed();
}
}

if (ev.isInterruption) {
this.hooks.onInterruption(ev);
}
Expand Down Expand Up @@ -1639,6 +1657,7 @@ export class AudioRecognition {
endOfUtteranceDelay: metrics.endOfUtteranceDelay,
startedSpeakingAt: metrics.startedSpeakingAt,
stoppedSpeakingAt: metrics.stoppedSpeakingAt,
backchannelOverAgent: this.turnBackchannelOverAgent,
});

if (committed) {
Expand Down Expand Up @@ -1670,6 +1689,8 @@ export class AudioRecognition {
}
}

this.turnBackchannelOverAgent = false;
this.overlapInCurrentTurn = false;
this.userTurnCommitted = false;
};

Expand Down Expand Up @@ -1833,6 +1854,8 @@ export class AudioRecognition {
const ctx = this.userTurnContext(span);
this.endpointing.onStartOfSpeech(startTime, this.isAgentSpeaking);
this.interruptionDetected = undefined;
this.turnBackchannelOverAgent = false;
this.overlapInCurrentTurn = this.isAgentSpeaking;
otelContext.with(ctx, () => this.hooks.onStartOfSpeech(ev));
}
this.speaking = true;
Expand Down
2 changes: 2 additions & 0 deletions agents/src/voice/audio_recognition_backchannel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
function createHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down Expand Up @@ -48,6 +49,7 @@ function overlapSpeechEvent(isInterruption: boolean): OverlappingSpeechEvent {
type: 'overlapping_speech',
detectedAt: Date.now(),
isInterruption,
agentEnded: false,
totalDurationInS: 0,
predictionDurationInS: 0,
detectionDelayInS: 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe('AudioRecognition duplicate EOU commit', () => {

const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_endpointing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { BaseEndpointing } from './turn_config/endpointing.js';
function createHooks(): RecognitionHooks {
return {
onInterruption: () => {},
onBackchannelConfirmed: () => {},
onStartOfSpeech: () => {},
onVADInferenceDone: () => {},
onEndOfSpeech: () => {},
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_eou.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class SilentVAD extends VAD {
function createHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { STTNode } from './io.js';
function createHooks() {
const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_interruption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AudioRecognition, type RecognitionHooks } from './audio_recognition.js'
function createHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_push_audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createSilenceFrame, createSilenceFrameLike } from './utils.js';
function createHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
4 changes: 4 additions & 0 deletions agents/src/voice/audio_recognition_span.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe('AudioRecognition user_turn span', () => {

const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down Expand Up @@ -199,6 +200,7 @@ describe('AudioRecognition user_turn span', () => {

const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down Expand Up @@ -272,6 +274,7 @@ describe('AudioRecognition user_turn span', () => {

const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down Expand Up @@ -373,6 +376,7 @@ describe('AudioRecognition user_turn span', () => {

const hooks: RecognitionHooks = {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ interface RecognitionInternals {
function makeHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/audio_recognition_vad_reset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface RecognitionInternals {
function makeHooks(): RecognitionHooks {
return {
onInterruption: vi.fn(),
onBackchannelConfirmed: vi.fn(),
onStartOfSpeech: vi.fn(),
onVADInferenceDone: vi.fn(),
onEndOfSpeech: vi.fn(),
Expand Down
Loading
Loading