{"version":3,"sources":["../../src/voice/generation_tts_timeout.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { AudioFrame } from '@livekit/rtc-node';\nimport { ReadableStream } from 'stream/web';\nimport { describe, expect, it, vi } from 'vitest';\nimport { initializeLogger } from '../log.js';\nimport { performAudioForwarding, performTTSInference } from './generation.js';\nimport { AudioOutput } from './io.js';\n\nfunction createSilentFrame(sampleRate = 24000, channels = 1, durationMs = 20): AudioFrame {\n  const samplesPerChannel = Math.floor((sampleRate * durationMs) / 1000);\n  const data = new Int16Array(samplesPerChannel * channels);\n  return new AudioFrame(data, sampleRate, channels, samplesPerChannel);\n}\n\nclass MockAudioOutput extends AudioOutput {\n  capturedFrames: AudioFrame[] = [];\n\n  constructor() {\n    super(24000);\n  }\n\n  async captureFrame(frame: AudioFrame): Promise<void> {\n    await super.captureFrame(frame);\n    this.capturedFrames.push(frame);\n    this.onPlaybackStarted(Date.now());\n  }\n\n  clearBuffer(): void {\n    // no-op for mock\n  }\n}\n\nclass DiscardingAudioOutput extends AudioOutput {\n  pushedDuration = 0;\n\n  override async captureFrame(frame: AudioFrame): Promise<void> {\n    await super.captureFrame(frame);\n    this.pushedDuration += frame.samplesPerChannel / frame.sampleRate;\n    this.onPlaybackStarted(Date.now());\n  }\n\n  clearBuffer(): void {\n    // no-op for mock\n  }\n}\n\ndescribe('TTS stream idle timeout', () => {\n  initializeLogger({ pretty: false, level: 'silent' });\n\n  it.each([undefined, 16000])(\n    'does not retain consumed audio with output sample rate %s',\n    async (outputSampleRate) => {\n      const stream = new ReadableStream<AudioFrame>({\n        start(controller) {\n          for (let i = 0; i < 3000; i++) {\n            controller.enqueue(new AudioFrame(new Int16Array(960), 48000, 1, 960));\n          }\n          controller.close();\n        },\n      });\n      const audioOutput = new DiscardingAudioOutput(outputSampleRate);\n\n      const [task, audioOut] = performAudioForwarding(\n        stream,\n        audioOutput,\n        new AbortController(),\n        () => {},\n      );\n      await task.result;\n\n      expect(audioOutput.pushedDuration).toBeCloseTo(60);\n      expect(audioOut).not.toHaveProperty('audio');\n      expect(audioOut.firstFrameFut.done).toBe(true);\n    },\n  );\n\n  it('forwardAudio completes when TTS stream stalls after producing frames', async () => {\n    const stalledStream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame());\n        controller.enqueue(createSilentFrame());\n      },\n    });\n\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n\n    const [task, audioOut] = performAudioForwarding(stalledStream, audioOutput, controller, () =>\n      audioOutput.resume(),\n    );\n\n    vi.useFakeTimers();\n\n    const taskPromise = task.result;\n    await vi.advanceTimersByTimeAsync(11_000);\n    await taskPromise;\n\n    vi.useRealTimers();\n\n    expect(audioOutput.capturedFrames.length).toBe(2);\n    expect(audioOut.firstFrameFut.done).toBe(true);\n  }, 10_000);\n\n  it('forwardAudio honours a custom idle timeout', async () => {\n    const stalledStream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame());\n      },\n    });\n\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n\n    const [task, audioOut] = performAudioForwarding(\n      stalledStream,\n      audioOutput,\n      controller,\n      () => audioOutput.resume(),\n      500,\n    );\n\n    vi.useFakeTimers();\n\n    const taskPromise = task.result;\n    await vi.advanceTimersByTimeAsync(600);\n    await taskPromise;\n\n    vi.useRealTimers();\n\n    expect(audioOutput.capturedFrames.length).toBe(1);\n    expect(audioOut.firstFrameFut.done).toBe(true);\n  });\n\n  it('forwardAudio completes normally when TTS stream closes properly', async () => {\n    const normalStream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame());\n        controller.enqueue(createSilentFrame());\n        controller.enqueue(createSilentFrame());\n        controller.close();\n      },\n    });\n\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n\n    const [task, audioOut] = performAudioForwarding(normalStream, audioOutput, controller, () =>\n      audioOutput.resume(),\n    );\n\n    await task.result;\n\n    expect(audioOutput.capturedFrames.length).toBe(3);\n    expect(audioOut.firstFrameFut.done).toBe(true);\n  });\n\n  it('ignores PLAYBACK_STARTED from another segment before its own first frame', async () => {\n    // Stalled stream so the forwarder is still waiting on its first read when a\n    // stray event (from an interrupted overlapping segment) arrives; the idle\n    // timeout then ends the loop without this segment ever capturing a frame.\n    const stalledStream = new ReadableStream<AudioFrame>({ start() {} });\n\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n    const [task, audioOut] = performAudioForwarding(\n      stalledStream,\n      audioOutput,\n      controller,\n      () => audioOutput.resume(),\n      500,\n    );\n\n    vi.useFakeTimers();\n\n    // Stray PLAYBACK_STARTED before this segment captures anything must be ignored.\n    audioOutput.onPlaybackStarted(Date.now());\n    expect(audioOut.firstFrameFut.done).toBe(false);\n\n    const taskPromise = task.result;\n    await vi.advanceTimersByTimeAsync(600);\n    await taskPromise;\n\n    vi.useRealTimers();\n\n    expect(audioOutput.capturedFrames.length).toBe(0);\n    // Forwarding ended without capturing a frame. The future stays pending —\n    // playback-started may legitimately arrive after forwarding completes\n    // (deferred avatar notification), so the caller settles it when the\n    // segment's playout window ends. Stray events must still be ignored.\n    audioOutput.onPlaybackStarted(Date.now());\n    expect(audioOut.firstFrameFut.done).toBe(false);\n  });\n\n  it('resamples a rate-mismatched frame even after a stray PLAYBACK_STARTED', async () => {\n    // Output is 24kHz; frames are 16kHz and must be resampled regardless of any\n    // stray PLAYBACK_STARTED resolving firstFrameFut early.\n    const stream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame(16000));\n        controller.enqueue(createSilentFrame(16000));\n        controller.close();\n      },\n    });\n\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n    const [task, audioOut] = performAudioForwarding(stream, audioOutput, controller, () =>\n      audioOutput.resume(),\n    );\n\n    // Stray event before the loop captures anything must not skip resampling.\n    audioOutput.onPlaybackStarted(Date.now());\n\n    await task.result;\n\n    expect(audioOut.firstFrameFut.done).toBe(true);\n    // Every captured frame must match the output sample rate (i.e. was resampled).\n    expect(audioOutput.capturedFrames.length).toBeGreaterThan(0);\n    for (const f of audioOutput.capturedFrames) {\n      expect(f.sampleRate).toBe(24000);\n    }\n  });\n\n  it('does not capture a frame returned by an in-flight read after abort', async () => {\n    let enqueue!: (frame: AudioFrame) => void;\n    let close!: () => void;\n    let streamCancelled = false;\n    const stream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        enqueue = (frame) => controller.enqueue(frame);\n        close = () => controller.close();\n      },\n      cancel() {\n        streamCancelled = true;\n      },\n    });\n    const audioOutput = new MockAudioOutput();\n    const controller = new AbortController();\n    const [task] = performAudioForwarding(stream, audioOutput, controller, () =>\n      audioOutput.resume(),\n    );\n\n    // Let forwarding enter reader.read(), which cannot be cancelled by the task's\n    // AbortSignal. The read resolves only after the task has already been aborted.\n    await new Promise<void>((resolve) => setImmediate(resolve));\n    controller.abort();\n    await new Promise<void>((resolve) => setImmediate(resolve));\n    if (!streamCancelled) {\n      enqueue(createSilentFrame());\n      close();\n    }\n    await task.result;\n\n    expect(streamCancelled).toBe(true);\n    expect(audioOutput.capturedFrames).toHaveLength(0);\n  });\n\n  it('performTTSInference completes when TTS node returns stalled stream', async () => {\n    const stalledTtsStream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame());\n      },\n    });\n\n    const ttsNode = async () => stalledTtsStream;\n    const textInput = new ReadableStream<string>({\n      start(controller) {\n        controller.enqueue('Hello world');\n        controller.close();\n      },\n    });\n\n    const controller = new AbortController();\n    const [task, genData] = performTTSInference(ttsNode, textInput, {}, controller);\n\n    vi.useFakeTimers();\n\n    const taskPromise = task.result;\n    await vi.advanceTimersByTimeAsync(11_000);\n    await taskPromise;\n\n    vi.useRealTimers();\n\n    expect(genData.ttfb).toBeDefined();\n  }, 10_000);\n\n  it('performTTSInference honours a custom read idle timeout', async () => {\n    const stalledTtsStream = new ReadableStream<AudioFrame>({\n      start(controller) {\n        controller.enqueue(createSilentFrame());\n      },\n    });\n\n    const ttsNode = async () => stalledTtsStream;\n    const textInput = new ReadableStream<string>({\n      start(controller) {\n        controller.enqueue('Hello world');\n        controller.close();\n      },\n    });\n\n    const controller = new AbortController();\n    const [task, genData] = performTTSInference(\n      ttsNode,\n      textInput,\n      {},\n      controller,\n      undefined,\n      undefined,\n      500,\n    );\n\n    vi.useFakeTimers();\n\n    const taskPromise = task.result;\n    await vi.advanceTimersByTimeAsync(600);\n    await taskPromise;\n\n    vi.useRealTimers();\n\n    expect(genData.ttfb).toBeDefined();\n  }, 10_000);\n});\n"],"mappings":"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","names":["controller"]}