kugelaudio 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +30 -1
- package/dist/chunk-CB3B6T7F.mjs +391 -0
- package/dist/chunk-D57AKD2S.mjs +391 -0
- package/dist/index.d.mts +84 -11
- package/dist/index.d.ts +84 -11
- package/dist/index.js +193 -7
- package/dist/index.mjs +186 -334
- package/dist/livekit/index.d.mts +222 -0
- package/dist/livekit/index.d.ts +222 -0
- package/dist/livekit/index.js +994 -0
- package/dist/livekit/index.mjs +746 -0
- package/package.json +23 -3
- package/src/cfg-scale.test.ts +33 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +213 -5
- package/src/index.ts +2 -0
- package/src/livekit/index.ts +32 -0
- package/src/livekit/models.test.ts +30 -0
- package/src/livekit/models.ts +84 -0
- package/src/livekit/tts.functional.test.ts +348 -0
- package/src/livekit/tts.ts +833 -0
- package/src/livekit/wire.test.ts +112 -0
- package/src/livekit/wire.ts +126 -0
- package/src/types.ts +47 -7
- package/src/utils.ts +16 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional tests for the LiveKit plugin against a mock `/ws/tts/multi`
|
|
3
|
+
* server that speaks the real ingress wire protocol (see
|
|
4
|
+
* `backend/ingress/src/ingress/routes/ws_multi.py`):
|
|
5
|
+
*
|
|
6
|
+
* - first message of a context carries `model_id` / `sample_rate` / config
|
|
7
|
+
* - `flush: true` triggers audio frames + `chunk_complete`
|
|
8
|
+
* - `{close_context}` is answered with `{context_closed}` (terminal)
|
|
9
|
+
* - `immediate: true` close cancels without draining
|
|
10
|
+
*
|
|
11
|
+
* These tests exercise the actual WebSocket state machine end to end:
|
|
12
|
+
* chunked synthesis, streaming synthesis, error frames, barge-in, and
|
|
13
|
+
* connection reuse.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer, type Server } from 'node:http';
|
|
17
|
+
import { AddressInfo } from 'node:net';
|
|
18
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
19
|
+
import { WebSocketServer, type WebSocket as ServerWebSocket } from 'ws';
|
|
20
|
+
|
|
21
|
+
import { initializeLogger, type APIError } from '@livekit/agents';
|
|
22
|
+
|
|
23
|
+
import { SynthesizeStream, TTS } from './tts';
|
|
24
|
+
|
|
25
|
+
/** 100ms of 16-bit mono PCM at 24kHz. */
|
|
26
|
+
const FRAME_BYTES = 4800;
|
|
27
|
+
|
|
28
|
+
interface MockContextState {
|
|
29
|
+
text: string;
|
|
30
|
+
configMessages: Record<string, unknown>[];
|
|
31
|
+
closed: boolean;
|
|
32
|
+
immediateClose: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal in-process implementation of the `/ws/tts/multi` protocol. Each
|
|
37
|
+
* flush emits `audioFramesPerFlush` PCM frames followed by `chunk_complete`;
|
|
38
|
+
* `close_context` drains (unless immediate) and answers `context_closed`.
|
|
39
|
+
*/
|
|
40
|
+
class MockMultiServer {
|
|
41
|
+
server: Server;
|
|
42
|
+
wss: WebSocketServer;
|
|
43
|
+
connectionCount = 0;
|
|
44
|
+
contexts = new Map<string, MockContextState>();
|
|
45
|
+
messages: Record<string, unknown>[] = [];
|
|
46
|
+
audioFramesPerFlush = 2;
|
|
47
|
+
/** When set, the first text message of a context is answered with this error frame. */
|
|
48
|
+
errorFrame: Record<string, unknown> | null = null;
|
|
49
|
+
/** When true, never respond to anything (for idle-timeout tests). */
|
|
50
|
+
silent = false;
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
this.server = createServer();
|
|
54
|
+
this.wss = new WebSocketServer({ server: this.server, path: '/ws/tts/multi' });
|
|
55
|
+
this.wss.on('connection', (ws) => {
|
|
56
|
+
this.connectionCount += 1;
|
|
57
|
+
ws.on('message', (raw) => this.onMessage(ws, JSON.parse(raw.toString())));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listen(): Promise<string> {
|
|
62
|
+
await new Promise<void>((resolve) => this.server.listen(0, '127.0.0.1', resolve));
|
|
63
|
+
const { port } = this.server.address() as AddressInfo;
|
|
64
|
+
return `http://127.0.0.1:${port}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async close(): Promise<void> {
|
|
68
|
+
for (const client of this.wss.clients) client.terminate();
|
|
69
|
+
await new Promise<void>((resolve) => this.wss.close(() => resolve()));
|
|
70
|
+
await new Promise<void>((resolve) => this.server.close(() => resolve()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private onMessage(ws: ServerWebSocket, msg: Record<string, unknown>): void {
|
|
74
|
+
this.messages.push(msg);
|
|
75
|
+
if (this.silent) return;
|
|
76
|
+
|
|
77
|
+
if (msg.close_socket) {
|
|
78
|
+
ws.send(JSON.stringify({ session_closed: true }));
|
|
79
|
+
ws.close();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const contextId = msg.context_id as string;
|
|
84
|
+
|
|
85
|
+
if (msg.close_context) {
|
|
86
|
+
const ctx = this.contexts.get(contextId);
|
|
87
|
+
if (ctx) {
|
|
88
|
+
ctx.closed = true;
|
|
89
|
+
ctx.immediateClose = Boolean(msg.immediate);
|
|
90
|
+
}
|
|
91
|
+
ws.send(JSON.stringify({ context_closed: true, context_id: contextId }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let ctx = this.contexts.get(contextId);
|
|
96
|
+
if (!ctx) {
|
|
97
|
+
ctx = { text: '', configMessages: [], closed: false, immediateClose: false };
|
|
98
|
+
this.contexts.set(contextId, ctx);
|
|
99
|
+
ws.send(JSON.stringify({ context_created: true, context_id: contextId }));
|
|
100
|
+
}
|
|
101
|
+
if (msg.model_id !== undefined) ctx.configMessages.push(msg);
|
|
102
|
+
if (typeof msg.text === 'string') ctx.text += msg.text;
|
|
103
|
+
|
|
104
|
+
if (msg.flush) {
|
|
105
|
+
if (this.errorFrame) {
|
|
106
|
+
ws.send(JSON.stringify({ ...this.errorFrame, context_id: contextId }));
|
|
107
|
+
this.contexts.delete(contextId);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (ctx.text.length > 0) {
|
|
111
|
+
for (let i = 0; i < this.audioFramesPerFlush; i++) {
|
|
112
|
+
const pcm = Buffer.alloc(FRAME_BYTES, i + 1);
|
|
113
|
+
ws.send(
|
|
114
|
+
JSON.stringify({ audio: pcm.toString('base64'), context_id: contextId }),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
ws.send(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
word_timestamps: [{ word: 'hello', start_ms: 0, end_ms: 500 }],
|
|
120
|
+
context_id: contextId,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
ws.send(JSON.stringify({ chunk_complete: true, context_id: contextId }));
|
|
124
|
+
ctx.text = '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let server: MockMultiServer;
|
|
131
|
+
let baseURL: string;
|
|
132
|
+
const openTTS: TTS[] = [];
|
|
133
|
+
|
|
134
|
+
beforeAll(() => {
|
|
135
|
+
initializeLogger({ pretty: false, level: 'silent' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(async () => {
|
|
139
|
+
for (const t of openTTS.splice(0)) await t.close();
|
|
140
|
+
await server?.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function setup(): Promise<TTS> {
|
|
144
|
+
server = new MockMultiServer();
|
|
145
|
+
baseURL = await server.listen();
|
|
146
|
+
const instance = new TTS({ apiKey: 'test-key', baseURL, language: 'en' });
|
|
147
|
+
openTTS.push(instance);
|
|
148
|
+
return instance;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe('LiveKit plugin against mock /ws/tts/multi server', () => {
|
|
152
|
+
it('chunked synthesize returns all audio with the final flag on the last frame', async () => {
|
|
153
|
+
const instance = await setup();
|
|
154
|
+
const stream = instance.synthesize('Hallo Welt');
|
|
155
|
+
|
|
156
|
+
const events = [];
|
|
157
|
+
for await (const event of stream) events.push(event);
|
|
158
|
+
|
|
159
|
+
expect(events.length).toBeGreaterThan(0);
|
|
160
|
+
const totalSamples = events.reduce((sum, e) => sum + e.frame.samplesPerChannel, 0);
|
|
161
|
+
expect(totalSamples).toBe((2 * FRAME_BYTES) / 2);
|
|
162
|
+
expect(events[events.length - 1]!.final).toBe(true);
|
|
163
|
+
expect(events.slice(0, -1).every((e) => !e.final)).toBe(true);
|
|
164
|
+
expect(events.every((e) => e.frame.sampleRate === 24000)).toBe(true);
|
|
165
|
+
|
|
166
|
+
// Word timestamps ride on the frame emitted after they arrived.
|
|
167
|
+
const timed = events.flatMap((e) => e.timedTranscripts ?? []);
|
|
168
|
+
expect(timed).toHaveLength(1);
|
|
169
|
+
expect(timed[0]!.text).toBe('hello');
|
|
170
|
+
expect(timed[0]!.startTime).toBe(0);
|
|
171
|
+
expect(timed[0]!.endTime).toBe(0.5);
|
|
172
|
+
|
|
173
|
+
// First message of the context carried the session config exactly once.
|
|
174
|
+
const [ctx] = [...server.contexts.values()];
|
|
175
|
+
expect(ctx!.configMessages).toHaveLength(1);
|
|
176
|
+
expect(ctx!.configMessages[0]).toMatchObject({
|
|
177
|
+
model_id: 'kugel-3',
|
|
178
|
+
sample_rate: 24000,
|
|
179
|
+
language: 'en',
|
|
180
|
+
normalize: true,
|
|
181
|
+
word_timestamps: false,
|
|
182
|
+
});
|
|
183
|
+
expect(ctx!.closed).toBe(true);
|
|
184
|
+
expect(ctx!.immediateClose).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('streaming synthesize forwards tokens and drains the tail gracefully', async () => {
|
|
188
|
+
const instance = await setup();
|
|
189
|
+
const stream = instance.stream();
|
|
190
|
+
|
|
191
|
+
stream.pushText('Guten ');
|
|
192
|
+
stream.pushText('Tag');
|
|
193
|
+
stream.endInput();
|
|
194
|
+
|
|
195
|
+
const events = [];
|
|
196
|
+
for await (const event of stream) {
|
|
197
|
+
if (event === SynthesizeStream.END_OF_STREAM) break;
|
|
198
|
+
events.push(event);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
expect(events.length).toBeGreaterThan(0);
|
|
202
|
+
const totalSamples = events.reduce((sum, e) => sum + e.frame.samplesPerChannel, 0);
|
|
203
|
+
expect(totalSamples).toBe((2 * FRAME_BYTES) / 2);
|
|
204
|
+
expect(events[events.length - 1]!.final).toBe(true);
|
|
205
|
+
|
|
206
|
+
// The server received the concatenated text and a graceful close.
|
|
207
|
+
const [ctx] = [...server.contexts.values()];
|
|
208
|
+
expect(ctx!.closed).toBe(true);
|
|
209
|
+
expect(ctx!.immediateClose).toBe(false);
|
|
210
|
+
const textMessages = server.messages
|
|
211
|
+
.filter((m) => typeof m.text === 'string')
|
|
212
|
+
.map((m) => m.text);
|
|
213
|
+
expect(textMessages.join('')).toBe('Guten Tag');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('reuses one WebSocket connection across sequential requests', async () => {
|
|
217
|
+
const instance = await setup();
|
|
218
|
+
|
|
219
|
+
for (const text of ['erste', 'zweite']) {
|
|
220
|
+
const stream = instance.synthesize(text);
|
|
221
|
+
for await (const _ of stream) {
|
|
222
|
+
// drain
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
expect(server.connectionCount).toBe(1);
|
|
227
|
+
// Each context got its own config-bearing first message.
|
|
228
|
+
expect(server.contexts.size).toBe(2);
|
|
229
|
+
for (const ctx of server.contexts.values()) {
|
|
230
|
+
expect(ctx.configMessages).toHaveLength(1);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('maps server error frames to a non-retryable APIStatusError', async () => {
|
|
235
|
+
const instance = await setup();
|
|
236
|
+
server.errorFrame = {
|
|
237
|
+
error: 'text validation failed',
|
|
238
|
+
error_code: 'VALIDATION_ERROR',
|
|
239
|
+
code: 400,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const errors: Error[] = [];
|
|
243
|
+
instance.on('error', (event) => errors.push(event.error));
|
|
244
|
+
|
|
245
|
+
const stream = instance.stream();
|
|
246
|
+
stream.pushText('kaputt');
|
|
247
|
+
stream.endInput();
|
|
248
|
+
|
|
249
|
+
const events = [];
|
|
250
|
+
for await (const event of stream) {
|
|
251
|
+
if (event === SynthesizeStream.END_OF_STREAM) break;
|
|
252
|
+
events.push(event);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
expect(events).toHaveLength(0);
|
|
256
|
+
expect(errors).toHaveLength(1);
|
|
257
|
+
expect(errors[0]!.message).toContain('text validation failed');
|
|
258
|
+
// 400-class errors must not be retried by the framework.
|
|
259
|
+
expect((errors[0] as APIError).retryable).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('barge-in (stream.close) sends an immediate close_context', async () => {
|
|
263
|
+
const instance = await setup();
|
|
264
|
+
// Many frames so generation is "in flight" when we barge in.
|
|
265
|
+
server.audioFramesPerFlush = 50;
|
|
266
|
+
|
|
267
|
+
const stream = instance.stream();
|
|
268
|
+
stream.pushText('Eine sehr lange Ansage');
|
|
269
|
+
stream.flush();
|
|
270
|
+
|
|
271
|
+
// Consume a couple of frames, then barge in.
|
|
272
|
+
let received = 0;
|
|
273
|
+
for await (const event of stream) {
|
|
274
|
+
if (event === SynthesizeStream.END_OF_STREAM) break;
|
|
275
|
+
received += 1;
|
|
276
|
+
if (received >= 2) {
|
|
277
|
+
stream.close();
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
expect(received).toBe(2);
|
|
282
|
+
|
|
283
|
+
// The immediate close reaches the server asynchronously.
|
|
284
|
+
await expect
|
|
285
|
+
.poll(() =>
|
|
286
|
+
server.messages.some((m) => m.close_context === true && m.immediate === true),
|
|
287
|
+
)
|
|
288
|
+
.toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('fails promptly on a server error even while the input channel stays open', async () => {
|
|
292
|
+
const instance = await setup();
|
|
293
|
+
server.errorFrame = {
|
|
294
|
+
error: 'model exploded',
|
|
295
|
+
error_code: 'INTERNAL_ERROR',
|
|
296
|
+
code: 500,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const errors: Error[] = [];
|
|
300
|
+
instance.on('error', (event) => errors.push(event.error));
|
|
301
|
+
|
|
302
|
+
const stream = instance.stream({
|
|
303
|
+
connOptions: { maxRetry: 0, retryIntervalMs: 10, timeoutMs: 5000 },
|
|
304
|
+
});
|
|
305
|
+
// Flush triggers the error frame, but the input channel is deliberately
|
|
306
|
+
// never ended — simulating an LLM that is still producing tokens. Without
|
|
307
|
+
// input-loop cancellation this would hang until the input closes.
|
|
308
|
+
stream.pushText('boom');
|
|
309
|
+
stream.flush();
|
|
310
|
+
|
|
311
|
+
const before = Date.now();
|
|
312
|
+
const events = [];
|
|
313
|
+
for await (const event of stream) {
|
|
314
|
+
if (event === SynthesizeStream.END_OF_STREAM) break;
|
|
315
|
+
events.push(event);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
expect(Date.now() - before).toBeLessThan(2000);
|
|
319
|
+
expect(events).toHaveLength(0);
|
|
320
|
+
expect(errors).toHaveLength(1);
|
|
321
|
+
expect(errors[0]!.message).toContain('model exploded');
|
|
322
|
+
stream.close();
|
|
323
|
+
}, 10_000);
|
|
324
|
+
|
|
325
|
+
it('rejects the run with APITimeoutError when the server goes silent', async () => {
|
|
326
|
+
const instance = await setup();
|
|
327
|
+
server.silent = true;
|
|
328
|
+
|
|
329
|
+
const errors: Error[] = [];
|
|
330
|
+
instance.on('error', (event) => errors.push(event.error));
|
|
331
|
+
|
|
332
|
+
const stream = instance.stream({
|
|
333
|
+
connOptions: { maxRetry: 0, retryIntervalMs: 10, timeoutMs: 500 },
|
|
334
|
+
});
|
|
335
|
+
stream.pushText('niemand antwortet');
|
|
336
|
+
stream.endInput();
|
|
337
|
+
|
|
338
|
+
const events = [];
|
|
339
|
+
for await (const event of stream) {
|
|
340
|
+
if (event === SynthesizeStream.END_OF_STREAM) break;
|
|
341
|
+
events.push(event);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
expect(events).toHaveLength(0);
|
|
345
|
+
expect(errors).toHaveLength(1);
|
|
346
|
+
expect(errors[0]!.name).toBe('APITimeoutError');
|
|
347
|
+
}, 10_000);
|
|
348
|
+
});
|