Skip to content

Commit 3e0c6be

Browse files
authored
Add AEC warmup functionality to AgentSession and AgentActivity (#1091)
1 parent 7ff548f commit 3e0c6be

4 files changed

Lines changed: 59 additions & 2 deletions

File tree

.changeset/itchy-rocks-cheer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Add AEC warmup functionality to AgentSession and AgentActivity

agents/src/voice/agent_activity.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Span } from '@opentelemetry/api';
77
import { ROOT_CONTEXT, context as otelContext, trace } from '@opentelemetry/api';
88
import { Heap } from 'heap-js';
99
import { AsyncLocalStorage } from 'node:async_hooks';
10-
import { ReadableStream } from 'node:stream/web';
10+
import { ReadableStream, TransformStream } from 'node:stream/web';
1111
import { type ChatContext, ChatMessage } from '../llm/chat_context.js';
1212
import {
1313
type ChatItem,
@@ -485,8 +485,24 @@ export class AgentActivity implements RecognitionHooks {
485485
void this.audioStream.close();
486486
this.audioStream = new MultiInputStream<AudioFrame>();
487487

488+
// Filter is applied on this.audioStream.stream (downstream of MultiInputStream) rather
489+
// than on the source audioStream via pipeThrough. pipeThrough locks its source stream, so
490+
// if it were applied directly on audioStream, that lock would survive MultiInputStream.close()
491+
// and make audioStream permanently locked for subsequent attachAudioInput calls (e.g. handoff).
492+
const aecWarmupAudioFilter = new TransformStream<AudioFrame, AudioFrame>({
493+
transform: (frame, controller) => {
494+
const shouldDiscardForAecWarmup =
495+
this.agentSession.agentState === 'speaking' && this.agentSession._aecWarmupRemaining > 0;
496+
if (!shouldDiscardForAecWarmup) {
497+
controller.enqueue(frame);
498+
}
499+
},
500+
});
501+
488502
this.audioStreamId = this.audioStream.addInputStream(audioStream);
489-
const [realtimeAudioStream, recognitionAudioStream] = this.audioStream.stream.tee();
503+
const [realtimeAudioStream, recognitionAudioStream] = this.audioStream.stream
504+
.pipeThrough(aecWarmupAudioFilter)
505+
.tee();
490506

491507
if (this.realtimeSession) {
492508
this.realtimeSession.setInputAudioStream(realtimeAudioStream);
@@ -755,6 +771,11 @@ export class AgentActivity implements RecognitionHooks {
755771
}
756772

757773
private interruptByAudioActivity(): void {
774+
if (this.agentSession._aecWarmupRemaining > 0) {
775+
// Disable interruption from audio activity while AEC warmup is active.
776+
return;
777+
}
778+
758779
if (this.llm instanceof RealtimeModel && this.llm.capabilities.turnDetection) {
759780
// skip speech handle interruption if server side turn detection is enabled
760781
return;

agents/src/voice/agent_session.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export interface VoiceOptions {
7777
maxToolSteps: number;
7878
preemptiveGeneration: boolean;
7979
userAwayTimeout?: number | null;
80+
aecWarmupDuration: number | null;
8081
useTtsAlignedTranscript: boolean;
8182
}
8283

@@ -90,6 +91,7 @@ const defaultVoiceOptions: VoiceOptions = {
9091
maxToolSteps: 3,
9192
preemptiveGeneration: false,
9293
userAwayTimeout: 15.0,
94+
aecWarmupDuration: 3000,
9395
useTtsAlignedTranscript: true,
9496
} as const;
9597

@@ -158,6 +160,8 @@ export class AgentSession<
158160
private closingTask: Promise<void> | null = null;
159161
private userAwayTimer: NodeJS.Timeout | null = null;
160162

163+
private _aecWarmupTimer: NodeJS.Timeout | null = null;
164+
161165
// Connection options for STT, LLM, and TTS
162166
private _connOptions: ResolvedSessionConnectOptions;
163167

@@ -169,6 +173,9 @@ export class AgentSession<
169173
private userSpeakingSpan?: Span;
170174
private agentSpeakingSpan?: Span;
171175

176+
/** @internal */
177+
_aecWarmupRemaining = 0;
178+
172179
/** @internal */
173180
_recorderIO?: RecorderIO;
174181

@@ -241,6 +248,7 @@ export class AgentSession<
241248
// This is the "global" chat context, it holds the entire conversation history
242249
this._chatCtx = ChatContext.empty();
243250
this.options = { ...defaultVoiceOptions, ...voiceOptions };
251+
this._aecWarmupRemaining = this.options.aecWarmupDuration ?? 0;
244252

245253
this._onUserInputTranscribed = this._onUserInputTranscribed.bind(this);
246254
this.on(AgentSessionEventTypes.UserInputTranscribed, this._onUserInputTranscribed);
@@ -845,6 +853,14 @@ export class AgentSession<
845853
this.agentSpeakingSpan = undefined;
846854
}
847855

856+
if (state === 'speaking' && this._aecWarmupRemaining > 0 && this._aecWarmupTimer === null) {
857+
this._aecWarmupTimer = setTimeout(() => this._onAecWarmupExpired(), this._aecWarmupRemaining);
858+
this.logger.debug(
859+
{ warmupDurationMs: this._aecWarmupRemaining },
860+
'aec warmup active, disabling interruptions',
861+
);
862+
}
863+
848864
const oldState = this._agentState;
849865
this._agentState = state;
850866

@@ -938,6 +954,19 @@ export class AgentSession<
938954
}
939955
}
940956

957+
/** @internal */
958+
_onAecWarmupExpired(): void {
959+
if (this._aecWarmupRemaining > 0) {
960+
this.logger.debug('aec warmup expired, re-enabling interruptions');
961+
}
962+
963+
this._aecWarmupRemaining = 0;
964+
if (this._aecWarmupTimer !== null) {
965+
clearTimeout(this._aecWarmupTimer);
966+
this._aecWarmupTimer = null;
967+
}
968+
}
969+
941970
private _onUserInputTranscribed(ev: UserInputTranscribedEvent): void {
942971
if (this.userState === 'away' && ev.isFinal) {
943972
this.logger.debug('User returned from away state due to speech input');
@@ -969,6 +998,7 @@ export class AgentSession<
969998
}
970999

9711000
this._cancelUserAwayTimer();
1001+
this._onAecWarmupExpired();
9721002
this.off(AgentSessionEventTypes.UserInputTranscribed, this._onUserInputTranscribed);
9731003

9741004
if (this.activity) {

examples/src/basic_agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default defineAgent({
7171
// allow the LLM to generate a response while waiting for the end of turn
7272
preemptiveGeneration: true,
7373
useTtsAlignedTranscript: true,
74+
aecWarmupDuration: 3000,
7475
},
7576
connOptions: {
7677
// Example of overriding the default connection options for the LLM/TTS/STT

0 commit comments

Comments
 (0)