@kuralle-syrinx/core 4.4.0 → 4.5.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 (217) hide show
  1. package/dist/audio/alaw.d.ts +3 -0
  2. package/dist/audio/alaw.d.ts.map +1 -0
  3. package/dist/audio/alaw.js +53 -0
  4. package/dist/audio/alaw.js.map +1 -0
  5. package/dist/audio/g722.d.ts +39 -0
  6. package/dist/audio/g722.d.ts.map +1 -0
  7. package/dist/audio/g722.js +286 -0
  8. package/dist/audio/g722.js.map +1 -0
  9. package/dist/audio/index.d.ts +7 -0
  10. package/dist/audio/index.d.ts.map +1 -0
  11. package/dist/audio/index.js +8 -0
  12. package/dist/audio/index.js.map +1 -0
  13. package/dist/audio/loudness.d.ts +13 -0
  14. package/dist/audio/loudness.d.ts.map +1 -0
  15. package/dist/audio/loudness.js +65 -0
  16. package/dist/audio/loudness.js.map +1 -0
  17. package/dist/audio/mulaw.d.ts +3 -0
  18. package/dist/audio/mulaw.d.ts.map +1 -0
  19. package/dist/audio/mulaw.js +39 -0
  20. package/dist/audio/mulaw.js.map +1 -0
  21. package/dist/audio/pcm.d.ts +5 -0
  22. package/dist/audio/pcm.d.ts.map +1 -0
  23. package/dist/audio/pcm.js +40 -0
  24. package/dist/audio/pcm.js.map +1 -0
  25. package/dist/audio/resample.d.ts +12 -0
  26. package/dist/audio/resample.d.ts.map +1 -0
  27. package/dist/audio/resample.js +157 -0
  28. package/dist/audio/resample.js.map +1 -0
  29. package/dist/audio-envelope.d.ts +23 -0
  30. package/dist/audio-envelope.d.ts.map +1 -0
  31. package/dist/audio-envelope.js +114 -0
  32. package/dist/audio-envelope.js.map +1 -0
  33. package/dist/confidence-to-wait.d.ts +12 -0
  34. package/dist/confidence-to-wait.d.ts.map +1 -0
  35. package/dist/confidence-to-wait.js +18 -0
  36. package/dist/confidence-to-wait.js.map +1 -0
  37. package/dist/conversation-event.d.ts +19 -0
  38. package/dist/conversation-event.d.ts.map +1 -0
  39. package/dist/conversation-event.js +44 -0
  40. package/dist/conversation-event.js.map +1 -0
  41. package/dist/error-handler.d.ts +35 -0
  42. package/dist/error-handler.d.ts.map +1 -0
  43. package/dist/error-handler.js +136 -0
  44. package/dist/error-handler.js.map +1 -0
  45. package/dist/idle-timeout.d.ts +69 -0
  46. package/dist/idle-timeout.d.ts.map +1 -0
  47. package/dist/idle-timeout.js +149 -0
  48. package/dist/idle-timeout.js.map +1 -0
  49. package/dist/incremental-unit.d.ts +17 -0
  50. package/dist/incremental-unit.d.ts.map +1 -0
  51. package/dist/incremental-unit.js +5 -0
  52. package/dist/incremental-unit.js.map +1 -0
  53. package/dist/index.d.ts +47 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +61 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/init-chain.d.ts +35 -0
  58. package/dist/init-chain.d.ts.map +1 -0
  59. package/dist/init-chain.js +102 -0
  60. package/dist/init-chain.js.map +1 -0
  61. package/dist/init-stage-order.d.ts +5 -0
  62. package/dist/init-stage-order.d.ts.map +1 -0
  63. package/dist/init-stage-order.js +74 -0
  64. package/dist/init-stage-order.js.map +1 -0
  65. package/dist/interaction-coordinator.d.ts +45 -0
  66. package/dist/interaction-coordinator.d.ts.map +1 -0
  67. package/dist/interaction-coordinator.js +197 -0
  68. package/dist/interaction-coordinator.js.map +1 -0
  69. package/dist/interaction-policy.d.ts +103 -0
  70. package/dist/interaction-policy.d.ts.map +1 -0
  71. package/dist/interaction-policy.js +6 -0
  72. package/dist/interaction-policy.js.map +1 -0
  73. package/dist/iu-ledger.d.ts +39 -0
  74. package/dist/iu-ledger.d.ts.map +1 -0
  75. package/dist/iu-ledger.js +90 -0
  76. package/dist/iu-ledger.js.map +1 -0
  77. package/dist/latency-filler-fixtures.d.ts +7 -0
  78. package/dist/latency-filler-fixtures.d.ts.map +1 -0
  79. package/dist/latency-filler-fixtures.js +10 -0
  80. package/dist/latency-filler-fixtures.js.map +1 -0
  81. package/dist/latency-filler.d.ts +29 -0
  82. package/dist/latency-filler.d.ts.map +1 -0
  83. package/dist/latency-filler.js +102 -0
  84. package/dist/latency-filler.js.map +1 -0
  85. package/dist/mode-switcher.d.ts +24 -0
  86. package/dist/mode-switcher.d.ts.map +1 -0
  87. package/dist/mode-switcher.js +83 -0
  88. package/dist/mode-switcher.js.map +1 -0
  89. package/dist/observability-observer.d.ts +36 -0
  90. package/dist/observability-observer.d.ts.map +1 -0
  91. package/dist/observability-observer.js +141 -0
  92. package/dist/observability-observer.js.map +1 -0
  93. package/dist/observability.d.ts +68 -0
  94. package/dist/observability.d.ts.map +1 -0
  95. package/dist/observability.js +70 -0
  96. package/dist/observability.js.map +1 -0
  97. package/dist/packet-factories.d.ts +58 -0
  98. package/dist/packet-factories.d.ts.map +1 -0
  99. package/dist/packet-factories.js +181 -0
  100. package/dist/packet-factories.js.map +1 -0
  101. package/dist/packets.d.ts +532 -0
  102. package/dist/packets.d.ts.map +1 -0
  103. package/dist/packets.js +51 -0
  104. package/dist/packets.js.map +1 -0
  105. package/dist/pipeline-bus.d.ts +92 -0
  106. package/dist/pipeline-bus.d.ts.map +1 -0
  107. package/dist/pipeline-bus.js +294 -0
  108. package/dist/pipeline-bus.js.map +1 -0
  109. package/dist/plugin-contract.d.ts +73 -0
  110. package/dist/plugin-contract.d.ts.map +1 -0
  111. package/dist/plugin-contract.js +30 -0
  112. package/dist/plugin-contract.js.map +1 -0
  113. package/dist/policies/defer.d.ts +11 -0
  114. package/dist/policies/defer.d.ts.map +1 -0
  115. package/dist/policies/defer.js +13 -0
  116. package/dist/policies/defer.js.map +1 -0
  117. package/dist/policies/rule-based.d.ts +35 -0
  118. package/dist/policies/rule-based.d.ts.map +1 -0
  119. package/dist/policies/rule-based.js +130 -0
  120. package/dist/policies/rule-based.js.map +1 -0
  121. package/dist/pricing.d.ts +34 -0
  122. package/dist/pricing.d.ts.map +1 -0
  123. package/dist/pricing.js +108 -0
  124. package/dist/pricing.js.map +1 -0
  125. package/dist/primary-speaker-fixtures.d.ts +12 -0
  126. package/dist/primary-speaker-fixtures.d.ts.map +1 -0
  127. package/dist/primary-speaker-fixtures.js +37 -0
  128. package/dist/primary-speaker-fixtures.js.map +1 -0
  129. package/dist/primary-speaker-gate.d.ts +42 -0
  130. package/dist/primary-speaker-gate.d.ts.map +1 -0
  131. package/dist/primary-speaker-gate.js +178 -0
  132. package/dist/primary-speaker-gate.js.map +1 -0
  133. package/dist/provider-fallback.d.ts +28 -0
  134. package/dist/provider-fallback.d.ts.map +1 -0
  135. package/dist/provider-fallback.js +72 -0
  136. package/dist/provider-fallback.js.map +1 -0
  137. package/dist/reasoner-hedge.d.ts +21 -0
  138. package/dist/reasoner-hedge.d.ts.map +1 -0
  139. package/dist/reasoner-hedge.js +170 -0
  140. package/dist/reasoner-hedge.js.map +1 -0
  141. package/dist/reasoner-route.d.ts +24 -0
  142. package/dist/reasoner-route.d.ts.map +1 -0
  143. package/dist/reasoner-route.js +87 -0
  144. package/dist/reasoner-route.js.map +1 -0
  145. package/dist/reasoner-session-store.d.ts +18 -0
  146. package/dist/reasoner-session-store.d.ts.map +1 -0
  147. package/dist/reasoner-session-store.js +21 -0
  148. package/dist/reasoner-session-store.js.map +1 -0
  149. package/dist/reasoner.d.ts +97 -0
  150. package/dist/reasoner.d.ts.map +1 -0
  151. package/dist/reasoner.js +9 -0
  152. package/dist/reasoner.js.map +1 -0
  153. package/dist/retry.d.ts +31 -0
  154. package/dist/retry.d.ts.map +1 -0
  155. package/dist/retry.js +83 -0
  156. package/dist/retry.js.map +1 -0
  157. package/dist/scheduler.d.ts +11 -0
  158. package/dist/scheduler.d.ts.map +1 -0
  159. package/dist/scheduler.js +20 -0
  160. package/dist/scheduler.js.map +1 -0
  161. package/dist/spend-cap.d.ts +27 -0
  162. package/dist/spend-cap.d.ts.map +1 -0
  163. package/dist/spend-cap.js +38 -0
  164. package/dist/spend-cap.js.map +1 -0
  165. package/dist/tts-playout-clock.d.ts +43 -0
  166. package/dist/tts-playout-clock.d.ts.map +1 -0
  167. package/dist/tts-playout-clock.js +125 -0
  168. package/dist/tts-playout-clock.js.map +1 -0
  169. package/dist/turn-arbiter.d.ts +41 -0
  170. package/dist/turn-arbiter.d.ts.map +1 -0
  171. package/dist/turn-arbiter.js +259 -0
  172. package/dist/turn-arbiter.js.map +1 -0
  173. package/dist/voice-agent-session-util.d.ts +60 -0
  174. package/dist/voice-agent-session-util.d.ts.map +1 -0
  175. package/dist/voice-agent-session-util.js +204 -0
  176. package/dist/voice-agent-session-util.js.map +1 -0
  177. package/dist/voice-agent-session.d.ts +435 -0
  178. package/dist/voice-agent-session.d.ts.map +1 -0
  179. package/dist/voice-agent-session.js +1597 -0
  180. package/dist/voice-agent-session.js.map +1 -0
  181. package/dist/voice-text.d.ts +46 -0
  182. package/dist/voice-text.d.ts.map +1 -0
  183. package/dist/voice-text.js +209 -0
  184. package/dist/voice-text.js.map +1 -0
  185. package/package.json +19 -6
  186. package/src/audio/alaw.test.ts +87 -0
  187. package/src/audio/audio.test.ts +285 -0
  188. package/src/audio/g722.test.ts +149 -0
  189. package/src/audio/loudness.test.ts +58 -0
  190. package/src/audio-envelope.test.ts +167 -0
  191. package/src/confidence-to-wait.test.ts +21 -0
  192. package/src/error-handler.test.ts +56 -0
  193. package/src/hedge-throwing-backend.test.ts +46 -0
  194. package/src/interaction-coordinator.test.ts +529 -0
  195. package/src/iu-ledger.test.ts +276 -0
  196. package/src/latency-filler.test.ts +62 -0
  197. package/src/observability-observer.test.ts +248 -0
  198. package/src/observability.test.ts +94 -0
  199. package/src/packet-factories.test.ts +126 -0
  200. package/src/pipeline-bus.g10.test.ts +145 -0
  201. package/src/pipeline-bus.test.ts +211 -0
  202. package/src/policies/defer.test.ts +86 -0
  203. package/src/policies/rule-based.test.ts +334 -0
  204. package/src/pricing.test.ts +226 -0
  205. package/src/primary-speaker-gate.test.ts +150 -0
  206. package/src/provider-fallback.test.ts +87 -0
  207. package/src/reasoner-hedge.test.ts +361 -0
  208. package/src/reasoner-route.test.ts +248 -0
  209. package/src/reasoner.test.ts +69 -0
  210. package/src/retry.test.ts +83 -0
  211. package/src/route-throwing-spec.test.ts +44 -0
  212. package/src/spend-cap.test.ts +114 -0
  213. package/src/tts-playout-clock.test.ts +125 -0
  214. package/src/turn-arbiter.characterization.test.ts +477 -0
  215. package/src/turn-arbiter.test.ts +762 -0
  216. package/src/voice-agent-session.test.ts +4165 -0
  217. package/src/voice-text.test.ts +156 -0
@@ -0,0 +1,285 @@
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
+ });
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // G.722 tests — round-trip SNR + known-answer smoke.
4
+ // NOT ITU-vector-certified (no embedded ITU G.722 Appendix test vectors).
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ createG722DecoderState,
9
+ createG722EncoderState,
10
+ decodeG722,
11
+ encodeG722,
12
+ G722_SAMPLE_RATE_HZ,
13
+ } from "./g722.js";
14
+
15
+ /**
16
+ * SNR with QMF group-delay alignment: recovered lags original by `delay` samples
17
+ * (recovered[i + delay] ≈ original[i]).
18
+ */
19
+ function snrDb(original: Int16Array, recovered: Int16Array, delay = 0): number {
20
+ let signal = 0;
21
+ let noise = 0;
22
+ const start = 320;
23
+ const end = Math.min(original.length, recovered.length - delay) - 8;
24
+ for (let i = start; i < end; i += 1) {
25
+ const o = original[i]!;
26
+ const r = recovered[i + delay]!;
27
+ const e = o - r;
28
+ signal += o * o;
29
+ noise += e * e;
30
+ }
31
+ if (noise === 0) return Infinity;
32
+ if (signal === 0) return 0;
33
+ return 10 * Math.log10(signal / noise);
34
+ }
35
+
36
+ /** Empirical QMF analysis+synthesis group delay for this implementation (samples). */
37
+ const QMF_GROUP_DELAY = 22;
38
+
39
+ function sine(hz: number, seconds: number, amp = 8000): Int16Array {
40
+ const n = Math.round(G722_SAMPLE_RATE_HZ * seconds);
41
+ // even length for clean pairing
42
+ const len = n & ~1;
43
+ const out = new Int16Array(len);
44
+ for (let i = 0; i < len; i += 1) {
45
+ out[i] = Math.round(amp * Math.sin((2 * Math.PI * hz * i) / G722_SAMPLE_RATE_HZ));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ describe("G.722 API shape", () => {
51
+ it("exports sample rate constant 16000", () => {
52
+ expect(G722_SAMPLE_RATE_HZ).toBe(16_000);
53
+ });
54
+
55
+ it("encoder produces half the sample count in bytes", () => {
56
+ const enc = createG722EncoderState();
57
+ const pcm = new Int16Array(320); // 20 ms @ 16 kHz
58
+ const bytes = encodeG722(enc, pcm);
59
+ expect(bytes.length).toBe(160);
60
+ });
61
+
62
+ it("decoder produces 2 samples per G.722 byte", () => {
63
+ const enc = createG722EncoderState();
64
+ const dec = createG722DecoderState();
65
+ const pcm = sine(1000, 0.02);
66
+ const bytes = encodeG722(enc, pcm);
67
+ const out = decodeG722(dec, bytes);
68
+ expect(out.length).toBe(bytes.length * 2);
69
+ });
70
+
71
+ it("holds an odd trailing sample for the next encode call", () => {
72
+ const enc = createG722EncoderState();
73
+ const a = encodeG722(enc, new Int16Array([100, 200, 300]));
74
+ expect(a.length).toBe(1); // one pair; 300 pending
75
+ const b = encodeG722(enc, new Int16Array([400]));
76
+ expect(b.length).toBe(1); // pending 300 + 400
77
+ });
78
+ });
79
+
80
+ describe("G.722 round-trip (spec-implemented, NOT ITU-vector-certified)", () => {
81
+ it("round-trips silence near zero after QMF settle", () => {
82
+ const enc = createG722EncoderState();
83
+ const dec = createG722DecoderState();
84
+ // Warm-up: QMF needs history before settling
85
+ const warm = new Int16Array(480);
86
+ decodeG722(dec, encodeG722(enc, warm));
87
+ const silence = new Int16Array(320);
88
+ const recovered = decodeG722(dec, encodeG722(enc, silence));
89
+ for (let i = 0; i < recovered.length; i += 1) {
90
+ expect(Math.abs(recovered[i]!)).toBeLessThanOrEqual(64);
91
+ }
92
+ });
93
+
94
+ it("round-trips 1 kHz tone with SNR ≥ 20 dB (after settle + QMF delay)", () => {
95
+ const enc = createG722EncoderState();
96
+ const dec = createG722DecoderState();
97
+ const pcm = sine(1000, 0.25, 10000);
98
+ const recovered = decodeG722(dec, encodeG722(enc, pcm));
99
+ const snr = snrDb(pcm, recovered, QMF_GROUP_DELAY);
100
+ expect(snr).toBeGreaterThanOrEqual(20);
101
+ });
102
+
103
+ it("round-trips multi-tone speech-like signal with SNR ≥ 18 dB", () => {
104
+ const enc = createG722EncoderState();
105
+ const dec = createG722DecoderState();
106
+ const n = 1600; // 100 ms
107
+ const pcm = new Int16Array(n);
108
+ for (let i = 0; i < n; i += 1) {
109
+ const t = i / G722_SAMPLE_RATE_HZ;
110
+ pcm[i] = Math.round(
111
+ 6000 * Math.sin(2 * Math.PI * 300 * t) +
112
+ 4000 * Math.sin(2 * Math.PI * 900 * t) +
113
+ 2500 * Math.sin(2 * Math.PI * 1800 * t) +
114
+ 1500 * Math.sin(2 * Math.PI * 3200 * t),
115
+ );
116
+ }
117
+ const recovered = decodeG722(dec, encodeG722(enc, pcm));
118
+ const snr = snrDb(pcm, recovered, QMF_GROUP_DELAY);
119
+ expect(snr).toBeGreaterThanOrEqual(18);
120
+ });
121
+
122
+ it("preserves sign of a positive then negative burst (aligned by QMF delay)", () => {
123
+ const enc = createG722EncoderState();
124
+ const dec = createG722DecoderState();
125
+ decodeG722(dec, encodeG722(enc, new Int16Array(640)));
126
+ const pos = new Int16Array(320).fill(8000);
127
+ const neg = new Int16Array(320).fill(-8000);
128
+ const rPos = decodeG722(dec, encodeG722(enc, pos));
129
+ const rNeg = decodeG722(dec, encodeG722(enc, neg));
130
+ // recovered[i + delay] ≈ input[i]; probe mid-burst after delay
131
+ const mid = 160;
132
+ expect(rPos[mid + QMF_GROUP_DELAY]!).toBeGreaterThan(0);
133
+ expect(rNeg[mid + QMF_GROUP_DELAY]!).toBeLessThan(0);
134
+ });
135
+
136
+ it("known-answer: encoder is deterministic for a fixed impulse train", () => {
137
+ const enc = createG722EncoderState();
138
+ const pcm = new Int16Array(64);
139
+ pcm[0] = 10000;
140
+ pcm[1] = -10000;
141
+ pcm[16] = 5000;
142
+ const a = encodeG722(enc, pcm);
143
+ const enc2 = createG722EncoderState();
144
+ const b = encodeG722(enc2, pcm);
145
+ expect(Array.from(a)).toEqual(Array.from(b));
146
+ // non-trivial: not all zeros after impulse
147
+ expect(a.some((v) => v !== 0)).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { createLoudnessState, normalizeLoudness, type LoudnessConfig } from "./loudness.js";
5
+
6
+ function rms(pcm: Int16Array): number {
7
+ let sumSquares = 0;
8
+ for (const sample of pcm) sumSquares += sample * sample;
9
+ return Math.sqrt(sumSquares / pcm.length);
10
+ }
11
+
12
+ function constantPcm(value: number, length = 320): Int16Array {
13
+ return new Int16Array(length).fill(value);
14
+ }
15
+
16
+ describe("normalizeLoudness", () => {
17
+ it("raises quiet PCM and lowers loud PCM toward the target", () => {
18
+ const config: LoudnessConfig = { targetRms: 8000 };
19
+ const quiet = normalizeLoudness(constantPcm(1000), createLoudnessState(), config);
20
+ const loud = normalizeLoudness(constantPcm(24000), createLoudnessState(), config);
21
+
22
+ expect(rms(quiet)).toBeGreaterThan(1000);
23
+ expect(rms(loud)).toBeLessThan(24000);
24
+ expect(Math.max(...quiet)).toBeLessThanOrEqual(32767);
25
+ expect(Math.min(...quiet)).toBeGreaterThanOrEqual(-32768);
26
+ expect(Math.max(...loud)).toBeLessThanOrEqual(32767);
27
+ expect(Math.min(...loud)).toBeGreaterThanOrEqual(-32768);
28
+ });
29
+
30
+ it("converges different input levels into the same output band", () => {
31
+ const config: LoudnessConfig = { targetRms: 8000 };
32
+ const quietState = createLoudnessState();
33
+ const loudState = createLoudnessState();
34
+ let quietRms = 0;
35
+ let loudRms = 0;
36
+
37
+ for (let i = 0; i < 12; i += 1) {
38
+ quietRms = rms(normalizeLoudness(constantPcm(2000), quietState, config));
39
+ loudRms = rms(normalizeLoudness(constantPcm(24000), loudState, config));
40
+ }
41
+
42
+ expect(Math.abs(quietRms - loudRms)).toBeLessThanOrEqual(100);
43
+ expect(Math.abs(quietRms - config.targetRms)).toBeLessThanOrEqual(100);
44
+ });
45
+
46
+ it("uses a soft ceiling for full-scale input without clipping or wrapping", () => {
47
+ const output = normalizeLoudness(
48
+ new Int16Array([32767, -32768, 32767, -32768]),
49
+ createLoudnessState(),
50
+ { targetRms: 32767 },
51
+ );
52
+
53
+ expect(Math.max(...output)).toBeLessThan(32767);
54
+ expect(Math.min(...output)).toBeGreaterThan(-32768);
55
+ expect(Math.max(...output)).toBeGreaterThan(0);
56
+ expect(Math.min(...output)).toBeLessThan(0);
57
+ });
58
+ });
@@ -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,21 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { confidenceToWaitMs } from "./confidence-to-wait.js";
6
+
7
+ describe("confidenceToWaitMs", () => {
8
+ it("maps high confidence to 150ms and low confidence to 2000ms", () => {
9
+ expect(confidenceToWaitMs(1)).toBe(150);
10
+ expect(confidenceToWaitMs(0)).toBe(2000);
11
+ expect(confidenceToWaitMs(2)).toBe(150);
12
+ expect(confidenceToWaitMs(-1)).toBe(2000);
13
+ });
14
+
15
+ it("is monotonic and supports a bounded custom range", () => {
16
+ const waits = [0, 0.25, 0.5, 0.75, 1].map((confidence) => confidenceToWaitMs(confidence));
17
+ expect(waits).toEqual([...waits].sort((a, b) => b - a));
18
+ expect(confidenceToWaitMs(0.5, { minWaitMs: 100, maxWaitMs: 1100 })).toBe(600);
19
+ expect(() => confidenceToWaitMs(0.5, { minWaitMs: 500, maxWaitMs: 100 })).toThrow(/must be >=/);
20
+ });
21
+ });