@kuralle-syrinx/server-websocket 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.
package/package.json CHANGED
@@ -1,28 +1,48 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/server-websocket",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Node WebSocket voice host for Syrinx — browser transport plus Twilio/Telnyx/SmartPBX telephony adapters, background audio, admission control",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "telephony",
12
+ "twilio",
13
+ "telnyx"
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/server-websocket"
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": {
10
29
  ".": "./src/index.ts",
11
30
  "./edge": "./src/edge.ts",
12
31
  "./edge-twilio": "./src/edge-twilio.ts",
32
+ "./edge-telnyx": "./src/edge-telnyx.ts",
13
33
  "./session-store": "./src/session-store.ts"
14
34
  },
15
35
  "dependencies": {
16
36
  "@evan/opus": "1.0.3",
17
- "ws": "^8.18.0",
18
- "@kuralle-syrinx/core": "4.1.0",
19
- "@kuralle-syrinx/ws": "4.1.0"
37
+ "ws": "^8.21.0",
38
+ "@kuralle-syrinx/core": "4.3.0",
39
+ "@kuralle-syrinx/ws": "4.3.0"
20
40
  },
21
41
  "devDependencies": {
22
42
  "@types/node": "^22.0.0",
23
43
  "@types/ws": "^8.5.0",
24
44
  "typescript": "^5.7.0",
25
- "vitest": "^2.1.0"
45
+ "vitest": "^3.2.6"
26
46
  },
27
47
  "scripts": {
28
48
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Deterministic placeholder backchannel cue PCM (16 kHz mono). Real voice-matched
4
+ // assets + locale table are a follow-up — these shaped tones prove the byte-config path.
5
+
6
+ import { synthesizeTonePcm16 } from "@kuralle-syrinx/core";
7
+ import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
8
+ import type { BackgroundAudioSource } from "./background-audio.js";
9
+
10
+ const CUE_SAMPLE_RATE_HZ = 16_000;
11
+
12
+ function placeholderCue(frequencyHz: number, durationMs: number, gain: number): BackgroundAudioSource {
13
+ const bytes = synthesizeTonePcm16({
14
+ frequencyHz,
15
+ durationMs,
16
+ sampleRateHz: CUE_SAMPLE_RATE_HZ,
17
+ amplitude: 0.35,
18
+ });
19
+ return { pcm: pcm16BytesToSamples(bytes), sampleRateHz: CUE_SAMPLE_RATE_HZ, gain };
20
+ }
21
+
22
+ /** Placeholder cue map — pass as `backgroundAudio.cues` at session init. */
23
+ export function buildPlaceholderBackchannelCues(): Readonly<Record<string, BackgroundAudioSource>> {
24
+ return {
25
+ mm_hmm: placeholderCue(440, 400, 0.65),
26
+ yeah: placeholderCue(520, 350, 0.6),
27
+ got_it: placeholderCue(380, 450, 0.6),
28
+ };
29
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Background audio for the outbound path: a looped ambient bed (which doubles
4
4
  // as telephony comfort noise between turns), an optional "thinking" loop keyed
5
- // off the G3 tool-call cues, and ducking under assistant speech.
5
+ // off the G3 tool-call cues, one-shot backchannel cues, and ducking under speech.
6
6
  //
7
7
  // Syrinx wires (Twilio media stream, browser jitter buffer) are single ORDERED
8
8
  // streams — unlike LiveKit's WebRTC rooms there is no client-side track mixing
@@ -15,7 +15,8 @@
15
15
  // Prior art: Pipecat SoundfileMixer (transport-level mix + volume + loop),
16
16
  // LiveKit BackgroundAudioPlayer (ambient/thinking split, state-keyed thinking).
17
17
 
18
- import type { VoiceAgentSession } from "@kuralle-syrinx/core";
18
+ import type { InteractionBackchannelPacket, VoiceAgentSession } from "@kuralle-syrinx/core";
19
+ import { Route } from "@kuralle-syrinx/core";
19
20
  import { pcm16BytesToSamples, pcm16SamplesToBytes, resamplePcm16 } from "@kuralle-syrinx/core/audio";
20
21
 
21
22
  export interface BackgroundAudioSource {
@@ -31,6 +32,8 @@ export interface BackgroundAudioConfig {
31
32
  readonly ambient?: BackgroundAudioSource;
32
33
  /** Looped while `setThinking(true)` — the audible face of a pending tool call. */
33
34
  readonly thinking?: BackgroundAudioSource;
35
+ /** Pre-cached one-shot backchannel cues keyed by cue id (IP-C3). */
36
+ readonly cues?: Readonly<Record<string, BackgroundAudioSource>>;
34
37
  /**
35
38
  * Extra multiplier on background sources while assistant speech is being
36
39
  * mixed, so the bed never muddies the voice. 1 disables ducking. @default 0.5
@@ -48,8 +51,10 @@ export interface BackgroundAudioConfig {
48
51
 
49
52
  const DEFAULT_AMBIENT_GAIN = 0.25;
50
53
  const DEFAULT_THINKING_GAIN = 0.4;
54
+ const DEFAULT_CUE_GAIN = 0.65;
51
55
  const DEFAULT_DUCK = 0.5;
52
56
  const DEFAULT_FADE_MS = 250;
57
+ const CUE_THINKING_DUCK = 0.25;
53
58
 
54
59
  interface LoopedSource {
55
60
  readonly original: BackgroundAudioSource;
@@ -61,6 +66,14 @@ interface LoopedSource {
61
66
  positionRateHz: number;
62
67
  }
63
68
 
69
+ interface OneShotSource {
70
+ readonly original: BackgroundAudioSource;
71
+ readonly gain: number;
72
+ readonly byRate: Map<number, Int16Array>;
73
+ position: number;
74
+ positionRateHz: number;
75
+ }
76
+
64
77
  function loopedSource(source: BackgroundAudioSource, defaultGain: number): LoopedSource {
65
78
  return {
66
79
  original: source,
@@ -71,14 +84,26 @@ function loopedSource(source: BackgroundAudioSource, defaultGain: number): Loope
71
84
  };
72
85
  }
73
86
 
74
- function samplesAtRate(source: LoopedSource, rateHz: number): Int16Array {
87
+ function oneShotSource(source: BackgroundAudioSource, defaultGain: number): OneShotSource {
88
+ return {
89
+ original: source,
90
+ gain: source.gain ?? defaultGain,
91
+ byRate: new Map(),
92
+ position: 0,
93
+ positionRateHz: source.sampleRateHz,
94
+ };
95
+ }
96
+
97
+ function samplesAtRate(
98
+ source: LoopedSource | OneShotSource,
99
+ rateHz: number,
100
+ ): Int16Array {
75
101
  let samples = source.byRate.get(rateHz);
76
102
  if (!samples) {
77
103
  samples = resamplePcm16(source.original.pcm, source.original.sampleRateHz, rateHz);
78
104
  source.byRate.set(rateHz, samples);
79
105
  }
80
106
  if (source.positionRateHz !== rateHz) {
81
- // Wire rate changed mid-connection (rare): carry the position over proportionally.
82
107
  source.position = Math.round((source.position * rateHz) / source.positionRateHz);
83
108
  source.positionRateHz = rateHz;
84
109
  }
@@ -108,6 +133,22 @@ function addLooped(
108
133
  source.position = pos;
109
134
  }
110
135
 
136
+ function addOneShot(target: Float64Array, source: OneShotSource, rateHz: number): boolean {
137
+ const samples = samplesAtRate(source, rateHz);
138
+ if (samples.length === 0 || source.gain === 0) return true;
139
+ let pos = source.position;
140
+ for (let i = 0; i < target.length; i += 1) {
141
+ if (pos >= samples.length) {
142
+ source.position = samples.length;
143
+ return true;
144
+ }
145
+ target[i]! += samples[pos]! * source.gain;
146
+ pos += 1;
147
+ }
148
+ source.position = pos;
149
+ return pos >= samples.length;
150
+ }
151
+
111
152
  /** Equal-power fade-in gain for sample `n` of a `total`-sample ramp (1 past the ramp). */
112
153
  function fadeInGain(n: number, total: number): number {
113
154
  if (total <= 0 || n >= total) return 1;
@@ -132,59 +173,65 @@ function clipToPcm16Bytes(mix: Float64Array): Uint8Array {
132
173
  export class BackgroundAudioMixer {
133
174
  private readonly ambient: LoopedSource | null;
134
175
  private readonly thinking: LoopedSource | null;
176
+ private readonly cueCatalog: ReadonlyMap<string, BackgroundAudioSource>;
135
177
  private readonly duck: number;
136
178
  private readonly fadeMs: number;
137
- /**
138
- * Thinking episode lifecycle: "stopping" keeps the loop audible for one
139
- * fade-out ramp after `setThinking(false)` instead of hard-cutting it.
140
- */
141
179
  private thinkingState: "off" | "on" | "stopping" = "off";
142
- /** Samples rendered since the current thinking episode started (fade-in). */
143
180
  private thinkingFadeInPos = 0;
144
- /** Samples rendered since the stop was requested (fade-out). */
145
181
  private thinkingFadeOutPos = 0;
146
- /** Samples of ambient rendered so far (one-time fade-in at bed start). */
147
182
  private ambientFadeInPos = 0;
148
- /** Realtime estimate of when already-mixed speech finishes playing out. */
149
183
  private speakingUntilMs = 0;
184
+ private pendingCue: OneShotSource | null = null;
150
185
 
151
186
  constructor(config: BackgroundAudioConfig) {
152
187
  this.ambient = config.ambient ? loopedSource(config.ambient, DEFAULT_AMBIENT_GAIN) : null;
153
188
  this.thinking = config.thinking ? loopedSource(config.thinking, DEFAULT_THINKING_GAIN) : null;
189
+ this.cueCatalog = new Map(Object.entries(config.cues ?? {}));
154
190
  this.duck = config.duckWhileSpeaking ?? DEFAULT_DUCK;
155
191
  this.fadeMs = config.fadeMs ?? DEFAULT_FADE_MS;
156
192
  }
157
193
 
158
194
  get hasSources(): boolean {
159
- return this.ambient !== null || this.thinking !== null;
195
+ return this.ambient !== null || this.thinking !== null || this.cueCatalog.size > 0;
196
+ }
197
+
198
+ hasCue(cueId: string): boolean {
199
+ return this.cueCatalog.has(cueId);
160
200
  }
161
201
 
162
202
  isSpeaking(nowMs = Date.now()): boolean {
163
203
  return nowMs < this.speakingUntilMs;
164
204
  }
165
205
 
206
+ /** Queue a one-shot backchannel cue by id. Returns false when the cue is unknown. */
207
+ queueCue(cueId: string): boolean {
208
+ const source = this.cueCatalog.get(cueId);
209
+ if (!source) return false;
210
+ this.pendingCue = oneShotSource(source, DEFAULT_CUE_GAIN);
211
+ return true;
212
+ }
213
+
166
214
  /** G3 cue wiring: started/delayed → true, complete/failed → false. */
167
215
  setThinking(on: boolean): void {
168
216
  if (on) {
169
217
  if (this.thinkingState === "on") return;
170
218
  this.thinkingState = "on";
171
219
  this.thinkingFadeInPos = 0;
172
- if (this.thinking) this.thinking.position = 0; // each episode starts from the top
220
+ if (this.thinking) this.thinking.position = 0;
173
221
  return;
174
222
  }
175
223
  if (this.thinkingState !== "on") return;
176
- // Audible for one fade-out ramp, then off (immediately off when fades are disabled).
177
224
  this.thinkingState = this.fadeMs > 0 ? "stopping" : "off";
178
225
  this.thinkingFadeOutPos = 0;
179
226
  }
180
227
 
181
- /**
182
- * Add the bed sources into `mixBuf` at `bedScale`, applying the fade
183
- * envelopes and advancing their positions/counters. Shared by mix (ducked)
184
- * and idleFrame (full gain) so episodes stay continuous across both.
185
- */
228
+ private thinkingScale(bedScale: number): number {
229
+ return this.pendingCue ? bedScale * CUE_THINKING_DUCK : bedScale;
230
+ }
231
+
186
232
  private addBed(mixBuf: Float64Array, sampleRateHz: number, bedScale: number): void {
187
233
  const fadeSamples = Math.round((this.fadeMs / 1000) * sampleRateHz);
234
+ const thinkingScale = this.thinkingScale(bedScale);
188
235
 
189
236
  if (this.ambient) {
190
237
  const startPos = this.ambientFadeInPos;
@@ -201,7 +248,7 @@ export class BackgroundAudioMixer {
201
248
  const envelope = startPos >= fadeSamples
202
249
  ? undefined
203
250
  : (i: number) => fadeInGain(startPos + i, fadeSamples);
204
- addLooped(mixBuf, this.thinking, sampleRateHz, this.thinking.gain * bedScale, envelope);
251
+ addLooped(mixBuf, this.thinking, sampleRateHz, this.thinking.gain * thinkingScale, envelope);
205
252
  this.thinkingFadeInPos = Math.min(fadeSamples, startPos + mixBuf.length);
206
253
  } else {
207
254
  const startPos = this.thinkingFadeOutPos;
@@ -209,7 +256,7 @@ export class BackgroundAudioMixer {
209
256
  mixBuf,
210
257
  this.thinking,
211
258
  sampleRateHz,
212
- this.thinking.gain * bedScale,
259
+ this.thinking.gain * thinkingScale,
213
260
  (i: number) => fadeOutGain(startPos + i, fadeSamples),
214
261
  );
215
262
  this.thinkingFadeOutPos = startPos + mixBuf.length;
@@ -218,46 +265,58 @@ export class BackgroundAudioMixer {
218
265
  }
219
266
  }
220
267
 
221
- /**
222
- * Mix the background bed (ducked) into an assistant speech chunk and advance
223
- * the speaking-until estimate by the chunk's realtime duration. Returns a new
224
- * buffer; the input is never mutated.
225
- */
268
+ private addPendingCue(mixBuf: Float64Array, sampleRateHz: number): void {
269
+ if (!this.pendingCue) return;
270
+ const finished = addOneShot(mixBuf, this.pendingCue, sampleRateHz);
271
+ if (finished) this.pendingCue = null;
272
+ }
273
+
226
274
  mix(chunk: Uint8Array, sampleRateHz: number, nowMs = Date.now()): Uint8Array {
227
275
  const speech = pcm16BytesToSamples(chunk);
228
276
  const durationMs = (speech.length / sampleRateHz) * 1000;
229
277
  this.speakingUntilMs = Math.max(this.speakingUntilMs, nowMs) + durationMs;
230
278
 
231
- if (!this.hasSources) return chunk;
279
+ if (!this.hasSources && !this.pendingCue) return chunk;
232
280
  const mixBuf = new Float64Array(speech.length);
233
281
  for (let i = 0; i < speech.length; i += 1) mixBuf[i] = speech[i]!;
234
282
  this.addBed(mixBuf, sampleRateHz, this.duck);
235
283
  return clipToPcm16Bytes(mixBuf);
236
284
  }
237
285
 
238
- /**
239
- * Bed-only frame for the gaps between turns (telephony comfort noise), or
240
- * null while speech is still playing out / there is nothing to play.
241
- */
242
286
  idleFrame(frameMs: number, sampleRateHz: number, nowMs = Date.now()): Uint8Array | null {
243
287
  if (this.isSpeaking(nowMs)) return null;
244
288
  const playThinking = this.thinking !== null && this.thinkingState !== "off";
245
- if (!this.ambient && !playThinking) return null;
289
+ if (!this.ambient && !playThinking && !this.pendingCue) return null;
246
290
 
247
291
  const sampleCount = Math.max(1, Math.round((frameMs / 1000) * sampleRateHz));
248
292
  const mixBuf = new Float64Array(sampleCount);
249
293
  this.addBed(mixBuf, sampleRateHz, 1);
294
+ this.addPendingCue(mixBuf, sampleRateHz);
250
295
  return clipToPcm16Bytes(mixBuf);
251
296
  }
252
297
  }
253
298
 
254
- /**
255
- * Drive the mixer's thinking loop from the session's G3 tool-call cues:
256
- * started/delayed → thinking on; complete/failed → off. Listener lifetime is
257
- * the session's — both are per-connection.
258
- */
259
299
  export function wireBackgroundThinking(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
260
300
  session.on("tool_call_cue", (event) => {
261
301
  mixer.setThinking(event.phase === "started" || event.phase === "delayed");
262
302
  });
263
303
  }
304
+
305
+ export function wireBackgroundBackchannel(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
306
+ session.bus.on("interaction.backchannel", (pkt) => {
307
+ const backchannel = pkt as InteractionBackchannelPacket;
308
+ if (mixer.queueCue(backchannel.cue)) return;
309
+ session.bus.push(Route.Background, {
310
+ kind: "metric.conversation",
311
+ contextId: backchannel.contextId,
312
+ timestampMs: backchannel.timestampMs,
313
+ name: "backchannel.suppressed_missing_asset",
314
+ value: backchannel.cue,
315
+ });
316
+ });
317
+ }
318
+
319
+ export function wireBackgroundAudio(session: VoiceAgentSession, mixer: BackgroundAudioMixer): void {
320
+ wireBackgroundThinking(session, mixer);
321
+ wireBackgroundBackchannel(session, mixer);
322
+ }