@kuralle-syrinx/stt-core 4.3.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/README.md +84 -0
- package/package.json +39 -0
- package/src/engine.ts +394 -0
- package/src/index.ts +16 -0
- package/src/session.ts +140 -0
- package/src/types.ts +130 -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/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @kuralle-syrinx/stt-core
|
|
2
|
+
|
|
3
|
+
The shared streaming-STT lifecycle engine for Syrinx STT adapters — the STT counterpart of
|
|
4
|
+
`@kuralle-syrinx/tts-core`. A provider becomes a Syrinx STT plugin by implementing one small,
|
|
5
|
+
socket-free port (`SttWireProtocol`); this package owns everything else: the
|
|
6
|
+
`@kuralle-syrinx/ws` `WebSocketConnection`, `stt.interim` / `stt.result` emission, the
|
|
7
|
+
smart-turn-safe final-transcript funnel, `usage.recorded{stage:"stt", audioSeconds}`
|
|
8
|
+
delta-billing, and finalize/reconfigure/reset plumbing.
|
|
9
|
+
|
|
10
|
+
## The `SttWireProtocol` port
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
export interface SttWireProtocol {
|
|
14
|
+
encodeFinalize(contextId: string): readonly SocketData[];
|
|
15
|
+
decode(data: SocketData, isBinary: boolean): readonly SttEvent[];
|
|
16
|
+
encodeClose?(): readonly SocketData[];
|
|
17
|
+
encodeAudio?(audio: Uint8Array): readonly SocketData[]; // default: raw PCM frame
|
|
18
|
+
onOpen?(): readonly SocketData[]; // handshake/config on (re)connect
|
|
19
|
+
encodeReconfigure?(partial: SttReconfigurePartial): readonly SocketData[];
|
|
20
|
+
isReady?(): boolean; // gate outbound audio pre-handshake
|
|
21
|
+
onConnectionLost?(): void;
|
|
22
|
+
attach?(host: SttProtocolHost): void; // async-emit seam (timers, etc.)
|
|
23
|
+
onFinalizeSent?(contextId: string): void;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A provider implements only wire encode/decode. The engine owns context tracking, outbound
|
|
28
|
+
audio send + finalize, the interim/final funnel, delta-billing, and (optional) `eos.turn_complete`.
|
|
29
|
+
|
|
30
|
+
## `startStreamingSttSession`
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { startStreamingSttSession, defaultNodeSocketFactory } from "@kuralle-syrinx/stt-core";
|
|
34
|
+
|
|
35
|
+
const session = await startStreamingSttSession(bus, {
|
|
36
|
+
protocol: new MyProviderWireProtocol(),
|
|
37
|
+
provider: { name: "my-provider", model: "my-model" },
|
|
38
|
+
url: () => "wss://api.example.com/stt",
|
|
39
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
40
|
+
retry: readProviderRetryConfig(config),
|
|
41
|
+
socketFactory: await defaultNodeSocketFactory(),
|
|
42
|
+
emitEosOnFinal: true, // eos.turn_complete on speechFinal:true results
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// session.dispose(): Promise<void>
|
|
46
|
+
// session.reconfigure(partial: SttReconfigurePartial): void — mid-turn keyterms/language/etc.
|
|
47
|
+
// session.reset(): void — force a transport reconnect
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Wires the standard `PipelineBus` plumbing: `stt.audio` → `engine.onAudio` (the **canonical STT
|
|
51
|
+
ingress** — plugins subscribe to `stt.audio` only, never `user.audio_received`, to avoid
|
|
52
|
+
double-sending/double-billing every frame), `stt.finalize` → `encodeFinalize`, `turn.change` /
|
|
53
|
+
`interrupt.stt` → context bookkeeping.
|
|
54
|
+
|
|
55
|
+
## Extension seams (wave 1 + wave 2)
|
|
56
|
+
|
|
57
|
+
- **`encodeAudio` / `onOpen` / `encodeReconfigure`** — optional wire hooks for providers whose
|
|
58
|
+
audio framing, connect-time handshake, or reconfigure protocol differs from the raw-PCM default.
|
|
59
|
+
- **Richer `SttEvent` vocabulary** — `speech_started`, `partial`, `eos_interim`, `eos_retracted`,
|
|
60
|
+
in addition to `interim` / `final` / `error` / `turn_complete` / `ignore`.
|
|
61
|
+
- **Pre-handshake audio buffering** — audio that races ahead of a provider handshake (e.g. Grok's
|
|
62
|
+
`transcript.created`) is buffered (capped) and flushed on the ready transition instead of dropped.
|
|
63
|
+
- **Sent-bytes billing fallback** — when a final has no provider duration, usage bills off sent PCM
|
|
64
|
+
bytes; a later duration-bearing final advances the byte marker so it can't double-bill.
|
|
65
|
+
- **`SttProtocolHost` (`attach`/`emit`/`reset`), `onFinalizeSent`, `Transport.reset`,
|
|
66
|
+
`SttEvent.turn_complete`** (wave 2) — async-emit seams for providers with their own
|
|
67
|
+
finalize-timeout/fallback/reconnect state machine (e.g. Deepgram nova's Finalize handshake).
|
|
68
|
+
|
|
69
|
+
## Providers built on it
|
|
70
|
+
|
|
71
|
+
Grok, ElevenLabs, Google, and Deepgram Flux STT are behavior-preserving migrations onto this
|
|
72
|
+
base. Deepgram nova STT also builds on it, using the wave-2 async-emit seams for its
|
|
73
|
+
Finalize-timeout state machine (multi-segment accumulation, `speech_final`/`from_finalize`
|
|
74
|
+
gating, UtteranceEnd backstop) — provider-boundary logic stays in the wire protocol; the
|
|
75
|
+
socket/reconnect/billing/buffer funnel is shared.
|
|
76
|
+
|
|
77
|
+
See `@kuralle-syrinx/grok`'s `GrokSTTPlugin` (`src/stt.ts`) or `@kuralle-syrinx/elevenlabs`'s
|
|
78
|
+
`ElevenLabsSTTPlugin` for a minimal real implementation.
|
|
79
|
+
|
|
80
|
+
## Deploy on Cloudflare Workers
|
|
81
|
+
|
|
82
|
+
Socket-free and transport-injectable like the rest of the Syrinx kernel: pass
|
|
83
|
+
`createWorkersSocket` (`@kuralle-syrinx/ws/workers`) as the `socketFactory` instead of
|
|
84
|
+
`defaultNodeSocketFactory()` to dial outbound provider WebSockets through the fetch-upgrade path.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/stt-core",
|
|
3
|
+
"version": "4.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Shared streaming-STT lifecycle engine for Syrinx STT adapters — connection, transcript funnel, usage delta-billing",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"speech-to-text",
|
|
12
|
+
"streaming"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
19
|
+
"directory": "packages/stt-core"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./src/index.ts",
|
|
26
|
+
"types": "./src/index.ts",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@kuralle-syrinx/core": "4.3.0",
|
|
29
|
+
"@kuralle-syrinx/ws": "4.3.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"typescript": "^5.7.0",
|
|
33
|
+
"vitest": "^3.2.6"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// The streaming-STT deep module. Owns the lifecycle adapters used to re-implement:
|
|
4
|
+
// context tracking, outbound audio send + finalize, transcript funnel (interim/final),
|
|
5
|
+
// usage delta-billing at finals, and optional eos.turn_complete. Socket-free: depends
|
|
6
|
+
// only on injected ports so the funnel is unit-testable without a real WebSocket.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Route,
|
|
10
|
+
assertAudioPayload,
|
|
11
|
+
categorizeSttError,
|
|
12
|
+
isRecoverable,
|
|
13
|
+
type AudioFormat,
|
|
14
|
+
type SttErrorPacket,
|
|
15
|
+
type SttInterimPacket,
|
|
16
|
+
type SttResultPacket,
|
|
17
|
+
} from "@kuralle-syrinx/core";
|
|
18
|
+
import type { SocketData } from "@kuralle-syrinx/ws";
|
|
19
|
+
|
|
20
|
+
import type { PacketSink, SttEvent, SttWireProtocol, Transport } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export interface SttEngineDeps {
|
|
23
|
+
readonly protocol: SttWireProtocol;
|
|
24
|
+
readonly transport: Transport;
|
|
25
|
+
readonly sink: PacketSink;
|
|
26
|
+
readonly provider: { readonly name: string; readonly model: string; readonly region?: string };
|
|
27
|
+
/** When true, emit `eos.turn_complete` for finals with `speechFinal: true`. */
|
|
28
|
+
readonly emitEosOnFinal: boolean;
|
|
29
|
+
/** Default language stamped on `stt.result` when the event omits one. */
|
|
30
|
+
readonly language: string;
|
|
31
|
+
/** When set, outbound audio is validated with `assertAudioPayload` before send. */
|
|
32
|
+
readonly format?: AudioFormat;
|
|
33
|
+
readonly now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SttEngine {
|
|
37
|
+
onAudio(audio: Uint8Array, contextId?: string): Promise<boolean>;
|
|
38
|
+
onFinalize(contextId?: string): Promise<void>;
|
|
39
|
+
onTurnChange(contextId: string): void;
|
|
40
|
+
onInterrupt(): void;
|
|
41
|
+
onMessage(data: SocketData, isBinary: boolean): void;
|
|
42
|
+
onConnectionLost(error: Error): void;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
readonly currentContextId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createSttEngine(deps: SttEngineDeps): SttEngine {
|
|
48
|
+
return new SttEngineImpl(deps);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cap the pre-handshake audio buffer so a never-ready session cannot grow it unbounded. */
|
|
52
|
+
const MAX_PENDING_AUDIO_FRAMES = 256;
|
|
53
|
+
|
|
54
|
+
class SttEngineImpl implements SttEngine {
|
|
55
|
+
private contextId = "";
|
|
56
|
+
/** Cumulative billed audio-seconds per context (provider duration is often cumulative). */
|
|
57
|
+
private readonly billedDurationByContextId = new Map<string, number>();
|
|
58
|
+
/** Sent PCM byte totals per context (for providers without a duration signal). */
|
|
59
|
+
private readonly sentBytesByContextId = new Map<string, number>();
|
|
60
|
+
/** Already-billed PCM byte totals per context (kept in sync with duration billing). */
|
|
61
|
+
private readonly billedBytesByContextId = new Map<string, number>();
|
|
62
|
+
/** Audio that arrived before the provider handshake (isReady()) — flushed on the ready transition. */
|
|
63
|
+
private readonly pendingAudio: Uint8Array[] = [];
|
|
64
|
+
private readonly now: () => number;
|
|
65
|
+
|
|
66
|
+
constructor(private readonly deps: SttEngineDeps) {
|
|
67
|
+
this.now = deps.now ?? (() => Date.now());
|
|
68
|
+
this.deps.protocol.attach?.({
|
|
69
|
+
emit: (event) => this.dispatch(event),
|
|
70
|
+
reset: () => {
|
|
71
|
+
this.deps.transport.reset?.();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get currentContextId(): string {
|
|
77
|
+
return this.contextId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async onAudio(audio: Uint8Array, contextId?: string): Promise<boolean> {
|
|
81
|
+
if (contextId) this.contextId = contextId;
|
|
82
|
+
if (audio.byteLength === 0) return true;
|
|
83
|
+
try {
|
|
84
|
+
if (this.deps.format) assertAudioPayload(this.deps.format, audio);
|
|
85
|
+
await this.deps.transport.ensureReady();
|
|
86
|
+
// Buffer (don't drop) audio that races ahead of the provider handshake (e.g. Grok's
|
|
87
|
+
// transcript.created); the pending frames flush on the isReady() transition in onMessage.
|
|
88
|
+
// Prevents losing the start of speech and the pre-handshake send/receive race.
|
|
89
|
+
if (this.deps.protocol.isReady && !this.deps.protocol.isReady()) {
|
|
90
|
+
this.pendingAudio.push(audio);
|
|
91
|
+
if (this.pendingAudio.length > MAX_PENDING_AUDIO_FRAMES) this.pendingAudio.shift();
|
|
92
|
+
this.recordSentBytes(this.contextId, audio.byteLength);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
this.flushPendingAudioIfReady(); // drain anything buffered before the handshake, in order
|
|
96
|
+
this.sendEncodedAudio(audio);
|
|
97
|
+
this.recordSentBytes(this.contextId, audio.byteLength);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
this.emitError(this.contextId, err instanceof Error ? err : new Error(String(err)));
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async onFinalize(contextId?: string): Promise<void> {
|
|
106
|
+
const ctx = contextId ?? this.contextId;
|
|
107
|
+
try {
|
|
108
|
+
await this.deps.transport.ensureReady();
|
|
109
|
+
if (this.deps.transport.isReady === false) return;
|
|
110
|
+
for (const frame of this.deps.protocol.encodeFinalize(ctx)) {
|
|
111
|
+
this.deps.transport.send(frame);
|
|
112
|
+
}
|
|
113
|
+
this.deps.protocol.onFinalizeSent?.(ctx);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
this.emitError(ctx, err instanceof Error ? err : new Error(String(err)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onTurnChange(nextContextId: string): void {
|
|
120
|
+
// Do NOT retire the prior context's billing here: a provider's finalize tail (e.g. nova's
|
|
121
|
+
// `from_finalize` final under smart-turn) can arrive AFTER turn.change rotates the context.
|
|
122
|
+
// Clearing on rotation would bill that trailing audio against a wiped counter (0s = under-bill).
|
|
123
|
+
// Billing is retired on true turn completion instead (speechFinal final / turn_complete / interrupt).
|
|
124
|
+
this.contextId = nextContextId;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onInterrupt(): void {
|
|
128
|
+
// Barge-in abandons the current turn — retire its billing (no trailing final will be billed).
|
|
129
|
+
if (this.contextId) this.clearContextBilling(this.contextId);
|
|
130
|
+
this.contextId = "";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onMessage(data: SocketData, isBinary: boolean): void {
|
|
134
|
+
let events: readonly SttEvent[];
|
|
135
|
+
try {
|
|
136
|
+
events = this.deps.protocol.decode(data, isBinary);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
this.emitError(this.contextId, err instanceof Error ? err : new Error(String(err)));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const event of events) this.dispatch(event);
|
|
142
|
+
this.flushPendingAudioIfReady();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private flushPendingAudioIfReady(): void {
|
|
146
|
+
if (this.pendingAudio.length === 0) return;
|
|
147
|
+
if (this.deps.protocol.isReady && !this.deps.protocol.isReady()) return;
|
|
148
|
+
const frames = this.pendingAudio.splice(0);
|
|
149
|
+
try {
|
|
150
|
+
for (const audio of frames) this.sendEncodedAudio(audio);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.emitError(this.contextId, err instanceof Error ? err : new Error(String(err)));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private sendEncodedAudio(audio: Uint8Array): void {
|
|
157
|
+
const frames = this.deps.protocol.encodeAudio?.(audio) ?? [audio];
|
|
158
|
+
for (const frame of frames) this.deps.transport.send(frame);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private recordSentBytes(contextId: string, byteLength: number): void {
|
|
162
|
+
if (!contextId || byteLength <= 0) return;
|
|
163
|
+
this.sentBytesByContextId.set(contextId, (this.sentBytesByContextId.get(contextId) ?? 0) + byteLength);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private clearContextBilling(contextId: string): void {
|
|
167
|
+
this.billedDurationByContextId.delete(contextId);
|
|
168
|
+
this.sentBytesByContextId.delete(contextId);
|
|
169
|
+
this.billedBytesByContextId.delete(contextId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onConnectionLost(error: Error): void {
|
|
173
|
+
this.deps.protocol.onConnectionLost?.();
|
|
174
|
+
this.emitError(this.contextId, error);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async close(): Promise<void> {
|
|
178
|
+
this.billedDurationByContextId.clear();
|
|
179
|
+
this.sentBytesByContextId.clear();
|
|
180
|
+
this.billedBytesByContextId.clear();
|
|
181
|
+
this.pendingAudio.length = 0;
|
|
182
|
+
try {
|
|
183
|
+
const frames = this.deps.protocol.encodeClose?.() ?? [];
|
|
184
|
+
if (frames.length > 0) {
|
|
185
|
+
await this.deps.transport.ensureReady();
|
|
186
|
+
if (this.deps.transport.isReady !== false) {
|
|
187
|
+
for (const frame of frames) this.deps.transport.send(frame);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// Best-effort session shutdown.
|
|
192
|
+
}
|
|
193
|
+
await this.deps.transport.close();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private dispatch(event: SttEvent): void {
|
|
197
|
+
switch (event.type) {
|
|
198
|
+
case "interim":
|
|
199
|
+
this.handleInterim(event);
|
|
200
|
+
return;
|
|
201
|
+
case "final":
|
|
202
|
+
this.handleFinal(event);
|
|
203
|
+
return;
|
|
204
|
+
case "speech_started":
|
|
205
|
+
this.handleSpeechStarted(event);
|
|
206
|
+
return;
|
|
207
|
+
case "partial":
|
|
208
|
+
this.handlePartial(event);
|
|
209
|
+
return;
|
|
210
|
+
case "eos_interim":
|
|
211
|
+
this.handleEosInterim(event);
|
|
212
|
+
return;
|
|
213
|
+
case "eos_retracted":
|
|
214
|
+
this.handleEosRetracted(event);
|
|
215
|
+
return;
|
|
216
|
+
case "turn_complete":
|
|
217
|
+
this.handleTurnComplete(event);
|
|
218
|
+
return;
|
|
219
|
+
case "error":
|
|
220
|
+
this.emitError(event.contextId || this.contextId, event.error);
|
|
221
|
+
return;
|
|
222
|
+
case "ignore":
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Eos-only: result + usage already emitted on per-segment `final` events. */
|
|
228
|
+
private handleTurnComplete(event: Extract<SttEvent, { type: "turn_complete" }>): void {
|
|
229
|
+
const text = event.text.trim();
|
|
230
|
+
if (!text) return;
|
|
231
|
+
const contextId = event.contextId || this.contextId;
|
|
232
|
+
this.deps.sink.push(Route.Main, {
|
|
233
|
+
kind: "eos.turn_complete",
|
|
234
|
+
contextId,
|
|
235
|
+
timestampMs: this.now(),
|
|
236
|
+
text,
|
|
237
|
+
transcripts: [],
|
|
238
|
+
});
|
|
239
|
+
// Turn committed (per-segment results already billed) — retire billing.
|
|
240
|
+
this.clearContextBilling(contextId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private handleInterim(event: Extract<SttEvent, { type: "interim" }>): void {
|
|
244
|
+
const text = event.text.trim();
|
|
245
|
+
if (!text) return;
|
|
246
|
+
const contextId = event.contextId || this.contextId;
|
|
247
|
+
const packet: SttInterimPacket = {
|
|
248
|
+
kind: "stt.interim",
|
|
249
|
+
contextId,
|
|
250
|
+
timestampMs: this.now(),
|
|
251
|
+
text,
|
|
252
|
+
};
|
|
253
|
+
this.deps.sink.push(Route.Main, packet);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private handlePartial(event: Extract<SttEvent, { type: "partial" }>): void {
|
|
257
|
+
const text = event.text.trim();
|
|
258
|
+
if (!text) return;
|
|
259
|
+
const contextId = event.contextId || this.contextId;
|
|
260
|
+
const packet: Record<string, unknown> = {
|
|
261
|
+
kind: "stt.partial",
|
|
262
|
+
contextId,
|
|
263
|
+
timestampMs: this.now(),
|
|
264
|
+
text,
|
|
265
|
+
};
|
|
266
|
+
if (event.wordTimings !== undefined) packet["wordTimings"] = event.wordTimings;
|
|
267
|
+
this.deps.sink.push(Route.Main, packet);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private handleSpeechStarted(event: Extract<SttEvent, { type: "speech_started" }>): void {
|
|
271
|
+
const contextId = event.contextId || this.contextId;
|
|
272
|
+
this.deps.sink.push(Route.Main, {
|
|
273
|
+
kind: "vad.speech_started",
|
|
274
|
+
contextId,
|
|
275
|
+
timestampMs: this.now(),
|
|
276
|
+
confidence: 1,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private handleEosInterim(event: Extract<SttEvent, { type: "eos_interim" }>): void {
|
|
281
|
+
const text = event.text.trim();
|
|
282
|
+
if (!text) return;
|
|
283
|
+
const contextId = event.contextId || this.contextId;
|
|
284
|
+
this.deps.sink.push(Route.Main, {
|
|
285
|
+
kind: "eos.interim",
|
|
286
|
+
contextId,
|
|
287
|
+
timestampMs: this.now(),
|
|
288
|
+
text,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private handleEosRetracted(event: Extract<SttEvent, { type: "eos_retracted" }>): void {
|
|
293
|
+
const contextId = event.contextId || this.contextId;
|
|
294
|
+
this.deps.sink.push(Route.Main, {
|
|
295
|
+
kind: "eos.retracted",
|
|
296
|
+
contextId,
|
|
297
|
+
timestampMs: this.now(),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private handleFinal(event: Extract<SttEvent, { type: "final" }>): void {
|
|
302
|
+
const text = event.text.trim();
|
|
303
|
+
if (!text) return;
|
|
304
|
+
const contextId = event.contextId || this.contextId;
|
|
305
|
+
const providerBase = {
|
|
306
|
+
name: this.deps.provider.name,
|
|
307
|
+
model: this.deps.provider.model,
|
|
308
|
+
region: this.deps.provider.region ?? "global",
|
|
309
|
+
};
|
|
310
|
+
const packet: SttResultPacket = {
|
|
311
|
+
kind: "stt.result",
|
|
312
|
+
contextId,
|
|
313
|
+
timestampMs: this.now(),
|
|
314
|
+
text,
|
|
315
|
+
confidence: event.confidence ?? 0,
|
|
316
|
+
language: event.language ?? this.deps.language,
|
|
317
|
+
provider: event.provider ? { ...providerBase, ...event.provider } : providerBase,
|
|
318
|
+
};
|
|
319
|
+
this.deps.sink.push(Route.Main, packet);
|
|
320
|
+
// Bill at the final-result funnel so usage fires under smart-turn endpointing too.
|
|
321
|
+
this.emitSttUsage(contextId, event.audioSeconds);
|
|
322
|
+
if (event.speechFinal) {
|
|
323
|
+
if (this.deps.emitEosOnFinal) {
|
|
324
|
+
this.deps.sink.push(Route.Main, {
|
|
325
|
+
kind: "eos.turn_complete",
|
|
326
|
+
contextId,
|
|
327
|
+
timestampMs: this.now(),
|
|
328
|
+
text,
|
|
329
|
+
transcripts: [],
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// Turn completed via a speech-final result (usage already billed above) — retire billing.
|
|
333
|
+
this.clearContextBilling(contextId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Prefer provider duration when present (Grok duration / Google resultEndOffset); otherwise
|
|
339
|
+
* bill unbilled PCM bytes as audio-seconds. Duration path also advances the byte marker so a
|
|
340
|
+
* later no-duration final cannot double-bill the same audio.
|
|
341
|
+
*/
|
|
342
|
+
private emitSttUsage(contextId: string, duration: number | undefined): void {
|
|
343
|
+
if (typeof duration === "number" && Number.isFinite(duration) && duration > 0) {
|
|
344
|
+
const billed = this.billedDurationByContextId.get(contextId) ?? 0;
|
|
345
|
+
const audioSeconds = duration - billed;
|
|
346
|
+
if (audioSeconds <= 0) return;
|
|
347
|
+
this.billedDurationByContextId.set(contextId, duration);
|
|
348
|
+
// Keep byte marker in sync so a later final without offset does not double-bill.
|
|
349
|
+
this.billedBytesByContextId.set(contextId, this.sentBytesByContextId.get(contextId) ?? 0);
|
|
350
|
+
this.deps.sink.push(Route.Background, {
|
|
351
|
+
kind: "usage.recorded",
|
|
352
|
+
contextId,
|
|
353
|
+
timestampMs: this.now(),
|
|
354
|
+
stage: "stt",
|
|
355
|
+
provider: this.deps.provider.name,
|
|
356
|
+
model: this.deps.provider.model,
|
|
357
|
+
audioSeconds,
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const sampleRate = this.deps.format?.sampleRateHz;
|
|
363
|
+
if (!sampleRate || sampleRate <= 0) return;
|
|
364
|
+
const sent = this.sentBytesByContextId.get(contextId) ?? 0;
|
|
365
|
+
const billedBytes = this.billedBytesByContextId.get(contextId) ?? 0;
|
|
366
|
+
const newBytes = sent - billedBytes;
|
|
367
|
+
if (newBytes <= 0) return;
|
|
368
|
+
this.billedBytesByContextId.set(contextId, sent);
|
|
369
|
+
const audioSeconds = newBytes / 2 / sampleRate;
|
|
370
|
+
this.deps.sink.push(Route.Background, {
|
|
371
|
+
kind: "usage.recorded",
|
|
372
|
+
contextId,
|
|
373
|
+
timestampMs: this.now(),
|
|
374
|
+
stage: "stt",
|
|
375
|
+
provider: this.deps.provider.name,
|
|
376
|
+
model: this.deps.provider.model,
|
|
377
|
+
audioSeconds,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private emitError(contextId: string, err: Error): void {
|
|
382
|
+
const category = categorizeSttError(err);
|
|
383
|
+
const packet: SttErrorPacket = {
|
|
384
|
+
kind: "stt.error",
|
|
385
|
+
contextId,
|
|
386
|
+
timestampMs: this.now(),
|
|
387
|
+
component: "stt",
|
|
388
|
+
category,
|
|
389
|
+
cause: err,
|
|
390
|
+
isRecoverable: isRecoverable(category),
|
|
391
|
+
};
|
|
392
|
+
this.deps.sink.push(Route.Critical, packet);
|
|
393
|
+
}
|
|
394
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export { createSttEngine, type SttEngine, type SttEngineDeps } from "./engine.js";
|
|
4
|
+
export {
|
|
5
|
+
startStreamingSttSession,
|
|
6
|
+
defaultNodeSocketFactory,
|
|
7
|
+
type StreamingSttSpec,
|
|
8
|
+
type StreamingSttSession,
|
|
9
|
+
} from "./session.js";
|
|
10
|
+
export type {
|
|
11
|
+
SttEvent,
|
|
12
|
+
SttProtocolHost,
|
|
13
|
+
SttWireProtocol,
|
|
14
|
+
Transport,
|
|
15
|
+
PacketSink,
|
|
16
|
+
} from "./types.js";
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Ergonomic factory: wires a provider `SttWireProtocol` into a running streaming-STT
|
|
4
|
+
// session over a `WebSocketConnection`-backed transport, with standard PipelineBus wiring
|
|
5
|
+
// (stt.audio → send, stt.finalize → encodeFinalize, turn.change / interrupt.stt context
|
|
6
|
+
// bookkeeping). A provider's published `*STTPlugin` class delegates `initialize`/`close`
|
|
7
|
+
// to this — its public surface is unchanged.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Route,
|
|
11
|
+
type AudioFormat,
|
|
12
|
+
type PipelineBus,
|
|
13
|
+
type RetryConfig,
|
|
14
|
+
type SttReconfigurePartial,
|
|
15
|
+
} from "@kuralle-syrinx/core";
|
|
16
|
+
import { WebSocketConnection, type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
|
|
17
|
+
|
|
18
|
+
import { createSttEngine } from "./engine.js";
|
|
19
|
+
import type { SttWireProtocol } from "./types.js";
|
|
20
|
+
|
|
21
|
+
export interface StreamingSttSpec {
|
|
22
|
+
readonly protocol: SttWireProtocol;
|
|
23
|
+
readonly provider: { readonly name: string; readonly model: string; readonly region?: string };
|
|
24
|
+
readonly url: () => string;
|
|
25
|
+
readonly headers?: Record<string, string>;
|
|
26
|
+
readonly retry: RetryConfig;
|
|
27
|
+
readonly socketFactory: SocketFactory;
|
|
28
|
+
readonly emitEosOnFinal?: boolean;
|
|
29
|
+
readonly language?: string;
|
|
30
|
+
readonly format?: AudioFormat;
|
|
31
|
+
readonly maxReconnectAttempts?: number;
|
|
32
|
+
readonly connectTimeoutMs?: number;
|
|
33
|
+
readonly replayBufferSize?: number;
|
|
34
|
+
readonly keepAliveIntervalMs?: number;
|
|
35
|
+
readonly keepAliveMessage?: () => SocketData;
|
|
36
|
+
readonly metricPrefix?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StreamingSttSession {
|
|
40
|
+
dispose(): Promise<void>;
|
|
41
|
+
reconfigure(partial: SttReconfigurePartial): void;
|
|
42
|
+
reset(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Open the provider socket, wire the bus, and return a handle whose `dispose()` tears it all down. */
|
|
46
|
+
export async function startStreamingSttSession(
|
|
47
|
+
bus: PipelineBus,
|
|
48
|
+
spec: StreamingSttSpec,
|
|
49
|
+
): Promise<StreamingSttSession> {
|
|
50
|
+
let conn: WebSocketConnection;
|
|
51
|
+
const metricPrefix = spec.metricPrefix ?? "stt";
|
|
52
|
+
const protocol = spec.protocol;
|
|
53
|
+
const engine = createSttEngine({
|
|
54
|
+
protocol,
|
|
55
|
+
transport: {
|
|
56
|
+
ensureReady: () => conn.ensureReady(),
|
|
57
|
+
send: (frame) => conn.send(frame),
|
|
58
|
+
close: () => conn.close(),
|
|
59
|
+
get isReady() {
|
|
60
|
+
return conn.isReady;
|
|
61
|
+
},
|
|
62
|
+
reset: () => {
|
|
63
|
+
conn.reset();
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
sink: { push: (route, packet) => bus.push(route, packet as Parameters<PipelineBus["push"]>[1]) },
|
|
67
|
+
provider: spec.provider,
|
|
68
|
+
emitEosOnFinal: spec.emitEosOnFinal ?? true,
|
|
69
|
+
language: spec.language ?? "en",
|
|
70
|
+
format: spec.format,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
conn = new WebSocketConnection({
|
|
74
|
+
url: spec.url,
|
|
75
|
+
headers: spec.headers,
|
|
76
|
+
socketFactory: spec.socketFactory,
|
|
77
|
+
retry: spec.retry,
|
|
78
|
+
maxReconnectAttempts: spec.maxReconnectAttempts,
|
|
79
|
+
connectTimeoutMs: spec.connectTimeoutMs,
|
|
80
|
+
replayBufferSize: spec.replayBufferSize,
|
|
81
|
+
keepAliveIntervalMs: spec.keepAliveIntervalMs,
|
|
82
|
+
keepAliveMessage: spec.keepAliveMessage,
|
|
83
|
+
onMessage: (data, isBinary) => engine.onMessage(data, isBinary),
|
|
84
|
+
onConnectionLost: (err) => engine.onConnectionLost(err),
|
|
85
|
+
onUnrecoverable: (err) => engine.onConnectionLost(err),
|
|
86
|
+
onReadyBeforeReplay: () => {
|
|
87
|
+
for (const frame of protocol.onOpen?.() ?? []) conn.send(frame);
|
|
88
|
+
},
|
|
89
|
+
onReplay: (event, count) =>
|
|
90
|
+
bus.push(Route.Background, {
|
|
91
|
+
kind: "metric.conversation",
|
|
92
|
+
contextId: "",
|
|
93
|
+
timestampMs: Date.now(),
|
|
94
|
+
name: `${metricPrefix}.reconnect_replay_${event}`,
|
|
95
|
+
value: String(count),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
await conn.connect();
|
|
99
|
+
|
|
100
|
+
const disposers: Array<() => void> = [
|
|
101
|
+
// STT plugins consume `stt.audio` only — the canonical STT ingress. VoiceAgentSession
|
|
102
|
+
// fans `user.audio_received` out to `stt.audio` (handleUserAudio), so subscribing to both
|
|
103
|
+
// would double-send + double-bill every frame in a real session.
|
|
104
|
+
bus.on("stt.audio", (pkt: unknown) => {
|
|
105
|
+
const audioPkt = pkt as { audio: Uint8Array; contextId?: string };
|
|
106
|
+
void engine.onAudio(audioPkt.audio, audioPkt.contextId);
|
|
107
|
+
}),
|
|
108
|
+
bus.on("stt.finalize", (pkt: unknown) => {
|
|
109
|
+
const finalizePkt = pkt as { contextId?: string };
|
|
110
|
+
void engine.onFinalize(finalizePkt.contextId);
|
|
111
|
+
}),
|
|
112
|
+
bus.on("turn.change", (pkt: unknown) => {
|
|
113
|
+
engine.onTurnChange((pkt as { contextId: string }).contextId);
|
|
114
|
+
}),
|
|
115
|
+
bus.on("interrupt.stt", () => {
|
|
116
|
+
engine.onInterrupt();
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
dispose: async () => {
|
|
122
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
123
|
+
await engine.close();
|
|
124
|
+
},
|
|
125
|
+
reconfigure: (partial: SttReconfigurePartial) => {
|
|
126
|
+
const frames = protocol.encodeReconfigure?.(partial) ?? [];
|
|
127
|
+
if (frames.length === 0) return;
|
|
128
|
+
for (const frame of frames) conn.send(frame);
|
|
129
|
+
},
|
|
130
|
+
reset: () => {
|
|
131
|
+
conn.reset();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Default Node socket factory — lazily imported so the heavy `ws` dep only loads when used. */
|
|
137
|
+
export async function defaultNodeSocketFactory(): Promise<SocketFactory> {
|
|
138
|
+
const mod = await import("@kuralle-syrinx/ws/node");
|
|
139
|
+
return mod.createNodeWsSocket;
|
|
140
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Ports for the shared streaming-STT deep module. Provider adapters implement only
|
|
4
|
+
// `SttWireProtocol`; the session owns the socket, bus wiring, and usage delta-billing.
|
|
5
|
+
|
|
6
|
+
import type { Route, SttReconfigurePartial } from "@kuralle-syrinx/core";
|
|
7
|
+
import type { SocketData } from "@kuralle-syrinx/ws";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decoded result of one inbound provider frame — a list, because a single frame can
|
|
11
|
+
* carry several aspects (or none).
|
|
12
|
+
*
|
|
13
|
+
* `audioSeconds` is the provider's per-turn (often cumulative-from-stream-start) duration
|
|
14
|
+
* signal used for billing at the final funnel; optional when the provider has no duration.
|
|
15
|
+
*/
|
|
16
|
+
export type SttEvent =
|
|
17
|
+
| {
|
|
18
|
+
readonly type: "interim";
|
|
19
|
+
readonly contextId: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly confidence?: number;
|
|
22
|
+
readonly wordTimings?: unknown;
|
|
23
|
+
readonly audioSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
readonly type: "final";
|
|
27
|
+
readonly contextId: string;
|
|
28
|
+
readonly text: string;
|
|
29
|
+
readonly confidence?: number;
|
|
30
|
+
readonly wordTimings?: unknown;
|
|
31
|
+
readonly audioSeconds?: number;
|
|
32
|
+
/** When true with `emitEosOnFinal`, the session emits `eos.turn_complete`. */
|
|
33
|
+
readonly speechFinal?: boolean;
|
|
34
|
+
readonly language?: string;
|
|
35
|
+
/** Provider-specific fields merged into `stt.result.provider`. */
|
|
36
|
+
readonly provider?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly type: "speech_started";
|
|
40
|
+
readonly contextId?: string;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
readonly type: "partial";
|
|
44
|
+
readonly contextId?: string;
|
|
45
|
+
readonly text: string;
|
|
46
|
+
readonly wordTimings?: unknown;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
readonly type: "eos_interim";
|
|
50
|
+
readonly contextId?: string;
|
|
51
|
+
readonly text: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly type: "eos_retracted";
|
|
55
|
+
readonly contextId?: string;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly type: "error";
|
|
59
|
+
readonly contextId?: string;
|
|
60
|
+
readonly error: Error;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
/** Eos-only commit after per-segment `final` results (e.g. Deepgram nova turn complete). */
|
|
64
|
+
readonly type: "turn_complete";
|
|
65
|
+
readonly contextId?: string;
|
|
66
|
+
readonly text: string;
|
|
67
|
+
}
|
|
68
|
+
| { readonly type: "ignore" };
|
|
69
|
+
|
|
70
|
+
/** Host the engine injects so a protocol can emit events or force reconnect outside decode. */
|
|
71
|
+
export interface SttProtocolHost {
|
|
72
|
+
/** Routes through engine.dispatch (same funnel as a decoded event: billing/eos/etc.). */
|
|
73
|
+
emit(event: SttEvent): void;
|
|
74
|
+
/** Force a transport reconnect (e.g. after repeated finalize timeouts). */
|
|
75
|
+
reset(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* DRIVEN PORT — provider wire protocol. Pure of sockets and the bus. This is the only
|
|
80
|
+
* surface a provider implements; connection lifecycle + funnel live in the session.
|
|
81
|
+
*/
|
|
82
|
+
export interface SttWireProtocol {
|
|
83
|
+
/** Encode the "no more audio for this turn" frame(s). `[]` if the provider has none. */
|
|
84
|
+
encodeFinalize(contextId: string): readonly SocketData[];
|
|
85
|
+
/** Optional session-teardown frame(s) sent best-effort on dispose. */
|
|
86
|
+
encodeClose?(): readonly SocketData[];
|
|
87
|
+
/**
|
|
88
|
+
* Optional wire encoding for outbound PCM. Default when unset: send raw `[audio]`
|
|
89
|
+
* (Grok / Deepgram / Google / Flux). ElevenLabs JSON-wraps each chunk.
|
|
90
|
+
*/
|
|
91
|
+
encodeAudio?(audio: Uint8Array): readonly SocketData[];
|
|
92
|
+
/** Optional config/handshake frame(s) sent on every (re)connect before replay. */
|
|
93
|
+
onOpen?(): readonly SocketData[];
|
|
94
|
+
/** Optional mid-stream reconfigure frame(s) (e.g. Flux Configure). */
|
|
95
|
+
encodeReconfigure?(partial: SttReconfigurePartial): readonly SocketData[];
|
|
96
|
+
/** Decode one inbound socket frame into domain events (0+). Throwing is treated as fatal. */
|
|
97
|
+
decode(data: SocketData, isBinary: boolean): readonly SttEvent[];
|
|
98
|
+
/**
|
|
99
|
+
* When false, outbound audio is dropped without error (e.g. awaiting provider handshake
|
|
100
|
+
* like Grok's `transcript.created`). Default true when omitted.
|
|
101
|
+
*/
|
|
102
|
+
isReady?(): boolean;
|
|
103
|
+
/** Optional: provider handshake state that must reset on socket drop (e.g. clear ready). */
|
|
104
|
+
onConnectionLost?(): void;
|
|
105
|
+
/**
|
|
106
|
+
* Optional host injection at engine construction. Protocols with async timers (e.g. nova
|
|
107
|
+
* Finalize timeout → fallback final / reconnect) use `host.emit` / `host.reset`.
|
|
108
|
+
*/
|
|
109
|
+
attach?(host: SttProtocolHost): void;
|
|
110
|
+
/**
|
|
111
|
+
* Optional: called after encodeFinalize frames were sent (transport ready). Nova starts
|
|
112
|
+
* its provider-finalize timeout here.
|
|
113
|
+
*/
|
|
114
|
+
onFinalizeSent?(contextId: string): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** DRIVEN PORT — transport. Production wraps a `WebSocketConnection`; tests pass a fake. */
|
|
118
|
+
export interface Transport {
|
|
119
|
+
ensureReady(): Promise<void>;
|
|
120
|
+
send(frame: SocketData): void;
|
|
121
|
+
close(): Promise<void>;
|
|
122
|
+
readonly isReady?: boolean;
|
|
123
|
+
/** Optional force-reconnect (session wires `conn.reset`). */
|
|
124
|
+
reset?(): void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The bus, narrowed to the one method the engine needs. Injectable for socket-free tests. */
|
|
128
|
+
export interface PacketSink {
|
|
129
|
+
push(route: Route, packet: unknown): void;
|
|
130
|
+
}
|