@inetafrica/open-claudia 3.2.1 → 3.3.1

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.
@@ -2,10 +2,17 @@
2
2
 
3
3
  const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
4
4
  const { JanusClient } = require("./janus-client");
5
- const { OpusAudioCodec, TurnDetector, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
6
- const { SpeechStream, synthesizePcm } = require("./speech-client");
5
+ const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
6
+ const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
7
+ const ttsVoice = require("../../../core/tts-voice");
7
8
 
8
9
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
+ const countWords = (text) => String(text || "").trim().split(/\s+/).filter(Boolean).length;
11
+
12
+ // Fixed lines (greeting, goodbyes) re-synthesize identically on every call;
13
+ // keep the rendered PCM per voice. Only those constants are cached, so the map
14
+ // stays tiny.
15
+ const SYNTH_CACHE = new Map();
9
16
 
10
17
  // Per-stage call logging. The media leg (ICE/RTP) can fail with zero thrown
11
18
  // errors — a caller then hears silence while the log shows a clean
@@ -13,7 +20,6 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
13
20
  // double as the STT/TTS timing measurements chat-side estimates lacked.
14
21
  const clog = (msg) => console.log(`${new Date().toISOString()} ${msg}`);
15
22
 
16
- const BASE_THRESHOLD_DB = -38;
17
23
  // While TTS plays, the caller's device can leak bot audio back through its mic.
18
24
  // Require a much louder signal before treating incoming audio as human speech.
19
25
  const SPEAKING_THRESHOLD_DB = -26;
@@ -23,6 +29,19 @@ const DEFAULT_MAX_CALL_MS = 30 * 60_000;
23
29
  const DEFAULT_IDLE_MS = 2 * 60_000;
24
30
  const DEFAULT_JOIN_RETRIES = 10;
25
31
  const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
32
+ // Inbound defer-publish window: how long to wait for the caller's own
33
+ // publisher before giving up and publishing anyway (see _maybePublish).
34
+ const PUBLISH_DEFER_MS = 4000;
35
+ // Hybrid turn-end (jig 2026-08-16): energy alone never closes a turn below
36
+ // ~15dB SNR of café noise, STT finals routinely never arrive at all, and
37
+ // babble can open a turn containing zero real words — so the recogniser's word
38
+ // timeline guards every energy close and the interim transcript stands in for
39
+ // missing finals.
40
+ const WORD_RESCUE_MIN_MS = 1500;
41
+ const WORD_RESCUE_NO_CADENCE_MS = 4000;
42
+ const WORD_WAIT_MAX_MS = 4000;
43
+ const FINAL_WAIT_MS = 2500;
44
+ const MIN_DISPATCH_WORDS = 2;
26
45
  const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
27
46
  const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
28
47
 
@@ -55,7 +74,7 @@ const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
55
74
 
56
75
  class KazeeCallSession {
57
76
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
58
- maxCallMs, idleMs, synthesize = synthesizePcm, asCaller = false, greeting = null,
77
+ maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
59
78
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
60
79
  this.roomId = Number(roomId);
61
80
  this.chatId = String(chatId);
@@ -64,7 +83,10 @@ class KazeeCallSession {
64
83
  this.asCaller = !!asCaller;
65
84
  this.greeting = greeting ? String(greeting) : null;
66
85
  this._greeted = false;
86
+ this._publisherUp = false;
67
87
  this._createdRoom = false;
88
+ this._joined = false;
89
+ this._publishTimer = null;
68
90
  this.janus = new JanusClientImpl({ url: janusUrl });
69
91
  this.PeerConnectionImpl = PeerConnectionImpl;
70
92
  this.SpeechStreamImpl = SpeechStreamImpl;
@@ -72,8 +94,12 @@ class KazeeCallSession {
72
94
  this.onTranscript = onTranscript || (() => {});
73
95
  this.onEnded = onEnded || (() => {});
74
96
  this.synthesize = synthesize;
97
+ // Tests and the jig inject a custom synthesize; only pair the streaming
98
+ // path with the service client it belongs to.
99
+ this.synthesizeStream = synthesizeStream ?? (synthesize === synthesizePcm ? synthesizeStreamPcm : null);
75
100
  this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
76
101
  this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
102
+ this.finalWaitMs = Number(finalWaitMs) || FINAL_WAIT_MS;
77
103
  this.joinRetries = DEFAULT_JOIN_RETRIES;
78
104
  this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
79
105
  this.codec = new OpusAudioCodec();
@@ -107,6 +133,9 @@ class KazeeCallSession {
107
133
  const created = await this.janus.message(this.publisherHandle, {
108
134
  request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
109
135
  description: `Call room ${this.roomId}`, permanent: false,
136
+ // Hidden from videoroom "list": the control plane is internet-reachable,
137
+ // so a listable room is eavesdroppable. Join-by-roomId is unaffected.
138
+ is_private: true,
110
139
  });
111
140
  const cdata = created?.plugindata?.data || {};
112
141
  const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
@@ -139,6 +168,7 @@ class KazeeCallSession {
139
168
  }
140
169
 
141
170
  async _onPublisherEvent(frame) {
171
+ if (frame?.janus === "trickle") return this._onTrickle(this.publisher, frame);
142
172
  try { await this._consumePublisherFrame(frame); }
143
173
  catch (error) { console.error("Kazee call publisher:", error.message); await this.close("janus-error"); }
144
174
  }
@@ -157,30 +187,100 @@ class KazeeCallSession {
157
187
  }
158
188
  }
159
189
  if (data.videoroom === "joined") {
190
+ this._joined = true;
160
191
  clog(`Kazee call room joined: room=${this.roomId} publishers=${(data.publishers || []).length}`);
161
- if (!this.publisher) await this._publish();
162
192
  }
193
+ if (this._joined && !this.publisher) await this._maybePublish();
163
194
  const gone = Number(data.leaving || data.unpublished || 0);
164
195
  if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
165
196
  if (data.videoroom === "destroyed") await this.close("room-destroyed");
166
197
  }
167
198
 
199
+ // Inbound, the bot answers faster than the caller's client finishes joining:
200
+ // publishing at join fires our publishers event into a tab that may not be
201
+ // listening yet, and a caller who misses it never subscribes to us — no
202
+ // remote audio, UI stuck pre-connected (web callers, 2026-08-16). Wait until
203
+ // the caller's own publisher is visible (proof their handlers are live, and
204
+ // subscribing to us flips their UI to connected), with a fallback so a
205
+ // caller who never publishes still gets our media leg. Outbound (asCaller)
206
+ // publishes at join as before: the callee joins after us and receives our
207
+ // feed in their own join response, so there is no race.
208
+ async _maybePublish() {
209
+ if (this.closed || this.publisher) return;
210
+ if (this.asCaller || this.subscribers.size > 0) {
211
+ this._clearPublishTimer();
212
+ return this._publish();
213
+ }
214
+ if (this._publishTimer) return;
215
+ this._publishTimer = setTimeout(() => {
216
+ this._publishTimer = null;
217
+ if (this.closed || this.publisher) return;
218
+ clog(`Kazee call publish fallback: room=${this.roomId} no remote publisher after ${PUBLISH_DEFER_MS}ms`);
219
+ this._publish().catch((error) => {
220
+ console.error("Kazee call publish:", error.message);
221
+ this.close("publish-failed").catch(() => {});
222
+ });
223
+ }, PUBLISH_DEFER_MS);
224
+ if (typeof this._publishTimer.unref === "function") this._publishTimer.unref();
225
+ }
226
+
227
+ _clearPublishTimer() {
228
+ if (this._publishTimer) { clearTimeout(this._publishTimer); this._publishTimer = null; }
229
+ }
230
+
168
231
  async _publish() {
169
232
  if (this.publisher) return;
170
233
  const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
171
- pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`));
234
+ pc.iceConnectionStateChange?.subscribe?.((state) => {
235
+ clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`);
236
+ if (state === "connected" || state === "completed") {
237
+ this._publisherUp = true;
238
+ this._maybeGreet();
239
+ }
240
+ });
172
241
  const track = new MediaStreamTrack({ kind: "audio" });
173
242
  const sender = pc.addTrack(track);
174
- this.publisher = { pc, track, sender };
243
+ this.publisher = { pc, track, sender, pending: [], remoteSet: false };
175
244
  const offer = await pc.createOffer();
176
245
  await pc.setLocalDescription(offer);
177
246
  const configured = await this.janus.message(this.publisherHandle, { request: "configure", audio: true, video: false }, pc.localDescription);
178
247
  const answer = configured?.jsep;
179
248
  if (!answer) throw new Error("Janus publisher configure did not return SDP");
180
249
  await pc.setRemoteDescription(answer);
250
+ this._flushTrickle(this.publisher);
181
251
  clog(`Kazee call publisher configured: room=${this.roomId}`);
182
252
  }
183
253
 
254
+ // Janus answers candidate-free and trickles ICE afterwards; dropping those
255
+ // frames leaves both legs stuck in "checking" and the call silently dead.
256
+ _onTrickle(target, frame) {
257
+ const candidate = frame?.candidate;
258
+ if (!target || !candidate || candidate.completed || !candidate.candidate) return;
259
+ if (!target.remoteSet) {
260
+ (target.pending ||= []).push(candidate);
261
+ return;
262
+ }
263
+ target.pc.addIceCandidate(candidate).catch((error) => console.error("Kazee call trickle:", error.message));
264
+ }
265
+
266
+ _flushTrickle(target) {
267
+ if (!target) return;
268
+ target.remoteSet = true;
269
+ for (const candidate of (target.pending || []).splice(0)) {
270
+ target.pc.addIceCandidate(candidate).catch((error) => console.error("Kazee call trickle:", error.message));
271
+ }
272
+ }
273
+
274
+ // Greeting on STT-connect alone used to speak into dead air while the media
275
+ // plane was still ICE-checking; wait for the publisher leg to actually come
276
+ // up AND a remote feed to exist, whichever lands second.
277
+ _maybeGreet() {
278
+ if (!this.greeting || this._greeted || this.closed) return;
279
+ if (!this._publisherUp || this.subscribers.size === 0) return;
280
+ this._greeted = true;
281
+ this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
282
+ }
283
+
184
284
  async _subscribe(feedId, display) {
185
285
  if (this.subscribers.has(feedId)) return;
186
286
  // Reserve the slot before the first await: the join response and the async
@@ -199,9 +299,23 @@ class KazeeCallSession {
199
299
  display: String(display || ""),
200
300
  // Opus decoders are stateful, so each feed needs its own.
201
301
  codec: new OpusAudioCodec(),
202
- vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
203
- stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
302
+ vad: new AdaptiveTurnDetector(),
303
+ stt: new this.SpeechStreamImpl({
304
+ onTranscript: (text) => this._onFeedFinal(feedId, text),
305
+ onPartial: (text) => this._onFeedPartial(feedId, text),
306
+ }),
204
307
  prebuffer: [],
308
+ pending: [],
309
+ remoteSet: false,
310
+ wordCount: 0,
311
+ lastWordAt: 0,
312
+ wordGaps: [],
313
+ lastPartial: "",
314
+ partialAtCommit: "",
315
+ awaitingWordsSince: 0,
316
+ noiseCommit: false,
317
+ fallbackUsed: false,
318
+ finalTimer: null,
205
319
  };
206
320
  this.subscribers.set(feedId, sub);
207
321
  pc.onTrack.subscribe((track) => {
@@ -214,11 +328,7 @@ class KazeeCallSession {
214
328
  () => clog(`Kazee call STT connected: feed=${feedId}`),
215
329
  (error) => console.error("Kazee call STT connect:", error.message),
216
330
  );
217
- // First remote feed up = both media legs established; greet exactly once.
218
- if (this.greeting && !this._greeted) {
219
- this._greeted = true;
220
- this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
221
- }
331
+ this._maybeGreet();
222
332
  } catch (error) {
223
333
  if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
224
334
  throw error;
@@ -226,6 +336,7 @@ class KazeeCallSession {
226
336
  }
227
337
 
228
338
  async _onSubscriberEvent(feedId, frame) {
339
+ if (frame?.janus === "trickle") return this._onTrickle(this.subscribers.get(feedId), frame);
229
340
  try { await this._consumeSubscriberFrame(feedId, frame); }
230
341
  catch (error) { console.error("Kazee call subscriber:", error.message); await this._removeSubscriber(feedId); }
231
342
  }
@@ -242,6 +353,7 @@ class KazeeCallSession {
242
353
  if (frame.jsep && !sub.started) {
243
354
  sub.started = true;
244
355
  await sub.pc.setRemoteDescription(frame.jsep);
356
+ this._flushTrickle(sub);
245
357
  const answer = await sub.pc.createAnswer();
246
358
  await sub.pc.setLocalDescription(answer);
247
359
  await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
@@ -249,11 +361,105 @@ class KazeeCallSession {
249
361
  }
250
362
  }
251
363
 
252
- _onFeedTranscript(feedId, text) {
364
+ _onFeedPartial(feedId, text) {
365
+ const sub = this.subscribers.get(feedId);
366
+ if (!sub || this.closed) return;
367
+ const partial = String(text || "");
368
+ const prevWords = countWords(sub.lastPartial);
369
+ const words = countWords(partial);
370
+ sub.lastPartial = partial;
371
+ // Growth only: a shrunk partial is a recogniser revision or a post-final
372
+ // buffer reset, not new words.
373
+ if (words <= prevWords) return;
374
+ const now = Date.now();
375
+ if (sub.lastWordAt) {
376
+ sub.wordGaps.push(now - sub.lastWordAt);
377
+ if (sub.wordGaps.length > 8) sub.wordGaps.shift();
378
+ }
379
+ sub.wordCount += words - prevWords;
380
+ sub.lastWordAt = now;
381
+ if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
382
+ }
383
+
384
+ // Interims arrive in bursts whose cadence tracks recogniser health (jig
385
+ // measured 1–20s between new words on a struggling service); a fixed stall
386
+ // gate would cut callers off mid-sentence on a slow day.
387
+ _wordRescueGapMs(sub) {
388
+ if (!sub.wordGaps.length) return WORD_RESCUE_NO_CADENCE_MS;
389
+ const sorted = [...sub.wordGaps].sort((a, b) => a - b);
390
+ return Math.max(WORD_RESCUE_MIN_MS, 2 * sorted[Math.floor(sorted.length / 2)]);
391
+ }
392
+
393
+ _commitTurn(feedId, reason) {
394
+ const sub = this.subscribers.get(feedId);
395
+ if (!sub || this.closed) return;
396
+ sub.awaitingWordsSince = 0;
397
+ if (sub.vad.active) sub.vad.reset();
398
+ this._lastHeard = Date.now();
399
+ clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
400
+ sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
401
+ if (!sub.noiseCommit) {
402
+ this._playCue(ACK_CHIME_PCM48).catch(() => {});
403
+ // STT finalization takes seconds; start ticking at commit so the
404
+ // chime→first-tick stretch doesn't read as a dead line.
405
+ this._startThinking();
406
+ }
407
+ // Finals routinely never arrive; after finalWaitMs the interim transcript
408
+ // is dispatched in their place.
409
+ sub.partialAtCommit = sub.lastPartial;
410
+ sub.fallbackUsed = false;
411
+ if (sub.finalTimer) clearTimeout(sub.finalTimer);
412
+ sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
413
+ if (typeof sub.finalTimer.unref === "function") sub.finalTimer.unref();
414
+ sub.wordCount = 0;
415
+ sub.lastWordAt = 0;
416
+ sub.wordGaps = [];
417
+ }
418
+
419
+ _finalFallback(feedId) {
420
+ const sub = this.subscribers.get(feedId);
421
+ if (!sub || this.closed) return;
422
+ sub.finalTimer = null;
423
+ // Partials keep growing after commit while the decoder catches up; accept
424
+ // the grown text only if it extends the committed snapshot (anything else
425
+ // belongs to the next turn).
426
+ const current = sub.lastPartial || "";
427
+ const snapshot = sub.partialAtCommit || "";
428
+ const text = current.length > snapshot.length && current.startsWith(snapshot) ? current : snapshot;
429
+ sub.fallbackUsed = true;
430
+ clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
431
+ this._dispatchTranscript(feedId, text);
432
+ }
433
+
434
+ _onFeedFinal(feedId, text) {
435
+ const sub = this.subscribers.get(feedId);
436
+ if (sub?.finalTimer) {
437
+ clearTimeout(sub.finalTimer);
438
+ sub.finalTimer = null;
439
+ } else if (sub?.fallbackUsed) {
440
+ // The interim already went to the model; a late final would double-answer.
441
+ sub.fallbackUsed = false;
442
+ clog(`Kazee call late final dropped: feed=${feedId}`);
443
+ return;
444
+ }
445
+ this._dispatchTranscript(feedId, text);
446
+ }
447
+
448
+ _dispatchTranscript(feedId, text) {
253
449
  const sub = this.subscribers.get(feedId);
254
- const len = String(text || "").trim().length;
255
- clog(`Kazee call transcript: feed=${feedId} chars=${len}${len ? "" : " (noise commit)"}`);
256
- if (String(text || "").trim()) this._startThinking();
450
+ const trimmed = String(text || "").trim();
451
+ if (sub?.noiseCommit) {
452
+ sub.noiseCommit = false;
453
+ // A turn the recogniser never put words in was background noise; babble
454
+ // occasionally leaks a single stray word into the transcript too.
455
+ if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
456
+ clog(`Kazee call noise turn dropped: feed=${feedId} words=${countWords(trimmed)}`);
457
+ if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
458
+ return;
459
+ }
460
+ }
461
+ clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
462
+ if (trimmed) this._startThinking();
257
463
  // Empty finalization (noise commit): nothing dispatches, no reply is
258
464
  // coming — don't tick into the void.
259
465
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
@@ -264,20 +470,23 @@ class KazeeCallSession {
264
470
  });
265
471
  }
266
472
 
473
+ _writeFrame(frame) {
474
+ if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
475
+ const opus = this.codec.encode(frame);
476
+ const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
477
+ this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
478
+ payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
479
+ }), opus));
480
+ this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
481
+ }
482
+
267
483
  // Writes a short cue into the outbound track. Yields to real speech: the
268
484
  // moment an utterance is active (or starting), remaining frames are dropped.
269
485
  async _playCue(pcm48) {
270
486
  if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
271
487
  for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
272
488
  if (this.closed || this.speaking || this._speakActive) return;
273
- let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
274
- if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
275
- const opus = this.codec.encode(frame);
276
- const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
277
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
278
- payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
279
- }), opus));
280
- this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
489
+ this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
281
490
  await wait(20);
282
491
  }
283
492
  }
@@ -309,9 +518,30 @@ class KazeeCallSession {
309
518
  }
310
519
  let pcm;
311
520
  try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
312
- sub.vad.thresholdDb = this.speaking ? SPEAKING_THRESHOLD_DB : BASE_THRESHOLD_DB;
521
+ // Echo guard: raise the effective threshold while the bot speaks; the
522
+ // adaptive floor keeps sampling the un-boosted base so echo never pollutes
523
+ // the noise-floor estimate.
524
+ sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
313
525
  const event = sub.vad.push(pcm);
314
526
  const pcm16 = downsample48To16(pcm);
527
+ if (sub.awaitingWordsSince) {
528
+ // Energy closed the turn but the recogniser hasn't produced a word yet:
529
+ // hold the commit and keep streaming audio so a slow decode still lands.
530
+ this._feedAudio(sub, pcm16);
531
+ if (event?.type === "start") {
532
+ // Caller resumed before any words arrived — same turn continues.
533
+ sub.awaitingWordsSince = 0;
534
+ this._lastHeard = Date.now();
535
+ if (this.speaking) this.cancelSpeech();
536
+ this._stopThinking();
537
+ return;
538
+ }
539
+ if (Date.now() - sub.awaitingWordsSince >= WORD_WAIT_MAX_MS) {
540
+ sub.noiseCommit = true;
541
+ this._commitTurn(feedId, "no-words");
542
+ }
543
+ return;
544
+ }
315
545
  if (!sub.vad.active && !event) {
316
546
  sub.prebuffer.push(pcm16);
317
547
  if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
@@ -327,13 +557,22 @@ class KazeeCallSession {
327
557
  }
328
558
  this._feedAudio(sub, pcm16);
329
559
  if (event?.type === "commit") {
330
- this._lastHeard = Date.now();
331
- clog(`Kazee call VAD commit: feed=${feedId}`);
332
- sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
333
- this._playCue(ACK_CHIME_PCM48).catch(() => {});
334
- // STT finalization takes seconds; start ticking at commit so the
335
- // chime→first-tick stretch doesn't read as a dead line.
336
- this._startThinking();
560
+ if (sub.wordCount > 0 || event.reason === "max-duration") {
561
+ if (sub.wordCount === 0) sub.noiseCommit = true;
562
+ this._commitTurn(feedId, event.reason);
563
+ } else {
564
+ sub.awaitingWordsSince = Date.now();
565
+ clog(`Kazee call commit held for words: feed=${feedId}`);
566
+ }
567
+ } else if (sub.vad.active && sub.wordCount >= MIN_DISPATCH_WORDS && sub.lastWordAt) {
568
+ // Steady background can keep every frame "loud" so the energy close
569
+ // never fires; when the word timeline has stalled well past its own
570
+ // cadence, the caller has finished — commit anyway.
571
+ const gap = this._wordRescueGapMs(sub);
572
+ if (Date.now() - sub.lastWordAt >= gap) {
573
+ clog(`Kazee call word-stall commit: feed=${feedId} gap=${gap}ms`);
574
+ this._commitTurn(feedId, "word-stall");
575
+ }
337
576
  }
338
577
  }
339
578
 
@@ -373,30 +612,100 @@ class KazeeCallSession {
373
612
  })();
374
613
  }
375
614
 
615
+ async _playPcm48(pcm48, gen) {
616
+ this._stopThinking();
617
+ this.speaking = true;
618
+ try {
619
+ // Drift-corrected pacing: a naive wait(20) accumulates encode time and
620
+ // timer slop, audibly stretching long utterances.
621
+ const startAt = Date.now();
622
+ for (let offset = 0, i = 1; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2, i += 1) {
623
+ if (this.closed || gen !== this._speakGen) return false;
624
+ this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
625
+ const delay = startAt + i * 20 - Date.now();
626
+ if (delay > 0) await wait(delay);
627
+ }
628
+ return true;
629
+ } finally {
630
+ this._lastSpokeAt = Date.now();
631
+ }
632
+ }
633
+
376
634
  async _playUtterance(text, gen) {
635
+ // Cache only audio rendered by the real service: the key is voice+text, so
636
+ // PCM from an injected synthesize would leak across sessions.
637
+ const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
638
+ const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE);
639
+ const cacheKey = `${ttsVoice.getVoice()}::${text}`;
377
640
  const synthStart = Date.now();
641
+ if (cacheable && SYNTH_CACHE.has(cacheKey)) {
642
+ clog(`Kazee call speak: cached chars=${text.length}`);
643
+ return this._playPcm48(SYNTH_CACHE.get(cacheKey), gen);
644
+ }
645
+ if (this.synthesizeStream) {
646
+ try {
647
+ return await this._playStreaming(text, gen, { cacheable, cacheKey, synthStart });
648
+ } catch (error) {
649
+ if (this.closed || gen !== this._speakGen) return false;
650
+ clog(`Kazee call stream synth failed (${error.message}); buffering`);
651
+ }
652
+ }
378
653
  const synthesized = await this.synthesize(text);
379
654
  if (this.closed || gen !== this._speakGen) return false;
380
655
  const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
381
656
  clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
382
- this._stopThinking();
383
- this.speaking = true;
657
+ if (cacheable) SYNTH_CACHE.set(cacheKey, pcm48);
658
+ return this._playPcm48(pcm48, gen);
659
+ }
660
+
661
+ // Plays TTS as it downloads: the service streams PCM, but buffering the full
662
+ // response cost ~3s of silence before the first word on every reply.
663
+ async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
664
+ const { sampleRate, chunks } = await this.synthesizeStream(text);
665
+ if (this.closed || gen !== this._speakGen) return false;
666
+ let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
667
+ let frameCarry = Buffer.alloc(0);
668
+ const parts = cacheable ? [] : null;
669
+ let started = false;
670
+ let startAt = 0;
671
+ let i = 0;
384
672
  try {
385
- for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
673
+ for await (const chunk of chunks) {
386
674
  if (this.closed || gen !== this._speakGen) return false;
387
- let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
388
- if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
389
- const opus = this.codec.encode(frame);
390
- const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
391
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
392
- payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
393
- }), opus));
394
- this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
395
- await wait(20);
675
+ const raw = rawCarry.length ? Buffer.concat([rawCarry, chunk]) : chunk;
676
+ const usable = raw.length & ~1;
677
+ rawCarry = raw.subarray(usable);
678
+ if (!usable) continue;
679
+ const pcm48 = upsampleTo48(raw.subarray(0, usable), sampleRate);
680
+ if (parts) parts.push(pcm48);
681
+ if (!started) {
682
+ started = true;
683
+ clog(`Kazee call speak: first-audio=${Date.now() - synthStart}ms chars=${text.length}`);
684
+ // Only now is the caller actually hearing us — until first audio the
685
+ // thinking ticks and barge-in semantics should stay in "waiting" mode.
686
+ this._stopThinking();
687
+ this.speaking = true;
688
+ startAt = Date.now();
689
+ }
690
+ frameCarry = frameCarry.length ? Buffer.concat([frameCarry, pcm48]) : pcm48;
691
+ let offset = 0;
692
+ for (; offset + WEBRTC_SAMPLES * 2 <= frameCarry.length; offset += WEBRTC_SAMPLES * 2) {
693
+ if (this.closed || gen !== this._speakGen) return false;
694
+ this._writeFrame(frameCarry.subarray(offset, offset + WEBRTC_SAMPLES * 2));
695
+ i += 1;
696
+ const delay = startAt + i * 20 - Date.now();
697
+ if (delay > 0) await wait(delay);
698
+ // The service stalled mid-utterance; re-anchor so playback resumes
699
+ // at normal pace instead of bursting the backlog.
700
+ else if (delay < -200) startAt = Date.now() - i * 20;
701
+ }
702
+ frameCarry = frameCarry.subarray(offset);
396
703
  }
397
- return true;
704
+ if (frameCarry.length && started && !this.closed && gen === this._speakGen) this._writeFrame(frameCarry);
705
+ if (parts && started) SYNTH_CACHE.set(cacheKey, Buffer.concat(parts));
706
+ return started;
398
707
  } finally {
399
- this._lastSpokeAt = Date.now();
708
+ if (started) this._lastSpokeAt = Date.now();
400
709
  }
401
710
  }
402
711
 
@@ -424,6 +733,7 @@ class KazeeCallSession {
424
733
  const sub = this.subscribers.get(feedId);
425
734
  if (!sub) return;
426
735
  this.subscribers.delete(feedId);
736
+ if (sub.finalTimer) clearTimeout(sub.finalTimer);
427
737
  try { sub.stt.close(); } catch (_) {}
428
738
  try { sub.codec.close(); } catch (_) {}
429
739
  try { await sub.pc.close(); } catch (_) {}
@@ -435,6 +745,7 @@ class KazeeCallSession {
435
745
  this.closed = true;
436
746
  console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
437
747
  if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
748
+ this._clearPublishTimer();
438
749
  this._stopThinking();
439
750
  this.cancelSpeech();
440
751
  for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
@@ -6,11 +6,12 @@ const { speechBearerToken } = require("../../../core/media");
6
6
  const ttsVoice = require("../../../core/tts-voice");
7
7
 
8
8
  class SpeechStream {
9
- constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript } = {}) {
9
+ constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial } = {}) {
10
10
  this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
11
11
  this.token = token;
12
12
  this.WebSocketImpl = WebSocketImpl;
13
13
  this.onTranscript = onTranscript || (() => {});
14
+ this.onPartial = onPartial || (() => {});
14
15
  this.socket = null;
15
16
  this.ready = null;
16
17
  }
@@ -47,8 +48,12 @@ class SpeechStream {
47
48
  let message;
48
49
  try { message = JSON.parse(raw.toString()); } catch (_) { return; }
49
50
  const text = String(message.text || message.transcript || "").trim();
50
- if (text && (message.final === true || message.type === "final" || message.type === "transcription")) {
51
+ if (!text) return;
52
+ if (message.final === true || message.type === "final" || message.type === "transcription") {
51
53
  Promise.resolve(this.onTranscript(text)).catch((error) => console.error("Kazee call transcript:", error.message));
54
+ } else {
55
+ // Interims drive word-based turn-end detection; they were previously discarded.
56
+ Promise.resolve(this.onPartial(text)).catch((error) => console.error("Kazee call partial:", error.message));
52
57
  }
53
58
  }
54
59
 
@@ -76,4 +81,34 @@ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speec
76
81
  return { pcm: Buffer.from(await response.arrayBuffer()), sampleRate: rate };
77
82
  }
78
83
 
79
- module.exports = { SpeechStream, synthesizePcm };
84
+ // Same endpoint as synthesizePcm, but yields PCM chunks as they arrive so
85
+ // playback can start on the first byte instead of after the full download.
86
+ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
87
+ if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
88
+ const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
89
+ method: "POST",
90
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
91
+ body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
92
+ signal: AbortSignal.timeout(90000),
93
+ });
94
+ if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
95
+ const contentType = response.headers?.get?.("content-type") || "";
96
+ const rate = Number(contentType.match(/(?:^|;)\s*rate=(\d+)/i)?.[1] || 24000);
97
+ if (!Number.isFinite(rate) || rate < 8000 || rate > 48000) throw new Error(`Hosted TTS returned invalid PCM rate: ${rate}`);
98
+ const reader = response.body.getReader();
99
+ async function* chunks() {
100
+ try {
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ if (done) return;
104
+ if (value?.byteLength) yield Buffer.from(value);
105
+ }
106
+ } finally {
107
+ // Barge-in abandons the iterator mid-stream; stop the download too.
108
+ reader.cancel?.().catch?.(() => {});
109
+ }
110
+ }
111
+ return { sampleRate: rate, chunks: chunks() };
112
+ }
113
+
114
+ module.exports = { SpeechStream, synthesizePcm, synthesizeStream };