livekit-plugin-parrot-stt 0.1.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/README.md +79 -0
- package/dist/index.cjs +303 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +280 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +18 -0
- package/src/models.ts +121 -0
- package/src/stt.ts +280 -0
package/src/stt.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Ringg Parrot STT plugin for LiveKit Agents.
|
|
2
|
+
//
|
|
3
|
+
// Implements the streaming STT interface (`stt.STT` + `stt.SpeechStream`) by
|
|
4
|
+
// speaking the Ringg WebSocket protocol directly (there is no JS SDK).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type APIConnectOptions,
|
|
8
|
+
type AudioBuffer,
|
|
9
|
+
AudioByteStream,
|
|
10
|
+
asLanguageCode,
|
|
11
|
+
log,
|
|
12
|
+
stt,
|
|
13
|
+
} from '@livekit/agents';
|
|
14
|
+
import type { AudioFrame } from '@livekit/rtc-node';
|
|
15
|
+
import { type RawData, WebSocket } from 'ws';
|
|
16
|
+
import {
|
|
17
|
+
type RinggSTTOptions,
|
|
18
|
+
type RinggServerEvent,
|
|
19
|
+
type RinggTranscriptEvent,
|
|
20
|
+
buildStartPayload,
|
|
21
|
+
defaultRinggSTTOptions,
|
|
22
|
+
} from './models.js';
|
|
23
|
+
|
|
24
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
|
|
26
|
+
export class STT extends stt.STT {
|
|
27
|
+
label = 'ringg.STT';
|
|
28
|
+
#opts: RinggSTTOptions;
|
|
29
|
+
|
|
30
|
+
constructor(opts: Partial<RinggSTTOptions> = {}) {
|
|
31
|
+
super({ streaming: true, interimResults: true });
|
|
32
|
+
this.#opts = { ...defaultRinggSTTOptions, ...opts };
|
|
33
|
+
if (!this.#opts.apiKey) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'Ringg STT: API key is required — set $RINGG_STT_API_KEY or pass { apiKey }.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (!this.#opts.url) {
|
|
39
|
+
throw new Error('Ringg STT: url is required — set $RINGG_STT_URL or pass { url }.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get provider(): string {
|
|
44
|
+
return 'ringg';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get model(): string {
|
|
48
|
+
return `ringg-parrot-${this.#opts.language}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
updateOptions(opts: Partial<RinggSTTOptions>): void {
|
|
52
|
+
this.#opts = { ...this.#opts, ...opts };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async _recognize(_frame: AudioBuffer): Promise<stt.SpeechEvent> {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'Ringg STT plugin is streaming-only; use stream(). ' +
|
|
58
|
+
'(Ringg also exposes a REST /transcriptions endpoint, but it is not wired into this plugin.)',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stream(options?: { connOptions?: APIConnectOptions }): stt.SpeechStream {
|
|
63
|
+
return new SpeechStream(this, this.#opts, options?.connOptions);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SpeechStream extends stt.SpeechStream {
|
|
68
|
+
label = 'ringg.SpeechStream';
|
|
69
|
+
#opts: RinggSTTOptions;
|
|
70
|
+
#logger = log();
|
|
71
|
+
#speaking = false;
|
|
72
|
+
#requestId?: string;
|
|
73
|
+
|
|
74
|
+
constructor(sttImpl: STT, opts: RinggSTTOptions, connOptions?: APIConnectOptions) {
|
|
75
|
+
// Passing sampleRate as `neededSampleRate` makes the base class resample
|
|
76
|
+
// incoming (typically 48kHz) LiveKit frames down to Ringg's rate for us.
|
|
77
|
+
super(sttImpl, opts.sampleRate, connOptions);
|
|
78
|
+
this.#opts = opts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected async run(): Promise<void> {
|
|
82
|
+
const ws = new WebSocket(this.#opts.url);
|
|
83
|
+
ws.binaryType = 'nodebuffer';
|
|
84
|
+
|
|
85
|
+
// 1) wait for the socket to open
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
const onErr = (err: Error) => reject(err);
|
|
88
|
+
ws.once('open', () => {
|
|
89
|
+
ws.off('error', onErr);
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
ws.once('error', onErr);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 2) send the `start` handshake and wait for the `ready` reply
|
|
96
|
+
ws.send(JSON.stringify(buildStartPayload(this.#opts)));
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
const onMsg = (data: RawData, isBinary: boolean) => {
|
|
99
|
+
if (isBinary) return;
|
|
100
|
+
let ev: RinggServerEvent;
|
|
101
|
+
try {
|
|
102
|
+
ev = JSON.parse(data.toString());
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (ev.type === 'ready') {
|
|
107
|
+
this.#requestId = (ev as { request_id?: string }).request_id;
|
|
108
|
+
cleanup();
|
|
109
|
+
resolve();
|
|
110
|
+
} else if (ev.type === 'error') {
|
|
111
|
+
cleanup();
|
|
112
|
+
reject(
|
|
113
|
+
new Error(
|
|
114
|
+
`Ringg STT: handshake rejected — ${(ev as { detail?: string }).detail ?? 'unknown error'}`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const onClose = (code: number) => {
|
|
120
|
+
cleanup();
|
|
121
|
+
reject(new Error(`Ringg STT: connection closed during handshake (code ${code})`));
|
|
122
|
+
};
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
ws.off('message', onMsg);
|
|
125
|
+
ws.off('close', onClose);
|
|
126
|
+
};
|
|
127
|
+
ws.on('message', onMsg);
|
|
128
|
+
ws.once('close', onClose);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this.#logger
|
|
132
|
+
.child({ url: this.#opts.url, language: this.#opts.language, requestId: this.#requestId })
|
|
133
|
+
.debug('ringg stt: connected');
|
|
134
|
+
|
|
135
|
+
let closed = false;
|
|
136
|
+
const closeWs = () => {
|
|
137
|
+
if (!closed) {
|
|
138
|
+
closed = true;
|
|
139
|
+
try {
|
|
140
|
+
ws.close();
|
|
141
|
+
} catch {
|
|
142
|
+
/* ignore */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// keepalive: application-level JSON ping (Ringg replies with `pong`)
|
|
148
|
+
let keepalive: ReturnType<typeof setInterval> | undefined;
|
|
149
|
+
if (this.#opts.keepaliveIntervalMs > 0) {
|
|
150
|
+
keepalive = setInterval(() => {
|
|
151
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
152
|
+
try {
|
|
153
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, this.#opts.keepaliveIntervalMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3) receive loop — resolves when the server closes the socket
|
|
162
|
+
const recvTask = new Promise<void>((resolve, reject) => {
|
|
163
|
+
ws.on('message', (data: RawData, isBinary: boolean) => {
|
|
164
|
+
if (isBinary) return; // Ringg only sends JSON text frames
|
|
165
|
+
let ev: RinggServerEvent;
|
|
166
|
+
try {
|
|
167
|
+
ev = JSON.parse(data.toString());
|
|
168
|
+
} catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.#handleEvent(ev);
|
|
172
|
+
});
|
|
173
|
+
ws.once('close', () => resolve());
|
|
174
|
+
ws.once('error', (err) => reject(err));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 4) send loop — reads resampled frames from `this.input`, ships raw PCM16
|
|
178
|
+
const sendTask = async () => {
|
|
179
|
+
const samples20ms = Math.floor(this.#opts.sampleRate / 50); // 20ms chunks
|
|
180
|
+
const bstream = new AudioByteStream(this.#opts.sampleRate, 1, samples20ms);
|
|
181
|
+
const shipFrames = (frames: AudioFrame[]) => {
|
|
182
|
+
for (const f of frames) {
|
|
183
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
184
|
+
// send exactly this frame's bytes (raw PCM16 LE), regardless of view offset
|
|
185
|
+
ws.send(new Uint8Array(f.data.buffer, f.data.byteOffset, f.data.byteLength));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (;;) {
|
|
191
|
+
if (closed) break;
|
|
192
|
+
const { done, value } = await this.input.next();
|
|
193
|
+
if (done) break;
|
|
194
|
+
if (value === SpeechStream.FLUSH_SENTINEL) {
|
|
195
|
+
shipFrames(bstream.flush());
|
|
196
|
+
} else {
|
|
197
|
+
shipFrames(bstream.write((value as AudioFrame).data.buffer));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// input exhausted: flush remaining audio and ask the server to finalize
|
|
202
|
+
shipFrames(bstream.flush());
|
|
203
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
204
|
+
ws.send(JSON.stringify({ type: 'end' }));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// abort (session close / cancel) → stop promptly
|
|
209
|
+
const abortPromise = new Promise<void>((resolve) => {
|
|
210
|
+
const signal = this.abortController.signal;
|
|
211
|
+
if (signal.aborted) return resolve();
|
|
212
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// finite input (a file): after the send loop finishes, wait a grace period
|
|
216
|
+
// for trailing final transcripts, then let the socket close.
|
|
217
|
+
const finiteInputPath = (async () => {
|
|
218
|
+
await sendTask();
|
|
219
|
+
await sleep(this.#opts.finalizeGraceMs);
|
|
220
|
+
})();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await Promise.race([abortPromise, recvTask, finiteInputPath]);
|
|
224
|
+
} finally {
|
|
225
|
+
if (keepalive) clearInterval(keepalive);
|
|
226
|
+
closeWs();
|
|
227
|
+
// let recvTask settle so its listeners are cleaned up
|
|
228
|
+
await recvTask.catch(() => {});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#handleEvent(ev: RinggServerEvent): void {
|
|
233
|
+
switch (ev.type) {
|
|
234
|
+
case 'transcript': {
|
|
235
|
+
const t = ev as RinggTranscriptEvent;
|
|
236
|
+
const text = (t.transcription ?? '').trim();
|
|
237
|
+
if (!text) return;
|
|
238
|
+
|
|
239
|
+
if (!this.#speaking) {
|
|
240
|
+
this.#speaking = true;
|
|
241
|
+
this.queue.put({ type: stt.SpeechEventType.START_OF_SPEECH });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data: stt.SpeechData = {
|
|
245
|
+
language: asLanguageCode(t.language ?? this.#opts.language),
|
|
246
|
+
text,
|
|
247
|
+
startTime: 0,
|
|
248
|
+
endTime: 0,
|
|
249
|
+
confidence: 1.0,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (t.is_final) {
|
|
253
|
+
this.queue.put({
|
|
254
|
+
type: stt.SpeechEventType.FINAL_TRANSCRIPT,
|
|
255
|
+
alternatives: [data],
|
|
256
|
+
requestId: this.#requestId,
|
|
257
|
+
});
|
|
258
|
+
if (this.#speaking) {
|
|
259
|
+
this.#speaking = false;
|
|
260
|
+
this.queue.put({ type: stt.SpeechEventType.END_OF_SPEECH });
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
this.queue.put({
|
|
264
|
+
type: stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
|
265
|
+
alternatives: [data],
|
|
266
|
+
requestId: this.#requestId,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'error': {
|
|
272
|
+
this.#logger.child({ event: ev }).error('ringg stt: server error');
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
// ready (duplicate), ack, pong, and unknown types are ignored
|
|
276
|
+
default:
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|