@inetafrica/open-claudia 3.4.13 → 3.4.15

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.
@@ -4,7 +4,8 @@ const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("w
4
4
  const { JanusClient } = require("./janus-client");
5
5
  const { projectAssignedIce, supportedIceServers } = require("./access");
6
6
  const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
7
- const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
7
+ const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm, detectTurn, selectAcknowledgement, CUE_PHRASES } = require("./speech-client");
8
+ const { SpeechTurnGate } = require("./turn-gate");
8
9
  const ttsVoice = require("../../../core/tts-voice");
9
10
 
10
11
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -42,40 +43,18 @@ const WORD_RESCUE_MIN_MS = 1500;
42
43
  const WORD_RESCUE_NO_CADENCE_MS = 4000;
43
44
  const WORD_WAIT_MAX_MS = 4000;
44
45
  const FINAL_WAIT_MS = 2500;
46
+ const SEGMENT_FINAL_WAIT_MS = 12000;
45
47
  const MIN_DISPATCH_WORDS = 2;
46
48
  const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
47
49
  const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
48
50
 
49
- // Audible state cues: a short ack chime when the caller finishes speaking, then
50
- // a soft periodic tick while the turn is being worked on — dead air otherwise
51
- // reads as a dropped call.
52
- const THINK_TICK_INTERVAL_MS = 1600;
53
- const THINKING_MAX_MS = 5 * 60_000;
54
-
55
- function tonePcm48(segments, gain = 0.08) {
56
- const rate = 48000;
57
- const fade = Math.floor(rate * 0.012);
58
- const buffers = segments.map(({ freq, ms }) => {
59
- const samples = Math.floor((rate * ms) / 1000);
60
- const buf = Buffer.alloc(samples * 2);
61
- for (let i = 0; i < samples; i += 1) {
62
- let a = gain;
63
- if (i < fade) a *= i / fade;
64
- if (i > samples - fade) a *= Math.max(0, (samples - i) / fade);
65
- buf.writeInt16LE(Math.round(Math.sin((2 * Math.PI * freq * i) / rate) * a * 32767), i * 2);
66
- }
67
- return buf;
68
- });
69
- return Buffer.concat(buffers);
70
- }
71
-
72
- // Rising two-tone "heard you", and a quieter single "still here" pulse.
73
- const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }], 0.09);
74
- const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
51
+ // One optional spoken cue after a delay, never periodic beeps.
52
+ const ACK_DELAY_MS = 2800;
75
53
 
76
54
  class KazeeCallSession {
77
55
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
78
56
  maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
57
+ turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart,
79
58
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
80
59
  this.roomId = Number(roomId);
81
60
  this.chatId = String(chatId);
@@ -103,6 +82,11 @@ class KazeeCallSession {
103
82
  this._transportIceExpiry = new Map();
104
83
  this._transportExpiryTimer = null;
105
84
  this.onTranscript = onTranscript || (() => {});
85
+ this.onSpeechStart = onSpeechStart || (() => {});
86
+ this.turnDecision = turnDecision;
87
+ this.acknowledgement = acknowledgement;
88
+ this._cueController = null;
89
+ this._cueRevision = 0;
106
90
  this.onEnded = onEnded || (() => {});
107
91
  this.onMediaReady = onMediaReady || (() => {});
108
92
  this._sentRtp = false;
@@ -256,8 +240,8 @@ class KazeeCallSession {
256
240
  }
257
241
  for (const publisher of data.publishers || []) {
258
242
  const feedId = Number(publisher.id);
259
- if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
260
- await this._subscribe(feedId, publisher.display);
243
+ if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId)) {
244
+ await this._subscribe(feedId, publisher.display, publisher.metadata);
261
245
  }
262
246
  }
263
247
  if (data.videoroom === "joined") {
@@ -357,8 +341,21 @@ class KazeeCallSession {
357
341
  this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
358
342
  }
359
343
 
360
- async _subscribe(feedId, display) {
361
- if (this.subscribers.has(feedId)) return;
344
+ async _subscribe(feedId, display, metadata) {
345
+ if (this.closed || feedId === this.publisherId || this.subscribers.has(feedId)) return;
346
+ if (this.managedRoom) {
347
+ // Only the enforced gateway owns this metadata. Display remains an email
348
+ // for presentation, and cannot tell us whether a publisher is another bot.
349
+ // Missing or malformed classification must not start STT on an unknown feed.
350
+ const principal = metadata?.kazee;
351
+ if (!principal || Array.isArray(principal) || principal.version !== 1 ||
352
+ typeof principal.userId !== "string" || !/^[a-f\d]{24}$/i.test(principal.userId) ||
353
+ !["User", "Bot"].includes(principal.principalKind) || principal.principalKind === "Bot" ||
354
+ principal.userId.toLowerCase() === this.botUserId.toLowerCase()) return;
355
+ } else if (String(display || "").startsWith("open-claudia:")) {
356
+ // Legacy/direct rooms allow client metadata, so it is not a trusted source.
357
+ return;
358
+ }
362
359
  // Reserve the slot before the first await: the join response and the async
363
360
  // publishers event both list the same feed, and while attach() is in
364
361
  // flight the second frame passes the caller's has() check and subscribes
@@ -379,8 +376,9 @@ class KazeeCallSession {
379
376
  codec: new OpusAudioCodec(),
380
377
  vad: new AdaptiveTurnDetector(),
381
378
  stt: new this.SpeechStreamImpl({
382
- onTranscript: (text) => this._onFeedFinal(feedId, text),
383
- onPartial: (text) => this._onFeedPartial(feedId, text),
379
+ onTranscript: (text, meta) => this._onFeedFinal(feedId, text, meta),
380
+ onPartial: (text, meta) => this._onFeedPartial(feedId, text, meta),
381
+ onDisconnect: () => this._resetRecognition(feedId),
384
382
  }),
385
383
  prebuffer: [],
386
384
  pending: [],
@@ -394,7 +392,9 @@ class KazeeCallSession {
394
392
  noiseCommit: false,
395
393
  fallbackUsed: false,
396
394
  finalTimer: null,
395
+ segments: new Map(),
397
396
  };
397
+ sub.turnGate = new SpeechTurnGate({ decide: this.turnDecision, commit: reason => this._commitTurn(feedId, reason) });
398
398
  this.subscribers.set(feedId, sub);
399
399
  pc.onTrack.subscribe((track) => {
400
400
  if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
@@ -439,9 +439,26 @@ class KazeeCallSession {
439
439
  }
440
440
  }
441
441
 
442
- _onFeedPartial(feedId, text) {
442
+ _resetRecognition(feedId) {
443
+ const sub = this.subscribers.get(feedId);
444
+ if (!sub) return;
445
+ for (const segment of sub.segments.values()) clearTimeout(segment.timer);
446
+ sub.segments.clear();
447
+ if (sub.finalTimer) clearTimeout(sub.finalTimer);
448
+ sub.finalTimer = null;
449
+ sub.lastPartial = sub.partialAtCommit = "";
450
+ sub.wordCount = sub.lastWordAt = sub.awaitingWordsSince = 0;
451
+ sub.wordGaps = [];
452
+ sub.noiseCommit = sub.fallbackUsed = false;
453
+ sub.vad.reset();
454
+ sub.turnGate?.reset();
455
+ this._stopThinking();
456
+ }
457
+
458
+ _onFeedPartial(feedId, text, meta = {}) {
443
459
  const sub = this.subscribers.get(feedId);
444
460
  if (!sub || this.closed) return;
461
+ if (meta.protocol === 2 && (meta.streamId !== sub.stt.streamId || meta.segmentId !== sub.stt.segmentId)) return;
445
462
  const partial = String(text || "");
446
463
  const prevWords = countWords(sub.lastPartial);
447
464
  const words = countWords(partial);
@@ -456,7 +473,7 @@ class KazeeCallSession {
456
473
  }
457
474
  sub.wordCount += words - prevWords;
458
475
  sub.lastWordAt = now;
459
- if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
476
+ if (sub.awaitingWordsSince && sub.stt.protocol !== 2) this._commitTurn(feedId, "words-arrived");
460
477
  }
461
478
 
462
479
  // Interims arrive in bursts whose cadence tracks recogniser health (jig
@@ -472,23 +489,49 @@ class KazeeCallSession {
472
489
  const sub = this.subscribers.get(feedId);
473
490
  if (!sub || this.closed) return;
474
491
  sub.awaitingWordsSince = 0;
492
+ sub.turnGate?.reset();
475
493
  if (sub.vad.active) sub.vad.reset();
476
494
  this._lastHeard = Date.now();
477
495
  clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
478
- sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
479
- if (!sub.noiseCommit) {
480
- this._playCue(ACK_CHIME_PCM48).catch(() => {});
481
- // STT finalization takes seconds; start ticking at commit so the
482
- // chime→first-tick stretch doesn't read as a dead line.
483
- this._startThinking();
484
- }
485
- // Finals routinely never arrive; after finalWaitMs the interim transcript
486
- // is dispatched in their place.
496
+ const versioned = sub.stt.protocol === 2 && typeof sub.stt.commitTurn === "function";
497
+ if (versioned) {
498
+ if (sub.segments.size >= 4) {
499
+ this.close("speech-backlog").catch(() => {});
500
+ return;
501
+ }
502
+ const commit = sub.stt.commitTurn();
503
+ const key = `${commit.streamId}:${commit.segmentId}`;
504
+ const segment = { noiseCommit: sub.noiseCommit, committedAt: Date.now(), timer: null };
505
+ sub.segments.set(key, segment);
506
+ segment.timer = setTimeout(() => {
507
+ if (!sub.segments.delete(key)) return;
508
+ clog(`Kazee call final timeout: feed=${feedId} segment=${key}`);
509
+ this._stopThinking();
510
+ // Never substitute an old or unfinished transcript. Ask for a repeat
511
+ // only when the caller is quiet, so recovery cannot interrupt them.
512
+ if (!this.closed && !segment.noiseCommit && !this._speakActive &&
513
+ ![...this.subscribers.values()].some(peer => peer?.vad?.active)) {
514
+ this.speak("I missed that. Could you repeat it?").catch(() => {});
515
+ }
516
+ }, SEGMENT_FINAL_WAIT_MS);
517
+ segment.timer.unref?.();
518
+ commit.pending.catch(() => {
519
+ clearTimeout(segment.timer);
520
+ sub.segments.delete(key);
521
+ clog(`Kazee call STT commit failed: feed=${feedId} segment=${key}`);
522
+ });
523
+ } else {
524
+ sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
525
+ }
526
+ // Recognition must finish before any acknowledgement claims a heard turn.
527
+ // The legacy fallback is retained only for older speech deployments.
487
528
  sub.partialAtCommit = sub.lastPartial;
529
+ sub.lastPartial = "";
488
530
  sub.fallbackUsed = false;
489
531
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
490
- sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
491
- if (typeof sub.finalTimer.unref === "function") sub.finalTimer.unref();
532
+ sub.finalTimer = versioned ? null : setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
533
+ sub.finalTimer?.unref?.();
534
+ if (versioned) sub.noiseCommit = false;
492
535
  sub.wordCount = 0;
493
536
  sub.lastWordAt = 0;
494
537
  sub.wordGaps = [];
@@ -498,19 +541,28 @@ class KazeeCallSession {
498
541
  const sub = this.subscribers.get(feedId);
499
542
  if (!sub || this.closed) return;
500
543
  sub.finalTimer = null;
501
- // Partials keep growing after commit while the decoder catches up; accept
502
- // the grown text only if it extends the committed snapshot (anything else
503
- // belongs to the next turn).
504
- const current = sub.lastPartial || "";
544
+ // Legacy services cannot label late partials. Use only the committed
545
+ // snapshot; newer text could belong to a different utterance.
505
546
  const snapshot = sub.partialAtCommit || "";
506
- const text = current.length > snapshot.length && current.startsWith(snapshot) ? current : snapshot;
547
+ const text = snapshot;
507
548
  sub.fallbackUsed = true;
508
549
  clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
509
550
  this._dispatchTranscript(feedId, text);
510
551
  }
511
552
 
512
- _onFeedFinal(feedId, text) {
553
+ _onFeedFinal(feedId, text, meta = {}) {
513
554
  const sub = this.subscribers.get(feedId);
555
+ if (!sub || this.closed) return;
556
+ if (meta.protocol === 2) {
557
+ const key = `${meta.streamId}:${meta.segmentId}`;
558
+ const segment = sub.segments.get(key);
559
+ if (!segment || meta.streamId !== sub.stt.streamId) return;
560
+ sub.segments.delete(key);
561
+ clearTimeout(segment.timer);
562
+ clog(`Kazee call final ready: feed=${feedId} segment=${key} wait=${Date.now() - segment.committedAt}ms`);
563
+ this._dispatchTranscript(feedId, text, { noiseCommit: segment.noiseCommit, utteranceId: key });
564
+ return;
565
+ }
514
566
  if (sub?.finalTimer) {
515
567
  clearTimeout(sub.finalTimer);
516
568
  sub.finalTimer = null;
@@ -523,11 +575,11 @@ class KazeeCallSession {
523
575
  this._dispatchTranscript(feedId, text);
524
576
  }
525
577
 
526
- _dispatchTranscript(feedId, text) {
578
+ _dispatchTranscript(feedId, text, options = {}) {
527
579
  const sub = this.subscribers.get(feedId);
528
580
  const trimmed = String(text || "").trim();
529
- if (sub?.noiseCommit) {
530
- sub.noiseCommit = false;
581
+ if (options.noiseCommit ?? sub?.noiseCommit) {
582
+ if (options.noiseCommit === undefined) sub.noiseCommit = false;
531
583
  // A turn the recogniser never put words in was background noise; babble
532
584
  // occasionally leaks a single stray word into the transcript too.
533
585
  if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
@@ -537,14 +589,16 @@ class KazeeCallSession {
537
589
  }
538
590
  }
539
591
  clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
540
- if (trimmed) this._startThinking();
592
+ if (trimmed) this._startThinking(trimmed);
541
593
  // Empty finalization (noise commit): nothing dispatches, no reply is
542
594
  // coming — don't tick into the void.
543
595
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
596
+ if (!trimmed) return;
544
597
  this.onTranscript(text, {
545
598
  feedId,
546
599
  display: sub?.display || "",
547
600
  participants: this.subscribers.size,
601
+ ...(options.utteranceId ? { utteranceId: options.utteranceId } : {}),
548
602
  });
549
603
  }
550
604
 
@@ -567,32 +621,34 @@ class KazeeCallSession {
567
621
  this._reportMediaReady();
568
622
  }
569
623
 
570
- // Writes a short cue into the outbound track. Yields to real speech: the
571
- // moment an utterance is active (or starting), remaining frames are dropped.
572
- async _playCue(pcm48) {
573
- if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
574
- for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
575
- if (this.closed || this.speaking || this._speakActive) return;
576
- this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
577
- await wait(20);
578
- }
579
- }
580
-
581
- _startThinking() {
582
- if (this.closed || this._thinkTimer) return;
583
- this._thinkingSince = Date.now();
584
- this._thinkTimer = setInterval(() => {
585
- if (this.closed || Date.now() - this._thinkingSince > THINKING_MAX_MS) return this._stopThinking();
586
- if (this.speaking || this._speakActive) return;
587
- this._playCue(THINK_TICK_PCM48).catch(() => {});
588
- }, THINK_TICK_INTERVAL_MS);
589
- if (typeof this._thinkTimer.unref === "function") this._thinkTimer.unref();
624
+ _startThinking(text = "", state = "waiting") {
625
+ this._stopThinking();
626
+ if (this.closed || !text) return;
627
+ const revision = this._cueRevision;
628
+ this._thinkTimer = setTimeout(async () => {
629
+ this._thinkTimer = null;
630
+ if (this.closed || this.speaking || this._speakActive ||
631
+ [...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending)) return;
632
+ const controller = new AbortController();
633
+ this._cueController = controller;
634
+ try {
635
+ const cue = await this.acknowledgement(text, state, { signal: controller.signal });
636
+ if (cue && revision === this._cueRevision && !this.closed && !this._speakActive &&
637
+ ![...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending)) {
638
+ await this.speak(cue);
639
+ }
640
+ } catch (_) { /* A missing cue never delays an answer. */ }
641
+ finally { if (this._cueController === controller) this._cueController = null; }
642
+ }, ACK_DELAY_MS);
643
+ this._thinkTimer.unref?.();
590
644
  }
591
645
 
592
646
  _stopThinking() {
593
- if (!this._thinkTimer) return;
594
- clearInterval(this._thinkTimer);
647
+ this._cueRevision += 1;
648
+ if (this._thinkTimer) clearTimeout(this._thinkTimer);
595
649
  this._thinkTimer = null;
650
+ this._cueController?.abort();
651
+ this._cueController = null;
596
652
  }
597
653
 
598
654
  _onFeedRtp(feedId, packet) {
@@ -613,6 +669,28 @@ class KazeeCallSession {
613
669
  sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
614
670
  const event = sub.vad.push(pcm);
615
671
  const pcm16 = downsample48To16(pcm);
672
+ if (sub.stt.protocol === 2 && sub.turnGate) {
673
+ if (event?.type === "start") {
674
+ sub.turnGate.resume();
675
+ this._lastHeard = Date.now();
676
+ this.cancelSpeech();
677
+ this._stopThinking();
678
+ this.onSpeechStart({ feedId });
679
+ for (const frame of sub.prebuffer.splice(0)) {
680
+ sub.turnGate.audio(frame);
681
+ this._feedAudio(sub, frame);
682
+ }
683
+ }
684
+ if (sub.vad.active || event || sub.turnGate.pending) {
685
+ sub.turnGate.audio(pcm16);
686
+ this._feedAudio(sub, pcm16);
687
+ if (event?.type === "commit") sub.turnGate.candidate(event.reason);
688
+ } else {
689
+ sub.prebuffer.push(pcm16);
690
+ if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
691
+ }
692
+ return;
693
+ }
616
694
  if (sub.awaitingWordsSince) {
617
695
  // Energy closed the turn but the recogniser hasn't produced a word yet:
618
696
  // hold the commit and keep streaming audio so a slow decode still lands.
@@ -621,7 +699,8 @@ class KazeeCallSession {
621
699
  // Caller resumed before any words arrived — same turn continues.
622
700
  sub.awaitingWordsSince = 0;
623
701
  this._lastHeard = Date.now();
624
- if (this.speaking) this.cancelSpeech();
702
+ this.cancelSpeech();
703
+ this.onSpeechStart({ feedId });
625
704
  this._stopThinking();
626
705
  return;
627
706
  }
@@ -640,7 +719,8 @@ class KazeeCallSession {
640
719
  this._lastHeard = Date.now();
641
720
  clog(`Kazee call VAD start: feed=${feedId}`);
642
721
  // Barge-in: the human wins — stop talking and listen.
643
- if (this.speaking) this.cancelSpeech();
722
+ this.cancelSpeech();
723
+ this.onSpeechStart({ feedId });
644
724
  this._stopThinking();
645
725
  for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
646
726
  }
@@ -681,6 +761,7 @@ class KazeeCallSession {
681
761
 
682
762
  cancelSpeech() {
683
763
  this._speakGen += 1;
764
+ this._synthController?.abort();
684
765
  for (const item of this._speakQueue.splice(0)) item.resolve(false);
685
766
  }
686
767
 
@@ -691,8 +772,11 @@ class KazeeCallSession {
691
772
  try {
692
773
  while (this._speakQueue.length && !this.closed) {
693
774
  const item = this._speakQueue.shift();
775
+ const controller = new AbortController();
776
+ this._synthController = controller;
694
777
  try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
695
- catch (error) { item.reject(error); }
778
+ catch (error) { if (controller.signal.aborted) item.resolve(false); else item.reject(error); }
779
+ finally { if (this._synthController === controller) this._synthController = null; }
696
780
  }
697
781
  } finally {
698
782
  this._speakActive = false;
@@ -724,7 +808,7 @@ class KazeeCallSession {
724
808
  // Cache only audio rendered by the real service: the key is voice+text, so
725
809
  // PCM from an injected synthesize would leak across sessions.
726
810
  const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
727
- const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE);
811
+ const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE || Object.values(CUE_PHRASES).includes(text));
728
812
  const cacheKey = `${ttsVoice.getVoice()}::${text}`;
729
813
  const synthStart = Date.now();
730
814
  if (cacheable && SYNTH_CACHE.has(cacheKey)) {
@@ -739,7 +823,7 @@ class KazeeCallSession {
739
823
  clog(`Kazee call stream synth failed (${error.message}); buffering`);
740
824
  }
741
825
  }
742
- const synthesized = await this.synthesize(text);
826
+ const synthesized = await this.synthesize(text, { signal: this._synthController?.signal });
743
827
  if (this.closed || gen !== this._speakGen) return false;
744
828
  const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
745
829
  clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
@@ -750,7 +834,7 @@ class KazeeCallSession {
750
834
  // Plays TTS as it downloads: the service streams PCM, but buffering the full
751
835
  // response cost ~3s of silence before the first word on every reply.
752
836
  async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
753
- const { sampleRate, chunks } = await this.synthesizeStream(text);
837
+ const { sampleRate, chunks } = await this.synthesizeStream(text, { signal: this._synthController?.signal });
754
838
  if (this.closed || gen !== this._speakGen) return false;
755
839
  let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
756
840
  let frameCarry = Buffer.alloc(0);
@@ -831,6 +915,9 @@ class KazeeCallSession {
831
915
  this._transportIceExpiry.delete(sub.pc);
832
916
  this._armTransportExpiry();
833
917
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
918
+ for (const segment of sub.segments.values()) clearTimeout(segment.timer);
919
+ sub.segments.clear();
920
+ sub.turnGate?.close();
834
921
  try { sub.stt.close(); } catch (_) {}
835
922
  try { sub.codec.close(); } catch (_) {}
836
923
  try { await sub.pc.close(); } catch (_) {}
@@ -6,58 +6,104 @@ 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, onPartial } = {}) {
9
+ constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial, onDisconnect } = {}) {
10
10
  this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
11
11
  this.token = token;
12
12
  this.WebSocketImpl = WebSocketImpl;
13
13
  this.onTranscript = onTranscript || (() => {});
14
14
  this.onPartial = onPartial || (() => {});
15
+ this.onDisconnect = onDisconnect || (() => {});
15
16
  this.socket = null;
16
17
  this.ready = null;
18
+ this.protocol = 1;
19
+ this._protocolCommitted = false;
20
+ this.segmentId = 1;
21
+ this.streamId = 0;
22
+ this.closed = false;
17
23
  }
18
24
 
19
25
  async connect() {
26
+ if (this.closed) throw new Error("Speech stream is closed");
20
27
  if (this.ready) return this.ready;
21
28
  if (!this.baseUrl || !this.token) throw new Error("Hosted speech service is not configured");
22
29
  const url = this.baseUrl.replace(/^http/, "ws") + "/v1/audio/transcriptions/stream";
23
30
  this.ready = new Promise((resolve, reject) => {
24
31
  const ws = new this.WebSocketImpl(url, { headers: { Authorization: `Bearer ${this.token}` } });
25
32
  this.socket = ws;
33
+ this.segmentId = 1;
34
+ this.protocol = 1;
35
+ this._protocolCommitted = false;
36
+ const streamId = ++this.streamId;
26
37
  ws.once("open", () => {
27
- ws.send(JSON.stringify({ sample_rate: 16000, language: "en" }));
38
+ if (this.closed || this.socket !== ws) return reject(new Error("Speech stream is closed"));
39
+ ws.send(JSON.stringify({ sample_rate: 16000, language: "en", protocol: 2 }));
28
40
  resolve();
29
41
  });
30
42
  ws.once("error", reject);
31
- ws.on("message", (raw) => this._onMessage(raw));
32
- ws.on("close", () => { this.socket = null; this.ready = null; });
43
+ ws.on("message", (raw) => { if (this.socket === ws && !this.closed) this._onMessage(raw, streamId); });
44
+ ws.on("close", () => {
45
+ reject(new Error("Speech stream disconnected"));
46
+ if (this.socket !== ws) return;
47
+ this.socket = null;
48
+ this.ready = null;
49
+ this.protocol = 1;
50
+ this._protocolCommitted = false;
51
+ this.onDisconnect({ streamId });
52
+ });
33
53
  });
34
54
  return this.ready;
35
55
  }
36
56
 
37
57
  async audio(pcm16k) {
38
- await this.connect();
39
- if (this.socket?.readyState === this.WebSocketImpl.OPEN) this.socket.send(pcm16k);
58
+ const pending = this.connect();
59
+ const socket = this.socket;
60
+ await pending;
61
+ if (this.closed || this.socket !== socket) return;
62
+ if (socket?.readyState === this.WebSocketImpl.OPEN) socket.send(pcm16k);
40
63
  }
41
64
 
42
- async commit() {
43
- await this.connect();
44
- this.socket.send(JSON.stringify({ type: "commit" }));
65
+ commitTurn() {
66
+ const connected = this.connect();
67
+ const streamId = this.streamId;
68
+ const segmentId = this.segmentId++;
69
+ // The session has already selected bookkeeping for this protocol. Pin it
70
+ // for this socket before any asynchronous send or delayed ready frame.
71
+ // V2 servers accept V1 commits too, so an early turn can safely retain
72
+ // legacy behavior until reconnect; it cannot switch final-routing mid-turn.
73
+ this._protocolCommitted = true;
74
+ const protocol = this.protocol;
75
+ const pending = connected.then(() => {
76
+ if (this.closed || streamId !== this.streamId || !this.socket) throw new Error("Speech stream disconnected before commit");
77
+ this.socket.send(JSON.stringify({ type: "commit", ...(protocol === 2 ? { segment_id: segmentId } : {}) }));
78
+ });
79
+ return { streamId, segmentId, pending };
45
80
  }
46
81
 
47
- _onMessage(raw) {
82
+ commit() { return this.commitTurn().pending; }
83
+
84
+ _onMessage(raw, streamId = this.streamId) {
48
85
  let message;
49
86
  try { message = JSON.parse(raw.toString()); } catch (_) { return; }
87
+ if (!message || typeof message !== "object" || Array.isArray(message)) return;
88
+ if (message.type === "ready" && message.protocol === 2 && message.segment_id === 1) {
89
+ if (!this._protocolCommitted) this.protocol = 2;
90
+ return;
91
+ }
92
+ const meta = { streamId, segmentId: message.segment_id, protocol: this.protocol };
93
+ if (this.protocol === 2 && (!Number.isSafeInteger(meta.segmentId) || meta.segmentId < 1)) return;
50
94
  const text = String(message.text || message.transcript || "").trim();
51
- if (!text) return;
52
95
  if (message.final === true || message.type === "final" || message.type === "transcription") {
53
- Promise.resolve(this.onTranscript(text)).catch((error) => console.error("Kazee call transcript:", error.message));
54
- } else {
96
+ // Empty finals are meaningful: they close that segment without reusing
97
+ // an earlier partial. The session owns exactly-once dispatch.
98
+ Promise.resolve(this.onTranscript(text, meta)).catch((error) => console.error("Kazee call transcript:", error.message));
99
+ } else if (text && message.type === "partial") {
55
100
  // 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));
101
+ Promise.resolve(this.onPartial(text, meta)).catch((error) => console.error("Kazee call partial:", error.message));
57
102
  }
58
103
  }
59
104
 
60
105
  close() {
106
+ this.closed = true;
61
107
  const ws = this.socket;
62
108
  this.socket = null;
63
109
  this.ready = null;
@@ -66,13 +112,13 @@ class SpeechStream {
66
112
  }
67
113
  }
68
114
 
69
- async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
115
+ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
70
116
  if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
71
117
  const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
72
118
  method: "POST",
73
119
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
74
120
  body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
75
- signal: AbortSignal.timeout(60000),
121
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000),
76
122
  });
77
123
  if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
78
124
  const contentType = response.headers?.get?.("content-type") || "";
@@ -83,13 +129,13 @@ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speec
83
129
 
84
130
  // Same endpoint as synthesizePcm, but yields PCM chunks as they arrive so
85
131
  // 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 } = {}) {
132
+ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
87
133
  if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
88
134
  const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
89
135
  method: "POST",
90
136
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
91
137
  body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
92
- signal: AbortSignal.timeout(90000),
138
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(90000)]) : AbortSignal.timeout(90000),
93
139
  });
94
140
  if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
95
141
  const contentType = response.headers?.get?.("content-type") || "";
@@ -111,4 +157,35 @@ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = sp
111
157
  return { sampleRate: rate, chunks: chunks() };
112
158
  }
113
159
 
114
- module.exports = { SpeechStream, synthesizePcm, synthesizeStream };
160
+ const CUE_PHRASES = Object.freeze({ none: "", acknowledge: "Mhm.", wait: "One moment.", working: "I'm checking that." });
161
+
162
+ async function controllerRequest(path, body, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal, binary = false } = {}) {
163
+ if (!baseUrl || !token) throw new Error("Hosted speech controller is not configured");
164
+ const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/${path}`, {
165
+ method: "POST",
166
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": binary ? "application/octet-stream" : "application/json" },
167
+ body: binary ? body : JSON.stringify(body),
168
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(1800)]) : AbortSignal.timeout(1800),
169
+ });
170
+ if (!response.ok) throw new Error("Hosted speech controller unavailable");
171
+ const result = await response.json();
172
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Invalid controller response");
173
+ return result;
174
+ }
175
+
176
+ async function detectTurn(pcm16, options = {}) {
177
+ const result = await controllerRequest("turn", pcm16, { ...options, binary: true });
178
+ if (typeof result.complete !== "boolean" || typeof result.probability !== "number" ||
179
+ !Number.isFinite(result.probability) || result.probability < 0 || result.probability > 1) throw new Error("Invalid turn decision");
180
+ return { complete: result.complete, probability: result.probability };
181
+ }
182
+
183
+ async function selectAcknowledgement(text, state = "waiting", options = {}) {
184
+ const allowed = { listening: ["none", "acknowledge"], waiting: ["none", "wait"], working: ["none", "working"] }[state];
185
+ if (!allowed) return "";
186
+ const result = await controllerRequest("acknowledgement", { text: String(text).slice(-1000), state }, options);
187
+ // Never speak remote free text. A cue is selected from our fixed dictionary.
188
+ return allowed.includes(result.choice) ? CUE_PHRASES[result.choice] : "";
189
+ }
190
+
191
+ module.exports = { SpeechStream, synthesizePcm, synthesizeStream, detectTurn, selectAcknowledgement, CUE_PHRASES };