🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@livekit/agents-plugin-fishaudio

Package Overview
Dependencies
Maintainers
20
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@livekit/agents-plugin-fishaudio - npm Package Compare versions

Comparing version
1.5.2
to
1.5.3
+1
-1
dist/index.cjs

@@ -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 @@ });

@@ -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":[]}

@@ -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

@@ -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"}

@@ -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 +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"]}
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;

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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"}

@@ -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 +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"]}
"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":"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","names":["testTts"]}

@@ -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":[]}
{
"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": {

@@ -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;
// 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
}
};