@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.
- package/README.md +41 -0
- package/package.json +22 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/index.ts +5 -0
- package/src/audio/mulaw.ts +42 -0
- package/src/audio/pcm.ts +43 -0
- package/src/audio/resample.ts +177 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/audio-envelope.ts +143 -0
- package/src/conversation-event.ts +62 -0
- package/src/error-handler.test.ts +56 -0
- package/src/error-handler.ts +149 -0
- package/src/idle-timeout.ts +210 -0
- package/src/index.ts +215 -0
- package/src/init-chain.ts +137 -0
- package/src/init-stage-order.ts +79 -0
- package/src/latency-filler-fixtures.ts +16 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/latency-filler.ts +125 -0
- package/src/mode-switcher.ts +110 -0
- package/src/observability-observer.test.ts +245 -0
- package/src/observability-observer.ts +195 -0
- package/src/observability.test.ts +85 -0
- package/src/observability.ts +93 -0
- package/src/packet-factories.test.ts +34 -0
- package/src/packet-factories.ts +243 -0
- package/src/packets.ts +553 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +197 -0
- package/src/pipeline-bus.ts +369 -0
- package/src/plugin-contract.ts +79 -0
- package/src/primary-speaker-fixtures.ts +45 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/primary-speaker-gate.ts +186 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/provider-fallback.ts +88 -0
- package/src/reasoner.test.ts +69 -0
- package/src/reasoner.ts +54 -0
- package/src/retry.test.ts +83 -0
- package/src/retry.ts +106 -0
- package/src/scheduler.ts +28 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/tts-playout-clock.ts +116 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +567 -0
- package/src/turn-arbiter.ts +283 -0
- package/src/voice-agent-session-util.ts +240 -0
- package/src/voice-agent-session.test.ts +2487 -0
- package/src/voice-agent-session.ts +1175 -0
- package/src/voice-text.test.ts +81 -0
- package/src/voice-text.ts +102 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
SYRINX_AUDIO_ENVELOPE_MAGIC,
|
|
6
|
+
assertAudioFormat,
|
|
7
|
+
assertAudioPayload,
|
|
8
|
+
decodeSyrinxAudioEnvelope,
|
|
9
|
+
encodeSyrinxAudioEnvelope,
|
|
10
|
+
hasSyrinxAudioEnvelope,
|
|
11
|
+
} from "./audio-envelope.js";
|
|
12
|
+
import type { SyrinxAudioEnvelopeHeader } from "./audio-envelope.js";
|
|
13
|
+
import type { AudioFormat } from "./packets.js";
|
|
14
|
+
|
|
15
|
+
describe("Syrinx binary audio envelope", () => {
|
|
16
|
+
it("round-trips audio metadata and payload", () => {
|
|
17
|
+
const encoded = encodeSyrinxAudioEnvelope({
|
|
18
|
+
type: "audio",
|
|
19
|
+
contextId: "turn-1",
|
|
20
|
+
sampleRateHz: 16000,
|
|
21
|
+
sequence: 3,
|
|
22
|
+
encoding: "pcm_s16le",
|
|
23
|
+
channels: 1,
|
|
24
|
+
byteLength: 4,
|
|
25
|
+
durationMs: 1,
|
|
26
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
27
|
+
|
|
28
|
+
expect(hasSyrinxAudioEnvelope(encoded)).toBe(true);
|
|
29
|
+
expect(decodeSyrinxAudioEnvelope(encoded)).toEqual({
|
|
30
|
+
header: {
|
|
31
|
+
type: "audio",
|
|
32
|
+
contextId: "turn-1",
|
|
33
|
+
sampleRateHz: 16000,
|
|
34
|
+
sequence: 3,
|
|
35
|
+
encoding: "pcm_s16le",
|
|
36
|
+
channels: 1,
|
|
37
|
+
byteLength: 4,
|
|
38
|
+
durationMs: 1,
|
|
39
|
+
},
|
|
40
|
+
audio: new Uint8Array([1, 2, 3, 4]),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("rejects envelopes whose declared byte length does not match the payload", () => {
|
|
45
|
+
const encoded = encodeMalformedSyrinxAudioEnvelope({
|
|
46
|
+
type: "audio",
|
|
47
|
+
sampleRateHz: 16000,
|
|
48
|
+
byteLength: 5,
|
|
49
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
50
|
+
|
|
51
|
+
expect(() => decodeSyrinxAudioEnvelope(encoded)).toThrow(/byteLength/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects odd-byte PCM16 payloads and inconsistent duration metadata", () => {
|
|
55
|
+
const oddPayload = encodeMalformedSyrinxAudioEnvelope({
|
|
56
|
+
type: "audio",
|
|
57
|
+
sampleRateHz: 16000,
|
|
58
|
+
byteLength: 3,
|
|
59
|
+
}, new Uint8Array([1, 2, 3]));
|
|
60
|
+
const wrongDuration = encodeMalformedSyrinxAudioEnvelope({
|
|
61
|
+
type: "audio",
|
|
62
|
+
sampleRateHz: 16000,
|
|
63
|
+
byteLength: 640,
|
|
64
|
+
durationMs: 200,
|
|
65
|
+
}, new Uint8Array(640));
|
|
66
|
+
|
|
67
|
+
expect(() => decodeSyrinxAudioEnvelope(oddPayload)).toThrow(/PCM16/);
|
|
68
|
+
expect(() => decodeSyrinxAudioEnvelope(wrongDuration)).toThrow(/durationMs/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects envelopes without a valid sample rate", () => {
|
|
72
|
+
const missingSampleRate = encodeMalformedSyrinxAudioEnvelope({
|
|
73
|
+
type: "audio",
|
|
74
|
+
byteLength: 4,
|
|
75
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
76
|
+
const invalidSampleRate = encodeMalformedSyrinxAudioEnvelope({
|
|
77
|
+
type: "audio",
|
|
78
|
+
sampleRateHz: 0,
|
|
79
|
+
byteLength: 4,
|
|
80
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
81
|
+
|
|
82
|
+
expect(() => decodeSyrinxAudioEnvelope(missingSampleRate)).toThrow(/sampleRateHz/);
|
|
83
|
+
expect(() => decodeSyrinxAudioEnvelope(invalidSampleRate)).toThrow(/sampleRateHz/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects malformed numeric metadata instead of silently defaulting", () => {
|
|
87
|
+
const invalidSequence = encodeMalformedSyrinxAudioEnvelope({
|
|
88
|
+
type: "audio",
|
|
89
|
+
sampleRateHz: 16000,
|
|
90
|
+
sequence: -1,
|
|
91
|
+
byteLength: 4,
|
|
92
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
93
|
+
const invalidDuration = encodeMalformedSyrinxAudioEnvelope({
|
|
94
|
+
type: "audio",
|
|
95
|
+
sampleRateHz: 16000,
|
|
96
|
+
durationMs: 1.5,
|
|
97
|
+
byteLength: 4,
|
|
98
|
+
}, new Uint8Array([1, 2, 3, 4]));
|
|
99
|
+
|
|
100
|
+
expect(() => decodeSyrinxAudioEnvelope(invalidSequence)).toThrow(/sequence/);
|
|
101
|
+
expect(() => decodeSyrinxAudioEnvelope(invalidDuration)).toThrow(/durationMs/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("round-trips opus-encoded envelopes without PCM16 byte alignment", () => {
|
|
105
|
+
const encoded = encodeSyrinxAudioEnvelope({
|
|
106
|
+
type: "audio",
|
|
107
|
+
contextId: "turn-opus",
|
|
108
|
+
sampleRateHz: 16000,
|
|
109
|
+
sequence: 1,
|
|
110
|
+
encoding: "opus",
|
|
111
|
+
channels: 1,
|
|
112
|
+
byteLength: 37,
|
|
113
|
+
durationMs: 20,
|
|
114
|
+
}, new Uint8Array(37));
|
|
115
|
+
|
|
116
|
+
expect(decodeSyrinxAudioEnvelope(encoded).header.encoding).toBe("opus");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("assertAudioPayload rejects odd-byte PCM16 and empty opus/mulaw", () => {
|
|
120
|
+
const pcmFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 16000, channels: 1 };
|
|
121
|
+
expect(() => assertAudioPayload(pcmFormat, new Uint8Array([1, 2, 3]))).toThrow(/even number of bytes/);
|
|
122
|
+
expect(() => assertAudioPayload(pcmFormat, new Uint8Array())).not.toThrow();
|
|
123
|
+
|
|
124
|
+
const opusFormat: AudioFormat = { encoding: "opus", sampleRateHz: 16000, channels: 1 };
|
|
125
|
+
expect(() => assertAudioPayload(opusFormat, new Uint8Array())).toThrow(/must not be empty/);
|
|
126
|
+
expect(() => assertAudioPayload(opusFormat, new Uint8Array([1]))).not.toThrow();
|
|
127
|
+
|
|
128
|
+
const mulawFormat: AudioFormat = { encoding: "mulaw", sampleRateHz: 8000, channels: 1 };
|
|
129
|
+
expect(() => assertAudioPayload(mulawFormat, new Uint8Array())).toThrow(/must not be empty/);
|
|
130
|
+
expect(() => assertAudioPayload(mulawFormat, new Uint8Array([1, 2, 3]))).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("assertAudioFormat rejects non-mono and invalid sample rates", () => {
|
|
134
|
+
expect(() => assertAudioFormat({ encoding: "pcm_s16le", sampleRateHz: 16000, channels: 2 as 1 })).toThrow(/mono/);
|
|
135
|
+
expect(() => assertAudioFormat({ encoding: "pcm_s16le", sampleRateHz: 0, channels: 1 })).toThrow(/sampleRateHz/);
|
|
136
|
+
expect(() => assertAudioFormat({ encoding: "pcm_s16le", sampleRateHz: 1.5, channels: 1 })).toThrow(/sampleRateHz/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects invalid envelopes before encoding them", () => {
|
|
140
|
+
expect(() => encodeSyrinxAudioEnvelope({
|
|
141
|
+
type: "audio",
|
|
142
|
+
byteLength: 4,
|
|
143
|
+
} as SyrinxAudioEnvelopeHeader, new Uint8Array([1, 2, 3, 4]))).toThrow(/sampleRateHz/);
|
|
144
|
+
expect(() => encodeSyrinxAudioEnvelope({
|
|
145
|
+
type: "audio",
|
|
146
|
+
sampleRateHz: 16000,
|
|
147
|
+
sequence: -1,
|
|
148
|
+
byteLength: 4,
|
|
149
|
+
}, new Uint8Array([1, 2, 3, 4]))).toThrow(/sequence/);
|
|
150
|
+
expect(() => encodeSyrinxAudioEnvelope({
|
|
151
|
+
type: "audio",
|
|
152
|
+
sampleRateHz: 16000,
|
|
153
|
+
byteLength: 5,
|
|
154
|
+
}, new Uint8Array([1, 2, 3, 4]))).toThrow(/byteLength/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function encodeMalformedSyrinxAudioEnvelope(header: Record<string, unknown>, audio: Uint8Array): Uint8Array {
|
|
159
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
|
|
160
|
+
const output = new Uint8Array(SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4 + headerBytes.byteLength + audio.byteLength);
|
|
161
|
+
output.set(SYRINX_AUDIO_ENVELOPE_MAGIC, 0);
|
|
162
|
+
new DataView(output.buffer, output.byteOffset, output.byteLength)
|
|
163
|
+
.setUint32(SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength, headerBytes.byteLength, true);
|
|
164
|
+
output.set(headerBytes, SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4);
|
|
165
|
+
output.set(audio, SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4 + headerBytes.byteLength);
|
|
166
|
+
return output;
|
|
167
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { AudioFormat } from "./packets.js";
|
|
4
|
+
|
|
5
|
+
export const SYRINX_AUDIO_ENVELOPE_NAME = "syrinx.audio.v1" as const;
|
|
6
|
+
export const SYRINX_AUDIO_ENVELOPE_MAGIC = new Uint8Array([83, 89, 82, 88, 65, 49, 10]);
|
|
7
|
+
|
|
8
|
+
export interface SyrinxAudioEnvelopeHeader {
|
|
9
|
+
readonly type: "audio";
|
|
10
|
+
readonly contextId?: string;
|
|
11
|
+
readonly sampleRateHz: number;
|
|
12
|
+
readonly sequence?: number;
|
|
13
|
+
readonly encoding?: "pcm_s16le" | "opus";
|
|
14
|
+
readonly channels?: 1;
|
|
15
|
+
readonly byteLength?: number;
|
|
16
|
+
readonly durationMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SyrinxAudioEnvelope {
|
|
20
|
+
readonly header: SyrinxAudioEnvelopeHeader;
|
|
21
|
+
readonly audio: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertAudioFormat(format: AudioFormat): void {
|
|
25
|
+
if (format.channels !== 1) throw new Error("audio must be mono");
|
|
26
|
+
if (!Number.isInteger(format.sampleRateHz) || format.sampleRateHz <= 0) {
|
|
27
|
+
throw new Error("sampleRateHz must be a positive integer");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function assertAudioPayload(format: AudioFormat, audio: Uint8Array): void {
|
|
32
|
+
if (format.encoding === "opus") {
|
|
33
|
+
if (audio.byteLength === 0) {
|
|
34
|
+
throw new Error("opus payload must not be empty");
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (format.encoding === "mulaw") {
|
|
39
|
+
if (audio.byteLength === 0) {
|
|
40
|
+
throw new Error("mulaw payload must not be empty");
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (audio.byteLength % 2 !== 0) {
|
|
45
|
+
throw new Error("PCM16 payload must contain an even number of bytes");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function encodeSyrinxAudioEnvelope(header: SyrinxAudioEnvelopeHeader, audio: Uint8Array): Uint8Array {
|
|
50
|
+
validateSyrinxAudioEnvelope(header, audio);
|
|
51
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
|
|
52
|
+
const output = new Uint8Array(SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4 + headerBytes.byteLength + audio.byteLength);
|
|
53
|
+
output.set(SYRINX_AUDIO_ENVELOPE_MAGIC, 0);
|
|
54
|
+
new DataView(output.buffer, output.byteOffset, output.byteLength)
|
|
55
|
+
.setUint32(SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength, headerBytes.byteLength, true);
|
|
56
|
+
output.set(headerBytes, SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4);
|
|
57
|
+
output.set(audio, SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength + 4 + headerBytes.byteLength);
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decodeSyrinxAudioEnvelope(data: Uint8Array): SyrinxAudioEnvelope {
|
|
62
|
+
if (!hasSyrinxAudioEnvelope(data)) {
|
|
63
|
+
throw new Error("Syrinx binary audio envelope magic is missing");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const headerLengthOffset = SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength;
|
|
67
|
+
if (data.byteLength < headerLengthOffset + 4) {
|
|
68
|
+
throw new Error("Syrinx binary audio envelope is truncated");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
72
|
+
const headerLength = view.getUint32(headerLengthOffset, true);
|
|
73
|
+
const headerStart = headerLengthOffset + 4;
|
|
74
|
+
const headerEnd = headerStart + headerLength;
|
|
75
|
+
if (headerLength <= 0 || headerEnd > data.byteLength) {
|
|
76
|
+
throw new Error("Syrinx binary audio envelope has an invalid header length");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(new TextDecoder().decode(data.subarray(headerStart, headerEnd))) as unknown;
|
|
80
|
+
if (!parsed || typeof parsed !== "object") {
|
|
81
|
+
throw new Error("Syrinx binary audio envelope header must be an object");
|
|
82
|
+
}
|
|
83
|
+
const header = parsed as SyrinxAudioEnvelopeHeader;
|
|
84
|
+
const audio = data.subarray(headerEnd);
|
|
85
|
+
validateSyrinxAudioEnvelope(header, audio);
|
|
86
|
+
|
|
87
|
+
return { header, audio };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function hasSyrinxAudioEnvelope(data: Uint8Array): boolean {
|
|
91
|
+
if (data.byteLength < SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength) return false;
|
|
92
|
+
for (let i = 0; i < SYRINX_AUDIO_ENVELOPE_MAGIC.byteLength; i += 1) {
|
|
93
|
+
if (data[i] !== SYRINX_AUDIO_ENVELOPE_MAGIC[i]) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateSyrinxAudioEnvelope(header: SyrinxAudioEnvelopeHeader, audio: Uint8Array): void {
|
|
99
|
+
if (header.type !== "audio") {
|
|
100
|
+
throw new Error("Syrinx binary audio envelope type must be audio");
|
|
101
|
+
}
|
|
102
|
+
if (!isPositiveInteger(header.sampleRateHz)) {
|
|
103
|
+
throw new Error("Syrinx binary audio envelope sampleRateHz must be a positive integer");
|
|
104
|
+
}
|
|
105
|
+
if (header.encoding && header.encoding !== "pcm_s16le" && header.encoding !== "opus") {
|
|
106
|
+
throw new Error(`Unsupported Syrinx binary audio encoding: ${header.encoding}`);
|
|
107
|
+
}
|
|
108
|
+
if (header.sequence !== undefined && !isNonNegativeInteger(header.sequence)) {
|
|
109
|
+
throw new Error("Syrinx binary audio envelope sequence must be a non-negative integer");
|
|
110
|
+
}
|
|
111
|
+
if (header.durationMs !== undefined && !isNonNegativeInteger(header.durationMs)) {
|
|
112
|
+
throw new Error("Syrinx binary audio envelope durationMs must be a non-negative integer");
|
|
113
|
+
}
|
|
114
|
+
if (header.byteLength !== undefined && !isNonNegativeInteger(header.byteLength)) {
|
|
115
|
+
throw new Error("Syrinx binary audio envelope byteLength must be a non-negative integer");
|
|
116
|
+
}
|
|
117
|
+
if (header.byteLength !== undefined && header.byteLength !== audio.byteLength) {
|
|
118
|
+
throw new Error("Syrinx binary audio envelope byteLength does not match payload");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const format: AudioFormat = {
|
|
122
|
+
encoding: header.encoding === "opus" ? "opus" : "pcm_s16le",
|
|
123
|
+
sampleRateHz: header.sampleRateHz,
|
|
124
|
+
channels: header.channels ?? 1,
|
|
125
|
+
};
|
|
126
|
+
assertAudioFormat(format);
|
|
127
|
+
assertAudioPayload(format, audio);
|
|
128
|
+
|
|
129
|
+
if (format.encoding !== "opus" && header.durationMs !== undefined) {
|
|
130
|
+
const expectedDurationMs = Math.round((audio.byteLength / 2 / header.sampleRateHz) * 1000);
|
|
131
|
+
if (Math.abs(header.durationMs - expectedDurationMs) > 1) {
|
|
132
|
+
throw new Error("Syrinx binary audio envelope durationMs does not match payload and sampleRateHz");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
138
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isNonNegativeInteger(value: unknown): value is number {
|
|
142
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
143
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Conversation Event (Debug Stream)
|
|
4
|
+
//
|
|
5
|
+
// Normalized event type consumed by debugger UIs and the VoiceSessionRecorder.
|
|
6
|
+
// Every pipeline component emits these alongside their functional packets.
|
|
7
|
+
// Exposed as `session.debugEvents: ReadableStream<ConversationEvent>`.
|
|
8
|
+
//
|
|
9
|
+
// Design decision (per RFC Q3 resolution): events flow through the bus on
|
|
10
|
+
// the Background route. If Background saturates, drops are metric'd.
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Type
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export interface ConversationEvent {
|
|
17
|
+
/** Component that emitted the event: "vad", "stt", "eos", "llm", "tts", "tool", "session", "turn" */
|
|
18
|
+
readonly component: string;
|
|
19
|
+
/** Sub-type: "speech_started", "interim", "final", "delta", "first_audio", "call_started", etc. */
|
|
20
|
+
readonly type: string;
|
|
21
|
+
/** Arbitrary key-value payload. Always includes "context_id". */
|
|
22
|
+
readonly data: Readonly<Record<string, string>>;
|
|
23
|
+
/** Wall-clock time of the event in ms since epoch. */
|
|
24
|
+
readonly timestampMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Factory
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a ReadableStream of ConversationEvents that is fed by a callback.
|
|
33
|
+
* Returns [stream, push] — call push(event) to enqueue, consumers read from stream.
|
|
34
|
+
*/
|
|
35
|
+
export function createConversationEventStream(): [
|
|
36
|
+
ReadableStream<ConversationEvent>,
|
|
37
|
+
(event: ConversationEvent) => void,
|
|
38
|
+
] {
|
|
39
|
+
let controller: ReadableStreamDefaultController<ConversationEvent> | null =
|
|
40
|
+
null;
|
|
41
|
+
|
|
42
|
+
const stream = new ReadableStream<ConversationEvent>({
|
|
43
|
+
start(c) {
|
|
44
|
+
controller = c;
|
|
45
|
+
},
|
|
46
|
+
cancel() {
|
|
47
|
+
controller = null;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const push = (event: ConversationEvent): void => {
|
|
52
|
+
if (controller) {
|
|
53
|
+
try {
|
|
54
|
+
controller.enqueue(event);
|
|
55
|
+
} catch {
|
|
56
|
+
// Stream closed — drop
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return [stream, push];
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ErrorCategory } from "./packets.js";
|
|
6
|
+
import { categorizeLlmError, categorizeSttError, categorizeTtsError, isRecoverable } from "./error-handler.js";
|
|
7
|
+
|
|
8
|
+
describe("LLM error handling", () => {
|
|
9
|
+
it("treats malformed provider tool calls as retryable generation failures", () => {
|
|
10
|
+
const category = categorizeLlmError(new Error("AI SDK provider step failed: MALFORMED_FUNCTION_CALL"));
|
|
11
|
+
|
|
12
|
+
expect(category).toBe(ErrorCategory.NetworkTimeout);
|
|
13
|
+
expect(isRecoverable(category)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("treats provider 500/internal TTS failures as retryable", () => {
|
|
17
|
+
const category = categorizeTtsError(new Error("code 500: An internal error has occurred. Please retry."));
|
|
18
|
+
|
|
19
|
+
expect(category).toBe(ErrorCategory.NetworkTimeout);
|
|
20
|
+
expect(isRecoverable(category)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps Deepgram NET websocket close frames to recoverable connection failures", () => {
|
|
24
|
+
const category = categorizeSttError(new Error("Deepgram STT WebSocket closed unexpectedly: code=1011 reason=NET-0000"));
|
|
25
|
+
|
|
26
|
+
expect(category).toBe(ErrorCategory.NetworkTimeout);
|
|
27
|
+
expect(isRecoverable(category)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("maps provider finalization timed-out wording to recoverable timeout", () => {
|
|
31
|
+
const category = categorizeSttError(new Error("Deepgram STT Finalize timed out before speech_final/from_finalize confirmation"));
|
|
32
|
+
|
|
33
|
+
expect(category).toBe(ErrorCategory.NetworkTimeout);
|
|
34
|
+
expect(isRecoverable(category)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("maps Deepgram DATA websocket close frames to fatal input failures", () => {
|
|
38
|
+
const category = categorizeSttError(new Error("Deepgram STT WebSocket closed unexpectedly: code=1008 reason=DATA-0000"));
|
|
39
|
+
|
|
40
|
+
expect(category).toBe(ErrorCategory.InvalidInput);
|
|
41
|
+
expect(isRecoverable(category)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maps provider concurrency-limit errors to recoverable rate-limit (G8)", () => {
|
|
45
|
+
for (const message of [
|
|
46
|
+
"Cartesia TTS provider error: concurrency limit exceeded",
|
|
47
|
+
"Too many concurrent connections",
|
|
48
|
+
"max concurrency reached",
|
|
49
|
+
"concurrency_limit_exceeded",
|
|
50
|
+
]) {
|
|
51
|
+
const category = categorizeTtsError(new Error(message));
|
|
52
|
+
expect(category, `message: ${message}`).toBe(ErrorCategory.RateLimit);
|
|
53
|
+
expect(isRecoverable(category)).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Syrinx Kernel v2 — Error Handler
|
|
4
|
+
//
|
|
5
|
+
// Categorizes errors from external service providers and determines
|
|
6
|
+
// whether they are recoverable (retry with backoff) or fatal (terminate session).
|
|
7
|
+
//
|
|
8
|
+
// Each component (STT, TTS, LLM) has its own categorization logic.
|
|
9
|
+
// The session manager uses these to decide retry vs. terminate.
|
|
10
|
+
|
|
11
|
+
import { ErrorCategory, type VoiceErrorPacket } from "./packets.js";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Error Categorization
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Categorize an error from the STT provider.
|
|
19
|
+
*
|
|
20
|
+
* Heuristic:
|
|
21
|
+
* - HTTP 429 → RateLimit (recoverable)
|
|
22
|
+
* - Network errors (ECONNRESET, ETIMEDOUT, ENOTFOUND) → NetworkTimeout (recoverable)
|
|
23
|
+
* - HTTP 401/403 → Authentication (fatal)
|
|
24
|
+
* - HTTP 400/422 → InvalidInput (fatal)
|
|
25
|
+
* - HTTP 402 → ResourceExhausted (fatal)
|
|
26
|
+
* - Everything else → InternalFault (fatal)
|
|
27
|
+
*/
|
|
28
|
+
export function categorizeSttError(err: unknown): ErrorCategory {
|
|
29
|
+
const msg = extractMessage(err).toLowerCase();
|
|
30
|
+
const code = extractHttpStatus(err);
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
code === 429 ||
|
|
34
|
+
msg.includes("rate limit") ||
|
|
35
|
+
msg.includes("rate_limit") ||
|
|
36
|
+
msg.includes("too many requests") ||
|
|
37
|
+
// Provider concurrency limits (e.g. Cartesia) — recoverable, retry with backoff.
|
|
38
|
+
msg.includes("concurrency limit") ||
|
|
39
|
+
msg.includes("concurrency_limit") ||
|
|
40
|
+
msg.includes("concurrent limit") ||
|
|
41
|
+
msg.includes("too many concurrent") ||
|
|
42
|
+
msg.includes("max concurrency") ||
|
|
43
|
+
(msg.includes("concurren") && (msg.includes("exceed") || msg.includes("limit")))
|
|
44
|
+
) {
|
|
45
|
+
return ErrorCategory.RateLimit;
|
|
46
|
+
}
|
|
47
|
+
if (code === 401 || code === 403 || msg.includes("unauthorized") || msg.includes("forbidden")) {
|
|
48
|
+
return ErrorCategory.Authentication;
|
|
49
|
+
}
|
|
50
|
+
if (code === 400 || code === 422 || msg.includes("invalid") || msg.includes("bad request")) {
|
|
51
|
+
return ErrorCategory.InvalidInput;
|
|
52
|
+
}
|
|
53
|
+
if (msg.includes("data-000")) {
|
|
54
|
+
return ErrorCategory.InvalidInput;
|
|
55
|
+
}
|
|
56
|
+
if (code === 402 || msg.includes("payment required") || msg.includes("insufficient")) {
|
|
57
|
+
return ErrorCategory.ResourceExhausted;
|
|
58
|
+
}
|
|
59
|
+
if (
|
|
60
|
+
msg.includes("econnreset") ||
|
|
61
|
+
msg.includes("etimedout") ||
|
|
62
|
+
msg.includes("enotfound") ||
|
|
63
|
+
msg.includes("network") ||
|
|
64
|
+
msg.includes("net-000") ||
|
|
65
|
+
msg.includes("timeout") ||
|
|
66
|
+
msg.includes("timed out") ||
|
|
67
|
+
msg.includes("econnrefused") ||
|
|
68
|
+
msg.includes("socket hang up") ||
|
|
69
|
+
msg.includes("code=1011") ||
|
|
70
|
+
msg.includes("internal error has occurred") ||
|
|
71
|
+
(code !== null && code >= 500 && code <= 599)
|
|
72
|
+
) {
|
|
73
|
+
return ErrorCategory.NetworkTimeout;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return ErrorCategory.InternalFault;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Categorize an error from the TTS provider.
|
|
81
|
+
* Same heuristic as STT — TTS providers have similar error patterns.
|
|
82
|
+
*/
|
|
83
|
+
export function categorizeTtsError(err: unknown): ErrorCategory {
|
|
84
|
+
return categorizeSttError(err); // Same logic applies
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Categorize an error from the LLM provider.
|
|
89
|
+
*
|
|
90
|
+
* Additional LLM-specific heuristics:
|
|
91
|
+
* - "context length exceeded" → InvalidInput (fatal)
|
|
92
|
+
* - "content filter" / "safety" → InvalidInput (fatal)
|
|
93
|
+
*/
|
|
94
|
+
export function categorizeLlmError(err: unknown): ErrorCategory {
|
|
95
|
+
const msg = extractMessage(err).toLowerCase();
|
|
96
|
+
|
|
97
|
+
if (msg.includes("context length") || msg.includes("token limit") || msg.includes("too many tokens")) {
|
|
98
|
+
return ErrorCategory.InvalidInput;
|
|
99
|
+
}
|
|
100
|
+
if (msg.includes("content filter") || msg.includes("safety") || msg.includes("blocked")) {
|
|
101
|
+
return ErrorCategory.InvalidInput;
|
|
102
|
+
}
|
|
103
|
+
if (msg.includes("malformed_function_call")) {
|
|
104
|
+
return ErrorCategory.NetworkTimeout;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return categorizeSttError(err); // Base logic for HTTP errors
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Recoverability
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns true if the error category is recoverable (retry with backoff).
|
|
116
|
+
*/
|
|
117
|
+
export function isRecoverable(category: ErrorCategory): boolean {
|
|
118
|
+
return category === ErrorCategory.RateLimit || category === ErrorCategory.NetworkTimeout;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns true if the error packet represents a fatal error.
|
|
123
|
+
*/
|
|
124
|
+
export function isFatalError(pkt: VoiceErrorPacket): boolean {
|
|
125
|
+
return !pkt.isRecoverable;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// Helpers
|
|
130
|
+
// =============================================================================
|
|
131
|
+
|
|
132
|
+
function extractMessage(err: unknown): string {
|
|
133
|
+
if (err instanceof Error) return err.message;
|
|
134
|
+
if (typeof err === "string") return err;
|
|
135
|
+
if (err && typeof err === "object" && "message" in err) {
|
|
136
|
+
return String((err as { message: unknown }).message);
|
|
137
|
+
}
|
|
138
|
+
return String(err);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractHttpStatus(err: unknown): number | null {
|
|
142
|
+
if (err && typeof err === "object") {
|
|
143
|
+
const e = err as Record<string, unknown>;
|
|
144
|
+
if (typeof e.status === "number") return e.status;
|
|
145
|
+
if (typeof e.statusCode === "number") return e.statusCode;
|
|
146
|
+
if (typeof e.code === "number") return e.code;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|