diff --git a/.changeset/quiet-clouds-warm.md b/.changeset/quiet-clouds-warm.md new file mode 100644 index 000000000..72ef58bcc --- /dev/null +++ b/.changeset/quiet-clouds-warm.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Disable the default AEC warmup for outbound SIP calls while preserving explicit settings. diff --git a/agents/src/voice/agent_session.test.ts b/agents/src/voice/agent_session.test.ts index ed7fb8452..3dfdf4f82 100644 --- a/agents/src/voice/agent_session.test.ts +++ b/agents/src/voice/agent_session.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 +import { ParticipantKind, type RemoteParticipant } from '@livekit/rtc-node'; import { describe, expect, it, vi } from 'vitest'; import { AgentSession, resolveRecordingOptions } from './agent_session.js'; import { AgentSessionEventTypes, createUserInputTranscribedEvent } from './events.js'; @@ -8,10 +9,64 @@ import { SpeechHandle } from './speech_handle.js'; type AgentSessionInternals = AgentSession & { _agentState: string; + _aecWarmupTimer: NodeJS.Timeout | null; _userState: string; _setUserAwayTimer: () => void; }; +describe('AgentSession AEC warmup', () => { + it.each([ + [ParticipantKind.SIP, {}, null], + [ParticipantKind.SIP, { 'sip.ruleID': 'SDR_inbound' }, 3000], + [ParticipantKind.STANDARD, {}, 3000], + ] as const)( + 'uses the call type default for participant kind %s with attributes %o', + (kind, attributes, expectedDuration) => { + const session = new AgentSession({ vad: null }); + const participant = { info: { kind }, attributes } as RemoteParticipant; + + session._onRoomIOParticipantLinked(participant); + + expect(session.sessionOptions.aecWarmupDuration).toBe(expectedDuration); + expect(session._aecWarmupRemaining).toBe(expectedDuration ?? 0); + }, + ); + + it.each([null, 0, 1500] as const)( + 'preserves an explicit AEC warmup duration of %s for outbound SIP', + (duration) => { + const session = new AgentSession({ vad: null, aecWarmupDuration: duration }); + const participant = { + info: { kind: ParticipantKind.SIP }, + attributes: {}, + } as RemoteParticipant; + + session._onRoomIOParticipantLinked(participant); + + expect(session.sessionOptions.aecWarmupDuration).toBe(duration); + expect(session._aecWarmupRemaining).toBe(duration ?? 0); + }, + ); + + it('cancels AEC warmup that already started for outbound SIP', () => { + const session = new AgentSession({ vad: null }); + const internals = session as AgentSessionInternals; + const timer = setTimeout(() => {}, 10_000); + internals._aecWarmupTimer = timer; + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const participant = { + info: { kind: ParticipantKind.SIP }, + attributes: {}, + } as RemoteParticipant; + + session._onRoomIOParticipantLinked(participant); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timer); + expect(internals._aecWarmupTimer).toBeNull(); + clearTimeoutSpy.mockRestore(); + }); +}); + describe('AgentSession.run', () => { it('forwards inputModality to generateReply', async () => { const session = new AgentSession(); diff --git a/agents/src/voice/agent_session.ts b/agents/src/voice/agent_session.ts index bd0b5c47c..cb16f44c1 100644 --- a/agents/src/voice/agent_session.ts +++ b/agents/src/voice/agent_session.ts @@ -4,7 +4,12 @@ import { type JsonObject, Struct } from '@bufbuild/protobuf'; import { Mutex } from '@livekit/mutex'; import { AgentSession as pb } from '@livekit/protocol'; -import type { AudioFrame, Room } from '@livekit/rtc-node'; +import { + type AudioFrame, + ParticipantKind, + type RemoteParticipant, + type Room, +} from '@livekit/rtc-node'; import { ThrowsPromise } from '@livekit/throws-transformer/throws'; import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter'; import type { Context, Span } from '@opentelemetry/api'; @@ -120,6 +125,9 @@ import type { import { migrateLegacyOptions, stripUndefined } from './turn_config/utils.js'; import { setParticipantSpanAttributes } from './utils.js'; +const SIP_RULE_ID_ATTR = 'sip.ruleID'; +const DEFAULT_AEC_WARMUP_DURATION = 3000; + export interface AgentSessionUsage { /** List of usage summaries, one per model/provider combination. */ modelUsage: Array>; @@ -291,7 +299,7 @@ export type AgentSessionOptions = { /** * Duration in milliseconds for AEC (Acoustic Echo Cancellation) warmup, during which * interruptions from audio activity are suppressed. Set to `null` to disable. - * @defaultValue 3000 + * Defaults to 3000, or `null` for outbound SIP calls. */ aecWarmupDuration?: number | null; @@ -417,6 +425,7 @@ export class AgentSession< private idleReleased = new Event(); private _aecWarmupTimer: NodeJS.Timeout | null = null; + private readonly _aecWarmupDurationExplicit: boolean; // Connection options for STT, LLM, and TTS private _connOptions: ResolvedSessionConnectOptions; @@ -510,6 +519,7 @@ export class AgentSession< constructor(options: AgentSessionOptions = {}) { super(); + this._aecWarmupDurationExplicit = options.aecWarmupDuration !== undefined; const { agentSessionOptions: opts, legacyVoiceOptions } = migrateLegacyOptions(options); @@ -1665,6 +1675,23 @@ export class AgentSession< } } + /** @internal */ + _onRoomIOParticipantLinked(participant: RemoteParticipant): void { + if (this._aecWarmupDurationExplicit) { + return; + } + + const isOutboundSip = + participant.info.kind === ParticipantKind.SIP && !participant.attributes[SIP_RULE_ID_ATTR]; + this.sessionOptions.aecWarmupDuration = isOutboundSip ? null : DEFAULT_AEC_WARMUP_DURATION; + this._aecWarmupRemaining = this.sessionOptions.aecWarmupDuration ?? 0; + + if (isOutboundSip && this._aecWarmupTimer !== null) { + clearTimeout(this._aecWarmupTimer); + this._aecWarmupTimer = null; + } + } + private _onUserInputTranscribed(ev: UserInputTranscribedEvent): void { if (ev.isFinal && this._userState !== 'speaking') { if (this._userState === 'away') { diff --git a/agents/src/voice/room_io/room_io.ts b/agents/src/voice/room_io/room_io.ts index b83471ade..4a955c5ce 100644 --- a/agents/src/voice/room_io/room_io.ts +++ b/agents/src/voice/room_io/room_io.ts @@ -259,6 +259,7 @@ export class RoomIO { } this.participantAvailableFuture.resolve(participant); + this.agentSession._onRoomIOParticipantLinked(participant); }; private onParticipantDisconnected = (participant: RemoteParticipant) => {