@kuralle-syrinx/google 2.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/LICENSE +22 -0
- package/package.json +23 -0
- package/src/index.test.ts +273 -0
- package/src/index.ts +249 -0
- package/tsconfig.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/google",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"ws": "^8.18.0",
|
|
11
|
+
"@kuralle-syrinx/core": "2.1.0",
|
|
12
|
+
"@kuralle-syrinx/ws": "2.1.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/ws": "^8.5.0",
|
|
16
|
+
"typescript": "^5.7.0",
|
|
17
|
+
"vitest": "^2.1.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
5
|
+
import {
|
|
6
|
+
PipelineBusImpl,
|
|
7
|
+
Route,
|
|
8
|
+
type SttErrorPacket,
|
|
9
|
+
type SttInterimPacket,
|
|
10
|
+
type SttResultPacket,
|
|
11
|
+
type EndOfSpeechPacket,
|
|
12
|
+
} from "@kuralle-syrinx/core";
|
|
13
|
+
import { GoogleSTTPlugin } from "./index.js";
|
|
14
|
+
|
|
15
|
+
let servers: WebSocketServer[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(
|
|
19
|
+
servers.splice(0).map(
|
|
20
|
+
(server) =>
|
|
21
|
+
new Promise<void>((resolve) => {
|
|
22
|
+
for (const client of server.clients) client.terminate();
|
|
23
|
+
server.close(() => resolve());
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
|
|
30
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
31
|
+
let nextServer: WebSocketServer;
|
|
32
|
+
nextServer = new WebSocketServer({ port: 0 }, () => {
|
|
33
|
+
resolve(nextServer);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
servers.push(server);
|
|
37
|
+
server.on("connection", onConnection);
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
40
|
+
return `ws://127.0.0.1:${address.port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function startBus(bus: PipelineBusImpl): Promise<void> {
|
|
44
|
+
const started = bus.start();
|
|
45
|
+
return started;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("GoogleSTTPlugin", () => {
|
|
49
|
+
it("pushes interim and final packets from Google streaming responses", async () => {
|
|
50
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
51
|
+
socket.once("message", () => {
|
|
52
|
+
socket.send(JSON.stringify({
|
|
53
|
+
results: [{
|
|
54
|
+
isFinal: false,
|
|
55
|
+
alternatives: [{ transcript: "hello", confidence: 0.7 }],
|
|
56
|
+
}],
|
|
57
|
+
}));
|
|
58
|
+
socket.send(JSON.stringify({
|
|
59
|
+
results: [{
|
|
60
|
+
isFinal: true,
|
|
61
|
+
alternatives: [{ transcript: "hello world", confidence: 0.95 }],
|
|
62
|
+
}],
|
|
63
|
+
}));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
const bus = new PipelineBusImpl();
|
|
67
|
+
const started = startBus(bus);
|
|
68
|
+
const plugin = new GoogleSTTPlugin();
|
|
69
|
+
const interims: SttInterimPacket[] = [];
|
|
70
|
+
const finals: SttResultPacket[] = [];
|
|
71
|
+
bus.on("stt.interim", (pkt) => {
|
|
72
|
+
interims.push(pkt as SttInterimPacket);
|
|
73
|
+
});
|
|
74
|
+
bus.on("stt.result", (pkt) => {
|
|
75
|
+
finals.push(pkt as SttResultPacket);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await plugin.initialize(bus, {
|
|
79
|
+
api_key: "test",
|
|
80
|
+
project_id: "test-project",
|
|
81
|
+
endpoint_url: endpointUrl,
|
|
82
|
+
});
|
|
83
|
+
bus.push(Route.Main, {
|
|
84
|
+
kind: "stt.audio",
|
|
85
|
+
contextId: "turn-1",
|
|
86
|
+
timestampMs: Date.now(),
|
|
87
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
88
|
+
});
|
|
89
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
90
|
+
|
|
91
|
+
expect(interims).toEqual([
|
|
92
|
+
expect.objectContaining({ kind: "stt.interim", contextId: "turn-1", text: "hello" }),
|
|
93
|
+
]);
|
|
94
|
+
expect(finals).toEqual([
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
kind: "stt.result",
|
|
97
|
+
contextId: "turn-1",
|
|
98
|
+
text: "hello world",
|
|
99
|
+
confidence: 0.95,
|
|
100
|
+
language: "en-US",
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
await plugin.close();
|
|
105
|
+
bus.stop();
|
|
106
|
+
await started;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("reconnects after a recoverable socket close before sending later audio", async () => {
|
|
110
|
+
let connections = 0;
|
|
111
|
+
const receivedFrames: Array<{ connection: number; kind: "config" | "audio"; payload: unknown }> = [];
|
|
112
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
113
|
+
connections++;
|
|
114
|
+
const connection = connections;
|
|
115
|
+
if (connections === 1) {
|
|
116
|
+
socket.close();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
socket.on("message", (data, isBinary) => {
|
|
120
|
+
if (isBinary) {
|
|
121
|
+
receivedFrames.push({ connection, kind: "audio", payload: Buffer.from(data as Buffer) });
|
|
122
|
+
} else {
|
|
123
|
+
receivedFrames.push({ connection, kind: "config", payload: JSON.parse(data.toString()) });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
const bus = new PipelineBusImpl();
|
|
128
|
+
const started = startBus(bus);
|
|
129
|
+
const plugin = new GoogleSTTPlugin();
|
|
130
|
+
|
|
131
|
+
await plugin.initialize(bus, {
|
|
132
|
+
api_key: "test",
|
|
133
|
+
project_id: "test-project",
|
|
134
|
+
endpoint_url: endpointUrl,
|
|
135
|
+
retry_base_delay_ms: 1,
|
|
136
|
+
retry_max_delay_ms: 1,
|
|
137
|
+
});
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
139
|
+
bus.push(Route.Main, {
|
|
140
|
+
kind: "stt.audio",
|
|
141
|
+
contextId: "turn-1",
|
|
142
|
+
timestampMs: Date.now(),
|
|
143
|
+
audio: new Uint8Array([9, 8, 7, 6]),
|
|
144
|
+
});
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
146
|
+
|
|
147
|
+
expect(connections).toBeGreaterThanOrEqual(2);
|
|
148
|
+
expect(receivedFrames).toEqual([
|
|
149
|
+
expect.objectContaining({ connection: 2, kind: "config" }),
|
|
150
|
+
expect.objectContaining({ connection: 2, kind: "audio", payload: Buffer.from([9, 8, 7, 6]) }),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
await plugin.close();
|
|
154
|
+
bus.stop();
|
|
155
|
+
await started;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("uses configured sample_rate in the audio contract and Google decoding config", async () => {
|
|
159
|
+
const configs: any[] = [];
|
|
160
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
161
|
+
socket.on("message", (data, isBinary) => {
|
|
162
|
+
if (!isBinary) configs.push(JSON.parse(data.toString()));
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
const bus = new PipelineBusImpl();
|
|
166
|
+
const started = startBus(bus);
|
|
167
|
+
const plugin = new GoogleSTTPlugin();
|
|
168
|
+
|
|
169
|
+
await plugin.initialize(bus, {
|
|
170
|
+
api_key: "test",
|
|
171
|
+
project_id: "test-project",
|
|
172
|
+
endpoint_url: endpointUrl,
|
|
173
|
+
sample_rate: 8000,
|
|
174
|
+
});
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
176
|
+
|
|
177
|
+
expect(configs[0]?.streamingConfig?.config?.explicitDecodingConfig?.sampleRateHertz).toBe(8000);
|
|
178
|
+
|
|
179
|
+
await plugin.close();
|
|
180
|
+
bus.stop();
|
|
181
|
+
await started;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("suppresses EOS when Smart Turn ownership disables provider finalization", async () => {
|
|
185
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
186
|
+
socket.once("message", () => {
|
|
187
|
+
socket.send(JSON.stringify({
|
|
188
|
+
results: [{
|
|
189
|
+
isFinal: true,
|
|
190
|
+
alternatives: [{ transcript: "hello world", confidence: 0.95 }],
|
|
191
|
+
}],
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
const bus = new PipelineBusImpl();
|
|
196
|
+
const started = startBus(bus);
|
|
197
|
+
const plugin = new GoogleSTTPlugin();
|
|
198
|
+
const finals: SttResultPacket[] = [];
|
|
199
|
+
const eos: EndOfSpeechPacket[] = [];
|
|
200
|
+
bus.on("stt.result", (pkt) => {
|
|
201
|
+
finals.push(pkt as SttResultPacket);
|
|
202
|
+
});
|
|
203
|
+
bus.on("eos.turn_complete", (pkt) => {
|
|
204
|
+
eos.push(pkt as EndOfSpeechPacket);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await plugin.initialize(bus, {
|
|
208
|
+
api_key: "test",
|
|
209
|
+
project_id: "test-project",
|
|
210
|
+
endpoint_url: endpointUrl,
|
|
211
|
+
emit_eos_on_final: false,
|
|
212
|
+
});
|
|
213
|
+
bus.push(Route.Main, {
|
|
214
|
+
kind: "stt.audio",
|
|
215
|
+
contextId: "turn-no-eos",
|
|
216
|
+
timestampMs: Date.now(),
|
|
217
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
218
|
+
});
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
220
|
+
|
|
221
|
+
expect(finals).toHaveLength(1);
|
|
222
|
+
expect(eos).toEqual([]);
|
|
223
|
+
|
|
224
|
+
await plugin.close();
|
|
225
|
+
bus.stop();
|
|
226
|
+
await started;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("emits stt.error for odd-length PCM16 without throwing into the bus pump", async () => {
|
|
230
|
+
const receivedAudio: Buffer[] = [];
|
|
231
|
+
const endpointUrl = await createLocalServer((socket) => {
|
|
232
|
+
socket.on("message", (data, isBinary) => {
|
|
233
|
+
if (isBinary) receivedAudio.push(data as Buffer);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
const bus = new PipelineBusImpl();
|
|
237
|
+
const started = startBus(bus);
|
|
238
|
+
const plugin = new GoogleSTTPlugin();
|
|
239
|
+
const errors: SttErrorPacket[] = [];
|
|
240
|
+
bus.on("stt.error", (pkt) => {
|
|
241
|
+
errors.push(pkt as SttErrorPacket);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await plugin.initialize(bus, {
|
|
245
|
+
api_key: "test",
|
|
246
|
+
project_id: "test-project",
|
|
247
|
+
endpoint_url: endpointUrl,
|
|
248
|
+
});
|
|
249
|
+
bus.push(Route.Main, {
|
|
250
|
+
kind: "stt.audio",
|
|
251
|
+
contextId: "turn-bad",
|
|
252
|
+
timestampMs: Date.now(),
|
|
253
|
+
audio: new Uint8Array([1, 2, 3]),
|
|
254
|
+
});
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
256
|
+
|
|
257
|
+
expect(errors).toEqual([
|
|
258
|
+
expect.objectContaining({
|
|
259
|
+
kind: "stt.error",
|
|
260
|
+
contextId: "turn-bad",
|
|
261
|
+
component: "stt",
|
|
262
|
+
cause: expect.objectContaining({
|
|
263
|
+
message: expect.stringMatching(/even number of bytes/i),
|
|
264
|
+
}),
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
expect(receivedAudio).toEqual([]);
|
|
268
|
+
|
|
269
|
+
await plugin.close();
|
|
270
|
+
bus.stop();
|
|
271
|
+
await started;
|
|
272
|
+
});
|
|
273
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Google Cloud Speech-to-Text Plugin
|
|
4
|
+
//
|
|
5
|
+
// Uses Google Cloud Speech-to-Text v2 REST API for streaming recognition.
|
|
6
|
+
// Sends audio chunks, receives interim and final transcripts.
|
|
7
|
+
// Pushes SttInterimPacket, SttResultPacket, and SttErrorPacket into the bus.
|
|
8
|
+
//
|
|
9
|
+
// Reference: Rapida transformer/google/stt.go (GCP Speech-to-Text v2 API)
|
|
10
|
+
// Reference: https://cloud.google.com/speech-to-text/v2/docs/streaming-recognize
|
|
11
|
+
//
|
|
12
|
+
// Unlike Deepgram which uses simple API keys, GCP requires:
|
|
13
|
+
// - API key (for public API) OR
|
|
14
|
+
// - Service account key (for private/project-scoped API)
|
|
15
|
+
// - Project ID (required for recognizer path)
|
|
16
|
+
|
|
17
|
+
import type { PipelineBus } from "@kuralle-syrinx/core";
|
|
18
|
+
import {
|
|
19
|
+
Route,
|
|
20
|
+
type AudioFormat,
|
|
21
|
+
type VoicePlugin,
|
|
22
|
+
type PluginConfig,
|
|
23
|
+
type SttErrorPacket,
|
|
24
|
+
assertAudioFormat,
|
|
25
|
+
assertAudioPayload,
|
|
26
|
+
requireStringConfig,
|
|
27
|
+
optionalStringConfig,
|
|
28
|
+
categorizeSttError,
|
|
29
|
+
isRecoverable,
|
|
30
|
+
readProviderRetryConfig,
|
|
31
|
+
} from "@kuralle-syrinx/core";
|
|
32
|
+
import { WebSocketConnection, type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
interface GCPConfig {
|
|
39
|
+
recognizer: string;
|
|
40
|
+
encoding: string;
|
|
41
|
+
sampleRateHertz: number;
|
|
42
|
+
languageCodes: string[];
|
|
43
|
+
model: string;
|
|
44
|
+
enableAutomaticPunctuation: boolean;
|
|
45
|
+
interimResults: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Plugin
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
export class GoogleSTTPlugin implements VoicePlugin {
|
|
53
|
+
readonly endpointingCapability = {
|
|
54
|
+
owner: "provider_stt" as const,
|
|
55
|
+
disableConfig: {
|
|
56
|
+
emit_eos_on_final: false,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
constructor(private readonly socketFactory?: SocketFactory) {}
|
|
61
|
+
|
|
62
|
+
private bus: PipelineBus | null = null;
|
|
63
|
+
private conn: WebSocketConnection | null = null;
|
|
64
|
+
private apiKey: string = "";
|
|
65
|
+
private projectId: string = "";
|
|
66
|
+
private languageCode: string = "en-US";
|
|
67
|
+
private model: string = "latest_long";
|
|
68
|
+
private endpointUrl: string | undefined;
|
|
69
|
+
private currentContextId = "";
|
|
70
|
+
private disposers: Array<() => void> = [];
|
|
71
|
+
private recognizerPath = "";
|
|
72
|
+
private sampleRate = 16000;
|
|
73
|
+
private confidenceThreshold = 0;
|
|
74
|
+
private emitEosOnFinal = true;
|
|
75
|
+
private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 };
|
|
76
|
+
|
|
77
|
+
async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
|
|
78
|
+
this.bus = bus;
|
|
79
|
+
this.apiKey = requireStringConfig(config, "api_key");
|
|
80
|
+
this.projectId = requireStringConfig(config, "project_id");
|
|
81
|
+
this.languageCode = optionalStringConfig(config, "language") ?? "en-US";
|
|
82
|
+
this.model = optionalStringConfig(config, "model") ?? "latest_long";
|
|
83
|
+
this.endpointUrl = optionalStringConfig(config, "endpoint_url");
|
|
84
|
+
this.sampleRate = (config["sample_rate"] as number) ?? 16000;
|
|
85
|
+
this.confidenceThreshold = (config["confidence_threshold"] as number) ?? 0;
|
|
86
|
+
this.emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
|
|
87
|
+
|
|
88
|
+
this.recognizerPath = `projects/${this.projectId}/locations/global/recognizers/_`;
|
|
89
|
+
this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
|
|
90
|
+
assertAudioFormat(this.audioFormat);
|
|
91
|
+
this.conn = new WebSocketConnection({
|
|
92
|
+
url: () => {
|
|
93
|
+
return this.endpointUrl ??
|
|
94
|
+
`wss://speech.googleapis.com/v2/${this.recognizerPath}:streamingRecognize?key=${this.apiKey}`;
|
|
95
|
+
},
|
|
96
|
+
socketFactory: this.socketFactory ?? await defaultSocketFactory(),
|
|
97
|
+
retry: readProviderRetryConfig(config),
|
|
98
|
+
replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
|
|
99
|
+
onReplay: (event, count) => {
|
|
100
|
+
this.pushMetric(this.currentContextId, `stt.google.reconnect_replay_${event}`, String(count));
|
|
101
|
+
},
|
|
102
|
+
onReadyBeforeReplay: () => this.sendConfig(),
|
|
103
|
+
onMessage: (data) => this.handleMessage(data),
|
|
104
|
+
onConnectionLost: (err) => {
|
|
105
|
+
this.emitError(err);
|
|
106
|
+
},
|
|
107
|
+
onUnrecoverable: (err) => {
|
|
108
|
+
this.emitError(err);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
await this.conn.connect();
|
|
112
|
+
|
|
113
|
+
this.disposers.push(
|
|
114
|
+
bus.on("stt.audio", async (pkt: unknown) => {
|
|
115
|
+
const audioPkt = pkt as { audio: Uint8Array; contextId?: string };
|
|
116
|
+
this.currentContextId = audioPkt.contextId ?? this.currentContextId;
|
|
117
|
+
await this.sendAudio(audioPkt.audio);
|
|
118
|
+
}),
|
|
119
|
+
bus.on("turn.change", (pkt: unknown) => {
|
|
120
|
+
this.currentContextId = (pkt as { contextId: string }).contextId;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async sendAudio(audio: Uint8Array): Promise<void> {
|
|
126
|
+
if (audio.byteLength === 0) return;
|
|
127
|
+
try {
|
|
128
|
+
assertAudioPayload(this.audioFormat, audio);
|
|
129
|
+
if (!this.conn) throw new Error("Google STT is not connected");
|
|
130
|
+
await this.conn.ensureReady();
|
|
131
|
+
this.conn.send(audio);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
this.emitError(err instanceof Error ? err : new Error(String(err)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async close(): Promise<void> {
|
|
138
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
139
|
+
await this.conn?.close();
|
|
140
|
+
this.conn = null;
|
|
141
|
+
this.bus = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private sendConfig(): void {
|
|
145
|
+
const configMsg = {
|
|
146
|
+
recognizer: this.recognizerPath,
|
|
147
|
+
streamingConfig: {
|
|
148
|
+
config: {
|
|
149
|
+
explicitDecodingConfig: {
|
|
150
|
+
encoding: "LINEAR16",
|
|
151
|
+
sampleRateHertz: this.sampleRate,
|
|
152
|
+
audioChannelCount: 1,
|
|
153
|
+
},
|
|
154
|
+
languageCodes: [this.languageCode],
|
|
155
|
+
model: this.model,
|
|
156
|
+
features: {
|
|
157
|
+
enableAutomaticPunctuation: true,
|
|
158
|
+
enableWordConfidence: true,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
streamingFeatures: {
|
|
162
|
+
interimResults: true,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
this.conn?.send(JSON.stringify(configMsg));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private handleMessage(data: SocketData): void {
|
|
170
|
+
if (typeof data !== "string") return;
|
|
171
|
+
try {
|
|
172
|
+
const msg = JSON.parse(data);
|
|
173
|
+
const results = msg.results;
|
|
174
|
+
if (!Array.isArray(results) || results.length === 0) return;
|
|
175
|
+
|
|
176
|
+
for (const result of results) {
|
|
177
|
+
const alt = result.alternatives?.[0];
|
|
178
|
+
if (!alt?.transcript) continue;
|
|
179
|
+
|
|
180
|
+
const text = String(alt.transcript).trim();
|
|
181
|
+
if (!text) continue;
|
|
182
|
+
const confidence = alt.confidence ?? 0;
|
|
183
|
+
|
|
184
|
+
if (this.confidenceThreshold > 0 && confidence < this.confidenceThreshold) {
|
|
185
|
+
this.pushMetric(this.currentContextId, "stt_low_confidence", String(confidence));
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result.isFinal === true) {
|
|
190
|
+
this.bus?.push(Route.Main, {
|
|
191
|
+
kind: "stt.result",
|
|
192
|
+
contextId: this.currentContextId,
|
|
193
|
+
timestampMs: Date.now(),
|
|
194
|
+
text,
|
|
195
|
+
confidence,
|
|
196
|
+
language: this.languageCode,
|
|
197
|
+
provider: { name: "google", model: this.model, region: "global" },
|
|
198
|
+
});
|
|
199
|
+
if (this.emitEosOnFinal) {
|
|
200
|
+
this.bus?.push(Route.Main, {
|
|
201
|
+
kind: "eos.turn_complete",
|
|
202
|
+
contextId: this.currentContextId,
|
|
203
|
+
timestampMs: Date.now(),
|
|
204
|
+
text,
|
|
205
|
+
transcripts: [],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
this.bus?.push(Route.Main, {
|
|
210
|
+
kind: "stt.interim",
|
|
211
|
+
contextId: this.currentContextId,
|
|
212
|
+
timestampMs: Date.now(),
|
|
213
|
+
text,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// Provider keepalives or malformed transient messages are ignored.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private emitError(error: Error, category = categorizeSttError(error)): void {
|
|
223
|
+
const packet: SttErrorPacket = {
|
|
224
|
+
kind: "stt.error",
|
|
225
|
+
contextId: this.currentContextId,
|
|
226
|
+
timestampMs: Date.now(),
|
|
227
|
+
component: "stt" as const,
|
|
228
|
+
category,
|
|
229
|
+
cause: error,
|
|
230
|
+
isRecoverable: isRecoverable(category),
|
|
231
|
+
};
|
|
232
|
+
this.bus?.push(Route.Critical, packet);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private pushMetric(contextId: string, name: string, value: string): void {
|
|
236
|
+
this.bus?.push(Route.Background, {
|
|
237
|
+
kind: "metric.conversation",
|
|
238
|
+
contextId,
|
|
239
|
+
timestampMs: Date.now(),
|
|
240
|
+
name,
|
|
241
|
+
value,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function defaultSocketFactory(): Promise<SocketFactory> {
|
|
247
|
+
const mod = await import("@kuralle-syrinx/ws/node");
|
|
248
|
+
return mod.createNodeWsSocket;
|
|
249
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitReturns": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"rootDir": "./src",
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|