@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/README.md CHANGED
@@ -4,6 +4,10 @@ The Syrinx Kernel v2 — the framework-agnostic core of the voice engine: the `P
4
4
 
5
5
  This README documents the **`Reasoner` seam** added for the Reasoner-bridge generalization; the rest of the kernel surface is in `src/index.ts`.
6
6
 
7
+ ## Incremental-Unit ledger (dormant)
8
+
9
+ The IU substrate (`IncrementalUnit`, `InMemoryIuLedger`) models speculative voice work as **hypothesize → ground → discard**: `add` registers a hypothesis, `commit` grounds it (optionally with a heard prefix in chars/ms), and `revoke` discards it. State is monotonic (`hypothesized` → `committed` or `revoked`; terminal ops are idempotent). The ledger is **dormant** in Sprint 0 — no pipeline consumer is wired yet; C2+ will re-express speculative generation, barge-in truncation, and poison-set bookkeeping on this primitive.
10
+
7
11
  ## The Reasoner seam
8
12
 
9
13
  A normalized pull-stream that lets one bridge ([`ReasoningBridge`](../aisdk/README.md)) drive **any** reasoning backend (Vercel AI SDK `ToolLoopAgent`/`streamText`, Mastra `Agent`, …) without changing the pipeline primitive. Defined in `src/reasoner.ts`:
package/package.json CHANGED
@@ -1,9 +1,28 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/core",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Voice pipeline kernel for Syrinx — pipeline bus, turn arbitration, playout clock, packets, and the framework-agnostic Reasoner seam",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "voice-pipeline",
12
+ "barge-in",
13
+ "turn-taking"
14
+ ],
6
15
  "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/core"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
7
26
  "main": "./src/index.ts",
8
27
  "types": "./src/index.ts",
9
28
  "exports": {
@@ -13,7 +32,7 @@
13
32
  "dependencies": {},
14
33
  "devDependencies": {
15
34
  "typescript": "^5.7.0",
16
- "vitest": "^2.1.0"
35
+ "vitest": "^3.2.6"
17
36
  },
18
37
  "scripts": {
19
38
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // ITU-T G.711 A-law (PCMA). Pure TypedArray — Workers-safe (no Buffer/node:).
4
+ // Unit-verified against known A-law encode/decode values + round-trip fidelity.
5
+ // Unverified against a live carrier trunk negotiation.
6
+
7
+ const ALAW_CLIP = 32635;
8
+ const ALAW_BIAS = 0x55;
9
+
10
+ export function decodeALawToPcm16(input: Uint8Array): Int16Array {
11
+ const output = new Int16Array(input.byteLength);
12
+ for (let i = 0; i < input.byteLength; i += 1) {
13
+ const alaw = input[i]! ^ ALAW_BIAS;
14
+ // A-law sign bit is inverted vs μ-law: 0x80 means positive.
15
+ const positive = (alaw & 0x80) !== 0;
16
+ const exponent = (alaw >> 4) & 0x07;
17
+ const mantissa = alaw & 0x0f;
18
+ let sample: number;
19
+ if (exponent === 0) {
20
+ sample = (mantissa << 4) + 8;
21
+ } else {
22
+ sample = ((mantissa << 4) + 0x108) << (exponent - 1);
23
+ }
24
+ output[i] = positive ? sample : -sample;
25
+ }
26
+ return output;
27
+ }
28
+
29
+ export function encodePcm16ToALaw(input: Int16Array): Uint8Array {
30
+ const output = new Uint8Array(input.length);
31
+ for (let i = 0; i < input.length; i += 1) {
32
+ output[i] = encodeSample(input[i]!);
33
+ }
34
+ return output;
35
+ }
36
+
37
+ function encodeSample(sample: number): number {
38
+ // A-law: sign bit 0x80 = positive (opposite of μ-law).
39
+ let sign = 0x80;
40
+ let magnitude = sample;
41
+ if (magnitude < 0) {
42
+ sign = 0;
43
+ magnitude = -magnitude;
44
+ }
45
+ if (magnitude > ALAW_CLIP) magnitude = ALAW_CLIP;
46
+
47
+ let exponent = 7;
48
+ for (let mask = 0x4000; (magnitude & mask) === 0 && exponent > 0; mask >>= 1) {
49
+ exponent -= 1;
50
+ }
51
+ const mantissa =
52
+ exponent === 0
53
+ ? (magnitude >> 4) & 0x0f
54
+ : (magnitude >> (exponent + 3)) & 0x0f;
55
+ return (sign | (exponent << 4) | mantissa) ^ ALAW_BIAS;
56
+ }
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // ITU-T G.722 64 kbit/s sub-band ADPCM (16 kHz PCM16 ↔ G.722).
4
+ // Pure TypedArray — Workers-safe (no Buffer / node: / process).
5
+ //
6
+ // HONESTY: spec-implemented (ITU-T G.722 algorithm tables + QMF + dual-band
7
+ // ADPCM), round-trip-tested on speech-like signals. NOT ITU-vector-certified
8
+ // (authoritative ITU G.722 test vectors were not embedded). Unverified against
9
+ // a live carrier trunk negotiation.
10
+
11
+ /** 16 kHz wideband PCM sample rate for G.722. */
12
+ export const G722_SAMPLE_RATE_HZ = 16_000;
13
+
14
+ /** 64 kbit/s = 1 byte per pair of 16 kHz samples. */
15
+ export const G722_BITRATE = 64_000;
16
+
17
+ // ── ITU-T G.722 tables ──────────────────────────────────────────────────────
18
+
19
+ const QMF_FWD = Object.freeze([
20
+ 3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11,
21
+ ]);
22
+ const QMF_REV = Object.freeze([
23
+ -11, 53, -156, 362, -805, 3876, 951, -210, 32, 12, -11, 3,
24
+ ]);
25
+
26
+ const QM2 = Object.freeze([-7408, -1616, 7408, 1616]);
27
+ const QM4 = Object.freeze([
28
+ 0, -20456, -12896, -8968, -6288, -4240, -2584, -1200, 20456, 12896, 8968, 6288, 4240, 2584, 1200, 0,
29
+ ]);
30
+ const QM6 = Object.freeze([
31
+ -136, -136, -136, -136, -24808, -21904, -19008, -16704, -14984, -13512, -12280, -11192, -10232, -9360, -8576, -7856,
32
+ -7192, -6576, -6000, -5456, -4944, -4464, -4008, -3576, -3168, -2776, -2400, -2032, -1688, -1360, -1040, -728,
33
+ 24808, 21904, 19008, 16704, 14984, 13512, 12280, 11192, 10232, 9360, 8576, 7856, 7192, 6576, 6000, 5456,
34
+ 4944, 4464, 4008, 3576, 3168, 2776, 2400, 2032, 1688, 1360, 1040, 728, 432, 136, -432, -136,
35
+ ]);
36
+ const Q6 = Object.freeze([
37
+ 0, 35, 72, 110, 150, 190, 233, 276, 323, 370, 422, 473, 530, 587, 650, 714, 786, 858, 940, 1023, 1121, 1219, 1339, 1458,
38
+ 1612, 1765, 1980, 2195, 2557, 2919, 0, 0,
39
+ ]);
40
+ const ILB = Object.freeze([
41
+ 2048, 2093, 2139, 2186, 2233, 2282, 2332, 2383, 2435, 2489, 2543, 2599, 2656, 2714, 2774, 2834, 2896, 2960, 3025, 3091,
42
+ 3158, 3228, 3298, 3371, 3444, 3520, 3597, 3676, 3756, 3838, 3922, 4008,
43
+ ]);
44
+ const ILN = Object.freeze([0, 63, 62, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 0]);
45
+ const ILP = Object.freeze([0, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 0]);
46
+ const IHN = Object.freeze([0, 1, 0]);
47
+ const IHP = Object.freeze([0, 3, 2]);
48
+ const WL = Object.freeze([-60, -30, 58, 172, 334, 538, 1198, 3042]);
49
+ const RL42 = Object.freeze([0, 7, 6, 5, 4, 3, 2, 1, 7, 6, 5, 4, 3, 2, 1, 0]);
50
+ const WH = Object.freeze([0, -214, 798]);
51
+ const RH2 = Object.freeze([2, 1, 2, 1]);
52
+
53
+ // ── helpers ─────────────────────────────────────────────────────────────────
54
+
55
+ function saturate16(amp: number): number {
56
+ if (amp > 32767) return 32767;
57
+ if (amp < -32768) return -32768;
58
+ return amp | 0;
59
+ }
60
+
61
+ function saturate15(amp: number): number {
62
+ if (amp > 16383) return 16383;
63
+ if (amp < -16384) return -16384;
64
+ return amp | 0;
65
+ }
66
+
67
+ function satAdd16(a: number, b: number): number {
68
+ return saturate16(a + b);
69
+ }
70
+
71
+ function satSub16(a: number, b: number): number {
72
+ return saturate16(a - b);
73
+ }
74
+
75
+ function circularDot(hist: Int16Array, coeffs: readonly number[], n: number, pos: number): number {
76
+ let z = 0;
77
+ for (let i = 0; i < n - pos; i += 1) z += hist[pos + i]! * coeffs[i]!;
78
+ for (let i = 0; i < pos; i += 1) z += hist[i]! * coeffs[n - pos + i]!;
79
+ return z;
80
+ }
81
+
82
+ interface BandState {
83
+ nb: number;
84
+ det: number;
85
+ s: number;
86
+ sz: number;
87
+ r: number;
88
+ p: [number, number];
89
+ a: [number, number];
90
+ b: [number, number, number, number, number, number];
91
+ d: [number, number, number, number, number, number, number];
92
+ }
93
+
94
+ function createBand(det: number): BandState {
95
+ return {
96
+ nb: 0,
97
+ det,
98
+ s: 0,
99
+ sz: 0,
100
+ r: 0,
101
+ p: [0, 0],
102
+ a: [0, 0],
103
+ b: [0, 0, 0, 0, 0, 0],
104
+ d: [0, 0, 0, 0, 0, 0, 0],
105
+ };
106
+ }
107
+
108
+ function block4(band: BandState, dx: number): void {
109
+ const r = satAdd16(band.s, dx);
110
+ const p = satAdd16(band.sz, dx);
111
+
112
+ // UPPOL2
113
+ let wd1 = saturate16(band.a[0] << 2);
114
+ let wd32 = (p ^ band.p[0]) & 0x8000 ? wd1 : -wd1;
115
+ if (wd32 > 32767) wd32 = 32767;
116
+ let wd3 =
117
+ (((p ^ band.p[1]) & 0x8000 ? -128 : 128) +
118
+ (wd32 >> 7) +
119
+ ((band.a[1] * 32512) >> 15)) |
120
+ 0;
121
+ if (Math.abs(wd3) > 12288) wd3 = wd3 < 0 ? -12288 : 12288;
122
+ const ap1 = wd3;
123
+
124
+ // UPPOL1
125
+ wd1 = (p ^ band.p[0]) & 0x8000 ? -192 : 192;
126
+ let wd2 = ((band.a[0] * 32640) >> 15) | 0;
127
+ let ap0 = satAdd16(wd1, wd2);
128
+ wd3 = satSub16(15360, ap1);
129
+ if (Math.abs(ap0) > wd3) ap0 = ap0 < 0 ? -wd3 : wd3;
130
+
131
+ // FILTEP
132
+ wd1 = satAdd16(r, r);
133
+ wd1 = ((ap0 * wd1) >> 15) | 0;
134
+ wd2 = satAdd16(band.r, band.r);
135
+ wd2 = ((ap1 * wd2) >> 15) | 0;
136
+ const sp = satAdd16(wd1, wd2);
137
+ band.r = r;
138
+ band.a[1] = ap1;
139
+ band.a[0] = ap0;
140
+ band.p[1] = band.p[0];
141
+ band.p[0] = p;
142
+
143
+ // UPZERO / DELAYA / FILTEZ
144
+ wd1 = dx === 0 ? 0 : 128;
145
+ band.d[0] = dx;
146
+ let sz = 0;
147
+ for (let i = 5; i >= 0; i -= 1) {
148
+ wd2 = (band.d[i + 1]! ^ dx) & 0x8000 ? -wd1 : wd1;
149
+ wd3 = ((band.b[i]! * 32640) >> 15) | 0;
150
+ band.b[i] = satAdd16(wd2, wd3);
151
+ wd3 = satAdd16(band.d[i]!, band.d[i]!);
152
+ sz += (band.b[i]! * wd3) >> 15;
153
+ band.d[i + 1] = band.d[i]!;
154
+ }
155
+ band.sz = saturate16(sz);
156
+ band.s = satAdd16(sp, band.sz);
157
+ }
158
+
159
+ // ── public state + API ──────────────────────────────────────────────────────
160
+
161
+ export interface G722EncoderState {
162
+ readonly _kind: "g722-encoder";
163
+ ptr: number;
164
+ x: Int16Array;
165
+ y: Int16Array;
166
+ band: [BandState, BandState];
167
+ /** leftover sample when input length is odd */
168
+ pending: number | null;
169
+ }
170
+
171
+ export interface G722DecoderState {
172
+ readonly _kind: "g722-decoder";
173
+ ptr: number;
174
+ x: Int16Array;
175
+ y: Int16Array;
176
+ band: [BandState, BandState];
177
+ }
178
+
179
+ export function createG722EncoderState(): G722EncoderState {
180
+ return {
181
+ _kind: "g722-encoder",
182
+ ptr: 0,
183
+ x: new Int16Array(12),
184
+ y: new Int16Array(12),
185
+ band: [createBand(32), createBand(8)],
186
+ pending: null,
187
+ };
188
+ }
189
+
190
+ export function createG722DecoderState(): G722DecoderState {
191
+ return {
192
+ _kind: "g722-decoder",
193
+ ptr: 0,
194
+ x: new Int16Array(12),
195
+ y: new Int16Array(12),
196
+ band: [createBand(32), createBand(8)],
197
+ };
198
+ }
199
+
200
+ /** Encode 16 kHz PCM16 → G.722 64 kbit/s bytes. Mutates `state`. Odd trailing sample is held for next call. */
201
+ export function encodeG722(state: G722EncoderState, pcm16: Int16Array): Uint8Array {
202
+ let offset = 0;
203
+ let samples = pcm16;
204
+ if (state.pending !== null) {
205
+ const merged = new Int16Array(pcm16.length + 1);
206
+ merged[0] = state.pending;
207
+ merged.set(pcm16, 1);
208
+ samples = merged;
209
+ state.pending = null;
210
+ }
211
+ if (samples.length % 2 !== 0) {
212
+ state.pending = samples[samples.length - 1]!;
213
+ samples = samples.subarray(0, samples.length - 1);
214
+ }
215
+ const out = new Uint8Array(samples.length >> 1);
216
+ let outIdx = 0;
217
+
218
+ while (offset < samples.length) {
219
+ state.x[state.ptr] = samples[offset++]!;
220
+ state.y[state.ptr] = samples[offset++]!;
221
+ state.ptr += 1;
222
+ if (state.ptr >= 12) state.ptr = 0;
223
+
224
+ const sumodd = circularDot(state.x, QMF_FWD, 12, state.ptr);
225
+ const sumeven = circularDot(state.y, QMF_REV, 12, state.ptr);
226
+ const xlow = ((sumeven + sumodd) >> 14) | 0;
227
+ const xhigh = ((sumeven - sumodd) >> 14) | 0;
228
+
229
+ // Low band
230
+ const el = satSub16(xlow, state.band[0].s);
231
+ let wd = el >= 0 ? el : ~el;
232
+ let i = 1;
233
+ for (; i < 30; i += 1) {
234
+ const wd1 = (Q6[i]! * state.band[0].det) >> 12;
235
+ if (wd < wd1) break;
236
+ }
237
+ const ilow = el < 0 ? ILN[i]! : ILP[i]!;
238
+ const ril = ilow >> 2;
239
+ let dlow = ((state.band[0].det * QM4[ril]!) >> 15) | 0;
240
+ const il4 = RL42[ril]!;
241
+ wd = ((state.band[0].nb * 127) >> 7) + WL[il4]!;
242
+ if (wd < 0) wd = 0;
243
+ else if (wd > 18432) wd = 18432;
244
+ state.band[0].nb = wd;
245
+ let wd1 = (state.band[0].nb >> 6) & 31;
246
+ let wd2 = 8 - (state.band[0].nb >> 11);
247
+ let wd3 = wd2 < 0 ? ILB[wd1]! << -wd2 : ILB[wd1]! >> wd2;
248
+ state.band[0].det = (wd3 << 2) | 0;
249
+ block4(state.band[0], dlow);
250
+
251
+ // High band
252
+ const eh = satSub16(xhigh, state.band[1].s);
253
+ wd = eh >= 0 ? eh : ~eh;
254
+ wd1 = (564 * state.band[1].det) >> 12;
255
+ const mih = wd >= wd1 ? 2 : 1;
256
+ const ihigh = eh < 0 ? IHN[mih]! : IHP[mih]!;
257
+ const dhigh = ((state.band[1].det * QM2[ihigh]!) >> 15) | 0;
258
+ const ih2 = RH2[ihigh]!;
259
+ wd = ((state.band[1].nb * 127) >> 7) + WH[ih2]!;
260
+ if (wd < 0) wd = 0;
261
+ else if (wd > 22528) wd = 22528;
262
+ state.band[1].nb = wd;
263
+ wd1 = (state.band[1].nb >> 6) & 31;
264
+ wd2 = 10 - (state.band[1].nb >> 11);
265
+ wd3 = wd2 < 0 ? ILB[wd1]! << -wd2 : ILB[wd1]! >> wd2;
266
+ state.band[1].det = (wd3 << 2) | 0;
267
+ block4(state.band[1], dhigh);
268
+
269
+ out[outIdx++] = ((ihigh << 6) | ilow) & 0xff;
270
+ }
271
+ return outIdx === out.length ? out : out.subarray(0, outIdx);
272
+ }
273
+
274
+ /** Decode G.722 64 kbit/s bytes → 16 kHz PCM16. Mutates `state`. */
275
+ export function decodeG722(state: G722DecoderState, g722: Uint8Array): Int16Array {
276
+ const out = new Int16Array(g722.byteLength * 2);
277
+ let outIdx = 0;
278
+
279
+ for (let j = 0; j < g722.byteLength; j += 1) {
280
+ const code = g722[j]!;
281
+ let wd1 = code & 0x3f;
282
+ const ihigh = (code >> 6) & 0x03;
283
+ let wd2 = QM6[wd1]!;
284
+ wd1 >>= 2;
285
+
286
+ // Low band INVQBL / RECONS / LIMIT
287
+ wd2 = ((state.band[0].det * wd2) >> 15) | 0;
288
+ const rlow = saturate15(state.band[0].s + wd2);
289
+
290
+ // Low band INVQAL + LOGSCL + SCALEL
291
+ wd2 = QM4[wd1]!;
292
+ const dlow = ((state.band[0].det * wd2) >> 15) | 0;
293
+ wd2 = RL42[wd1]!;
294
+ let nb = ((state.band[0].nb * 127) >> 7) + WL[wd2]!;
295
+ if (nb < 0) nb = 0;
296
+ else if (nb > 18432) nb = 18432;
297
+ state.band[0].nb = nb;
298
+ let s1 = (state.band[0].nb >> 6) & 31;
299
+ let s2 = 8 - (state.band[0].nb >> 11);
300
+ let s3 = s2 < 0 ? ILB[s1]! << -s2 : ILB[s1]! >> s2;
301
+ state.band[0].det = (s3 << 2) | 0;
302
+ block4(state.band[0], dlow);
303
+
304
+ // High band
305
+ wd2 = QM2[ihigh]!;
306
+ const dhigh = ((state.band[1].det * wd2) >> 15) | 0;
307
+ const rhigh = saturate15(dhigh + state.band[1].s);
308
+ wd2 = RH2[ihigh]!;
309
+ nb = ((state.band[1].nb * 127) >> 7) + WH[wd2]!;
310
+ if (nb < 0) nb = 0;
311
+ else if (nb > 22528) nb = 22528;
312
+ state.band[1].nb = nb;
313
+ s1 = (state.band[1].nb >> 6) & 31;
314
+ s2 = 10 - (state.band[1].nb >> 11);
315
+ s3 = s2 < 0 ? ILB[s1]! << -s2 : ILB[s1]! >> s2;
316
+ state.band[1].det = (s3 << 2) | 0;
317
+ block4(state.band[1], dhigh);
318
+
319
+ // QMF synthesis
320
+ state.x[state.ptr] = (rlow + rhigh) | 0;
321
+ state.y[state.ptr] = (rlow - rhigh) | 0;
322
+ state.ptr += 1;
323
+ if (state.ptr >= 12) state.ptr = 0;
324
+ // QMF introduces ~22-sample group delay; polarity follows the ITU analysis/synthesis pair.
325
+ out[outIdx++] = (circularDot(state.y, QMF_REV, 12, state.ptr) >> 11) | 0;
326
+ out[outIdx++] = (circularDot(state.x, QMF_FWD, 12, state.ptr) >> 11) | 0;
327
+ }
328
+ return outIdx === out.length ? out : out.subarray(0, outIdx);
329
+ }
@@ -2,4 +2,16 @@
2
2
 
3
3
  export { pcm16BytesToSamples, pcm16SamplesToBytes, bigEndianPcm16BytesToSamples, pcm16SamplesToBigEndianBytes } from "./pcm.js";
4
4
  export { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "./mulaw.js";
5
+ export { decodeALawToPcm16, encodePcm16ToALaw } from "./alaw.js";
6
+ export {
7
+ createG722EncoderState,
8
+ createG722DecoderState,
9
+ encodeG722,
10
+ decodeG722,
11
+ G722_SAMPLE_RATE_HZ,
12
+ G722_BITRATE,
13
+ type G722EncoderState,
14
+ type G722DecoderState,
15
+ } from "./g722.js";
5
16
  export { resamplePcm16, resamplePcm16Streaming, StreamingPcm16Resampler } from "./resample.js";
17
+ export { createLoudnessState, normalizeLoudness, type LoudnessConfig, type LoudnessState } from "./loudness.js";
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ const INT16_MAX = 32767;
4
+ const DEFAULT_MAX_GAIN_STEP = 0.5;
5
+ const DEFAULT_RMS_SMOOTHING = 0.25;
6
+ const DEFAULT_CEILING = 30000;
7
+
8
+ export interface LoudnessConfig {
9
+ readonly targetRms: number;
10
+ readonly maxGainStep?: number;
11
+ readonly rmsSmoothing?: number;
12
+ readonly ceiling?: number;
13
+ }
14
+
15
+ export interface LoudnessState {
16
+ gain: number;
17
+ runningRms: number;
18
+ }
19
+
20
+ export function createLoudnessState(): LoudnessState {
21
+ return { gain: 1, runningRms: 0 };
22
+ }
23
+
24
+ export function normalizeLoudness(
25
+ pcm: Int16Array,
26
+ state: LoudnessState,
27
+ cfg: LoudnessConfig,
28
+ ): Int16Array {
29
+ const targetRms = positiveBounded(cfg.targetRms, "targetRms", INT16_MAX);
30
+ const maxGainStep = positiveFinite(cfg.maxGainStep ?? DEFAULT_MAX_GAIN_STEP, "maxGainStep");
31
+ const rmsSmoothing = bounded(cfg.rmsSmoothing ?? DEFAULT_RMS_SMOOTHING, "rmsSmoothing", 0, 1);
32
+ const ceiling = positiveBounded(cfg.ceiling ?? DEFAULT_CEILING, "ceiling", INT16_MAX);
33
+
34
+ if (pcm.length === 0) return pcm;
35
+
36
+ const frameRms = rms(pcm);
37
+ if (frameRms > 0) {
38
+ state.runningRms =
39
+ state.runningRms > 0
40
+ ? state.runningRms + (frameRms - state.runningRms) * rmsSmoothing
41
+ : frameRms;
42
+ const desiredGain = targetRms / state.runningRms;
43
+ const gainDelta = desiredGain - state.gain;
44
+ state.gain += Math.max(-maxGainStep, Math.min(maxGainStep, gainDelta));
45
+ }
46
+
47
+ const output = new Int16Array(pcm.length);
48
+ for (let i = 0; i < pcm.length; i += 1) {
49
+ output[i] = Math.round(softLimit(pcm[i]! * state.gain, ceiling));
50
+ }
51
+ return output;
52
+ }
53
+
54
+ function rms(pcm: Int16Array): number {
55
+ let sumSquares = 0;
56
+ for (const sample of pcm) sumSquares += sample * sample;
57
+ return Math.sqrt(sumSquares / pcm.length);
58
+ }
59
+
60
+ function softLimit(value: number, ceiling: number): number {
61
+ const magnitude = Math.abs(value);
62
+ const knee = ceiling * 0.75;
63
+ if (magnitude <= knee) return value;
64
+ const range = ceiling - knee;
65
+ const limited = knee + range * (1 - Math.exp(-(magnitude - knee) / range));
66
+ return Math.sign(value) * limited;
67
+ }
68
+
69
+ function positiveFinite(value: number, name: string): number {
70
+ if (!Number.isFinite(value) || value <= 0) {
71
+ throw new RangeError(`LoudnessConfig.${name} must be a positive finite number`);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function positiveBounded(value: number, name: string, max: number): number {
77
+ const parsed = positiveFinite(value, name);
78
+ if (parsed > max) throw new RangeError(`LoudnessConfig.${name} must be at most ${String(max)}`);
79
+ return parsed;
80
+ }
81
+
82
+ function bounded(value: number, name: string, min: number, max: number): number {
83
+ if (!Number.isFinite(value) || value <= min || value > max) {
84
+ throw new RangeError(`LoudnessConfig.${name} must be greater than ${String(min)} and at most ${String(max)}`);
85
+ }
86
+ return value;
87
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export interface ConfidenceToWaitConfig {
4
+ /** Wait at confidence = 1.0 (high EOT certainty). Default: 150 ms. */
5
+ readonly minWaitMs?: number;
6
+ /** Wait at confidence = 0.0 (low EOT certainty). Default: 2000 ms. */
7
+ readonly maxWaitMs?: number;
8
+ }
9
+
10
+ const DEFAULT_MIN_WAIT_MS = 150;
11
+ const DEFAULT_MAX_WAIT_MS = 2000;
12
+
13
+ /**
14
+ * Maps an end-of-turn confidence score to a bounded finalize wait (LiveKit-style).
15
+ * Higher confidence → shorter wait. Monotonic, clamped, deterministic.
16
+ */
17
+ export function confidenceToWaitMs(
18
+ confidence: number,
19
+ config: ConfidenceToWaitConfig = {},
20
+ ): number {
21
+ const minWaitMs = config.minWaitMs ?? DEFAULT_MIN_WAIT_MS;
22
+ const maxWaitMs = config.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
23
+ if (maxWaitMs < minWaitMs) {
24
+ throw new Error(`maxWaitMs (${String(maxWaitMs)}) must be >= minWaitMs (${String(minWaitMs)})`);
25
+ }
26
+
27
+ const clamped = Math.min(1, Math.max(0, confidence));
28
+ const span = maxWaitMs - minWaitMs;
29
+ return Math.round(maxWaitMs - clamped * span);
30
+ }
@@ -118,6 +118,7 @@ export class IdleTimeoutManager {
118
118
  */
119
119
  extend(ms: number): void {
120
120
  this.clearTimer();
121
+ if (this.config.durationMs <= 0) return;
121
122
  this.timerScheduled = true;
122
123
  this.scheduler.schedule("voice.idle_timeout", this.config.durationMs + ms, () => {
123
124
  this.timerScheduled = false;
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Incremental-Unit identity + state (IU substrate, RFC incremental-unit-substrate)
4
+
5
+ export type IuState = "hypothesized" | "committed" | "revoked";
6
+
7
+ export interface IncrementalUnitId {
8
+ readonly contextId: string;
9
+ readonly iuId: string;
10
+ readonly epoch: number;
11
+ }
12
+
13
+ export interface IncrementalUnit {
14
+ readonly id: IncrementalUnitId;
15
+ readonly kind: "user_turn" | "assistant_response" | "tts_segment";
16
+ state: IuState;
17
+ /** For assistant/tts IUs: the committed character/ms prefix (heard). */
18
+ committedPrefix?: { chars?: number; ms?: number };
19
+ }