@livekit/agents-plugin-fishaudio
Advanced tools
+1
-1
@@ -25,3 +25,3 @@ "use strict"; | ||
| title: "fishaudio", | ||
| version: "1.5.2", | ||
| version: "1.5.3", | ||
| package: "@livekit/agents-plugin-fishaudio" | ||
@@ -28,0 +28,0 @@ }); |
+1
-1
@@ -8,3 +8,3 @@ import { Plugin } from "@livekit/agents"; | ||
| title: "fishaudio", | ||
| version: "1.5.2", | ||
| version: "1.5.3", | ||
| package: "@livekit/agents-plugin-fishaudio" | ||
@@ -11,0 +11,0 @@ }); |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/models.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\n\nexport type TTSModels = 's1' | 's2-pro' | 's2.1-pro';\n\nexport type LatencyMode = 'normal' | 'balanced' | 'low';\n"],"mappings":";;;;;;;;;;;;;;AAAA;AAAA;","names":[]} | ||
| {"version":3,"sources":["../src/models.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\n\n/** @public */\nexport type TTSModels = 's1' | 's2-pro' | 's2.1-pro';\n\n/** @public */\nexport type LatencyMode = 'normal' | 'balanced' | 'low';\n\n/**\n * MP3 bitrate request value in kbps.\n *\n * The JS plugin currently requests PCM output, so this value does not change\n * the emitted audio format or PCM bandwidth.\n * @public\n */\nexport type MP3Bitrate = 64 | 128 | 192;\n\n/**\n * Opus bitrate request value in bps. `-1000` selects Fish Audio's automatic\n * bitrate. The JS plugin currently requests PCM output, so this value does not\n * change the emitted audio format or PCM bandwidth.\n * @public\n */\nexport type OpusBitrate = -1000 | 24000 | 32000 | 48000 | 64000;\n"],"mappings":";;;;;;;;;;;;;;AAAA;AAAA;","names":[]} |
+17
-0
@@ -0,3 +1,20 @@ | ||
| /** @public */ | ||
| export type TTSModels = 's1' | 's2-pro' | 's2.1-pro'; | ||
| /** @public */ | ||
| export type LatencyMode = 'normal' | 'balanced' | 'low'; | ||
| /** | ||
| * MP3 bitrate request value in kbps. | ||
| * | ||
| * The JS plugin currently requests PCM output, so this value does not change | ||
| * the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type MP3Bitrate = 64 | 128 | 192; | ||
| /** | ||
| * Opus bitrate request value in bps. `-1000` selects Fish Audio's automatic | ||
| * bitrate. The JS plugin currently requests PCM output, so this value does not | ||
| * change the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type OpusBitrate = -1000 | 24000 | 32000 | 48000 | 64000; | ||
| //# sourceMappingURL=models.d.ts.map |
+17
-0
@@ -0,3 +1,20 @@ | ||
| /** @public */ | ||
| export type TTSModels = 's1' | 's2-pro' | 's2.1-pro'; | ||
| /** @public */ | ||
| export type LatencyMode = 'normal' | 'balanced' | 'low'; | ||
| /** | ||
| * MP3 bitrate request value in kbps. | ||
| * | ||
| * The JS plugin currently requests PCM output, so this value does not change | ||
| * the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type MP3Bitrate = 64 | 128 | 192; | ||
| /** | ||
| * Opus bitrate request value in bps. `-1000` selects Fish Audio's automatic | ||
| * bitrate. The JS plugin currently requests PCM output, so this value does not | ||
| * change the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type OpusBitrate = -1000 | 24000 | 32000 | 48000 | 64000; | ||
| //# sourceMappingURL=models.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,QAAQ,GAAG,UAAU,CAAC;AAErD,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU,GAAG,KAAK,CAAC"} | ||
| {"version":3,"file":"models.d.ts","sourceRoot":"","sources":["../src/models.ts"],"names":[],"mappings":"AAIA,cAAc;AACd,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,QAAQ,GAAG,UAAU,CAAC;AAErD,cAAc;AACd,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,UAAU,GAAG,KAAK,CAAC;AAExD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,EAAE,GAAG,GAAG,GAAG,GAAG,CAAC;AAExC;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC"} |
+123
-58
@@ -35,2 +35,3 @@ "use strict"; | ||
| const DEFAULT_SAMPLE_RATE = 24e3; | ||
| const connectionPools = /* @__PURE__ */ new WeakMap(); | ||
| const DEFAULT_OPTS = { | ||
@@ -42,3 +43,8 @@ model: DEFAULT_MODEL, | ||
| latencyMode: "balanced", | ||
| chunkLength: 100 | ||
| chunkLength: 100, | ||
| temperature: 0.7, | ||
| topP: 0.7, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 64e3, | ||
| normalize: true | ||
| }; | ||
@@ -50,2 +56,7 @@ const validateChunkLength = (chunkLength) => { | ||
| }; | ||
| const validateProbability = (name, value) => { | ||
| if (!Number.isFinite(value) || value < 0 || value > 1) { | ||
| throw new Error(`${name} must be between 0 and 1`); | ||
| } | ||
| }; | ||
| const buildTtsRequest = (opts, text = "") => { | ||
@@ -61,4 +72,4 @@ const prosody = opts.speed !== void 0 || opts.volume !== void 0 ? { | ||
| sample_rate: opts.sampleRate, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 64e3, | ||
| mp3_bitrate: opts.mp3Bitrate, | ||
| opus_bitrate: opts.opusBitrate, | ||
| references: [], | ||
@@ -68,7 +79,7 @@ // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on | ||
| reference_id: opts.voiceId ?? null, | ||
| normalize: true, | ||
| normalize: opts.normalize, | ||
| latency: opts.latencyMode, | ||
| prosody, | ||
| top_p: 0.7, | ||
| temperature: 0.7 | ||
| top_p: opts.topP, | ||
| temperature: opts.temperature | ||
| }; | ||
@@ -78,2 +89,4 @@ }; | ||
| #opts; | ||
| #pool; | ||
| #closed = false; | ||
| label = "fishaudio.TTS"; | ||
@@ -89,2 +102,6 @@ constructor(opts = {}) { | ||
| validateChunkLength(chunkLength); | ||
| const temperature = opts.temperature ?? DEFAULT_OPTS.temperature; | ||
| validateProbability("temperature", temperature); | ||
| const topP = opts.topP ?? DEFAULT_OPTS.topP; | ||
| validateProbability("topP", topP); | ||
| const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate; | ||
@@ -103,4 +120,16 @@ super(sampleRate, NUM_CHANNELS, { streaming: true }); | ||
| volume: opts.volume, | ||
| temperature, | ||
| topP, | ||
| mp3Bitrate: opts.mp3Bitrate ?? DEFAULT_OPTS.mp3Bitrate, | ||
| opusBitrate: opts.opusBitrate ?? DEFAULT_OPTS.opusBitrate, | ||
| normalize: opts.normalize ?? DEFAULT_OPTS.normalize, | ||
| tokenizer | ||
| }; | ||
| this.#pool = new import_agents.ConnectionPool({ | ||
| connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs), | ||
| closeCb: async (ws) => closeWebSocket(ws), | ||
| maxSessionDuration: 3e5, | ||
| markRefreshedOnGet: true | ||
| }); | ||
| connectionPools.set(this, this.#pool); | ||
| } | ||
@@ -114,3 +143,6 @@ get model() { | ||
| updateOptions(opts) { | ||
| if (opts.model !== void 0) this.#opts.model = opts.model; | ||
| if (opts.model !== void 0 && opts.model !== this.#opts.model) { | ||
| this.#opts.model = opts.model; | ||
| this.#pool.invalidate(); | ||
| } | ||
| if (opts.voiceId !== void 0) this.#opts.voiceId = opts.voiceId; | ||
@@ -124,9 +156,49 @@ if (opts.latencyMode !== void 0) this.#opts.latencyMode = opts.latencyMode; | ||
| if (opts.volume !== void 0) this.#opts.volume = opts.volume; | ||
| if (opts.temperature !== void 0) { | ||
| validateProbability("temperature", opts.temperature); | ||
| this.#opts.temperature = opts.temperature; | ||
| } | ||
| if (opts.topP !== void 0) { | ||
| validateProbability("topP", opts.topP); | ||
| this.#opts.topP = opts.topP; | ||
| } | ||
| if (opts.mp3Bitrate !== void 0) this.#opts.mp3Bitrate = opts.mp3Bitrate; | ||
| if (opts.opusBitrate !== void 0) this.#opts.opusBitrate = opts.opusBitrate; | ||
| if (opts.normalize !== void 0) this.#opts.normalize = opts.normalize; | ||
| } | ||
| synthesize(text, connOptions, abortSignal) { | ||
| return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal); | ||
| return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal); | ||
| } | ||
| stream(options) { | ||
| return new SynthesizeStream(this, this.#opts, options == null ? void 0 : options.connOptions); | ||
| return new SynthesizeStream(this, { ...this.#opts }, options == null ? void 0 : options.connOptions); | ||
| } | ||
| prewarm() { | ||
| this.#pool.prewarm(); | ||
| } | ||
| async close() { | ||
| this.#closed = true; | ||
| await this.#pool.close(); | ||
| await super.close(); | ||
| } | ||
| async #connectWebSocket(timeoutMs) { | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, "ws")}/v1/tts/live`; | ||
| const model = this.#opts.model; | ||
| const ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model | ||
| }, | ||
| timeoutMs | ||
| }); | ||
| if (this.#closed) { | ||
| closeWebSocket(ws); | ||
| throw new import_agents.APIConnectionError({ message: "Fish Audio TTS is closed" }); | ||
| } | ||
| if (model !== this.#opts.model) { | ||
| closeWebSocket(ws); | ||
| return await this.#connectWebSocket(timeoutMs); | ||
| } | ||
| return ws; | ||
| } | ||
| } | ||
@@ -252,5 +324,9 @@ class ChunkedStream extends import_agents.tts.ChunkedStream { | ||
| #logger = (0, import_agents.log)(); | ||
| #pool; | ||
| #opts; | ||
| constructor(tts2, opts, connOptions) { | ||
| super(tts2, connOptions); | ||
| const pool = connectionPools.get(tts2); | ||
| if (!pool) throw new Error("Fish Audio connection pool is not initialized"); | ||
| this.#pool = pool; | ||
| this.#opts = opts; | ||
@@ -262,19 +338,2 @@ } | ||
| const sentStream = this.#opts.tokenizer.stream(); | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, "ws")}/v1/tts/live`; | ||
| let ws; | ||
| try { | ||
| ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model: this.#opts.model | ||
| }, | ||
| timeoutMs: this.connOptions.timeoutMs, | ||
| abortSignal: this.abortSignal | ||
| }); | ||
| } catch (e) { | ||
| throw new import_agents.APIConnectionError({ | ||
| message: `Fish Audio websocket connect failed: ${e.message ?? "unknown error"}` | ||
| }); | ||
| } | ||
| const finished = new import_agents.Future(); | ||
@@ -296,3 +355,3 @@ const inputTask = async () => { | ||
| }; | ||
| const sendTask = async () => { | ||
| const sendTask = async (ws) => { | ||
| const startMsg = { event: "start", request: buildTtsRequest(this.#opts) }; | ||
@@ -312,10 +371,3 @@ ws.send(Buffer.from((0, import_msgpack.encode)(startMsg))); | ||
| }; | ||
| let lastFrame; | ||
| const sendLastFrame = (final) => { | ||
| if (lastFrame) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame: lastFrame, final }); | ||
| lastFrame = void 0; | ||
| } | ||
| }; | ||
| const recvTask = async () => { | ||
| const recvTask = async (ws) => { | ||
| const onMessage = (raw) => { | ||
@@ -341,5 +393,4 @@ let frame; | ||
| if (audio && audio.byteLength > 0) { | ||
| for (const f of bstream.write(audio)) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| for (const frame2 of bstream.write(audio)) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame: frame2, final: false }); | ||
| } | ||
@@ -358,7 +409,11 @@ } | ||
| } | ||
| for (const f of bstream.flush()) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| const remainingFrames = [...bstream.flush()]; | ||
| for (const [idx, frame2] of remainingFrames.entries()) { | ||
| this.queue.put({ | ||
| requestId, | ||
| segmentId: requestId, | ||
| frame: frame2, | ||
| final: idx === remainingFrames.length - 1 | ||
| }); | ||
| } | ||
| sendLastFrame(true); | ||
| if (!this.queue.closed) { | ||
@@ -400,3 +455,13 @@ this.queue.put(SynthesizeStream.END_OF_STREAM); | ||
| try { | ||
| await Promise.all([inputTask(), sendTask(), recvTask()]); | ||
| await this.#pool.withConnection( | ||
| async (ws) => { | ||
| if (ws.readyState !== import_ws.WebSocket.OPEN) { | ||
| throw new import_agents.APIConnectionError({ | ||
| message: "Fish Audio pooled websocket is not open" | ||
| }); | ||
| } | ||
| await Promise.all([inputTask(), sendTask(ws), recvTask(ws)]); | ||
| }, | ||
| { timeout: this.connOptions.timeoutMs, signal: this.abortSignal } | ||
| ); | ||
| } catch (e) { | ||
@@ -412,8 +477,2 @@ if (this.abortSignal.aborted) return; | ||
| if (!sentStream.closed) sentStream.close(); | ||
| if (ws && ws.readyState !== import_ws.WebSocket.CLOSED) { | ||
| try { | ||
| ws.close(); | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
@@ -436,3 +495,3 @@ } | ||
| ws.off("close", onClose); | ||
| abortSignal.removeEventListener("abort", onAbort); | ||
| abortSignal == null ? void 0 : abortSignal.removeEventListener("abort", onAbort); | ||
| }; | ||
@@ -448,3 +507,3 @@ const onOpen = () => fut.resolve(); | ||
| ws.on("close", onClose); | ||
| abortSignal.addEventListener("abort", onAbort, { once: true }); | ||
| abortSignal == null ? void 0 : abortSignal.addEventListener("abort", onAbort, { once: true }); | ||
| if (timeoutMs > 0) { | ||
@@ -458,9 +517,3 @@ timeout = setTimeout(() => fut.reject(new Error("connect timeout")), timeoutMs); | ||
| try { | ||
| ws.on("error", () => { | ||
| }); | ||
| if (ws.readyState === import_ws.WebSocket.CONNECTING) { | ||
| ws.close(); | ||
| } else { | ||
| ws.terminate(); | ||
| } | ||
| closeWebSocket(ws); | ||
| } catch { | ||
@@ -473,2 +526,14 @@ } | ||
| }; | ||
| const closeWebSocket = (ws) => { | ||
| try { | ||
| ws.on("error", () => { | ||
| }); | ||
| if (ws.readyState === import_ws.WebSocket.CONNECTING || ws.readyState === import_ws.WebSocket.OPEN) { | ||
| ws.close(); | ||
| } else if (ws.readyState !== import_ws.WebSocket.CLOSED && ws.readyState !== import_ws.WebSocket.CLOSING) { | ||
| ws.terminate(); | ||
| } | ||
| } catch { | ||
| } | ||
| }; | ||
| // Annotate the CommonJS export names for ESM import in node: | ||
@@ -475,0 +540,0 @@ 0 && (module.exports = { |
+1
-1
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/tts.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport {\n type APIConnectOptions,\n APIConnectionError,\n APIStatusError,\n AudioByteStream,\n Future,\n log,\n shortuuid,\n tokenize,\n tts,\n} from '@livekit/agents';\nimport type { AudioFrame } from '@livekit/rtc-node';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { request } from 'node:https';\nimport { type RawData, WebSocket } from 'ws';\nimport type { LatencyMode, TTSModels } from './models.js';\n\nconst DEFAULT_MODEL: TTSModels = 's2.1-pro';\nconst DEFAULT_VOICE_ID = '933563129e564b19a115bedd57b7406a';\nconst DEFAULT_BASE_URL = 'https://api.fish.audio';\nconst NUM_CHANNELS = 1;\n// Fish Audio's default sample rate for raw PCM output.\nconst DEFAULT_SAMPLE_RATE = 24000;\n\nexport interface TTSOptions {\n apiKey?: string;\n model?: TTSModels | string;\n voiceId?: string;\n sampleRate?: number;\n baseURL?: string;\n latencyMode?: LatencyMode;\n /**\n * Upper bound on the number of characters Fish buffers before auto-synthesizing.\n * Must be between 100 and 300. With sentence-level flushing this is only hit by\n * sentences longer than `chunkLength`; otherwise audio is produced as soon as\n * each sentence is flushed. Defaults to 100.\n */\n chunkLength?: number;\n /**\n * Speaking rate multiplier for Fish `prosody.speed`. `1.0` is normal; below\n * 1.0 is slower, above is faster. Unset uses the voice's natural pace.\n */\n speed?: number;\n /**\n * Loudness adjustment in decibels for Fish `prosody.volume`. `0` is the\n * voice's natural level. Unset leaves it unchanged.\n */\n volume?: number;\n tokenizer?: tokenize.SentenceTokenizer;\n}\n\ninterface ResolvedTTSOptions {\n apiKey: string;\n model: TTSModels | string;\n voiceId?: string;\n sampleRate: number;\n baseURL: string;\n latencyMode: LatencyMode;\n chunkLength: number;\n speed?: number;\n volume?: number;\n tokenizer: tokenize.SentenceTokenizer;\n}\n\nconst DEFAULT_OPTS: Omit<ResolvedTTSOptions, 'apiKey' | 'tokenizer'> = {\n model: DEFAULT_MODEL,\n voiceId: DEFAULT_VOICE_ID,\n sampleRate: DEFAULT_SAMPLE_RATE,\n baseURL: DEFAULT_BASE_URL,\n latencyMode: 'balanced',\n chunkLength: 100,\n};\n\nconst validateChunkLength = (chunkLength: number) => {\n if (!Number.isFinite(chunkLength) || chunkLength < 100 || chunkLength > 300) {\n throw new Error('chunkLength must be between 100 and 300');\n }\n};\n\n// Fish Audio's wire format mirrors the upstream Python SDK so the server\n// doesn't fall back to its own larger defaults — in particular the docs default\n// of `chunk_length=300` produces large bursts that leave audible gaps between\n// chunk boundaries.\nconst buildTtsRequest = (opts: ResolvedTTSOptions, text: string = ''): Record<string, unknown> => {\n const prosody =\n opts.speed !== undefined || opts.volume !== undefined\n ? {\n ...(opts.speed !== undefined ? { speed: opts.speed } : {}),\n ...(opts.volume !== undefined ? { volume: opts.volume } : {}),\n }\n : null;\n\n return {\n text,\n chunk_length: opts.chunkLength,\n format: 'pcm',\n sample_rate: opts.sampleRate,\n mp3_bitrate: 64,\n opus_bitrate: 64000,\n references: [],\n // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on\n // the plugin for consistency with other TTS plugins.\n reference_id: opts.voiceId ?? null,\n normalize: true,\n latency: opts.latencyMode,\n prosody,\n top_p: 0.7,\n temperature: 0.7,\n };\n};\n\nexport class TTS extends tts.TTS {\n #opts: ResolvedTTSOptions;\n label = 'fishaudio.TTS';\n\n constructor(opts: TTSOptions = {}) {\n const apiKey = opts.apiKey ?? process.env.FISH_API_KEY;\n if (!apiKey) {\n throw new Error(\n 'Fish Audio API key is required, either as argument or set FISH_API_KEY environment variable',\n );\n }\n\n const chunkLength = opts.chunkLength ?? DEFAULT_OPTS.chunkLength;\n validateChunkLength(chunkLength);\n\n const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate;\n\n super(sampleRate, NUM_CHANNELS, { streaming: true });\n\n // min_sentence_len=1 emits each sentence as soon as the next one starts,\n // rather than batching short sentences together — minimizes TTFB on the\n // first sentence and keeps Fish synthesizing continuously.\n const tokenizer =\n opts.tokenizer ?? new tokenize.basic.SentenceTokenizer({ minSentenceLength: 1 });\n\n this.#opts = {\n apiKey,\n model: opts.model ?? DEFAULT_OPTS.model,\n voiceId: opts.voiceId ?? DEFAULT_OPTS.voiceId,\n sampleRate,\n baseURL: opts.baseURL ?? DEFAULT_OPTS.baseURL,\n latencyMode: opts.latencyMode ?? DEFAULT_OPTS.latencyMode,\n chunkLength,\n speed: opts.speed,\n volume: opts.volume,\n tokenizer,\n };\n }\n\n get model(): string {\n return this.#opts.model;\n }\n\n get provider(): string {\n return 'FishAudio';\n }\n\n updateOptions(opts: {\n model?: TTSModels | string;\n voiceId?: string;\n latencyMode?: LatencyMode;\n chunkLength?: number;\n speed?: number;\n volume?: number;\n }): void {\n if (opts.model !== undefined) this.#opts.model = opts.model;\n if (opts.voiceId !== undefined) this.#opts.voiceId = opts.voiceId;\n if (opts.latencyMode !== undefined) this.#opts.latencyMode = opts.latencyMode;\n if (opts.chunkLength !== undefined) {\n validateChunkLength(opts.chunkLength);\n this.#opts.chunkLength = opts.chunkLength;\n }\n if (opts.speed !== undefined) this.#opts.speed = opts.speed;\n if (opts.volume !== undefined) this.#opts.volume = opts.volume;\n }\n\n synthesize(\n text: string,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ): tts.ChunkedStream {\n return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal);\n }\n\n stream(options?: { connOptions?: APIConnectOptions }): tts.SynthesizeStream {\n return new SynthesizeStream(this, this.#opts, options?.connOptions);\n }\n}\n\nexport class ChunkedStream extends tts.ChunkedStream {\n label = 'fishaudio.ChunkedStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n #text: string;\n\n constructor(\n tts: TTS,\n text: string,\n opts: ResolvedTTSOptions,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ) {\n super(text, tts, connOptions, abortSignal);\n this.#text = text;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n const payload = encode(buildTtsRequest(this.#opts, this.#text));\n\n const baseUrl = new URL(this.#opts.baseURL);\n const isHttps = baseUrl.protocol === 'https:';\n if (!isHttps) {\n // The plugin only supports https; fall back via Node's http module is\n // intentionally not implemented to keep the code path simple.\n throw new APIConnectionError({\n message: `Fish Audio base URL must use https (got ${this.#opts.baseURL})`,\n });\n }\n\n const doneFut = new Future<void>();\n\n const req = request(\n {\n hostname: baseUrl.hostname,\n port: parseInt(baseUrl.port) || 443,\n path: '/v1/tts',\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n 'Content-Type': 'application/msgpack',\n model: this.#opts.model,\n 'Content-Length': payload.byteLength,\n },\n signal: this.abortSignal,\n },\n (res) => {\n const status = res.statusCode ?? -1;\n if (status < 200 || status >= 300) {\n const chunks: Buffer[] = [];\n res.on('data', (c: Buffer) => chunks.push(c));\n res.on('end', () => {\n const body = Buffer.concat(chunks).toString();\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed: ${body}`,\n options: { statusCode: status, body: { raw: body } },\n }),\n );\n }\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS error response stream error');\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed (status ${status})`,\n options: { statusCode: status },\n }),\n );\n }\n });\n return;\n }\n\n res.on('data', (chunk: Buffer) => {\n for (const frame of bstream.write(chunk)) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n });\n res.on('close', () => {\n for (const frame of bstream.flush()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n if (!this.queue.closed) this.queue.close();\n if (!doneFut.done) doneFut.resolve();\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS response error');\n if (!doneFut.done) doneFut.reject(err);\n });\n },\n );\n\n req.on('error', (err) => {\n if (err.name === 'AbortError') return;\n this.#logger.error({ err }, 'Fish Audio TTS request error');\n if (!doneFut.done) doneFut.reject(err);\n });\n req.write(payload);\n req.end();\n\n try {\n await doneFut.await;\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (!this.queue.closed) this.queue.close();\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio connection failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n }\n}\n\nexport class SynthesizeStream extends tts.SynthesizeStream {\n label = 'fishaudio.SynthesizeStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n\n constructor(tts: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {\n super(tts, connOptions);\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n\n // Tokenize incoming text by sentence and flush after each sentence so Fish\n // synthesizes immediately at sentence boundaries instead of waiting for\n // `chunkLength` characters to accumulate. The result is much smoother\n // audio: gaps line up with sentence breaks (where pauses are natural)\n // rather than mid-clause.\n const sentStream = this.#opts.tokenizer.stream();\n\n const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`;\n let ws: WebSocket | undefined;\n try {\n ws = await connectWebSocket({\n url: wsUrl,\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n model: this.#opts.model,\n },\n timeoutMs: this.connOptions.timeoutMs,\n abortSignal: this.abortSignal,\n });\n } catch (e) {\n throw new APIConnectionError({\n message: `Fish Audio websocket connect failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n\n const finished = new Future<void>();\n\n const inputTask = async () => {\n try {\n for await (const data of this.input) {\n if (this.abortController.signal.aborted) break;\n if (data === SynthesizeStream.FLUSH_SENTINEL) {\n sentStream.flush();\n continue;\n }\n if (!data) continue;\n sentStream.pushText(data);\n }\n } finally {\n if (!sentStream.closed) sentStream.endInput();\n }\n };\n\n const sendTask = async () => {\n const startMsg = { event: 'start', request: buildTtsRequest(this.#opts) };\n ws!.send(Buffer.from(encode(startMsg)));\n\n for await (const ev of sentStream) {\n if (this.abortController.signal.aborted) break;\n const sentence = ev.token;\n if (!sentence) continue;\n this.markStarted();\n ws!.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' })));\n ws!.send(Buffer.from(encode({ event: 'flush' })));\n }\n\n if (!this.abortController.signal.aborted) {\n ws!.send(Buffer.from(encode({ event: 'stop' })));\n }\n };\n\n let lastFrame: AudioFrame | undefined;\n const sendLastFrame = (final: boolean) => {\n if (lastFrame) {\n this.queue.put({ requestId, segmentId: requestId, frame: lastFrame, final });\n lastFrame = undefined;\n }\n };\n\n const recvTask = async () => {\n // No per-receive timeout: Fish has natural inter-sentence gaps that can\n // exceed connOptions.timeoutMs when the LLM is slow.\n const onMessage = (raw: RawData) => {\n let frame: Buffer;\n if (Buffer.isBuffer(raw)) {\n frame = raw;\n } else if (Array.isArray(raw)) {\n frame = Buffer.concat(raw);\n } else {\n frame = Buffer.from(raw as ArrayBuffer);\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = decode(frame) as Record<string, unknown>;\n } catch (err) {\n this.#logger.warn({ err }, 'Fish Audio failed to decode message');\n return;\n }\n\n const event = parsed.event as string | undefined;\n if (event === 'audio') {\n const audio = parsed.audio as Uint8Array | undefined;\n if (audio && audio.byteLength > 0) {\n for (const f of bstream.write(audio)) {\n sendLastFrame(false);\n lastFrame = f;\n }\n }\n } else if (event === 'finish') {\n const reason = parsed.reason as string | undefined;\n if (reason === 'error') {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio TTS reported an error',\n options: { body: { raw: JSON.stringify(parsed) } },\n }),\n );\n return;\n }\n for (const f of bstream.flush()) {\n sendLastFrame(false);\n lastFrame = f;\n }\n sendLastFrame(true);\n if (!this.queue.closed) {\n this.queue.put(SynthesizeStream.END_OF_STREAM);\n }\n if (!finished.done) finished.resolve();\n } else {\n this.#logger.debug({ event }, 'unknown Fish Audio event');\n }\n };\n\n const onClose = (code: number, reason: Buffer) => {\n if (!finished.done) {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio websocket connection closed unexpectedly',\n options: {\n statusCode: code || -1,\n body: { reason: reason.toString() },\n },\n }),\n );\n }\n };\n\n const onError = (err: Error) => {\n if (!finished.done) finished.reject(err);\n };\n\n ws!.on('message', onMessage);\n ws!.on('close', onClose);\n ws!.on('error', onError);\n\n try {\n await finished.await;\n } finally {\n ws!.off('message', onMessage);\n ws!.off('close', onClose);\n ws!.off('error', onError);\n }\n };\n\n try {\n await Promise.all([inputTask(), sendTask(), recvTask()]);\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio websocket failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n } finally {\n if (!sentStream.closed) sentStream.close();\n if (ws && ws.readyState !== WebSocket.CLOSED) {\n try {\n ws.close();\n } catch {\n // ignore\n }\n }\n }\n }\n}\n\nconst connectWebSocket = async ({\n url,\n headers,\n timeoutMs,\n abortSignal,\n}: {\n url: string;\n headers: Record<string, string>;\n timeoutMs: number;\n abortSignal: AbortSignal;\n}): Promise<WebSocket> => {\n const ws = new WebSocket(url, { headers, handshakeTimeout: timeoutMs });\n const fut = new Future<void>();\n\n let timeout: NodeJS.Timeout | undefined;\n const cleanup = () => {\n if (timeout) clearTimeout(timeout);\n ws.off('open', onOpen);\n ws.off('error', onError);\n ws.off('close', onClose);\n abortSignal.removeEventListener('abort', onAbort);\n };\n\n const onOpen = () => fut.resolve();\n const onError = (err: Error) => fut.reject(err);\n const onClose = (code: number, reason: Buffer) =>\n fut.reject(\n new Error(`websocket closed before open (code=${code}, reason=${reason.toString()})`),\n );\n const onAbort = () => fut.reject(new Error('aborted'));\n\n ws.on('open', onOpen);\n ws.on('error', onError);\n ws.on('close', onClose);\n abortSignal.addEventListener('abort', onAbort, { once: true });\n\n if (timeoutMs > 0) {\n timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);\n }\n\n try {\n await fut.await;\n return ws;\n } catch (e) {\n try {\n ws.on('error', () => {});\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.close();\n } else {\n ws.terminate();\n }\n } catch {\n // ignore\n }\n throw e;\n } finally {\n cleanup();\n }\n};\n"],"mappings":"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","names":["tts"]} | ||
| {"version":3,"sources":["../src/tts.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport {\n type APIConnectOptions,\n APIConnectionError,\n APIStatusError,\n AudioByteStream,\n ConnectionPool,\n Future,\n log,\n shortuuid,\n tokenize,\n tts,\n} from '@livekit/agents';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { request } from 'node:https';\nimport { type RawData, WebSocket } from 'ws';\nimport type { LatencyMode, MP3Bitrate, OpusBitrate, TTSModels } from './models.js';\n\nconst DEFAULT_MODEL: TTSModels = 's2.1-pro';\nconst DEFAULT_VOICE_ID = '933563129e564b19a115bedd57b7406a';\nconst DEFAULT_BASE_URL = 'https://api.fish.audio';\nconst NUM_CHANNELS = 1;\n// Fish Audio's default sample rate for raw PCM output.\nconst DEFAULT_SAMPLE_RATE = 24000;\n\nconst connectionPools = new WeakMap<TTS, ConnectionPool<WebSocket>>();\n\n/** @public */\nexport interface TTSOptions {\n apiKey?: string;\n model?: TTSModels | string;\n voiceId?: string;\n sampleRate?: number;\n baseURL?: string;\n latencyMode?: LatencyMode;\n /**\n * Upper bound on the number of characters Fish buffers before auto-synthesizing.\n * Must be between 100 and 300. With sentence-level flushing this is only hit by\n * sentences longer than `chunkLength`; otherwise audio is produced as soon as\n * each sentence is flushed. Defaults to 100.\n */\n chunkLength?: number;\n /**\n * Speaking rate multiplier for Fish `prosody.speed`. `1.0` is normal; below\n * 1.0 is slower, above is faster. Unset uses the voice's natural pace.\n */\n speed?: number;\n /**\n * Loudness adjustment in decibels for Fish `prosody.volume`. `0` is the\n * voice's natural level. Unset leaves it unchanged.\n */\n volume?: number;\n /**\n * Sampling temperature (0-1). Higher values produce more varied, expressive\n * speech; lower values are more stable. Defaults to 0.7.\n */\n temperature?: number;\n /** Nucleus sampling probability mass (0-1). Defaults to 0.7. */\n topP?: number;\n /**\n * MP3 bitrate request field in kbps: 64, 128, or 192. Defaults to 64.\n * The JS plugin currently requests PCM output, so this shapes the Fish Audio\n * request but does not change the emitted audio format or PCM bandwidth.\n */\n mp3Bitrate?: MP3Bitrate;\n /**\n * Opus bitrate request field in bps: -1000 (auto), 24000, 32000, 48000, or\n * 64000. Defaults to 64000. The JS plugin currently requests PCM output, so\n * this does not change the emitted audio format or PCM bandwidth.\n */\n opusBitrate?: OpusBitrate;\n /** Whether Fish normalizes the input text before synthesis. Defaults to true. */\n normalize?: boolean;\n tokenizer?: tokenize.SentenceTokenizer;\n}\n\ninterface ResolvedTTSOptions {\n apiKey: string;\n model: TTSModels | string;\n voiceId?: string;\n sampleRate: number;\n baseURL: string;\n latencyMode: LatencyMode;\n chunkLength: number;\n speed?: number;\n volume?: number;\n temperature: number;\n topP: number;\n mp3Bitrate: MP3Bitrate;\n opusBitrate: OpusBitrate;\n normalize: boolean;\n tokenizer: tokenize.SentenceTokenizer;\n}\n\nconst DEFAULT_OPTS: Omit<ResolvedTTSOptions, 'apiKey' | 'tokenizer'> = {\n model: DEFAULT_MODEL,\n voiceId: DEFAULT_VOICE_ID,\n sampleRate: DEFAULT_SAMPLE_RATE,\n baseURL: DEFAULT_BASE_URL,\n latencyMode: 'balanced',\n chunkLength: 100,\n temperature: 0.7,\n topP: 0.7,\n mp3Bitrate: 64,\n opusBitrate: 64000,\n normalize: true,\n};\n\nconst validateChunkLength = (chunkLength: number) => {\n if (!Number.isFinite(chunkLength) || chunkLength < 100 || chunkLength > 300) {\n throw new Error('chunkLength must be between 100 and 300');\n }\n};\n\nconst validateProbability = (name: string, value: number) => {\n if (!Number.isFinite(value) || value < 0 || value > 1) {\n throw new Error(`${name} must be between 0 and 1`);\n }\n};\n\n// Fish Audio's wire format mirrors the upstream Python SDK so the server\n// doesn't fall back to its own larger defaults — in particular the docs default\n// of `chunk_length=300` produces large bursts that leave audible gaps between\n// chunk boundaries.\nconst buildTtsRequest = (opts: ResolvedTTSOptions, text: string = ''): Record<string, unknown> => {\n const prosody =\n opts.speed !== undefined || opts.volume !== undefined\n ? {\n ...(opts.speed !== undefined ? { speed: opts.speed } : {}),\n ...(opts.volume !== undefined ? { volume: opts.volume } : {}),\n }\n : null;\n\n return {\n text,\n chunk_length: opts.chunkLength,\n format: 'pcm',\n sample_rate: opts.sampleRate,\n mp3_bitrate: opts.mp3Bitrate,\n opus_bitrate: opts.opusBitrate,\n references: [],\n // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on\n // the plugin for consistency with other TTS plugins.\n reference_id: opts.voiceId ?? null,\n normalize: opts.normalize,\n latency: opts.latencyMode,\n prosody,\n top_p: opts.topP,\n temperature: opts.temperature,\n };\n};\n\n/** @public */\nexport class TTS extends tts.TTS {\n #opts: ResolvedTTSOptions;\n #pool: ConnectionPool<WebSocket>;\n #closed = false;\n label = 'fishaudio.TTS';\n\n constructor(opts: TTSOptions = {}) {\n const apiKey = opts.apiKey ?? process.env.FISH_API_KEY;\n if (!apiKey) {\n throw new Error(\n 'Fish Audio API key is required, either as argument or set FISH_API_KEY environment variable',\n );\n }\n\n const chunkLength = opts.chunkLength ?? DEFAULT_OPTS.chunkLength;\n validateChunkLength(chunkLength);\n\n const temperature = opts.temperature ?? DEFAULT_OPTS.temperature;\n validateProbability('temperature', temperature);\n const topP = opts.topP ?? DEFAULT_OPTS.topP;\n validateProbability('topP', topP);\n\n const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate;\n\n super(sampleRate, NUM_CHANNELS, { streaming: true });\n\n // min_sentence_len=1 emits each sentence as soon as the next one starts,\n // rather than batching short sentences together — minimizes TTFB on the\n // first sentence and keeps Fish synthesizing continuously.\n const tokenizer =\n opts.tokenizer ?? new tokenize.basic.SentenceTokenizer({ minSentenceLength: 1 });\n\n this.#opts = {\n apiKey,\n model: opts.model ?? DEFAULT_OPTS.model,\n voiceId: opts.voiceId ?? DEFAULT_OPTS.voiceId,\n sampleRate,\n baseURL: opts.baseURL ?? DEFAULT_OPTS.baseURL,\n latencyMode: opts.latencyMode ?? DEFAULT_OPTS.latencyMode,\n chunkLength,\n speed: opts.speed,\n volume: opts.volume,\n temperature,\n topP,\n mp3Bitrate: opts.mp3Bitrate ?? DEFAULT_OPTS.mp3Bitrate,\n opusBitrate: opts.opusBitrate ?? DEFAULT_OPTS.opusBitrate,\n normalize: opts.normalize ?? DEFAULT_OPTS.normalize,\n tokenizer,\n };\n\n this.#pool = new ConnectionPool<WebSocket>({\n connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs),\n closeCb: async (ws) => closeWebSocket(ws),\n maxSessionDuration: 300_000,\n markRefreshedOnGet: true,\n });\n connectionPools.set(this, this.#pool);\n }\n\n get model(): string {\n return this.#opts.model;\n }\n\n get provider(): string {\n return 'FishAudio';\n }\n\n updateOptions(opts: {\n model?: TTSModels | string;\n voiceId?: string;\n latencyMode?: LatencyMode;\n chunkLength?: number;\n speed?: number;\n volume?: number;\n temperature?: number;\n topP?: number;\n mp3Bitrate?: MP3Bitrate;\n opusBitrate?: OpusBitrate;\n normalize?: boolean;\n }): void {\n if (opts.model !== undefined && opts.model !== this.#opts.model) {\n this.#opts.model = opts.model;\n // The model is sent as a connection header at ws-handshake time, not in the\n // per-request body, so a pooled socket keeps the old model.\n this.#pool.invalidate();\n }\n if (opts.voiceId !== undefined) this.#opts.voiceId = opts.voiceId;\n if (opts.latencyMode !== undefined) this.#opts.latencyMode = opts.latencyMode;\n if (opts.chunkLength !== undefined) {\n validateChunkLength(opts.chunkLength);\n this.#opts.chunkLength = opts.chunkLength;\n }\n if (opts.speed !== undefined) this.#opts.speed = opts.speed;\n if (opts.volume !== undefined) this.#opts.volume = opts.volume;\n if (opts.temperature !== undefined) {\n validateProbability('temperature', opts.temperature);\n this.#opts.temperature = opts.temperature;\n }\n if (opts.topP !== undefined) {\n validateProbability('topP', opts.topP);\n this.#opts.topP = opts.topP;\n }\n if (opts.mp3Bitrate !== undefined) this.#opts.mp3Bitrate = opts.mp3Bitrate;\n if (opts.opusBitrate !== undefined) this.#opts.opusBitrate = opts.opusBitrate;\n if (opts.normalize !== undefined) this.#opts.normalize = opts.normalize;\n }\n\n synthesize(\n text: string,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ): tts.ChunkedStream {\n return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal);\n }\n\n stream(options?: { connOptions?: APIConnectOptions }): tts.SynthesizeStream {\n return new SynthesizeStream(this, { ...this.#opts }, options?.connOptions);\n }\n\n prewarm(): void {\n this.#pool.prewarm();\n }\n\n override async close(): Promise<void> {\n this.#closed = true;\n await this.#pool.close();\n await super.close();\n }\n\n async #connectWebSocket(timeoutMs: number): Promise<WebSocket> {\n const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`;\n const model = this.#opts.model;\n const ws = await connectWebSocket({\n url: wsUrl,\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n model,\n },\n timeoutMs,\n });\n if (this.#closed) {\n closeWebSocket(ws);\n throw new APIConnectionError({ message: 'Fish Audio TTS is closed' });\n }\n if (model !== this.#opts.model) {\n closeWebSocket(ws);\n return await this.#connectWebSocket(timeoutMs);\n }\n return ws;\n }\n}\n\n/** @public */\nexport class ChunkedStream extends tts.ChunkedStream {\n label = 'fishaudio.ChunkedStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n #text: string;\n\n constructor(\n tts: TTS,\n text: string,\n opts: ResolvedTTSOptions,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ) {\n super(text, tts, connOptions, abortSignal);\n this.#text = text;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n const payload = encode(buildTtsRequest(this.#opts, this.#text));\n\n const baseUrl = new URL(this.#opts.baseURL);\n const isHttps = baseUrl.protocol === 'https:';\n if (!isHttps) {\n // The plugin only supports https; fall back via Node's http module is\n // intentionally not implemented to keep the code path simple.\n throw new APIConnectionError({\n message: `Fish Audio base URL must use https (got ${this.#opts.baseURL})`,\n });\n }\n\n const doneFut = new Future<void>();\n\n const req = request(\n {\n hostname: baseUrl.hostname,\n port: parseInt(baseUrl.port) || 443,\n path: '/v1/tts',\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n 'Content-Type': 'application/msgpack',\n model: this.#opts.model,\n 'Content-Length': payload.byteLength,\n },\n signal: this.abortSignal,\n },\n (res) => {\n const status = res.statusCode ?? -1;\n if (status < 200 || status >= 300) {\n const chunks: Buffer[] = [];\n res.on('data', (c: Buffer) => chunks.push(c));\n res.on('end', () => {\n const body = Buffer.concat(chunks).toString();\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed: ${body}`,\n options: { statusCode: status, body: { raw: body } },\n }),\n );\n }\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS error response stream error');\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed (status ${status})`,\n options: { statusCode: status },\n }),\n );\n }\n });\n return;\n }\n\n res.on('data', (chunk: Buffer) => {\n for (const frame of bstream.write(chunk)) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n });\n res.on('close', () => {\n for (const frame of bstream.flush()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n if (!this.queue.closed) this.queue.close();\n if (!doneFut.done) doneFut.resolve();\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS response error');\n if (!doneFut.done) doneFut.reject(err);\n });\n },\n );\n\n req.on('error', (err) => {\n if (err.name === 'AbortError') return;\n this.#logger.error({ err }, 'Fish Audio TTS request error');\n if (!doneFut.done) doneFut.reject(err);\n });\n req.write(payload);\n req.end();\n\n try {\n await doneFut.await;\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (!this.queue.closed) this.queue.close();\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio connection failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n }\n}\n\n/** @public */\nexport class SynthesizeStream extends tts.SynthesizeStream {\n label = 'fishaudio.SynthesizeStream';\n #logger = log();\n #pool: ConnectionPool<WebSocket>;\n #opts: ResolvedTTSOptions;\n\n constructor(tts: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {\n super(tts, connOptions);\n const pool = connectionPools.get(tts);\n if (!pool) throw new Error('Fish Audio connection pool is not initialized');\n this.#pool = pool;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n\n // Tokenize incoming text by sentence and flush after each sentence so Fish\n // synthesizes immediately at sentence boundaries instead of waiting for\n // `chunkLength` characters to accumulate. The result is much smoother\n // audio: gaps line up with sentence breaks (where pauses are natural)\n // rather than mid-clause.\n const sentStream = this.#opts.tokenizer.stream();\n\n const finished = new Future<void>();\n\n const inputTask = async () => {\n try {\n for await (const data of this.input) {\n if (this.abortController.signal.aborted) break;\n if (data === SynthesizeStream.FLUSH_SENTINEL) {\n sentStream.flush();\n continue;\n }\n if (!data) continue;\n sentStream.pushText(data);\n }\n } finally {\n if (!sentStream.closed) sentStream.endInput();\n }\n };\n\n const sendTask = async (ws: WebSocket) => {\n const startMsg = { event: 'start', request: buildTtsRequest(this.#opts) };\n ws.send(Buffer.from(encode(startMsg)));\n\n for await (const ev of sentStream) {\n if (this.abortController.signal.aborted) break;\n const sentence = ev.token;\n if (!sentence) continue;\n this.markStarted();\n ws.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' })));\n ws.send(Buffer.from(encode({ event: 'flush' })));\n }\n\n if (!this.abortController.signal.aborted) {\n ws.send(Buffer.from(encode({ event: 'stop' })));\n }\n };\n\n const recvTask = async (ws: WebSocket) => {\n // No per-receive timeout: Fish has natural inter-sentence gaps that can\n // exceed connOptions.timeoutMs when the LLM is slow.\n const onMessage = (raw: RawData) => {\n let frame: Buffer;\n if (Buffer.isBuffer(raw)) {\n frame = raw;\n } else if (Array.isArray(raw)) {\n frame = Buffer.concat(raw);\n } else {\n frame = Buffer.from(raw as ArrayBuffer);\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = decode(frame) as Record<string, unknown>;\n } catch (err) {\n this.#logger.warn({ err }, 'Fish Audio failed to decode message');\n return;\n }\n\n const event = parsed.event as string | undefined;\n if (event === 'audio') {\n const audio = parsed.audio as Uint8Array | undefined;\n if (audio && audio.byteLength > 0) {\n for (const frame of bstream.write(audio)) {\n this.queue.put({ requestId, segmentId: requestId, frame, final: false });\n }\n }\n } else if (event === 'finish') {\n const reason = parsed.reason as string | undefined;\n if (reason === 'error') {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio TTS reported an error',\n options: { body: { raw: JSON.stringify(parsed) } },\n }),\n );\n return;\n }\n const remainingFrames = [...bstream.flush()];\n for (const [idx, frame] of remainingFrames.entries()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: idx === remainingFrames.length - 1,\n });\n }\n if (!this.queue.closed) {\n this.queue.put(SynthesizeStream.END_OF_STREAM);\n }\n if (!finished.done) finished.resolve();\n } else {\n this.#logger.debug({ event }, 'unknown Fish Audio event');\n }\n };\n\n const onClose = (code: number, reason: Buffer) => {\n if (!finished.done) {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio websocket connection closed unexpectedly',\n options: {\n statusCode: code || -1,\n body: { reason: reason.toString() },\n },\n }),\n );\n }\n };\n\n const onError = (err: Error) => {\n if (!finished.done) finished.reject(err);\n };\n\n ws.on('message', onMessage);\n ws.on('close', onClose);\n ws.on('error', onError);\n\n try {\n await finished.await;\n } finally {\n ws.off('message', onMessage);\n ws.off('close', onClose);\n ws.off('error', onError);\n }\n };\n\n try {\n await this.#pool.withConnection(\n async (ws) => {\n if (ws.readyState !== WebSocket.OPEN) {\n throw new APIConnectionError({\n message: 'Fish Audio pooled websocket is not open',\n });\n }\n await Promise.all([inputTask(), sendTask(ws), recvTask(ws)]);\n },\n { timeout: this.connOptions.timeoutMs, signal: this.abortSignal },\n );\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio websocket failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n } finally {\n if (!sentStream.closed) sentStream.close();\n }\n }\n}\n\nconst connectWebSocket = async ({\n url,\n headers,\n timeoutMs,\n abortSignal,\n}: {\n url: string;\n headers: Record<string, string>;\n timeoutMs: number;\n abortSignal?: AbortSignal;\n}): Promise<WebSocket> => {\n const ws = new WebSocket(url, { headers, handshakeTimeout: timeoutMs });\n const fut = new Future<void>();\n\n let timeout: NodeJS.Timeout | undefined;\n const cleanup = () => {\n if (timeout) clearTimeout(timeout);\n ws.off('open', onOpen);\n ws.off('error', onError);\n ws.off('close', onClose);\n abortSignal?.removeEventListener('abort', onAbort);\n };\n\n const onOpen = () => fut.resolve();\n const onError = (err: Error) => fut.reject(err);\n const onClose = (code: number, reason: Buffer) =>\n fut.reject(\n new Error(`websocket closed before open (code=${code}, reason=${reason.toString()})`),\n );\n const onAbort = () => fut.reject(new Error('aborted'));\n\n ws.on('open', onOpen);\n ws.on('error', onError);\n ws.on('close', onClose);\n abortSignal?.addEventListener('abort', onAbort, { once: true });\n\n if (timeoutMs > 0) {\n timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);\n }\n\n try {\n await fut.await;\n return ws;\n } catch (e) {\n try {\n closeWebSocket(ws);\n } catch {\n // ignore\n }\n throw e;\n } finally {\n cleanup();\n }\n};\n\nconst closeWebSocket = (ws: WebSocket) => {\n try {\n ws.on('error', () => {});\n if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {\n ws.close();\n } else if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {\n ws.terminate();\n }\n } catch {\n // ignore\n }\n};\n"],"mappings":"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","names":["tts","frame"]} |
+38
-1
| import { type APIConnectOptions, tokenize, tts } from '@livekit/agents'; | ||
| import type { LatencyMode, TTSModels } from './models.js'; | ||
| import type { LatencyMode, MP3Bitrate, OpusBitrate, TTSModels } from './models.js'; | ||
| /** @public */ | ||
| export interface TTSOptions { | ||
@@ -27,2 +28,23 @@ apiKey?: string; | ||
| volume?: number; | ||
| /** | ||
| * Sampling temperature (0-1). Higher values produce more varied, expressive | ||
| * speech; lower values are more stable. Defaults to 0.7. | ||
| */ | ||
| temperature?: number; | ||
| /** Nucleus sampling probability mass (0-1). Defaults to 0.7. */ | ||
| topP?: number; | ||
| /** | ||
| * MP3 bitrate request field in kbps: 64, 128, or 192. Defaults to 64. | ||
| * The JS plugin currently requests PCM output, so this shapes the Fish Audio | ||
| * request but does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| mp3Bitrate?: MP3Bitrate; | ||
| /** | ||
| * Opus bitrate request field in bps: -1000 (auto), 24000, 32000, 48000, or | ||
| * 64000. Defaults to 64000. The JS plugin currently requests PCM output, so | ||
| * this does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| opusBitrate?: OpusBitrate; | ||
| /** Whether Fish normalizes the input text before synthesis. Defaults to true. */ | ||
| normalize?: boolean; | ||
| tokenizer?: tokenize.SentenceTokenizer; | ||
@@ -40,4 +62,10 @@ } | ||
| volume?: number; | ||
| temperature: number; | ||
| topP: number; | ||
| mp3Bitrate: MP3Bitrate; | ||
| opusBitrate: OpusBitrate; | ||
| normalize: boolean; | ||
| tokenizer: tokenize.SentenceTokenizer; | ||
| } | ||
| /** @public */ | ||
| export declare class TTS extends tts.TTS { | ||
@@ -56,2 +84,7 @@ #private; | ||
| volume?: number; | ||
| temperature?: number; | ||
| topP?: number; | ||
| mp3Bitrate?: MP3Bitrate; | ||
| opusBitrate?: OpusBitrate; | ||
| normalize?: boolean; | ||
| }): void; | ||
@@ -62,3 +95,6 @@ synthesize(text: string, connOptions?: APIConnectOptions, abortSignal?: AbortSignal): tts.ChunkedStream; | ||
| }): tts.SynthesizeStream; | ||
| prewarm(): void; | ||
| close(): Promise<void>; | ||
| } | ||
| /** @public */ | ||
| export declare class ChunkedStream extends tts.ChunkedStream { | ||
@@ -70,2 +106,3 @@ #private; | ||
| } | ||
| /** @public */ | ||
| export declare class SynthesizeStream extends tts.SynthesizeStream { | ||
@@ -72,0 +109,0 @@ #private; |
+38
-1
| import { type APIConnectOptions, tokenize, tts } from '@livekit/agents'; | ||
| import type { LatencyMode, TTSModels } from './models.js'; | ||
| import type { LatencyMode, MP3Bitrate, OpusBitrate, TTSModels } from './models.js'; | ||
| /** @public */ | ||
| export interface TTSOptions { | ||
@@ -27,2 +28,23 @@ apiKey?: string; | ||
| volume?: number; | ||
| /** | ||
| * Sampling temperature (0-1). Higher values produce more varied, expressive | ||
| * speech; lower values are more stable. Defaults to 0.7. | ||
| */ | ||
| temperature?: number; | ||
| /** Nucleus sampling probability mass (0-1). Defaults to 0.7. */ | ||
| topP?: number; | ||
| /** | ||
| * MP3 bitrate request field in kbps: 64, 128, or 192. Defaults to 64. | ||
| * The JS plugin currently requests PCM output, so this shapes the Fish Audio | ||
| * request but does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| mp3Bitrate?: MP3Bitrate; | ||
| /** | ||
| * Opus bitrate request field in bps: -1000 (auto), 24000, 32000, 48000, or | ||
| * 64000. Defaults to 64000. The JS plugin currently requests PCM output, so | ||
| * this does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| opusBitrate?: OpusBitrate; | ||
| /** Whether Fish normalizes the input text before synthesis. Defaults to true. */ | ||
| normalize?: boolean; | ||
| tokenizer?: tokenize.SentenceTokenizer; | ||
@@ -40,4 +62,10 @@ } | ||
| volume?: number; | ||
| temperature: number; | ||
| topP: number; | ||
| mp3Bitrate: MP3Bitrate; | ||
| opusBitrate: OpusBitrate; | ||
| normalize: boolean; | ||
| tokenizer: tokenize.SentenceTokenizer; | ||
| } | ||
| /** @public */ | ||
| export declare class TTS extends tts.TTS { | ||
@@ -56,2 +84,7 @@ #private; | ||
| volume?: number; | ||
| temperature?: number; | ||
| topP?: number; | ||
| mp3Bitrate?: MP3Bitrate; | ||
| opusBitrate?: OpusBitrate; | ||
| normalize?: boolean; | ||
| }): void; | ||
@@ -62,3 +95,6 @@ synthesize(text: string, connOptions?: APIConnectOptions, abortSignal?: AbortSignal): tts.ChunkedStream; | ||
| }): tts.SynthesizeStream; | ||
| prewarm(): void; | ||
| close(): Promise<void>; | ||
| } | ||
| /** @public */ | ||
| export declare class ChunkedStream extends tts.ChunkedStream { | ||
@@ -70,2 +106,3 @@ #private; | ||
| } | ||
| /** @public */ | ||
| export declare class SynthesizeStream extends tts.SynthesizeStream { | ||
@@ -72,0 +109,0 @@ #private; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../src/tts.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EAOtB,QAAQ,EACR,GAAG,EACJ,MAAM,iBAAiB,CAAC;AAKzB,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAS1D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,QAAQ,CAAC,iBAAiB,CAAC;CACxC;AAED,UAAU,kBAAkB;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,WAAW,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,QAAQ,CAAC,iBAAiB,CAAC;CACvC;AAiDD,qBAAa,GAAI,SAAQ,GAAG,CAAC,GAAG;;IAE9B,KAAK,SAAmB;gBAEZ,IAAI,GAAE,UAAe;IAmCjC,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,aAAa,CAAC,IAAI,EAAE;QAClB,KAAK,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;QAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,WAAW,CAAC;QAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,IAAI;IAYR,UAAU,CACR,IAAI,EAAE,MAAM,EACZ,WAAW,CAAC,EAAE,iBAAiB,EAC/B,WAAW,CAAC,EAAE,WAAW,GACxB,GAAG,CAAC,aAAa;IAIpB,MAAM,CAAC,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,iBAAiB,CAAA;KAAE,GAAG,GAAG,CAAC,gBAAgB;CAG5E;AAED,qBAAa,aAAc,SAAQ,GAAG,CAAC,aAAa;;IAClD,KAAK,SAA6B;gBAMhC,GAAG,EAAE,GAAG,EACR,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,kBAAkB,EACxB,WAAW,CAAC,EAAE,iBAAiB,EAC/B,WAAW,CAAC,EAAE,WAAW;cAOX,GAAG;CAiHpB;AAED,qBAAa,gBAAiB,SAAQ,GAAG,CAAC,gBAAgB;;IACxD,KAAK,SAAgC;gBAIzB,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,kBAAkB,EAAE,WAAW,CAAC,EAAE,iBAAiB;cAK/D,GAAG;CAoLpB"} | ||
| {"version":3,"file":"tts.d.ts","sourceRoot":"","sources":["../src/tts.ts"],"names":[],"mappings":"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"} |
+124
-58
@@ -5,2 +5,3 @@ import { | ||
| AudioByteStream, | ||
| ConnectionPool, | ||
| Future, | ||
@@ -20,2 +21,3 @@ log, | ||
| const DEFAULT_SAMPLE_RATE = 24e3; | ||
| const connectionPools = /* @__PURE__ */ new WeakMap(); | ||
| const DEFAULT_OPTS = { | ||
@@ -27,3 +29,8 @@ model: DEFAULT_MODEL, | ||
| latencyMode: "balanced", | ||
| chunkLength: 100 | ||
| chunkLength: 100, | ||
| temperature: 0.7, | ||
| topP: 0.7, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 64e3, | ||
| normalize: true | ||
| }; | ||
@@ -35,2 +42,7 @@ const validateChunkLength = (chunkLength) => { | ||
| }; | ||
| const validateProbability = (name, value) => { | ||
| if (!Number.isFinite(value) || value < 0 || value > 1) { | ||
| throw new Error(`${name} must be between 0 and 1`); | ||
| } | ||
| }; | ||
| const buildTtsRequest = (opts, text = "") => { | ||
@@ -46,4 +58,4 @@ const prosody = opts.speed !== void 0 || opts.volume !== void 0 ? { | ||
| sample_rate: opts.sampleRate, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 64e3, | ||
| mp3_bitrate: opts.mp3Bitrate, | ||
| opus_bitrate: opts.opusBitrate, | ||
| references: [], | ||
@@ -53,7 +65,7 @@ // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on | ||
| reference_id: opts.voiceId ?? null, | ||
| normalize: true, | ||
| normalize: opts.normalize, | ||
| latency: opts.latencyMode, | ||
| prosody, | ||
| top_p: 0.7, | ||
| temperature: 0.7 | ||
| top_p: opts.topP, | ||
| temperature: opts.temperature | ||
| }; | ||
@@ -63,2 +75,4 @@ }; | ||
| #opts; | ||
| #pool; | ||
| #closed = false; | ||
| label = "fishaudio.TTS"; | ||
@@ -74,2 +88,6 @@ constructor(opts = {}) { | ||
| validateChunkLength(chunkLength); | ||
| const temperature = opts.temperature ?? DEFAULT_OPTS.temperature; | ||
| validateProbability("temperature", temperature); | ||
| const topP = opts.topP ?? DEFAULT_OPTS.topP; | ||
| validateProbability("topP", topP); | ||
| const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate; | ||
@@ -88,4 +106,16 @@ super(sampleRate, NUM_CHANNELS, { streaming: true }); | ||
| volume: opts.volume, | ||
| temperature, | ||
| topP, | ||
| mp3Bitrate: opts.mp3Bitrate ?? DEFAULT_OPTS.mp3Bitrate, | ||
| opusBitrate: opts.opusBitrate ?? DEFAULT_OPTS.opusBitrate, | ||
| normalize: opts.normalize ?? DEFAULT_OPTS.normalize, | ||
| tokenizer | ||
| }; | ||
| this.#pool = new ConnectionPool({ | ||
| connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs), | ||
| closeCb: async (ws) => closeWebSocket(ws), | ||
| maxSessionDuration: 3e5, | ||
| markRefreshedOnGet: true | ||
| }); | ||
| connectionPools.set(this, this.#pool); | ||
| } | ||
@@ -99,3 +129,6 @@ get model() { | ||
| updateOptions(opts) { | ||
| if (opts.model !== void 0) this.#opts.model = opts.model; | ||
| if (opts.model !== void 0 && opts.model !== this.#opts.model) { | ||
| this.#opts.model = opts.model; | ||
| this.#pool.invalidate(); | ||
| } | ||
| if (opts.voiceId !== void 0) this.#opts.voiceId = opts.voiceId; | ||
@@ -109,9 +142,49 @@ if (opts.latencyMode !== void 0) this.#opts.latencyMode = opts.latencyMode; | ||
| if (opts.volume !== void 0) this.#opts.volume = opts.volume; | ||
| if (opts.temperature !== void 0) { | ||
| validateProbability("temperature", opts.temperature); | ||
| this.#opts.temperature = opts.temperature; | ||
| } | ||
| if (opts.topP !== void 0) { | ||
| validateProbability("topP", opts.topP); | ||
| this.#opts.topP = opts.topP; | ||
| } | ||
| if (opts.mp3Bitrate !== void 0) this.#opts.mp3Bitrate = opts.mp3Bitrate; | ||
| if (opts.opusBitrate !== void 0) this.#opts.opusBitrate = opts.opusBitrate; | ||
| if (opts.normalize !== void 0) this.#opts.normalize = opts.normalize; | ||
| } | ||
| synthesize(text, connOptions, abortSignal) { | ||
| return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal); | ||
| return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal); | ||
| } | ||
| stream(options) { | ||
| return new SynthesizeStream(this, this.#opts, options == null ? void 0 : options.connOptions); | ||
| return new SynthesizeStream(this, { ...this.#opts }, options == null ? void 0 : options.connOptions); | ||
| } | ||
| prewarm() { | ||
| this.#pool.prewarm(); | ||
| } | ||
| async close() { | ||
| this.#closed = true; | ||
| await this.#pool.close(); | ||
| await super.close(); | ||
| } | ||
| async #connectWebSocket(timeoutMs) { | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, "ws")}/v1/tts/live`; | ||
| const model = this.#opts.model; | ||
| const ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model | ||
| }, | ||
| timeoutMs | ||
| }); | ||
| if (this.#closed) { | ||
| closeWebSocket(ws); | ||
| throw new APIConnectionError({ message: "Fish Audio TTS is closed" }); | ||
| } | ||
| if (model !== this.#opts.model) { | ||
| closeWebSocket(ws); | ||
| return await this.#connectWebSocket(timeoutMs); | ||
| } | ||
| return ws; | ||
| } | ||
| } | ||
@@ -237,5 +310,9 @@ class ChunkedStream extends tts.ChunkedStream { | ||
| #logger = log(); | ||
| #pool; | ||
| #opts; | ||
| constructor(tts2, opts, connOptions) { | ||
| super(tts2, connOptions); | ||
| const pool = connectionPools.get(tts2); | ||
| if (!pool) throw new Error("Fish Audio connection pool is not initialized"); | ||
| this.#pool = pool; | ||
| this.#opts = opts; | ||
@@ -247,19 +324,2 @@ } | ||
| const sentStream = this.#opts.tokenizer.stream(); | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, "ws")}/v1/tts/live`; | ||
| let ws; | ||
| try { | ||
| ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model: this.#opts.model | ||
| }, | ||
| timeoutMs: this.connOptions.timeoutMs, | ||
| abortSignal: this.abortSignal | ||
| }); | ||
| } catch (e) { | ||
| throw new APIConnectionError({ | ||
| message: `Fish Audio websocket connect failed: ${e.message ?? "unknown error"}` | ||
| }); | ||
| } | ||
| const finished = new Future(); | ||
@@ -281,3 +341,3 @@ const inputTask = async () => { | ||
| }; | ||
| const sendTask = async () => { | ||
| const sendTask = async (ws) => { | ||
| const startMsg = { event: "start", request: buildTtsRequest(this.#opts) }; | ||
@@ -297,10 +357,3 @@ ws.send(Buffer.from(encode(startMsg))); | ||
| }; | ||
| let lastFrame; | ||
| const sendLastFrame = (final) => { | ||
| if (lastFrame) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame: lastFrame, final }); | ||
| lastFrame = void 0; | ||
| } | ||
| }; | ||
| const recvTask = async () => { | ||
| const recvTask = async (ws) => { | ||
| const onMessage = (raw) => { | ||
@@ -326,5 +379,4 @@ let frame; | ||
| if (audio && audio.byteLength > 0) { | ||
| for (const f of bstream.write(audio)) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| for (const frame2 of bstream.write(audio)) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame: frame2, final: false }); | ||
| } | ||
@@ -343,7 +395,11 @@ } | ||
| } | ||
| for (const f of bstream.flush()) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| const remainingFrames = [...bstream.flush()]; | ||
| for (const [idx, frame2] of remainingFrames.entries()) { | ||
| this.queue.put({ | ||
| requestId, | ||
| segmentId: requestId, | ||
| frame: frame2, | ||
| final: idx === remainingFrames.length - 1 | ||
| }); | ||
| } | ||
| sendLastFrame(true); | ||
| if (!this.queue.closed) { | ||
@@ -385,3 +441,13 @@ this.queue.put(SynthesizeStream.END_OF_STREAM); | ||
| try { | ||
| await Promise.all([inputTask(), sendTask(), recvTask()]); | ||
| await this.#pool.withConnection( | ||
| async (ws) => { | ||
| if (ws.readyState !== WebSocket.OPEN) { | ||
| throw new APIConnectionError({ | ||
| message: "Fish Audio pooled websocket is not open" | ||
| }); | ||
| } | ||
| await Promise.all([inputTask(), sendTask(ws), recvTask(ws)]); | ||
| }, | ||
| { timeout: this.connOptions.timeoutMs, signal: this.abortSignal } | ||
| ); | ||
| } catch (e) { | ||
@@ -397,8 +463,2 @@ if (this.abortSignal.aborted) return; | ||
| if (!sentStream.closed) sentStream.close(); | ||
| if (ws && ws.readyState !== WebSocket.CLOSED) { | ||
| try { | ||
| ws.close(); | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
@@ -421,3 +481,3 @@ } | ||
| ws.off("close", onClose); | ||
| abortSignal.removeEventListener("abort", onAbort); | ||
| abortSignal == null ? void 0 : abortSignal.removeEventListener("abort", onAbort); | ||
| }; | ||
@@ -433,3 +493,3 @@ const onOpen = () => fut.resolve(); | ||
| ws.on("close", onClose); | ||
| abortSignal.addEventListener("abort", onAbort, { once: true }); | ||
| abortSignal == null ? void 0 : abortSignal.addEventListener("abort", onAbort, { once: true }); | ||
| if (timeoutMs > 0) { | ||
@@ -443,9 +503,3 @@ timeout = setTimeout(() => fut.reject(new Error("connect timeout")), timeoutMs); | ||
| try { | ||
| ws.on("error", () => { | ||
| }); | ||
| if (ws.readyState === WebSocket.CONNECTING) { | ||
| ws.close(); | ||
| } else { | ||
| ws.terminate(); | ||
| } | ||
| closeWebSocket(ws); | ||
| } catch { | ||
@@ -458,2 +512,14 @@ } | ||
| }; | ||
| const closeWebSocket = (ws) => { | ||
| try { | ||
| ws.on("error", () => { | ||
| }); | ||
| if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) { | ||
| ws.close(); | ||
| } else if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { | ||
| ws.terminate(); | ||
| } | ||
| } catch { | ||
| } | ||
| }; | ||
| export { | ||
@@ -460,0 +526,0 @@ ChunkedStream, |
+1
-1
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/tts.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport {\n type APIConnectOptions,\n APIConnectionError,\n APIStatusError,\n AudioByteStream,\n Future,\n log,\n shortuuid,\n tokenize,\n tts,\n} from '@livekit/agents';\nimport type { AudioFrame } from '@livekit/rtc-node';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { request } from 'node:https';\nimport { type RawData, WebSocket } from 'ws';\nimport type { LatencyMode, TTSModels } from './models.js';\n\nconst DEFAULT_MODEL: TTSModels = 's2.1-pro';\nconst DEFAULT_VOICE_ID = '933563129e564b19a115bedd57b7406a';\nconst DEFAULT_BASE_URL = 'https://api.fish.audio';\nconst NUM_CHANNELS = 1;\n// Fish Audio's default sample rate for raw PCM output.\nconst DEFAULT_SAMPLE_RATE = 24000;\n\nexport interface TTSOptions {\n apiKey?: string;\n model?: TTSModels | string;\n voiceId?: string;\n sampleRate?: number;\n baseURL?: string;\n latencyMode?: LatencyMode;\n /**\n * Upper bound on the number of characters Fish buffers before auto-synthesizing.\n * Must be between 100 and 300. With sentence-level flushing this is only hit by\n * sentences longer than `chunkLength`; otherwise audio is produced as soon as\n * each sentence is flushed. Defaults to 100.\n */\n chunkLength?: number;\n /**\n * Speaking rate multiplier for Fish `prosody.speed`. `1.0` is normal; below\n * 1.0 is slower, above is faster. Unset uses the voice's natural pace.\n */\n speed?: number;\n /**\n * Loudness adjustment in decibels for Fish `prosody.volume`. `0` is the\n * voice's natural level. Unset leaves it unchanged.\n */\n volume?: number;\n tokenizer?: tokenize.SentenceTokenizer;\n}\n\ninterface ResolvedTTSOptions {\n apiKey: string;\n model: TTSModels | string;\n voiceId?: string;\n sampleRate: number;\n baseURL: string;\n latencyMode: LatencyMode;\n chunkLength: number;\n speed?: number;\n volume?: number;\n tokenizer: tokenize.SentenceTokenizer;\n}\n\nconst DEFAULT_OPTS: Omit<ResolvedTTSOptions, 'apiKey' | 'tokenizer'> = {\n model: DEFAULT_MODEL,\n voiceId: DEFAULT_VOICE_ID,\n sampleRate: DEFAULT_SAMPLE_RATE,\n baseURL: DEFAULT_BASE_URL,\n latencyMode: 'balanced',\n chunkLength: 100,\n};\n\nconst validateChunkLength = (chunkLength: number) => {\n if (!Number.isFinite(chunkLength) || chunkLength < 100 || chunkLength > 300) {\n throw new Error('chunkLength must be between 100 and 300');\n }\n};\n\n// Fish Audio's wire format mirrors the upstream Python SDK so the server\n// doesn't fall back to its own larger defaults — in particular the docs default\n// of `chunk_length=300` produces large bursts that leave audible gaps between\n// chunk boundaries.\nconst buildTtsRequest = (opts: ResolvedTTSOptions, text: string = ''): Record<string, unknown> => {\n const prosody =\n opts.speed !== undefined || opts.volume !== undefined\n ? {\n ...(opts.speed !== undefined ? { speed: opts.speed } : {}),\n ...(opts.volume !== undefined ? { volume: opts.volume } : {}),\n }\n : null;\n\n return {\n text,\n chunk_length: opts.chunkLength,\n format: 'pcm',\n sample_rate: opts.sampleRate,\n mp3_bitrate: 64,\n opus_bitrate: 64000,\n references: [],\n // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on\n // the plugin for consistency with other TTS plugins.\n reference_id: opts.voiceId ?? null,\n normalize: true,\n latency: opts.latencyMode,\n prosody,\n top_p: 0.7,\n temperature: 0.7,\n };\n};\n\nexport class TTS extends tts.TTS {\n #opts: ResolvedTTSOptions;\n label = 'fishaudio.TTS';\n\n constructor(opts: TTSOptions = {}) {\n const apiKey = opts.apiKey ?? process.env.FISH_API_KEY;\n if (!apiKey) {\n throw new Error(\n 'Fish Audio API key is required, either as argument or set FISH_API_KEY environment variable',\n );\n }\n\n const chunkLength = opts.chunkLength ?? DEFAULT_OPTS.chunkLength;\n validateChunkLength(chunkLength);\n\n const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate;\n\n super(sampleRate, NUM_CHANNELS, { streaming: true });\n\n // min_sentence_len=1 emits each sentence as soon as the next one starts,\n // rather than batching short sentences together — minimizes TTFB on the\n // first sentence and keeps Fish synthesizing continuously.\n const tokenizer =\n opts.tokenizer ?? new tokenize.basic.SentenceTokenizer({ minSentenceLength: 1 });\n\n this.#opts = {\n apiKey,\n model: opts.model ?? DEFAULT_OPTS.model,\n voiceId: opts.voiceId ?? DEFAULT_OPTS.voiceId,\n sampleRate,\n baseURL: opts.baseURL ?? DEFAULT_OPTS.baseURL,\n latencyMode: opts.latencyMode ?? DEFAULT_OPTS.latencyMode,\n chunkLength,\n speed: opts.speed,\n volume: opts.volume,\n tokenizer,\n };\n }\n\n get model(): string {\n return this.#opts.model;\n }\n\n get provider(): string {\n return 'FishAudio';\n }\n\n updateOptions(opts: {\n model?: TTSModels | string;\n voiceId?: string;\n latencyMode?: LatencyMode;\n chunkLength?: number;\n speed?: number;\n volume?: number;\n }): void {\n if (opts.model !== undefined) this.#opts.model = opts.model;\n if (opts.voiceId !== undefined) this.#opts.voiceId = opts.voiceId;\n if (opts.latencyMode !== undefined) this.#opts.latencyMode = opts.latencyMode;\n if (opts.chunkLength !== undefined) {\n validateChunkLength(opts.chunkLength);\n this.#opts.chunkLength = opts.chunkLength;\n }\n if (opts.speed !== undefined) this.#opts.speed = opts.speed;\n if (opts.volume !== undefined) this.#opts.volume = opts.volume;\n }\n\n synthesize(\n text: string,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ): tts.ChunkedStream {\n return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal);\n }\n\n stream(options?: { connOptions?: APIConnectOptions }): tts.SynthesizeStream {\n return new SynthesizeStream(this, this.#opts, options?.connOptions);\n }\n}\n\nexport class ChunkedStream extends tts.ChunkedStream {\n label = 'fishaudio.ChunkedStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n #text: string;\n\n constructor(\n tts: TTS,\n text: string,\n opts: ResolvedTTSOptions,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ) {\n super(text, tts, connOptions, abortSignal);\n this.#text = text;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n const payload = encode(buildTtsRequest(this.#opts, this.#text));\n\n const baseUrl = new URL(this.#opts.baseURL);\n const isHttps = baseUrl.protocol === 'https:';\n if (!isHttps) {\n // The plugin only supports https; fall back via Node's http module is\n // intentionally not implemented to keep the code path simple.\n throw new APIConnectionError({\n message: `Fish Audio base URL must use https (got ${this.#opts.baseURL})`,\n });\n }\n\n const doneFut = new Future<void>();\n\n const req = request(\n {\n hostname: baseUrl.hostname,\n port: parseInt(baseUrl.port) || 443,\n path: '/v1/tts',\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n 'Content-Type': 'application/msgpack',\n model: this.#opts.model,\n 'Content-Length': payload.byteLength,\n },\n signal: this.abortSignal,\n },\n (res) => {\n const status = res.statusCode ?? -1;\n if (status < 200 || status >= 300) {\n const chunks: Buffer[] = [];\n res.on('data', (c: Buffer) => chunks.push(c));\n res.on('end', () => {\n const body = Buffer.concat(chunks).toString();\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed: ${body}`,\n options: { statusCode: status, body: { raw: body } },\n }),\n );\n }\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS error response stream error');\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed (status ${status})`,\n options: { statusCode: status },\n }),\n );\n }\n });\n return;\n }\n\n res.on('data', (chunk: Buffer) => {\n for (const frame of bstream.write(chunk)) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n });\n res.on('close', () => {\n for (const frame of bstream.flush()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n if (!this.queue.closed) this.queue.close();\n if (!doneFut.done) doneFut.resolve();\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS response error');\n if (!doneFut.done) doneFut.reject(err);\n });\n },\n );\n\n req.on('error', (err) => {\n if (err.name === 'AbortError') return;\n this.#logger.error({ err }, 'Fish Audio TTS request error');\n if (!doneFut.done) doneFut.reject(err);\n });\n req.write(payload);\n req.end();\n\n try {\n await doneFut.await;\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (!this.queue.closed) this.queue.close();\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio connection failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n }\n}\n\nexport class SynthesizeStream extends tts.SynthesizeStream {\n label = 'fishaudio.SynthesizeStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n\n constructor(tts: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {\n super(tts, connOptions);\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n\n // Tokenize incoming text by sentence and flush after each sentence so Fish\n // synthesizes immediately at sentence boundaries instead of waiting for\n // `chunkLength` characters to accumulate. The result is much smoother\n // audio: gaps line up with sentence breaks (where pauses are natural)\n // rather than mid-clause.\n const sentStream = this.#opts.tokenizer.stream();\n\n const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`;\n let ws: WebSocket | undefined;\n try {\n ws = await connectWebSocket({\n url: wsUrl,\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n model: this.#opts.model,\n },\n timeoutMs: this.connOptions.timeoutMs,\n abortSignal: this.abortSignal,\n });\n } catch (e) {\n throw new APIConnectionError({\n message: `Fish Audio websocket connect failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n\n const finished = new Future<void>();\n\n const inputTask = async () => {\n try {\n for await (const data of this.input) {\n if (this.abortController.signal.aborted) break;\n if (data === SynthesizeStream.FLUSH_SENTINEL) {\n sentStream.flush();\n continue;\n }\n if (!data) continue;\n sentStream.pushText(data);\n }\n } finally {\n if (!sentStream.closed) sentStream.endInput();\n }\n };\n\n const sendTask = async () => {\n const startMsg = { event: 'start', request: buildTtsRequest(this.#opts) };\n ws!.send(Buffer.from(encode(startMsg)));\n\n for await (const ev of sentStream) {\n if (this.abortController.signal.aborted) break;\n const sentence = ev.token;\n if (!sentence) continue;\n this.markStarted();\n ws!.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' })));\n ws!.send(Buffer.from(encode({ event: 'flush' })));\n }\n\n if (!this.abortController.signal.aborted) {\n ws!.send(Buffer.from(encode({ event: 'stop' })));\n }\n };\n\n let lastFrame: AudioFrame | undefined;\n const sendLastFrame = (final: boolean) => {\n if (lastFrame) {\n this.queue.put({ requestId, segmentId: requestId, frame: lastFrame, final });\n lastFrame = undefined;\n }\n };\n\n const recvTask = async () => {\n // No per-receive timeout: Fish has natural inter-sentence gaps that can\n // exceed connOptions.timeoutMs when the LLM is slow.\n const onMessage = (raw: RawData) => {\n let frame: Buffer;\n if (Buffer.isBuffer(raw)) {\n frame = raw;\n } else if (Array.isArray(raw)) {\n frame = Buffer.concat(raw);\n } else {\n frame = Buffer.from(raw as ArrayBuffer);\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = decode(frame) as Record<string, unknown>;\n } catch (err) {\n this.#logger.warn({ err }, 'Fish Audio failed to decode message');\n return;\n }\n\n const event = parsed.event as string | undefined;\n if (event === 'audio') {\n const audio = parsed.audio as Uint8Array | undefined;\n if (audio && audio.byteLength > 0) {\n for (const f of bstream.write(audio)) {\n sendLastFrame(false);\n lastFrame = f;\n }\n }\n } else if (event === 'finish') {\n const reason = parsed.reason as string | undefined;\n if (reason === 'error') {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio TTS reported an error',\n options: { body: { raw: JSON.stringify(parsed) } },\n }),\n );\n return;\n }\n for (const f of bstream.flush()) {\n sendLastFrame(false);\n lastFrame = f;\n }\n sendLastFrame(true);\n if (!this.queue.closed) {\n this.queue.put(SynthesizeStream.END_OF_STREAM);\n }\n if (!finished.done) finished.resolve();\n } else {\n this.#logger.debug({ event }, 'unknown Fish Audio event');\n }\n };\n\n const onClose = (code: number, reason: Buffer) => {\n if (!finished.done) {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio websocket connection closed unexpectedly',\n options: {\n statusCode: code || -1,\n body: { reason: reason.toString() },\n },\n }),\n );\n }\n };\n\n const onError = (err: Error) => {\n if (!finished.done) finished.reject(err);\n };\n\n ws!.on('message', onMessage);\n ws!.on('close', onClose);\n ws!.on('error', onError);\n\n try {\n await finished.await;\n } finally {\n ws!.off('message', onMessage);\n ws!.off('close', onClose);\n ws!.off('error', onError);\n }\n };\n\n try {\n await Promise.all([inputTask(), sendTask(), recvTask()]);\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio websocket failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n } finally {\n if (!sentStream.closed) sentStream.close();\n if (ws && ws.readyState !== WebSocket.CLOSED) {\n try {\n ws.close();\n } catch {\n // ignore\n }\n }\n }\n }\n}\n\nconst connectWebSocket = async ({\n url,\n headers,\n timeoutMs,\n abortSignal,\n}: {\n url: string;\n headers: Record<string, string>;\n timeoutMs: number;\n abortSignal: AbortSignal;\n}): Promise<WebSocket> => {\n const ws = new WebSocket(url, { headers, handshakeTimeout: timeoutMs });\n const fut = new Future<void>();\n\n let timeout: NodeJS.Timeout | undefined;\n const cleanup = () => {\n if (timeout) clearTimeout(timeout);\n ws.off('open', onOpen);\n ws.off('error', onError);\n ws.off('close', onClose);\n abortSignal.removeEventListener('abort', onAbort);\n };\n\n const onOpen = () => fut.resolve();\n const onError = (err: Error) => fut.reject(err);\n const onClose = (code: number, reason: Buffer) =>\n fut.reject(\n new Error(`websocket closed before open (code=${code}, reason=${reason.toString()})`),\n );\n const onAbort = () => fut.reject(new Error('aborted'));\n\n ws.on('open', onOpen);\n ws.on('error', onError);\n ws.on('close', onClose);\n abortSignal.addEventListener('abort', onAbort, { once: true });\n\n if (timeoutMs > 0) {\n timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);\n }\n\n try {\n await fut.await;\n return ws;\n } catch (e) {\n try {\n ws.on('error', () => {});\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.close();\n } else {\n ws.terminate();\n }\n } catch {\n // ignore\n }\n throw e;\n } finally {\n cleanup();\n }\n};\n"],"mappings":"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","names":["tts"]} | ||
| {"version":3,"sources":["../src/tts.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport {\n type APIConnectOptions,\n APIConnectionError,\n APIStatusError,\n AudioByteStream,\n ConnectionPool,\n Future,\n log,\n shortuuid,\n tokenize,\n tts,\n} from '@livekit/agents';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { request } from 'node:https';\nimport { type RawData, WebSocket } from 'ws';\nimport type { LatencyMode, MP3Bitrate, OpusBitrate, TTSModels } from './models.js';\n\nconst DEFAULT_MODEL: TTSModels = 's2.1-pro';\nconst DEFAULT_VOICE_ID = '933563129e564b19a115bedd57b7406a';\nconst DEFAULT_BASE_URL = 'https://api.fish.audio';\nconst NUM_CHANNELS = 1;\n// Fish Audio's default sample rate for raw PCM output.\nconst DEFAULT_SAMPLE_RATE = 24000;\n\nconst connectionPools = new WeakMap<TTS, ConnectionPool<WebSocket>>();\n\n/** @public */\nexport interface TTSOptions {\n apiKey?: string;\n model?: TTSModels | string;\n voiceId?: string;\n sampleRate?: number;\n baseURL?: string;\n latencyMode?: LatencyMode;\n /**\n * Upper bound on the number of characters Fish buffers before auto-synthesizing.\n * Must be between 100 and 300. With sentence-level flushing this is only hit by\n * sentences longer than `chunkLength`; otherwise audio is produced as soon as\n * each sentence is flushed. Defaults to 100.\n */\n chunkLength?: number;\n /**\n * Speaking rate multiplier for Fish `prosody.speed`. `1.0` is normal; below\n * 1.0 is slower, above is faster. Unset uses the voice's natural pace.\n */\n speed?: number;\n /**\n * Loudness adjustment in decibels for Fish `prosody.volume`. `0` is the\n * voice's natural level. Unset leaves it unchanged.\n */\n volume?: number;\n /**\n * Sampling temperature (0-1). Higher values produce more varied, expressive\n * speech; lower values are more stable. Defaults to 0.7.\n */\n temperature?: number;\n /** Nucleus sampling probability mass (0-1). Defaults to 0.7. */\n topP?: number;\n /**\n * MP3 bitrate request field in kbps: 64, 128, or 192. Defaults to 64.\n * The JS plugin currently requests PCM output, so this shapes the Fish Audio\n * request but does not change the emitted audio format or PCM bandwidth.\n */\n mp3Bitrate?: MP3Bitrate;\n /**\n * Opus bitrate request field in bps: -1000 (auto), 24000, 32000, 48000, or\n * 64000. Defaults to 64000. The JS plugin currently requests PCM output, so\n * this does not change the emitted audio format or PCM bandwidth.\n */\n opusBitrate?: OpusBitrate;\n /** Whether Fish normalizes the input text before synthesis. Defaults to true. */\n normalize?: boolean;\n tokenizer?: tokenize.SentenceTokenizer;\n}\n\ninterface ResolvedTTSOptions {\n apiKey: string;\n model: TTSModels | string;\n voiceId?: string;\n sampleRate: number;\n baseURL: string;\n latencyMode: LatencyMode;\n chunkLength: number;\n speed?: number;\n volume?: number;\n temperature: number;\n topP: number;\n mp3Bitrate: MP3Bitrate;\n opusBitrate: OpusBitrate;\n normalize: boolean;\n tokenizer: tokenize.SentenceTokenizer;\n}\n\nconst DEFAULT_OPTS: Omit<ResolvedTTSOptions, 'apiKey' | 'tokenizer'> = {\n model: DEFAULT_MODEL,\n voiceId: DEFAULT_VOICE_ID,\n sampleRate: DEFAULT_SAMPLE_RATE,\n baseURL: DEFAULT_BASE_URL,\n latencyMode: 'balanced',\n chunkLength: 100,\n temperature: 0.7,\n topP: 0.7,\n mp3Bitrate: 64,\n opusBitrate: 64000,\n normalize: true,\n};\n\nconst validateChunkLength = (chunkLength: number) => {\n if (!Number.isFinite(chunkLength) || chunkLength < 100 || chunkLength > 300) {\n throw new Error('chunkLength must be between 100 and 300');\n }\n};\n\nconst validateProbability = (name: string, value: number) => {\n if (!Number.isFinite(value) || value < 0 || value > 1) {\n throw new Error(`${name} must be between 0 and 1`);\n }\n};\n\n// Fish Audio's wire format mirrors the upstream Python SDK so the server\n// doesn't fall back to its own larger defaults — in particular the docs default\n// of `chunk_length=300` produces large bursts that leave audible gaps between\n// chunk boundaries.\nconst buildTtsRequest = (opts: ResolvedTTSOptions, text: string = ''): Record<string, unknown> => {\n const prosody =\n opts.speed !== undefined || opts.volume !== undefined\n ? {\n ...(opts.speed !== undefined ? { speed: opts.speed } : {}),\n ...(opts.volume !== undefined ? { volume: opts.volume } : {}),\n }\n : null;\n\n return {\n text,\n chunk_length: opts.chunkLength,\n format: 'pcm',\n sample_rate: opts.sampleRate,\n mp3_bitrate: opts.mp3Bitrate,\n opus_bitrate: opts.opusBitrate,\n references: [],\n // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on\n // the plugin for consistency with other TTS plugins.\n reference_id: opts.voiceId ?? null,\n normalize: opts.normalize,\n latency: opts.latencyMode,\n prosody,\n top_p: opts.topP,\n temperature: opts.temperature,\n };\n};\n\n/** @public */\nexport class TTS extends tts.TTS {\n #opts: ResolvedTTSOptions;\n #pool: ConnectionPool<WebSocket>;\n #closed = false;\n label = 'fishaudio.TTS';\n\n constructor(opts: TTSOptions = {}) {\n const apiKey = opts.apiKey ?? process.env.FISH_API_KEY;\n if (!apiKey) {\n throw new Error(\n 'Fish Audio API key is required, either as argument or set FISH_API_KEY environment variable',\n );\n }\n\n const chunkLength = opts.chunkLength ?? DEFAULT_OPTS.chunkLength;\n validateChunkLength(chunkLength);\n\n const temperature = opts.temperature ?? DEFAULT_OPTS.temperature;\n validateProbability('temperature', temperature);\n const topP = opts.topP ?? DEFAULT_OPTS.topP;\n validateProbability('topP', topP);\n\n const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate;\n\n super(sampleRate, NUM_CHANNELS, { streaming: true });\n\n // min_sentence_len=1 emits each sentence as soon as the next one starts,\n // rather than batching short sentences together — minimizes TTFB on the\n // first sentence and keeps Fish synthesizing continuously.\n const tokenizer =\n opts.tokenizer ?? new tokenize.basic.SentenceTokenizer({ minSentenceLength: 1 });\n\n this.#opts = {\n apiKey,\n model: opts.model ?? DEFAULT_OPTS.model,\n voiceId: opts.voiceId ?? DEFAULT_OPTS.voiceId,\n sampleRate,\n baseURL: opts.baseURL ?? DEFAULT_OPTS.baseURL,\n latencyMode: opts.latencyMode ?? DEFAULT_OPTS.latencyMode,\n chunkLength,\n speed: opts.speed,\n volume: opts.volume,\n temperature,\n topP,\n mp3Bitrate: opts.mp3Bitrate ?? DEFAULT_OPTS.mp3Bitrate,\n opusBitrate: opts.opusBitrate ?? DEFAULT_OPTS.opusBitrate,\n normalize: opts.normalize ?? DEFAULT_OPTS.normalize,\n tokenizer,\n };\n\n this.#pool = new ConnectionPool<WebSocket>({\n connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs),\n closeCb: async (ws) => closeWebSocket(ws),\n maxSessionDuration: 300_000,\n markRefreshedOnGet: true,\n });\n connectionPools.set(this, this.#pool);\n }\n\n get model(): string {\n return this.#opts.model;\n }\n\n get provider(): string {\n return 'FishAudio';\n }\n\n updateOptions(opts: {\n model?: TTSModels | string;\n voiceId?: string;\n latencyMode?: LatencyMode;\n chunkLength?: number;\n speed?: number;\n volume?: number;\n temperature?: number;\n topP?: number;\n mp3Bitrate?: MP3Bitrate;\n opusBitrate?: OpusBitrate;\n normalize?: boolean;\n }): void {\n if (opts.model !== undefined && opts.model !== this.#opts.model) {\n this.#opts.model = opts.model;\n // The model is sent as a connection header at ws-handshake time, not in the\n // per-request body, so a pooled socket keeps the old model.\n this.#pool.invalidate();\n }\n if (opts.voiceId !== undefined) this.#opts.voiceId = opts.voiceId;\n if (opts.latencyMode !== undefined) this.#opts.latencyMode = opts.latencyMode;\n if (opts.chunkLength !== undefined) {\n validateChunkLength(opts.chunkLength);\n this.#opts.chunkLength = opts.chunkLength;\n }\n if (opts.speed !== undefined) this.#opts.speed = opts.speed;\n if (opts.volume !== undefined) this.#opts.volume = opts.volume;\n if (opts.temperature !== undefined) {\n validateProbability('temperature', opts.temperature);\n this.#opts.temperature = opts.temperature;\n }\n if (opts.topP !== undefined) {\n validateProbability('topP', opts.topP);\n this.#opts.topP = opts.topP;\n }\n if (opts.mp3Bitrate !== undefined) this.#opts.mp3Bitrate = opts.mp3Bitrate;\n if (opts.opusBitrate !== undefined) this.#opts.opusBitrate = opts.opusBitrate;\n if (opts.normalize !== undefined) this.#opts.normalize = opts.normalize;\n }\n\n synthesize(\n text: string,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ): tts.ChunkedStream {\n return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal);\n }\n\n stream(options?: { connOptions?: APIConnectOptions }): tts.SynthesizeStream {\n return new SynthesizeStream(this, { ...this.#opts }, options?.connOptions);\n }\n\n prewarm(): void {\n this.#pool.prewarm();\n }\n\n override async close(): Promise<void> {\n this.#closed = true;\n await this.#pool.close();\n await super.close();\n }\n\n async #connectWebSocket(timeoutMs: number): Promise<WebSocket> {\n const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`;\n const model = this.#opts.model;\n const ws = await connectWebSocket({\n url: wsUrl,\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n model,\n },\n timeoutMs,\n });\n if (this.#closed) {\n closeWebSocket(ws);\n throw new APIConnectionError({ message: 'Fish Audio TTS is closed' });\n }\n if (model !== this.#opts.model) {\n closeWebSocket(ws);\n return await this.#connectWebSocket(timeoutMs);\n }\n return ws;\n }\n}\n\n/** @public */\nexport class ChunkedStream extends tts.ChunkedStream {\n label = 'fishaudio.ChunkedStream';\n #logger = log();\n #opts: ResolvedTTSOptions;\n #text: string;\n\n constructor(\n tts: TTS,\n text: string,\n opts: ResolvedTTSOptions,\n connOptions?: APIConnectOptions,\n abortSignal?: AbortSignal,\n ) {\n super(text, tts, connOptions, abortSignal);\n this.#text = text;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n const payload = encode(buildTtsRequest(this.#opts, this.#text));\n\n const baseUrl = new URL(this.#opts.baseURL);\n const isHttps = baseUrl.protocol === 'https:';\n if (!isHttps) {\n // The plugin only supports https; fall back via Node's http module is\n // intentionally not implemented to keep the code path simple.\n throw new APIConnectionError({\n message: `Fish Audio base URL must use https (got ${this.#opts.baseURL})`,\n });\n }\n\n const doneFut = new Future<void>();\n\n const req = request(\n {\n hostname: baseUrl.hostname,\n port: parseInt(baseUrl.port) || 443,\n path: '/v1/tts',\n method: 'POST',\n headers: {\n Authorization: `Bearer ${this.#opts.apiKey}`,\n 'Content-Type': 'application/msgpack',\n model: this.#opts.model,\n 'Content-Length': payload.byteLength,\n },\n signal: this.abortSignal,\n },\n (res) => {\n const status = res.statusCode ?? -1;\n if (status < 200 || status >= 300) {\n const chunks: Buffer[] = [];\n res.on('data', (c: Buffer) => chunks.push(c));\n res.on('end', () => {\n const body = Buffer.concat(chunks).toString();\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed: ${body}`,\n options: { statusCode: status, body: { raw: body } },\n }),\n );\n }\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS error response stream error');\n if (!doneFut.done) {\n doneFut.reject(\n new APIStatusError({\n message: `Fish Audio TTS request failed (status ${status})`,\n options: { statusCode: status },\n }),\n );\n }\n });\n return;\n }\n\n res.on('data', (chunk: Buffer) => {\n for (const frame of bstream.write(chunk)) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n });\n res.on('close', () => {\n for (const frame of bstream.flush()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: false,\n });\n }\n if (!this.queue.closed) this.queue.close();\n if (!doneFut.done) doneFut.resolve();\n });\n res.on('error', (err) => {\n if (err.message === 'aborted') return;\n this.#logger.error({ err }, 'Fish Audio TTS response error');\n if (!doneFut.done) doneFut.reject(err);\n });\n },\n );\n\n req.on('error', (err) => {\n if (err.name === 'AbortError') return;\n this.#logger.error({ err }, 'Fish Audio TTS request error');\n if (!doneFut.done) doneFut.reject(err);\n });\n req.write(payload);\n req.end();\n\n try {\n await doneFut.await;\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (!this.queue.closed) this.queue.close();\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio connection failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n }\n }\n}\n\n/** @public */\nexport class SynthesizeStream extends tts.SynthesizeStream {\n label = 'fishaudio.SynthesizeStream';\n #logger = log();\n #pool: ConnectionPool<WebSocket>;\n #opts: ResolvedTTSOptions;\n\n constructor(tts: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {\n super(tts, connOptions);\n const pool = connectionPools.get(tts);\n if (!pool) throw new Error('Fish Audio connection pool is not initialized');\n this.#pool = pool;\n this.#opts = opts;\n }\n\n protected async run() {\n const requestId = shortuuid();\n const bstream = new AudioByteStream(this.#opts.sampleRate, NUM_CHANNELS);\n\n // Tokenize incoming text by sentence and flush after each sentence so Fish\n // synthesizes immediately at sentence boundaries instead of waiting for\n // `chunkLength` characters to accumulate. The result is much smoother\n // audio: gaps line up with sentence breaks (where pauses are natural)\n // rather than mid-clause.\n const sentStream = this.#opts.tokenizer.stream();\n\n const finished = new Future<void>();\n\n const inputTask = async () => {\n try {\n for await (const data of this.input) {\n if (this.abortController.signal.aborted) break;\n if (data === SynthesizeStream.FLUSH_SENTINEL) {\n sentStream.flush();\n continue;\n }\n if (!data) continue;\n sentStream.pushText(data);\n }\n } finally {\n if (!sentStream.closed) sentStream.endInput();\n }\n };\n\n const sendTask = async (ws: WebSocket) => {\n const startMsg = { event: 'start', request: buildTtsRequest(this.#opts) };\n ws.send(Buffer.from(encode(startMsg)));\n\n for await (const ev of sentStream) {\n if (this.abortController.signal.aborted) break;\n const sentence = ev.token;\n if (!sentence) continue;\n this.markStarted();\n ws.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' })));\n ws.send(Buffer.from(encode({ event: 'flush' })));\n }\n\n if (!this.abortController.signal.aborted) {\n ws.send(Buffer.from(encode({ event: 'stop' })));\n }\n };\n\n const recvTask = async (ws: WebSocket) => {\n // No per-receive timeout: Fish has natural inter-sentence gaps that can\n // exceed connOptions.timeoutMs when the LLM is slow.\n const onMessage = (raw: RawData) => {\n let frame: Buffer;\n if (Buffer.isBuffer(raw)) {\n frame = raw;\n } else if (Array.isArray(raw)) {\n frame = Buffer.concat(raw);\n } else {\n frame = Buffer.from(raw as ArrayBuffer);\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = decode(frame) as Record<string, unknown>;\n } catch (err) {\n this.#logger.warn({ err }, 'Fish Audio failed to decode message');\n return;\n }\n\n const event = parsed.event as string | undefined;\n if (event === 'audio') {\n const audio = parsed.audio as Uint8Array | undefined;\n if (audio && audio.byteLength > 0) {\n for (const frame of bstream.write(audio)) {\n this.queue.put({ requestId, segmentId: requestId, frame, final: false });\n }\n }\n } else if (event === 'finish') {\n const reason = parsed.reason as string | undefined;\n if (reason === 'error') {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio TTS reported an error',\n options: { body: { raw: JSON.stringify(parsed) } },\n }),\n );\n return;\n }\n const remainingFrames = [...bstream.flush()];\n for (const [idx, frame] of remainingFrames.entries()) {\n this.queue.put({\n requestId,\n segmentId: requestId,\n frame,\n final: idx === remainingFrames.length - 1,\n });\n }\n if (!this.queue.closed) {\n this.queue.put(SynthesizeStream.END_OF_STREAM);\n }\n if (!finished.done) finished.resolve();\n } else {\n this.#logger.debug({ event }, 'unknown Fish Audio event');\n }\n };\n\n const onClose = (code: number, reason: Buffer) => {\n if (!finished.done) {\n finished.reject(\n new APIStatusError({\n message: 'Fish Audio websocket connection closed unexpectedly',\n options: {\n statusCode: code || -1,\n body: { reason: reason.toString() },\n },\n }),\n );\n }\n };\n\n const onError = (err: Error) => {\n if (!finished.done) finished.reject(err);\n };\n\n ws.on('message', onMessage);\n ws.on('close', onClose);\n ws.on('error', onError);\n\n try {\n await finished.await;\n } finally {\n ws.off('message', onMessage);\n ws.off('close', onClose);\n ws.off('error', onError);\n }\n };\n\n try {\n await this.#pool.withConnection(\n async (ws) => {\n if (ws.readyState !== WebSocket.OPEN) {\n throw new APIConnectionError({\n message: 'Fish Audio pooled websocket is not open',\n });\n }\n await Promise.all([inputTask(), sendTask(ws), recvTask(ws)]);\n },\n { timeout: this.connOptions.timeoutMs, signal: this.abortSignal },\n );\n } catch (e) {\n if (this.abortSignal.aborted) return;\n if (e instanceof APIStatusError || e instanceof APIConnectionError) {\n throw e;\n }\n throw new APIConnectionError({\n message: `Fish Audio websocket failed: ${(e as Error).message ?? 'unknown error'}`,\n });\n } finally {\n if (!sentStream.closed) sentStream.close();\n }\n }\n}\n\nconst connectWebSocket = async ({\n url,\n headers,\n timeoutMs,\n abortSignal,\n}: {\n url: string;\n headers: Record<string, string>;\n timeoutMs: number;\n abortSignal?: AbortSignal;\n}): Promise<WebSocket> => {\n const ws = new WebSocket(url, { headers, handshakeTimeout: timeoutMs });\n const fut = new Future<void>();\n\n let timeout: NodeJS.Timeout | undefined;\n const cleanup = () => {\n if (timeout) clearTimeout(timeout);\n ws.off('open', onOpen);\n ws.off('error', onError);\n ws.off('close', onClose);\n abortSignal?.removeEventListener('abort', onAbort);\n };\n\n const onOpen = () => fut.resolve();\n const onError = (err: Error) => fut.reject(err);\n const onClose = (code: number, reason: Buffer) =>\n fut.reject(\n new Error(`websocket closed before open (code=${code}, reason=${reason.toString()})`),\n );\n const onAbort = () => fut.reject(new Error('aborted'));\n\n ws.on('open', onOpen);\n ws.on('error', onError);\n ws.on('close', onClose);\n abortSignal?.addEventListener('abort', onAbort, { once: true });\n\n if (timeoutMs > 0) {\n timeout = setTimeout(() => fut.reject(new Error('connect timeout')), timeoutMs);\n }\n\n try {\n await fut.await;\n return ws;\n } catch (e) {\n try {\n closeWebSocket(ws);\n } catch {\n // ignore\n }\n throw e;\n } finally {\n cleanup();\n }\n};\n\nconst closeWebSocket = (ws: WebSocket) => {\n try {\n ws.on('error', () => {});\n if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {\n ws.close();\n } else if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {\n ws.terminate();\n }\n } catch {\n // ignore\n }\n};\n"],"mappings":"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","names":["tts","frame"]} |
+543
-1
| "use strict"; | ||
| var import_agents = require("@livekit/agents"); | ||
| var import_agents_plugin_openai = require("@livekit/agents-plugin-openai"); | ||
| var import_agents_plugins_test = require("@livekit/agents-plugins-test"); | ||
| var import_msgpack = require("@msgpack/msgpack"); | ||
| var import_node_events = require("node:events"); | ||
| var import_vitest = require("vitest"); | ||
| var import_ws = require("ws"); | ||
| var import_tts = require("./tts.cjs"); | ||
| const httpsBoundary = import_vitest.vi.hoisted(() => ({ payloads: [] })); | ||
| import_vitest.vi.mock("node:https", () => { | ||
| class TestEmitter { | ||
| handlers = /* @__PURE__ */ new Map(); | ||
| on(event, handler) { | ||
| const handlers = this.handlers.get(event) ?? []; | ||
| handlers.push(handler); | ||
| this.handlers.set(event, handlers); | ||
| return this; | ||
| } | ||
| emit(event, ...args) { | ||
| for (const handler of this.handlers.get(event) ?? []) handler(...args); | ||
| } | ||
| } | ||
| return { | ||
| request: (_options, callback) => { | ||
| const request = new TestEmitter(); | ||
| let payload = new Uint8Array(); | ||
| return Object.assign(request, { | ||
| write: (chunk) => { | ||
| payload = new Uint8Array(chunk); | ||
| }, | ||
| end: () => { | ||
| httpsBoundary.payloads.push(payload); | ||
| const response = Object.assign(new TestEmitter(), { statusCode: 200 }); | ||
| callback(response); | ||
| queueMicrotask(() => response.emit("close")); | ||
| } | ||
| }); | ||
| } | ||
| }; | ||
| }); | ||
| async function startWebSocketServer() { | ||
| const wss = new import_ws.WebSocketServer({ host: "127.0.0.1", port: 0 }); | ||
| await (0, import_node_events.once)(wss, "listening"); | ||
| const address = wss.address(); | ||
| return { wss, baseURL: `http://127.0.0.1:${address.port}` }; | ||
| } | ||
| async function closeWebSocketServer(wss) { | ||
| for (const client of wss.clients) { | ||
| client.close(); | ||
| } | ||
| await new Promise((resolve) => wss.close(() => resolve())); | ||
| } | ||
| async function waitFor(promise, timeoutMs = 1e3) { | ||
| let timeout; | ||
| try { | ||
| return await Promise.race([ | ||
| promise, | ||
| new Promise((_, reject) => { | ||
| timeout = setTimeout(() => reject(new Error("timed out waiting for promise")), timeoutMs); | ||
| }) | ||
| ]); | ||
| } finally { | ||
| if (timeout) clearTimeout(timeout); | ||
| } | ||
| } | ||
| async function startSynthesis(wss) { | ||
| const stopReceived = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") resolve(ws); | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: `http://127.0.0.1:${wss.address().port}` | ||
| }); | ||
| const stream = fishAudio.stream(); | ||
| stream.pushText("hello world."); | ||
| stream.endInput(); | ||
| return { fishAudio, stream, ws: await waitFor(stopReceived) }; | ||
| } | ||
| async function synthesizeTurn(fishAudio, text, connOptions) { | ||
| const stream = fishAudio.stream({ connOptions }); | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| try { | ||
| const events = []; | ||
| for await (const event of stream) { | ||
| if (event !== import_agents.tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| return events; | ||
| } finally { | ||
| stream.close(); | ||
| } | ||
| } | ||
| async function captureStartRequest(opts = {}, update) { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const startRequest = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "start") { | ||
| resolve(message.request); | ||
| } else if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL, ...opts }); | ||
| update == null ? void 0 : update(fishAudio); | ||
| const stream = fishAudio.stream(); | ||
| const drain = (async () => { | ||
| for await (const _event of stream) { | ||
| } | ||
| })(); | ||
| try { | ||
| stream.pushText("hello world."); | ||
| stream.endInput(); | ||
| const request = await waitFor(startRequest); | ||
| await waitFor(drain); | ||
| return request; | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| } | ||
| const hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY); | ||
| if (hasFishAudioConfig) { | ||
| (0, import_vitest.describe)("FishAudio", async () => { | ||
| await (0, import_agents_plugins_test.tts)(new import_tts.TTS(), new import_agents_plugin_openai.STT()); | ||
| await (0, import_agents_plugins_test.tts)(new import_tts.TTS(), new import_agents_plugin_openai.STT({ useRealtime: false })); | ||
| }); | ||
@@ -17,2 +143,418 @@ } else { | ||
| } | ||
| (0, import_vitest.describe)("FishAudio streaming", () => { | ||
| (0, import_vitest.it)("keeps sampling and encoding defaults unchanged", async () => { | ||
| const request = await captureStartRequest(); | ||
| (0, import_vitest.expect)(request.temperature).toBe(0.7); | ||
| (0, import_vitest.expect)(request.top_p).toBe(0.7); | ||
| (0, import_vitest.expect)(request.mp3_bitrate).toBe(64); | ||
| (0, import_vitest.expect)(request.opus_bitrate).toBe(64e3); | ||
| (0, import_vitest.expect)(request.normalize).toBe(true); | ||
| }); | ||
| (0, import_vitest.it)("sets sampling and encoding params from the constructor", async () => { | ||
| const request = await captureStartRequest({ | ||
| temperature: 0.3, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 32e3, | ||
| normalize: false | ||
| }); | ||
| (0, import_vitest.expect)(request.temperature).toBe(0.3); | ||
| (0, import_vitest.expect)(request.top_p).toBe(0.9); | ||
| (0, import_vitest.expect)(request.mp3_bitrate).toBe(192); | ||
| (0, import_vitest.expect)(request.opus_bitrate).toBe(32e3); | ||
| (0, import_vitest.expect)(request.normalize).toBe(false); | ||
| }); | ||
| (0, import_vitest.it)("sets sampling and encoding params from updateOptions", async () => { | ||
| const request = await captureStartRequest({}, (fishAudio) => { | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.5, | ||
| topP: 0.8, | ||
| mp3Bitrate: 128, | ||
| opusBitrate: -1e3, | ||
| normalize: false | ||
| }); | ||
| }); | ||
| (0, import_vitest.expect)(request.temperature).toBe(0.5); | ||
| (0, import_vitest.expect)(request.top_p).toBe(0.8); | ||
| (0, import_vitest.expect)(request.mp3_bitrate).toBe(128); | ||
| (0, import_vitest.expect)(request.opus_bitrate).toBe(-1e3); | ||
| (0, import_vitest.expect)(request.normalize).toBe(false); | ||
| }); | ||
| (0, import_vitest.it)("snapshots options when each stream is constructed", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const requests = []; | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "start") { | ||
| requests.push(message.request); | ||
| } else if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ | ||
| apiKey: "test-key", | ||
| baseURL, | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| normalize: false | ||
| }); | ||
| const streamA = fishAudio.stream(); | ||
| fishAudio.updateOptions({ temperature: 0.8, topP: 0.9, normalize: true }); | ||
| const streamB = fishAudio.stream(); | ||
| try { | ||
| for (const [stream, text] of [ | ||
| [streamA, "stream A."], | ||
| [streamB, "stream B."] | ||
| ]) { | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| for await (const _event of stream) { | ||
| } | ||
| } | ||
| (0, import_vitest.expect)(requests).toHaveLength(2); | ||
| (0, import_vitest.expect)(requests[0]).toMatchObject({ | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| normalize: false | ||
| }); | ||
| (0, import_vitest.expect)(requests[1]).toMatchObject({ | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| normalize: true | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("snapshots options for each chunked HTTP request at construction", async () => { | ||
| httpsBoundary.payloads.length = 0; | ||
| const fishAudio = new import_tts.TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: "https://fish.test", | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 24e3, | ||
| normalize: false | ||
| }); | ||
| const streamA = fishAudio.synthesize("stream A."); | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.8, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 64e3, | ||
| normalize: true | ||
| }); | ||
| const streamB = fishAudio.synthesize("stream B."); | ||
| try { | ||
| for (const stream of [streamA, streamB]) { | ||
| for await (const _event of stream) { | ||
| } | ||
| } | ||
| (0, import_vitest.expect)(httpsBoundary.payloads).toHaveLength(2); | ||
| (0, import_vitest.expect)((0, import_msgpack.decode)(httpsBoundary.payloads[0])).toMatchObject({ | ||
| text: "stream A.", | ||
| format: "pcm", | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 24e3, | ||
| normalize: false | ||
| }); | ||
| (0, import_vitest.expect)((0, import_msgpack.decode)(httpsBoundary.payloads[1])).toMatchObject({ | ||
| text: "stream B.", | ||
| format: "pcm", | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| mp3_bitrate: 192, | ||
| opus_bitrate: 64e3, | ||
| normalize: true | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("validates temperature and topP", async () => { | ||
| (0, import_vitest.expect)(() => new import_tts.TTS({ apiKey: "test-key", temperature: 1.5 })).toThrow( | ||
| "temperature must be between 0 and 1" | ||
| ); | ||
| (0, import_vitest.expect)(() => new import_tts.TTS({ apiKey: "test-key", topP: -0.1 })).toThrow( | ||
| "topP must be between 0 and 1" | ||
| ); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key" }); | ||
| try { | ||
| (0, import_vitest.expect)(() => fishAudio.updateOptions({ temperature: 1.5 })).toThrow( | ||
| "temperature must be between 0 and 1" | ||
| ); | ||
| (0, import_vitest.expect)(() => fishAudio.updateOptions({ topP: -0.1 })).toThrow("topP must be between 0 and 1"); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("reuses one websocket across sequential turns", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "first turn.")).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "second turn.")).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("prewarms and reuses the ready websocket", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| const connected = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| resolve(); | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(connected); | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "prewarmed turn.")).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("opens a websocket with the updated model after model changes", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const models = []; | ||
| wss.on("connection", (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === "string") models.push(model); | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL, model: "s2-pro" }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, "old model."); | ||
| fishAudio.updateOptions({ model: "s2.1-pro" }); | ||
| await synthesizeTurn(fishAudio, "new model."); | ||
| (0, import_vitest.expect)(models).toEqual(["s2-pro", "s2.1-pro"]); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("reconnects with the new model when an in-flight prewarm becomes stale", async () => { | ||
| let approveFirstUpgrade; | ||
| let firstUpgradeStarted; | ||
| const firstUpgrade = new Promise((resolve) => { | ||
| firstUpgradeStarted = resolve; | ||
| }); | ||
| let upgradeCount = 0; | ||
| const wss = new import_ws.WebSocketServer({ | ||
| host: "127.0.0.1", | ||
| port: 0, | ||
| verifyClient: (_info, approve) => { | ||
| upgradeCount++; | ||
| if (upgradeCount === 1) { | ||
| approveFirstUpgrade = () => approve(true); | ||
| firstUpgradeStarted == null ? void 0 : firstUpgradeStarted(); | ||
| return; | ||
| } | ||
| approve(true); | ||
| } | ||
| }); | ||
| await (0, import_node_events.once)(wss, "listening"); | ||
| const address = wss.address(); | ||
| if (address === null || typeof address === "string") { | ||
| throw new Error("expected websocket server to listen on a TCP port"); | ||
| } | ||
| const models = []; | ||
| wss.on("connection", (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === "string") models.push(model); | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: `http://127.0.0.1:${address.port}`, | ||
| model: "s2-pro" | ||
| }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(firstUpgrade); | ||
| fishAudio.updateOptions({ model: "s2.1-pro" }); | ||
| approveFirstUpgrade == null ? void 0 : approveFirstUpgrade(); | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "new model.")).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(models).toEqual(["s2-pro", "s2.1-pro"]); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("discards a failed websocket before the next turn", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event !== "stop") return; | ||
| if (connectionNumber === 1) { | ||
| ws.close(1011, "provider failure"); | ||
| return; | ||
| } | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| (0, import_vitest.expect)( | ||
| await synthesizeTurn(fishAudio, "failing turn.", { | ||
| ...import_agents.DEFAULT_API_CONNECT_OPTIONS, | ||
| maxRetry: 0 | ||
| }) | ||
| ).toHaveLength(0); | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "recovery turn.")).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("replaces a websocket that closed while idle", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| let firstConnectionClosed; | ||
| const firstClosed = new Promise((resolve) => { | ||
| firstConnectionClosed = resolve; | ||
| }); | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on("close", () => { | ||
| if (connectionNumber === 1) firstConnectionClosed == null ? void 0 : firstConnectionClosed(); | ||
| }); | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event !== "stop") return; | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| if (connectionNumber === 1) setTimeout(() => ws.close(), 10); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| (0, import_vitest.expect)(await synthesizeTurn(fishAudio, "first turn.")).not.toHaveLength(0); | ||
| await waitFor(firstClosed); | ||
| (0, import_vitest.expect)(await waitFor(synthesizeTurn(fishAudio, "second turn."))).not.toHaveLength(0); | ||
| (0, import_vitest.expect)(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("closes a pooled websocket when the TTS closes", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const closed = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("close", () => resolve()); | ||
| ws.on("message", (raw) => { | ||
| const message = (0, import_msgpack.decode)(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new import_tts.TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, "closing turn."); | ||
| await fishAudio.close(); | ||
| await waitFor(closed); | ||
| (0, import_vitest.expect)(wss.clients.size).toBe(0); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("emits the first complete frame before the next provider event", async () => { | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| const first = await waitFor(stream.next()); | ||
| (0, import_vitest.expect)(first.done).toBe(false); | ||
| (0, import_vitest.expect)(first.value).not.toBe(import_agents.tts.SynthesizeStream.END_OF_STREAM); | ||
| if (first.value !== import_agents.tts.SynthesizeStream.END_OF_STREAM) { | ||
| (0, import_vitest.expect)(first.value.frame.samplesPerChannel).toBe(2400); | ||
| (0, import_vitest.expect)(first.value.final).toBe(false); | ||
| } | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| for await (const _event of stream) { | ||
| } | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| (0, import_vitest.it)("marks the terminal frame final before ending the stream", async () => { | ||
| var _a; | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send((0, import_msgpack.encode)({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send((0, import_msgpack.encode)({ event: "finish", reason: "stop" })); | ||
| const events = []; | ||
| for await (const event of stream) { | ||
| if (event !== import_agents.tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| (0, import_vitest.expect)(events.length).toBeGreaterThan(0); | ||
| (0, import_vitest.expect)((_a = events.at(-1)) == null ? void 0 : _a.final).toBe(true); | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| }); | ||
| //# sourceMappingURL=tts.test.cjs.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/tts.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { STT } from '@livekit/agents-plugin-openai';\nimport { tts } from '@livekit/agents-plugins-test';\nimport { describe, it } from 'vitest';\nimport { TTS } from './tts.js';\n\nconst hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY);\n\nif (hasFishAudioConfig) {\n describe('FishAudio', async () => {\n await tts(new TTS(), new STT());\n });\n} else {\n describe('FishAudio', () => {\n it.skip('requires FISH_API_KEY and OPENAI_API_KEY', () => {});\n });\n}\n"],"mappings":";AAGA,kCAAoB;AACpB,iCAAoB;AACpB,oBAA6B;AAC7B,iBAAoB;AAEpB,MAAM,qBAAqB,QAAQ,QAAQ,IAAI,gBAAgB,QAAQ,IAAI,cAAc;AAEzF,IAAI,oBAAoB;AACtB,8BAAS,aAAa,YAAY;AAChC,cAAM,gCAAI,IAAI,eAAI,GAAG,IAAI,gCAAI,CAAC;AAAA,EAChC,CAAC;AACH,OAAO;AACL,8BAAS,aAAa,MAAM;AAC1B,qBAAG,KAAK,4CAA4C,MAAM;AAAA,IAAC,CAAC;AAAA,EAC9D,CAAC;AACH;","names":[]} | ||
| {"version":3,"sources":["../src/tts.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, tts } from '@livekit/agents';\nimport { STT } from '@livekit/agents-plugin-openai';\nimport { tts as testTts } from '@livekit/agents-plugins-test';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { once } from 'node:events';\nimport type { AddressInfo } from 'node:net';\nimport { describe, expect, it, vi } from 'vitest';\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport { TTS } from './tts.js';\n\nconst httpsBoundary = vi.hoisted<{ payloads: Uint8Array[] }>(() => ({ payloads: [] }));\n\nvi.mock('node:https', () => {\n class TestEmitter {\n readonly handlers = new Map<string, ((...args: unknown[]) => void)[]>();\n\n on(event: string, handler: (...args: unknown[]) => void): this {\n const handlers = this.handlers.get(event) ?? [];\n handlers.push(handler);\n this.handlers.set(event, handlers);\n return this;\n }\n\n emit(event: string, ...args: unknown[]): void {\n for (const handler of this.handlers.get(event) ?? []) handler(...args);\n }\n }\n\n return {\n request: (\n _options: unknown,\n callback: (response: TestEmitter & { statusCode: number }) => void,\n ) => {\n const request = new TestEmitter();\n let payload = new Uint8Array();\n return Object.assign(request, {\n write: (chunk: Uint8Array) => {\n payload = new Uint8Array(chunk);\n },\n end: () => {\n httpsBoundary.payloads.push(payload);\n const response = Object.assign(new TestEmitter(), { statusCode: 200 });\n callback(response);\n queueMicrotask(() => response.emit('close'));\n },\n });\n },\n };\n});\n\nasync function startWebSocketServer() {\n const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });\n await once(wss, 'listening');\n const address = wss.address() as AddressInfo;\n return { wss, baseURL: `http://127.0.0.1:${address.port}` };\n}\n\nasync function closeWebSocketServer(wss: WebSocketServer): Promise<void> {\n for (const client of wss.clients) {\n client.close();\n }\n await new Promise<void>((resolve) => wss.close(() => resolve()));\n}\n\nasync function waitFor<T>(promise: Promise<T>, timeoutMs = 1000): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('timed out waiting for promise')), timeoutMs);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nasync function startSynthesis(wss: WebSocketServer) {\n const stopReceived = new Promise<WebSocket>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') resolve(ws);\n });\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: `http://127.0.0.1:${(wss.address() as AddressInfo).port}`,\n });\n const stream = fishAudio.stream();\n stream.pushText('hello world.');\n stream.endInput();\n\n return { fishAudio, stream, ws: await waitFor(stopReceived) };\n}\n\nasync function synthesizeTurn(\n fishAudio: TTS,\n text: string,\n connOptions?: APIConnectOptions,\n): Promise<tts.SynthesizedAudio[]> {\n const stream = fishAudio.stream({ connOptions });\n stream.pushText(text);\n stream.endInput();\n\n try {\n const events: tts.SynthesizedAudio[] = [];\n for await (const event of stream) {\n if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event);\n }\n return events;\n } finally {\n stream.close();\n }\n}\n\nasync function captureStartRequest(\n opts: ConstructorParameters<typeof TTS>[0] = {},\n update?: (fishAudio: TTS) => void,\n): Promise<Record<string, unknown>> {\n const { wss, baseURL } = await startWebSocketServer();\n const startRequest = new Promise<Record<string, unknown>>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'start') {\n resolve(message.request as Record<string, unknown>);\n } else if (message.event === 'stop') {\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL, ...opts });\n update?.(fishAudio);\n const stream = fishAudio.stream();\n const drain = (async () => {\n for await (const _event of stream) {\n // Drain the stream so its tasks finish before cleanup.\n }\n })();\n\n try {\n stream.pushText('hello world.');\n stream.endInput();\n const request = await waitFor(startRequest);\n await waitFor(drain);\n return request;\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n}\n\nconst hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY);\n\nif (hasFishAudioConfig) {\n describe('FishAudio', async () => {\n await testTts(new TTS(), new STT({ useRealtime: false }));\n });\n} else {\n describe('FishAudio', () => {\n it.skip('requires FISH_API_KEY and OPENAI_API_KEY', () => {});\n });\n}\n\ndescribe('FishAudio streaming', () => {\n it('keeps sampling and encoding defaults unchanged', async () => {\n const request = await captureStartRequest();\n expect(request.temperature).toBe(0.7);\n expect(request.top_p).toBe(0.7);\n expect(request.mp3_bitrate).toBe(64);\n expect(request.opus_bitrate).toBe(64000);\n expect(request.normalize).toBe(true);\n });\n\n it('sets sampling and encoding params from the constructor', async () => {\n const request = await captureStartRequest({\n temperature: 0.3,\n topP: 0.9,\n mp3Bitrate: 192,\n opusBitrate: 32000,\n normalize: false,\n });\n expect(request.temperature).toBe(0.3);\n expect(request.top_p).toBe(0.9);\n expect(request.mp3_bitrate).toBe(192);\n expect(request.opus_bitrate).toBe(32000);\n expect(request.normalize).toBe(false);\n });\n\n it('sets sampling and encoding params from updateOptions', async () => {\n const request = await captureStartRequest({}, (fishAudio) => {\n fishAudio.updateOptions({\n temperature: 0.5,\n topP: 0.8,\n mp3Bitrate: 128,\n opusBitrate: -1000,\n normalize: false,\n });\n });\n expect(request.temperature).toBe(0.5);\n expect(request.top_p).toBe(0.8);\n expect(request.mp3_bitrate).toBe(128);\n expect(request.opus_bitrate).toBe(-1000);\n expect(request.normalize).toBe(false);\n });\n\n it('snapshots options when each stream is constructed', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const requests: Record<string, unknown>[] = [];\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'start') {\n requests.push(message.request as Record<string, unknown>);\n } else if (message.event === 'stop') {\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL,\n temperature: 0.2,\n topP: 0.3,\n normalize: false,\n });\n const streamA = fishAudio.stream();\n fishAudio.updateOptions({ temperature: 0.8, topP: 0.9, normalize: true });\n const streamB = fishAudio.stream();\n\n try {\n for (const [stream, text] of [\n [streamA, 'stream A.'],\n [streamB, 'stream B.'],\n ] as const) {\n stream.pushText(text);\n stream.endInput();\n for await (const _event of stream) {\n // Drain each stream so its tasks finish before checking the requests.\n }\n }\n\n expect(requests).toHaveLength(2);\n expect(requests[0]).toMatchObject({\n temperature: 0.2,\n top_p: 0.3,\n normalize: false,\n });\n expect(requests[1]).toMatchObject({\n temperature: 0.8,\n top_p: 0.9,\n normalize: true,\n });\n } finally {\n streamA.close();\n streamB.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('snapshots options for each chunked HTTP request at construction', async () => {\n httpsBoundary.payloads.length = 0;\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: 'https://fish.test',\n temperature: 0.2,\n topP: 0.3,\n mp3Bitrate: 64,\n opusBitrate: 24000,\n normalize: false,\n });\n const streamA = fishAudio.synthesize('stream A.');\n fishAudio.updateOptions({\n temperature: 0.8,\n topP: 0.9,\n mp3Bitrate: 192,\n opusBitrate: 64000,\n normalize: true,\n });\n const streamB = fishAudio.synthesize('stream B.');\n\n try {\n for (const stream of [streamA, streamB]) {\n for await (const _event of stream) {\n // Drain each stream so its HTTP request finishes before assertions.\n }\n }\n\n expect(httpsBoundary.payloads).toHaveLength(2);\n expect(decode(httpsBoundary.payloads[0]!)).toMatchObject({\n text: 'stream A.',\n format: 'pcm',\n temperature: 0.2,\n top_p: 0.3,\n mp3_bitrate: 64,\n opus_bitrate: 24000,\n normalize: false,\n });\n expect(decode(httpsBoundary.payloads[1]!)).toMatchObject({\n text: 'stream B.',\n format: 'pcm',\n temperature: 0.8,\n top_p: 0.9,\n mp3_bitrate: 192,\n opus_bitrate: 64000,\n normalize: true,\n });\n } finally {\n streamA.close();\n streamB.close();\n await fishAudio.close();\n }\n });\n\n it('validates temperature and topP', async () => {\n expect(() => new TTS({ apiKey: 'test-key', temperature: 1.5 })).toThrow(\n 'temperature must be between 0 and 1',\n );\n expect(() => new TTS({ apiKey: 'test-key', topP: -0.1 })).toThrow(\n 'topP must be between 0 and 1',\n );\n\n const fishAudio = new TTS({ apiKey: 'test-key' });\n try {\n expect(() => fishAudio.updateOptions({ temperature: 1.5 })).toThrow(\n 'temperature must be between 0 and 1',\n );\n expect(() => fishAudio.updateOptions({ topP: -0.1 })).toThrow('topP must be between 0 and 1');\n } finally {\n await fishAudio.close();\n }\n });\n\n it('reuses one websocket across sequential turns', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n\n wss.on('connection', (ws) => {\n connectionCount++;\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0);\n expect(await synthesizeTurn(fishAudio, 'second turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(1);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('prewarms and reuses the ready websocket', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n const connected = new Promise<void>((resolve) => {\n wss.on('connection', (ws) => {\n connectionCount++;\n resolve();\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n fishAudio.prewarm();\n await waitFor(connected);\n expect(await synthesizeTurn(fishAudio, 'prewarmed turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(1);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('opens a websocket with the updated model after model changes', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const models: string[] = [];\n\n wss.on('connection', (ws, request) => {\n const model = request.headers.model;\n if (typeof model === 'string') models.push(model);\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL, model: 's2-pro' });\n try {\n await synthesizeTurn(fishAudio, 'old model.');\n fishAudio.updateOptions({ model: 's2.1-pro' });\n await synthesizeTurn(fishAudio, 'new model.');\n expect(models).toEqual(['s2-pro', 's2.1-pro']);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('reconnects with the new model when an in-flight prewarm becomes stale', async () => {\n let approveFirstUpgrade: (() => void) | undefined;\n let firstUpgradeStarted: (() => void) | undefined;\n const firstUpgrade = new Promise<void>((resolve) => {\n firstUpgradeStarted = resolve;\n });\n let upgradeCount = 0;\n const wss = new WebSocketServer({\n host: '127.0.0.1',\n port: 0,\n verifyClient: (_info, approve) => {\n upgradeCount++;\n if (upgradeCount === 1) {\n approveFirstUpgrade = () => approve(true);\n firstUpgradeStarted?.();\n return;\n }\n approve(true);\n },\n });\n await once(wss, 'listening');\n const address = wss.address();\n if (address === null || typeof address === 'string') {\n throw new Error('expected websocket server to listen on a TCP port');\n }\n const models: string[] = [];\n wss.on('connection', (ws, request) => {\n const model = request.headers.model;\n if (typeof model === 'string') models.push(model);\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: `http://127.0.0.1:${address.port}`,\n model: 's2-pro',\n });\n try {\n fishAudio.prewarm();\n await waitFor(firstUpgrade);\n fishAudio.updateOptions({ model: 's2.1-pro' });\n approveFirstUpgrade?.();\n expect(await synthesizeTurn(fishAudio, 'new model.')).not.toHaveLength(0);\n expect(models).toEqual(['s2-pro', 's2.1-pro']);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('discards a failed websocket before the next turn', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n\n wss.on('connection', (ws) => {\n connectionCount++;\n const connectionNumber = connectionCount;\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event !== 'stop') return;\n if (connectionNumber === 1) {\n ws.close(1011, 'provider failure');\n return;\n }\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(\n await synthesizeTurn(fishAudio, 'failing turn.', {\n ...DEFAULT_API_CONNECT_OPTIONS,\n maxRetry: 0,\n }),\n ).toHaveLength(0);\n expect(await synthesizeTurn(fishAudio, 'recovery turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(2);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('replaces a websocket that closed while idle', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n let firstConnectionClosed: (() => void) | undefined;\n const firstClosed = new Promise<void>((resolve) => {\n firstConnectionClosed = resolve;\n });\n\n wss.on('connection', (ws) => {\n connectionCount++;\n const connectionNumber = connectionCount;\n ws.on('close', () => {\n if (connectionNumber === 1) firstConnectionClosed?.();\n });\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event !== 'stop') return;\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n if (connectionNumber === 1) setTimeout(() => ws.close(), 10);\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0);\n await waitFor(firstClosed);\n expect(await waitFor(synthesizeTurn(fishAudio, 'second turn.'))).not.toHaveLength(0);\n expect(connectionCount).toBe(2);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('closes a pooled websocket when the TTS closes', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const closed = new Promise<void>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('close', () => resolve());\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n await synthesizeTurn(fishAudio, 'closing turn.');\n await fishAudio.close();\n await waitFor(closed);\n expect(wss.clients.size).toBe(0);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('emits the first complete frame before the next provider event', async () => {\n const { wss } = await startWebSocketServer();\n const { fishAudio, stream, ws } = await startSynthesis(wss);\n\n try {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n\n const first = await waitFor(stream.next());\n expect(first.done).toBe(false);\n expect(first.value).not.toBe(tts.SynthesizeStream.END_OF_STREAM);\n if (first.value !== tts.SynthesizeStream.END_OF_STREAM) {\n expect(first.value.frame.samplesPerChannel).toBe(2400);\n expect(first.value.final).toBe(false);\n }\n\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n for await (const _event of stream) {\n // Drain the stream so its tasks finish before cleanup.\n }\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('marks the terminal frame final before ending the stream', async () => {\n const { wss } = await startWebSocketServer();\n const { fishAudio, stream, ws } = await startSynthesis(wss);\n\n try {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n\n const events: tts.SynthesizedAudio[] = [];\n for await (const event of stream) {\n if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event);\n }\n\n expect(events.length).toBeGreaterThan(0);\n expect(events.at(-1)?.final).toBe(true);\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n});\n"],"mappings":";AAGA,oBAAyE;AACzE,kCAAoB;AACpB,iCAA+B;AAC/B,qBAA+B;AAC/B,yBAAqB;AAErB,oBAAyC;AACzC,gBAAgD;AAChD,iBAAoB;AAEpB,MAAM,gBAAgB,iBAAG,QAAoC,OAAO,EAAE,UAAU,CAAC,EAAE,EAAE;AAErF,iBAAG,KAAK,cAAc,MAAM;AAAA,EAC1B,MAAM,YAAY;AAAA,IACP,WAAW,oBAAI,IAA8C;AAAA,IAEtE,GAAG,OAAe,SAA6C;AAC7D,YAAM,WAAW,KAAK,SAAS,IAAI,KAAK,KAAK,CAAC;AAC9C,eAAS,KAAK,OAAO;AACrB,WAAK,SAAS,IAAI,OAAO,QAAQ;AACjC,aAAO;AAAA,IACT;AAAA,IAEA,KAAK,UAAkB,MAAuB;AAC5C,iBAAW,WAAW,KAAK,SAAS,IAAI,KAAK,KAAK,CAAC,EAAG,SAAQ,GAAG,IAAI;AAAA,IACvE;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS,CACP,UACA,aACG;AACH,YAAM,UAAU,IAAI,YAAY;AAChC,UAAI,UAAU,IAAI,WAAW;AAC7B,aAAO,OAAO,OAAO,SAAS;AAAA,QAC5B,OAAO,CAAC,UAAsB;AAC5B,oBAAU,IAAI,WAAW,KAAK;AAAA,QAChC;AAAA,QACA,KAAK,MAAM;AACT,wBAAc,SAAS,KAAK,OAAO;AACnC,gBAAM,WAAW,OAAO,OAAO,IAAI,YAAY,GAAG,EAAE,YAAY,IAAI,CAAC;AACrE,mBAAS,QAAQ;AACjB,yBAAe,MAAM,SAAS,KAAK,OAAO,CAAC;AAAA,QAC7C;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACF,CAAC;AAED,eAAe,uBAAuB;AACpC,QAAM,MAAM,IAAI,0BAAgB,EAAE,MAAM,aAAa,MAAM,EAAE,CAAC;AAC9D,YAAM,yBAAK,KAAK,WAAW;AAC3B,QAAM,UAAU,IAAI,QAAQ;AAC5B,SAAO,EAAE,KAAK,SAAS,oBAAoB,QAAQ,IAAI,GAAG;AAC5D;AAEA,eAAe,qBAAqB,KAAqC;AACvE,aAAW,UAAU,IAAI,SAAS;AAChC,WAAO,MAAM;AAAA,EACf;AACA,QAAM,IAAI,QAAc,CAAC,YAAY,IAAI,MAAM,MAAM,QAAQ,CAAC,CAAC;AACjE;AAEA,eAAe,QAAW,SAAqB,YAAY,KAAkB;AAC3E,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,QAAQ,KAAK;AAAA,MACxB;AAAA,MACA,IAAI,QAAe,CAAC,GAAG,WAAW;AAChC,kBAAU,WAAW,MAAM,OAAO,IAAI,MAAM,+BAA+B,CAAC,GAAG,SAAS;AAAA,MAC1F,CAAC;AAAA,IACH,CAAC;AAAA,EACH,UAAE;AACA,QAAI,QAAS,cAAa,OAAO;AAAA,EACnC;AACF;AAEA,eAAe,eAAe,KAAsB;AAClD,QAAM,eAAe,IAAI,QAAmB,CAAC,YAAY;AACvD,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,OAAQ,SAAQ,EAAE;AAAA,MAC1C,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AAED,QAAM,YAAY,IAAI,eAAI;AAAA,IACxB,QAAQ;AAAA,IACR,SAAS,oBAAqB,IAAI,QAAQ,EAAkB,IAAI;AAAA,EAClE,CAAC;AACD,QAAM,SAAS,UAAU,OAAO;AAChC,SAAO,SAAS,cAAc;AAC9B,SAAO,SAAS;AAEhB,SAAO,EAAE,WAAW,QAAQ,IAAI,MAAM,QAAQ,YAAY,EAAE;AAC9D;AAEA,eAAe,eACb,WACA,MACA,aACiC;AACjC,QAAM,SAAS,UAAU,OAAO,EAAE,YAAY,CAAC;AAC/C,SAAO,SAAS,IAAI;AACpB,SAAO,SAAS;AAEhB,MAAI;AACF,UAAM,SAAiC,CAAC;AACxC,qBAAiB,SAAS,QAAQ;AAChC,UAAI,UAAU,kBAAI,iBAAiB,cAAe,QAAO,KAAK,KAAK;AAAA,IACrE;AACA,WAAO;AAAA,EACT,UAAE;AACA,WAAO,MAAM;AAAA,EACf;AACF;AAEA,eAAe,oBACb,OAA6C,CAAC,GAC9C,QACkC;AAClC,QAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,QAAM,eAAe,IAAI,QAAiC,CAAC,YAAY;AACrE,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,SAAS;AAC7B,kBAAQ,QAAQ,OAAkC;AAAA,QACpD,WAAW,QAAQ,UAAU,QAAQ;AACnC,aAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,QACrD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH,CAAC;AAED,QAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,SAAS,GAAG,KAAK,CAAC;AAClE,mCAAS;AACT,QAAM,SAAS,UAAU,OAAO;AAChC,QAAM,SAAS,YAAY;AACzB,qBAAiB,UAAU,QAAQ;AAAA,IAEnC;AAAA,EACF,GAAG;AAEH,MAAI;AACF,WAAO,SAAS,cAAc;AAC9B,WAAO,SAAS;AAChB,UAAM,UAAU,MAAM,QAAQ,YAAY;AAC1C,UAAM,QAAQ,KAAK;AACnB,WAAO;AAAA,EACT,UAAE;AACA,WAAO,MAAM;AACb,UAAM,UAAU,MAAM;AACtB,UAAM,qBAAqB,GAAG;AAAA,EAChC;AACF;AAEA,MAAM,qBAAqB,QAAQ,QAAQ,IAAI,gBAAgB,QAAQ,IAAI,cAAc;AAEzF,IAAI,oBAAoB;AACtB,8BAAS,aAAa,YAAY;AAChC,cAAM,2BAAAA,KAAQ,IAAI,eAAI,GAAG,IAAI,gCAAI,EAAE,aAAa,MAAM,CAAC,CAAC;AAAA,EAC1D,CAAC;AACH,OAAO;AACL,8BAAS,aAAa,MAAM;AAC1B,qBAAG,KAAK,4CAA4C,MAAM;AAAA,IAAC,CAAC;AAAA,EAC9D,CAAC;AACH;AAAA,IAEA,wBAAS,uBAAuB,MAAM;AACpC,wBAAG,kDAAkD,YAAY;AAC/D,UAAM,UAAU,MAAM,oBAAoB;AAC1C,8BAAO,QAAQ,WAAW,EAAE,KAAK,GAAG;AACpC,8BAAO,QAAQ,KAAK,EAAE,KAAK,GAAG;AAC9B,8BAAO,QAAQ,WAAW,EAAE,KAAK,EAAE;AACnC,8BAAO,QAAQ,YAAY,EAAE,KAAK,IAAK;AACvC,8BAAO,QAAQ,SAAS,EAAE,KAAK,IAAI;AAAA,EACrC,CAAC;AAED,wBAAG,0DAA0D,YAAY;AACvE,UAAM,UAAU,MAAM,oBAAoB;AAAA,MACxC,aAAa;AAAA,MACb,MAAM;AAAA,MACN,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,WAAW;AAAA,IACb,CAAC;AACD,8BAAO,QAAQ,WAAW,EAAE,KAAK,GAAG;AACpC,8BAAO,QAAQ,KAAK,EAAE,KAAK,GAAG;AAC9B,8BAAO,QAAQ,WAAW,EAAE,KAAK,GAAG;AACpC,8BAAO,QAAQ,YAAY,EAAE,KAAK,IAAK;AACvC,8BAAO,QAAQ,SAAS,EAAE,KAAK,KAAK;AAAA,EACtC,CAAC;AAED,wBAAG,wDAAwD,YAAY;AACrE,UAAM,UAAU,MAAM,oBAAoB,CAAC,GAAG,CAAC,cAAc;AAC3D,gBAAU,cAAc;AAAA,QACtB,aAAa;AAAA,QACb,MAAM;AAAA,QACN,YAAY;AAAA,QACZ,aAAa;AAAA,QACb,WAAW;AAAA,MACb,CAAC;AAAA,IACH,CAAC;AACD,8BAAO,QAAQ,WAAW,EAAE,KAAK,GAAG;AACpC,8BAAO,QAAQ,KAAK,EAAE,KAAK,GAAG;AAC9B,8BAAO,QAAQ,WAAW,EAAE,KAAK,GAAG;AACpC,8BAAO,QAAQ,YAAY,EAAE,KAAK,IAAK;AACvC,8BAAO,QAAQ,SAAS,EAAE,KAAK,KAAK;AAAA,EACtC,CAAC;AAED,wBAAG,qDAAqD,YAAY;AAClE,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,UAAM,WAAsC,CAAC;AAC7C,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,SAAS;AAC7B,mBAAS,KAAK,QAAQ,OAAkC;AAAA,QAC1D,WAAW,QAAQ,UAAU,QAAQ;AACnC,aAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,QACrD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI;AAAA,MACxB,QAAQ;AAAA,MACR;AAAA,MACA,aAAa;AAAA,MACb,MAAM;AAAA,MACN,WAAW;AAAA,IACb,CAAC;AACD,UAAM,UAAU,UAAU,OAAO;AACjC,cAAU,cAAc,EAAE,aAAa,KAAK,MAAM,KAAK,WAAW,KAAK,CAAC;AACxE,UAAM,UAAU,UAAU,OAAO;AAEjC,QAAI;AACF,iBAAW,CAAC,QAAQ,IAAI,KAAK;AAAA,QAC3B,CAAC,SAAS,WAAW;AAAA,QACrB,CAAC,SAAS,WAAW;AAAA,MACvB,GAAY;AACV,eAAO,SAAS,IAAI;AACpB,eAAO,SAAS;AAChB,yBAAiB,UAAU,QAAQ;AAAA,QAEnC;AAAA,MACF;AAEA,gCAAO,QAAQ,EAAE,aAAa,CAAC;AAC/B,gCAAO,SAAS,CAAC,CAAC,EAAE,cAAc;AAAA,QAChC,aAAa;AAAA,QACb,OAAO;AAAA,QACP,WAAW;AAAA,MACb,CAAC;AACD,gCAAO,SAAS,CAAC,CAAC,EAAE,cAAc;AAAA,QAChC,aAAa;AAAA,QACb,OAAO;AAAA,QACP,WAAW;AAAA,MACb,CAAC;AAAA,IACH,UAAE;AACA,cAAQ,MAAM;AACd,cAAQ,MAAM;AACd,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,mEAAmE,YAAY;AAChF,kBAAc,SAAS,SAAS;AAChC,UAAM,YAAY,IAAI,eAAI;AAAA,MACxB,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA,MACN,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,WAAW;AAAA,IACb,CAAC;AACD,UAAM,UAAU,UAAU,WAAW,WAAW;AAChD,cAAU,cAAc;AAAA,MACtB,aAAa;AAAA,MACb,MAAM;AAAA,MACN,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,WAAW;AAAA,IACb,CAAC;AACD,UAAM,UAAU,UAAU,WAAW,WAAW;AAEhD,QAAI;AACF,iBAAW,UAAU,CAAC,SAAS,OAAO,GAAG;AACvC,yBAAiB,UAAU,QAAQ;AAAA,QAEnC;AAAA,MACF;AAEA,gCAAO,cAAc,QAAQ,EAAE,aAAa,CAAC;AAC7C,oCAAO,uBAAO,cAAc,SAAS,CAAC,CAAE,CAAC,EAAE,cAAc;AAAA,QACvD,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,OAAO;AAAA,QACP,aAAa;AAAA,QACb,cAAc;AAAA,QACd,WAAW;AAAA,MACb,CAAC;AACD,oCAAO,uBAAO,cAAc,SAAS,CAAC,CAAE,CAAC,EAAE,cAAc;AAAA,QACvD,MAAM;AAAA,QACN,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,OAAO;AAAA,QACP,aAAa;AAAA,QACb,cAAc;AAAA,QACd,WAAW;AAAA,MACb,CAAC;AAAA,IACH,UAAE;AACA,cAAQ,MAAM;AACd,cAAQ,MAAM;AACd,YAAM,UAAU,MAAM;AAAA,IACxB;AAAA,EACF,CAAC;AAED,wBAAG,kCAAkC,YAAY;AAC/C,8BAAO,MAAM,IAAI,eAAI,EAAE,QAAQ,YAAY,aAAa,IAAI,CAAC,CAAC,EAAE;AAAA,MAC9D;AAAA,IACF;AACA,8BAAO,MAAM,IAAI,eAAI,EAAE,QAAQ,YAAY,MAAM,KAAK,CAAC,CAAC,EAAE;AAAA,MACxD;AAAA,IACF;AAEA,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,WAAW,CAAC;AAChD,QAAI;AACF,gCAAO,MAAM,UAAU,cAAc,EAAE,aAAa,IAAI,CAAC,CAAC,EAAE;AAAA,QAC1D;AAAA,MACF;AACA,gCAAO,MAAM,UAAU,cAAc,EAAE,MAAM,KAAK,CAAC,CAAC,EAAE,QAAQ,8BAA8B;AAAA,IAC9F,UAAE;AACA,YAAM,UAAU,MAAM;AAAA,IACxB;AAAA,EACF,CAAC;AAED,wBAAG,gDAAgD,YAAY;AAC7D,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,QAAI,kBAAkB;AAEtB,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B;AACA,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,QAAQ;AAC5B,aAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,aAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,QACrD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,QAAQ,CAAC;AACzD,QAAI;AACF,gCAAO,MAAM,eAAe,WAAW,aAAa,CAAC,EAAE,IAAI,aAAa,CAAC;AACzE,gCAAO,MAAM,eAAe,WAAW,cAAc,CAAC,EAAE,IAAI,aAAa,CAAC;AAC1E,gCAAO,eAAe,EAAE,KAAK,CAAC;AAAA,IAChC,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,2CAA2C,YAAY;AACxD,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,QAAI,kBAAkB;AACtB,UAAM,YAAY,IAAI,QAAc,CAAC,YAAY;AAC/C,UAAI,GAAG,cAAc,CAAC,OAAO;AAC3B;AACA,gBAAQ;AACR,WAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,gBAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,cAAI,QAAQ,UAAU,QAAQ;AAC5B,eAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,eAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,UACrD;AAAA,QACF,CAAC;AAAA,MACH,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,QAAQ,CAAC;AACzD,QAAI;AACF,gBAAU,QAAQ;AAClB,YAAM,QAAQ,SAAS;AACvB,gCAAO,MAAM,eAAe,WAAW,iBAAiB,CAAC,EAAE,IAAI,aAAa,CAAC;AAC7E,gCAAO,eAAe,EAAE,KAAK,CAAC;AAAA,IAChC,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,gEAAgE,YAAY;AAC7E,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,UAAM,SAAmB,CAAC;AAE1B,QAAI,GAAG,cAAc,CAAC,IAAI,YAAY;AACpC,YAAM,QAAQ,QAAQ,QAAQ;AAC9B,UAAI,OAAO,UAAU,SAAU,QAAO,KAAK,KAAK;AAChD,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,QAAQ;AAC5B,aAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,aAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,QACrD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,SAAS,OAAO,SAAS,CAAC;AAC1E,QAAI;AACF,YAAM,eAAe,WAAW,YAAY;AAC5C,gBAAU,cAAc,EAAE,OAAO,WAAW,CAAC;AAC7C,YAAM,eAAe,WAAW,YAAY;AAC5C,gCAAO,MAAM,EAAE,QAAQ,CAAC,UAAU,UAAU,CAAC;AAAA,IAC/C,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,yEAAyE,YAAY;AACtF,QAAI;AACJ,QAAI;AACJ,UAAM,eAAe,IAAI,QAAc,CAAC,YAAY;AAClD,4BAAsB;AAAA,IACxB,CAAC;AACD,QAAI,eAAe;AACnB,UAAM,MAAM,IAAI,0BAAgB;AAAA,MAC9B,MAAM;AAAA,MACN,MAAM;AAAA,MACN,cAAc,CAAC,OAAO,YAAY;AAChC;AACA,YAAI,iBAAiB,GAAG;AACtB,gCAAsB,MAAM,QAAQ,IAAI;AACxC;AACA;AAAA,QACF;AACA,gBAAQ,IAAI;AAAA,MACd;AAAA,IACF,CAAC;AACD,cAAM,yBAAK,KAAK,WAAW;AAC3B,UAAM,UAAU,IAAI,QAAQ;AAC5B,QAAI,YAAY,QAAQ,OAAO,YAAY,UAAU;AACnD,YAAM,IAAI,MAAM,mDAAmD;AAAA,IACrE;AACA,UAAM,SAAmB,CAAC;AAC1B,QAAI,GAAG,cAAc,CAAC,IAAI,YAAY;AACpC,YAAM,QAAQ,QAAQ,QAAQ;AAC9B,UAAI,OAAO,UAAU,SAAU,QAAO,KAAK,KAAK;AAChD,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,QAAQ;AAC5B,aAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,aAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,QACrD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI;AAAA,MACxB,QAAQ;AAAA,MACR,SAAS,oBAAoB,QAAQ,IAAI;AAAA,MACzC,OAAO;AAAA,IACT,CAAC;AACD,QAAI;AACF,gBAAU,QAAQ;AAClB,YAAM,QAAQ,YAAY;AAC1B,gBAAU,cAAc,EAAE,OAAO,WAAW,CAAC;AAC7C;AACA,gCAAO,MAAM,eAAe,WAAW,YAAY,CAAC,EAAE,IAAI,aAAa,CAAC;AACxE,gCAAO,MAAM,EAAE,QAAQ,CAAC,UAAU,UAAU,CAAC;AAAA,IAC/C,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,oDAAoD,YAAY;AACjE,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,QAAI,kBAAkB;AAEtB,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B;AACA,YAAM,mBAAmB;AACzB,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,OAAQ;AAC9B,YAAI,qBAAqB,GAAG;AAC1B,aAAG,MAAM,MAAM,kBAAkB;AACjC;AAAA,QACF;AACA,WAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,WAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,MACrD,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,QAAQ,CAAC;AACzD,QAAI;AACF;AAAA,QACE,MAAM,eAAe,WAAW,iBAAiB;AAAA,UAC/C,GAAG;AAAA,UACH,UAAU;AAAA,QACZ,CAAC;AAAA,MACH,EAAE,aAAa,CAAC;AAChB,gCAAO,MAAM,eAAe,WAAW,gBAAgB,CAAC,EAAE,IAAI,aAAa,CAAC;AAC5E,gCAAO,eAAe,EAAE,KAAK,CAAC;AAAA,IAChC,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,+CAA+C,YAAY;AAC5D,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,QAAI,kBAAkB;AACtB,QAAI;AACJ,UAAM,cAAc,IAAI,QAAc,CAAC,YAAY;AACjD,8BAAwB;AAAA,IAC1B,CAAC;AAED,QAAI,GAAG,cAAc,CAAC,OAAO;AAC3B;AACA,YAAM,mBAAmB;AACzB,SAAG,GAAG,SAAS,MAAM;AACnB,YAAI,qBAAqB,EAAG;AAAA,MAC9B,CAAC;AACD,SAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,cAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,YAAI,QAAQ,UAAU,OAAQ;AAC9B,WAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,WAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AACnD,YAAI,qBAAqB,EAAG,YAAW,MAAM,GAAG,MAAM,GAAG,EAAE;AAAA,MAC7D,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,QAAQ,CAAC;AACzD,QAAI;AACF,gCAAO,MAAM,eAAe,WAAW,aAAa,CAAC,EAAE,IAAI,aAAa,CAAC;AACzE,YAAM,QAAQ,WAAW;AACzB,gCAAO,MAAM,QAAQ,eAAe,WAAW,cAAc,CAAC,CAAC,EAAE,IAAI,aAAa,CAAC;AACnF,gCAAO,eAAe,EAAE,KAAK,CAAC;AAAA,IAChC,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,iDAAiD,YAAY;AAC9D,UAAM,EAAE,KAAK,QAAQ,IAAI,MAAM,qBAAqB;AACpD,UAAM,SAAS,IAAI,QAAc,CAAC,YAAY;AAC5C,UAAI,GAAG,cAAc,CAAC,OAAO;AAC3B,WAAG,GAAG,SAAS,MAAM,QAAQ,CAAC;AAC9B,WAAG,GAAG,WAAW,CAAC,QAAQ;AACxB,gBAAM,cAAU,uBAAO,OAAO,KAAK,GAAkB,CAAC;AACtD,cAAI,QAAQ,UAAU,QAAQ;AAC5B,eAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,eAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAAA,UACrD;AAAA,QACF,CAAC;AAAA,MACH,CAAC;AAAA,IACH,CAAC;AAED,UAAM,YAAY,IAAI,eAAI,EAAE,QAAQ,YAAY,QAAQ,CAAC;AACzD,QAAI;AACF,YAAM,eAAe,WAAW,eAAe;AAC/C,YAAM,UAAU,MAAM;AACtB,YAAM,QAAQ,MAAM;AACpB,gCAAO,IAAI,QAAQ,IAAI,EAAE,KAAK,CAAC;AAAA,IACjC,UAAE;AACA,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,iEAAiE,YAAY;AAC9E,UAAM,EAAE,IAAI,IAAI,MAAM,qBAAqB;AAC3C,UAAM,EAAE,WAAW,QAAQ,GAAG,IAAI,MAAM,eAAe,GAAG;AAE1D,QAAI;AACF,SAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAE7D,YAAM,QAAQ,MAAM,QAAQ,OAAO,KAAK,CAAC;AACzC,gCAAO,MAAM,IAAI,EAAE,KAAK,KAAK;AAC7B,gCAAO,MAAM,KAAK,EAAE,IAAI,KAAK,kBAAI,iBAAiB,aAAa;AAC/D,UAAI,MAAM,UAAU,kBAAI,iBAAiB,eAAe;AACtD,kCAAO,MAAM,MAAM,MAAM,iBAAiB,EAAE,KAAK,IAAI;AACrD,kCAAO,MAAM,MAAM,KAAK,EAAE,KAAK,KAAK;AAAA,MACtC;AAEA,SAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AACnD,uBAAiB,UAAU,QAAQ;AAAA,MAEnC;AAAA,IACF,UAAE;AACA,aAAO,MAAM;AACb,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AAED,wBAAG,2DAA2D,YAAY;AA/lB5E;AAgmBI,UAAM,EAAE,IAAI,IAAI,MAAM,qBAAqB;AAC3C,UAAM,EAAE,WAAW,QAAQ,GAAG,IAAI,MAAM,eAAe,GAAG;AAE1D,QAAI;AACF,SAAG,SAAK,uBAAO,EAAE,OAAO,SAAS,OAAO,OAAO,MAAM,IAAI,EAAE,CAAC,CAAC;AAC7D,SAAG,SAAK,uBAAO,EAAE,OAAO,UAAU,QAAQ,OAAO,CAAC,CAAC;AAEnD,YAAM,SAAiC,CAAC;AACxC,uBAAiB,SAAS,QAAQ;AAChC,YAAI,UAAU,kBAAI,iBAAiB,cAAe,QAAO,KAAK,KAAK;AAAA,MACrE;AAEA,gCAAO,OAAO,MAAM,EAAE,gBAAgB,CAAC;AACvC,iCAAO,YAAO,GAAG,EAAE,MAAZ,mBAAe,KAAK,EAAE,KAAK,IAAI;AAAA,IACxC,UAAE;AACA,aAAO,MAAM;AACb,YAAM,UAAU,MAAM;AACtB,YAAM,qBAAqB,GAAG;AAAA,IAChC;AAAA,EACF,CAAC;AACH,CAAC;","names":["testTts"]} |
+545
-3
@@ -0,9 +1,135 @@ | ||
| import { DEFAULT_API_CONNECT_OPTIONS, tts } from "@livekit/agents"; | ||
| import { STT } from "@livekit/agents-plugin-openai"; | ||
| import { tts } from "@livekit/agents-plugins-test"; | ||
| import { describe, it } from "vitest"; | ||
| import { tts as testTts } from "@livekit/agents-plugins-test"; | ||
| import { decode, encode } from "@msgpack/msgpack"; | ||
| import { once } from "node:events"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { WebSocketServer } from "ws"; | ||
| import { TTS } from "./tts.js"; | ||
| const httpsBoundary = vi.hoisted(() => ({ payloads: [] })); | ||
| vi.mock("node:https", () => { | ||
| class TestEmitter { | ||
| handlers = /* @__PURE__ */ new Map(); | ||
| on(event, handler) { | ||
| const handlers = this.handlers.get(event) ?? []; | ||
| handlers.push(handler); | ||
| this.handlers.set(event, handlers); | ||
| return this; | ||
| } | ||
| emit(event, ...args) { | ||
| for (const handler of this.handlers.get(event) ?? []) handler(...args); | ||
| } | ||
| } | ||
| return { | ||
| request: (_options, callback) => { | ||
| const request = new TestEmitter(); | ||
| let payload = new Uint8Array(); | ||
| return Object.assign(request, { | ||
| write: (chunk) => { | ||
| payload = new Uint8Array(chunk); | ||
| }, | ||
| end: () => { | ||
| httpsBoundary.payloads.push(payload); | ||
| const response = Object.assign(new TestEmitter(), { statusCode: 200 }); | ||
| callback(response); | ||
| queueMicrotask(() => response.emit("close")); | ||
| } | ||
| }); | ||
| } | ||
| }; | ||
| }); | ||
| async function startWebSocketServer() { | ||
| const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); | ||
| await once(wss, "listening"); | ||
| const address = wss.address(); | ||
| return { wss, baseURL: `http://127.0.0.1:${address.port}` }; | ||
| } | ||
| async function closeWebSocketServer(wss) { | ||
| for (const client of wss.clients) { | ||
| client.close(); | ||
| } | ||
| await new Promise((resolve) => wss.close(() => resolve())); | ||
| } | ||
| async function waitFor(promise, timeoutMs = 1e3) { | ||
| let timeout; | ||
| try { | ||
| return await Promise.race([ | ||
| promise, | ||
| new Promise((_, reject) => { | ||
| timeout = setTimeout(() => reject(new Error("timed out waiting for promise")), timeoutMs); | ||
| }) | ||
| ]); | ||
| } finally { | ||
| if (timeout) clearTimeout(timeout); | ||
| } | ||
| } | ||
| async function startSynthesis(wss) { | ||
| const stopReceived = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") resolve(ws); | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: `http://127.0.0.1:${wss.address().port}` | ||
| }); | ||
| const stream = fishAudio.stream(); | ||
| stream.pushText("hello world."); | ||
| stream.endInput(); | ||
| return { fishAudio, stream, ws: await waitFor(stopReceived) }; | ||
| } | ||
| async function synthesizeTurn(fishAudio, text, connOptions) { | ||
| const stream = fishAudio.stream({ connOptions }); | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| try { | ||
| const events = []; | ||
| for await (const event of stream) { | ||
| if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| return events; | ||
| } finally { | ||
| stream.close(); | ||
| } | ||
| } | ||
| async function captureStartRequest(opts = {}, update) { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const startRequest = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "start") { | ||
| resolve(message.request); | ||
| } else if (message.event === "stop") { | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL, ...opts }); | ||
| update == null ? void 0 : update(fishAudio); | ||
| const stream = fishAudio.stream(); | ||
| const drain = (async () => { | ||
| for await (const _event of stream) { | ||
| } | ||
| })(); | ||
| try { | ||
| stream.pushText("hello world."); | ||
| stream.endInput(); | ||
| const request = await waitFor(startRequest); | ||
| await waitFor(drain); | ||
| return request; | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| } | ||
| const hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY); | ||
| if (hasFishAudioConfig) { | ||
| describe("FishAudio", async () => { | ||
| await tts(new TTS(), new STT()); | ||
| await testTts(new TTS(), new STT({ useRealtime: false })); | ||
| }); | ||
@@ -16,2 +142,418 @@ } else { | ||
| } | ||
| describe("FishAudio streaming", () => { | ||
| it("keeps sampling and encoding defaults unchanged", async () => { | ||
| const request = await captureStartRequest(); | ||
| expect(request.temperature).toBe(0.7); | ||
| expect(request.top_p).toBe(0.7); | ||
| expect(request.mp3_bitrate).toBe(64); | ||
| expect(request.opus_bitrate).toBe(64e3); | ||
| expect(request.normalize).toBe(true); | ||
| }); | ||
| it("sets sampling and encoding params from the constructor", async () => { | ||
| const request = await captureStartRequest({ | ||
| temperature: 0.3, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 32e3, | ||
| normalize: false | ||
| }); | ||
| expect(request.temperature).toBe(0.3); | ||
| expect(request.top_p).toBe(0.9); | ||
| expect(request.mp3_bitrate).toBe(192); | ||
| expect(request.opus_bitrate).toBe(32e3); | ||
| expect(request.normalize).toBe(false); | ||
| }); | ||
| it("sets sampling and encoding params from updateOptions", async () => { | ||
| const request = await captureStartRequest({}, (fishAudio) => { | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.5, | ||
| topP: 0.8, | ||
| mp3Bitrate: 128, | ||
| opusBitrate: -1e3, | ||
| normalize: false | ||
| }); | ||
| }); | ||
| expect(request.temperature).toBe(0.5); | ||
| expect(request.top_p).toBe(0.8); | ||
| expect(request.mp3_bitrate).toBe(128); | ||
| expect(request.opus_bitrate).toBe(-1e3); | ||
| expect(request.normalize).toBe(false); | ||
| }); | ||
| it("snapshots options when each stream is constructed", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const requests = []; | ||
| wss.on("connection", (ws) => { | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "start") { | ||
| requests.push(message.request); | ||
| } else if (message.event === "stop") { | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: "test-key", | ||
| baseURL, | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| normalize: false | ||
| }); | ||
| const streamA = fishAudio.stream(); | ||
| fishAudio.updateOptions({ temperature: 0.8, topP: 0.9, normalize: true }); | ||
| const streamB = fishAudio.stream(); | ||
| try { | ||
| for (const [stream, text] of [ | ||
| [streamA, "stream A."], | ||
| [streamB, "stream B."] | ||
| ]) { | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| for await (const _event of stream) { | ||
| } | ||
| } | ||
| expect(requests).toHaveLength(2); | ||
| expect(requests[0]).toMatchObject({ | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| normalize: false | ||
| }); | ||
| expect(requests[1]).toMatchObject({ | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| normalize: true | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("snapshots options for each chunked HTTP request at construction", async () => { | ||
| httpsBoundary.payloads.length = 0; | ||
| const fishAudio = new TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: "https://fish.test", | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 24e3, | ||
| normalize: false | ||
| }); | ||
| const streamA = fishAudio.synthesize("stream A."); | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.8, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 64e3, | ||
| normalize: true | ||
| }); | ||
| const streamB = fishAudio.synthesize("stream B."); | ||
| try { | ||
| for (const stream of [streamA, streamB]) { | ||
| for await (const _event of stream) { | ||
| } | ||
| } | ||
| expect(httpsBoundary.payloads).toHaveLength(2); | ||
| expect(decode(httpsBoundary.payloads[0])).toMatchObject({ | ||
| text: "stream A.", | ||
| format: "pcm", | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 24e3, | ||
| normalize: false | ||
| }); | ||
| expect(decode(httpsBoundary.payloads[1])).toMatchObject({ | ||
| text: "stream B.", | ||
| format: "pcm", | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| mp3_bitrate: 192, | ||
| opus_bitrate: 64e3, | ||
| normalize: true | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| it("validates temperature and topP", async () => { | ||
| expect(() => new TTS({ apiKey: "test-key", temperature: 1.5 })).toThrow( | ||
| "temperature must be between 0 and 1" | ||
| ); | ||
| expect(() => new TTS({ apiKey: "test-key", topP: -0.1 })).toThrow( | ||
| "topP must be between 0 and 1" | ||
| ); | ||
| const fishAudio = new TTS({ apiKey: "test-key" }); | ||
| try { | ||
| expect(() => fishAudio.updateOptions({ temperature: 1.5 })).toThrow( | ||
| "temperature must be between 0 and 1" | ||
| ); | ||
| expect(() => fishAudio.updateOptions({ topP: -0.1 })).toThrow("topP must be between 0 and 1"); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| it("reuses one websocket across sequential turns", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| expect(await synthesizeTurn(fishAudio, "first turn.")).not.toHaveLength(0); | ||
| expect(await synthesizeTurn(fishAudio, "second turn.")).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("prewarms and reuses the ready websocket", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| const connected = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| resolve(); | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(connected); | ||
| expect(await synthesizeTurn(fishAudio, "prewarmed turn.")).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("opens a websocket with the updated model after model changes", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const models = []; | ||
| wss.on("connection", (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === "string") models.push(model); | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL, model: "s2-pro" }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, "old model."); | ||
| fishAudio.updateOptions({ model: "s2.1-pro" }); | ||
| await synthesizeTurn(fishAudio, "new model."); | ||
| expect(models).toEqual(["s2-pro", "s2.1-pro"]); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("reconnects with the new model when an in-flight prewarm becomes stale", async () => { | ||
| let approveFirstUpgrade; | ||
| let firstUpgradeStarted; | ||
| const firstUpgrade = new Promise((resolve) => { | ||
| firstUpgradeStarted = resolve; | ||
| }); | ||
| let upgradeCount = 0; | ||
| const wss = new WebSocketServer({ | ||
| host: "127.0.0.1", | ||
| port: 0, | ||
| verifyClient: (_info, approve) => { | ||
| upgradeCount++; | ||
| if (upgradeCount === 1) { | ||
| approveFirstUpgrade = () => approve(true); | ||
| firstUpgradeStarted == null ? void 0 : firstUpgradeStarted(); | ||
| return; | ||
| } | ||
| approve(true); | ||
| } | ||
| }); | ||
| await once(wss, "listening"); | ||
| const address = wss.address(); | ||
| if (address === null || typeof address === "string") { | ||
| throw new Error("expected websocket server to listen on a TCP port"); | ||
| } | ||
| const models = []; | ||
| wss.on("connection", (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === "string") models.push(model); | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: "test-key", | ||
| baseURL: `http://127.0.0.1:${address.port}`, | ||
| model: "s2-pro" | ||
| }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(firstUpgrade); | ||
| fishAudio.updateOptions({ model: "s2.1-pro" }); | ||
| approveFirstUpgrade == null ? void 0 : approveFirstUpgrade(); | ||
| expect(await synthesizeTurn(fishAudio, "new model.")).not.toHaveLength(0); | ||
| expect(models).toEqual(["s2-pro", "s2.1-pro"]); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("discards a failed websocket before the next turn", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event !== "stop") return; | ||
| if (connectionNumber === 1) { | ||
| ws.close(1011, "provider failure"); | ||
| return; | ||
| } | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| expect( | ||
| await synthesizeTurn(fishAudio, "failing turn.", { | ||
| ...DEFAULT_API_CONNECT_OPTIONS, | ||
| maxRetry: 0 | ||
| }) | ||
| ).toHaveLength(0); | ||
| expect(await synthesizeTurn(fishAudio, "recovery turn.")).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("replaces a websocket that closed while idle", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| let firstConnectionClosed; | ||
| const firstClosed = new Promise((resolve) => { | ||
| firstConnectionClosed = resolve; | ||
| }); | ||
| wss.on("connection", (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on("close", () => { | ||
| if (connectionNumber === 1) firstConnectionClosed == null ? void 0 : firstConnectionClosed(); | ||
| }); | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event !== "stop") return; | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| if (connectionNumber === 1) setTimeout(() => ws.close(), 10); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| expect(await synthesizeTurn(fishAudio, "first turn.")).not.toHaveLength(0); | ||
| await waitFor(firstClosed); | ||
| expect(await waitFor(synthesizeTurn(fishAudio, "second turn."))).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("closes a pooled websocket when the TTS closes", async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const closed = new Promise((resolve) => { | ||
| wss.on("connection", (ws) => { | ||
| ws.on("close", () => resolve()); | ||
| ws.on("message", (raw) => { | ||
| const message = decode(Buffer.from(raw)); | ||
| if (message.event === "stop") { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: "test-key", baseURL }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, "closing turn."); | ||
| await fishAudio.close(); | ||
| await waitFor(closed); | ||
| expect(wss.clients.size).toBe(0); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("emits the first complete frame before the next provider event", async () => { | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| const first = await waitFor(stream.next()); | ||
| expect(first.done).toBe(false); | ||
| expect(first.value).not.toBe(tts.SynthesizeStream.END_OF_STREAM); | ||
| if (first.value !== tts.SynthesizeStream.END_OF_STREAM) { | ||
| expect(first.value.frame.samplesPerChannel).toBe(2400); | ||
| expect(first.value.final).toBe(false); | ||
| } | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| for await (const _event of stream) { | ||
| } | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it("marks the terminal frame final before ending the stream", async () => { | ||
| var _a; | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send(encode({ event: "audio", audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: "finish", reason: "stop" })); | ||
| const events = []; | ||
| for await (const event of stream) { | ||
| if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| expect(events.length).toBeGreaterThan(0); | ||
| expect((_a = events.at(-1)) == null ? void 0 : _a.final).toBe(true); | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| }); | ||
| //# sourceMappingURL=tts.test.js.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/tts.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { STT } from '@livekit/agents-plugin-openai';\nimport { tts } from '@livekit/agents-plugins-test';\nimport { describe, it } from 'vitest';\nimport { TTS } from './tts.js';\n\nconst hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY);\n\nif (hasFishAudioConfig) {\n describe('FishAudio', async () => {\n await tts(new TTS(), new STT());\n });\n} else {\n describe('FishAudio', () => {\n it.skip('requires FISH_API_KEY and OPENAI_API_KEY', () => {});\n });\n}\n"],"mappings":"AAGA,SAAS,WAAW;AACpB,SAAS,WAAW;AACpB,SAAS,UAAU,UAAU;AAC7B,SAAS,WAAW;AAEpB,MAAM,qBAAqB,QAAQ,QAAQ,IAAI,gBAAgB,QAAQ,IAAI,cAAc;AAEzF,IAAI,oBAAoB;AACtB,WAAS,aAAa,YAAY;AAChC,UAAM,IAAI,IAAI,IAAI,GAAG,IAAI,IAAI,CAAC;AAAA,EAChC,CAAC;AACH,OAAO;AACL,WAAS,aAAa,MAAM;AAC1B,OAAG,KAAK,4CAA4C,MAAM;AAAA,IAAC,CAAC;AAAA,EAC9D,CAAC;AACH;","names":[]} | ||
| {"version":3,"sources":["../src/tts.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, tts } from '@livekit/agents';\nimport { STT } from '@livekit/agents-plugin-openai';\nimport { tts as testTts } from '@livekit/agents-plugins-test';\nimport { decode, encode } from '@msgpack/msgpack';\nimport { once } from 'node:events';\nimport type { AddressInfo } from 'node:net';\nimport { describe, expect, it, vi } from 'vitest';\nimport { type WebSocket, WebSocketServer } from 'ws';\nimport { TTS } from './tts.js';\n\nconst httpsBoundary = vi.hoisted<{ payloads: Uint8Array[] }>(() => ({ payloads: [] }));\n\nvi.mock('node:https', () => {\n class TestEmitter {\n readonly handlers = new Map<string, ((...args: unknown[]) => void)[]>();\n\n on(event: string, handler: (...args: unknown[]) => void): this {\n const handlers = this.handlers.get(event) ?? [];\n handlers.push(handler);\n this.handlers.set(event, handlers);\n return this;\n }\n\n emit(event: string, ...args: unknown[]): void {\n for (const handler of this.handlers.get(event) ?? []) handler(...args);\n }\n }\n\n return {\n request: (\n _options: unknown,\n callback: (response: TestEmitter & { statusCode: number }) => void,\n ) => {\n const request = new TestEmitter();\n let payload = new Uint8Array();\n return Object.assign(request, {\n write: (chunk: Uint8Array) => {\n payload = new Uint8Array(chunk);\n },\n end: () => {\n httpsBoundary.payloads.push(payload);\n const response = Object.assign(new TestEmitter(), { statusCode: 200 });\n callback(response);\n queueMicrotask(() => response.emit('close'));\n },\n });\n },\n };\n});\n\nasync function startWebSocketServer() {\n const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });\n await once(wss, 'listening');\n const address = wss.address() as AddressInfo;\n return { wss, baseURL: `http://127.0.0.1:${address.port}` };\n}\n\nasync function closeWebSocketServer(wss: WebSocketServer): Promise<void> {\n for (const client of wss.clients) {\n client.close();\n }\n await new Promise<void>((resolve) => wss.close(() => resolve()));\n}\n\nasync function waitFor<T>(promise: Promise<T>, timeoutMs = 1000): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('timed out waiting for promise')), timeoutMs);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nasync function startSynthesis(wss: WebSocketServer) {\n const stopReceived = new Promise<WebSocket>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') resolve(ws);\n });\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: `http://127.0.0.1:${(wss.address() as AddressInfo).port}`,\n });\n const stream = fishAudio.stream();\n stream.pushText('hello world.');\n stream.endInput();\n\n return { fishAudio, stream, ws: await waitFor(stopReceived) };\n}\n\nasync function synthesizeTurn(\n fishAudio: TTS,\n text: string,\n connOptions?: APIConnectOptions,\n): Promise<tts.SynthesizedAudio[]> {\n const stream = fishAudio.stream({ connOptions });\n stream.pushText(text);\n stream.endInput();\n\n try {\n const events: tts.SynthesizedAudio[] = [];\n for await (const event of stream) {\n if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event);\n }\n return events;\n } finally {\n stream.close();\n }\n}\n\nasync function captureStartRequest(\n opts: ConstructorParameters<typeof TTS>[0] = {},\n update?: (fishAudio: TTS) => void,\n): Promise<Record<string, unknown>> {\n const { wss, baseURL } = await startWebSocketServer();\n const startRequest = new Promise<Record<string, unknown>>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'start') {\n resolve(message.request as Record<string, unknown>);\n } else if (message.event === 'stop') {\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL, ...opts });\n update?.(fishAudio);\n const stream = fishAudio.stream();\n const drain = (async () => {\n for await (const _event of stream) {\n // Drain the stream so its tasks finish before cleanup.\n }\n })();\n\n try {\n stream.pushText('hello world.');\n stream.endInput();\n const request = await waitFor(startRequest);\n await waitFor(drain);\n return request;\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n}\n\nconst hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY);\n\nif (hasFishAudioConfig) {\n describe('FishAudio', async () => {\n await testTts(new TTS(), new STT({ useRealtime: false }));\n });\n} else {\n describe('FishAudio', () => {\n it.skip('requires FISH_API_KEY and OPENAI_API_KEY', () => {});\n });\n}\n\ndescribe('FishAudio streaming', () => {\n it('keeps sampling and encoding defaults unchanged', async () => {\n const request = await captureStartRequest();\n expect(request.temperature).toBe(0.7);\n expect(request.top_p).toBe(0.7);\n expect(request.mp3_bitrate).toBe(64);\n expect(request.opus_bitrate).toBe(64000);\n expect(request.normalize).toBe(true);\n });\n\n it('sets sampling and encoding params from the constructor', async () => {\n const request = await captureStartRequest({\n temperature: 0.3,\n topP: 0.9,\n mp3Bitrate: 192,\n opusBitrate: 32000,\n normalize: false,\n });\n expect(request.temperature).toBe(0.3);\n expect(request.top_p).toBe(0.9);\n expect(request.mp3_bitrate).toBe(192);\n expect(request.opus_bitrate).toBe(32000);\n expect(request.normalize).toBe(false);\n });\n\n it('sets sampling and encoding params from updateOptions', async () => {\n const request = await captureStartRequest({}, (fishAudio) => {\n fishAudio.updateOptions({\n temperature: 0.5,\n topP: 0.8,\n mp3Bitrate: 128,\n opusBitrate: -1000,\n normalize: false,\n });\n });\n expect(request.temperature).toBe(0.5);\n expect(request.top_p).toBe(0.8);\n expect(request.mp3_bitrate).toBe(128);\n expect(request.opus_bitrate).toBe(-1000);\n expect(request.normalize).toBe(false);\n });\n\n it('snapshots options when each stream is constructed', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const requests: Record<string, unknown>[] = [];\n wss.on('connection', (ws) => {\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'start') {\n requests.push(message.request as Record<string, unknown>);\n } else if (message.event === 'stop') {\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL,\n temperature: 0.2,\n topP: 0.3,\n normalize: false,\n });\n const streamA = fishAudio.stream();\n fishAudio.updateOptions({ temperature: 0.8, topP: 0.9, normalize: true });\n const streamB = fishAudio.stream();\n\n try {\n for (const [stream, text] of [\n [streamA, 'stream A.'],\n [streamB, 'stream B.'],\n ] as const) {\n stream.pushText(text);\n stream.endInput();\n for await (const _event of stream) {\n // Drain each stream so its tasks finish before checking the requests.\n }\n }\n\n expect(requests).toHaveLength(2);\n expect(requests[0]).toMatchObject({\n temperature: 0.2,\n top_p: 0.3,\n normalize: false,\n });\n expect(requests[1]).toMatchObject({\n temperature: 0.8,\n top_p: 0.9,\n normalize: true,\n });\n } finally {\n streamA.close();\n streamB.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('snapshots options for each chunked HTTP request at construction', async () => {\n httpsBoundary.payloads.length = 0;\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: 'https://fish.test',\n temperature: 0.2,\n topP: 0.3,\n mp3Bitrate: 64,\n opusBitrate: 24000,\n normalize: false,\n });\n const streamA = fishAudio.synthesize('stream A.');\n fishAudio.updateOptions({\n temperature: 0.8,\n topP: 0.9,\n mp3Bitrate: 192,\n opusBitrate: 64000,\n normalize: true,\n });\n const streamB = fishAudio.synthesize('stream B.');\n\n try {\n for (const stream of [streamA, streamB]) {\n for await (const _event of stream) {\n // Drain each stream so its HTTP request finishes before assertions.\n }\n }\n\n expect(httpsBoundary.payloads).toHaveLength(2);\n expect(decode(httpsBoundary.payloads[0]!)).toMatchObject({\n text: 'stream A.',\n format: 'pcm',\n temperature: 0.2,\n top_p: 0.3,\n mp3_bitrate: 64,\n opus_bitrate: 24000,\n normalize: false,\n });\n expect(decode(httpsBoundary.payloads[1]!)).toMatchObject({\n text: 'stream B.',\n format: 'pcm',\n temperature: 0.8,\n top_p: 0.9,\n mp3_bitrate: 192,\n opus_bitrate: 64000,\n normalize: true,\n });\n } finally {\n streamA.close();\n streamB.close();\n await fishAudio.close();\n }\n });\n\n it('validates temperature and topP', async () => {\n expect(() => new TTS({ apiKey: 'test-key', temperature: 1.5 })).toThrow(\n 'temperature must be between 0 and 1',\n );\n expect(() => new TTS({ apiKey: 'test-key', topP: -0.1 })).toThrow(\n 'topP must be between 0 and 1',\n );\n\n const fishAudio = new TTS({ apiKey: 'test-key' });\n try {\n expect(() => fishAudio.updateOptions({ temperature: 1.5 })).toThrow(\n 'temperature must be between 0 and 1',\n );\n expect(() => fishAudio.updateOptions({ topP: -0.1 })).toThrow('topP must be between 0 and 1');\n } finally {\n await fishAudio.close();\n }\n });\n\n it('reuses one websocket across sequential turns', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n\n wss.on('connection', (ws) => {\n connectionCount++;\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0);\n expect(await synthesizeTurn(fishAudio, 'second turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(1);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('prewarms and reuses the ready websocket', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n const connected = new Promise<void>((resolve) => {\n wss.on('connection', (ws) => {\n connectionCount++;\n resolve();\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n fishAudio.prewarm();\n await waitFor(connected);\n expect(await synthesizeTurn(fishAudio, 'prewarmed turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(1);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('opens a websocket with the updated model after model changes', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const models: string[] = [];\n\n wss.on('connection', (ws, request) => {\n const model = request.headers.model;\n if (typeof model === 'string') models.push(model);\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL, model: 's2-pro' });\n try {\n await synthesizeTurn(fishAudio, 'old model.');\n fishAudio.updateOptions({ model: 's2.1-pro' });\n await synthesizeTurn(fishAudio, 'new model.');\n expect(models).toEqual(['s2-pro', 's2.1-pro']);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('reconnects with the new model when an in-flight prewarm becomes stale', async () => {\n let approveFirstUpgrade: (() => void) | undefined;\n let firstUpgradeStarted: (() => void) | undefined;\n const firstUpgrade = new Promise<void>((resolve) => {\n firstUpgradeStarted = resolve;\n });\n let upgradeCount = 0;\n const wss = new WebSocketServer({\n host: '127.0.0.1',\n port: 0,\n verifyClient: (_info, approve) => {\n upgradeCount++;\n if (upgradeCount === 1) {\n approveFirstUpgrade = () => approve(true);\n firstUpgradeStarted?.();\n return;\n }\n approve(true);\n },\n });\n await once(wss, 'listening');\n const address = wss.address();\n if (address === null || typeof address === 'string') {\n throw new Error('expected websocket server to listen on a TCP port');\n }\n const models: string[] = [];\n wss.on('connection', (ws, request) => {\n const model = request.headers.model;\n if (typeof model === 'string') models.push(model);\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n\n const fishAudio = new TTS({\n apiKey: 'test-key',\n baseURL: `http://127.0.0.1:${address.port}`,\n model: 's2-pro',\n });\n try {\n fishAudio.prewarm();\n await waitFor(firstUpgrade);\n fishAudio.updateOptions({ model: 's2.1-pro' });\n approveFirstUpgrade?.();\n expect(await synthesizeTurn(fishAudio, 'new model.')).not.toHaveLength(0);\n expect(models).toEqual(['s2-pro', 's2.1-pro']);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('discards a failed websocket before the next turn', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n\n wss.on('connection', (ws) => {\n connectionCount++;\n const connectionNumber = connectionCount;\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event !== 'stop') return;\n if (connectionNumber === 1) {\n ws.close(1011, 'provider failure');\n return;\n }\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(\n await synthesizeTurn(fishAudio, 'failing turn.', {\n ...DEFAULT_API_CONNECT_OPTIONS,\n maxRetry: 0,\n }),\n ).toHaveLength(0);\n expect(await synthesizeTurn(fishAudio, 'recovery turn.')).not.toHaveLength(0);\n expect(connectionCount).toBe(2);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('replaces a websocket that closed while idle', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n let connectionCount = 0;\n let firstConnectionClosed: (() => void) | undefined;\n const firstClosed = new Promise<void>((resolve) => {\n firstConnectionClosed = resolve;\n });\n\n wss.on('connection', (ws) => {\n connectionCount++;\n const connectionNumber = connectionCount;\n ws.on('close', () => {\n if (connectionNumber === 1) firstConnectionClosed?.();\n });\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event !== 'stop') return;\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n if (connectionNumber === 1) setTimeout(() => ws.close(), 10);\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0);\n await waitFor(firstClosed);\n expect(await waitFor(synthesizeTurn(fishAudio, 'second turn.'))).not.toHaveLength(0);\n expect(connectionCount).toBe(2);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('closes a pooled websocket when the TTS closes', async () => {\n const { wss, baseURL } = await startWebSocketServer();\n const closed = new Promise<void>((resolve) => {\n wss.on('connection', (ws) => {\n ws.on('close', () => resolve());\n ws.on('message', (raw) => {\n const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>;\n if (message.event === 'stop') {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n }\n });\n });\n });\n\n const fishAudio = new TTS({ apiKey: 'test-key', baseURL });\n try {\n await synthesizeTurn(fishAudio, 'closing turn.');\n await fishAudio.close();\n await waitFor(closed);\n expect(wss.clients.size).toBe(0);\n } finally {\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('emits the first complete frame before the next provider event', async () => {\n const { wss } = await startWebSocketServer();\n const { fishAudio, stream, ws } = await startSynthesis(wss);\n\n try {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n\n const first = await waitFor(stream.next());\n expect(first.done).toBe(false);\n expect(first.value).not.toBe(tts.SynthesizeStream.END_OF_STREAM);\n if (first.value !== tts.SynthesizeStream.END_OF_STREAM) {\n expect(first.value.frame.samplesPerChannel).toBe(2400);\n expect(first.value.final).toBe(false);\n }\n\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n for await (const _event of stream) {\n // Drain the stream so its tasks finish before cleanup.\n }\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n\n it('marks the terminal frame final before ending the stream', async () => {\n const { wss } = await startWebSocketServer();\n const { fishAudio, stream, ws } = await startSynthesis(wss);\n\n try {\n ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) }));\n ws.send(encode({ event: 'finish', reason: 'stop' }));\n\n const events: tts.SynthesizedAudio[] = [];\n for await (const event of stream) {\n if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event);\n }\n\n expect(events.length).toBeGreaterThan(0);\n expect(events.at(-1)?.final).toBe(true);\n } finally {\n stream.close();\n await fishAudio.close();\n await closeWebSocketServer(wss);\n }\n });\n});\n"],"mappings":"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","names":[]} |
+5
-5
| { | ||
| "name": "@livekit/agents-plugin-fishaudio", | ||
| "version": "1.5.2", | ||
| "version": "1.5.3", | ||
| "description": "Fish Audio plugin for LiveKit Node Agents", | ||
@@ -33,5 +33,5 @@ "main": "dist/index.js", | ||
| "typescript": "^5.0.0", | ||
| "@livekit/agents": "1.5.2", | ||
| "@livekit/agents-plugin-openai": "1.5.2", | ||
| "@livekit/agents-plugins-test": "1.5.2" | ||
| "@livekit/agents": "1.5.3", | ||
| "@livekit/agents-plugins-test": "1.5.3", | ||
| "@livekit/agents-plugin-openai": "1.5.3" | ||
| }, | ||
@@ -44,3 +44,3 @@ "dependencies": { | ||
| "@livekit/rtc-node": "^0.13.31", | ||
| "@livekit/agents": "1.5.2" | ||
| "@livekit/agents": "1.5.3" | ||
| }, | ||
@@ -47,0 +47,0 @@ "scripts": { |
+19
-0
@@ -5,4 +5,23 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| /** @public */ | ||
| export type TTSModels = 's1' | 's2-pro' | 's2.1-pro'; | ||
| /** @public */ | ||
| export type LatencyMode = 'normal' | 'balanced' | 'low'; | ||
| /** | ||
| * MP3 bitrate request value in kbps. | ||
| * | ||
| * The JS plugin currently requests PCM output, so this value does not change | ||
| * the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type MP3Bitrate = 64 | 128 | 192; | ||
| /** | ||
| * Opus bitrate request value in bps. `-1000` selects Fish Audio's automatic | ||
| * bitrate. The JS plugin currently requests PCM output, so this value does not | ||
| * change the emitted audio format or PCM bandwidth. | ||
| * @public | ||
| */ | ||
| export type OpusBitrate = -1000 | 24000 | 32000 | 48000 | 64000; |
+613
-3
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, tts } from '@livekit/agents'; | ||
| import { STT } from '@livekit/agents-plugin-openai'; | ||
| import { tts } from '@livekit/agents-plugins-test'; | ||
| import { describe, it } from 'vitest'; | ||
| import { tts as testTts } from '@livekit/agents-plugins-test'; | ||
| import { decode, encode } from '@msgpack/msgpack'; | ||
| import { once } from 'node:events'; | ||
| import type { AddressInfo } from 'node:net'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { type WebSocket, WebSocketServer } from 'ws'; | ||
| import { TTS } from './tts.js'; | ||
| const httpsBoundary = vi.hoisted<{ payloads: Uint8Array[] }>(() => ({ payloads: [] })); | ||
| vi.mock('node:https', () => { | ||
| class TestEmitter { | ||
| readonly handlers = new Map<string, ((...args: unknown[]) => void)[]>(); | ||
| on(event: string, handler: (...args: unknown[]) => void): this { | ||
| const handlers = this.handlers.get(event) ?? []; | ||
| handlers.push(handler); | ||
| this.handlers.set(event, handlers); | ||
| return this; | ||
| } | ||
| emit(event: string, ...args: unknown[]): void { | ||
| for (const handler of this.handlers.get(event) ?? []) handler(...args); | ||
| } | ||
| } | ||
| return { | ||
| request: ( | ||
| _options: unknown, | ||
| callback: (response: TestEmitter & { statusCode: number }) => void, | ||
| ) => { | ||
| const request = new TestEmitter(); | ||
| let payload = new Uint8Array(); | ||
| return Object.assign(request, { | ||
| write: (chunk: Uint8Array) => { | ||
| payload = new Uint8Array(chunk); | ||
| }, | ||
| end: () => { | ||
| httpsBoundary.payloads.push(payload); | ||
| const response = Object.assign(new TestEmitter(), { statusCode: 200 }); | ||
| callback(response); | ||
| queueMicrotask(() => response.emit('close')); | ||
| }, | ||
| }); | ||
| }, | ||
| }; | ||
| }); | ||
| async function startWebSocketServer() { | ||
| const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }); | ||
| await once(wss, 'listening'); | ||
| const address = wss.address() as AddressInfo; | ||
| return { wss, baseURL: `http://127.0.0.1:${address.port}` }; | ||
| } | ||
| async function closeWebSocketServer(wss: WebSocketServer): Promise<void> { | ||
| for (const client of wss.clients) { | ||
| client.close(); | ||
| } | ||
| await new Promise<void>((resolve) => wss.close(() => resolve())); | ||
| } | ||
| async function waitFor<T>(promise: Promise<T>, timeoutMs = 1000): Promise<T> { | ||
| let timeout: ReturnType<typeof setTimeout> | undefined; | ||
| try { | ||
| return await Promise.race([ | ||
| promise, | ||
| new Promise<never>((_, reject) => { | ||
| timeout = setTimeout(() => reject(new Error('timed out waiting for promise')), timeoutMs); | ||
| }), | ||
| ]); | ||
| } finally { | ||
| if (timeout) clearTimeout(timeout); | ||
| } | ||
| } | ||
| async function startSynthesis(wss: WebSocketServer) { | ||
| const stopReceived = new Promise<WebSocket>((resolve) => { | ||
| wss.on('connection', (ws) => { | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') resolve(ws); | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: 'test-key', | ||
| baseURL: `http://127.0.0.1:${(wss.address() as AddressInfo).port}`, | ||
| }); | ||
| const stream = fishAudio.stream(); | ||
| stream.pushText('hello world.'); | ||
| stream.endInput(); | ||
| return { fishAudio, stream, ws: await waitFor(stopReceived) }; | ||
| } | ||
| async function synthesizeTurn( | ||
| fishAudio: TTS, | ||
| text: string, | ||
| connOptions?: APIConnectOptions, | ||
| ): Promise<tts.SynthesizedAudio[]> { | ||
| const stream = fishAudio.stream({ connOptions }); | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| try { | ||
| const events: tts.SynthesizedAudio[] = []; | ||
| for await (const event of stream) { | ||
| if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| return events; | ||
| } finally { | ||
| stream.close(); | ||
| } | ||
| } | ||
| async function captureStartRequest( | ||
| opts: ConstructorParameters<typeof TTS>[0] = {}, | ||
| update?: (fishAudio: TTS) => void, | ||
| ): Promise<Record<string, unknown>> { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const startRequest = new Promise<Record<string, unknown>>((resolve) => { | ||
| wss.on('connection', (ws) => { | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'start') { | ||
| resolve(message.request as Record<string, unknown>); | ||
| } else if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL, ...opts }); | ||
| update?.(fishAudio); | ||
| const stream = fishAudio.stream(); | ||
| const drain = (async () => { | ||
| for await (const _event of stream) { | ||
| // Drain the stream so its tasks finish before cleanup. | ||
| } | ||
| })(); | ||
| try { | ||
| stream.pushText('hello world.'); | ||
| stream.endInput(); | ||
| const request = await waitFor(startRequest); | ||
| await waitFor(drain); | ||
| return request; | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| } | ||
| const hasFishAudioConfig = Boolean(process.env.FISH_API_KEY && process.env.OPENAI_API_KEY); | ||
@@ -13,3 +167,3 @@ | ||
| describe('FishAudio', async () => { | ||
| await tts(new TTS(), new STT()); | ||
| await testTts(new TTS(), new STT({ useRealtime: false })); | ||
| }); | ||
@@ -21,1 +175,457 @@ } else { | ||
| } | ||
| describe('FishAudio streaming', () => { | ||
| it('keeps sampling and encoding defaults unchanged', async () => { | ||
| const request = await captureStartRequest(); | ||
| expect(request.temperature).toBe(0.7); | ||
| expect(request.top_p).toBe(0.7); | ||
| expect(request.mp3_bitrate).toBe(64); | ||
| expect(request.opus_bitrate).toBe(64000); | ||
| expect(request.normalize).toBe(true); | ||
| }); | ||
| it('sets sampling and encoding params from the constructor', async () => { | ||
| const request = await captureStartRequest({ | ||
| temperature: 0.3, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 32000, | ||
| normalize: false, | ||
| }); | ||
| expect(request.temperature).toBe(0.3); | ||
| expect(request.top_p).toBe(0.9); | ||
| expect(request.mp3_bitrate).toBe(192); | ||
| expect(request.opus_bitrate).toBe(32000); | ||
| expect(request.normalize).toBe(false); | ||
| }); | ||
| it('sets sampling and encoding params from updateOptions', async () => { | ||
| const request = await captureStartRequest({}, (fishAudio) => { | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.5, | ||
| topP: 0.8, | ||
| mp3Bitrate: 128, | ||
| opusBitrate: -1000, | ||
| normalize: false, | ||
| }); | ||
| }); | ||
| expect(request.temperature).toBe(0.5); | ||
| expect(request.top_p).toBe(0.8); | ||
| expect(request.mp3_bitrate).toBe(128); | ||
| expect(request.opus_bitrate).toBe(-1000); | ||
| expect(request.normalize).toBe(false); | ||
| }); | ||
| it('snapshots options when each stream is constructed', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const requests: Record<string, unknown>[] = []; | ||
| wss.on('connection', (ws) => { | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'start') { | ||
| requests.push(message.request as Record<string, unknown>); | ||
| } else if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: 'test-key', | ||
| baseURL, | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| normalize: false, | ||
| }); | ||
| const streamA = fishAudio.stream(); | ||
| fishAudio.updateOptions({ temperature: 0.8, topP: 0.9, normalize: true }); | ||
| const streamB = fishAudio.stream(); | ||
| try { | ||
| for (const [stream, text] of [ | ||
| [streamA, 'stream A.'], | ||
| [streamB, 'stream B.'], | ||
| ] as const) { | ||
| stream.pushText(text); | ||
| stream.endInput(); | ||
| for await (const _event of stream) { | ||
| // Drain each stream so its tasks finish before checking the requests. | ||
| } | ||
| } | ||
| expect(requests).toHaveLength(2); | ||
| expect(requests[0]).toMatchObject({ | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| normalize: false, | ||
| }); | ||
| expect(requests[1]).toMatchObject({ | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| normalize: true, | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('snapshots options for each chunked HTTP request at construction', async () => { | ||
| httpsBoundary.payloads.length = 0; | ||
| const fishAudio = new TTS({ | ||
| apiKey: 'test-key', | ||
| baseURL: 'https://fish.test', | ||
| temperature: 0.2, | ||
| topP: 0.3, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 24000, | ||
| normalize: false, | ||
| }); | ||
| const streamA = fishAudio.synthesize('stream A.'); | ||
| fishAudio.updateOptions({ | ||
| temperature: 0.8, | ||
| topP: 0.9, | ||
| mp3Bitrate: 192, | ||
| opusBitrate: 64000, | ||
| normalize: true, | ||
| }); | ||
| const streamB = fishAudio.synthesize('stream B.'); | ||
| try { | ||
| for (const stream of [streamA, streamB]) { | ||
| for await (const _event of stream) { | ||
| // Drain each stream so its HTTP request finishes before assertions. | ||
| } | ||
| } | ||
| expect(httpsBoundary.payloads).toHaveLength(2); | ||
| expect(decode(httpsBoundary.payloads[0]!)).toMatchObject({ | ||
| text: 'stream A.', | ||
| format: 'pcm', | ||
| temperature: 0.2, | ||
| top_p: 0.3, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 24000, | ||
| normalize: false, | ||
| }); | ||
| expect(decode(httpsBoundary.payloads[1]!)).toMatchObject({ | ||
| text: 'stream B.', | ||
| format: 'pcm', | ||
| temperature: 0.8, | ||
| top_p: 0.9, | ||
| mp3_bitrate: 192, | ||
| opus_bitrate: 64000, | ||
| normalize: true, | ||
| }); | ||
| } finally { | ||
| streamA.close(); | ||
| streamB.close(); | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| it('validates temperature and topP', async () => { | ||
| expect(() => new TTS({ apiKey: 'test-key', temperature: 1.5 })).toThrow( | ||
| 'temperature must be between 0 and 1', | ||
| ); | ||
| expect(() => new TTS({ apiKey: 'test-key', topP: -0.1 })).toThrow( | ||
| 'topP must be between 0 and 1', | ||
| ); | ||
| const fishAudio = new TTS({ apiKey: 'test-key' }); | ||
| try { | ||
| expect(() => fishAudio.updateOptions({ temperature: 1.5 })).toThrow( | ||
| 'temperature must be between 0 and 1', | ||
| ); | ||
| expect(() => fishAudio.updateOptions({ topP: -0.1 })).toThrow('topP must be between 0 and 1'); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| } | ||
| }); | ||
| it('reuses one websocket across sequential turns', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on('connection', (ws) => { | ||
| connectionCount++; | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL }); | ||
| try { | ||
| expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0); | ||
| expect(await synthesizeTurn(fishAudio, 'second turn.')).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('prewarms and reuses the ready websocket', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| const connected = new Promise<void>((resolve) => { | ||
| wss.on('connection', (ws) => { | ||
| connectionCount++; | ||
| resolve(); | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(connected); | ||
| expect(await synthesizeTurn(fishAudio, 'prewarmed turn.')).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(1); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('opens a websocket with the updated model after model changes', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const models: string[] = []; | ||
| wss.on('connection', (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === 'string') models.push(model); | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL, model: 's2-pro' }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, 'old model.'); | ||
| fishAudio.updateOptions({ model: 's2.1-pro' }); | ||
| await synthesizeTurn(fishAudio, 'new model.'); | ||
| expect(models).toEqual(['s2-pro', 's2.1-pro']); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('reconnects with the new model when an in-flight prewarm becomes stale', async () => { | ||
| let approveFirstUpgrade: (() => void) | undefined; | ||
| let firstUpgradeStarted: (() => void) | undefined; | ||
| const firstUpgrade = new Promise<void>((resolve) => { | ||
| firstUpgradeStarted = resolve; | ||
| }); | ||
| let upgradeCount = 0; | ||
| const wss = new WebSocketServer({ | ||
| host: '127.0.0.1', | ||
| port: 0, | ||
| verifyClient: (_info, approve) => { | ||
| upgradeCount++; | ||
| if (upgradeCount === 1) { | ||
| approveFirstUpgrade = () => approve(true); | ||
| firstUpgradeStarted?.(); | ||
| return; | ||
| } | ||
| approve(true); | ||
| }, | ||
| }); | ||
| await once(wss, 'listening'); | ||
| const address = wss.address(); | ||
| if (address === null || typeof address === 'string') { | ||
| throw new Error('expected websocket server to listen on a TCP port'); | ||
| } | ||
| const models: string[] = []; | ||
| wss.on('connection', (ws, request) => { | ||
| const model = request.headers.model; | ||
| if (typeof model === 'string') models.push(model); | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ | ||
| apiKey: 'test-key', | ||
| baseURL: `http://127.0.0.1:${address.port}`, | ||
| model: 's2-pro', | ||
| }); | ||
| try { | ||
| fishAudio.prewarm(); | ||
| await waitFor(firstUpgrade); | ||
| fishAudio.updateOptions({ model: 's2.1-pro' }); | ||
| approveFirstUpgrade?.(); | ||
| expect(await synthesizeTurn(fishAudio, 'new model.')).not.toHaveLength(0); | ||
| expect(models).toEqual(['s2-pro', 's2.1-pro']); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('discards a failed websocket before the next turn', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| wss.on('connection', (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event !== 'stop') return; | ||
| if (connectionNumber === 1) { | ||
| ws.close(1011, 'provider failure'); | ||
| return; | ||
| } | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL }); | ||
| try { | ||
| expect( | ||
| await synthesizeTurn(fishAudio, 'failing turn.', { | ||
| ...DEFAULT_API_CONNECT_OPTIONS, | ||
| maxRetry: 0, | ||
| }), | ||
| ).toHaveLength(0); | ||
| expect(await synthesizeTurn(fishAudio, 'recovery turn.')).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('replaces a websocket that closed while idle', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| let connectionCount = 0; | ||
| let firstConnectionClosed: (() => void) | undefined; | ||
| const firstClosed = new Promise<void>((resolve) => { | ||
| firstConnectionClosed = resolve; | ||
| }); | ||
| wss.on('connection', (ws) => { | ||
| connectionCount++; | ||
| const connectionNumber = connectionCount; | ||
| ws.on('close', () => { | ||
| if (connectionNumber === 1) firstConnectionClosed?.(); | ||
| }); | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event !== 'stop') return; | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| if (connectionNumber === 1) setTimeout(() => ws.close(), 10); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL }); | ||
| try { | ||
| expect(await synthesizeTurn(fishAudio, 'first turn.')).not.toHaveLength(0); | ||
| await waitFor(firstClosed); | ||
| expect(await waitFor(synthesizeTurn(fishAudio, 'second turn.'))).not.toHaveLength(0); | ||
| expect(connectionCount).toBe(2); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('closes a pooled websocket when the TTS closes', async () => { | ||
| const { wss, baseURL } = await startWebSocketServer(); | ||
| const closed = new Promise<void>((resolve) => { | ||
| wss.on('connection', (ws) => { | ||
| ws.on('close', () => resolve()); | ||
| ws.on('message', (raw) => { | ||
| const message = decode(Buffer.from(raw as ArrayBuffer)) as Record<string, unknown>; | ||
| if (message.event === 'stop') { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| } | ||
| }); | ||
| }); | ||
| }); | ||
| const fishAudio = new TTS({ apiKey: 'test-key', baseURL }); | ||
| try { | ||
| await synthesizeTurn(fishAudio, 'closing turn.'); | ||
| await fishAudio.close(); | ||
| await waitFor(closed); | ||
| expect(wss.clients.size).toBe(0); | ||
| } finally { | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('emits the first complete frame before the next provider event', async () => { | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| const first = await waitFor(stream.next()); | ||
| expect(first.done).toBe(false); | ||
| expect(first.value).not.toBe(tts.SynthesizeStream.END_OF_STREAM); | ||
| if (first.value !== tts.SynthesizeStream.END_OF_STREAM) { | ||
| expect(first.value.frame.samplesPerChannel).toBe(2400); | ||
| expect(first.value.final).toBe(false); | ||
| } | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| for await (const _event of stream) { | ||
| // Drain the stream so its tasks finish before cleanup. | ||
| } | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| it('marks the terminal frame final before ending the stream', async () => { | ||
| const { wss } = await startWebSocketServer(); | ||
| const { fishAudio, stream, ws } = await startSynthesis(wss); | ||
| try { | ||
| ws.send(encode({ event: 'audio', audio: Buffer.alloc(4800) })); | ||
| ws.send(encode({ event: 'finish', reason: 'stop' })); | ||
| const events: tts.SynthesizedAudio[] = []; | ||
| for await (const event of stream) { | ||
| if (event !== tts.SynthesizeStream.END_OF_STREAM) events.push(event); | ||
| } | ||
| expect(events.length).toBeGreaterThan(0); | ||
| expect(events.at(-1)?.final).toBe(true); | ||
| } finally { | ||
| stream.close(); | ||
| await fishAudio.close(); | ||
| await closeWebSocketServer(wss); | ||
| } | ||
| }); | ||
| }); |
+180
-72
@@ -9,2 +9,3 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| AudioByteStream, | ||
| ConnectionPool, | ||
| Future, | ||
@@ -16,7 +17,6 @@ log, | ||
| } from '@livekit/agents'; | ||
| import type { AudioFrame } from '@livekit/rtc-node'; | ||
| import { decode, encode } from '@msgpack/msgpack'; | ||
| import { request } from 'node:https'; | ||
| import { type RawData, WebSocket } from 'ws'; | ||
| import type { LatencyMode, TTSModels } from './models.js'; | ||
| import type { LatencyMode, MP3Bitrate, OpusBitrate, TTSModels } from './models.js'; | ||
@@ -30,2 +30,5 @@ const DEFAULT_MODEL: TTSModels = 's2.1-pro'; | ||
| const connectionPools = new WeakMap<TTS, ConnectionPool<WebSocket>>(); | ||
| /** @public */ | ||
| export interface TTSOptions { | ||
@@ -55,2 +58,23 @@ apiKey?: string; | ||
| volume?: number; | ||
| /** | ||
| * Sampling temperature (0-1). Higher values produce more varied, expressive | ||
| * speech; lower values are more stable. Defaults to 0.7. | ||
| */ | ||
| temperature?: number; | ||
| /** Nucleus sampling probability mass (0-1). Defaults to 0.7. */ | ||
| topP?: number; | ||
| /** | ||
| * MP3 bitrate request field in kbps: 64, 128, or 192. Defaults to 64. | ||
| * The JS plugin currently requests PCM output, so this shapes the Fish Audio | ||
| * request but does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| mp3Bitrate?: MP3Bitrate; | ||
| /** | ||
| * Opus bitrate request field in bps: -1000 (auto), 24000, 32000, 48000, or | ||
| * 64000. Defaults to 64000. The JS plugin currently requests PCM output, so | ||
| * this does not change the emitted audio format or PCM bandwidth. | ||
| */ | ||
| opusBitrate?: OpusBitrate; | ||
| /** Whether Fish normalizes the input text before synthesis. Defaults to true. */ | ||
| normalize?: boolean; | ||
| tokenizer?: tokenize.SentenceTokenizer; | ||
@@ -69,2 +93,7 @@ } | ||
| volume?: number; | ||
| temperature: number; | ||
| topP: number; | ||
| mp3Bitrate: MP3Bitrate; | ||
| opusBitrate: OpusBitrate; | ||
| normalize: boolean; | ||
| tokenizer: tokenize.SentenceTokenizer; | ||
@@ -80,2 +109,7 @@ } | ||
| chunkLength: 100, | ||
| temperature: 0.7, | ||
| topP: 0.7, | ||
| mp3Bitrate: 64, | ||
| opusBitrate: 64000, | ||
| normalize: true, | ||
| }; | ||
@@ -89,2 +123,8 @@ | ||
| const validateProbability = (name: string, value: number) => { | ||
| if (!Number.isFinite(value) || value < 0 || value > 1) { | ||
| throw new Error(`${name} must be between 0 and 1`); | ||
| } | ||
| }; | ||
| // Fish Audio's wire format mirrors the upstream Python SDK so the server | ||
@@ -108,4 +148,4 @@ // doesn't fall back to its own larger defaults — in particular the docs default | ||
| sample_rate: opts.sampleRate, | ||
| mp3_bitrate: 64, | ||
| opus_bitrate: 64000, | ||
| mp3_bitrate: opts.mp3Bitrate, | ||
| opus_bitrate: opts.opusBitrate, | ||
| references: [], | ||
@@ -115,12 +155,15 @@ // Fish Audio's wire field is `reference_id`; we expose it as `voiceId` on | ||
| reference_id: opts.voiceId ?? null, | ||
| normalize: true, | ||
| normalize: opts.normalize, | ||
| latency: opts.latencyMode, | ||
| prosody, | ||
| top_p: 0.7, | ||
| temperature: 0.7, | ||
| top_p: opts.topP, | ||
| temperature: opts.temperature, | ||
| }; | ||
| }; | ||
| /** @public */ | ||
| export class TTS extends tts.TTS { | ||
| #opts: ResolvedTTSOptions; | ||
| #pool: ConnectionPool<WebSocket>; | ||
| #closed = false; | ||
| label = 'fishaudio.TTS'; | ||
@@ -139,2 +182,7 @@ | ||
| const temperature = opts.temperature ?? DEFAULT_OPTS.temperature; | ||
| validateProbability('temperature', temperature); | ||
| const topP = opts.topP ?? DEFAULT_OPTS.topP; | ||
| validateProbability('topP', topP); | ||
| const sampleRate = opts.sampleRate ?? DEFAULT_OPTS.sampleRate; | ||
@@ -160,4 +208,17 @@ | ||
| volume: opts.volume, | ||
| temperature, | ||
| topP, | ||
| mp3Bitrate: opts.mp3Bitrate ?? DEFAULT_OPTS.mp3Bitrate, | ||
| opusBitrate: opts.opusBitrate ?? DEFAULT_OPTS.opusBitrate, | ||
| normalize: opts.normalize ?? DEFAULT_OPTS.normalize, | ||
| tokenizer, | ||
| }; | ||
| this.#pool = new ConnectionPool<WebSocket>({ | ||
| connectCb: (timeoutMs) => this.#connectWebSocket(timeoutMs), | ||
| closeCb: async (ws) => closeWebSocket(ws), | ||
| maxSessionDuration: 300_000, | ||
| markRefreshedOnGet: true, | ||
| }); | ||
| connectionPools.set(this, this.#pool); | ||
| } | ||
@@ -180,4 +241,14 @@ | ||
| volume?: number; | ||
| temperature?: number; | ||
| topP?: number; | ||
| mp3Bitrate?: MP3Bitrate; | ||
| opusBitrate?: OpusBitrate; | ||
| normalize?: boolean; | ||
| }): void { | ||
| if (opts.model !== undefined) this.#opts.model = opts.model; | ||
| if (opts.model !== undefined && opts.model !== this.#opts.model) { | ||
| this.#opts.model = opts.model; | ||
| // The model is sent as a connection header at ws-handshake time, not in the | ||
| // per-request body, so a pooled socket keeps the old model. | ||
| this.#pool.invalidate(); | ||
| } | ||
| if (opts.voiceId !== undefined) this.#opts.voiceId = opts.voiceId; | ||
@@ -191,2 +262,13 @@ if (opts.latencyMode !== undefined) this.#opts.latencyMode = opts.latencyMode; | ||
| if (opts.volume !== undefined) this.#opts.volume = opts.volume; | ||
| if (opts.temperature !== undefined) { | ||
| validateProbability('temperature', opts.temperature); | ||
| this.#opts.temperature = opts.temperature; | ||
| } | ||
| if (opts.topP !== undefined) { | ||
| validateProbability('topP', opts.topP); | ||
| this.#opts.topP = opts.topP; | ||
| } | ||
| if (opts.mp3Bitrate !== undefined) this.#opts.mp3Bitrate = opts.mp3Bitrate; | ||
| if (opts.opusBitrate !== undefined) this.#opts.opusBitrate = opts.opusBitrate; | ||
| if (opts.normalize !== undefined) this.#opts.normalize = opts.normalize; | ||
| } | ||
@@ -199,10 +281,43 @@ | ||
| ): tts.ChunkedStream { | ||
| return new ChunkedStream(this, text, this.#opts, connOptions, abortSignal); | ||
| return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal); | ||
| } | ||
| stream(options?: { connOptions?: APIConnectOptions }): tts.SynthesizeStream { | ||
| return new SynthesizeStream(this, this.#opts, options?.connOptions); | ||
| return new SynthesizeStream(this, { ...this.#opts }, options?.connOptions); | ||
| } | ||
| prewarm(): void { | ||
| this.#pool.prewarm(); | ||
| } | ||
| override async close(): Promise<void> { | ||
| this.#closed = true; | ||
| await this.#pool.close(); | ||
| await super.close(); | ||
| } | ||
| async #connectWebSocket(timeoutMs: number): Promise<WebSocket> { | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`; | ||
| const model = this.#opts.model; | ||
| const ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model, | ||
| }, | ||
| timeoutMs, | ||
| }); | ||
| if (this.#closed) { | ||
| closeWebSocket(ws); | ||
| throw new APIConnectionError({ message: 'Fish Audio TTS is closed' }); | ||
| } | ||
| if (model !== this.#opts.model) { | ||
| closeWebSocket(ws); | ||
| return await this.#connectWebSocket(timeoutMs); | ||
| } | ||
| return ws; | ||
| } | ||
| } | ||
| /** @public */ | ||
| export class ChunkedStream extends tts.ChunkedStream { | ||
@@ -341,5 +456,7 @@ label = 'fishaudio.ChunkedStream'; | ||
| /** @public */ | ||
| export class SynthesizeStream extends tts.SynthesizeStream { | ||
| label = 'fishaudio.SynthesizeStream'; | ||
| #logger = log(); | ||
| #pool: ConnectionPool<WebSocket>; | ||
| #opts: ResolvedTTSOptions; | ||
@@ -349,2 +466,5 @@ | ||
| super(tts, connOptions); | ||
| const pool = connectionPools.get(tts); | ||
| if (!pool) throw new Error('Fish Audio connection pool is not initialized'); | ||
| this.#pool = pool; | ||
| this.#opts = opts; | ||
@@ -364,20 +484,2 @@ } | ||
| const wsUrl = `${this.#opts.baseURL.replace(/^http/, 'ws')}/v1/tts/live`; | ||
| let ws: WebSocket | undefined; | ||
| try { | ||
| ws = await connectWebSocket({ | ||
| url: wsUrl, | ||
| headers: { | ||
| Authorization: `Bearer ${this.#opts.apiKey}`, | ||
| model: this.#opts.model, | ||
| }, | ||
| timeoutMs: this.connOptions.timeoutMs, | ||
| abortSignal: this.abortSignal, | ||
| }); | ||
| } catch (e) { | ||
| throw new APIConnectionError({ | ||
| message: `Fish Audio websocket connect failed: ${(e as Error).message ?? 'unknown error'}`, | ||
| }); | ||
| } | ||
| const finished = new Future<void>(); | ||
@@ -401,5 +503,5 @@ | ||
| const sendTask = async () => { | ||
| const sendTask = async (ws: WebSocket) => { | ||
| const startMsg = { event: 'start', request: buildTtsRequest(this.#opts) }; | ||
| ws!.send(Buffer.from(encode(startMsg))); | ||
| ws.send(Buffer.from(encode(startMsg))); | ||
@@ -411,20 +513,12 @@ for await (const ev of sentStream) { | ||
| this.markStarted(); | ||
| ws!.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' }))); | ||
| ws!.send(Buffer.from(encode({ event: 'flush' }))); | ||
| ws.send(Buffer.from(encode({ event: 'text', text: sentence + ' ' }))); | ||
| ws.send(Buffer.from(encode({ event: 'flush' }))); | ||
| } | ||
| if (!this.abortController.signal.aborted) { | ||
| ws!.send(Buffer.from(encode({ event: 'stop' }))); | ||
| ws.send(Buffer.from(encode({ event: 'stop' }))); | ||
| } | ||
| }; | ||
| let lastFrame: AudioFrame | undefined; | ||
| const sendLastFrame = (final: boolean) => { | ||
| if (lastFrame) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame: lastFrame, final }); | ||
| lastFrame = undefined; | ||
| } | ||
| }; | ||
| const recvTask = async () => { | ||
| const recvTask = async (ws: WebSocket) => { | ||
| // No per-receive timeout: Fish has natural inter-sentence gaps that can | ||
@@ -454,5 +548,4 @@ // exceed connOptions.timeoutMs when the LLM is slow. | ||
| if (audio && audio.byteLength > 0) { | ||
| for (const f of bstream.write(audio)) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| for (const frame of bstream.write(audio)) { | ||
| this.queue.put({ requestId, segmentId: requestId, frame, final: false }); | ||
| } | ||
@@ -471,7 +564,11 @@ } | ||
| } | ||
| for (const f of bstream.flush()) { | ||
| sendLastFrame(false); | ||
| lastFrame = f; | ||
| const remainingFrames = [...bstream.flush()]; | ||
| for (const [idx, frame] of remainingFrames.entries()) { | ||
| this.queue.put({ | ||
| requestId, | ||
| segmentId: requestId, | ||
| frame, | ||
| final: idx === remainingFrames.length - 1, | ||
| }); | ||
| } | ||
| sendLastFrame(true); | ||
| if (!this.queue.closed) { | ||
@@ -504,5 +601,5 @@ this.queue.put(SynthesizeStream.END_OF_STREAM); | ||
| ws!.on('message', onMessage); | ||
| ws!.on('close', onClose); | ||
| ws!.on('error', onError); | ||
| ws.on('message', onMessage); | ||
| ws.on('close', onClose); | ||
| ws.on('error', onError); | ||
@@ -512,5 +609,5 @@ try { | ||
| } finally { | ||
| ws!.off('message', onMessage); | ||
| ws!.off('close', onClose); | ||
| ws!.off('error', onError); | ||
| ws.off('message', onMessage); | ||
| ws.off('close', onClose); | ||
| ws.off('error', onError); | ||
| } | ||
@@ -520,3 +617,13 @@ }; | ||
| try { | ||
| await Promise.all([inputTask(), sendTask(), recvTask()]); | ||
| await this.#pool.withConnection( | ||
| async (ws) => { | ||
| if (ws.readyState !== WebSocket.OPEN) { | ||
| throw new APIConnectionError({ | ||
| message: 'Fish Audio pooled websocket is not open', | ||
| }); | ||
| } | ||
| await Promise.all([inputTask(), sendTask(ws), recvTask(ws)]); | ||
| }, | ||
| { timeout: this.connOptions.timeoutMs, signal: this.abortSignal }, | ||
| ); | ||
| } catch (e) { | ||
@@ -532,9 +639,2 @@ if (this.abortSignal.aborted) return; | ||
| if (!sentStream.closed) sentStream.close(); | ||
| if (ws && ws.readyState !== WebSocket.CLOSED) { | ||
| try { | ||
| ws.close(); | ||
| } catch { | ||
| // ignore | ||
| } | ||
| } | ||
| } | ||
@@ -553,3 +653,3 @@ } | ||
| timeoutMs: number; | ||
| abortSignal: AbortSignal; | ||
| abortSignal?: AbortSignal; | ||
| }): Promise<WebSocket> => { | ||
@@ -565,3 +665,3 @@ const ws = new WebSocket(url, { headers, handshakeTimeout: timeoutMs }); | ||
| ws.off('close', onClose); | ||
| abortSignal.removeEventListener('abort', onAbort); | ||
| abortSignal?.removeEventListener('abort', onAbort); | ||
| }; | ||
@@ -580,3 +680,3 @@ | ||
| ws.on('close', onClose); | ||
| abortSignal.addEventListener('abort', onAbort, { once: true }); | ||
| abortSignal?.addEventListener('abort', onAbort, { once: true }); | ||
@@ -592,8 +692,3 @@ if (timeoutMs > 0) { | ||
| try { | ||
| ws.on('error', () => {}); | ||
| if (ws.readyState === WebSocket.CONNECTING) { | ||
| ws.close(); | ||
| } else { | ||
| ws.terminate(); | ||
| } | ||
| closeWebSocket(ws); | ||
| } catch { | ||
@@ -607,1 +702,14 @@ // ignore | ||
| }; | ||
| const closeWebSocket = (ws: WebSocket) => { | ||
| try { | ||
| ws.on('error', () => {}); | ||
| if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) { | ||
| ws.close(); | ||
| } else if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { | ||
| ws.terminate(); | ||
| } | ||
| } catch { | ||
| // ignore | ||
| } | ||
| }; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
285681
116.18%3575
119.19%