@inetafrica/open-claudia 3.2.3 → 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();
@@ -142,6 +168,7 @@ class KazeeCallSession {
142
168
  }
143
169
 
144
170
  async _onPublisherEvent(frame) {
171
+ if (frame?.janus === "trickle") return this._onTrickle(this.publisher, frame);
145
172
  try { await this._consumePublisherFrame(frame); }
146
173
  catch (error) { console.error("Kazee call publisher:", error.message); await this.close("janus-error"); }
147
174
  }
@@ -160,30 +187,100 @@ class KazeeCallSession {
160
187
  }
161
188
  }
162
189
  if (data.videoroom === "joined") {
190
+ this._joined = true;
163
191
  clog(`Kazee call room joined: room=${this.roomId} publishers=${(data.publishers || []).length}`);
164
- if (!this.publisher) await this._publish();
165
192
  }
193
+ if (this._joined && !this.publisher) await this._maybePublish();
166
194
  const gone = Number(data.leaving || data.unpublished || 0);
167
195
  if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
168
196
  if (data.videoroom === "destroyed") await this.close("room-destroyed");
169
197
  }
170
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
+
171
231
  async _publish() {
172
232
  if (this.publisher) return;
173
233
  const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
174
- 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
+ });
175
241
  const track = new MediaStreamTrack({ kind: "audio" });
176
242
  const sender = pc.addTrack(track);
177
- this.publisher = { pc, track, sender };
243
+ this.publisher = { pc, track, sender, pending: [], remoteSet: false };
178
244
  const offer = await pc.createOffer();
179
245
  await pc.setLocalDescription(offer);
180
246
  const configured = await this.janus.message(this.publisherHandle, { request: "configure", audio: true, video: false }, pc.localDescription);
181
247
  const answer = configured?.jsep;
182
248
  if (!answer) throw new Error("Janus publisher configure did not return SDP");
183
249
  await pc.setRemoteDescription(answer);
250
+ this._flushTrickle(this.publisher);
184
251
  clog(`Kazee call publisher configured: room=${this.roomId}`);
185
252
  }
186
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
+
187
284
  async _subscribe(feedId, display) {
188
285
  if (this.subscribers.has(feedId)) return;
189
286
  // Reserve the slot before the first await: the join response and the async
@@ -202,9 +299,23 @@ class KazeeCallSession {
202
299
  display: String(display || ""),
203
300
  // Opus decoders are stateful, so each feed needs its own.
204
301
  codec: new OpusAudioCodec(),
205
- vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
206
- 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
+ }),
207
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,
208
319
  };
209
320
  this.subscribers.set(feedId, sub);
210
321
  pc.onTrack.subscribe((track) => {
@@ -217,11 +328,7 @@ class KazeeCallSession {
217
328
  () => clog(`Kazee call STT connected: feed=${feedId}`),
218
329
  (error) => console.error("Kazee call STT connect:", error.message),
219
330
  );
220
- // First remote feed up = both media legs established; greet exactly once.
221
- if (this.greeting && !this._greeted) {
222
- this._greeted = true;
223
- this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
224
- }
331
+ this._maybeGreet();
225
332
  } catch (error) {
226
333
  if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
227
334
  throw error;
@@ -229,6 +336,7 @@ class KazeeCallSession {
229
336
  }
230
337
 
231
338
  async _onSubscriberEvent(feedId, frame) {
339
+ if (frame?.janus === "trickle") return this._onTrickle(this.subscribers.get(feedId), frame);
232
340
  try { await this._consumeSubscriberFrame(feedId, frame); }
233
341
  catch (error) { console.error("Kazee call subscriber:", error.message); await this._removeSubscriber(feedId); }
234
342
  }
@@ -245,6 +353,7 @@ class KazeeCallSession {
245
353
  if (frame.jsep && !sub.started) {
246
354
  sub.started = true;
247
355
  await sub.pc.setRemoteDescription(frame.jsep);
356
+ this._flushTrickle(sub);
248
357
  const answer = await sub.pc.createAnswer();
249
358
  await sub.pc.setLocalDescription(answer);
250
359
  await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
@@ -252,11 +361,105 @@ class KazeeCallSession {
252
361
  }
253
362
  }
254
363
 
255
- _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) {
256
449
  const sub = this.subscribers.get(feedId);
257
- const len = String(text || "").trim().length;
258
- clog(`Kazee call transcript: feed=${feedId} chars=${len}${len ? "" : " (noise commit)"}`);
259
- 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();
260
463
  // Empty finalization (noise commit): nothing dispatches, no reply is
261
464
  // coming — don't tick into the void.
262
465
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
@@ -267,20 +470,23 @@ class KazeeCallSession {
267
470
  });
268
471
  }
269
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
+
270
483
  // Writes a short cue into the outbound track. Yields to real speech: the
271
484
  // moment an utterance is active (or starting), remaining frames are dropped.
272
485
  async _playCue(pcm48) {
273
486
  if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
274
487
  for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
275
488
  if (this.closed || this.speaking || this._speakActive) return;
276
- let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
277
- if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
278
- const opus = this.codec.encode(frame);
279
- const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
280
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
281
- payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
282
- }), opus));
283
- this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
489
+ this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
284
490
  await wait(20);
285
491
  }
286
492
  }
@@ -312,9 +518,30 @@ class KazeeCallSession {
312
518
  }
313
519
  let pcm;
314
520
  try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
315
- 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;
316
525
  const event = sub.vad.push(pcm);
317
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
+ }
318
545
  if (!sub.vad.active && !event) {
319
546
  sub.prebuffer.push(pcm16);
320
547
  if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
@@ -330,13 +557,22 @@ class KazeeCallSession {
330
557
  }
331
558
  this._feedAudio(sub, pcm16);
332
559
  if (event?.type === "commit") {
333
- this._lastHeard = Date.now();
334
- clog(`Kazee call VAD commit: feed=${feedId}`);
335
- sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
336
- this._playCue(ACK_CHIME_PCM48).catch(() => {});
337
- // STT finalization takes seconds; start ticking at commit so the
338
- // chime→first-tick stretch doesn't read as a dead line.
339
- 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
+ }
340
576
  }
341
577
  }
342
578
 
@@ -376,30 +612,100 @@ class KazeeCallSession {
376
612
  })();
377
613
  }
378
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
+
379
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}`;
380
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
+ }
381
653
  const synthesized = await this.synthesize(text);
382
654
  if (this.closed || gen !== this._speakGen) return false;
383
655
  const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
384
656
  clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
385
- this._stopThinking();
386
- 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;
387
672
  try {
388
- for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
673
+ for await (const chunk of chunks) {
389
674
  if (this.closed || gen !== this._speakGen) return false;
390
- let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
391
- if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
392
- const opus = this.codec.encode(frame);
393
- const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
394
- this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
395
- payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
396
- }), opus));
397
- this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
398
- 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);
399
703
  }
400
- 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;
401
707
  } finally {
402
- this._lastSpokeAt = Date.now();
708
+ if (started) this._lastSpokeAt = Date.now();
403
709
  }
404
710
  }
405
711
 
@@ -427,6 +733,7 @@ class KazeeCallSession {
427
733
  const sub = this.subscribers.get(feedId);
428
734
  if (!sub) return;
429
735
  this.subscribers.delete(feedId);
736
+ if (sub.finalTimer) clearTimeout(sub.finalTimer);
430
737
  try { sub.stt.close(); } catch (_) {}
431
738
  try { sub.codec.close(); } catch (_) {}
432
739
  try { await sub.pc.close(); } catch (_) {}
@@ -438,6 +745,7 @@ class KazeeCallSession {
438
745
  this.closed = true;
439
746
  console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
440
747
  if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
748
+ this._clearPublishTimer();
441
749
  this._stopThinking();
442
750
  this.cancelSpeech();
443
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 };