{"version":3,"sources":["../../src/stt/stt.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { beforeAll, describe, expect, it } from 'vitest';\nimport { APIConnectionError, APIStatusError } from '../_exceptions.js';\nimport { initializeLogger } from '../log.js';\nimport type { APIConnectOptions } from '../types.js';\nimport { type AudioBuffer, delay } from '../utils.js';\nimport { STT, type SpeechEvent, SpeechEventType, SpeechStream } from './stt.js';\n\n/**\n * Emits one final transcript per attempt, then closes the socket the way a provider with a\n * fixed session cap does (Gemini Live sends a GoAway roughly every 10 minutes).\n */\nclass DurationCapSTT extends STT {\n  label = 'duration-cap-stt';\n\n  constructor(private readonly failuresBeforeIdle: number) {\n    super({ streaming: true, interimResults: false });\n  }\n\n  protected async _recognize(_buffer: AudioBuffer): Promise<SpeechEvent> {\n    throw new APIConnectionError({ message: 'not used' });\n  }\n\n  override stream(options?: { connOptions?: APIConnectOptions }): DurationCapStream {\n    return new DurationCapStream(this, this.failuresBeforeIdle, options?.connOptions);\n  }\n}\n\nclass DurationCapStream extends SpeechStream {\n  label = 'duration-cap-stream';\n  runCount = 0;\n\n  constructor(\n    stt: STT,\n    private readonly failuresBeforeIdle: number,\n    connOptions?: APIConnectOptions,\n  ) {\n    super(stt, undefined, connOptions);\n  }\n\n  protected async run(): Promise<void> {\n    this.runCount += 1;\n\n    if (this.runCount > this.failuresBeforeIdle) {\n      for await (const _ of this.input) {\n        /* drain */\n      }\n      return;\n    }\n\n    this.queue.put({\n      type: SpeechEventType.FINAL_TRANSCRIPT,\n      alternatives: [{ text: `turn ${this.runCount}`, startTime: 0, endTime: 0, confidence: 1 }],\n    });\n\n    // Let monitorMetrics() drain the queue so the successful turn is observed before the\n    // socket drops, matching the ordering a real provider produces.\n    await delay(5);\n\n    throw new APIStatusError({\n      message: 'GoAway: session duration limit reached',\n      options: { statusCode: 1008, body: null, retryable: true },\n    });\n  }\n}\n\nclass AlwaysFailingSTT extends STT {\n  label = 'always-failing-stt';\n\n  constructor() {\n    super({ streaming: true, interimResults: false });\n  }\n\n  protected async _recognize(_buffer: AudioBuffer): Promise<SpeechEvent> {\n    throw new APIConnectionError({ message: 'not used' });\n  }\n\n  override stream(options?: { connOptions?: APIConnectOptions }): AlwaysFailingStream {\n    return new AlwaysFailingStream(this, options?.connOptions);\n  }\n}\n\nclass AlwaysFailingStream extends SpeechStream {\n  label = 'always-failing-stream';\n  runCount = 0;\n\n  constructor(stt: STT, connOptions?: APIConnectOptions) {\n    super(stt, undefined, connOptions);\n  }\n\n  protected async run(): Promise<void> {\n    this.runCount += 1;\n    await delay(1);\n    throw new APIStatusError({\n      message: 'still down',\n      options: { statusCode: 503, body: null, retryable: true },\n    });\n  }\n}\n\nconst connOptions: APIConnectOptions = { maxRetry: 3, retryIntervalMs: 1, timeoutMs: 10_000 };\n\ndescribe('SpeechStream retry budget', () => {\n  beforeAll(() => {\n    initializeLogger({ pretty: false });\n    process.on('unhandledRejection', () => {});\n  });\n\n  it('reconnects past maxRetry when each attempt produced a final transcript', async () => {\n    // 8 session drops with maxRetry: 3 — a lifetime counter would give up after 4 attempts.\n    const stream = new DurationCapSTT(8).stream({ connOptions });\n\n    const texts: string[] = [];\n    for await (const event of stream) {\n      if (event.type === SpeechEventType.FINAL_TRANSCRIPT) {\n        texts.push(event.alternatives![0].text);\n        if (texts.length === 8) break;\n      }\n    }\n\n    expect(texts).toEqual([\n      'turn 1',\n      'turn 2',\n      'turn 3',\n      'turn 4',\n      'turn 5',\n      'turn 6',\n      'turn 7',\n      'turn 8',\n    ]);\n    expect(stream.runCount).toBe(8);\n\n    await stream.close();\n  });\n\n  it('still gives up after maxRetry consecutive failures with no transcript', async () => {\n    const stt = new AlwaysFailingSTT();\n    const stream = stt.stream({ connOptions });\n\n    const errors: { error: Error; recoverable: boolean }[] = [];\n    stt.on('error', (ev) => errors.push({ error: ev.error, recoverable: ev.recoverable }));\n\n    for await (const _ of stream) {\n      /* the stream ends once the retry budget is exhausted */\n    }\n\n    expect(stream.runCount).toBe(connOptions.maxRetry + 1);\n    expect(errors.at(-1)!.error).toBeInstanceOf(APIStatusError);\n    expect(errors.at(-1)!.recoverable).toBe(false);\n\n    await stream.close();\n  });\n});\n"],"mappings":";AAGA,oBAAgD;AAChD,wBAAmD;AACnD,iBAAiC;AAEjC,mBAAwC;AACxC,iBAAqE;AAMrE,MAAM,uBAAuB,eAAI;AAAA,EAG/B,YAA6B,oBAA4B;AACvD,UAAM,EAAE,WAAW,MAAM,gBAAgB,MAAM,CAAC;AADrB;AAAA,EAE7B;AAAA,EAJA,QAAQ;AAAA,EAMR,MAAgB,WAAW,SAA4C;AACrE,UAAM,IAAI,qCAAmB,EAAE,SAAS,WAAW,CAAC;AAAA,EACtD;AAAA,EAES,OAAO,SAAkE;AAChF,WAAO,IAAI,kBAAkB,MAAM,KAAK,oBAAoB,mCAAS,WAAW;AAAA,EAClF;AACF;AAEA,MAAM,0BAA0B,wBAAa;AAAA,EAI3C,YACE,KACiB,oBACjBA,cACA;AACA,UAAM,KAAK,QAAWA,YAAW;AAHhB;AAAA,EAInB;AAAA,EATA,QAAQ;AAAA,EACR,WAAW;AAAA,EAUX,MAAgB,MAAqB;AACnC,SAAK,YAAY;AAEjB,QAAI,KAAK,WAAW,KAAK,oBAAoB;AAC3C,uBAAiB,KAAK,KAAK,OAAO;AAAA,MAElC;AACA;AAAA,IACF;AAEA,SAAK,MAAM,IAAI;AAAA,MACb,MAAM,2BAAgB;AAAA,MACtB,cAAc,CAAC,EAAE,MAAM,QAAQ,KAAK,QAAQ,IAAI,WAAW,GAAG,SAAS,GAAG,YAAY,EAAE,CAAC;AAAA,IAC3F,CAAC;AAID,cAAM,oBAAM,CAAC;AAEb,UAAM,IAAI,iCAAe;AAAA,MACvB,SAAS;AAAA,MACT,SAAS,EAAE,YAAY,MAAM,MAAM,MAAM,WAAW,KAAK;AAAA,IAC3D,CAAC;AAAA,EACH;AACF;AAEA,MAAM,yBAAyB,eAAI;AAAA,EACjC,QAAQ;AAAA,EAER,cAAc;AACZ,UAAM,EAAE,WAAW,MAAM,gBAAgB,MAAM,CAAC;AAAA,EAClD;AAAA,EAEA,MAAgB,WAAW,SAA4C;AACrE,UAAM,IAAI,qCAAmB,EAAE,SAAS,WAAW,CAAC;AAAA,EACtD;AAAA,EAES,OAAO,SAAoE;AAClF,WAAO,IAAI,oBAAoB,MAAM,mCAAS,WAAW;AAAA,EAC3D;AACF;AAEA,MAAM,4BAA4B,wBAAa;AAAA,EAC7C,QAAQ;AAAA,EACR,WAAW;AAAA,EAEX,YAAY,KAAUA,cAAiC;AACrD,UAAM,KAAK,QAAWA,YAAW;AAAA,EACnC;AAAA,EAEA,MAAgB,MAAqB;AACnC,SAAK,YAAY;AACjB,cAAM,oBAAM,CAAC;AACb,UAAM,IAAI,iCAAe;AAAA,MACvB,SAAS;AAAA,MACT,SAAS,EAAE,YAAY,KAAK,MAAM,MAAM,WAAW,KAAK;AAAA,IAC1D,CAAC;AAAA,EACH;AACF;AAEA,MAAM,cAAiC,EAAE,UAAU,GAAG,iBAAiB,GAAG,WAAW,IAAO;AAAA,IAE5F,wBAAS,6BAA6B,MAAM;AAC1C,+BAAU,MAAM;AACd,qCAAiB,EAAE,QAAQ,MAAM,CAAC;AAClC,YAAQ,GAAG,sBAAsB,MAAM;AAAA,IAAC,CAAC;AAAA,EAC3C,CAAC;AAED,wBAAG,0EAA0E,YAAY;AAEvF,UAAM,SAAS,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,YAAY,CAAC;AAE3D,UAAM,QAAkB,CAAC;AACzB,qBAAiB,SAAS,QAAQ;AAChC,UAAI,MAAM,SAAS,2BAAgB,kBAAkB;AACnD,cAAM,KAAK,MAAM,aAAc,CAAC,EAAE,IAAI;AACtC,YAAI,MAAM,WAAW,EAAG;AAAA,MAC1B;AAAA,IACF;AAEA,8BAAO,KAAK,EAAE,QAAQ;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AACD,8BAAO,OAAO,QAAQ,EAAE,KAAK,CAAC;AAE9B,UAAM,OAAO,MAAM;AAAA,EACrB,CAAC;AAED,wBAAG,yEAAyE,YAAY;AACtF,UAAM,MAAM,IAAI,iBAAiB;AACjC,UAAM,SAAS,IAAI,OAAO,EAAE,YAAY,CAAC;AAEzC,UAAM,SAAmD,CAAC;AAC1D,QAAI,GAAG,SAAS,CAAC,OAAO,OAAO,KAAK,EAAE,OAAO,GAAG,OAAO,aAAa,GAAG,YAAY,CAAC,CAAC;AAErF,qBAAiB,KAAK,QAAQ;AAAA,IAE9B;AAEA,8BAAO,OAAO,QAAQ,EAAE,KAAK,YAAY,WAAW,CAAC;AACrD,8BAAO,OAAO,GAAG,EAAE,EAAG,KAAK,EAAE,eAAe,gCAAc;AAC1D,8BAAO,OAAO,GAAG,EAAE,EAAG,WAAW,EAAE,KAAK,KAAK;AAE7C,UAAM,OAAO,MAAM;AAAA,EACrB,CAAC;AACH,CAAC;","names":["connOptions"]}