{"version":3,"sources":["../../src/voice/audio_recognition_eou.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { ParticipantKind } from '@livekit/rtc-node';\nimport { ReadableStream, type ReadableStreamDefaultController } from 'node:stream/web';\nimport { afterEach, describe, expect, it, vi } from 'vitest';\nimport { ChatContext } from '../llm/chat_context.js';\nimport { initializeLogger } from '../log.js';\nimport { type SpeechEvent, SpeechEventType } from '../stt/stt.js';\nimport { VAD, type VADEvent, type VADStream } from '../vad.js';\nimport {\n  AudioRecognition,\n  type RecognitionHooks,\n  STTPipeline,\n  type _TurnDetector,\n} from './audio_recognition.js';\nimport type { STTNode } from './io.js';\n\nconst fastTurnDetector: _TurnDetector = {\n  model: 'test-turn-detector',\n  provider: 'test-provider',\n  supportsLanguage: async () => true,\n  unlikelyThreshold: async () => undefined,\n  predictEndOfTurn: async () => 1.0,\n};\n\n// A VAD that never detects speech — models VAD missing a quiet utterance,\n// which forces AudioRecognition to fall back to STT-derived timestamps.\nclass SilentVADStream extends (Object as unknown as { new (): VADStream }) {\n  updateInputStream() {}\n  detachInputStream() {}\n  close() {}\n  [Symbol.asyncIterator]() {\n    return this;\n  }\n  async next(): Promise<IteratorResult<VADEvent>> {\n    return { done: true, value: undefined };\n  }\n}\n\nclass SilentVAD extends VAD {\n  label = 'silent-vad';\n  constructor() {\n    super({ updateInterval: 1 });\n  }\n  stream(): any {\n    return new SilentVADStream();\n  }\n}\n\nfunction createHooks(): RecognitionHooks {\n  return {\n    onInterruption: vi.fn(),\n    onBackchannelConfirmed: vi.fn(),\n    onStartOfSpeech: vi.fn(),\n    onVADInferenceDone: vi.fn(),\n    onEndOfSpeech: vi.fn(),\n    onInterimTranscript: vi.fn(),\n    onFinalTranscript: vi.fn(),\n    onPreemptiveGeneration: vi.fn(),\n    onAgentBackchannelOpportunity: vi.fn(),\n    onUserTurnExceeded: vi.fn(),\n    retrieveChatCtx: () => ChatContext.empty(),\n    onEndOfTurn: vi.fn(async () => true),\n  };\n}\n\ndescribe('AudioRecognition EOU scheduling with STT timestamps', () => {\n  initializeLogger({ pretty: false, level: 'silent' });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  async function runTurn(opts: { sttEndTimeInS: number }) {\n    vi.useFakeTimers();\n    const hooks = createHooks();\n\n    let sttController!: ReadableStreamDefaultController<SpeechEvent | string>;\n    const sttNode: STTNode = async () =>\n      new ReadableStream<SpeechEvent | string>({\n        start(controller) {\n          sttController = controller;\n        },\n      });\n\n    const ar = new AudioRecognition({\n      recognitionHooks: hooks,\n      stt: sttNode,\n      vad: new SilentVAD(),\n      turnDetector: fastTurnDetector,\n      turnDetectionMode: 'vad',\n      minEndpointingDelay: 0,\n      maxEndpointingDelay: 0,\n      sttModel: 'stt-model',\n      sttProvider: 'stt-provider',\n      getLinkedParticipant: () => ({ sid: 'p1', identity: 'bob', kind: ParticipantKind.AGENT }),\n    });\n\n    // a freshly (re)started activity: input epoch base is \"now\", while the\n    // (potentially reused) STT stream reports endTime relative to its own,\n    // possibly much older, stream clock\n    const pipeline = new STTPipeline(sttNode);\n    pipeline.inputStartedAt = Date.now();\n    await ar.start({ sttPipeline: pipeline });\n    await vi.advanceTimersByTimeAsync(0);\n\n    sttController.enqueue({\n      type: SpeechEventType.FINAL_TRANSCRIPT,\n      alternatives: [\n        {\n          language: 'en',\n          text: 'okay',\n          startTime: 0,\n          endTime: opts.sttEndTimeInS,\n          confidence: 0.9,\n        },\n      ],\n    });\n\n    return { ar, hooks };\n  }\n\n  async function closeRecognition(ar: AudioRecognition) {\n    vi.useRealTimers();\n    await ar.close();\n  }\n\n  it('commits the user turn promptly when STT endTime maps to the past', async () => {\n    const { ar, hooks } = await runTurn({ sttEndTimeInS: 0.5 });\n    try {\n      // endpointing delays are 0; the 0.5s stt offset is already in the past\n      // relative to fake-now after 2s, so the turn must have committed\n      await vi.advanceTimersByTimeAsync(2_000);\n      expect(hooks.onEndOfTurn).toHaveBeenCalledTimes(1);\n    } finally {\n      await closeRecognition(ar);\n    }\n  });\n\n  it('does not stall the user turn commit when STT endTime maps to the future', async () => {\n    // endTime of 120s on a stream whose epoch base is \"now\" puts\n    // lastSpeakingTime ~2 minutes in the future (reused STT pipeline after a\n    // handoff, or any stream-clock/input-epoch divergence). VAD missed the\n    // utterance, so the STT timestamp is authoritative.\n    const { ar, hooks } = await runTurn({ sttEndTimeInS: 120 });\n    try {\n      // the user turn must still commit within the endpointing window (0ms)\n      // plus generous scheduling slack — NOT minutes later\n      await vi.advanceTimersByTimeAsync(2_000);\n      expect(hooks.onEndOfTurn).toHaveBeenCalledTimes(1);\n    } finally {\n      await closeRecognition(ar);\n    }\n  });\n});\n"],"mappings":"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","names":[]}