@inetafrica/open-claudia 3.2.3 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/loopback-client.js +4 -0
- package/bot.js +10 -1
- package/channels/kazee/call/audio.js +79 -0
- package/channels/kazee/call/manager.js +6 -0
- package/channels/kazee/call/session.js +356 -48
- package/channels/kazee/call/speech-client.js +38 -3
- package/core/actions.js +80 -0
- package/core/enforcer.js +22 -1
- package/core/handlers.js +46 -2
- package/core/loopback.js +37 -0
- package/core/media.js +71 -18
- package/core/pack-review.js +18 -0
- package/core/relationship.js +18 -2
- package/core/runner.js +7 -1
- package/core/speaker-attest.js +56 -0
- package/core/system-prompt.js +21 -4
- package/core/verify-contact.js +156 -0
- package/package.json +2 -2
- package/test-kazee-call-manager.js +4 -1
- package/test-kazee-call-session.js +100 -4
- package/test-provider-language.js +15 -1
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
|
|
4
4
|
const { JanusClient } = require("./janus-client");
|
|
5
|
-
const {
|
|
6
|
-
const { SpeechStream, synthesizePcm } = require("./speech-client");
|
|
5
|
+
const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
|
|
6
|
+
const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
|
|
7
|
+
const ttsVoice = require("../../../core/tts-voice");
|
|
7
8
|
|
|
8
9
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
const countWords = (text) => String(text || "").trim().split(/\s+/).filter(Boolean).length;
|
|
11
|
+
|
|
12
|
+
// Fixed lines (greeting, goodbyes) re-synthesize identically on every call;
|
|
13
|
+
// keep the rendered PCM per voice. Only those constants are cached, so the map
|
|
14
|
+
// stays tiny.
|
|
15
|
+
const SYNTH_CACHE = new Map();
|
|
9
16
|
|
|
10
17
|
// Per-stage call logging. The media leg (ICE/RTP) can fail with zero thrown
|
|
11
18
|
// errors — a caller then hears silence while the log shows a clean
|
|
@@ -13,7 +20,6 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
13
20
|
// double as the STT/TTS timing measurements chat-side estimates lacked.
|
|
14
21
|
const clog = (msg) => console.log(`${new Date().toISOString()} ${msg}`);
|
|
15
22
|
|
|
16
|
-
const BASE_THRESHOLD_DB = -38;
|
|
17
23
|
// While TTS plays, the caller's device can leak bot audio back through its mic.
|
|
18
24
|
// Require a much louder signal before treating incoming audio as human speech.
|
|
19
25
|
const SPEAKING_THRESHOLD_DB = -26;
|
|
@@ -23,6 +29,19 @@ const DEFAULT_MAX_CALL_MS = 30 * 60_000;
|
|
|
23
29
|
const DEFAULT_IDLE_MS = 2 * 60_000;
|
|
24
30
|
const DEFAULT_JOIN_RETRIES = 10;
|
|
25
31
|
const DEFAULT_JOIN_RETRY_DELAY_MS = 500;
|
|
32
|
+
// Inbound defer-publish window: how long to wait for the caller's own
|
|
33
|
+
// publisher before giving up and publishing anyway (see _maybePublish).
|
|
34
|
+
const PUBLISH_DEFER_MS = 4000;
|
|
35
|
+
// Hybrid turn-end (jig 2026-08-16): energy alone never closes a turn below
|
|
36
|
+
// ~15dB SNR of café noise, STT finals routinely never arrive at all, and
|
|
37
|
+
// babble can open a turn containing zero real words — so the recogniser's word
|
|
38
|
+
// timeline guards every energy close and the interim transcript stands in for
|
|
39
|
+
// missing finals.
|
|
40
|
+
const WORD_RESCUE_MIN_MS = 1500;
|
|
41
|
+
const WORD_RESCUE_NO_CADENCE_MS = 4000;
|
|
42
|
+
const WORD_WAIT_MAX_MS = 4000;
|
|
43
|
+
const FINAL_WAIT_MS = 2500;
|
|
44
|
+
const MIN_DISPATCH_WORDS = 2;
|
|
26
45
|
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
27
46
|
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
28
47
|
|
|
@@ -55,7 +74,7 @@ const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
|
55
74
|
|
|
56
75
|
class KazeeCallSession {
|
|
57
76
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
58
|
-
maxCallMs, idleMs, synthesize = synthesizePcm, asCaller = false, greeting = null,
|
|
77
|
+
maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
59
78
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
60
79
|
this.roomId = Number(roomId);
|
|
61
80
|
this.chatId = String(chatId);
|
|
@@ -64,7 +83,10 @@ class KazeeCallSession {
|
|
|
64
83
|
this.asCaller = !!asCaller;
|
|
65
84
|
this.greeting = greeting ? String(greeting) : null;
|
|
66
85
|
this._greeted = false;
|
|
86
|
+
this._publisherUp = false;
|
|
67
87
|
this._createdRoom = false;
|
|
88
|
+
this._joined = false;
|
|
89
|
+
this._publishTimer = null;
|
|
68
90
|
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
69
91
|
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
70
92
|
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
@@ -72,8 +94,12 @@ class KazeeCallSession {
|
|
|
72
94
|
this.onTranscript = onTranscript || (() => {});
|
|
73
95
|
this.onEnded = onEnded || (() => {});
|
|
74
96
|
this.synthesize = synthesize;
|
|
97
|
+
// Tests and the jig inject a custom synthesize; only pair the streaming
|
|
98
|
+
// path with the service client it belongs to.
|
|
99
|
+
this.synthesizeStream = synthesizeStream ?? (synthesize === synthesizePcm ? synthesizeStreamPcm : null);
|
|
75
100
|
this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
|
|
76
101
|
this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
|
|
102
|
+
this.finalWaitMs = Number(finalWaitMs) || FINAL_WAIT_MS;
|
|
77
103
|
this.joinRetries = DEFAULT_JOIN_RETRIES;
|
|
78
104
|
this.joinRetryDelayMs = DEFAULT_JOIN_RETRY_DELAY_MS;
|
|
79
105
|
this.codec = new OpusAudioCodec();
|
|
@@ -142,6 +168,7 @@ class KazeeCallSession {
|
|
|
142
168
|
}
|
|
143
169
|
|
|
144
170
|
async _onPublisherEvent(frame) {
|
|
171
|
+
if (frame?.janus === "trickle") return this._onTrickle(this.publisher, frame);
|
|
145
172
|
try { await this._consumePublisherFrame(frame); }
|
|
146
173
|
catch (error) { console.error("Kazee call publisher:", error.message); await this.close("janus-error"); }
|
|
147
174
|
}
|
|
@@ -160,30 +187,100 @@ class KazeeCallSession {
|
|
|
160
187
|
}
|
|
161
188
|
}
|
|
162
189
|
if (data.videoroom === "joined") {
|
|
190
|
+
this._joined = true;
|
|
163
191
|
clog(`Kazee call room joined: room=${this.roomId} publishers=${(data.publishers || []).length}`);
|
|
164
|
-
if (!this.publisher) await this._publish();
|
|
165
192
|
}
|
|
193
|
+
if (this._joined && !this.publisher) await this._maybePublish();
|
|
166
194
|
const gone = Number(data.leaving || data.unpublished || 0);
|
|
167
195
|
if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
|
|
168
196
|
if (data.videoroom === "destroyed") await this.close("room-destroyed");
|
|
169
197
|
}
|
|
170
198
|
|
|
199
|
+
// Inbound, the bot answers faster than the caller's client finishes joining:
|
|
200
|
+
// publishing at join fires our publishers event into a tab that may not be
|
|
201
|
+
// listening yet, and a caller who misses it never subscribes to us — no
|
|
202
|
+
// remote audio, UI stuck pre-connected (web callers, 2026-08-16). Wait until
|
|
203
|
+
// the caller's own publisher is visible (proof their handlers are live, and
|
|
204
|
+
// subscribing to us flips their UI to connected), with a fallback so a
|
|
205
|
+
// caller who never publishes still gets our media leg. Outbound (asCaller)
|
|
206
|
+
// publishes at join as before: the callee joins after us and receives our
|
|
207
|
+
// feed in their own join response, so there is no race.
|
|
208
|
+
async _maybePublish() {
|
|
209
|
+
if (this.closed || this.publisher) return;
|
|
210
|
+
if (this.asCaller || this.subscribers.size > 0) {
|
|
211
|
+
this._clearPublishTimer();
|
|
212
|
+
return this._publish();
|
|
213
|
+
}
|
|
214
|
+
if (this._publishTimer) return;
|
|
215
|
+
this._publishTimer = setTimeout(() => {
|
|
216
|
+
this._publishTimer = null;
|
|
217
|
+
if (this.closed || this.publisher) return;
|
|
218
|
+
clog(`Kazee call publish fallback: room=${this.roomId} no remote publisher after ${PUBLISH_DEFER_MS}ms`);
|
|
219
|
+
this._publish().catch((error) => {
|
|
220
|
+
console.error("Kazee call publish:", error.message);
|
|
221
|
+
this.close("publish-failed").catch(() => {});
|
|
222
|
+
});
|
|
223
|
+
}, PUBLISH_DEFER_MS);
|
|
224
|
+
if (typeof this._publishTimer.unref === "function") this._publishTimer.unref();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_clearPublishTimer() {
|
|
228
|
+
if (this._publishTimer) { clearTimeout(this._publishTimer); this._publishTimer = null; }
|
|
229
|
+
}
|
|
230
|
+
|
|
171
231
|
async _publish() {
|
|
172
232
|
if (this.publisher) return;
|
|
173
233
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
174
|
-
pc.iceConnectionStateChange?.subscribe?.((state) =>
|
|
234
|
+
pc.iceConnectionStateChange?.subscribe?.((state) => {
|
|
235
|
+
clog(`Kazee call publisher ICE: ${state} room=${this.roomId}`);
|
|
236
|
+
if (state === "connected" || state === "completed") {
|
|
237
|
+
this._publisherUp = true;
|
|
238
|
+
this._maybeGreet();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
175
241
|
const track = new MediaStreamTrack({ kind: "audio" });
|
|
176
242
|
const sender = pc.addTrack(track);
|
|
177
|
-
this.publisher = { pc, track, sender };
|
|
243
|
+
this.publisher = { pc, track, sender, pending: [], remoteSet: false };
|
|
178
244
|
const offer = await pc.createOffer();
|
|
179
245
|
await pc.setLocalDescription(offer);
|
|
180
246
|
const configured = await this.janus.message(this.publisherHandle, { request: "configure", audio: true, video: false }, pc.localDescription);
|
|
181
247
|
const answer = configured?.jsep;
|
|
182
248
|
if (!answer) throw new Error("Janus publisher configure did not return SDP");
|
|
183
249
|
await pc.setRemoteDescription(answer);
|
|
250
|
+
this._flushTrickle(this.publisher);
|
|
184
251
|
clog(`Kazee call publisher configured: room=${this.roomId}`);
|
|
185
252
|
}
|
|
186
253
|
|
|
254
|
+
// Janus answers candidate-free and trickles ICE afterwards; dropping those
|
|
255
|
+
// frames leaves both legs stuck in "checking" and the call silently dead.
|
|
256
|
+
_onTrickle(target, frame) {
|
|
257
|
+
const candidate = frame?.candidate;
|
|
258
|
+
if (!target || !candidate || candidate.completed || !candidate.candidate) return;
|
|
259
|
+
if (!target.remoteSet) {
|
|
260
|
+
(target.pending ||= []).push(candidate);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
target.pc.addIceCandidate(candidate).catch((error) => console.error("Kazee call trickle:", error.message));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_flushTrickle(target) {
|
|
267
|
+
if (!target) return;
|
|
268
|
+
target.remoteSet = true;
|
|
269
|
+
for (const candidate of (target.pending || []).splice(0)) {
|
|
270
|
+
target.pc.addIceCandidate(candidate).catch((error) => console.error("Kazee call trickle:", error.message));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Greeting on STT-connect alone used to speak into dead air while the media
|
|
275
|
+
// plane was still ICE-checking; wait for the publisher leg to actually come
|
|
276
|
+
// up AND a remote feed to exist, whichever lands second.
|
|
277
|
+
_maybeGreet() {
|
|
278
|
+
if (!this.greeting || this._greeted || this.closed) return;
|
|
279
|
+
if (!this._publisherUp || this.subscribers.size === 0) return;
|
|
280
|
+
this._greeted = true;
|
|
281
|
+
this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
|
|
282
|
+
}
|
|
283
|
+
|
|
187
284
|
async _subscribe(feedId, display) {
|
|
188
285
|
if (this.subscribers.has(feedId)) return;
|
|
189
286
|
// Reserve the slot before the first await: the join response and the async
|
|
@@ -202,9 +299,23 @@ class KazeeCallSession {
|
|
|
202
299
|
display: String(display || ""),
|
|
203
300
|
// Opus decoders are stateful, so each feed needs its own.
|
|
204
301
|
codec: new OpusAudioCodec(),
|
|
205
|
-
vad: new
|
|
206
|
-
stt: new this.SpeechStreamImpl({
|
|
302
|
+
vad: new AdaptiveTurnDetector(),
|
|
303
|
+
stt: new this.SpeechStreamImpl({
|
|
304
|
+
onTranscript: (text) => this._onFeedFinal(feedId, text),
|
|
305
|
+
onPartial: (text) => this._onFeedPartial(feedId, text),
|
|
306
|
+
}),
|
|
207
307
|
prebuffer: [],
|
|
308
|
+
pending: [],
|
|
309
|
+
remoteSet: false,
|
|
310
|
+
wordCount: 0,
|
|
311
|
+
lastWordAt: 0,
|
|
312
|
+
wordGaps: [],
|
|
313
|
+
lastPartial: "",
|
|
314
|
+
partialAtCommit: "",
|
|
315
|
+
awaitingWordsSince: 0,
|
|
316
|
+
noiseCommit: false,
|
|
317
|
+
fallbackUsed: false,
|
|
318
|
+
finalTimer: null,
|
|
208
319
|
};
|
|
209
320
|
this.subscribers.set(feedId, sub);
|
|
210
321
|
pc.onTrack.subscribe((track) => {
|
|
@@ -217,11 +328,7 @@ class KazeeCallSession {
|
|
|
217
328
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
218
329
|
(error) => console.error("Kazee call STT connect:", error.message),
|
|
219
330
|
);
|
|
220
|
-
|
|
221
|
-
if (this.greeting && !this._greeted) {
|
|
222
|
-
this._greeted = true;
|
|
223
|
-
this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
|
|
224
|
-
}
|
|
331
|
+
this._maybeGreet();
|
|
225
332
|
} catch (error) {
|
|
226
333
|
if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
|
|
227
334
|
throw error;
|
|
@@ -229,6 +336,7 @@ class KazeeCallSession {
|
|
|
229
336
|
}
|
|
230
337
|
|
|
231
338
|
async _onSubscriberEvent(feedId, frame) {
|
|
339
|
+
if (frame?.janus === "trickle") return this._onTrickle(this.subscribers.get(feedId), frame);
|
|
232
340
|
try { await this._consumeSubscriberFrame(feedId, frame); }
|
|
233
341
|
catch (error) { console.error("Kazee call subscriber:", error.message); await this._removeSubscriber(feedId); }
|
|
234
342
|
}
|
|
@@ -245,6 +353,7 @@ class KazeeCallSession {
|
|
|
245
353
|
if (frame.jsep && !sub.started) {
|
|
246
354
|
sub.started = true;
|
|
247
355
|
await sub.pc.setRemoteDescription(frame.jsep);
|
|
356
|
+
this._flushTrickle(sub);
|
|
248
357
|
const answer = await sub.pc.createAnswer();
|
|
249
358
|
await sub.pc.setLocalDescription(answer);
|
|
250
359
|
await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
|
|
@@ -252,11 +361,105 @@ class KazeeCallSession {
|
|
|
252
361
|
}
|
|
253
362
|
}
|
|
254
363
|
|
|
255
|
-
|
|
364
|
+
_onFeedPartial(feedId, text) {
|
|
365
|
+
const sub = this.subscribers.get(feedId);
|
|
366
|
+
if (!sub || this.closed) return;
|
|
367
|
+
const partial = String(text || "");
|
|
368
|
+
const prevWords = countWords(sub.lastPartial);
|
|
369
|
+
const words = countWords(partial);
|
|
370
|
+
sub.lastPartial = partial;
|
|
371
|
+
// Growth only: a shrunk partial is a recogniser revision or a post-final
|
|
372
|
+
// buffer reset, not new words.
|
|
373
|
+
if (words <= prevWords) return;
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
if (sub.lastWordAt) {
|
|
376
|
+
sub.wordGaps.push(now - sub.lastWordAt);
|
|
377
|
+
if (sub.wordGaps.length > 8) sub.wordGaps.shift();
|
|
378
|
+
}
|
|
379
|
+
sub.wordCount += words - prevWords;
|
|
380
|
+
sub.lastWordAt = now;
|
|
381
|
+
if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Interims arrive in bursts whose cadence tracks recogniser health (jig
|
|
385
|
+
// measured 1–20s between new words on a struggling service); a fixed stall
|
|
386
|
+
// gate would cut callers off mid-sentence on a slow day.
|
|
387
|
+
_wordRescueGapMs(sub) {
|
|
388
|
+
if (!sub.wordGaps.length) return WORD_RESCUE_NO_CADENCE_MS;
|
|
389
|
+
const sorted = [...sub.wordGaps].sort((a, b) => a - b);
|
|
390
|
+
return Math.max(WORD_RESCUE_MIN_MS, 2 * sorted[Math.floor(sorted.length / 2)]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
_commitTurn(feedId, reason) {
|
|
394
|
+
const sub = this.subscribers.get(feedId);
|
|
395
|
+
if (!sub || this.closed) return;
|
|
396
|
+
sub.awaitingWordsSince = 0;
|
|
397
|
+
if (sub.vad.active) sub.vad.reset();
|
|
398
|
+
this._lastHeard = Date.now();
|
|
399
|
+
clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
|
|
400
|
+
sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
|
|
401
|
+
if (!sub.noiseCommit) {
|
|
402
|
+
this._playCue(ACK_CHIME_PCM48).catch(() => {});
|
|
403
|
+
// STT finalization takes seconds; start ticking at commit so the
|
|
404
|
+
// chime→first-tick stretch doesn't read as a dead line.
|
|
405
|
+
this._startThinking();
|
|
406
|
+
}
|
|
407
|
+
// Finals routinely never arrive; after finalWaitMs the interim transcript
|
|
408
|
+
// is dispatched in their place.
|
|
409
|
+
sub.partialAtCommit = sub.lastPartial;
|
|
410
|
+
sub.fallbackUsed = false;
|
|
411
|
+
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
412
|
+
sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
|
|
413
|
+
if (typeof sub.finalTimer.unref === "function") sub.finalTimer.unref();
|
|
414
|
+
sub.wordCount = 0;
|
|
415
|
+
sub.lastWordAt = 0;
|
|
416
|
+
sub.wordGaps = [];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_finalFallback(feedId) {
|
|
420
|
+
const sub = this.subscribers.get(feedId);
|
|
421
|
+
if (!sub || this.closed) return;
|
|
422
|
+
sub.finalTimer = null;
|
|
423
|
+
// Partials keep growing after commit while the decoder catches up; accept
|
|
424
|
+
// the grown text only if it extends the committed snapshot (anything else
|
|
425
|
+
// belongs to the next turn).
|
|
426
|
+
const current = sub.lastPartial || "";
|
|
427
|
+
const snapshot = sub.partialAtCommit || "";
|
|
428
|
+
const text = current.length > snapshot.length && current.startsWith(snapshot) ? current : snapshot;
|
|
429
|
+
sub.fallbackUsed = true;
|
|
430
|
+
clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
|
|
431
|
+
this._dispatchTranscript(feedId, text);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_onFeedFinal(feedId, text) {
|
|
435
|
+
const sub = this.subscribers.get(feedId);
|
|
436
|
+
if (sub?.finalTimer) {
|
|
437
|
+
clearTimeout(sub.finalTimer);
|
|
438
|
+
sub.finalTimer = null;
|
|
439
|
+
} else if (sub?.fallbackUsed) {
|
|
440
|
+
// The interim already went to the model; a late final would double-answer.
|
|
441
|
+
sub.fallbackUsed = false;
|
|
442
|
+
clog(`Kazee call late final dropped: feed=${feedId}`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this._dispatchTranscript(feedId, text);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_dispatchTranscript(feedId, text) {
|
|
256
449
|
const sub = this.subscribers.get(feedId);
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
450
|
+
const trimmed = String(text || "").trim();
|
|
451
|
+
if (sub?.noiseCommit) {
|
|
452
|
+
sub.noiseCommit = false;
|
|
453
|
+
// A turn the recogniser never put words in was background noise; babble
|
|
454
|
+
// occasionally leaks a single stray word into the transcript too.
|
|
455
|
+
if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
|
|
456
|
+
clog(`Kazee call noise turn dropped: feed=${feedId} words=${countWords(trimmed)}`);
|
|
457
|
+
if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
|
|
462
|
+
if (trimmed) this._startThinking();
|
|
260
463
|
// Empty finalization (noise commit): nothing dispatches, no reply is
|
|
261
464
|
// coming — don't tick into the void.
|
|
262
465
|
else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
@@ -267,20 +470,23 @@ class KazeeCallSession {
|
|
|
267
470
|
});
|
|
268
471
|
}
|
|
269
472
|
|
|
473
|
+
_writeFrame(frame) {
|
|
474
|
+
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
475
|
+
const opus = this.codec.encode(frame);
|
|
476
|
+
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
477
|
+
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
478
|
+
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
479
|
+
}), opus));
|
|
480
|
+
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
270
483
|
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
271
484
|
// moment an utterance is active (or starting), remaining frames are dropped.
|
|
272
485
|
async _playCue(pcm48) {
|
|
273
486
|
if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
|
|
274
487
|
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
275
488
|
if (this.closed || this.speaking || this._speakActive) return;
|
|
276
|
-
|
|
277
|
-
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
278
|
-
const opus = this.codec.encode(frame);
|
|
279
|
-
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
280
|
-
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
281
|
-
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
282
|
-
}), opus));
|
|
283
|
-
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
489
|
+
this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
|
|
284
490
|
await wait(20);
|
|
285
491
|
}
|
|
286
492
|
}
|
|
@@ -312,9 +518,30 @@ class KazeeCallSession {
|
|
|
312
518
|
}
|
|
313
519
|
let pcm;
|
|
314
520
|
try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
|
|
315
|
-
|
|
521
|
+
// Echo guard: raise the effective threshold while the bot speaks; the
|
|
522
|
+
// adaptive floor keeps sampling the un-boosted base so echo never pollutes
|
|
523
|
+
// the noise-floor estimate.
|
|
524
|
+
sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
|
|
316
525
|
const event = sub.vad.push(pcm);
|
|
317
526
|
const pcm16 = downsample48To16(pcm);
|
|
527
|
+
if (sub.awaitingWordsSince) {
|
|
528
|
+
// Energy closed the turn but the recogniser hasn't produced a word yet:
|
|
529
|
+
// hold the commit and keep streaming audio so a slow decode still lands.
|
|
530
|
+
this._feedAudio(sub, pcm16);
|
|
531
|
+
if (event?.type === "start") {
|
|
532
|
+
// Caller resumed before any words arrived — same turn continues.
|
|
533
|
+
sub.awaitingWordsSince = 0;
|
|
534
|
+
this._lastHeard = Date.now();
|
|
535
|
+
if (this.speaking) this.cancelSpeech();
|
|
536
|
+
this._stopThinking();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (Date.now() - sub.awaitingWordsSince >= WORD_WAIT_MAX_MS) {
|
|
540
|
+
sub.noiseCommit = true;
|
|
541
|
+
this._commitTurn(feedId, "no-words");
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
318
545
|
if (!sub.vad.active && !event) {
|
|
319
546
|
sub.prebuffer.push(pcm16);
|
|
320
547
|
if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
|
|
@@ -330,13 +557,22 @@ class KazeeCallSession {
|
|
|
330
557
|
}
|
|
331
558
|
this._feedAudio(sub, pcm16);
|
|
332
559
|
if (event?.type === "commit") {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
560
|
+
if (sub.wordCount > 0 || event.reason === "max-duration") {
|
|
561
|
+
if (sub.wordCount === 0) sub.noiseCommit = true;
|
|
562
|
+
this._commitTurn(feedId, event.reason);
|
|
563
|
+
} else {
|
|
564
|
+
sub.awaitingWordsSince = Date.now();
|
|
565
|
+
clog(`Kazee call commit held for words: feed=${feedId}`);
|
|
566
|
+
}
|
|
567
|
+
} else if (sub.vad.active && sub.wordCount >= MIN_DISPATCH_WORDS && sub.lastWordAt) {
|
|
568
|
+
// Steady background can keep every frame "loud" so the energy close
|
|
569
|
+
// never fires; when the word timeline has stalled well past its own
|
|
570
|
+
// cadence, the caller has finished — commit anyway.
|
|
571
|
+
const gap = this._wordRescueGapMs(sub);
|
|
572
|
+
if (Date.now() - sub.lastWordAt >= gap) {
|
|
573
|
+
clog(`Kazee call word-stall commit: feed=${feedId} gap=${gap}ms`);
|
|
574
|
+
this._commitTurn(feedId, "word-stall");
|
|
575
|
+
}
|
|
340
576
|
}
|
|
341
577
|
}
|
|
342
578
|
|
|
@@ -376,30 +612,100 @@ class KazeeCallSession {
|
|
|
376
612
|
})();
|
|
377
613
|
}
|
|
378
614
|
|
|
615
|
+
async _playPcm48(pcm48, gen) {
|
|
616
|
+
this._stopThinking();
|
|
617
|
+
this.speaking = true;
|
|
618
|
+
try {
|
|
619
|
+
// Drift-corrected pacing: a naive wait(20) accumulates encode time and
|
|
620
|
+
// timer slop, audibly stretching long utterances.
|
|
621
|
+
const startAt = Date.now();
|
|
622
|
+
for (let offset = 0, i = 1; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2, i += 1) {
|
|
623
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
624
|
+
this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
|
|
625
|
+
const delay = startAt + i * 20 - Date.now();
|
|
626
|
+
if (delay > 0) await wait(delay);
|
|
627
|
+
}
|
|
628
|
+
return true;
|
|
629
|
+
} finally {
|
|
630
|
+
this._lastSpokeAt = Date.now();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
379
634
|
async _playUtterance(text, gen) {
|
|
635
|
+
// Cache only audio rendered by the real service: the key is voice+text, so
|
|
636
|
+
// PCM from an injected synthesize would leak across sessions.
|
|
637
|
+
const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
|
|
638
|
+
const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE);
|
|
639
|
+
const cacheKey = `${ttsVoice.getVoice()}::${text}`;
|
|
380
640
|
const synthStart = Date.now();
|
|
641
|
+
if (cacheable && SYNTH_CACHE.has(cacheKey)) {
|
|
642
|
+
clog(`Kazee call speak: cached chars=${text.length}`);
|
|
643
|
+
return this._playPcm48(SYNTH_CACHE.get(cacheKey), gen);
|
|
644
|
+
}
|
|
645
|
+
if (this.synthesizeStream) {
|
|
646
|
+
try {
|
|
647
|
+
return await this._playStreaming(text, gen, { cacheable, cacheKey, synthStart });
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
650
|
+
clog(`Kazee call stream synth failed (${error.message}); buffering`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
381
653
|
const synthesized = await this.synthesize(text);
|
|
382
654
|
if (this.closed || gen !== this._speakGen) return false;
|
|
383
655
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
384
656
|
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
385
|
-
|
|
386
|
-
this.
|
|
657
|
+
if (cacheable) SYNTH_CACHE.set(cacheKey, pcm48);
|
|
658
|
+
return this._playPcm48(pcm48, gen);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Plays TTS as it downloads: the service streams PCM, but buffering the full
|
|
662
|
+
// response cost ~3s of silence before the first word on every reply.
|
|
663
|
+
async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
|
|
664
|
+
const { sampleRate, chunks } = await this.synthesizeStream(text);
|
|
665
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
666
|
+
let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
|
|
667
|
+
let frameCarry = Buffer.alloc(0);
|
|
668
|
+
const parts = cacheable ? [] : null;
|
|
669
|
+
let started = false;
|
|
670
|
+
let startAt = 0;
|
|
671
|
+
let i = 0;
|
|
387
672
|
try {
|
|
388
|
-
for (
|
|
673
|
+
for await (const chunk of chunks) {
|
|
389
674
|
if (this.closed || gen !== this._speakGen) return false;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
675
|
+
const raw = rawCarry.length ? Buffer.concat([rawCarry, chunk]) : chunk;
|
|
676
|
+
const usable = raw.length & ~1;
|
|
677
|
+
rawCarry = raw.subarray(usable);
|
|
678
|
+
if (!usable) continue;
|
|
679
|
+
const pcm48 = upsampleTo48(raw.subarray(0, usable), sampleRate);
|
|
680
|
+
if (parts) parts.push(pcm48);
|
|
681
|
+
if (!started) {
|
|
682
|
+
started = true;
|
|
683
|
+
clog(`Kazee call speak: first-audio=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
684
|
+
// Only now is the caller actually hearing us — until first audio the
|
|
685
|
+
// thinking ticks and barge-in semantics should stay in "waiting" mode.
|
|
686
|
+
this._stopThinking();
|
|
687
|
+
this.speaking = true;
|
|
688
|
+
startAt = Date.now();
|
|
689
|
+
}
|
|
690
|
+
frameCarry = frameCarry.length ? Buffer.concat([frameCarry, pcm48]) : pcm48;
|
|
691
|
+
let offset = 0;
|
|
692
|
+
for (; offset + WEBRTC_SAMPLES * 2 <= frameCarry.length; offset += WEBRTC_SAMPLES * 2) {
|
|
693
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
694
|
+
this._writeFrame(frameCarry.subarray(offset, offset + WEBRTC_SAMPLES * 2));
|
|
695
|
+
i += 1;
|
|
696
|
+
const delay = startAt + i * 20 - Date.now();
|
|
697
|
+
if (delay > 0) await wait(delay);
|
|
698
|
+
// The service stalled mid-utterance; re-anchor so playback resumes
|
|
699
|
+
// at normal pace instead of bursting the backlog.
|
|
700
|
+
else if (delay < -200) startAt = Date.now() - i * 20;
|
|
701
|
+
}
|
|
702
|
+
frameCarry = frameCarry.subarray(offset);
|
|
399
703
|
}
|
|
400
|
-
|
|
704
|
+
if (frameCarry.length && started && !this.closed && gen === this._speakGen) this._writeFrame(frameCarry);
|
|
705
|
+
if (parts && started) SYNTH_CACHE.set(cacheKey, Buffer.concat(parts));
|
|
706
|
+
return started;
|
|
401
707
|
} finally {
|
|
402
|
-
this._lastSpokeAt = Date.now();
|
|
708
|
+
if (started) this._lastSpokeAt = Date.now();
|
|
403
709
|
}
|
|
404
710
|
}
|
|
405
711
|
|
|
@@ -427,6 +733,7 @@ class KazeeCallSession {
|
|
|
427
733
|
const sub = this.subscribers.get(feedId);
|
|
428
734
|
if (!sub) return;
|
|
429
735
|
this.subscribers.delete(feedId);
|
|
736
|
+
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
430
737
|
try { sub.stt.close(); } catch (_) {}
|
|
431
738
|
try { sub.codec.close(); } catch (_) {}
|
|
432
739
|
try { await sub.pc.close(); } catch (_) {}
|
|
@@ -438,6 +745,7 @@ class KazeeCallSession {
|
|
|
438
745
|
this.closed = true;
|
|
439
746
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
440
747
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
748
|
+
this._clearPublishTimer();
|
|
441
749
|
this._stopThinking();
|
|
442
750
|
this.cancelSpeech();
|
|
443
751
|
for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
|
|
@@ -6,11 +6,12 @@ const { speechBearerToken } = require("../../../core/media");
|
|
|
6
6
|
const ttsVoice = require("../../../core/tts-voice");
|
|
7
7
|
|
|
8
8
|
class SpeechStream {
|
|
9
|
-
constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript } = {}) {
|
|
9
|
+
constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial } = {}) {
|
|
10
10
|
this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
|
|
11
11
|
this.token = token;
|
|
12
12
|
this.WebSocketImpl = WebSocketImpl;
|
|
13
13
|
this.onTranscript = onTranscript || (() => {});
|
|
14
|
+
this.onPartial = onPartial || (() => {});
|
|
14
15
|
this.socket = null;
|
|
15
16
|
this.ready = null;
|
|
16
17
|
}
|
|
@@ -47,8 +48,12 @@ class SpeechStream {
|
|
|
47
48
|
let message;
|
|
48
49
|
try { message = JSON.parse(raw.toString()); } catch (_) { return; }
|
|
49
50
|
const text = String(message.text || message.transcript || "").trim();
|
|
50
|
-
if (text
|
|
51
|
+
if (!text) return;
|
|
52
|
+
if (message.final === true || message.type === "final" || message.type === "transcription") {
|
|
51
53
|
Promise.resolve(this.onTranscript(text)).catch((error) => console.error("Kazee call transcript:", error.message));
|
|
54
|
+
} else {
|
|
55
|
+
// Interims drive word-based turn-end detection; they were previously discarded.
|
|
56
|
+
Promise.resolve(this.onPartial(text)).catch((error) => console.error("Kazee call partial:", error.message));
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -76,4 +81,34 @@ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speec
|
|
|
76
81
|
return { pcm: Buffer.from(await response.arrayBuffer()), sampleRate: rate };
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
// Same endpoint as synthesizePcm, but yields PCM chunks as they arrive so
|
|
85
|
+
// playback can start on the first byte instead of after the full download.
|
|
86
|
+
async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
|
|
87
|
+
if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
|
|
88
|
+
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
|
|
92
|
+
signal: AbortSignal.timeout(90000),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
|
|
95
|
+
const contentType = response.headers?.get?.("content-type") || "";
|
|
96
|
+
const rate = Number(contentType.match(/(?:^|;)\s*rate=(\d+)/i)?.[1] || 24000);
|
|
97
|
+
if (!Number.isFinite(rate) || rate < 8000 || rate > 48000) throw new Error(`Hosted TTS returned invalid PCM rate: ${rate}`);
|
|
98
|
+
const reader = response.body.getReader();
|
|
99
|
+
async function* chunks() {
|
|
100
|
+
try {
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) return;
|
|
104
|
+
if (value?.byteLength) yield Buffer.from(value);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
// Barge-in abandons the iterator mid-stream; stop the download too.
|
|
108
|
+
reader.cancel?.().catch?.(() => {});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { sampleRate: rate, chunks: chunks() };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { SpeechStream, synthesizePcm, synthesizeStream };
|