@kuralle-syrinx/core 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +22 -0
- package/src/audio/audio.test.ts +285 -0
- package/src/audio/index.ts +5 -0
- package/src/audio/mulaw.ts +42 -0
- package/src/audio/pcm.ts +43 -0
- package/src/audio/resample.ts +177 -0
- package/src/audio-envelope.test.ts +167 -0
- package/src/audio-envelope.ts +143 -0
- package/src/conversation-event.ts +62 -0
- package/src/error-handler.test.ts +56 -0
- package/src/error-handler.ts +149 -0
- package/src/idle-timeout.ts +210 -0
- package/src/index.ts +215 -0
- package/src/init-chain.ts +137 -0
- package/src/init-stage-order.ts +79 -0
- package/src/latency-filler-fixtures.ts +16 -0
- package/src/latency-filler.test.ts +62 -0
- package/src/latency-filler.ts +125 -0
- package/src/mode-switcher.ts +110 -0
- package/src/observability-observer.test.ts +245 -0
- package/src/observability-observer.ts +195 -0
- package/src/observability.test.ts +85 -0
- package/src/observability.ts +93 -0
- package/src/packet-factories.test.ts +34 -0
- package/src/packet-factories.ts +243 -0
- package/src/packets.ts +553 -0
- package/src/pipeline-bus.g10.test.ts +145 -0
- package/src/pipeline-bus.test.ts +197 -0
- package/src/pipeline-bus.ts +369 -0
- package/src/plugin-contract.ts +79 -0
- package/src/primary-speaker-fixtures.ts +45 -0
- package/src/primary-speaker-gate.test.ts +150 -0
- package/src/primary-speaker-gate.ts +186 -0
- package/src/provider-fallback.test.ts +87 -0
- package/src/provider-fallback.ts +88 -0
- package/src/reasoner.test.ts +69 -0
- package/src/reasoner.ts +54 -0
- package/src/retry.test.ts +83 -0
- package/src/retry.ts +106 -0
- package/src/scheduler.ts +28 -0
- package/src/tts-playout-clock.test.ts +125 -0
- package/src/tts-playout-clock.ts +116 -0
- package/src/turn-arbiter.characterization.test.ts +477 -0
- package/src/turn-arbiter.test.ts +567 -0
- package/src/turn-arbiter.ts +283 -0
- package/src/voice-agent-session-util.ts +240 -0
- package/src/voice-agent-session.test.ts +2487 -0
- package/src/voice-agent-session.ts +1175 -0
- package/src/voice-text.test.ts +81 -0
- package/src/voice-text.ts +102 -0
- package/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @kuralle-syrinx/core
|
|
2
|
+
|
|
3
|
+
The Syrinx Kernel v2 — the framework-agnostic core of the voice engine: the `PipelineBus`, the `VoicePlugin` contract, the packet vocabulary (`stt.*` / `llm.*` / `tts.*` / `reasoning.*` …), turn-taking, observability, and the latency-hiding/barge-in primitives. STT/TTS/bridge/transport packages plug into this bus.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## The Reasoner seam
|
|
8
|
+
|
|
9
|
+
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`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
export interface Reasoner {
|
|
13
|
+
// Drive one turn. The returned async-iterable IS the response.
|
|
14
|
+
// LATENCY INVARIANT: yield every part the instant the backend produces it —
|
|
15
|
+
// no buffering, no awaiting to completion, no batching.
|
|
16
|
+
stream(turn: ReasonerTurn): AsyncIterable<ReasoningPart>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReasonerTurn {
|
|
20
|
+
readonly userText: string; // finalized transcript (from eos.turn_complete)
|
|
21
|
+
readonly messages: readonly ReasonerMessage[]; // prior context — the BRIDGE owns history
|
|
22
|
+
readonly signal: AbortSignal; // barge-in / supersede
|
|
23
|
+
readonly resume?: { readonly runId: string; readonly data: unknown }; // suspend/resume only
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ReasoningPart =
|
|
27
|
+
| { type: "text-delta"; text: string }
|
|
28
|
+
| { type: "tool-call"; toolId: string; toolName: string; args: Record<string, unknown> }
|
|
29
|
+
| { type: "tool-result"; toolId: string; toolName: string; result: string }
|
|
30
|
+
| { type: "suspended"; runId: string; toolId?: string; prompt?: string; payload: unknown } // terminal (HITL)
|
|
31
|
+
| { type: "error"; cause: Error; recoverable: boolean } // terminal (→ retry)
|
|
32
|
+
| { type: "finish"; reason: "stop" | "tool" | "length"; text: string };
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Frameworks become a `Reasoner` via **named adapters** (never auto-wrap / duck-typing):
|
|
36
|
+
- AI SDK → `fromAiSdkAgent` / `fromStreamText` / `fromStreamFactory` (`@kuralle-syrinx/aisdk`)
|
|
37
|
+
- Mastra → `fromMastraAgent` (`@kuralle-syrinx/mastra`)
|
|
38
|
+
|
|
39
|
+
The bridge (a `VoicePlugin`) consumes the seam and pushes `llm.*` packets; the seam adds at most one microtask + a synchronous object remap per part. History, spoken-prefix barge-in, retry, and turn-superseding all stay in the bridge — the backend is stateless-per-turn.
|
|
40
|
+
|
|
41
|
+
See `docs/rfc-reasoner-bridge.md` for the full design.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/core",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./audio": "./src/audio/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "vitest run"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.7.0",
|
|
20
|
+
"vitest": "^2.1.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -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,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export { pcm16BytesToSamples, pcm16SamplesToBytes, bigEndianPcm16BytesToSamples, pcm16SamplesToBigEndianBytes } from "./pcm.js";
|
|
4
|
+
export { decodeMuLawToPcm16, encodePcm16ToMuLaw } from "./mulaw.js";
|
|
5
|
+
export { resamplePcm16, resamplePcm16Streaming, StreamingPcm16Resampler } from "./resample.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
const MULAW_BIAS = 0x84;
|
|
4
|
+
const MULAW_CLIP = 32635;
|
|
5
|
+
|
|
6
|
+
export function decodeMuLawToPcm16(input: Uint8Array): Int16Array {
|
|
7
|
+
const output = new Int16Array(input.byteLength);
|
|
8
|
+
for (let i = 0; i < input.byteLength; i += 1) {
|
|
9
|
+
const ulaw = (~input[i]!) & 0xff;
|
|
10
|
+
const sign = ulaw & 0x80;
|
|
11
|
+
const exponent = (ulaw >> 4) & 0x07;
|
|
12
|
+
const mantissa = ulaw & 0x0f;
|
|
13
|
+
let sample = ((mantissa << 3) + MULAW_BIAS) << exponent;
|
|
14
|
+
sample -= MULAW_BIAS;
|
|
15
|
+
output[i] = sign ? -sample : sample;
|
|
16
|
+
}
|
|
17
|
+
return output;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function encodePcm16ToMuLaw(input: Int16Array): Uint8Array {
|
|
21
|
+
const output = new Uint8Array(input.length);
|
|
22
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
23
|
+
output[i] = encodeSample(input[i]!);
|
|
24
|
+
}
|
|
25
|
+
return output;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function encodeSample(sample: number): number {
|
|
29
|
+
let sign = 0;
|
|
30
|
+
let magnitude = sample;
|
|
31
|
+
if (magnitude < 0) {
|
|
32
|
+
sign = 0x80;
|
|
33
|
+
magnitude = -magnitude;
|
|
34
|
+
}
|
|
35
|
+
magnitude = Math.min(magnitude, MULAW_CLIP) + MULAW_BIAS;
|
|
36
|
+
let exponent = 7;
|
|
37
|
+
for (let mask = 0x4000; (magnitude & mask) === 0 && exponent > 0; mask >>= 1) {
|
|
38
|
+
exponent -= 1;
|
|
39
|
+
}
|
|
40
|
+
const mantissa = (magnitude >> (exponent + 3)) & 0x0f;
|
|
41
|
+
return (~(sign | (exponent << 4) | mantissa)) & 0xff;
|
|
42
|
+
}
|
package/src/audio/pcm.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export function pcm16BytesToSamples(audio: Uint8Array): Int16Array {
|
|
4
|
+
if (audio.byteLength % 2 !== 0) {
|
|
5
|
+
throw new Error("PCM16 audio payload must contain an even number of bytes");
|
|
6
|
+
}
|
|
7
|
+
const view = new DataView(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
8
|
+
const samples = new Int16Array(audio.byteLength / 2);
|
|
9
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
10
|
+
samples[i] = view.getInt16(i * 2, true);
|
|
11
|
+
}
|
|
12
|
+
return samples;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function pcm16SamplesToBytes(samples: Int16Array): Uint8Array {
|
|
16
|
+
const bytes = new Uint8Array(samples.byteLength);
|
|
17
|
+
const view = new DataView(bytes.buffer);
|
|
18
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
19
|
+
view.setInt16(i * 2, samples[i]!, true);
|
|
20
|
+
}
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bigEndianPcm16BytesToSamples(audio: Uint8Array): Int16Array {
|
|
25
|
+
if (audio.byteLength % 2 !== 0) {
|
|
26
|
+
throw new Error("L16 audio payload must contain an even number of bytes");
|
|
27
|
+
}
|
|
28
|
+
const view = new DataView(audio.buffer, audio.byteOffset, audio.byteLength);
|
|
29
|
+
const samples = new Int16Array(audio.byteLength / 2);
|
|
30
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
31
|
+
samples[i] = view.getInt16(i * 2, false);
|
|
32
|
+
}
|
|
33
|
+
return samples;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function pcm16SamplesToBigEndianBytes(samples: Int16Array): Uint8Array {
|
|
37
|
+
const bytes = new Uint8Array(samples.byteLength);
|
|
38
|
+
const view = new DataView(bytes.buffer);
|
|
39
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
40
|
+
view.setInt16(i * 2, samples[i]!, false);
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
// Windowed-sinc FIR low-pass filter for anti-alias downsampling.
|
|
4
|
+
// Hann window, 127 taps — gives ~44 dB min stopband attenuation, sufficient for
|
|
5
|
+
// the ≥40 dB alias-suppression requirement (F3). A polyphase structure would be
|
|
6
|
+
// faster but this direct-form is simpler to audit and correct for our frame sizes.
|
|
7
|
+
const FIR_TAPS = 127;
|
|
8
|
+
|
|
9
|
+
function buildLowPassFir(cutoffNormalized: number): Float64Array {
|
|
10
|
+
// cutoffNormalized: fraction of the source sample rate (0 = DC, 0.5 = Nyquist).
|
|
11
|
+
const M = FIR_TAPS - 1; // filter order (126 for 127 taps)
|
|
12
|
+
const h = new Float64Array(FIR_TAPS);
|
|
13
|
+
let sum = 0;
|
|
14
|
+
for (let n = 0; n < FIR_TAPS; n += 1) {
|
|
15
|
+
const delay = n - M / 2; // centered delay: -63..0..63
|
|
16
|
+
const sinc =
|
|
17
|
+
delay === 0
|
|
18
|
+
? 2 * cutoffNormalized
|
|
19
|
+
: Math.sin(2 * Math.PI * cutoffNormalized * delay) / (Math.PI * delay);
|
|
20
|
+
const hann = 0.5 - 0.5 * Math.cos((2 * Math.PI * n) / M);
|
|
21
|
+
h[n] = sinc * hann;
|
|
22
|
+
sum += h[n]!;
|
|
23
|
+
}
|
|
24
|
+
// Normalize for unity DC gain.
|
|
25
|
+
for (let n = 0; n < FIR_TAPS; n += 1) {
|
|
26
|
+
h[n]! /= sum;
|
|
27
|
+
}
|
|
28
|
+
return h;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The FIR depends only on the (source,target) rate pair via its cutoff, which is
|
|
32
|
+
// constant per connection. resamplePcm16 runs per audio chunk in the hot path, so
|
|
33
|
+
// cache the kernel instead of rebuilding 127 sinc·Hann taps on every call. Bounded:
|
|
34
|
+
// real deployments use a handful of distinct rate pairs (24k/16k→8k, 48k→16k, …).
|
|
35
|
+
const firCache = new Map<number, Float64Array>();
|
|
36
|
+
|
|
37
|
+
function getLowPassFir(cutoffNormalized: number): Float64Array {
|
|
38
|
+
const key = Math.round(cutoffNormalized * 1e6);
|
|
39
|
+
let fir = firCache.get(key);
|
|
40
|
+
if (fir === undefined) {
|
|
41
|
+
fir = buildLowPassFir(cutoffNormalized);
|
|
42
|
+
firCache.set(key, fir);
|
|
43
|
+
}
|
|
44
|
+
return fir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Evaluate the symmetric FIR centered at each decimated output position.
|
|
48
|
+
// Using the symmetric (zero-delay) formula avoids a group-delay shift at the
|
|
49
|
+
// cost of needing halfTaps future samples — which are available because we
|
|
50
|
+
// process a complete chunk at once. Edge samples (first / last halfTaps/ratio
|
|
51
|
+
// output samples) use only the available taps; their amplitude tapers due to
|
|
52
|
+
// the missing context, which is the accepted trade-off for stateless chunk
|
|
53
|
+
// processing.
|
|
54
|
+
function firDecimate(input: Int16Array, outputLength: number, ratio: number, fir: Float64Array): Int16Array {
|
|
55
|
+
const M = fir.length;
|
|
56
|
+
const halfTaps = Math.floor(M / 2); // 63 for 127-tap filter
|
|
57
|
+
const output = new Int16Array(outputLength);
|
|
58
|
+
for (let m = 0; m < outputLength; m += 1) {
|
|
59
|
+
const n0 = Math.round(m * ratio); // nearest source position for output m
|
|
60
|
+
let acc = 0;
|
|
61
|
+
// Centered: h[0] multiplies x[n0+halfTaps], h[halfTaps] multiplies x[n0],
|
|
62
|
+
// h[M-1] multiplies x[n0-halfTaps].
|
|
63
|
+
for (let k = 0; k < M; k += 1) {
|
|
64
|
+
const srcIdx = n0 - k + halfTaps;
|
|
65
|
+
if (srcIdx >= 0 && srcIdx < input.length) {
|
|
66
|
+
acc += fir[k]! * input[srcIdx]!;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Clamp to Int16 range; FIR sum can slightly exceed due to rounding.
|
|
70
|
+
output[m] = Math.max(-32768, Math.min(32767, Math.round(acc)));
|
|
71
|
+
}
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function linearInterpolate(input: Int16Array, outputLength: number, ratio: number): Int16Array {
|
|
76
|
+
const output = new Int16Array(outputLength);
|
|
77
|
+
for (let i = 0; i < outputLength; i += 1) {
|
|
78
|
+
const pos = i * ratio;
|
|
79
|
+
const lo = Math.floor(pos);
|
|
80
|
+
const hi = Math.min(input.length - 1, lo + 1);
|
|
81
|
+
const frac = pos - lo;
|
|
82
|
+
output[i] = Math.round(input[lo]! * (1 - frac) + input[hi]! * frac);
|
|
83
|
+
}
|
|
84
|
+
return output;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function outputLengthFor(inputLength: number, sourceSampleRateHz: number, targetSampleRateHz: number): number {
|
|
88
|
+
return Math.max(1, Math.round((inputLength * targetSampleRateHz) / sourceSampleRateHz));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resamplePcm16(
|
|
92
|
+
input: Int16Array,
|
|
93
|
+
sourceSampleRateHz: number,
|
|
94
|
+
targetSampleRateHz: number,
|
|
95
|
+
): Int16Array {
|
|
96
|
+
if (input.length === 0) return new Int16Array(0);
|
|
97
|
+
if (sourceSampleRateHz === targetSampleRateHz) return input;
|
|
98
|
+
|
|
99
|
+
const outputLength = outputLengthFor(input.length, sourceSampleRateHz, targetSampleRateHz);
|
|
100
|
+
const ratio = sourceSampleRateHz / targetSampleRateHz;
|
|
101
|
+
|
|
102
|
+
if (ratio <= 1) {
|
|
103
|
+
return linearInterpolate(input, outputLength, ratio);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cutoffNormalized = (0.45 * targetSampleRateHz) / sourceSampleRateHz;
|
|
107
|
+
const fir = getLowPassFir(cutoffNormalized);
|
|
108
|
+
return firDecimate(input, outputLength, ratio, fir);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class StreamingPcm16Resampler {
|
|
112
|
+
private history = new Int16Array(0);
|
|
113
|
+
private readonly ratio: number;
|
|
114
|
+
private readonly fir: Float64Array | null;
|
|
115
|
+
private readonly sourceSampleRateHz: number;
|
|
116
|
+
private readonly targetSampleRateHz: number;
|
|
117
|
+
|
|
118
|
+
constructor(sourceSampleRateHz: number, targetSampleRateHz: number) {
|
|
119
|
+
this.sourceSampleRateHz = sourceSampleRateHz;
|
|
120
|
+
this.targetSampleRateHz = targetSampleRateHz;
|
|
121
|
+
this.ratio = sourceSampleRateHz / targetSampleRateHz;
|
|
122
|
+
if (this.ratio > 1) {
|
|
123
|
+
const cutoffNormalized = (0.45 * targetSampleRateHz) / sourceSampleRateHz;
|
|
124
|
+
this.fir = getLowPassFir(cutoffNormalized);
|
|
125
|
+
} else {
|
|
126
|
+
this.fir = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
process(input: Int16Array): Int16Array {
|
|
131
|
+
if (input.length === 0) return new Int16Array(0);
|
|
132
|
+
if (this.sourceSampleRateHz === this.targetSampleRateHz) return input;
|
|
133
|
+
|
|
134
|
+
if (this.ratio <= 1 || this.fir === null) {
|
|
135
|
+
const outputLength = outputLengthFor(input.length, this.sourceSampleRateHz, this.targetSampleRateHz);
|
|
136
|
+
return linearInterpolate(input, outputLength, this.ratio);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const historyLength = this.history.length;
|
|
140
|
+
const combined = new Int16Array(historyLength + input.length);
|
|
141
|
+
combined.set(this.history, 0);
|
|
142
|
+
combined.set(input, historyLength);
|
|
143
|
+
|
|
144
|
+
const fullOutputLength = outputLengthFor(combined.length, this.sourceSampleRateHz, this.targetSampleRateHz);
|
|
145
|
+
const fullOutput = firDecimate(combined, fullOutputLength, this.ratio, this.fir);
|
|
146
|
+
let firstNewOutput = 0;
|
|
147
|
+
if (historyLength > 0) {
|
|
148
|
+
while (firstNewOutput < fullOutput.length) {
|
|
149
|
+
const centerInputIndex = Math.round(firstNewOutput * this.ratio);
|
|
150
|
+
if (centerInputIndex >= historyLength) break;
|
|
151
|
+
firstNewOutput += 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const output = fullOutput.subarray(firstNewOutput);
|
|
155
|
+
|
|
156
|
+
const keepHistory = Math.min(FIR_TAPS - 1, combined.length);
|
|
157
|
+
this.history = combined.subarray(combined.length - keepHistory);
|
|
158
|
+
return output;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resamplePcm16Streaming(
|
|
163
|
+
resamplers: Map<string, StreamingPcm16Resampler>,
|
|
164
|
+
input: Int16Array,
|
|
165
|
+
sourceSampleRateHz: number,
|
|
166
|
+
targetSampleRateHz: number,
|
|
167
|
+
): Int16Array {
|
|
168
|
+
if (input.length === 0) return new Int16Array(0);
|
|
169
|
+
if (sourceSampleRateHz === targetSampleRateHz) return input;
|
|
170
|
+
const key = `${String(sourceSampleRateHz)}->${String(targetSampleRateHz)}`;
|
|
171
|
+
let resampler = resamplers.get(key);
|
|
172
|
+
if (!resampler) {
|
|
173
|
+
resampler = new StreamingPcm16Resampler(sourceSampleRateHz, targetSampleRateHz);
|
|
174
|
+
resamplers.set(key, resampler);
|
|
175
|
+
}
|
|
176
|
+
return resampler.process(input);
|
|
177
|
+
}
|