@inetafrica/open-claudia 3.5.47 → 3.5.59
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 +70 -10
- package/bin/cli.js +10 -3
- package/bot.js +11 -0
- package/channels/kazee/adapter.js +244 -23
- package/channels/kazee/call/camera.js +233 -0
- package/channels/kazee/call/job-steps.js +81 -0
- package/channels/kazee/call/manager.js +1056 -27
- package/channels/kazee/call/miss-capture.js +82 -0
- package/channels/kazee/call/quick-voice.js +610 -0
- package/channels/kazee/call/session.js +787 -36
- package/channels/kazee/call/thread-context.js +100 -0
- package/channels/kazee/catch-up.js +275 -0
- package/core/actions.js +13 -4
- package/core/enforcer.js +3 -0
- package/core/handlers.js +14 -16
- package/core/identity.js +15 -1
- package/core/loopback.js +4 -1
- package/core/media.js +39 -1
- package/core/relationship.js +41 -18
- package/core/run-context.js +27 -3
- package/core/runner.js +70 -1
- package/core/state.js +6 -2
- package/core/switchboard/brain-link.js +161 -0
- package/core/switchboard/call-host.js +298 -0
- package/core/switchboard/handoff.js +80 -0
- package/core/switchboard/ipc.js +130 -0
- package/core/switchboard/supervisor.js +225 -0
- package/core/system-prompt.js +64 -12
- package/core/transcripts.js +39 -0
- package/core/tts-voice.js +62 -4
- package/core/turn-journal.js +23 -1
- package/core/utility-agent.js +5 -2
- package/package.json +2 -2
- package/test-call-speech.js +7 -0
- package/test-external-rule-scopes.js +24 -0
- package/test-fixtures/switchboard-fake-brain.js +20 -0
- package/test-kazee-call-manager.js +22 -4
- package/test-kazee-call-session.js +345 -8
- package/test-provider-compaction.js +8 -0
- package/test-provider-language.js +19 -9
- package/test-provider-prompt-parity.js +1 -0
- package/test-provider-runner.js +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
677
|
-
this.
|
|
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.
|
|
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
|
-
//
|
|
722
|
-
this.
|
|
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
|
-
|
|
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 {
|
|
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 ||
|
|
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
|
|
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 = {
|
|
1703
|
+
module.exports = {
|
|
1704
|
+
unheardText, realWords, KazeeCallSession, newHeard, noteHeard, heardSummary };
|