@inetafrica/open-claudia 3.5.47 → 3.5.58

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +62 -10
  2. package/bin/cli.js +10 -3
  3. package/bot.js +11 -0
  4. package/channels/kazee/adapter.js +220 -23
  5. package/channels/kazee/call/camera.js +233 -0
  6. package/channels/kazee/call/job-steps.js +81 -0
  7. package/channels/kazee/call/manager.js +1056 -27
  8. package/channels/kazee/call/miss-capture.js +82 -0
  9. package/channels/kazee/call/quick-voice.js +610 -0
  10. package/channels/kazee/call/session.js +787 -36
  11. package/channels/kazee/call/thread-context.js +100 -0
  12. package/channels/kazee/catch-up.js +275 -0
  13. package/core/actions.js +13 -4
  14. package/core/handlers.js +14 -16
  15. package/core/identity.js +15 -1
  16. package/core/loopback.js +4 -1
  17. package/core/media.js +39 -1
  18. package/core/run-context.js +27 -3
  19. package/core/runner.js +70 -1
  20. package/core/state.js +6 -2
  21. package/core/switchboard/brain-link.js +161 -0
  22. package/core/switchboard/call-host.js +298 -0
  23. package/core/switchboard/handoff.js +80 -0
  24. package/core/switchboard/ipc.js +130 -0
  25. package/core/switchboard/supervisor.js +225 -0
  26. package/core/system-prompt.js +44 -4
  27. package/core/transcripts.js +39 -0
  28. package/core/tts-voice.js +62 -4
  29. package/core/turn-journal.js +23 -1
  30. package/package.json +2 -2
  31. package/test-call-speech.js +7 -0
  32. package/test-fixtures/switchboard-fake-brain.js +20 -0
  33. package/test-kazee-call-manager.js +22 -4
  34. package/test-kazee-call-session.js +345 -8
  35. package/test-provider-compaction.js +8 -0
  36. package/test-provider-language.js +17 -9
  37. package/test-provider-prompt-parity.js +1 -0
  38. package/test-provider-runner.js +2 -0
  39. package/test-run-lock.js +24 -0
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
 
3
- const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
3
+ const { MediaStreamTrack, RTCPeerConnection, RTCRtpHeaderExtensionParameters, RtpHeader, RtpPacket } = require("werift");
4
4
  const { JanusClient } = require("./janus-client");
5
5
  const { projectAssignedIce, supportedIceServers } = require("./access");
6
- const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
6
+ const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48, rmsDbfs } = require("./audio");
7
7
  const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm, detectTurn, selectAcknowledgement, CUE_PHRASES } = require("./speech-client");
8
8
  const { SpeechTurnGate } = require("./turn-gate");
9
+ const { CameraStills, VIDEO_ORIENTATION_URI, orientationExtensionId } = require("./camera");
10
+ const path = require("path");
11
+ const { MAX_SEGMENT_BYTES, missDir, segmentLevel, keepMiss, settings } = require("./miss-capture");
9
12
  const ttsVoice = require("../../../core/tts-voice");
10
13
 
11
14
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -22,6 +25,31 @@ const SYNTH_CACHE = new Map();
22
25
  // double as the STT/TTS timing measurements chat-side estimates lacked.
23
26
  const clog = (msg) => console.log(`${new Date().toISOString()} ${msg}`);
24
27
 
28
+ // What came in from a caller, for the close log (13 Oct: a call heard no
29
+ // words while packets arrived; the log could not say whether the audio was
30
+ // silent, undecodable, or never loud enough for the voice gate). Levels are
31
+ // whole dBFS per 20 ms frame, counted from -100 to 0.
32
+ function newHeard() { return { frames: 0, decodeErrors: 0, starts: 0, hist: new Array(101).fill(0) }; }
33
+ function noteHeard(heard, db) {
34
+ heard.frames += 1;
35
+ const at = Number.isFinite(db) ? Math.max(-100, Math.min(0, Math.round(db))) : -100;
36
+ heard.hist[at + 100] += 1;
37
+ }
38
+ function heardPercentile(heard, q) {
39
+ if (!heard.frames) return null;
40
+ const want = Math.max(1, Math.ceil(heard.frames * q));
41
+ for (let i = 0, seen = 0; i < heard.hist.length; i += 1) {
42
+ seen += heard.hist[i];
43
+ if (seen >= want) return i - 100;
44
+ }
45
+ return 0;
46
+ }
47
+ function heardSummary(heard, thresholdDb) {
48
+ const p = (q) => { const v = heardPercentile(heard, q); return v === null ? "none" : `${v}dB`; };
49
+ return `frames=${heard.frames} decode-errors=${heard.decodeErrors} p10=${p(0.1)} p50=${p(0.5)} p90=${p(0.9)} max=${p(1)} ` +
50
+ `voice-starts=${heard.starts} gate=${Number.isFinite(thresholdDb) ? Math.round(thresholdDb) : "none"}dB`;
51
+ }
52
+
25
53
  // While TTS plays, the caller's device can leak bot audio back through its mic.
26
54
  // Require a much louder signal before treating incoming audio as human speech.
27
55
  const SPEAKING_THRESHOLD_DB = -26;
@@ -47,14 +75,158 @@ const SEGMENT_FINAL_WAIT_MS = 12000;
47
75
  const MIN_DISPATCH_WORDS = 2;
48
76
  const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
49
77
  const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
78
+ // Waiting on the caller and hearing no words, only sound or silence (Sumeet,
79
+ // 12 Oct: the 06:47 call went silent for 100 s while his words were lost):
80
+ // ask twice, then say goodbye and hang up unless words arrive in the grace.
81
+ // Never while work runs or anyone is speaking.
82
+ const CHECK_IN_MS = 10_000;
83
+ const CHECK_IN_TICK_MS = 1000;
84
+ const CHECK_IN_GRACE_MS = 5000;
85
+ const CHECK_IN_SOUND_GRACE_MS = 3000;
86
+ const CHECK_IN_LINES = ["Are you still there? I can't hear anything.", "I still can't hear you. Are you there?"];
87
+ const NO_WORDS_GOODBYE = "I haven't been able to hear anything for a while, so I'll end this call. Call me back if you still need me.";
88
+
89
+ // The caller's line, not the caller (Sumeet, 12 Oct 07:40): their audio
90
+ // packets stopped. A phone sends one every 20 ms while the mic is open, and
91
+ // at least every 400 ms even when it saves data in silence (Opus DTX), so 3 s
92
+ // without one is the line. The moment they come back I say so, with the last
93
+ // words I heard; after 30 s I give up and hang up.
94
+ const DROP_MS = 3000;
95
+ const DROP_GIVE_UP_MS = 30_000;
96
+ const LINE_BACK_LEAD = "Sorry, I lost you there for a moment.";
97
+ const LINE_BACK_NOTHING = `${LINE_BACK_LEAD} Can you hear me now?`;
98
+ const LINE_BACK_RESUME = "As I was saying,";
99
+ const LINE_BACK_WORDS = 12;
100
+ // A soft hiss while I am not talking (Sumeet, 12 Oct 07:40), so a quiet line
101
+ // still sounds alive and a dead one sounds dead. KAZEE_CALL_HUM sets its level
102
+ // in dBFS, or "off".
103
+ const HUM_DB = -55;
104
+ // Speech writes a frame every 20 ms; the hiss fills only real gaps.
105
+ const HUM_AFTER_SPEECH_MS = 60;
106
+ // A speech frame this quiet (peak) carries nothing audible.
107
+ const SILENT_FRAME_PEAK = 33;
108
+ // With the hiss on, the bot should never go this long without sending.
109
+ const SENT_GAP_MS = 1000;
110
+ // A caller's app labels its audio stream when they mute or hold: Janus stops
111
+ // forwarding a muted caller's audio and tells nobody, so without the label a
112
+ // mute looked exactly like a dropped line (12 Oct). Janus passes a stream's
113
+ // description on to everyone in the room. Apps without it act as before.
114
+ const AUDIO_OFF_LABELS = new Set(["muted", "held"]);
115
+ const HUM_FRAMES = 100;
116
+
117
+ function humLevel(env = settings()) {
118
+ const raw = String(env.KAZEE_CALL_HUM ?? "").trim();
119
+ if (/^(off|false|no|0)$/i.test(raw)) return null;
120
+ const db = Number(raw);
121
+ return raw && Number.isFinite(db) && db < -20 ? db : HUM_DB;
122
+ }
123
+
124
+ // Two seconds of soft, slightly muffled noise at the given level, as 20 ms
125
+ // frames to loop.
126
+ function humFrames(db, frames = HUM_FRAMES) {
127
+ const samples = WEBRTC_SAMPLES * frames;
128
+ const raw = new Float64Array(samples);
129
+ let low = 0;
130
+ let sum = 0;
131
+ for (let i = 0; i < samples; i += 1) {
132
+ low += 0.3 * ((Math.random() * 2 - 1) - low);
133
+ raw[i] = low;
134
+ sum += low * low;
135
+ }
136
+ const scale = (32768 * 10 ** (db / 20)) / Math.sqrt(sum / samples || 1);
137
+ const pcm = Buffer.alloc(samples * 2);
138
+ for (let i = 0; i < samples; i += 1) pcm.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(raw[i] * scale))), i * 2);
139
+ return Array.from({ length: frames }, (_, k) => pcm.subarray(k * WEBRTC_SAMPLES * 2, (k + 1) * WEBRTC_SAMPLES * 2));
140
+ }
141
+
142
+ // Never holds the process open (a test or a closing bot).
143
+ const softWait = (ms) => new Promise((resolve) => { const timer = setTimeout(resolve, ms); timer.unref?.(); });
50
144
 
51
145
  // One optional spoken cue after a delay, never periodic beeps.
52
146
  const ACK_DELAY_MS = 2800;
147
+ // Cut-off words (cutSpeech): kept this long for the manager to say again.
148
+ const CUT_KEEP_MS = 30_000;
149
+ // Mary on Pocket TTS speaks ~16 characters a second (11 Oct, 370 chars in
150
+ // 23.1 s); a little under, so the resume starts no later than the cut.
151
+ const SPOKEN_CHARS_PER_SECOND = 14;
152
+ // A line stopped with this little of it left (by the estimate above) counts
153
+ // as heard: nothing is kept to say again.
154
+ const HEARD_ENOUGH_LEFT_MS = 1500;
155
+ // Words stop us, not sound (11 Oct 17:51: chewing stopped every reply, and a
156
+ // sound gate fails in public or with a film on). Over our speech the caller's
157
+ // turn starts at this many real words in the interim transcript; when we are
158
+ // quiet, at one. Pipecat's MinWordsUserTurnStartStrategy counts the same way.
159
+ const INTERRUPT_WORDS = 2;
160
+ const FILLER_WORDS = new Set(["um", "umm", "uh", "uhh", "uhm", "erm", "er", "ah", "ahh", "oh", "mm", "mmm", "hmm", "hm", "mhm", "uh-huh", "huh", "ha"]);
161
+
162
+ const wordList = (text) => String(text || "").toLowerCase().replace(/[^\p{L}\p{N}'\s-]/gu, " ").split(/\s+/).filter(Boolean);
163
+
164
+ // Words someone really said: no fillers, and none of our own line (spoken)
165
+ // coming back through their speaker. A word is ours when it and a neighbour
166
+ // form a pair our line has; the caller's own words after it still count.
167
+ function realWords(text, spoken = "") {
168
+ const words = wordList(text).filter((word) => !FILLER_WORDS.has(word));
169
+ if (words.length < 2 || !spoken) return words.length;
170
+ const ours = wordList(spoken);
171
+ const pairs = new Set(ours.slice(1).map((word, i) => `${ours[i]} ${word}`));
172
+ const echo = words.map(() => false);
173
+ for (let i = 1; i < words.length; i += 1) {
174
+ if (pairs.has(`${words[i - 1]} ${words[i]}`)) echo[i - 1] = echo[i] = true;
175
+ }
176
+ return echo.filter((isEcho) => !isEcho).length;
177
+ }
178
+
179
+ // What an offer carries, for the log: "audio:0:sendonly,video:1:port0".
180
+ function sdpStreams(sdp) {
181
+ const out = [];
182
+ for (const block of String(sdp || "").split(/\r?\n(?=m=)/)) {
183
+ const m = block.match(/^m=(\w+) (\d+)/);
184
+ if (!m) continue;
185
+ const mid = block.match(/\na=mid:(\S+)/)?.[1] ?? "?";
186
+ const dir = block.match(/\na=(sendrecv|sendonly|recvonly|inactive)\b/)?.[1];
187
+ out.push(`${m[1]}:${mid}${m[2] === "0" ? ":port0" : ""}${dir ? `:${dir}` : ""}`);
188
+ }
189
+ return out.join(",") || "none";
190
+ }
191
+
192
+ // The caller's stream mids a subscriber offer really carries: from the
193
+ // offer's streams list, minus m-lines Janus left inactive or with port 0
194
+ // (a stream the caller had stopped). Same rule as the web app's
195
+ // callMediaTracks.ts observe.
196
+ function receivedMids(data, sdp) {
197
+ const sections = String(sdp || "").split(/\r?\n(?=m=)/).slice(1);
198
+ const mids = new Set();
199
+ for (const stream of data?.streams || []) {
200
+ const section = sections[Number(stream?.mindex)];
201
+ if (!section || stream.feed_mid == null || stream.active === false) continue;
202
+ if (/^m=\S+ 0 /.test(section) || /\na=inactive\r?(?:\n|$)/.test(section)) continue;
203
+ mids.add(String(stream.feed_mid));
204
+ }
205
+ return mids;
206
+ }
207
+
208
+ // The part of a line not yet heard after heardMs of playback, from the start
209
+ // of the sentence it was cut in (a restart mid-word is hard to follow).
210
+ // Under 1.5 s in, the whole line.
211
+ function unheardText(text, heardMs) {
212
+ const full = String(text || "").trim();
213
+ if (!full || heardMs < 1500) return full;
214
+ const heardChars = (heardMs / 1000) * SPOKEN_CHARS_PER_SECOND;
215
+ const sentences = full.match(/[^.!?]+(?:[.!?]+["')\]]*|$)\s*/g) || [full];
216
+ let end = 0;
217
+ for (let i = 0; i < sentences.length; i += 1) {
218
+ end += sentences[i].length;
219
+ if (end > heardChars) return sentences.slice(i).join("").trim();
220
+ }
221
+ // Cut means it was still playing: the estimate ran past the end, so at
222
+ // least the last sentence went unheard.
223
+ return sentences.at(-1).trim();
224
+ }
53
225
 
54
226
  class KazeeCallSession {
55
227
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
56
- maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
57
- turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart,
228
+ maxCallMs, idleMs, checkInMs, checkInGraceMs, dropMs, dropGiveUpMs, humDb, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
229
+ turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart, onSpeechNoise, onEarlyWords, onUtteranceDropped,
58
230
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
59
231
  this.roomId = Number(roomId);
60
232
  this.chatId = String(chatId);
@@ -69,6 +241,9 @@ class KazeeCallSession {
69
241
  this.privateId = null;
70
242
  this.publisherId = null;
71
243
  this.greeting = greeting ? String(greeting) : null;
244
+ // Optional promise of a better opening line (the quick voice's greeting
245
+ // from the recent chat); empty or failed means the fixed greeting.
246
+ this.greetingLine = null;
72
247
  this._greeted = false;
73
248
  this._publisherUp = false;
74
249
  this._createdRoom = false;
@@ -83,6 +258,12 @@ class KazeeCallSession {
83
258
  this._transportExpiryTimer = null;
84
259
  this.onTranscript = onTranscript || (() => {});
85
260
  this.onSpeechStart = onSpeechStart || (() => {});
261
+ // The words heard when a turn was committed, before its final; and a
262
+ // committed turn that will never reach onTranscript (manager.js
263
+ // _earlyWords).
264
+ this.onEarlyWords = onEarlyWords || (() => {});
265
+ this.onUtteranceDropped = onUtteranceDropped || (() => {});
266
+ this.onSpeechNoise = onSpeechNoise || (() => {});
86
267
  this.turnDecision = turnDecision;
87
268
  this.acknowledgement = acknowledgement;
88
269
  this._cueController = null;
@@ -99,6 +280,25 @@ class KazeeCallSession {
99
280
  this.synthesizeStream = synthesizeStream ?? (synthesize === synthesizePcm ? synthesizeStreamPcm : null);
100
281
  this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
101
282
  this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
283
+ // Where segments that came back without words are kept (miss-capture.js).
284
+ this.missDir = missDir();
285
+ this.checkInMs = Number(checkInMs) || CHECK_IN_MS;
286
+ this.checkInGraceMs = Number(checkInGraceMs) || CHECK_IN_GRACE_MS;
287
+ this._lastWordsAt = 0;
288
+ this._workedAt = 0;
289
+ this._checkIns = 0;
290
+ this._checkInTimer = null;
291
+ this.dropMs = Number(dropMs) || DROP_MS;
292
+ this.dropGiveUpMs = Number(dropGiveUpMs) || DROP_GIVE_UP_MS;
293
+ this._lineDropped = null;
294
+ this._lastHeardText = "";
295
+ // Lines made ahead of time (the line-back while the caller is gone).
296
+ this._prepared = new Map();
297
+ this.humDb = humDb === undefined ? humLevel() : humDb;
298
+ this._lastSpeechFrameAt = 0;
299
+ // What went out, for the close log (12 Oct: the hiss went unheard and
300
+ // nothing showed whether it was ever sent).
301
+ this._sent = { speech: 0, silentSpeech: 0, hum: 0, gaps1s: 0, longestGapMs: 0, lastAt: 0 };
102
302
  this.finalWaitMs = Number(finalWaitMs) || FINAL_WAIT_MS;
103
303
  this.joinRetries = DEFAULT_JOIN_RETRIES;
104
304
  this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
@@ -111,6 +311,8 @@ class KazeeCallSession {
111
311
  this._speakQueue = [];
112
312
  this._speakActive = false;
113
313
  this._speakGen = 0;
314
+ this._speaking = null; // the item being said: { text, resumable, startedAt }
315
+ this._cut = null; // words the caller cut off: { text, fromStart, at }
114
316
  this._startedAt = 0;
115
317
  this._lastHeard = 0;
116
318
  this._lastSpokeAt = 0;
@@ -149,10 +351,14 @@ class KazeeCallSession {
149
351
  return { token: this.roomToken };
150
352
  }
151
353
 
152
- _newPeerConnection() {
354
+ _newPeerConnection({ camera = false } = {}) {
153
355
  const expiresAt = this.iceCredentialsExpiresAt;
154
356
  if (this.managedRoom && (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000)) throw new Error("Call relay credentials unavailable");
155
- const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
357
+ // A feed's connection also takes the caller's camera; with the
358
+ // orientation extension a phone held upright gives an upright still.
359
+ const pc = new this.PeerConnectionImpl(camera
360
+ ? { iceServers: this.iceServers, headerExtensions: { audio: [], video: [new RTCRtpHeaderExtensionParameters({ id: 13, uri: VIDEO_ORIENTATION_URI })] } }
361
+ : { iceServers: this.iceServers });
156
362
  if (Number.isFinite(expiresAt)) {
157
363
  this._transportIceExpiry.set(pc, expiresAt);
158
364
  this._armTransportExpiry();
@@ -217,6 +423,13 @@ class KazeeCallSession {
217
423
  this._lastSpokeAt = this._startedAt;
218
424
  this._limitTimer = setInterval(() => { this._enforceLimits().catch(() => {}); }, LIMIT_CHECK_MS);
219
425
  if (typeof this._limitTimer.unref === "function") this._limitTimer.unref();
426
+ this._lastWordsAt = this._startedAt;
427
+ this._checkInTimer = setInterval(() => {
428
+ this._dropTick();
429
+ this._checkInTick().catch(() => {});
430
+ }, CHECK_IN_TICK_MS);
431
+ this._checkInTimer.unref?.();
432
+ if (this.humDb != null) this._humLoop().catch((error) => console.error("Kazee call hum:", error.message));
220
433
  }
221
434
 
222
435
  async _onPublisherEvent(frame) {
@@ -242,6 +455,10 @@ class KazeeCallSession {
242
455
  const feedId = Number(publisher.id);
243
456
  if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId)) {
244
457
  await this._subscribe(feedId, publisher.display, publisher.metadata);
458
+ this._noteAudioState(feedId, this.subscribers.get(feedId), publisher.streams);
459
+ } else if (this.subscribers.get(feedId)) {
460
+ this._noteAudioState(feedId, this.subscribers.get(feedId), publisher.streams);
461
+ await this._askForNewStreams(feedId, this.subscribers.get(feedId), publisher.streams);
245
462
  }
246
463
  }
247
464
  if (data.videoroom === "joined") {
@@ -338,7 +555,11 @@ class KazeeCallSession {
338
555
  if (!this.greeting || this._greeted || this.closed) return;
339
556
  if (!this._publisherUp || this.subscribers.size === 0) return;
340
557
  this._greeted = true;
341
- this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
558
+ const fixed = this.greeting;
559
+ Promise.resolve(this.greetingLine).catch(() => "").then((line) => {
560
+ if (this.closed) return null;
561
+ return this.speak(String(line || "").trim() || fixed, { resumable: true });
562
+ }).catch((error) => console.error("Kazee call greeting:", error.message));
342
563
  }
343
564
 
344
565
  async _subscribe(feedId, display, metadata) {
@@ -366,15 +587,17 @@ class KazeeCallSession {
366
587
  const roomAuth = await this._roomAuth();
367
588
  const handle = await this.janus.attach();
368
589
  if (this.closed) { await this.janus.detach(handle); this.subscribers.delete(feedId); return; }
369
- const pc = this._newPeerConnection();
590
+ const pc = this._newPeerConnection({ camera: true });
370
591
  pc.iceConnectionStateChange?.subscribe?.((state) => clog(`Kazee call subscriber ICE: ${state} feed=${feedId}`));
371
592
  const sub = {
372
593
  handle,
373
594
  pc,
374
595
  display: String(display || ""),
596
+ metadata,
375
597
  // Opus decoders are stateful, so each feed needs its own.
376
598
  codec: new OpusAudioCodec(),
377
599
  vad: new AdaptiveTurnDetector(),
600
+ heard: newHeard(),
378
601
  stt: new this.SpeechStreamImpl({
379
602
  onTranscript: (text, meta) => this._onFeedFinal(feedId, text, meta),
380
603
  onPartial: (text, meta) => this._onFeedPartial(feedId, text, meta),
@@ -393,11 +616,21 @@ class KazeeCallSession {
393
616
  fallbackUsed: false,
394
617
  finalTimer: null,
395
618
  segments: new Map(),
619
+ // Audio sent for the open segment, kept until its final (miss-capture.js).
620
+ segAudio: [],
621
+ segBytes: 0,
622
+ // Packet timing: a drop is the line, not silence (_dropTick).
623
+ lastRtpAt: 0,
624
+ rtpPackets: 0,
625
+ maxRtpGap: 0,
626
+ rtpGaps400: 0,
627
+ rtpGaps1s: 0,
396
628
  };
397
629
  sub.turnGate = new SpeechTurnGate({ decide: this.turnDecision, commit: reason => this._commitTurn(feedId, reason) });
398
630
  this.subscribers.set(feedId, sub);
399
631
  pc.onTrack.subscribe((track) => {
400
632
  if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
633
+ if (track.kind === "video") this._onFeedVideo(feedId, sub, pc, track);
401
634
  });
402
635
  this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
403
636
  const attached = await this.janus.message(handle, { ...roomAuth, request: "join", ptype: "subscriber", room: this.roomId, feed: feedId, ...(this.privateId != null ? { private_id: this.privateId } : {}) });
@@ -428,8 +661,44 @@ class KazeeCallSession {
428
661
  if (data.error_code != null) error.code = Number(data.error_code);
429
662
  throw error;
430
663
  }
664
+ if (frame.jsep) {
665
+ sub.orientationId = orientationExtensionId(frame.jsep.sdp);
666
+ sub.camera?.setOrientationExtension(sub.orientationId);
667
+ }
668
+ // What the feed carries changed: a camera asked for (_askForNewStreams)
669
+ // or a stream the caller stopped. Janus offers the feed again
670
+ // ("updated"), answered the same way. The join's own offer ("attached")
671
+ // arrives twice, as the join's reply and as a handle event; answering
672
+ // that copy again kept the caller's audio from ever connecting (11 Oct
673
+ // 3.5.37/3.5.38 calls), so only a new offer counts.
674
+ if (frame.jsep?.type === "offer" && sub.started && data.videoroom !== "attached" && frame.jsep.sdp !== sub.offerSdp) {
675
+ sub.offerSdp = frame.jsep.sdp;
676
+ sub.receiving = receivedMids(data, frame.jsep.sdp);
677
+ sub.askedMids = null;
678
+ // A failed re-offer must not cost the call its audio: the feed stays as
679
+ // it was (only the camera is missed).
680
+ clog(`Kazee call subscriber renegotiating: feed=${feedId} event=${data.videoroom || "?"}`);
681
+ try {
682
+ await sub.pc.setRemoteDescription(frame.jsep);
683
+ const answer = await sub.pc.createAnswer();
684
+ await sub.pc.setLocalDescription(answer);
685
+ await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
686
+ clog(`Kazee call subscriber renegotiated: feed=${feedId}`);
687
+ } catch (error) {
688
+ const have = (sub.pc.getTransceivers?.() || []).map((t) => `${t.kind}:${t.mid}:${t.currentDirection || t.direction}`).join(",");
689
+ console.error(`Kazee call subscriber renegotiation failed: feed=${feedId} ${error.message} offer=${sdpStreams(frame.jsep.sdp)} have=${have || "none"}`);
690
+ // The connection could not take the new stream (11 Oct 17:44, werift:
691
+ // "Transceiver with mid=1 not found", camera never seen): join the
692
+ // feed again, once, so one fresh offer carries audio and camera. The
693
+ // caller's audio pauses for a moment instead of the camera being lost.
694
+ if (!sub.rejoined) setImmediate(() => this._rejoinFeed(feedId, sub).catch((rejoinError) => console.error(`Kazee call subscriber rejoin failed: feed=${feedId} ${rejoinError.message}`)));
695
+ }
696
+ return;
697
+ }
431
698
  if (frame.jsep && !sub.started) {
432
699
  sub.started = true;
700
+ sub.offerSdp = frame.jsep.sdp;
701
+ sub.receiving = receivedMids(data, frame.jsep.sdp);
433
702
  await sub.pc.setRemoteDescription(frame.jsep);
434
703
  this._flushTrickle(sub);
435
704
  const answer = await sub.pc.createAnswer();
@@ -439,17 +708,114 @@ class KazeeCallSession {
439
708
  }
440
709
  }
441
710
 
711
+ // Join a feed again from scratch (a re-offer the connection could not take).
712
+ async _rejoinFeed(feedId, sub) {
713
+ if (this.closed || this.subscribers.get(feedId) !== sub) return;
714
+ clog(`Kazee call subscriber rejoining: feed=${feedId}`);
715
+ await this._removeSubscriber(feedId);
716
+ await this._subscribe(feedId, sub.display, sub.metadata);
717
+ const fresh = this.subscribers.get(feedId);
718
+ if (fresh) fresh.rejoined = true;
719
+ }
720
+
721
+ // A camera switched on mid-call: Janus 1.4 tells only the room's publishers
722
+ // (a "publishers" event listing the feed's streams), never a subscriber
723
+ // that joined the whole feed, so the new or re-enabled video is asked for
724
+ // with "update" (as the web app does, callMediaTracks.ts). Janus answers
725
+ // with an "updated" offer (_consumeSubscriberFrame). 11 Oct 17:04: the
726
+ // caller's camera was on and no video ever reached the bot.
727
+ // The caller muted, held or came back (AUDIO_OFF_LABELS).
728
+ _noteAudioState(feedId, sub, streams) {
729
+ if (!sub || !Array.isArray(streams)) return;
730
+ const audio = streams.find((stream) => stream?.type === "audio" && typeof stream.description === "string");
731
+ if (!audio) return;
732
+ const label = audio.description.trim().toLowerCase();
733
+ const off = AUDIO_OFF_LABELS.has(label) ? label : null;
734
+ if ((sub.audioOff || null) === off) return;
735
+ sub.audioOff = off;
736
+ const now = Date.now();
737
+ clog(`Kazee call caller ${off || "unmuted"}: feed=${feedId}`);
738
+ if (!off) {
739
+ // Their audio is about to flow again: the silence was theirs, not the
740
+ // line's, and the quiet clock starts now.
741
+ if (sub.lastRtpAt) sub.lastRtpAt = now;
742
+ this._workedAt = now;
743
+ this._checkIns = 0;
744
+ return;
745
+ }
746
+ if (this._lineDropped && !this._liveFeeds().length) {
747
+ // The label came after the drop was called: it was a mute. Nothing to
748
+ // apologise for; what they missed is said again, they are listening.
749
+ clog(`Kazee call line drop was a ${off}: feed=${feedId}`);
750
+ this._prepared.delete(this._lineDropped.line);
751
+ this._lineDropped = null;
752
+ this._resumeCut();
753
+ }
754
+ }
755
+
756
+ // Callers whose audio should be arriving: heard from, and not muted.
757
+ _liveFeeds() {
758
+ return [...this.subscribers.values()].filter((sub) => sub?.lastRtpAt && !sub.audioOff);
759
+ }
760
+
761
+ async _askForNewStreams(feedId, sub, streams) {
762
+ if (!sub?.started || !sub.receiving || !Array.isArray(streams)) return;
763
+ const wanted = streams
764
+ .filter((stream) => stream?.type === "video" && !stream.disabled && stream.mid != null)
765
+ .map((stream) => String(stream.mid))
766
+ .filter((mid) => !sub.receiving.has(mid) && !sub.askedMids?.has(mid));
767
+ if (!wanted.length) return;
768
+ sub.askedMids = new Set([...(sub.askedMids || []), ...wanted]);
769
+ clog(`Kazee call subscriber asking for new streams: feed=${feedId} mids=${wanted.join(",")}`);
770
+ try {
771
+ const reply = await this.janus.message(sub.handle, { request: "update", subscribe: wanted.map((mid) => ({ feed: feedId, mid })) });
772
+ if (reply?.jsep) await this._consumeSubscriberFrame(feedId, reply);
773
+ } catch (error) {
774
+ // The caller keeps their audio; only the camera is missed.
775
+ console.error(`Kazee call subscriber update failed: feed=${feedId} ${error.message}`);
776
+ }
777
+ }
778
+
779
+ // The caller's camera: stills for the full agent (camera.js). The sender
780
+ // is asked for a keyframe (PLI) whenever the stills need one.
781
+ _onFeedVideo(feedId, sub, pc, track) {
782
+ if (sub.camera || this.closed) return;
783
+ const receiver = () => pc.getTransceivers?.().find((transceiver) => transceiver.receiver?.tracks?.includes(track))?.receiver;
784
+ sub.camera = new CameraStills({
785
+ feedId,
786
+ requestKeyframe: async () => { if (track.ssrc != null) await receiver()?.sendRtcpPLI(track.ssrc); },
787
+ });
788
+ sub.camera.setOrientationExtension(sub.orientationId ?? null);
789
+ clog(`Kazee call camera track: feed=${feedId}`);
790
+ track.onReceiveRtp.subscribe((packet) => sub.camera?.push(packet));
791
+ }
792
+
793
+ // The newest still of a caller's camera, or null: { path, ageMs, receiving }.
794
+ cameraStill() {
795
+ let best = null;
796
+ for (const sub of this.subscribers.values()) {
797
+ const still = sub?.camera?.latest();
798
+ if (still && (!best || still.ageMs < best.ageMs)) best = still;
799
+ }
800
+ return best;
801
+ }
802
+
442
803
  _resetRecognition(feedId) {
443
804
  const sub = this.subscribers.get(feedId);
444
805
  if (!sub) return;
445
- for (const segment of sub.segments.values()) clearTimeout(segment.timer);
806
+ for (const [key, segment] of sub.segments) {
807
+ clearTimeout(segment.timer);
808
+ this._utteranceDropped(key);
809
+ }
446
810
  sub.segments.clear();
811
+ this._takeSegmentAudio(sub);
447
812
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
448
813
  sub.finalTimer = null;
449
814
  sub.lastPartial = sub.partialAtCommit = "";
450
815
  sub.wordCount = sub.lastWordAt = sub.awaitingWordsSince = 0;
451
816
  sub.wordGaps = [];
452
817
  sub.noiseCommit = sub.fallbackUsed = false;
818
+ sub.wordsStarted = sub.overSpeech = sub.underSpeech = false;
453
819
  sub.vad.reset();
454
820
  sub.turnGate?.reset();
455
821
  this._stopThinking();
@@ -473,9 +839,25 @@ class KazeeCallSession {
473
839
  }
474
840
  sub.wordCount += words - prevWords;
475
841
  sub.lastWordAt = now;
842
+ this._heardWords();
843
+ if (!sub.wordsStarted) {
844
+ const busy = this.speechBusy();
845
+ const real = realWords(partial, busy ? this._speaking?.text : "");
846
+ if (real >= (busy ? INTERRUPT_WORDS : 1)) this._wordsStarted(feedId, sub, real, busy);
847
+ }
476
848
  if (sub.awaitingWordsSince && sub.stt.protocol !== 2) this._commitTurn(feedId, "words-arrived");
477
849
  }
478
850
 
851
+ // The caller is talking: real words came through. Only now do we stop
852
+ // talking and hold the work's answers; a sound alone did neither.
853
+ _wordsStarted(feedId, sub, words, overSpeech) {
854
+ sub.wordsStarted = true;
855
+ if (overSpeech) clog(`Kazee call words over speech: feed=${feedId} words=${words}`);
856
+ this._bargeIn();
857
+ this._stopThinking();
858
+ this.onSpeechStart({ feedId });
859
+ }
860
+
479
861
  // Interims arrive in bursts whose cadence tracks recogniser health (jig
480
862
  // measured 1–20s between new words on a struggling service); a fixed stall
481
863
  // gate would cut callers off mid-sentence on a slow day.
@@ -492,8 +874,14 @@ class KazeeCallSession {
492
874
  sub.turnGate?.reset();
493
875
  if (sub.vad.active) sub.vad.reset();
494
876
  this._lastHeard = Date.now();
877
+ const audio = this._takeSegmentAudio(sub);
495
878
  clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
879
+ // Started over our speech and never reached the word gate: a "yeah" or
880
+ // "mm-hmm" while we talk, or a few words of background.
881
+ const underSpeech = !!sub.overSpeech && !sub.wordsStarted;
882
+ sub.wordsStarted = sub.overSpeech = false;
496
883
  const versioned = sub.stt.protocol === 2 && typeof sub.stt.commitTurn === "function";
884
+ let early = null;
497
885
  if (versioned) {
498
886
  if (sub.segments.size >= 4) {
499
887
  this.close("speech-backlog").catch(() => {});
@@ -501,11 +889,12 @@ class KazeeCallSession {
501
889
  }
502
890
  const commit = sub.stt.commitTurn();
503
891
  const key = `${commit.streamId}:${commit.segmentId}`;
504
- const segment = { noiseCommit: sub.noiseCommit, committedAt: Date.now(), timer: null };
892
+ const segment = { noiseCommit: sub.noiseCommit, underSpeech, committedAt: Date.now(), timer: null, audio };
505
893
  sub.segments.set(key, segment);
506
894
  segment.timer = setTimeout(() => {
507
895
  if (!sub.segments.delete(key)) return;
508
896
  clog(`Kazee call final timeout: feed=${feedId} segment=${key}`);
897
+ this._utteranceDropped(key);
509
898
  this._stopThinking();
510
899
  // Never substitute an old or unfinished transcript. Ask for a repeat
511
900
  // only when the caller is quiet, so recovery cannot interrupt them.
@@ -519,8 +908,17 @@ class KazeeCallSession {
519
908
  clearTimeout(segment.timer);
520
909
  sub.segments.delete(key);
521
910
  clog(`Kazee call STT commit failed: feed=${feedId} segment=${key}`);
911
+ this._utteranceDropped(key);
522
912
  });
913
+ // The words heard so far are usually the final's own words, which take
914
+ // another 0.69 s (median of 273 finals, 11 Oct): the quick voice may
915
+ // start on them now, and the final confirms or replaces them.
916
+ const heard = String(sub.lastPartial || "").trim();
917
+ if (!segment.noiseCommit && countWords(heard) >= MIN_DISPATCH_WORDS && !(underSpeech && this.speechBusy())) {
918
+ early = { text: heard, meta: { feedId, display: sub.display || "", participants: this.subscribers.size, utteranceId: key } };
919
+ }
523
920
  } else {
921
+ sub.underSpeech = underSpeech;
524
922
  sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
525
923
  }
526
924
  // Recognition must finish before any acknowledgement claims a heard turn.
@@ -535,6 +933,14 @@ class KazeeCallSession {
535
933
  sub.wordCount = 0;
536
934
  sub.lastWordAt = 0;
537
935
  sub.wordGaps = [];
936
+ if (early) {
937
+ try { this.onEarlyWords(early.text, early.meta); } catch (error) { console.error("Kazee call early words:", error.message); }
938
+ }
939
+ }
940
+
941
+ _utteranceDropped(utteranceId) {
942
+ if (!utteranceId) return;
943
+ try { this.onUtteranceDropped(utteranceId); } catch (_) {}
538
944
  }
539
945
 
540
946
  _finalFallback(feedId) {
@@ -559,8 +965,11 @@ class KazeeCallSession {
559
965
  if (!segment || meta.streamId !== sub.stt.streamId) return;
560
966
  sub.segments.delete(key);
561
967
  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 });
968
+ const level = segmentLevel(segment.audio);
969
+ clog(`Kazee call final ready: feed=${feedId} segment=${key} wait=${Date.now() - segment.committedAt}ms ` +
970
+ `audio=${Math.round((segment.audio?.length || 0) / 32)}ms level=${level.rms}dB loud=${level.loud}dB`);
971
+ if (!String(text || "").trim()) this._keepMiss(key, segment.audio, level);
972
+ this._dispatchTranscript(feedId, text, { noiseCommit: segment.noiseCommit, underSpeech: segment.underSpeech, utteranceId: key });
564
973
  return;
565
974
  }
566
975
  if (sub?.finalTimer) {
@@ -584,16 +993,28 @@ class KazeeCallSession {
584
993
  // occasionally leaks a single stray word into the transcript too.
585
994
  if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
586
995
  clog(`Kazee call noise turn dropped: feed=${feedId} words=${countWords(trimmed)}`);
996
+ this._utteranceDropped(options.utteranceId);
587
997
  if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
998
+ this._speechWasNoise(feedId);
588
999
  return;
589
1000
  }
590
1001
  }
1002
+ const underSpeech = options.underSpeech ?? sub?.underSpeech;
1003
+ if (options.underSpeech === undefined && sub) sub.underSpeech = false;
1004
+ // Said over us and too few real words to stop us, and we are still
1005
+ // talking: not a turn (Pipecat drops these too). Once we are quiet, a
1006
+ // short "yes" is an answer and goes through.
1007
+ if (underSpeech && trimmed && this.speechBusy()) {
1008
+ clog(`Kazee call words under speech dropped: feed=${feedId} words=${countWords(trimmed)}`);
1009
+ this._utteranceDropped(options.utteranceId);
1010
+ return;
1011
+ }
591
1012
  clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
592
- if (trimmed) this._startThinking(trimmed);
1013
+ if (trimmed) { this._heardWords(); this._lastHeardText = trimmed; this._startThinking(trimmed); }
593
1014
  // Empty finalization (noise commit): nothing dispatches, no reply is
594
1015
  // coming — don't tick into the void.
595
1016
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
596
- if (!trimmed) return;
1017
+ if (!trimmed) { this._utteranceDropped(options.utteranceId); this._speechWasNoise(feedId); return; }
597
1018
  this.onTranscript(text, {
598
1019
  feedId,
599
1020
  display: sub?.display || "",
@@ -602,14 +1023,26 @@ class KazeeCallSession {
602
1023
  });
603
1024
  }
604
1025
 
1026
+ // Speech start muted the turn being worked on, in case it was a new ask. A
1027
+ // sound the recogniser found no words in (wind, footsteps, a cough) was not:
1028
+ // unless someone is still talking or another segment is pending, the
1029
+ // muted turn gets its voice back.
1030
+ _speechWasNoise(feedId) {
1031
+ if ([...this.subscribers.values()].some(peer => peer?.vad?.active || peer?.turnGate?.pending ||
1032
+ peer?.awaitingWordsSince || peer?.segments?.size)) return;
1033
+ this.onSpeechNoise({ feedId });
1034
+ }
1035
+
605
1036
  _reportMediaReady() {
606
1037
  if (this.closed || this._reportedMediaReady || !this._sentRtp || !this._receivedRtp) return;
607
1038
  this._reportedMediaReady = true;
608
1039
  this.onMediaReady();
609
1040
  }
610
1041
 
611
- _writeFrame(frame) {
1042
+ _writeFrame(frame, { hum = false } = {}) {
612
1043
  if (this.closed || !this.publisher?.track?.writeRtp) return;
1044
+ this._countSent(frame, hum);
1045
+ if (!hum) this._lastSpeechFrameAt = Date.now();
613
1046
  if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
614
1047
  const opus = this.codec.encode(frame);
615
1048
  const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
@@ -621,20 +1054,37 @@ class KazeeCallSession {
621
1054
  this._reportMediaReady();
622
1055
  }
623
1056
 
1057
+ _countSent(frame, hum) {
1058
+ const sent = this._sent;
1059
+ const now = Date.now();
1060
+ if (sent.lastAt && this.humDb != null) {
1061
+ const gap = now - sent.lastAt;
1062
+ if (gap > sent.longestGapMs) sent.longestGapMs = gap;
1063
+ if (gap >= SENT_GAP_MS) {
1064
+ sent.gaps1s += 1;
1065
+ clog(`Kazee call sent nothing for ${gap}ms (hiss on)`);
1066
+ }
1067
+ }
1068
+ sent.lastAt = now;
1069
+ if (hum) { sent.hum += 1; return; }
1070
+ sent.speech += 1;
1071
+ let peak = 0;
1072
+ for (let i = 0; i + 1 < frame.length && peak < SILENT_FRAME_PEAK; i += 2) peak = Math.max(peak, Math.abs(frame.readInt16LE(i)));
1073
+ if (peak < SILENT_FRAME_PEAK) sent.silentSpeech += 1;
1074
+ }
1075
+
624
1076
  _startThinking(text = "", state = "waiting") {
625
1077
  this._stopThinking();
626
1078
  if (this.closed || !text) return;
627
1079
  const revision = this._cueRevision;
628
1080
  this._thinkTimer = setTimeout(async () => {
629
1081
  this._thinkTimer = null;
630
- if (this.closed || this.speaking || this._speakActive ||
631
- [...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending)) return;
1082
+ if (this.closed || this.speaking || this._speakActive || this.callerTalking()) return;
632
1083
  const controller = new AbortController();
633
1084
  this._cueController = controller;
634
1085
  try {
635
1086
  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)) {
1087
+ if (cue && revision === this._cueRevision && !this.closed && !this._speakActive && !this.callerTalking()) {
638
1088
  await this.speak(cue);
639
1089
  }
640
1090
  } catch (_) { /* A missing cue never delays an answer. */ }
@@ -661,21 +1111,23 @@ class KazeeCallSession {
661
1111
  sub.firstRtpAt = Date.now();
662
1112
  clog(`Kazee call first RTP: feed=${feedId}`);
663
1113
  }
1114
+ this._rtpArrived(feedId, sub);
664
1115
  let pcm;
665
- try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
1116
+ try { pcm = sub.codec.decode(packet.payload); } catch (_) { if (sub.heard) sub.heard.decodeErrors += 1; return; }
1117
+ if (sub.heard) noteHeard(sub.heard, rmsDbfs(pcm));
666
1118
  // Echo guard: raise the effective threshold while the bot speaks; the
667
1119
  // adaptive floor keeps sampling the un-boosted base so echo never pollutes
668
1120
  // the noise-floor estimate.
669
1121
  sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
670
1122
  const event = sub.vad.push(pcm);
1123
+ if (event?.type === "start" && sub.heard) sub.heard.starts += 1;
671
1124
  const pcm16 = downsample48To16(pcm);
672
1125
  if (sub.stt.protocol === 2 && sub.turnGate) {
673
1126
  if (event?.type === "start") {
674
1127
  sub.turnGate.resume();
675
1128
  this._lastHeard = Date.now();
676
- this.cancelSpeech();
677
- this._stopThinking();
678
- this.onSpeechStart({ feedId });
1129
+ // A sound: listen for words (_onFeedPartial), keep talking.
1130
+ if (this.speechBusy()) sub.overSpeech = true;
679
1131
  for (const frame of sub.prebuffer.splice(0)) {
680
1132
  sub.turnGate.audio(frame);
681
1133
  this._feedAudio(sub, frame);
@@ -699,9 +1151,7 @@ class KazeeCallSession {
699
1151
  // Caller resumed before any words arrived — same turn continues.
700
1152
  sub.awaitingWordsSince = 0;
701
1153
  this._lastHeard = Date.now();
702
- this.cancelSpeech();
703
- this.onSpeechStart({ feedId });
704
- this._stopThinking();
1154
+ if (this.speechBusy()) sub.overSpeech = true;
705
1155
  return;
706
1156
  }
707
1157
  if (Date.now() - sub.awaitingWordsSince >= WORD_WAIT_MAX_MS) {
@@ -718,10 +1168,8 @@ class KazeeCallSession {
718
1168
  if (event?.type === "start") {
719
1169
  this._lastHeard = Date.now();
720
1170
  clog(`Kazee call VAD start: feed=${feedId}`);
721
- // Barge-in: the human wins — stop talking and listen.
722
- this.cancelSpeech();
723
- this.onSpeechStart({ feedId });
724
- this._stopThinking();
1171
+ // A sound: listen for words (_onFeedPartial), keep talking.
1172
+ if (this.speechBusy()) sub.overSpeech = true;
725
1173
  for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
726
1174
  }
727
1175
  this._feedAudio(sub, pcm16);
@@ -748,17 +1196,104 @@ class KazeeCallSession {
748
1196
  // A transient speech-service blip drops frames but must not end a live call;
749
1197
  // SpeechStream reconnects on the next send.
750
1198
  _feedAudio(sub, pcm16) {
1199
+ if (sub.segBytes < MAX_SEGMENT_BYTES) {
1200
+ sub.segAudio.push(pcm16);
1201
+ sub.segBytes += pcm16.length;
1202
+ }
751
1203
  sub.stt.audio(pcm16).catch((error) => console.error("Kazee call STT send:", error.message));
752
1204
  }
753
1205
 
754
- speak(text) {
1206
+ _takeSegmentAudio(sub) {
1207
+ const audio = sub.segAudio?.length ? Buffer.concat(sub.segAudio) : Buffer.alloc(0);
1208
+ sub.segAudio = [];
1209
+ sub.segBytes = 0;
1210
+ return audio;
1211
+ }
1212
+
1213
+ // A segment the recogniser found no words in: kept for a day when
1214
+ // KAZEE_CALL_KEEP_MISSES is on, so a lost question can be listened to.
1215
+ _keepMiss(key, audio, level) {
1216
+ if (!this.missDir) return;
1217
+ try {
1218
+ const file = keepMiss(this.missDir, `${this.roomId}-${key}-${Date.now()}`, audio);
1219
+ if (file) clog(`Kazee call miss kept: ${path.basename(file)} loud=${level.loud}dB`);
1220
+ } catch (error) {
1221
+ console.error("Kazee call miss keep:", error.message);
1222
+ }
1223
+ }
1224
+
1225
+ // For the quick voice: someone is talking, with words (a sound alone is
1226
+ // not talking), or their turn is being decided; speechBusy: speech is
1227
+ // playing or queued.
1228
+ callerTalking() {
1229
+ return [...this.subscribers.values()].some(sub => sub?.wordsStarted && (sub.vad?.active || sub.turnGate?.pending || sub.awaitingWordsSince));
1230
+ }
1231
+
1232
+ speechBusy() {
1233
+ return !!(this.speaking || this._speakActive || this._speakQueue.length);
1234
+ }
1235
+
1236
+ // resumable: an answer or the greeting, which is said again when the
1237
+ // caller cuts it off by accident (cutSpeech); quick lines are not.
1238
+ speak(text, { resumable = false } = {}) {
755
1239
  if (this.closed || !String(text || "").trim()) return Promise.resolve(false);
756
1240
  return new Promise((resolve, reject) => {
757
- this._speakQueue.push({ text: String(text), resolve, reject });
1241
+ this._speakQueue.push({ text: String(text), resumable, resolve, reject });
758
1242
  this._drainSpeech();
759
1243
  });
760
1244
  }
761
1245
 
1246
+ // The caller started talking: the human wins, so stop talking and listen,
1247
+ // but keep the words they did not hear yet (11 Oct: "Are you still there?"
1248
+ // over the start of a 539-character answer threw it away for good).
1249
+ // Words cut off earlier and not said yet stay behind the new ones (11 Oct:
1250
+ // a reply cut while the greeting's rest waited lost the greeting's rest).
1251
+ _bargeIn() {
1252
+ const current = this._speaking;
1253
+ const queued = this._speakQueue.filter((item) => item.resumable).map((item) => item.text);
1254
+ const heardMs = current?.startedAt ? Date.now() - current.startedAt : 0;
1255
+ // Stopped in its last moments, the line was heard: the caller is
1256
+ // answering it (11 Oct 17:43: a 61-character question cut 0.4 s from its
1257
+ // end was asked three times over).
1258
+ const leftMs = current?.startedAt ? (current.text.length / SPOKEN_CHARS_PER_SECOND) * 1000 - heardMs : Infinity;
1259
+ const rest = current?.resumable && leftMs > HEARD_ENOUGH_LEFT_MS ? unheardText(current.text, heardMs) : "";
1260
+ const prior = this.cutSpeech();
1261
+ const text = [rest, ...queued, prior?.text].filter(Boolean).join(" ").trim();
1262
+ if (rest || queued.length) this._cut = { text, fromStart: rest ? rest === current.text : true, at: Date.now() };
1263
+ // Logged: a reply stopped before its first sound was invisible (11 Oct
1264
+ // 17:31, "Ready when you are" never played and nothing said why).
1265
+ if (current || this._speakQueue.length) {
1266
+ const when = !current ? "queued" : current.startedAt ? `after ${heardMs}ms` : "before first audio";
1267
+ clog(`Kazee call barge-in: speech cut ${when} chars=${current?.text.length || 0} queued=${this._speakQueue.length} kept=${rest || queued.length ? text.length : 0}`);
1268
+ }
1269
+ this.cancelSpeech();
1270
+ }
1271
+
1272
+ // A reply that could not be said yet (the caller was talking): kept like
1273
+ // cut-off words, said after a no-words sound or offered to the next turn.
1274
+ // after: it carries on from the words already kept (the rest of an answer
1275
+ // whose start was cut off), so it goes behind them.
1276
+ keepUnsaid(text, { after = false } = {}) {
1277
+ const words = String(text || "").trim();
1278
+ if (!words) return;
1279
+ const prior = this.cutSpeech();
1280
+ const parts = after ? [prior?.text, words] : [words, prior?.text];
1281
+ this._cut = { text: parts.filter(Boolean).join(" "), fromStart: after && prior ? prior.fromStart : true, at: Date.now() };
1282
+ }
1283
+
1284
+ // Words cut off in the last 30 s, still unsaid: { text, fromStart }, or null.
1285
+ // takeCutSpeech also forgets them, so they are said again at most once.
1286
+ cutSpeech() {
1287
+ const cut = this._cut;
1288
+ return cut && Date.now() - cut.at <= CUT_KEEP_MS ? { text: cut.text, fromStart: cut.fromStart } : null;
1289
+ }
1290
+
1291
+ takeCutSpeech() {
1292
+ const cut = this.cutSpeech();
1293
+ this._cut = null;
1294
+ return cut;
1295
+ }
1296
+
762
1297
  cancelSpeech() {
763
1298
  this._speakGen += 1;
764
1299
  this._synthController?.abort();
@@ -771,12 +1306,18 @@ class KazeeCallSession {
771
1306
  (async () => {
772
1307
  try {
773
1308
  while (this._speakQueue.length && !this.closed) {
1309
+ // The caller's line is down: what is queued waits for it.
1310
+ if (this._lineDropped) { await wait(100); continue; }
774
1311
  const item = this._speakQueue.shift();
775
1312
  const controller = new AbortController();
776
1313
  this._synthController = controller;
1314
+ this._speaking = { text: item.text, resumable: item.resumable, startedAt: 0 };
777
1315
  try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
778
1316
  catch (error) { if (controller.signal.aborted) item.resolve(false); else item.reject(error); }
779
- finally { if (this._synthController === controller) this._synthController = null; }
1317
+ finally {
1318
+ if (this._synthController === controller) this._synthController = null;
1319
+ this._speaking = null;
1320
+ }
780
1321
  }
781
1322
  } finally {
782
1323
  this._speakActive = false;
@@ -788,6 +1329,7 @@ class KazeeCallSession {
788
1329
  async _playPcm48(pcm48, gen) {
789
1330
  this._stopThinking();
790
1331
  this.speaking = true;
1332
+ if (this._speaking && !this._speaking.startedAt) this._speaking.startedAt = Date.now();
791
1333
  try {
792
1334
  // Drift-corrected pacing: a naive wait(20) accumulates encode time and
793
1335
  // timer slop, audibly stretching long utterances.
@@ -808,13 +1350,24 @@ class KazeeCallSession {
808
1350
  // Cache only audio rendered by the real service: the key is voice+text, so
809
1351
  // PCM from an injected synthesize would leak across sessions.
810
1352
  const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
811
- const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE || Object.values(CUE_PHRASES).includes(text));
1353
+ const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE ||
1354
+ text === NO_WORDS_GOODBYE || CHECK_IN_LINES.includes(text) || Object.values(CUE_PHRASES).includes(text));
812
1355
  const cacheKey = `${ttsVoice.getVoice()}::${text}`;
813
1356
  const synthStart = Date.now();
814
1357
  if (cacheable && SYNTH_CACHE.has(cacheKey)) {
815
1358
  clog(`Kazee call speak: cached chars=${text.length}`);
816
1359
  return this._playPcm48(SYNTH_CACHE.get(cacheKey), gen);
817
1360
  }
1361
+ const prepared = this._prepared.get(text);
1362
+ if (prepared) {
1363
+ this._prepared.delete(text);
1364
+ const pcm48 = await prepared.catch(() => null);
1365
+ if (this.closed || gen !== this._speakGen) return false;
1366
+ if (pcm48) {
1367
+ clog(`Kazee call speak: prepared chars=${text.length}`);
1368
+ return this._playPcm48(pcm48, gen);
1369
+ }
1370
+ }
818
1371
  if (this.synthesizeStream) {
819
1372
  try {
820
1373
  return await this._playStreaming(text, gen, { cacheable, cacheKey, synthStart });
@@ -859,6 +1412,7 @@ class KazeeCallSession {
859
1412
  this._stopThinking();
860
1413
  this.speaking = true;
861
1414
  startAt = Date.now();
1415
+ if (this._speaking && !this._speaking.startedAt) this._speaking.startedAt = startAt;
862
1416
  }
863
1417
  frameCarry = frameCarry.length ? Buffer.concat([frameCarry, pcm48]) : pcm48;
864
1418
  let offset = 0;
@@ -882,6 +1436,191 @@ class KazeeCallSession {
882
1436
  }
883
1437
  }
884
1438
 
1439
+ // ── The line ────────────────────────────────────────────────────
1440
+
1441
+ _rtpArrived(feedId, sub) {
1442
+ const now = Date.now();
1443
+ if (sub.lastRtpAt) {
1444
+ const gap = now - sub.lastRtpAt;
1445
+ if (gap > sub.maxRtpGap) sub.maxRtpGap = gap;
1446
+ if (gap >= 400) sub.rtpGaps400 += 1;
1447
+ if (gap >= 1000) {
1448
+ sub.rtpGaps1s += 1;
1449
+ clog(`Kazee call RTP gap: feed=${feedId} ${gap}ms`);
1450
+ }
1451
+ }
1452
+ sub.lastRtpAt = now;
1453
+ sub.rtpPackets += 1;
1454
+ if (this._lineDropped) this._lineBack(feedId, now);
1455
+ }
1456
+
1457
+ // No packets from anyone for dropMs: the line dropped. What I was saying
1458
+ // stops and is kept, what is queued waits, the check-in clock stops, and
1459
+ // the line-back is made now so it plays the moment they return.
1460
+ _dropTick() {
1461
+ if (this.closed || this._ending) return;
1462
+ // A muted caller's silence is theirs (_noteAudioState).
1463
+ const feeds = this._liveFeeds();
1464
+ if (!feeds.length && !this._lineDropped) return;
1465
+ const now = Date.now();
1466
+ if (this._lineDropped) {
1467
+ if (now - this._lineDropped.since < this.dropGiveUpMs) return;
1468
+ clog(`Kazee call line lost: no audio for ${Math.round((now - this._lineDropped.since) / 1000)} s, hanging up`);
1469
+ this.close("connection-lost").catch(() => {});
1470
+ return;
1471
+ }
1472
+ const last = Math.max(...feeds.map((sub) => sub.lastRtpAt));
1473
+ if (now - last < this.dropMs) return;
1474
+ const heard = this._lastHeardWords();
1475
+ const line = heard ? `${LINE_BACK_LEAD} The last thing I heard was: ${heard}.` : LINE_BACK_NOTHING;
1476
+ this._lineDropped = { since: last, line };
1477
+ clog(`Kazee call line dropped: no audio for ${now - last} ms${heard ? `, last heard ${countWords(heard)} words` : ""}`);
1478
+ if (this.speechBusy()) this._bargeIn();
1479
+ this._prepare(line);
1480
+ }
1481
+
1482
+ _lineBack(feedId, now) {
1483
+ const { since, line } = this._lineDropped;
1484
+ this._lineDropped = null;
1485
+ clog(`Kazee call line back: feed=${feedId} after ${now - since} ms`);
1486
+ // The quiet clock starts again from now, not from before the drop.
1487
+ this._workedAt = now;
1488
+ const missed = this._missedWords();
1489
+ // First, ahead of anything that queued while they were gone, with the
1490
+ // words they missed right after; queued together so nothing gets between.
1491
+ const items = [{ text: line }, missed && { text: missed, resumable: true }].filter(Boolean)
1492
+ .map(({ text, resumable = false }) => ({ text, resumable, resolve: () => {}, reject: () => {} }));
1493
+ this._speakQueue.unshift(...items);
1494
+ this._drainSpeech();
1495
+ }
1496
+
1497
+ // "As I was saying, ..." with the words a drop cut off, or "".
1498
+ _missedWords() {
1499
+ const cut = this.takeCutSpeech();
1500
+ return cut ? `${LINE_BACK_RESUME} ${cut.text.replace(/^([A-Z])(?=[a-z])/, (letter) => letter.toLowerCase())}` : "";
1501
+ }
1502
+
1503
+ _resumeCut() {
1504
+ const missed = this._missedWords();
1505
+ if (missed) this._speakQueue.unshift({ text: missed, resumable: true, resolve: () => {}, reject: () => {} });
1506
+ this._drainSpeech();
1507
+ }
1508
+
1509
+ // The last words heard: the sentence they were in the middle of, or else
1510
+ // their last full one; its last few words.
1511
+ _lastHeardWords() {
1512
+ const live = [...this.subscribers.values()].map((sub) => String(sub?.lastPartial || "").trim()).find((text) => countWords(text));
1513
+ const words = String(live || this._lastHeardText || "").trim().replace(/[.!?,;:]+$/, "").split(/\s+/).filter(Boolean);
1514
+ return words.slice(-LINE_BACK_WORDS).join(" ");
1515
+ }
1516
+
1517
+ lastHeardText() {
1518
+ return this._lastHeardText;
1519
+ }
1520
+
1521
+ // Synthesized ahead, played by _playUtterance when the same text is said.
1522
+ _prepare(text) {
1523
+ if (this._prepared.has(text)) return;
1524
+ const pending = Promise.resolve()
1525
+ .then(() => this.synthesize(text))
1526
+ .then((synthesized) => upsampleTo48(synthesized.pcm, synthesized.sampleRate));
1527
+ pending.catch(() => {});
1528
+ this._prepared.set(text, pending);
1529
+ }
1530
+
1531
+ // The hiss: a frame every 20 ms whenever no speech frame went out in the
1532
+ // last 60 ms, from my first words on (before them nothing is sent, as
1533
+ // before, so ringing and media-ready are unchanged).
1534
+ async _humLoop() {
1535
+ const frames = humFrames(this.humDb);
1536
+ let anchor = 0;
1537
+ let sent = 0;
1538
+ let next = 0;
1539
+ while (!this.closed) {
1540
+ const now = Date.now();
1541
+ // Never inside a line: a voice stream that stalls mid-sentence would
1542
+ // get hiss between its words and run the call's clock ahead.
1543
+ if (!this._lastSpeechFrameAt || now - this._lastSpeechFrameAt < HUM_AFTER_SPEECH_MS || this._speaking?.startedAt ||
1544
+ !this.publisher?.track?.writeRtp) {
1545
+ anchor = 0;
1546
+ await softWait(20);
1547
+ continue;
1548
+ }
1549
+ if (!anchor) { anchor = now; sent = 0; }
1550
+ this._writeFrame(frames[next], { hum: true });
1551
+ next = (next + 1) % frames.length;
1552
+ sent += 1;
1553
+ const delay = anchor + sent * 20 - Date.now();
1554
+ if (delay > 0) await softWait(delay);
1555
+ else if (delay < -200) anchor = 0;
1556
+ }
1557
+ }
1558
+
1559
+ _heardWords() {
1560
+ this._lastWordsAt = Date.now();
1561
+ this._checkIns = 0;
1562
+ }
1563
+
1564
+ // Work the caller is waiting on: a call job not yet answered (manager.js
1565
+ // _startJob/_openJobs), or a turn about to be.
1566
+ _working() {
1567
+ if (this._thinkTimer || this._hangUpPending) return true;
1568
+ return (this._jobs || []).some(job => !job.controller?.signal?.aborted && (!job.done || job.pending?.length));
1569
+ }
1570
+
1571
+ // Someone is mid-sound or a segment is still being recognised: not a gap
1572
+ // to speak into, though sound alone never counts as being heard.
1573
+ _soundPending() {
1574
+ return [...this.subscribers.values()].some(sub => sub && (sub.vad?.active || sub.turnGate?.pending ||
1575
+ sub.awaitingWordsSince || sub.segments?.size));
1576
+ }
1577
+
1578
+ async _checkInTick() {
1579
+ // A dropped line is not the caller being quiet (_dropTick).
1580
+ if (this.closed || this._ending || this._checkingIn || this._lineDropped) return;
1581
+ // Only once the caller's audio is arriving: not while ringing.
1582
+ if (![...this.subscribers.values()].some(sub => sub?.firstRtpAt)) return;
1583
+ const now = Date.now();
1584
+ // Muted on purpose: they are listening, not gone.
1585
+ if ([...this.subscribers.values()].every(sub => !sub?.firstRtpAt || sub.audioOff)) { this._workedAt = now; return; }
1586
+ if (this._working()) { this._workedAt = now; return; }
1587
+ if (this.speechBusy() || this._soundPending()) return;
1588
+ if (now - Math.max(this._lastWordsAt, this._lastSpokeAt, this._workedAt) < this.checkInMs) return;
1589
+ this._checkingIn = true;
1590
+ try {
1591
+ if (this._checkIns < CHECK_IN_LINES.length) {
1592
+ const line = CHECK_IN_LINES[this._checkIns];
1593
+ this._checkIns += 1;
1594
+ clog(`Kazee call check-in: ${this._checkIns} of ${CHECK_IN_LINES.length}, no words for ${Math.round((now - this._lastWordsAt) / 1000)} s`);
1595
+ await this.speak(line);
1596
+ return;
1597
+ }
1598
+ await this._noWordsGoodbye();
1599
+ } finally {
1600
+ this._checkingIn = false;
1601
+ }
1602
+ }
1603
+
1604
+ async _noWordsGoodbye() {
1605
+ this._ending = true;
1606
+ const wordsBefore = this._lastWordsAt;
1607
+ clog("Kazee call check-in: no words after the check-ins, saying goodbye");
1608
+ try { await this.speak(NO_WORDS_GOODBYE); } catch (_) {}
1609
+ const heard = () => this._lastWordsAt !== wordsBefore;
1610
+ const graceEnd = Date.now() + this.checkInGraceMs;
1611
+ while (!this.closed && !heard() && Date.now() < graceEnd) await wait(100);
1612
+ // Mid-sound at the deadline: give its words a moment to arrive.
1613
+ const soundEnd = Date.now() + CHECK_IN_SOUND_GRACE_MS;
1614
+ while (!this.closed && !heard() && this._soundPending() && Date.now() < soundEnd) await wait(100);
1615
+ if (this.closed) return;
1616
+ if (heard()) {
1617
+ clog("Kazee call check-in: words in the grace, staying on");
1618
+ this._ending = false;
1619
+ return;
1620
+ }
1621
+ await this.close("no-words");
1622
+ }
1623
+
885
1624
  async _enforceLimits() {
886
1625
  if (this.closed || this._ending) return;
887
1626
  const now = Date.now();
@@ -912,12 +1651,20 @@ class KazeeCallSession {
912
1651
  const sub = this.subscribers.get(feedId);
913
1652
  if (!sub) return;
914
1653
  this.subscribers.delete(feedId);
1654
+ // Measured per call: how far apart the caller's packets really came.
1655
+ if (sub.rtpPackets) clog(`Kazee call RTP: feed=${feedId} packets=${sub.rtpPackets} max-gap=${sub.maxRtpGap}ms gaps>=400ms=${sub.rtpGaps400} gaps>=1s=${sub.rtpGaps1s}`);
1656
+ if (sub.heard?.frames || sub.heard?.decodeErrors) clog(`Kazee call heard: feed=${feedId} ${heardSummary(sub.heard, sub.vad?.thresholdDb)}`);
915
1657
  this._transportIceExpiry.delete(sub.pc);
916
1658
  this._armTransportExpiry();
917
1659
  if (sub.finalTimer) clearTimeout(sub.finalTimer);
918
- for (const segment of sub.segments.values()) clearTimeout(segment.timer);
1660
+ for (const [key, segment] of sub.segments) {
1661
+ clearTimeout(segment.timer);
1662
+ this._utteranceDropped(key);
1663
+ }
919
1664
  sub.segments.clear();
1665
+ this._takeSegmentAudio(sub);
920
1666
  sub.turnGate?.close();
1667
+ try { sub.camera?.close(); } catch (_) {}
921
1668
  try { sub.stt.close(); } catch (_) {}
922
1669
  try { sub.codec.close(); } catch (_) {}
923
1670
  try { await sub.pc.close(); } catch (_) {}
@@ -933,7 +1680,10 @@ class KazeeCallSession {
933
1680
  this._transportIceExpiry.clear();
934
1681
  this._leaseHeartbeat?.close();
935
1682
  console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
1683
+ const sent = this._sent;
1684
+ if (sent.speech || sent.hum) clog(`Kazee call sent: speech=${sent.speech} silent-speech=${sent.silentSpeech} hiss=${sent.hum} gaps>=1s=${sent.gaps1s} longest-gap=${sent.longestGapMs}ms`);
936
1685
  if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
1686
+ if (this._checkInTimer) { clearInterval(this._checkInTimer); this._checkInTimer = null; }
937
1687
  this._clearPublishTimer();
938
1688
  this._stopThinking();
939
1689
  this.cancelSpeech();
@@ -950,4 +1700,5 @@ class KazeeCallSession {
950
1700
  }
951
1701
  }
952
1702
 
953
- module.exports = { KazeeCallSession };
1703
+ module.exports = {
1704
+ unheardText, realWords, KazeeCallSession, newHeard, noteHeard, heardSummary };