@kuralle-syrinx/core 4.1.0 → 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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +22 -3
  3. package/src/audio/alaw.ts +56 -0
  4. package/src/audio/g722.ts +329 -0
  5. package/src/audio/index.ts +12 -0
  6. package/src/audio/loudness.ts +87 -0
  7. package/src/confidence-to-wait.ts +30 -0
  8. package/src/idle-timeout.ts +1 -0
  9. package/src/incremental-unit.ts +19 -0
  10. package/src/index.ts +101 -1
  11. package/src/interaction-coordinator.ts +240 -0
  12. package/src/interaction-policy.ts +116 -0
  13. package/src/iu-ledger.ts +110 -0
  14. package/src/observability-observer.ts +8 -4
  15. package/src/observability.ts +30 -1
  16. package/src/packet-factories.ts +137 -2
  17. package/src/packets.ts +140 -4
  18. package/src/plugin-contract.ts +41 -0
  19. package/src/policies/defer.ts +15 -0
  20. package/src/policies/rule-based.ts +155 -0
  21. package/src/pricing.ts +137 -0
  22. package/src/primary-speaker-gate.ts +23 -3
  23. package/src/reasoner-hedge.ts +216 -0
  24. package/src/reasoner-route.ts +113 -0
  25. package/src/reasoner.ts +28 -1
  26. package/src/spend-cap.ts +52 -0
  27. package/src/turn-arbiter.ts +45 -4
  28. package/src/voice-agent-session.ts +682 -53
  29. package/src/voice-text.ts +69 -0
  30. package/src/audio/audio.test.ts +0 -285
  31. package/src/audio-envelope.test.ts +0 -167
  32. package/src/error-handler.test.ts +0 -56
  33. package/src/latency-filler.test.ts +0 -62
  34. package/src/observability-observer.test.ts +0 -245
  35. package/src/observability.test.ts +0 -85
  36. package/src/packet-factories.test.ts +0 -34
  37. package/src/pipeline-bus.g10.test.ts +0 -145
  38. package/src/pipeline-bus.test.ts +0 -211
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner.test.ts +0 -69
  42. package/src/retry.test.ts +0 -83
  43. package/src/tts-playout-clock.test.ts +0 -125
  44. package/src/turn-arbiter.characterization.test.ts +0 -477
  45. package/src/turn-arbiter.test.ts +0 -567
  46. package/src/voice-agent-session.test.ts +0 -2879
  47. package/src/voice-text.test.ts +0 -94
  48. package/tsconfig.json +0 -21
package/src/voice-text.ts CHANGED
@@ -81,6 +81,75 @@ export function appendVoiceText(existing: string, next: string): string {
81
81
  return `${existing} ${normalizedNext}`;
82
82
  }
83
83
 
84
+ /**
85
+ * Normalize a speakable segment so TTS does not read formatting aloud.
86
+ *
87
+ * LLM output is full of markdown — `**bold**`, `## heading`, `` `code` ``, `[text](url)` —
88
+ * and a TTS engine narrates the punctuation literally ("star star bold star star"). This is
89
+ * the single highest-frequency voice bug class and has no app-layer fix, because the text is
90
+ * generated inside the pipeline. Vapi ships a 14-step "voice formatting plan" on by default;
91
+ * this is the locale-free core of it: markdown removal.
92
+ *
93
+ * Deliberately conservative — it strips *formatting* markers, never content, so a sentence
94
+ * that legitimately contains an asterisk or a hash in prose is left intact where ambiguous.
95
+ * Number/currency/date verbalization is locale-sensitive and intentionally NOT done here yet;
96
+ * doing it wrong (wrong locale, wrong magnitude) speaks worse than leaving the digits. That is
97
+ * a separate stage, not a silent omission.
98
+ */
99
+ /**
100
+ * Strip leaked tool-call protocol tokens so TTS never speaks them.
101
+ *
102
+ * A model emits tool calls in its own syntax (`<|tool_call|>…`, `<tool_call>…</tool_call>`,
103
+ * `[TOOL_CALLS]`, harmony `<|channel|>commentary`). The inference server is supposed to parse
104
+ * that into the structured `tool_calls` field; when the serving stack lacks the right parser
105
+ * the tokens fall through as ordinary assistant text and the pipeline speaks the markup aloud.
106
+ * LiveKit's finding: the same weights score 100% behind one endpoint and 0% behind another —
107
+ * it is the serving stack, not the model. Syrinx (and the Kuralle runtime) both read only the
108
+ * finalized structured tool-call and never text-scrape, so neither catches a leaked one.
109
+ *
110
+ * This is a high-precision guard: it removes only unambiguous sentinel tokens/blocks that never
111
+ * occur in real speech. It does NOT try to strip bare JSON function calls — that is ambiguous and
112
+ * stripping legitimate content is worse than the rare leak. A leak of that shape is a serving-stack
113
+ * bug to fix at the endpoint (LiveKit's "one curl" diagnosis), not something to paper over here.
114
+ */
115
+ export function stripLeakedToolCalls(text: string): string {
116
+ let out = text;
117
+ // Paired XML-style blocks (Qwen/Hermes/Mistral): drop the whole block including inner JSON.
118
+ out = out.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, "");
119
+ out = out.replace(/<tool_response>[\s\S]*?<\/tool_response>/gi, "");
120
+ // Harmony: a channel token is immediately followed by its label (analysis/commentary/final).
121
+ // Match the label only right after the token so a legitimate "commentary" in prose is untouched.
122
+ out = out.replace(/<\|channel\|>\s*(?:analysis|commentary|final)\b/gi, "");
123
+ // Special sentinel tokens (Gemma/Llama/harmony): <|...|> never appears in spoken text.
124
+ out = out.replace(/<\|[^|]*\|>/g, "");
125
+ // Mistral marker.
126
+ out = out.replace(/\[TOOL_CALLS\]/gi, "");
127
+ return out.replace(/[ \t]{2,}/g, " ").trim();
128
+ }
129
+
130
+ export function normalizeForSpeech(text: string): string {
131
+ let out = stripLeakedToolCalls(text);
132
+ // Links/images: [label](url) -> label ; ![alt](url) -> alt
133
+ out = out.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1");
134
+ // Bold/italic/strikethrough: **x** __x__ *x* _x_ ~~x~~ -> x
135
+ out = out.replace(/(\*\*|__)(.+?)\1/g, "$2");
136
+ out = out.replace(/(\*|_)(?=\S)(.+?)(?<=\S)\1/g, "$2");
137
+ out = out.replace(/~~(.+?)~~/g, "$1");
138
+ // Inline + fenced code: `x` and ```x``` -> x
139
+ out = out.replace(/```[a-zA-Z0-9]*\n?([\s\S]*?)```/g, "$1");
140
+ out = out.replace(/`([^`]+)`/g, "$1");
141
+ // Leading block markers per line: headings (#), blockquotes (>), list bullets (-,*,+), numbered lists.
142
+ out = out.replace(/^\s{0,3}#{1,6}\s+/gm, "");
143
+ out = out.replace(/^\s{0,3}>\s?/gm, "");
144
+ out = out.replace(/^\s{0,3}[-*+]\s+/gm, "");
145
+ out = out.replace(/^\s{0,3}\d+\.\s+/gm, "");
146
+ // Horizontal rules on their own line.
147
+ out = out.replace(/^\s{0,3}([-*_])\1{2,}\s*$/gm, "");
148
+ // Collapse whitespace the stripping may have opened up, preserving single spaces.
149
+ out = out.replace(/[ \t]{2,}/g, " ");
150
+ return out;
151
+ }
152
+
84
153
  function isClosingPunctuation(char: string): boolean {
85
154
  return char === ")" || char === "]" || char === "}" || char === "\"" || char === "'" || char === "”" || char === "’";
86
155
  }
@@ -1,285 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import { pcm16BytesToSamples, pcm16SamplesToBytes, bigEndianPcm16BytesToSamples, pcm16SamplesToBigEndianBytes } from "./pcm.js";
5
- import { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "./mulaw.js";
6
- import { resamplePcm16, StreamingPcm16Resampler } from "./resample.js";
7
-
8
- // ── PCM byte ↔ sample conversions ──────────────────────────────────────────
9
-
10
- describe("pcm16BytesToSamples (little-endian)", () => {
11
- it("decodes a known LE byte sequence", () => {
12
- // [0x01, 0x00] = 1 in LE; [0xFF, 0x7F] = 32767; [0x00, 0x80] = -32768
13
- const bytes = new Uint8Array([0x01, 0x00, 0xff, 0x7f, 0x00, 0x80]);
14
- const samples = pcm16BytesToSamples(bytes);
15
- expect(samples[0]).toBe(1);
16
- expect(samples[1]).toBe(32767);
17
- expect(samples[2]).toBe(-32768);
18
- });
19
-
20
- it("throws on odd byte length", () => {
21
- expect(() => pcm16BytesToSamples(new Uint8Array(3))).toThrow(/even/);
22
- });
23
-
24
- it("round-trips through pcm16SamplesToBytes", () => {
25
- const original = new Int16Array([0, 1, -1, 32767, -32768, 1000, -1000]);
26
- const bytes = pcm16SamplesToBytes(original);
27
- const recovered = pcm16BytesToSamples(bytes);
28
- expect(Array.from(recovered)).toEqual(Array.from(original));
29
- });
30
- });
31
-
32
- describe("pcm16SamplesToBytes (little-endian)", () => {
33
- it("encodes a known sample sequence to LE bytes", () => {
34
- const samples = new Int16Array([1, 32767, -32768]);
35
- const bytes = pcm16SamplesToBytes(samples);
36
- expect(bytes[0]).toBe(0x01);
37
- expect(bytes[1]).toBe(0x00);
38
- expect(bytes[2]).toBe(0xff);
39
- expect(bytes[3]).toBe(0x7f);
40
- expect(bytes[4]).toBe(0x00);
41
- expect(bytes[5]).toBe(0x80);
42
- });
43
- });
44
-
45
- describe("bigEndianPcm16BytesToSamples", () => {
46
- it("decodes a known BE byte sequence", () => {
47
- // BE: [0x00, 0x01] = 1; [0x7F, 0xFF] = 32767; [0x80, 0x00] = -32768
48
- const bytes = new Uint8Array([0x00, 0x01, 0x7f, 0xff, 0x80, 0x00]);
49
- const samples = bigEndianPcm16BytesToSamples(bytes);
50
- expect(samples[0]).toBe(1);
51
- expect(samples[1]).toBe(32767);
52
- expect(samples[2]).toBe(-32768);
53
- });
54
-
55
- it("throws on odd byte length", () => {
56
- expect(() => bigEndianPcm16BytesToSamples(new Uint8Array(3))).toThrow(/even/);
57
- });
58
-
59
- it("round-trips through pcm16SamplesToBigEndianBytes", () => {
60
- const original = new Int16Array([0, 1, -1, 32767, -32768, 1000]);
61
- const bytes = pcm16SamplesToBigEndianBytes(original);
62
- const recovered = bigEndianPcm16BytesToSamples(bytes);
63
- expect(Array.from(recovered)).toEqual(Array.from(original));
64
- });
65
- });
66
-
67
- describe("LE vs BE byte order", () => {
68
- it("produces distinct byte sequences for the same samples", () => {
69
- const samples = new Int16Array([256]); // LE: [0x00, 0x01], BE: [0x01, 0x00]
70
- const le = pcm16SamplesToBytes(samples);
71
- const be = pcm16SamplesToBigEndianBytes(samples);
72
- expect(le[0]).not.toBe(be[0]);
73
- });
74
-
75
- it("LE bytes decoded as BE yield wrong values and vice versa", () => {
76
- const samples = new Int16Array([1000, -500]);
77
- const leBytes = pcm16SamplesToBytes(samples);
78
- const beBytes = pcm16SamplesToBigEndianBytes(samples);
79
- // Cross-decode: LE bytes read as BE should NOT equal original
80
- const mismatch = bigEndianPcm16BytesToSamples(leBytes);
81
- expect(mismatch[0]).not.toBe(1000);
82
- // BE bytes read as LE should NOT equal original
83
- const mismatch2 = pcm16BytesToSamples(beBytes);
84
- expect(mismatch2[0]).not.toBe(1000);
85
- });
86
- });
87
-
88
- // ── μ-law codec ─────────────────────────────────────────────────────────────
89
-
90
- describe("μ-law round-trip", () => {
91
- it("round-trips silence exactly", () => {
92
- const silence = new Int16Array(160);
93
- const encoded = encodePcm16ToMuLaw(silence);
94
- const decoded = decodeMuLawToPcm16(encoded);
95
- for (let i = 0; i < decoded.length; i += 1) {
96
- // silence encodes to the mid-code and decodes back to near-zero
97
- expect(Math.abs(decoded[i]!)).toBeLessThanOrEqual(8);
98
- }
99
- });
100
-
101
- it("round-trips a sine wave within μ-law quantization tolerance", () => {
102
- const N = 160;
103
- const input = new Int16Array(N);
104
- for (let i = 0; i < N; i += 1) {
105
- input[i] = Math.round(16000 * Math.sin((2 * Math.PI * 1000 * i) / 8000));
106
- }
107
- const encoded = encodePcm16ToMuLaw(input);
108
- expect(encoded.length).toBe(N);
109
- const decoded = decodeMuLawToPcm16(encoded);
110
- // μ-law is lossy; allow ≤5% full-scale error (~1638 counts)
111
- for (let i = 0; i < N; i += 1) {
112
- expect(Math.abs(decoded[i]! - input[i]!)).toBeLessThanOrEqual(1638);
113
- }
114
- });
115
-
116
- it("preserves sign correctly", () => {
117
- const positive = new Int16Array([10000]);
118
- const negative = new Int16Array([-10000]);
119
- const decPos = decodeMuLawToPcm16(encodePcm16ToMuLaw(positive));
120
- const decNeg = decodeMuLawToPcm16(encodePcm16ToMuLaw(negative));
121
- expect(decPos[0]).toBeGreaterThan(0);
122
- expect(decNeg[0]).toBeLessThan(0);
123
- });
124
- });
125
-
126
- // ── Resampler ───────────────────────────────────────────────────────────────
127
-
128
- describe("resamplePcm16 — identity and length", () => {
129
- it("returns the same reference when rates are equal", () => {
130
- const input = new Int16Array([100, 200, 300]);
131
- const output = resamplePcm16(input, 16000, 16000);
132
- expect(output).toBe(input);
133
- });
134
-
135
- it("returns empty array for empty input", () => {
136
- const output = resamplePcm16(new Int16Array(0), 16000, 8000);
137
- expect(output.length).toBe(0);
138
- });
139
-
140
- it("produces correct output length for 2× upsample", () => {
141
- const input = new Int16Array(80);
142
- const output = resamplePcm16(input, 8000, 16000);
143
- expect(output.length).toBe(160);
144
- });
145
-
146
- it("produces correct output length for 2× downsample", () => {
147
- const input = new Int16Array(160);
148
- const output = resamplePcm16(input, 16000, 8000);
149
- expect(output.length).toBe(80);
150
- });
151
-
152
- it("produces correct output length for 3× downsample", () => {
153
- const input = new Int16Array(240);
154
- const output = resamplePcm16(input, 24000, 8000);
155
- expect(output.length).toBe(80);
156
- });
157
-
158
- it("produces correct output length for 48k→16k (3× downsample)", () => {
159
- const input = new Int16Array(480);
160
- const output = resamplePcm16(input, 48000, 16000);
161
- expect(output.length).toBe(160);
162
- });
163
- });
164
-
165
- describe("resamplePcm16 — upsample fidelity", () => {
166
- it("preserves a DC signal on upsample", () => {
167
- // A constant signal should remain constant after upsampling.
168
- const dc = 10000;
169
- const input = new Int16Array(80).fill(dc);
170
- const output = resamplePcm16(input, 8000, 16000);
171
- for (let i = 0; i < output.length; i += 1) {
172
- expect(output[i]).toBe(dc);
173
- }
174
- });
175
- });
176
-
177
- describe("resamplePcm16 — downsample fidelity", () => {
178
- it("preserves a DC signal on downsample in the interior (no boundary effects)", () => {
179
- // Use 640 input samples (40 ms @ 16 kHz) → 320 output samples @ 8 kHz.
180
- // With a 127-tap centered FIR (halfTaps=63), both sides of the filter are
181
- // fully populated for output indices m where:
182
- // n0 - halfTaps >= 0 → n0 >= 63 → m >= 32
183
- // n0 + halfTaps < 640 → n0 < 577 → m <= 288
184
- // Check m=40..280 to leave a comfortable margin on both ends.
185
- const dc = 10000;
186
- const input = new Int16Array(640).fill(dc);
187
- const output = resamplePcm16(input, 16000, 8000);
188
- expect(output.length).toBe(320);
189
- for (let i = 40; i <= 280; i += 1) {
190
- expect(Math.abs(output[i]! - dc)).toBeLessThanOrEqual(10);
191
- }
192
- });
193
- });
194
-
195
- // ── Anti-alias spectral test (F3 regression lock) ───────────────────────────
196
- //
197
- // Strategy: synthesize a 7 kHz tone at 16 kHz. When decimated to 8 kHz without
198
- // a low-pass filter, 7 kHz aliases to 1 kHz (8000 − 7000). Compare the DFT
199
- // magnitude at 1 kHz between the naive linear-interp baseline and the
200
- // anti-aliased output. The spec requires ≥40 dB suppression of the alias.
201
-
202
- function dftMagnitudeAtBin(samples: Int16Array, binIndex: number): number {
203
- // Compute a single DFT bin via direct evaluation (O(N)).
204
- let re = 0;
205
- let im = 0;
206
- const N = samples.length;
207
- for (let n = 0; n < N; n += 1) {
208
- const angle = (2 * Math.PI * binIndex * n) / N;
209
- re += samples[n]! * Math.cos(angle);
210
- im -= samples[n]! * Math.sin(angle);
211
- }
212
- return Math.sqrt(re * re + im * im);
213
- }
214
-
215
- function naiveDecimate16kTo8k(input: Int16Array): Int16Array {
216
- // Pure linear interpolation decimation — the pre-existing buggy baseline.
217
- const outputLength = Math.max(1, Math.round((input.length * 8000) / 16000));
218
- const output = new Int16Array(outputLength);
219
- const ratio = 2.0;
220
- for (let i = 0; i < outputLength; i += 1) {
221
- const pos = i * ratio;
222
- const lo = Math.floor(pos);
223
- const hi = Math.min(input.length - 1, lo + 1);
224
- const frac = pos - lo;
225
- output[i] = Math.round(input[lo]! * (1 - frac) + input[hi]! * frac);
226
- }
227
- return output;
228
- }
229
-
230
- describe("StreamingPcm16Resampler — chunk continuity", () => {
231
- it("preserves constant-amplitude PCM across successive 20ms chunks (stateless path rings)", () => {
232
- const chunkSamples = 320;
233
- const value = 10_000;
234
- const chunks = 10;
235
- const stateful = new StreamingPcm16Resampler(16_000, 8_000);
236
-
237
- for (let i = 0; i < chunks; i += 1) {
238
- const chunk = new Int16Array(chunkSamples).fill(value);
239
- const statefulOut = stateful.process(chunk);
240
- const statelessOut = resamplePcm16(chunk, 16_000, 8_000);
241
- if (i === 0) continue;
242
-
243
- const statefulSwing = Math.max(...statefulOut) - Math.min(...statefulOut);
244
- expect(statefulSwing).toBeLessThan(700);
245
-
246
- const statelessSwing = Math.max(...statelessOut) - Math.min(...statelessOut);
247
- expect(statelessSwing).toBeGreaterThan(3000);
248
- expect(Math.min(...statelessOut)).toBeLessThan(8000);
249
- expect(Math.max(...statelessOut)).toBeGreaterThan(10_000);
250
- }
251
- });
252
- });
253
-
254
- describe("resamplePcm16 — anti-alias spectral test (F3)", () => {
255
- it("attenuates 7 kHz alias by ≥40 dB compared to naive linear-interp", () => {
256
- // 100 ms of 7 kHz tone at 16 kHz (amplitude 20000).
257
- const N_IN = 1600;
258
- const AMPLITUDE = 20000;
259
- const FREQ_HZ = 7000;
260
- const SRC_RATE = 16000;
261
- const DST_RATE = 8000;
262
-
263
- const input = new Int16Array(N_IN);
264
- for (let n = 0; n < N_IN; n += 1) {
265
- input[n] = Math.round(AMPLITUDE * Math.sin((2 * Math.PI * FREQ_HZ * n) / SRC_RATE));
266
- }
267
-
268
- const naiveOutput = naiveDecimate16kTo8k(input);
269
- const aaOutput = resamplePcm16(input, SRC_RATE, DST_RATE);
270
-
271
- // 7 kHz aliases to 1 kHz (8000 - 7000) in the 8 kHz output.
272
- // DFT bin for 1 kHz with 800 output samples at 8 kHz: bin = 800 * 1000/8000 = 100.
273
- const aliasBin = Math.round((naiveOutput.length * 1000) / DST_RATE);
274
-
275
- const naiveMag = dftMagnitudeAtBin(naiveOutput, aliasBin);
276
- const aaMag = dftMagnitudeAtBin(aaOutput, aliasBin);
277
-
278
- // Guard: naive must actually have a strong alias (sanity-check the test itself).
279
- expect(naiveMag).toBeGreaterThan(AMPLITUDE * 100); // should be ~amplitude*N/2
280
-
281
- // Anti-aliased output alias must be ≥40 dB below the naive alias.
282
- const ratioDb = 20 * Math.log10(naiveMag / Math.max(1, aaMag));
283
- expect(ratioDb).toBeGreaterThanOrEqual(40);
284
- });
285
- });
@@ -1,167 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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
- });
@@ -1,62 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import {
5
- LatencyFillerController,
6
- selectLatencyFillerConnective,
7
- stripRedundantFillerPrefix,
8
- } from "./latency-filler.js";
9
- import { LATENCY_FILLER_FIXTURES } from "./latency-filler-fixtures.js";
10
-
11
- describe("selectLatencyFillerConnective", () => {
12
- it("selects question and gratitude connectives from fixtures", () => {
13
- for (const fixture of LATENCY_FILLER_FIXTURES) {
14
- const turnIndex = fixture.id === "statement-1" ? 1 : 0;
15
- expect(selectLatencyFillerConnective(fixture.userText, turnIndex)).toBe(fixture.expectedConnective);
16
- }
17
- });
18
- });
19
-
20
- describe("stripRedundantFillerPrefix", () => {
21
- it("removes a duplicated leading connective without leaving a gap", () => {
22
- expect(stripRedundantFillerPrefix("So,", "So here's the answer.")).toBe("here's the answer.");
23
- expect(stripRedundantFillerPrefix("Well,", " Well, the deadline passed.")).toBe("the deadline passed.");
24
- });
25
-
26
- it("preserves unrelated LLM text", () => {
27
- expect(stripRedundantFillerPrefix("So,", "The deadline passed.")).toBe("The deadline passed.");
28
- });
29
-
30
- it("does not strip filler words embedded in longer words", () => {
31
- expect(stripRedundantFillerPrefix("So,", "Some people say...")).toBe("Some people say...");
32
- expect(stripRedundantFillerPrefix("So,", "Social distancing matters")).toBe("Social distancing matters");
33
- expect(stripRedundantFillerPrefix("Well,", "Wellness is important")).toBe("Wellness is important");
34
- expect(stripRedundantFillerPrefix("Well,", "Welcome back")).toBe("Welcome back");
35
- expect(stripRedundantFillerPrefix("Well,", "Wellington is windy")).toBe("Wellington is windy");
36
- });
37
-
38
- it("still strips a genuine repeated filler connective", () => {
39
- expect(stripRedundantFillerPrefix("So,", "So, the plan is simple.")).toBe("the plan is simple.");
40
- expect(stripRedundantFillerPrefix("Well,", "Well, let me explain.")).toBe("let me explain.");
41
- });
42
- });
43
-
44
- describe("LatencyFillerController", () => {
45
- it("tracks active filler-only state until splice or cancel", () => {
46
- const controller = new LatencyFillerController({ enabled: true });
47
- expect(controller.start("turn-1", "hello")).toBe("So,");
48
- expect(controller.isFillerOnly("turn-1")).toBe(true);
49
- expect(controller.spliceLlmDelta("turn-1", "The answer is ready.")).toBe("The answer is ready.");
50
- expect(controller.isFillerOnly("turn-1")).toBe(false);
51
- expect(controller.getState("turn-1")?.spliced).toBe(true);
52
- });
53
-
54
- it("marks filler cancelled without splicing", () => {
55
- const controller = new LatencyFillerController({ enabled: true });
56
- controller.start("turn-1", "hello");
57
- const cancelled = controller.cancel("turn-1");
58
- expect(cancelled?.text).toBe("So,");
59
- expect(controller.isFillerOnly("turn-1")).toBe(false);
60
- expect(controller.spliceLlmDelta("turn-1", "ignored")).toBe("ignored");
61
- });
62
- });