@kuralle-syrinx/google 4.4.0 → 4.5.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.
@@ -0,0 +1,16 @@
1
+ import { type PipelineBus, type PluginConfig, type VoicePlugin } from "@kuralle-syrinx/core";
2
+ import type { SocketFactory } from "@kuralle-syrinx/ws";
3
+ export declare class GoogleSTTPlugin implements VoicePlugin {
4
+ private readonly socketFactory?;
5
+ readonly endpointingCapability: {
6
+ owner: "provider_stt";
7
+ disableConfig: {
8
+ emit_eos_on_final: boolean;
9
+ };
10
+ };
11
+ constructor(socketFactory?: SocketFactory | undefined);
12
+ private session;
13
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
14
+ close(): Promise<void>;
15
+ }
16
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,OAAO,EAOL,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAQ9B,OAAO,KAAK,EAAc,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAsEpE,qBAAa,eAAgB,YAAW,WAAW;IAQrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAP3C,QAAQ,CAAC,qBAAqB;;;;;MAK5B;gBAE2B,aAAa,CAAC,EAAE,aAAa,YAAA;IAE1D,OAAO,CAAC,OAAO,CAAoC;IAE7C,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA+EjE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAI7B"}
package/dist/index.js ADDED
@@ -0,0 +1,208 @@
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
+ // Wire protocol + lifecycle live in @kuralle-syrinx/stt-core; this file is
7
+ // the Google connect/config/decode surface only.
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
+ import { Route, assertAudioFormat, optionalStringConfig, readProviderRetryConfig, requireStringConfig, } from "@kuralle-syrinx/core";
12
+ import { defaultNodeSocketFactory, startStreamingSttSession, } from "@kuralle-syrinx/stt-core";
13
+ class GoogleSttWireProtocol {
14
+ configMsg;
15
+ confidenceThreshold;
16
+ onMetric;
17
+ constructor(configMsg, confidenceThreshold, onMetric) {
18
+ this.configMsg = configMsg;
19
+ this.confidenceThreshold = confidenceThreshold;
20
+ this.onMetric = onMetric;
21
+ }
22
+ onOpen() {
23
+ return [JSON.stringify(this.configMsg)];
24
+ }
25
+ encodeFinalize(_contextId) {
26
+ return [];
27
+ }
28
+ decode(data, _isBinary) {
29
+ if (typeof data !== "string")
30
+ return [];
31
+ let msg;
32
+ try {
33
+ msg = JSON.parse(data);
34
+ }
35
+ catch {
36
+ // Provider keepalives or malformed transient messages are ignored.
37
+ return [];
38
+ }
39
+ const results = msg.results;
40
+ if (!Array.isArray(results) || results.length === 0)
41
+ return [];
42
+ const events = [];
43
+ for (const result of results) {
44
+ const r = result;
45
+ const alt = r.alternatives?.[0];
46
+ if (!alt?.transcript)
47
+ continue;
48
+ const text = String(alt.transcript).trim();
49
+ if (!text)
50
+ continue;
51
+ const confidence = alt.confidence ?? 0;
52
+ if (this.confidenceThreshold > 0 && confidence < this.confidenceThreshold) {
53
+ this.onMetric("stt_low_confidence", String(confidence));
54
+ continue;
55
+ }
56
+ if (r.isFinal === true) {
57
+ const audioSeconds = parseDurationSeconds(r.resultEndOffset ?? r.resultEndTime);
58
+ events.push({
59
+ type: "final",
60
+ contextId: "",
61
+ text,
62
+ confidence,
63
+ speechFinal: true,
64
+ ...(audioSeconds !== null && audioSeconds > 0 ? { audioSeconds } : {}),
65
+ });
66
+ }
67
+ else {
68
+ events.push({ type: "interim", contextId: "", text });
69
+ }
70
+ }
71
+ return events;
72
+ }
73
+ }
74
+ export class GoogleSTTPlugin {
75
+ socketFactory;
76
+ endpointingCapability = {
77
+ owner: "provider_stt",
78
+ disableConfig: {
79
+ emit_eos_on_final: false,
80
+ },
81
+ };
82
+ constructor(socketFactory) {
83
+ this.socketFactory = socketFactory;
84
+ }
85
+ session = null;
86
+ async initialize(bus, config) {
87
+ const apiKey = requireStringConfig(config, "api_key");
88
+ const projectId = requireStringConfig(config, "project_id");
89
+ const languageCode = optionalStringConfig(config, "language") ?? "en-US";
90
+ const languageCodes = readStringList(config["language_codes"]) ?? [languageCode];
91
+ const model = optionalStringConfig(config, "model") ?? "latest_long";
92
+ const endpointUrl = optionalStringConfig(config, "endpoint_url");
93
+ const location = optionalStringConfig(config, "location") ?? "global";
94
+ const recognizerId = optionalStringConfig(config, "recognizer") ?? "_";
95
+ const sampleRate = config["sample_rate"] ?? 16000;
96
+ const encoding = optionalStringConfig(config, "encoding") ?? "LINEAR16";
97
+ const audioChannelCount = typeof config["channels"] === "number" && Number.isFinite(config["channels"])
98
+ ? Math.max(1, Math.floor(config["channels"]))
99
+ : 1;
100
+ const enableAutomaticPunctuation = config["enable_automatic_punctuation"] ?? true;
101
+ const enableWordConfidence = config["enable_word_confidence"] ?? true;
102
+ const interimResults = config["interim_results"] ?? true;
103
+ const recognitionFeatures = readPlainObject(config["recognition_features"]);
104
+ const streamingFeatures = readPlainObject(config["streaming_features"]);
105
+ const confidenceThreshold = config["confidence_threshold"] ?? 0;
106
+ const emitEosOnFinal = config["emit_eos_on_final"] ?? true;
107
+ const recognizerPath = `projects/${projectId}/locations/${location}/recognizers/${recognizerId}`;
108
+ const audioFormat = {
109
+ encoding: "pcm_s16le",
110
+ sampleRateHz: sampleRate,
111
+ channels: 1,
112
+ };
113
+ assertAudioFormat(audioFormat);
114
+ const configMsg = {
115
+ recognizer: recognizerPath,
116
+ streamingConfig: {
117
+ config: {
118
+ explicitDecodingConfig: {
119
+ encoding,
120
+ sampleRateHertz: sampleRate,
121
+ audioChannelCount,
122
+ },
123
+ languageCodes,
124
+ model,
125
+ features: {
126
+ enableAutomaticPunctuation,
127
+ enableWordConfidence,
128
+ ...recognitionFeatures,
129
+ },
130
+ },
131
+ streamingFeatures: {
132
+ interimResults,
133
+ ...streamingFeatures,
134
+ },
135
+ },
136
+ };
137
+ this.session = await startStreamingSttSession(bus, {
138
+ protocol: new GoogleSttWireProtocol(configMsg, confidenceThreshold, (name, value) => {
139
+ bus.push(Route.Background, {
140
+ kind: "metric.conversation",
141
+ contextId: "",
142
+ timestampMs: Date.now(),
143
+ name,
144
+ value,
145
+ });
146
+ }),
147
+ provider: { name: "google", model, region: "global" },
148
+ format: audioFormat,
149
+ language: languageCode,
150
+ emitEosOnFinal,
151
+ url: () => endpointUrl ??
152
+ `wss://speech.googleapis.com/v2/${recognizerPath}:streamingRecognize?key=${apiKey}`,
153
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
154
+ retry: readProviderRetryConfig(config),
155
+ replayBufferSize: config["replay_buffer_size"] ?? 64,
156
+ metricPrefix: "stt.google",
157
+ });
158
+ }
159
+ async close() {
160
+ await this.session?.dispose();
161
+ this.session = null;
162
+ }
163
+ }
164
+ /** Parse Google protobuf Duration JSON (`"1.250s"` or `{seconds,nanos}`) → seconds, or null. */
165
+ function parseDurationSeconds(value) {
166
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
167
+ return value;
168
+ if (typeof value === "string") {
169
+ const trimmed = value.trim();
170
+ if (trimmed.endsWith("s")) {
171
+ const n = Number(trimmed.slice(0, -1));
172
+ return Number.isFinite(n) && n >= 0 ? n : null;
173
+ }
174
+ const n = Number(trimmed);
175
+ return Number.isFinite(n) && n >= 0 ? n : null;
176
+ }
177
+ if (value && typeof value === "object") {
178
+ const obj = value;
179
+ const seconds = typeof obj.seconds === "number"
180
+ ? obj.seconds
181
+ : typeof obj.seconds === "string"
182
+ ? Number(obj.seconds)
183
+ : 0;
184
+ const nanos = typeof obj.nanos === "number"
185
+ ? obj.nanos
186
+ : typeof obj.nanos === "string"
187
+ ? Number(obj.nanos)
188
+ : 0;
189
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos))
190
+ return null;
191
+ const total = seconds + nanos / 1e9;
192
+ return total >= 0 ? total : null;
193
+ }
194
+ return null;
195
+ }
196
+ function readPlainObject(value) {
197
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
198
+ return value;
199
+ }
200
+ return undefined;
201
+ }
202
+ function readStringList(value) {
203
+ if (!Array.isArray(value))
204
+ return undefined;
205
+ const list = value.filter((item) => typeof item === "string" && item.length > 0);
206
+ return list.length > 0 ? list : undefined;
207
+ }
208
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/google",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "private": false,
5
5
  "description": "Google Cloud Speech-to-Text v2 STT adapter for Syrinx",
6
6
  "keywords": [
@@ -22,21 +22,28 @@
22
22
  "url": "https://github.com/kuralle/syrinx/issues"
23
23
  },
24
24
  "type": "module",
25
- "main": "./src/index.ts",
26
- "types": "./src/index.ts",
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
27
  "dependencies": {
28
28
  "ws": "^8.21.0",
29
- "@kuralle-syrinx/core": "4.4.0",
30
- "@kuralle-syrinx/stt-core": "4.4.0",
31
- "@kuralle-syrinx/ws": "4.4.0"
29
+ "@kuralle-syrinx/core": "4.5.0",
30
+ "@kuralle-syrinx/stt-core": "4.5.0",
31
+ "@kuralle-syrinx/ws": "4.5.0"
32
32
  },
33
33
  "devDependencies": {
34
+ "@types/node": "^22.0.0",
34
35
  "@types/ws": "^8.5.0",
35
36
  "typescript": "^5.7.0",
36
37
  "vitest": "^3.2.6"
37
38
  },
39
+ "files": [
40
+ "dist",
41
+ "src",
42
+ "README.md"
43
+ ],
38
44
  "scripts": {
39
45
  "typecheck": "tsc --noEmit",
40
- "test": "vitest run"
46
+ "test": "vitest run",
47
+ "build": "tsc -p tsconfig.build.json"
41
48
  }
42
49
  }
@@ -0,0 +1,528 @@
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
+ type UsageRecordedPacket,
13
+ } from "@kuralle-syrinx/core";
14
+ import { GoogleSTTPlugin } from "./index.js";
15
+
16
+ let servers: WebSocketServer[] = [];
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(
20
+ servers.splice(0).map(
21
+ (server) =>
22
+ new Promise<void>((resolve) => {
23
+ for (const client of server.clients) client.terminate();
24
+ server.close(() => resolve());
25
+ }),
26
+ ),
27
+ );
28
+ });
29
+
30
+ async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
31
+ const server = await new Promise<WebSocketServer>((resolve) => {
32
+ let nextServer: WebSocketServer;
33
+ nextServer = new WebSocketServer({ port: 0 }, () => {
34
+ resolve(nextServer);
35
+ });
36
+ });
37
+ servers.push(server);
38
+ server.on("connection", onConnection);
39
+ const address = server.address();
40
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
41
+ return `ws://127.0.0.1:${address.port}`;
42
+ }
43
+
44
+ function startBus(bus: PipelineBusImpl): Promise<void> {
45
+ const started = bus.start();
46
+ return started;
47
+ }
48
+
49
+ describe("GoogleSTTPlugin", () => {
50
+ it("pushes interim and final packets from Google streaming responses", async () => {
51
+ const endpointUrl = await createLocalServer((socket) => {
52
+ socket.once("message", () => {
53
+ socket.send(JSON.stringify({
54
+ results: [{
55
+ isFinal: false,
56
+ alternatives: [{ transcript: "hello", confidence: 0.7 }],
57
+ }],
58
+ }));
59
+ socket.send(JSON.stringify({
60
+ results: [{
61
+ isFinal: true,
62
+ alternatives: [{ transcript: "hello world", confidence: 0.95 }],
63
+ }],
64
+ }));
65
+ });
66
+ });
67
+ const bus = new PipelineBusImpl();
68
+ const started = startBus(bus);
69
+ const plugin = new GoogleSTTPlugin();
70
+ const interims: SttInterimPacket[] = [];
71
+ const finals: SttResultPacket[] = [];
72
+ bus.on("stt.interim", (pkt) => {
73
+ interims.push(pkt as SttInterimPacket);
74
+ });
75
+ bus.on("stt.result", (pkt) => {
76
+ finals.push(pkt as SttResultPacket);
77
+ });
78
+
79
+ await plugin.initialize(bus, {
80
+ api_key: "test",
81
+ project_id: "test-project",
82
+ endpoint_url: endpointUrl,
83
+ });
84
+ bus.push(Route.Main, {
85
+ kind: "stt.audio",
86
+ contextId: "turn-1",
87
+ timestampMs: Date.now(),
88
+ audio: new Uint8Array([1, 2, 3, 4]),
89
+ });
90
+ await new Promise((resolve) => setTimeout(resolve, 30));
91
+
92
+ expect(interims).toEqual([
93
+ expect.objectContaining({ kind: "stt.interim", contextId: "turn-1", text: "hello" }),
94
+ ]);
95
+ expect(finals).toEqual([
96
+ expect.objectContaining({
97
+ kind: "stt.result",
98
+ contextId: "turn-1",
99
+ text: "hello world",
100
+ confidence: 0.95,
101
+ language: "en-US",
102
+ }),
103
+ ]);
104
+
105
+ await plugin.close();
106
+ bus.stop();
107
+ await started;
108
+ });
109
+
110
+ it("reconnects after a recoverable socket close before sending later audio", async () => {
111
+ let connections = 0;
112
+ const receivedFrames: Array<{ connection: number; kind: "config" | "audio"; payload: unknown }> = [];
113
+ const endpointUrl = await createLocalServer((socket) => {
114
+ connections++;
115
+ const connection = connections;
116
+ if (connections === 1) {
117
+ socket.close();
118
+ return;
119
+ }
120
+ socket.on("message", (data, isBinary) => {
121
+ if (isBinary) {
122
+ receivedFrames.push({ connection, kind: "audio", payload: Buffer.from(data as Buffer) });
123
+ } else {
124
+ receivedFrames.push({ connection, kind: "config", payload: JSON.parse(data.toString()) });
125
+ }
126
+ });
127
+ });
128
+ const bus = new PipelineBusImpl();
129
+ const started = startBus(bus);
130
+ const plugin = new GoogleSTTPlugin();
131
+
132
+ await plugin.initialize(bus, {
133
+ api_key: "test",
134
+ project_id: "test-project",
135
+ endpoint_url: endpointUrl,
136
+ retry_base_delay_ms: 1,
137
+ retry_max_delay_ms: 1,
138
+ });
139
+ await new Promise((resolve) => setTimeout(resolve, 30));
140
+ bus.push(Route.Main, {
141
+ kind: "stt.audio",
142
+ contextId: "turn-1",
143
+ timestampMs: Date.now(),
144
+ audio: new Uint8Array([9, 8, 7, 6]),
145
+ });
146
+ await new Promise((resolve) => setTimeout(resolve, 50));
147
+
148
+ expect(connections).toBeGreaterThanOrEqual(2);
149
+ expect(receivedFrames).toEqual([
150
+ expect.objectContaining({ connection: 2, kind: "config" }),
151
+ expect.objectContaining({ connection: 2, kind: "audio", payload: Buffer.from([9, 8, 7, 6]) }),
152
+ ]);
153
+
154
+ await plugin.close();
155
+ bus.stop();
156
+ await started;
157
+ });
158
+
159
+ it("uses configured sample_rate in the audio contract and Google decoding config", async () => {
160
+ const configs: any[] = [];
161
+ const endpointUrl = await createLocalServer((socket) => {
162
+ socket.on("message", (data, isBinary) => {
163
+ if (!isBinary) configs.push(JSON.parse(data.toString()));
164
+ });
165
+ });
166
+ const bus = new PipelineBusImpl();
167
+ const started = startBus(bus);
168
+ const plugin = new GoogleSTTPlugin();
169
+
170
+ await plugin.initialize(bus, {
171
+ api_key: "test",
172
+ project_id: "test-project",
173
+ endpoint_url: endpointUrl,
174
+ sample_rate: 8000,
175
+ });
176
+ await new Promise((resolve) => setTimeout(resolve, 20));
177
+
178
+ expect(configs[0]?.streamingConfig?.config?.explicitDecodingConfig?.sampleRateHertz).toBe(8000);
179
+
180
+ await plugin.close();
181
+ bus.stop();
182
+ await started;
183
+ });
184
+
185
+ it("emits usage.recorded with stage stt from tracked audio bytes on final", async () => {
186
+ const endpointUrl = await createLocalServer((socket) => {
187
+ socket.once("message", () => {
188
+ socket.send(JSON.stringify({
189
+ results: [{
190
+ isFinal: true,
191
+ alternatives: [{ transcript: "hello world", confidence: 0.95 }],
192
+ }],
193
+ }));
194
+ });
195
+ });
196
+ const bus = new PipelineBusImpl();
197
+ const started = startBus(bus);
198
+ const plugin = new GoogleSTTPlugin();
199
+ const usage: UsageRecordedPacket[] = [];
200
+ bus.on("usage.recorded", (pkt) => {
201
+ usage.push(pkt as UsageRecordedPacket);
202
+ });
203
+
204
+ // 640 bytes pcm_s16le @ 16kHz = 640 / 2 / 16000 = 0.02 s
205
+ const audio = new Uint8Array(640);
206
+ await plugin.initialize(bus, {
207
+ api_key: "test",
208
+ project_id: "test-project",
209
+ endpoint_url: endpointUrl,
210
+ sample_rate: 16000,
211
+ model: "latest_long",
212
+ });
213
+ bus.push(Route.Main, {
214
+ kind: "stt.audio",
215
+ contextId: "turn-usage",
216
+ timestampMs: Date.now(),
217
+ audio,
218
+ });
219
+ await new Promise((resolve) => setTimeout(resolve, 40));
220
+
221
+ expect(usage).toEqual([
222
+ expect.objectContaining({
223
+ kind: "usage.recorded",
224
+ contextId: "turn-usage",
225
+ stage: "stt",
226
+ provider: "google",
227
+ model: "latest_long",
228
+ audioSeconds: 0.02,
229
+ }),
230
+ ]);
231
+
232
+ await plugin.close();
233
+ bus.stop();
234
+ await started;
235
+ });
236
+
237
+ it("prefers resultEndOffset for audioSeconds when present", async () => {
238
+ const endpointUrl = await createLocalServer((socket) => {
239
+ socket.once("message", () => {
240
+ socket.send(JSON.stringify({
241
+ results: [{
242
+ isFinal: true,
243
+ resultEndOffset: "1.250s",
244
+ alternatives: [{ transcript: "hello world", confidence: 0.95 }],
245
+ }],
246
+ }));
247
+ });
248
+ });
249
+ const bus = new PipelineBusImpl();
250
+ const started = startBus(bus);
251
+ const plugin = new GoogleSTTPlugin();
252
+ const usage: UsageRecordedPacket[] = [];
253
+ bus.on("usage.recorded", (pkt) => {
254
+ usage.push(pkt as UsageRecordedPacket);
255
+ });
256
+
257
+ await plugin.initialize(bus, {
258
+ api_key: "test",
259
+ project_id: "test-project",
260
+ endpoint_url: endpointUrl,
261
+ model: "latest_long",
262
+ });
263
+ bus.push(Route.Main, {
264
+ kind: "stt.audio",
265
+ contextId: "turn-offset",
266
+ timestampMs: Date.now(),
267
+ audio: new Uint8Array(640),
268
+ });
269
+ await new Promise((resolve) => setTimeout(resolve, 40));
270
+
271
+ expect(usage).toEqual([
272
+ expect.objectContaining({
273
+ kind: "usage.recorded",
274
+ contextId: "turn-offset",
275
+ stage: "stt",
276
+ provider: "google",
277
+ model: "latest_long",
278
+ audioSeconds: 1.25,
279
+ }),
280
+ ]);
281
+
282
+ await plugin.close();
283
+ bus.stop();
284
+ await started;
285
+ });
286
+
287
+ it("emits usage.recorded even when emit_eos_on_final is false", async () => {
288
+ const endpointUrl = await createLocalServer((socket) => {
289
+ socket.once("message", () => {
290
+ socket.send(JSON.stringify({
291
+ results: [{
292
+ isFinal: true,
293
+ alternatives: [{ transcript: "account number please", confidence: 0.95 }],
294
+ }],
295
+ }));
296
+ });
297
+ });
298
+ const bus = new PipelineBusImpl();
299
+ const started = startBus(bus);
300
+ const plugin = new GoogleSTTPlugin();
301
+ const usage: UsageRecordedPacket[] = [];
302
+ bus.on("usage.recorded", (pkt) => {
303
+ usage.push(pkt as UsageRecordedPacket);
304
+ });
305
+
306
+ const audio = new Uint8Array(640);
307
+ await plugin.initialize(bus, {
308
+ api_key: "test",
309
+ project_id: "test-project",
310
+ endpoint_url: endpointUrl,
311
+ sample_rate: 16000,
312
+ model: "latest_long",
313
+ emit_eos_on_final: false,
314
+ });
315
+ bus.push(Route.Main, {
316
+ kind: "stt.audio",
317
+ contextId: "turn-smartturn",
318
+ timestampMs: Date.now(),
319
+ audio,
320
+ });
321
+ await new Promise((resolve) => setTimeout(resolve, 40));
322
+
323
+ expect(usage).toEqual([
324
+ expect.objectContaining({
325
+ kind: "usage.recorded",
326
+ contextId: "turn-smartturn",
327
+ stage: "stt",
328
+ provider: "google",
329
+ model: "latest_long",
330
+ audioSeconds: 0.02,
331
+ }),
332
+ ]);
333
+
334
+ await plugin.close();
335
+ bus.stop();
336
+ await started;
337
+ });
338
+
339
+ it("suppresses EOS when Smart Turn ownership disables provider finalization", async () => {
340
+ const endpointUrl = await createLocalServer((socket) => {
341
+ socket.once("message", () => {
342
+ socket.send(JSON.stringify({
343
+ results: [{
344
+ isFinal: true,
345
+ alternatives: [{ transcript: "hello world", confidence: 0.95 }],
346
+ }],
347
+ }));
348
+ });
349
+ });
350
+ const bus = new PipelineBusImpl();
351
+ const started = startBus(bus);
352
+ const plugin = new GoogleSTTPlugin();
353
+ const finals: SttResultPacket[] = [];
354
+ const eos: EndOfSpeechPacket[] = [];
355
+ bus.on("stt.result", (pkt) => {
356
+ finals.push(pkt as SttResultPacket);
357
+ });
358
+ bus.on("eos.turn_complete", (pkt) => {
359
+ eos.push(pkt as EndOfSpeechPacket);
360
+ });
361
+
362
+ await plugin.initialize(bus, {
363
+ api_key: "test",
364
+ project_id: "test-project",
365
+ endpoint_url: endpointUrl,
366
+ emit_eos_on_final: false,
367
+ });
368
+ bus.push(Route.Main, {
369
+ kind: "stt.audio",
370
+ contextId: "turn-no-eos",
371
+ timestampMs: Date.now(),
372
+ audio: new Uint8Array([1, 2, 3, 4]),
373
+ });
374
+ await new Promise((resolve) => setTimeout(resolve, 30));
375
+
376
+ expect(finals).toHaveLength(1);
377
+ expect(eos).toEqual([]);
378
+
379
+ await plugin.close();
380
+ bus.stop();
381
+ await started;
382
+ });
383
+
384
+ it("emits stt.error for odd-length PCM16 without throwing into the bus pump", async () => {
385
+ const receivedAudio: Buffer[] = [];
386
+ const endpointUrl = await createLocalServer((socket) => {
387
+ socket.on("message", (data, isBinary) => {
388
+ if (isBinary) receivedAudio.push(data as Buffer);
389
+ });
390
+ });
391
+ const bus = new PipelineBusImpl();
392
+ const started = startBus(bus);
393
+ const plugin = new GoogleSTTPlugin();
394
+ const errors: SttErrorPacket[] = [];
395
+ bus.on("stt.error", (pkt) => {
396
+ errors.push(pkt as SttErrorPacket);
397
+ });
398
+
399
+ await plugin.initialize(bus, {
400
+ api_key: "test",
401
+ project_id: "test-project",
402
+ endpoint_url: endpointUrl,
403
+ });
404
+ bus.push(Route.Main, {
405
+ kind: "stt.audio",
406
+ contextId: "turn-bad",
407
+ timestampMs: Date.now(),
408
+ audio: new Uint8Array([1, 2, 3]),
409
+ });
410
+ await new Promise((resolve) => setTimeout(resolve, 30));
411
+
412
+ expect(errors).toEqual([
413
+ expect.objectContaining({
414
+ kind: "stt.error",
415
+ contextId: "turn-bad",
416
+ component: "stt",
417
+ cause: expect.objectContaining({
418
+ message: expect.stringMatching(/even number of bytes/i),
419
+ }),
420
+ }),
421
+ ]);
422
+ expect(receivedAudio).toEqual([]);
423
+
424
+ await plugin.close();
425
+ bus.stop();
426
+ await started;
427
+ });
428
+
429
+ it("defaults recognition config knobs and allows overrides + feature passthrough", async () => {
430
+ const configs: any[] = [];
431
+ const endpointUrl = await createLocalServer((socket) => {
432
+ socket.on("message", (data, isBinary) => {
433
+ if (!isBinary) configs.push(JSON.parse(data.toString()));
434
+ });
435
+ });
436
+
437
+ const busDefault = new PipelineBusImpl();
438
+ const startedDefault = startBus(busDefault);
439
+ const pluginDefault = new GoogleSTTPlugin();
440
+ await pluginDefault.initialize(busDefault, {
441
+ api_key: "test",
442
+ project_id: "test-project",
443
+ endpoint_url: endpointUrl,
444
+ });
445
+ await new Promise((resolve) => setTimeout(resolve, 20));
446
+ await pluginDefault.close();
447
+ busDefault.stop();
448
+ await startedDefault;
449
+
450
+ expect(configs[0]).toEqual({
451
+ recognizer: "projects/test-project/locations/global/recognizers/_",
452
+ streamingConfig: {
453
+ config: {
454
+ explicitDecodingConfig: {
455
+ encoding: "LINEAR16",
456
+ sampleRateHertz: 16000,
457
+ audioChannelCount: 1,
458
+ },
459
+ languageCodes: ["en-US"],
460
+ model: "latest_long",
461
+ features: {
462
+ enableAutomaticPunctuation: true,
463
+ enableWordConfidence: true,
464
+ },
465
+ },
466
+ streamingFeatures: {
467
+ interimResults: true,
468
+ },
469
+ },
470
+ });
471
+
472
+ const busOverride = new PipelineBusImpl();
473
+ const startedOverride = startBus(busOverride);
474
+ const pluginOverride = new GoogleSTTPlugin();
475
+ await pluginOverride.initialize(busOverride, {
476
+ api_key: "test",
477
+ project_id: "my-proj",
478
+ endpoint_url: endpointUrl,
479
+ location: "us-central1",
480
+ recognizer: "phone",
481
+ model: "chirp_2",
482
+ language: "es-ES",
483
+ language_codes: ["es-ES", "en-US"],
484
+ sample_rate: 8000,
485
+ encoding: "MULAW",
486
+ channels: 1,
487
+ enable_automatic_punctuation: false,
488
+ enable_word_confidence: false,
489
+ interim_results: false,
490
+ recognition_features: {
491
+ profanityFilter: true,
492
+ enableWordTimeOffsets: true,
493
+ },
494
+ streaming_features: {
495
+ enableVoiceActivityEvents: true,
496
+ },
497
+ });
498
+ await new Promise((resolve) => setTimeout(resolve, 20));
499
+ await pluginOverride.close();
500
+ busOverride.stop();
501
+ await startedOverride;
502
+
503
+ expect(configs[1]).toEqual({
504
+ recognizer: "projects/my-proj/locations/us-central1/recognizers/phone",
505
+ streamingConfig: {
506
+ config: {
507
+ explicitDecodingConfig: {
508
+ encoding: "MULAW",
509
+ sampleRateHertz: 8000,
510
+ audioChannelCount: 1,
511
+ },
512
+ languageCodes: ["es-ES", "en-US"],
513
+ model: "chirp_2",
514
+ features: {
515
+ enableAutomaticPunctuation: false,
516
+ enableWordConfidence: false,
517
+ profanityFilter: true,
518
+ enableWordTimeOffsets: true,
519
+ },
520
+ },
521
+ streamingFeatures: {
522
+ interimResults: false,
523
+ enableVoiceActivityEvents: true,
524
+ },
525
+ },
526
+ });
527
+ });
528
+ });