@kuralle-syrinx/google 4.2.0 → 4.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/google",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Google Cloud Speech-to-Text v2 STT adapter for Syrinx",
6
6
  "keywords": [
@@ -25,14 +25,15 @@
25
25
  "main": "./src/index.ts",
26
26
  "types": "./src/index.ts",
27
27
  "dependencies": {
28
- "ws": "^8.18.0",
29
- "@kuralle-syrinx/core": "4.2.0",
30
- "@kuralle-syrinx/ws": "4.2.0"
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"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/ws": "^8.5.0",
34
35
  "typescript": "^5.7.0",
35
- "vitest": "^2.1.0"
36
+ "vitest": "^3.2.6"
36
37
  },
37
38
  "scripts": {
38
39
  "typecheck": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -3,51 +3,99 @@
3
3
  // Syrinx Kernel v2 — Google Cloud Speech-to-Text Plugin
4
4
  //
5
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.
6
+ // Wire protocol + lifecycle live in @kuralle-syrinx/stt-core; this file is
7
+ // the Google connect/config/decode surface only.
8
8
  //
9
9
  // Reference: Rapida transformer/google/stt.go (GCP Speech-to-Text v2 API)
10
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
11
 
17
- import type { PipelineBus } from "@kuralle-syrinx/core";
18
12
  import {
19
13
  Route,
20
- type AudioFormat,
21
- type VoicePlugin,
22
- type PluginConfig,
23
- type SttErrorPacket,
24
14
  assertAudioFormat,
25
- assertAudioPayload,
26
- requireStringConfig,
27
15
  optionalStringConfig,
28
- categorizeSttError,
29
- isRecoverable,
30
16
  readProviderRetryConfig,
17
+ requireStringConfig,
18
+ type AudioFormat,
19
+ type PipelineBus,
20
+ type PluginConfig,
21
+ type VoicePlugin,
31
22
  } 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
- }
23
+ import {
24
+ defaultNodeSocketFactory,
25
+ startStreamingSttSession,
26
+ type SttEvent,
27
+ type SttWireProtocol,
28
+ type StreamingSttSession,
29
+ } from "@kuralle-syrinx/stt-core";
30
+ import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
31
+
32
+ type MetricSink = (name: string, value: string) => void;
33
+
34
+ class GoogleSttWireProtocol implements SttWireProtocol {
35
+ constructor(
36
+ private readonly configMsg: Record<string, unknown>,
37
+ private readonly confidenceThreshold: number,
38
+ private readonly onMetric: MetricSink,
39
+ ) {}
40
+
41
+ onOpen(): readonly SocketData[] {
42
+ return [JSON.stringify(this.configMsg)];
43
+ }
47
44
 
48
- // =============================================================================
49
- // Plugin
50
- // =============================================================================
45
+ encodeFinalize(_contextId: string): readonly SocketData[] {
46
+ return [];
47
+ }
48
+
49
+ decode(data: SocketData, _isBinary: boolean): readonly SttEvent[] {
50
+ if (typeof data !== "string") return [];
51
+ let msg: { results?: unknown };
52
+ try {
53
+ msg = JSON.parse(data) as { results?: unknown };
54
+ } catch {
55
+ // Provider keepalives or malformed transient messages are ignored.
56
+ return [];
57
+ }
58
+
59
+ const results = msg.results;
60
+ if (!Array.isArray(results) || results.length === 0) return [];
61
+
62
+ const events: SttEvent[] = [];
63
+ for (const result of results) {
64
+ const r = result as {
65
+ alternatives?: Array<{ transcript?: unknown; confidence?: number }>;
66
+ isFinal?: boolean;
67
+ resultEndOffset?: unknown;
68
+ resultEndTime?: unknown;
69
+ };
70
+ const alt = r.alternatives?.[0];
71
+ if (!alt?.transcript) continue;
72
+
73
+ const text = String(alt.transcript).trim();
74
+ if (!text) continue;
75
+ const confidence = alt.confidence ?? 0;
76
+
77
+ if (this.confidenceThreshold > 0 && confidence < this.confidenceThreshold) {
78
+ this.onMetric("stt_low_confidence", String(confidence));
79
+ continue;
80
+ }
81
+
82
+ if (r.isFinal === true) {
83
+ const audioSeconds = parseDurationSeconds(r.resultEndOffset ?? r.resultEndTime);
84
+ events.push({
85
+ type: "final",
86
+ contextId: "",
87
+ text,
88
+ confidence,
89
+ speechFinal: true,
90
+ ...(audioSeconds !== null && audioSeconds > 0 ? { audioSeconds } : {}),
91
+ });
92
+ } else {
93
+ events.push({ type: "interim", contextId: "", text });
94
+ }
95
+ }
96
+ return events;
97
+ }
98
+ }
51
99
 
52
100
  export class GoogleSTTPlugin implements VoicePlugin {
53
101
  readonly endpointingCapability = {
@@ -59,191 +107,135 @@ export class GoogleSTTPlugin implements VoicePlugin {
59
107
 
60
108
  constructor(private readonly socketFactory?: SocketFactory) {}
61
109
 
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 };
110
+ private session: StreamingSttSession | null = null;
76
111
 
77
112
  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
- }
113
+ const apiKey = requireStringConfig(config, "api_key");
114
+ const projectId = requireStringConfig(config, "project_id");
115
+ const languageCode = optionalStringConfig(config, "language") ?? "en-US";
116
+ const languageCodes = readStringList(config["language_codes"]) ?? [languageCode];
117
+ const model = optionalStringConfig(config, "model") ?? "latest_long";
118
+ const endpointUrl = optionalStringConfig(config, "endpoint_url");
119
+ const location = optionalStringConfig(config, "location") ?? "global";
120
+ const recognizerId = optionalStringConfig(config, "recognizer") ?? "_";
121
+ const sampleRate = (config["sample_rate"] as number) ?? 16000;
122
+ const encoding = optionalStringConfig(config, "encoding") ?? "LINEAR16";
123
+ const audioChannelCount =
124
+ typeof config["channels"] === "number" && Number.isFinite(config["channels"])
125
+ ? Math.max(1, Math.floor(config["channels"] as number))
126
+ : 1;
127
+ const enableAutomaticPunctuation = (config["enable_automatic_punctuation"] as boolean) ?? true;
128
+ const enableWordConfidence = (config["enable_word_confidence"] as boolean) ?? true;
129
+ const interimResults = (config["interim_results"] as boolean) ?? true;
130
+ const recognitionFeatures = readPlainObject(config["recognition_features"]);
131
+ const streamingFeatures = readPlainObject(config["streaming_features"]);
132
+ const confidenceThreshold = (config["confidence_threshold"] as number) ?? 0;
133
+ const emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
134
+
135
+ const recognizerPath = `projects/${projectId}/locations/${location}/recognizers/${recognizerId}`;
136
+ const audioFormat: AudioFormat = {
137
+ encoding: "pcm_s16le",
138
+ sampleRateHz: sampleRate,
139
+ channels: 1,
140
+ };
141
+ assertAudioFormat(audioFormat);
143
142
 
144
- private sendConfig(): void {
145
143
  const configMsg = {
146
- recognizer: this.recognizerPath,
144
+ recognizer: recognizerPath,
147
145
  streamingConfig: {
148
146
  config: {
149
147
  explicitDecodingConfig: {
150
- encoding: "LINEAR16",
151
- sampleRateHertz: this.sampleRate,
152
- audioChannelCount: 1,
148
+ encoding,
149
+ sampleRateHertz: sampleRate,
150
+ audioChannelCount,
153
151
  },
154
- languageCodes: [this.languageCode],
155
- model: this.model,
152
+ languageCodes,
153
+ model,
156
154
  features: {
157
- enableAutomaticPunctuation: true,
158
- enableWordConfidence: true,
155
+ enableAutomaticPunctuation,
156
+ enableWordConfidence,
157
+ ...recognitionFeatures,
159
158
  },
160
159
  },
161
160
  streamingFeatures: {
162
- interimResults: true,
161
+ interimResults,
162
+ ...streamingFeatures,
163
163
  },
164
164
  },
165
165
  };
166
- this.conn?.send(JSON.stringify(configMsg));
166
+
167
+ this.session = await startStreamingSttSession(bus, {
168
+ protocol: new GoogleSttWireProtocol(configMsg, confidenceThreshold, (name, value) => {
169
+ bus.push(Route.Background, {
170
+ kind: "metric.conversation",
171
+ contextId: "",
172
+ timestampMs: Date.now(),
173
+ name,
174
+ value,
175
+ });
176
+ }),
177
+ provider: { name: "google", model, region: "global" },
178
+ format: audioFormat,
179
+ language: languageCode,
180
+ emitEosOnFinal,
181
+ url: () =>
182
+ endpointUrl ??
183
+ `wss://speech.googleapis.com/v2/${recognizerPath}:streamingRecognize?key=${apiKey}`,
184
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
185
+ retry: readProviderRetryConfig(config),
186
+ replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
187
+ metricPrefix: "stt.google",
188
+ });
167
189
  }
168
190
 
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
- }
191
+ async close(): Promise<void> {
192
+ await this.session?.dispose();
193
+ this.session = null;
220
194
  }
195
+ }
221
196
 
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);
197
+ /** Parse Google protobuf Duration JSON (`"1.250s"` or `{seconds,nanos}`) seconds, or null. */
198
+ function parseDurationSeconds(value: unknown): number | null {
199
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
200
+ if (typeof value === "string") {
201
+ const trimmed = value.trim();
202
+ if (trimmed.endsWith("s")) {
203
+ const n = Number(trimmed.slice(0, -1));
204
+ return Number.isFinite(n) && n >= 0 ? n : null;
205
+ }
206
+ const n = Number(trimmed);
207
+ return Number.isFinite(n) && n >= 0 ? n : null;
233
208
  }
209
+ if (value && typeof value === "object") {
210
+ const obj = value as { seconds?: unknown; nanos?: unknown };
211
+ const seconds =
212
+ typeof obj.seconds === "number"
213
+ ? obj.seconds
214
+ : typeof obj.seconds === "string"
215
+ ? Number(obj.seconds)
216
+ : 0;
217
+ const nanos =
218
+ typeof obj.nanos === "number"
219
+ ? obj.nanos
220
+ : typeof obj.nanos === "string"
221
+ ? Number(obj.nanos)
222
+ : 0;
223
+ if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) return null;
224
+ const total = seconds + nanos / 1e9;
225
+ return total >= 0 ? total : null;
226
+ }
227
+ return null;
228
+ }
234
229
 
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
- });
230
+ function readPlainObject(value: unknown): Record<string, unknown> | undefined {
231
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
232
+ return value as Record<string, unknown>;
243
233
  }
234
+ return undefined;
244
235
  }
245
236
 
246
- async function defaultSocketFactory(): Promise<SocketFactory> {
247
- const mod = await import("@kuralle-syrinx/ws/node");
248
- return mod.createNodeWsSocket;
237
+ function readStringList(value: unknown): string[] | undefined {
238
+ if (!Array.isArray(value)) return undefined;
239
+ const list = value.filter((item): item is string => typeof item === "string" && item.length > 0);
240
+ return list.length > 0 ? list : undefined;
249
241
  }
package/src/index.test.ts DELETED
@@ -1,273 +0,0 @@
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/tsconfig.json DELETED
@@ -1,21 +0,0 @@
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
- }