@kuralle-syrinx/core 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.
Files changed (52) hide show
  1. package/README.md +41 -0
  2. package/package.json +22 -0
  3. package/src/audio/audio.test.ts +285 -0
  4. package/src/audio/index.ts +5 -0
  5. package/src/audio/mulaw.ts +42 -0
  6. package/src/audio/pcm.ts +43 -0
  7. package/src/audio/resample.ts +177 -0
  8. package/src/audio-envelope.test.ts +167 -0
  9. package/src/audio-envelope.ts +143 -0
  10. package/src/conversation-event.ts +62 -0
  11. package/src/error-handler.test.ts +56 -0
  12. package/src/error-handler.ts +149 -0
  13. package/src/idle-timeout.ts +210 -0
  14. package/src/index.ts +215 -0
  15. package/src/init-chain.ts +137 -0
  16. package/src/init-stage-order.ts +79 -0
  17. package/src/latency-filler-fixtures.ts +16 -0
  18. package/src/latency-filler.test.ts +62 -0
  19. package/src/latency-filler.ts +125 -0
  20. package/src/mode-switcher.ts +110 -0
  21. package/src/observability-observer.test.ts +245 -0
  22. package/src/observability-observer.ts +195 -0
  23. package/src/observability.test.ts +85 -0
  24. package/src/observability.ts +93 -0
  25. package/src/packet-factories.test.ts +34 -0
  26. package/src/packet-factories.ts +243 -0
  27. package/src/packets.ts +553 -0
  28. package/src/pipeline-bus.g10.test.ts +145 -0
  29. package/src/pipeline-bus.test.ts +197 -0
  30. package/src/pipeline-bus.ts +369 -0
  31. package/src/plugin-contract.ts +79 -0
  32. package/src/primary-speaker-fixtures.ts +45 -0
  33. package/src/primary-speaker-gate.test.ts +150 -0
  34. package/src/primary-speaker-gate.ts +186 -0
  35. package/src/provider-fallback.test.ts +87 -0
  36. package/src/provider-fallback.ts +88 -0
  37. package/src/reasoner.test.ts +69 -0
  38. package/src/reasoner.ts +54 -0
  39. package/src/retry.test.ts +83 -0
  40. package/src/retry.ts +106 -0
  41. package/src/scheduler.ts +28 -0
  42. package/src/tts-playout-clock.test.ts +125 -0
  43. package/src/tts-playout-clock.ts +116 -0
  44. package/src/turn-arbiter.characterization.test.ts +477 -0
  45. package/src/turn-arbiter.test.ts +567 -0
  46. package/src/turn-arbiter.ts +283 -0
  47. package/src/voice-agent-session-util.ts +240 -0
  48. package/src/voice-agent-session.test.ts +2487 -0
  49. package/src/voice-agent-session.ts +1175 -0
  50. package/src/voice-text.test.ts +81 -0
  51. package/src/voice-text.ts +102 -0
  52. package/tsconfig.json +21 -0
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { PipelineBusImpl, Route, type PipelineBusConfig } from "../src/pipeline-bus.js";
5
+ import type { VoicePacket } from "../src/packets.js";
6
+
7
+ // =============================================================================
8
+ // Helpers
9
+ // =============================================================================
10
+
11
+ function pkt(kind: string, contextId = "ctx-1"): VoicePacket {
12
+ return { kind, contextId, timestampMs: Date.now() };
13
+ }
14
+
15
+ function createBus(config?: PipelineBusConfig): PipelineBusImpl {
16
+ return new PipelineBusImpl(config);
17
+ }
18
+
19
+ /** Start bus, run fn, stop bus, await drain completion. */
20
+ async function withBus(
21
+ config: PipelineBusConfig | undefined,
22
+ fn: (bus: PipelineBusImpl) => void | Promise<void>,
23
+ ): Promise<void> {
24
+ const bus = createBus(config);
25
+ const startP = bus.start();
26
+ // Give the start loop a tick to begin
27
+ await new Promise((r) => setTimeout(r, 5));
28
+ await fn(bus);
29
+ // Allow pending dispatches to complete
30
+ await new Promise((r) => setTimeout(r, 20));
31
+ bus.stop();
32
+ await startP;
33
+ }
34
+
35
+ // =============================================================================
36
+ // Tests
37
+ // =============================================================================
38
+
39
+ describe("PipelineBusImpl", () => {
40
+ describe("push and drain order", () => {
41
+ it("drains Critical before Main", async () => {
42
+ const processed: string[] = [];
43
+ await withBus(undefined, (bus) => {
44
+ bus.on("critical.event", () => { processed.push("critical"); });
45
+ bus.on("main.event", () => { processed.push("main"); });
46
+ bus.push(Route.Main, pkt("main.event"));
47
+ bus.push(Route.Critical, pkt("critical.event"));
48
+ });
49
+ expect(processed).toEqual(["critical", "main"]);
50
+ });
51
+
52
+ it("drains Main before Background", async () => {
53
+ const processed: string[] = [];
54
+ await withBus(undefined, (bus) => {
55
+ bus.on("main.event", () => { processed.push("main"); });
56
+ bus.on("bg.event", () => { processed.push("bg"); });
57
+ bus.push(Route.Background, pkt("bg.event"));
58
+ bus.push(Route.Main, pkt("main.event"));
59
+ });
60
+ expect(processed).toEqual(["main", "bg"]);
61
+ });
62
+
63
+ it("batches Critical up to criticalBatchSize before yielding", async () => {
64
+ const processed: string[] = [];
65
+ await withBus({ criticalBatchSize: 3 }, (bus) => {
66
+ bus.on("critical.event", () => { processed.push("c"); });
67
+ for (let i = 0; i < 5; i++) {
68
+ bus.push(Route.Critical, pkt("critical.event"));
69
+ }
70
+ });
71
+ expect(processed.length).toBeGreaterThanOrEqual(5);
72
+ });
73
+ });
74
+
75
+ describe("capacity and overflow", () => {
76
+ it("drops oldest Background on overflow", async () => {
77
+ const dropped: VoicePacket[] = [];
78
+ const metrics: string[] = [];
79
+ await withBus(
80
+ { bgCapacity: 2, onBackgroundDrop: (d: VoicePacket) => { dropped.push(d); } },
81
+ (bus) => {
82
+ bus.on("metric.conversation", (pkt: any) => {
83
+ metrics.push(pkt.name);
84
+ });
85
+ bus.push(Route.Background, pkt("bg.1", "id-1"));
86
+ bus.push(Route.Background, pkt("bg.2", "id-2"));
87
+ bus.push(Route.Background, pkt("bg.3", "id-3"));
88
+ },
89
+ );
90
+ expect(dropped.length).toBeGreaterThanOrEqual(1);
91
+ if (dropped.length > 0) {
92
+ expect(dropped[0]!.contextId).toBe("id-1");
93
+ }
94
+ expect(metrics).toContain("pipeline.bus.background.dropped");
95
+ });
96
+
97
+ it("throws on Main overflow", () => {
98
+ const bus = createBus({ mainCapacity: 1 });
99
+ bus.push(Route.Main, pkt("main.1"));
100
+ expect(() => bus.push(Route.Main, pkt("main.2"))).toThrow("Main queue full");
101
+ });
102
+
103
+ it("Critical never overflows", () => {
104
+ const bus = createBus();
105
+ for (let i = 0; i < 10000; i++) {
106
+ bus.push(Route.Critical, pkt("critical.event"));
107
+ }
108
+ expect(true).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("handler registration", () => {
113
+ it("calls matching handler for packet kind", async () => {
114
+ const fn = vi.fn();
115
+ await withBus(undefined, (bus) => {
116
+ bus.on("test.event", fn);
117
+ bus.push(Route.Main, pkt("test.event"));
118
+ });
119
+ expect(fn).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("does not call handler for different kind", async () => {
123
+ const fn = vi.fn();
124
+ await withBus(undefined, (bus) => {
125
+ bus.on("test.event", fn);
126
+ bus.push(Route.Main, pkt("other.event"));
127
+ });
128
+ expect(fn).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("unsubscribe removes handler", async () => {
132
+ const fn = vi.fn();
133
+ await withBus(undefined, (bus) => {
134
+ const unsub = bus.on("test.event", fn);
135
+ unsub();
136
+ bus.push(Route.Main, pkt("test.event"));
137
+ });
138
+ expect(fn).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("multiple handlers for same kind all fire", async () => {
142
+ const fn1 = vi.fn();
143
+ const fn2 = vi.fn();
144
+ await withBus(undefined, (bus) => {
145
+ bus.on("test.event", fn1);
146
+ bus.on("test.event", fn2);
147
+ bus.push(Route.Main, pkt("test.event"));
148
+ });
149
+ expect(fn1).toHaveBeenCalledTimes(1);
150
+ expect(fn2).toHaveBeenCalledTimes(1);
151
+ });
152
+ });
153
+
154
+ describe("allPackets", () => {
155
+ it("publishes every pushed packet with its route", async () => {
156
+ const bus = createBus();
157
+ const reader = bus.allPackets.getReader();
158
+ bus.push(Route.Main, pkt("main.event", "main-1"));
159
+ bus.push(Route.Critical, pkt("critical.event", "critical-1"));
160
+
161
+ const first = await reader.read();
162
+ const second = await reader.read();
163
+ reader.releaseLock();
164
+
165
+ expect(first.value).toMatchObject({
166
+ route: Route.Main,
167
+ packet: { kind: "main.event", contextId: "main-1" },
168
+ });
169
+ expect(second.value).toMatchObject({
170
+ route: Route.Critical,
171
+ packet: { kind: "critical.event", contextId: "critical-1" },
172
+ });
173
+ });
174
+ });
175
+
176
+ describe("error handling", () => {
177
+ it("handler error pushes VoiceErrorPacket to Critical", async () => {
178
+ const errorHandler = vi.fn();
179
+ await withBus(undefined, (bus) => {
180
+ bus.on("test.event", () => { throw new Error("boom"); });
181
+ bus.on("pipeline.error", errorHandler);
182
+ bus.push(Route.Main, pkt("test.event"));
183
+ });
184
+ expect(errorHandler).toHaveBeenCalled();
185
+ });
186
+
187
+ it("handler error does not stop other handlers", async () => {
188
+ const fn2 = vi.fn();
189
+ await withBus(undefined, (bus) => {
190
+ bus.on("test.event", () => { throw new Error("boom"); });
191
+ bus.on("test.event", fn2);
192
+ bus.push(Route.Main, pkt("test.event"));
193
+ });
194
+ expect(fn2).toHaveBeenCalledTimes(1);
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,369 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Priority Pipeline Bus
4
+ //
5
+ // Three priority channels (Critical, Main, Background) with strict drain order.
6
+ // All pipeline components push packets into the bus. The bus dispatches to
7
+ // registered handlers. Handlers are registered by packet kind (discriminated string).
8
+ //
9
+ // Design decisions (per RFC Q1 resolution):
10
+ // - Uses setTimeout(fn, 0) for the drain loop to yield to I/O between iterations.
11
+ // - Critical channel batches up to 4 packets per tick before yielding.
12
+ // - Main queue is bounded at 4096 packets; throws on overflow.
13
+ // - Background queue is bounded at 2048 packets; drops oldest on overflow.
14
+ // - Pipeline handler errors emit VoiceErrorPacket on Critical route — bus continues.
15
+
16
+ import type { VoicePacket, AsyncPacket, VoiceErrorPacket } from "./packets.js";
17
+ import { ErrorCategory, type ConversationMetricPacket, type PipelineErrorPacket } from "./packets.js";
18
+
19
+ // =============================================================================
20
+ // Public Types
21
+ // =============================================================================
22
+
23
+ export enum Route {
24
+ Critical = 0, // interrupts, turn changes — drained first, never bounded
25
+ Main = 1, // pipeline flow: audio in, STT results, LLM deltas, TTS audio
26
+ Background = 2, // metrics, debug events, DB writes — drained last, droppable
27
+ }
28
+
29
+ export type PacketHandler<T extends VoicePacket = VoicePacket> = (
30
+ pkt: T,
31
+ ) => void | Promise<void>;
32
+
33
+ export interface PipelineBus {
34
+ /** Push one or more packets into a priority route. */
35
+ push<T extends readonly VoicePacket[]>(route: Route, ...packets: T): void;
36
+
37
+ /**
38
+ * Register a handler for a specific packet kind. Returns unsubscribe function.
39
+ *
40
+ * By default handlers are awaited in registration order (consumer semantics — the
41
+ * handler's state mutations are visible to the next packet's handlers). Pass
42
+ * `{ concurrent: true }` for a long-running PRODUCER handler (e.g. an LLM-generation
43
+ * loop that emits its own packets over time): it is dispatched fire-and-forget so it
44
+ * does not park the drain loop and defer subsequent Main packets / Critical interrupts
45
+ * behind it. Concurrent handler errors are surfaced as `pipeline.error`, like async packets.
46
+ */
47
+ on<T extends VoicePacket>(
48
+ kind: T["kind"],
49
+ handler: PacketHandler<T>,
50
+ opts?: { concurrent?: boolean },
51
+ ): () => void;
52
+
53
+ /** Start draining the bus. Resolves when stop() is called and final drain completes. */
54
+ start(): Promise<void>;
55
+
56
+ /** Stop draining. Flushes Critical+Main, discards Background. */
57
+ stop(): void;
58
+
59
+ /** Readonly stream of every packet pushed into the bus, before route dispatch. */
60
+ readonly allPackets: ReadableStream<{ route: Route; packet: VoicePacket }>;
61
+ }
62
+
63
+ // =============================================================================
64
+ // Internal
65
+ // =============================================================================
66
+
67
+ interface QueueEntry {
68
+ route: Route;
69
+ packet: VoicePacket;
70
+ }
71
+
72
+ /**
73
+ * Configuration for PipelineBusImpl.
74
+ * Critical is always unbounded. Main and Background can be configured.
75
+ */
76
+ export interface PipelineBusConfig {
77
+ /** Maximum Main queue size. Default 4096. Throws on overflow. */
78
+ mainCapacity?: number;
79
+ /** Maximum Background queue size. Default 2048. Drops oldest on overflow. */
80
+ bgCapacity?: number;
81
+ /** Maximum Critical packets to batch per tick before yielding to I/O. Default 4. */
82
+ criticalBatchSize?: number;
83
+ /** Called when a Background packet is dropped. For metrics emission. */
84
+ onBackgroundDrop?: (dropped: VoicePacket) => void;
85
+ /** Called for every packet pushed into the bus. */
86
+ onPacket?: (route: Route, packet: VoicePacket) => void;
87
+ }
88
+
89
+ // =============================================================================
90
+ // Implementation
91
+ // =============================================================================
92
+
93
+ export class PipelineBusImpl implements PipelineBus {
94
+ private critical: VoicePacket[] = [];
95
+ private main: VoicePacket[] = [];
96
+ private background: VoicePacket[] = [];
97
+ private handlers = new Map<string, Set<PacketHandler>>();
98
+ private concurrentHandlers = new Set<PacketHandler>();
99
+ private running = false;
100
+ private resolver: (() => void) | null = null;
101
+ private drainedCount = 0;
102
+ private allPacketsController:
103
+ | ReadableStreamDefaultController<{ route: Route; packet: VoicePacket }>
104
+ | null = null;
105
+
106
+ readonly allPackets: ReadableStream<{ route: Route; packet: VoicePacket }>;
107
+
108
+ private readonly mainCapacity: number;
109
+ private readonly bgCapacity: number;
110
+ private readonly criticalBatchSize: number;
111
+ private readonly onBgDrop: ((dropped: VoicePacket) => void) | undefined;
112
+ private readonly onPacket: ((route: Route, packet: VoicePacket) => void) | undefined;
113
+
114
+ constructor(config?: PipelineBusConfig) {
115
+ this.mainCapacity = config?.mainCapacity ?? 4096;
116
+ this.bgCapacity = config?.bgCapacity ?? 2048;
117
+ this.criticalBatchSize = config?.criticalBatchSize ?? 4;
118
+ this.onBgDrop = config?.onBackgroundDrop;
119
+ this.onPacket = config?.onPacket;
120
+ this.allPackets = new ReadableStream<{ route: Route; packet: VoicePacket }>({
121
+ start: (controller) => {
122
+ this.allPacketsController = controller;
123
+ },
124
+ cancel: () => {
125
+ this.allPacketsController = null;
126
+ },
127
+ });
128
+
129
+ if (this.mainCapacity < 1) throw new Error("mainCapacity must be >= 1");
130
+ if (this.bgCapacity < 1) throw new Error("bgCapacity must be >= 1");
131
+ if (this.criticalBatchSize < 1) throw new Error("criticalBatchSize must be >= 1");
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Public API
136
+ // ---------------------------------------------------------------------------
137
+
138
+ push<T extends readonly VoicePacket[]>(route: Route, ...packets: T): void {
139
+ for (const p of packets) {
140
+ this.publishAllPackets(route, p);
141
+ const q = this.queueFor(route);
142
+ let droppedForMetric: VoicePacket | null = null;
143
+ if (q.length >= this.capacityFor(route)) {
144
+ if (route === Route.Background) {
145
+ const dropped = q.shift();
146
+ if (dropped && this.onBgDrop) {
147
+ this.onBgDrop(dropped);
148
+ }
149
+ if (dropped) {
150
+ droppedForMetric = dropped;
151
+ }
152
+ // continue — push after dropping oldest
153
+ } else if (route === Route.Main) {
154
+ throw new Error(
155
+ `PipelineBus: Main queue full (${this.mainCapacity}). ` +
156
+ `Backpressure required — slow down producers or increase capacity.`,
157
+ );
158
+ }
159
+ // Critical is never bounded
160
+ }
161
+ q.push(p);
162
+ if (droppedForMetric) {
163
+ this.enqueueBackgroundDropMetric(droppedForMetric);
164
+ }
165
+ }
166
+ // Wake the drain loop
167
+ this.resolver?.();
168
+ this.resolver = null;
169
+ }
170
+
171
+ on<T extends VoicePacket>(
172
+ kind: T["kind"],
173
+ handler: PacketHandler<T>,
174
+ opts?: { concurrent?: boolean },
175
+ ): () => void {
176
+ let set = this.handlers.get(kind);
177
+ if (!set) {
178
+ set = new Set();
179
+ this.handlers.set(kind, set);
180
+ }
181
+ const h = handler as PacketHandler;
182
+ set.add(h);
183
+ if (opts?.concurrent) this.concurrentHandlers.add(h);
184
+ return () => {
185
+ set!.delete(h);
186
+ this.concurrentHandlers.delete(h);
187
+ if (set!.size === 0) {
188
+ this.handlers.delete(kind);
189
+ }
190
+ };
191
+ }
192
+
193
+ async start(): Promise<void> {
194
+ this.running = true;
195
+ while (this.running) {
196
+ const batch = this.dequeueBatch();
197
+ if (batch.length === 0) {
198
+ // Yield to I/O — wait for next push()
199
+ await new Promise<void>((resolve) => {
200
+ this.resolver = resolve;
201
+ });
202
+ continue;
203
+ }
204
+
205
+ for (const entry of batch) {
206
+ await this.dispatch(entry.packet);
207
+ this.drainedCount++;
208
+ }
209
+ }
210
+ }
211
+
212
+ stop(): void {
213
+ this.running = false;
214
+ // Drain remaining Critical then Main (synchronous — stop is a shutdown path)
215
+ while (this.critical.length > 0) {
216
+ const pkt = this.critical.shift()!;
217
+ void this.dispatchSync(pkt);
218
+ }
219
+ while (this.main.length > 0) {
220
+ const pkt = this.main.shift()!;
221
+ void this.dispatchSync(pkt);
222
+ }
223
+ // Discard Background
224
+ this.background.length = 0;
225
+ this.resolver?.();
226
+ this.resolver = null;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Private
231
+ // ---------------------------------------------------------------------------
232
+
233
+ private queueFor(r: Route): VoicePacket[] {
234
+ if (r === Route.Critical) return this.critical;
235
+ if (r === Route.Main) return this.main;
236
+ return this.background;
237
+ }
238
+
239
+ private publishAllPackets(route: Route, packet: VoicePacket): void {
240
+ this.onPacket?.(route, packet);
241
+ if (!this.allPacketsController) return;
242
+ try {
243
+ this.allPacketsController.enqueue({ route, packet });
244
+ } catch {
245
+ this.allPacketsController = null;
246
+ }
247
+ }
248
+
249
+ private enqueueBackgroundDropMetric(dropped: VoicePacket): void {
250
+ const metric: ConversationMetricPacket = {
251
+ kind: "metric.conversation",
252
+ contextId: dropped.contextId,
253
+ timestampMs: Date.now(),
254
+ name: "pipeline.bus.background.dropped",
255
+ value: dropped.kind,
256
+ };
257
+
258
+ this.publishAllPackets(Route.Background, metric);
259
+ if (this.background.length >= this.bgCapacity) {
260
+ this.background.shift();
261
+ }
262
+ this.background.push(metric);
263
+ }
264
+
265
+ private capacityFor(r: Route): number {
266
+ if (r === Route.Critical) return Infinity;
267
+ if (r === Route.Main) return this.mainCapacity;
268
+ return this.bgCapacity;
269
+ }
270
+
271
+ /**
272
+ * Dequeue a batch of packets. Always drains Critical first.
273
+ * Critical batches up to `criticalBatchSize` per tick before yielding to I/O.
274
+ * Main and Background drain one packet per tick.
275
+ */
276
+ private dequeueBatch(): QueueEntry[] {
277
+ // 1. Critical — batch up to N
278
+ if (this.critical.length > 0) {
279
+ const batch: QueueEntry[] = [];
280
+ const count = Math.min(this.critical.length, this.criticalBatchSize);
281
+ for (let i = 0; i < count; i++) {
282
+ batch.push({
283
+ route: Route.Critical,
284
+ packet: this.critical.shift()!,
285
+ });
286
+ }
287
+ return batch;
288
+ }
289
+
290
+ // 2. Main — one packet per tick
291
+ if (this.main.length > 0) {
292
+ return [{ route: Route.Main, packet: this.main.shift()! }];
293
+ }
294
+
295
+ // 3. Background — one packet per tick
296
+ if (this.background.length > 0) {
297
+ return [{ route: Route.Background, packet: this.background.shift()! }];
298
+ }
299
+
300
+ return [];
301
+ }
302
+
303
+ /** Dispatch one packet to registered handlers. */
304
+ private async dispatch(pkt: VoicePacket): Promise<void> {
305
+ const matches = this.handlers.get(pkt.kind);
306
+ if (!matches || matches.size === 0) return;
307
+
308
+ // Async packets: fire-and-forget, don't await
309
+ if ("isAsync" in pkt && (pkt as unknown as AsyncPacket).isAsync) {
310
+ for (const h of matches) {
311
+ void (async () => {
312
+ try {
313
+ await (h as PacketHandler)(pkt);
314
+ } catch {
315
+ // Fire-and-forget errors are intentionally swallowed.
316
+ // AsyncPackets are for non-critical telemetry/events.
317
+ }
318
+ })();
319
+ }
320
+ return;
321
+ }
322
+
323
+ // Sync packets: await each consumer handler in registration order. Concurrent
324
+ // (producer) handlers are fired fire-and-forget so a long-running handler does
325
+ // not park the drain loop and defer subsequent Main/Critical packets behind it.
326
+ for (const h of matches) {
327
+ const handler = h as PacketHandler;
328
+ if (this.concurrentHandlers.has(handler)) {
329
+ void (async () => {
330
+ try {
331
+ await handler(pkt);
332
+ } catch (err) {
333
+ this.emitHandlerError(pkt, err);
334
+ }
335
+ })();
336
+ continue;
337
+ }
338
+ try {
339
+ await handler(pkt);
340
+ } catch (err) {
341
+ // Handler error → emit PipelineErrorPacket on Critical.
342
+ // Continue processing other handlers — don't abort the bus.
343
+ this.emitHandlerError(pkt, err);
344
+ }
345
+ }
346
+ }
347
+
348
+ private emitHandlerError(pkt: VoicePacket, err: unknown): void {
349
+ const errorPkt: PipelineErrorPacket = {
350
+ kind: "pipeline.error",
351
+ contextId: pkt.contextId,
352
+ timestampMs: Date.now(),
353
+ component: "pipeline",
354
+ category: ErrorCategory.InternalFault,
355
+ cause: err instanceof Error ? err : new Error(String(err)),
356
+ isRecoverable: true,
357
+ };
358
+ this.push(Route.Critical, errorPkt);
359
+ }
360
+
361
+ /** Synchronous dispatch for drain-on-stop. Swallows errors. */
362
+ private async dispatchSync(pkt: VoicePacket): Promise<void> {
363
+ try {
364
+ await this.dispatch(pkt);
365
+ } catch {
366
+ // Drain phase — silence errors
367
+ }
368
+ }
369
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Plugin Contract
4
+ //
5
+ // Every pipeline plugin (STT, TTS, VAD, EOS, Denoiser, Bridge) implements
6
+ // this interface. Plugins receive the PipelineBus on initialization and
7
+ // push all output (transcripts, audio, errors, events) into the bus.
8
+ //
9
+ // Breaking change from v0.1: plugins now accept PipelineBus directly.
10
+ // No callbacks, no adapters. Clean contract, one code path.
11
+
12
+ import type { PipelineBus } from "./pipeline-bus.js";
13
+
14
+ // =============================================================================
15
+ // Contract
16
+ // =============================================================================
17
+
18
+ export type EndpointingOwner = "provider_stt" | "smart_turn";
19
+
20
+ export interface EndpointingCapability {
21
+ readonly owner: EndpointingOwner;
22
+ readonly disableConfig?: PluginConfig;
23
+ }
24
+
25
+ export interface VoicePlugin {
26
+ readonly endpointingCapability?: EndpointingCapability;
27
+
28
+ /**
29
+ * Initialize the plugin. Called during the init chain.
30
+ * Connect to provider, start streams, register bus handlers if needed.
31
+ *
32
+ * @param bus — The session's PipelineBus. Push all output packets here.
33
+ * @param config — Plugin-specific configuration (API keys, model IDs, etc.).
34
+ */
35
+ initialize(bus: PipelineBus, config: PluginConfig): Promise<void>;
36
+
37
+ /**
38
+ * Tear down the plugin. Called during the finalize chain (reverse order).
39
+ * Close connections, flush buffers, release resources.
40
+ */
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ // =============================================================================
45
+ // Configuration
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Plugin configuration — a flat key-value bag.
50
+ * Plugins extract the keys they need (e.g., "api_key", "model_id", "voice_id").
51
+ */
52
+ export type PluginConfig = Record<string, unknown>;
53
+
54
+ /**
55
+ * Convenience: extract a string config value, throwing if missing.
56
+ */
57
+ export function requireStringConfig(
58
+ config: PluginConfig,
59
+ key: string,
60
+ ): string {
61
+ const value = config[key];
62
+ if (typeof value !== "string" || value.trim().length === 0) {
63
+ throw new Error(`Plugin config missing required key: ${key}`);
64
+ }
65
+ return value;
66
+ }
67
+
68
+ /**
69
+ * Convenience: extract an optional string config value.
70
+ */
71
+ export function optionalStringConfig(
72
+ config: PluginConfig,
73
+ key: string,
74
+ ): string | undefined {
75
+ const value = config[key];
76
+ return typeof value === "string" && value.trim().length > 0
77
+ ? value
78
+ : undefined;
79
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Synthetic PCM fixtures for primary-speaker gate tests.
4
+
5
+ import { pcm16BytesToSamples, pcm16SamplesToBytes } from "./audio/pcm.js";
6
+
7
+ export function synthesizeTonePcm16(options: {
8
+ frequencyHz: number;
9
+ durationMs: number;
10
+ sampleRateHz?: number;
11
+ amplitude?: number;
12
+ phaseRad?: number;
13
+ }): Uint8Array {
14
+ const sampleRateHz = options.sampleRateHz ?? 16000;
15
+ const amplitude = options.amplitude ?? 0.35;
16
+ const phaseRad = options.phaseRad ?? 0;
17
+ const sampleCount = Math.max(1, Math.round((options.durationMs * sampleRateHz) / 1000));
18
+ const samples = new Int16Array(sampleCount);
19
+ const omega = (2 * Math.PI * options.frequencyHz) / sampleRateHz;
20
+ for (let i = 0; i < sampleCount; i += 1) {
21
+ const value = Math.sin(omega * i + phaseRad) * amplitude * 32767;
22
+ samples[i] = Math.max(-32768, Math.min(32767, Math.round(value)));
23
+ }
24
+ return pcm16SamplesToBytes(samples);
25
+ }
26
+
27
+ export function mixPcm16(chunks: Uint8Array[], weights: number[]): Uint8Array {
28
+ if (chunks.length === 0) return new Uint8Array(0);
29
+ const maxLen = Math.max(...chunks.map((c) => c.byteLength));
30
+ const out = new Int16Array(maxLen / 2);
31
+ for (let c = 0; c < chunks.length; c += 1) {
32
+ const chunk = chunks[c]!;
33
+ const weight = weights[c] ?? 1;
34
+ const samples = pcm16BytesToSamples(chunk);
35
+ for (let i = 0; i < samples.length; i += 1) {
36
+ const mixed = (out[i] ?? 0) + samples[i]! * weight;
37
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(mixed)));
38
+ }
39
+ }
40
+ return pcm16SamplesToBytes(out);
41
+ }
42
+
43
+ export const PRIMARY_SPEAKER_TONE_HZ = 280;
44
+ export const BYSTANDER_SPEAKER_TONE_HZ = 2100;
45
+ export const ASSISTANT_ECHO_TONE_HZ = 520;