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/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
+ }