@inetafrica/open-claudia 3.4.14 → 3.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.16
4
+
5
+ - Start live-call answer generation without waiting for an auxiliary memory-relevance model. Calls keep local matches from the current utterance, bounded spoken history, and full policy; context-only keyword matches remain excluded without a relevance judgment. Ordinary chat filtering and explicit on-demand recall are unchanged.
6
+ - Log live-call provider preparation, prompt readiness, process start and completion with elapsed time, so recognition delay can be distinguished from reply startup and generation.
7
+ - Reproduce the call-path regression through the actual prompt builder and classic matcher, covering both configured recall engines, context-only exclusions, ordinary chat filtering and on-demand recall.
8
+
9
+ ## 3.4.15
10
+
11
+ - Preserve complete Kazee speech turns with protocol-2 segment matching, bounded pending finals, and cancellation when speech resumes during an endpoint decision. Keep compatibility with older speech servers, including a delayed protocol-ready message.
12
+ - Stop obsolete speech and remove superseded queued call turns on interruption or hangup. Use a fresh provider conversation with bounded spoken history while preserving identity, policy and recall. In-progress tools continue under their existing lifecycle.
13
+ - Add `open-claudia call-say` for explicit complete answer sentences before the provider finishes. Each request is bound to the active call and run, redacted, and checked by the outbound reply guard. Retries are idempotent; final delivery removes the exact prefix already spoken. Provider previews and reasoning remain unspoken; this does not add token streaming to the provider transport.
14
+ - Replace repeated thinking sounds with optional delayed fixed cues from the separate speech controller. Endpoint and cue models require the corresponding speech-service deployment; model quality, live latency and Android acceptance remain separate verification gates.
15
+ - Isolate permission-test fixtures from inherited test-launcher approval without changing production permission gates.
16
+
3
17
  ## 3.4.14
4
18
 
5
19
  - Subscribe to managed call audio only when gateway-authenticated publisher metadata identifies a User. Exclude bots, the current bot's other feeds, and missing or malformed classifications before creating subscriber connections or speech streams.
package/bin/cli.js CHANGED
@@ -277,6 +277,33 @@ switch (command) {
277
277
  break;
278
278
  }
279
279
 
280
+ case "call-say": {
281
+ if (args[1] === "--help" || args[1] === "-h") {
282
+ console.log("Usage: open-claudia call-say [--id <request-id>] <text>\nOr: open-claudia call-say [--id <request-id>] --stdin\nSpeak one checked 1–450 character chunk into the current call turn. Wait for completion; reuse an ID only when retrying identical text.");
283
+ break;
284
+ }
285
+ const parts = args.slice(1);
286
+ let requestId = require("crypto").randomUUID();
287
+ if (parts[0] === "--id") { parts.shift(); requestId = parts.shift(); }
288
+ const text = parts.length === 1 && parts[0] === "--stdin"
289
+ ? require("fs").readFileSync(0, "utf8").trim() : parts.join(" ").trim();
290
+ if (!text || text.length > 450 || !/^[A-Za-z0-9_-]{1,80}$/.test(requestId || "")) {
291
+ console.error("Provide a valid request ID and 1–450 characters of spoken text.");
292
+ process.exit(2);
293
+ }
294
+ (async () => {
295
+ try {
296
+ const result = await require("./loopback-client").postJson("call-say", { text, requestId });
297
+ if (result.ok !== true || result.spoken !== true) throw new Error("Speech was not completed");
298
+ console.log(result.duplicate ? "Already spoken." : "Spoken.");
299
+ } catch (error) {
300
+ console.error(error.message);
301
+ process.exitCode = 1;
302
+ }
303
+ })();
304
+ break;
305
+ }
306
+
280
307
  case "say": {
281
308
  // Agents probe with --help before speaking; don't synthesize the flag.
282
309
  if (args[1] === "--help" || args[1] === "-h") {
@@ -563,6 +590,7 @@ Send tools (only work inside an active bot-spawned task):
563
590
  open-claudia send-photo <path> [caption]
564
591
  open-claudia send-voice <path>
565
592
  open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
593
+ open-claudia call-say "<text>" Speak a checked sentence into the active call turn
566
594
  open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
567
595
  narration; ext picks format; --voice <id> optional)
568
596
  open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
@@ -75,6 +75,7 @@ function postJson(kind, body) {
75
75
  const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
76
76
  const payload = JSON.stringify({
77
77
  ...body,
78
+ ...(kind === "call-say" ? { callSpeechToken: process.env.OC_CALL_SPEECH_TOKEN || "" } : {}),
78
79
  channelId: ctx.channelId,
79
80
  adapter: ctx.adapterId,
80
81
  userId: ctx.userId,
@@ -86,13 +86,21 @@ class KazeeCallManager {
86
86
  }
87
87
 
88
88
  _sessionEnded(session, reason) {
89
+ // Several terminal paths clear this.session before close() calls back.
90
+ // Invalidate the closed call's work even when it no longer owns the slot.
91
+ session?._voiceTurn?.controller.abort("call-ended");
89
92
  if (this.session !== session) return;
90
- this.session = null;
93
+ this._detachSession(session);
91
94
  if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
92
95
  ...session.callIdentity, chatId: session.chatId, toUserId: session.callerId, reason,
93
96
  });
94
97
  }
95
98
 
99
+ _detachSession(session) {
100
+ session?._voiceTurn?.controller.abort("call-ended");
101
+ if (this.session === session) this.session = null;
102
+ }
103
+
96
104
  _manageLease(session, access) {
97
105
  if (!access) return;
98
106
  const socket = this.adapter._socket;
@@ -103,7 +111,7 @@ class KazeeCallManager {
103
111
  if (this.session !== session || session.closed) return;
104
112
  console.warn(`Kazee managed call lease ${reason}`);
105
113
  socket.emit("call:end", { ...session.callIdentity, deviceId: this.deviceId, chatId: session.chatId, toUserId: session.callerId, reason: "network-timeout" });
106
- this.session = null;
114
+ this._detachSession(session);
107
115
  session.close(`lease-${reason}`).catch(() => console.error("Kazee call lease cleanup failed"));
108
116
  }, access.leaseExpiresAt, randomUUID);
109
117
  }
@@ -141,7 +149,8 @@ class KazeeCallManager {
141
149
  const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
142
150
  session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
143
151
  ...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
144
- onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta),
152
+ onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta, session),
153
+ onSpeechStart: () => this._speechStart(session),
145
154
  onMediaReady: () => this._mediaReady(session),
146
155
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
147
156
  });
@@ -156,7 +165,7 @@ class KazeeCallManager {
156
165
  if (session._mediaReady) session._leaseHeartbeat?.activate();
157
166
  } catch (_) {
158
167
  const cancelled = pending.controller.signal.aborted;
159
- if (this.session === session) this.session = null;
168
+ this._detachSession(session);
160
169
  if (session) await session.close("connect-failed").catch(() => {});
161
170
  if (!cancelled) reject("connection_failed");
162
171
  } finally { if (this._pending === pending) this._pending = null; }
@@ -187,7 +196,8 @@ class KazeeCallManager {
187
196
  const remote = { ...identity, chatId: chat, fromUserId: target, fromUserName: "" };
188
197
  session = new this.SessionImpl({ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
189
198
  ...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
190
- onTranscript: (text, meta) => this._transcript(text, remote, meta),
199
+ onTranscript: (text, meta) => this._transcript(text, remote, meta, session),
200
+ onSpeechStart: () => this._speechStart(session),
191
201
  onMediaReady: () => this._mediaReady(session),
192
202
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
193
203
  });
@@ -202,7 +212,7 @@ class KazeeCallManager {
202
212
  this._ringTimer.unref?.();
203
213
  return { ...identity, chatId: chat, toUserId: target };
204
214
  } catch (error) {
205
- if (this.session === session) this.session = null;
215
+ this._detachSession(session);
206
216
  if (session) await session.close("connect-failed").catch(() => {});
207
217
  throw error;
208
218
  } finally { if (this._pending === pending) this._pending = null; }
@@ -223,7 +233,7 @@ class KazeeCallManager {
223
233
  // Caller hanging up while ringing: chat-central finalizes the CallLog as
224
234
  // missed and dismisses the callee's ringing UI/push.
225
235
  this.adapter._socket?.emit("call:end", { ...session.callIdentity, toUserId, chatId });
226
- this.session = null;
236
+ this._detachSession(session);
227
237
  await session.close("no-answer").catch(() => {});
228
238
  }
229
239
 
@@ -231,23 +241,65 @@ class KazeeCallManager {
231
241
  if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
232
242
  }
233
243
 
234
- _transcript(text, incoming, meta = {}) {
244
+ _speechStart(session) {
245
+ if (session?._voiceTurn) session._voiceTurn.muted = true;
246
+ // Sound alone is not a new request. Cancel queued work only once a new
247
+ // complete transcript exists; a cough must not discard an accepted ask.
248
+ }
249
+
250
+ _rememberSpeech(session, role, text) {
251
+ const history = session._voiceHistory ||= [];
252
+ if (role === "assistant" && history.at(-1)?.role === "assistant") {
253
+ history.at(-1).text = `${history.at(-1).text} ${text}`.slice(-6000);
254
+ } else history.push({ role, text: String(text).slice(-6000) });
255
+ while (history.length > 8 || history.reduce((size, entry) => size + entry.text.length, 0) > 12000) history.shift();
256
+ }
257
+
258
+ _transcript(text, incoming, meta = {}, sourceSession = this.session) {
235
259
  const session = this.session;
236
- if (!session || !text) return;
260
+ if (!session || session.closed || sourceSession !== session || !text) return;
237
261
  // In a group room, tell the provider who is talking; 1:1 stays unprefixed.
238
262
  const speaker = String(meta.display || "").trim();
239
263
  const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
264
+ session._voiceTurn?.controller.abort("superseded");
265
+ const turn = { controller: new AbortController(), muted: false };
266
+ session._voiceTurn = turn;
240
267
  const callAdapter = Object.create(this.adapter);
268
+ callAdapter.callHistory = (session._voiceHistory || []).map(entry => ({ ...entry }));
269
+ callAdapter.callTurn = { signal: turn.controller.signal };
270
+ this._rememberSpeech(session, "user", attributed);
241
271
  // Provider narration/previews are useful in chat but must never be spoken
242
- // into a call. Only the settled, guarded final answer reaches send().
272
+ // into a call. send() receives settled answers; explicit sentence speech
273
+ // enters separately through the call-turn lease and outbound reply guard.
243
274
  callAdapter.suppressStreamPreview = true;
244
275
  // Recall/tooltrace banners route through the same send() as the answer —
245
276
  // without this flag the TTS reads memory-debug lines to the caller.
246
277
  callAdapter.suppressChatVisibility = true;
247
278
  // Prompt builder switches to phone-call brevity when it sees this.
248
279
  callAdapter.liveCall = true;
280
+ const spokenSentences = [];
281
+ callAdapter.isCallTurnActive = () => !turn.muted && !turn.controller.signal.aborted &&
282
+ !session.closed && this.session === session && session._voiceTurn === turn;
283
+ callAdapter.cancelCallSpeech = () => {
284
+ if (this.session === session && session._voiceTurn === turn) session.cancelSpeech();
285
+ };
286
+ const speak = async response => {
287
+ if (!callAdapter.isCallTurnActive()) return false;
288
+ const spoken = await session.speak(response);
289
+ if (spoken !== false && callAdapter.isCallTurnActive()) {
290
+ this._rememberSpeech(session, "assistant", response);
291
+ return true;
292
+ }
293
+ return false;
294
+ };
295
+ callAdapter.speakSentence = async response => {
296
+ const spoken = await speak(response);
297
+ if (spoken) spokenSentences.push(response);
298
+ return spoken;
299
+ };
249
300
  callAdapter.send = async (_channelId, response) => {
250
- await session.speak(response);
301
+ const remainder = require("../../../core/call-speech").unspokenSuffix(response, spokenSentences);
302
+ if (remainder) await speak(remainder);
251
303
  return true;
252
304
  };
253
305
  callAdapter.edit = async () => true;
@@ -275,7 +327,7 @@ class KazeeCallManager {
275
327
  if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
276
328
  // Terminal server state is authoritative; ended attempts are never revived.
277
329
  const session = this.session;
278
- this.session = null;
330
+ this._detachSession(session);
279
331
  await session.close("remote-ended");
280
332
  }
281
333
 
@@ -283,7 +335,7 @@ class KazeeCallManager {
283
335
  if (!this.session || !this._matches(payload)) return;
284
336
  if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
285
337
  const session = this.session;
286
- this.session = null;
338
+ this._detachSession(session);
287
339
  await session.close("remote-rejected");
288
340
  }
289
341
 
@@ -292,7 +344,7 @@ class KazeeCallManager {
292
344
  this._clearRingTimer();
293
345
  if (!this.session) return;
294
346
  const session = this.session;
295
- this.session = null;
347
+ this._detachSession(session);
296
348
  await session.close("adapter-stopped");
297
349
  }
298
350
  }
@@ -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;
@@ -392,8 +376,9 @@ class KazeeCallSession {
392
376
  codec: new OpusAudioCodec(),
393
377
  vad: new AdaptiveTurnDetector(),
394
378
  stt: new this.SpeechStreamImpl({
395
- onTranscript: (text) => this._onFeedFinal(feedId, text),
396
- 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),
397
382
  }),
398
383
  prebuffer: [],
399
384
  pending: [],
@@ -407,7 +392,9 @@ class KazeeCallSession {
407
392
  noiseCommit: false,
408
393
  fallbackUsed: false,
409
394
  finalTimer: null,
395
+ segments: new Map(),
410
396
  };
397
+ sub.turnGate = new SpeechTurnGate({ decide: this.turnDecision, commit: reason => this._commitTurn(feedId, reason) });
411
398
  this.subscribers.set(feedId, sub);
412
399
  pc.onTrack.subscribe((track) => {
413
400
  if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
@@ -452,9 +439,26 @@ class KazeeCallSession {
452
439
  }
453
440
  }
454
441
 
455
- _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 = {}) {
456
459
  const sub = this.subscribers.get(feedId);
457
460
  if (!sub || this.closed) return;
461
+ if (meta.protocol === 2 && (meta.streamId !== sub.stt.streamId || meta.segmentId !== sub.stt.segmentId)) return;
458
462
  const partial = String(text || "");
459
463
  const prevWords = countWords(sub.lastPartial);
460
464
  const words = countWords(partial);
@@ -469,7 +473,7 @@ class KazeeCallSession {
469
473
  }
470
474
  sub.wordCount += words - prevWords;
471
475
  sub.lastWordAt = now;
472
- if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
476
+ if (sub.awaitingWordsSince && sub.stt.protocol !== 2) this._commitTurn(feedId, "words-arrived");
473
477
  }
474
478
 
475
479
  // Interims arrive in bursts whose cadence tracks recogniser health (jig
@@ -485,23 +489,49 @@ class KazeeCallSession {
485
489
  const sub = this.subscribers.get(feedId);
486
490
  if (!sub || this.closed) return;
487
491
  sub.awaitingWordsSince = 0;
492
+ sub.turnGate?.reset();
488
493
  if (sub.vad.active) sub.vad.reset();
489
494
  this._lastHeard = Date.now();
490
495
  clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
491
- sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
492
- if (!sub.noiseCommit) {
493
- this._playCue(ACK_CHIME_PCM48).catch(() => {});
494
- // STT finalization takes seconds; start ticking at commit so the
495
- // chime→first-tick stretch doesn't read as a dead line.
496
- this._startThinking();
497
- }
498
- // Finals routinely never arrive; after finalWaitMs the interim transcript
499
- // 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.
500
528
  sub.partialAtCommit = sub.lastPartial;
529
+ sub.lastPartial = "";
501
530
  sub.fallbackUsed = false;
502
531
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
503
- sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
504
- 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;
505
535
  sub.wordCount = 0;
506
536
  sub.lastWordAt = 0;
507
537
  sub.wordGaps = [];
@@ -511,19 +541,28 @@ class KazeeCallSession {
511
541
  const sub = this.subscribers.get(feedId);
512
542
  if (!sub || this.closed) return;
513
543
  sub.finalTimer = null;
514
- // Partials keep growing after commit while the decoder catches up; accept
515
- // the grown text only if it extends the committed snapshot (anything else
516
- // belongs to the next turn).
517
- 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.
518
546
  const snapshot = sub.partialAtCommit || "";
519
- const text = current.length > snapshot.length && current.startsWith(snapshot) ? current : snapshot;
547
+ const text = snapshot;
520
548
  sub.fallbackUsed = true;
521
549
  clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
522
550
  this._dispatchTranscript(feedId, text);
523
551
  }
524
552
 
525
- _onFeedFinal(feedId, text) {
553
+ _onFeedFinal(feedId, text, meta = {}) {
526
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
+ }
527
566
  if (sub?.finalTimer) {
528
567
  clearTimeout(sub.finalTimer);
529
568
  sub.finalTimer = null;
@@ -536,11 +575,11 @@ class KazeeCallSession {
536
575
  this._dispatchTranscript(feedId, text);
537
576
  }
538
577
 
539
- _dispatchTranscript(feedId, text) {
578
+ _dispatchTranscript(feedId, text, options = {}) {
540
579
  const sub = this.subscribers.get(feedId);
541
580
  const trimmed = String(text || "").trim();
542
- if (sub?.noiseCommit) {
543
- sub.noiseCommit = false;
581
+ if (options.noiseCommit ?? sub?.noiseCommit) {
582
+ if (options.noiseCommit === undefined) sub.noiseCommit = false;
544
583
  // A turn the recogniser never put words in was background noise; babble
545
584
  // occasionally leaks a single stray word into the transcript too.
546
585
  if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
@@ -550,14 +589,16 @@ class KazeeCallSession {
550
589
  }
551
590
  }
552
591
  clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
553
- if (trimmed) this._startThinking();
592
+ if (trimmed) this._startThinking(trimmed);
554
593
  // Empty finalization (noise commit): nothing dispatches, no reply is
555
594
  // coming — don't tick into the void.
556
595
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
596
+ if (!trimmed) return;
557
597
  this.onTranscript(text, {
558
598
  feedId,
559
599
  display: sub?.display || "",
560
600
  participants: this.subscribers.size,
601
+ ...(options.utteranceId ? { utteranceId: options.utteranceId } : {}),
561
602
  });
562
603
  }
563
604
 
@@ -580,32 +621,34 @@ class KazeeCallSession {
580
621
  this._reportMediaReady();
581
622
  }
582
623
 
583
- // Writes a short cue into the outbound track. Yields to real speech: the
584
- // moment an utterance is active (or starting), remaining frames are dropped.
585
- async _playCue(pcm48) {
586
- if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
587
- for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
588
- if (this.closed || this.speaking || this._speakActive) return;
589
- this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
590
- await wait(20);
591
- }
592
- }
593
-
594
- _startThinking() {
595
- if (this.closed || this._thinkTimer) return;
596
- this._thinkingSince = Date.now();
597
- this._thinkTimer = setInterval(() => {
598
- if (this.closed || Date.now() - this._thinkingSince > THINKING_MAX_MS) return this._stopThinking();
599
- if (this.speaking || this._speakActive) return;
600
- this._playCue(THINK_TICK_PCM48).catch(() => {});
601
- }, THINK_TICK_INTERVAL_MS);
602
- 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?.();
603
644
  }
604
645
 
605
646
  _stopThinking() {
606
- if (!this._thinkTimer) return;
607
- clearInterval(this._thinkTimer);
647
+ this._cueRevision += 1;
648
+ if (this._thinkTimer) clearTimeout(this._thinkTimer);
608
649
  this._thinkTimer = null;
650
+ this._cueController?.abort();
651
+ this._cueController = null;
609
652
  }
610
653
 
611
654
  _onFeedRtp(feedId, packet) {
@@ -626,6 +669,28 @@ class KazeeCallSession {
626
669
  sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
627
670
  const event = sub.vad.push(pcm);
628
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
+ }
629
694
  if (sub.awaitingWordsSince) {
630
695
  // Energy closed the turn but the recogniser hasn't produced a word yet:
631
696
  // hold the commit and keep streaming audio so a slow decode still lands.
@@ -634,7 +699,8 @@ class KazeeCallSession {
634
699
  // Caller resumed before any words arrived — same turn continues.
635
700
  sub.awaitingWordsSince = 0;
636
701
  this._lastHeard = Date.now();
637
- if (this.speaking) this.cancelSpeech();
702
+ this.cancelSpeech();
703
+ this.onSpeechStart({ feedId });
638
704
  this._stopThinking();
639
705
  return;
640
706
  }
@@ -653,7 +719,8 @@ class KazeeCallSession {
653
719
  this._lastHeard = Date.now();
654
720
  clog(`Kazee call VAD start: feed=${feedId}`);
655
721
  // Barge-in: the human wins — stop talking and listen.
656
- if (this.speaking) this.cancelSpeech();
722
+ this.cancelSpeech();
723
+ this.onSpeechStart({ feedId });
657
724
  this._stopThinking();
658
725
  for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
659
726
  }
@@ -694,6 +761,7 @@ class KazeeCallSession {
694
761
 
695
762
  cancelSpeech() {
696
763
  this._speakGen += 1;
764
+ this._synthController?.abort();
697
765
  for (const item of this._speakQueue.splice(0)) item.resolve(false);
698
766
  }
699
767
 
@@ -704,8 +772,11 @@ class KazeeCallSession {
704
772
  try {
705
773
  while (this._speakQueue.length && !this.closed) {
706
774
  const item = this._speakQueue.shift();
775
+ const controller = new AbortController();
776
+ this._synthController = controller;
707
777
  try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
708
- 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; }
709
780
  }
710
781
  } finally {
711
782
  this._speakActive = false;
@@ -737,7 +808,7 @@ class KazeeCallSession {
737
808
  // Cache only audio rendered by the real service: the key is voice+text, so
738
809
  // PCM from an injected synthesize would leak across sessions.
739
810
  const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
740
- 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));
741
812
  const cacheKey = `${ttsVoice.getVoice()}::${text}`;
742
813
  const synthStart = Date.now();
743
814
  if (cacheable && SYNTH_CACHE.has(cacheKey)) {
@@ -752,7 +823,7 @@ class KazeeCallSession {
752
823
  clog(`Kazee call stream synth failed (${error.message}); buffering`);
753
824
  }
754
825
  }
755
- const synthesized = await this.synthesize(text);
826
+ const synthesized = await this.synthesize(text, { signal: this._synthController?.signal });
756
827
  if (this.closed || gen !== this._speakGen) return false;
757
828
  const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
758
829
  clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
@@ -763,7 +834,7 @@ class KazeeCallSession {
763
834
  // Plays TTS as it downloads: the service streams PCM, but buffering the full
764
835
  // response cost ~3s of silence before the first word on every reply.
765
836
  async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
766
- const { sampleRate, chunks } = await this.synthesizeStream(text);
837
+ const { sampleRate, chunks } = await this.synthesizeStream(text, { signal: this._synthController?.signal });
767
838
  if (this.closed || gen !== this._speakGen) return false;
768
839
  let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
769
840
  let frameCarry = Buffer.alloc(0);
@@ -844,6 +915,9 @@ class KazeeCallSession {
844
915
  this._transportIceExpiry.delete(sub.pc);
845
916
  this._armTransportExpiry();
846
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();
847
921
  try { sub.stt.close(); } catch (_) {}
848
922
  try { sub.codec.close(); } catch (_) {}
849
923
  try { await sub.pc.close(); } catch (_) {}