@inetafrica/open-claudia 3.2.1 → 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 +27 -0
- package/bin/cli.js +1 -1
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +24 -4
- 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 +359 -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 +52 -4
- package/core/loopback.js +41 -0
- package/core/media.js +71 -18
- package/core/pack-review.js +18 -0
- package/core/providers/utility-policy.js +4 -1
- package/core/relationship.js +18 -2
- package/core/router.js +28 -4
- package/core/runner.js +21 -4
- package/core/scheduler.js +60 -21
- package/core/speaker-attest.js +56 -0
- package/core/system-prompt.js +22 -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 +29 -1
- package/test-provider-scheduler.js +80 -0
- package/test-utility-provider-policy.js +22 -0
|
@@ -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();
|
|
@@ -107,6 +133,9 @@ class KazeeCallSession {
|
|
|
107
133
|
const created = await this.janus.message(this.publisherHandle, {
|
|
108
134
|
request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
|
|
109
135
|
description: `Call room ${this.roomId}`, permanent: false,
|
|
136
|
+
// Hidden from videoroom "list": the control plane is internet-reachable,
|
|
137
|
+
// so a listable room is eavesdroppable. Join-by-roomId is unaffected.
|
|
138
|
+
is_private: true,
|
|
110
139
|
});
|
|
111
140
|
const cdata = created?.plugindata?.data || {};
|
|
112
141
|
const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
|
|
@@ -139,6 +168,7 @@ class KazeeCallSession {
|
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
async _onPublisherEvent(frame) {
|
|
171
|
+
if (frame?.janus === "trickle") return this._onTrickle(this.publisher, frame);
|
|
142
172
|
try { await this._consumePublisherFrame(frame); }
|
|
143
173
|
catch (error) { console.error("Kazee call publisher:", error.message); await this.close("janus-error"); }
|
|
144
174
|
}
|
|
@@ -157,30 +187,100 @@ class KazeeCallSession {
|
|
|
157
187
|
}
|
|
158
188
|
}
|
|
159
189
|
if (data.videoroom === "joined") {
|
|
190
|
+
this._joined = true;
|
|
160
191
|
clog(`Kazee call room joined: room=${this.roomId} publishers=${(data.publishers || []).length}`);
|
|
161
|
-
if (!this.publisher) await this._publish();
|
|
162
192
|
}
|
|
193
|
+
if (this._joined && !this.publisher) await this._maybePublish();
|
|
163
194
|
const gone = Number(data.leaving || data.unpublished || 0);
|
|
164
195
|
if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
|
|
165
196
|
if (data.videoroom === "destroyed") await this.close("room-destroyed");
|
|
166
197
|
}
|
|
167
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
|
+
|
|
168
231
|
async _publish() {
|
|
169
232
|
if (this.publisher) return;
|
|
170
233
|
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
171
|
-
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
|
+
});
|
|
172
241
|
const track = new MediaStreamTrack({ kind: "audio" });
|
|
173
242
|
const sender = pc.addTrack(track);
|
|
174
|
-
this.publisher = { pc, track, sender };
|
|
243
|
+
this.publisher = { pc, track, sender, pending: [], remoteSet: false };
|
|
175
244
|
const offer = await pc.createOffer();
|
|
176
245
|
await pc.setLocalDescription(offer);
|
|
177
246
|
const configured = await this.janus.message(this.publisherHandle, { request: "configure", audio: true, video: false }, pc.localDescription);
|
|
178
247
|
const answer = configured?.jsep;
|
|
179
248
|
if (!answer) throw new Error("Janus publisher configure did not return SDP");
|
|
180
249
|
await pc.setRemoteDescription(answer);
|
|
250
|
+
this._flushTrickle(this.publisher);
|
|
181
251
|
clog(`Kazee call publisher configured: room=${this.roomId}`);
|
|
182
252
|
}
|
|
183
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
|
+
|
|
184
284
|
async _subscribe(feedId, display) {
|
|
185
285
|
if (this.subscribers.has(feedId)) return;
|
|
186
286
|
// Reserve the slot before the first await: the join response and the async
|
|
@@ -199,9 +299,23 @@ class KazeeCallSession {
|
|
|
199
299
|
display: String(display || ""),
|
|
200
300
|
// Opus decoders are stateful, so each feed needs its own.
|
|
201
301
|
codec: new OpusAudioCodec(),
|
|
202
|
-
vad: new
|
|
203
|
-
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
|
+
}),
|
|
204
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,
|
|
205
319
|
};
|
|
206
320
|
this.subscribers.set(feedId, sub);
|
|
207
321
|
pc.onTrack.subscribe((track) => {
|
|
@@ -214,11 +328,7 @@ class KazeeCallSession {
|
|
|
214
328
|
() => clog(`Kazee call STT connected: feed=${feedId}`),
|
|
215
329
|
(error) => console.error("Kazee call STT connect:", error.message),
|
|
216
330
|
);
|
|
217
|
-
|
|
218
|
-
if (this.greeting && !this._greeted) {
|
|
219
|
-
this._greeted = true;
|
|
220
|
-
this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
|
|
221
|
-
}
|
|
331
|
+
this._maybeGreet();
|
|
222
332
|
} catch (error) {
|
|
223
333
|
if (this.subscribers.get(feedId) === null) this.subscribers.delete(feedId);
|
|
224
334
|
throw error;
|
|
@@ -226,6 +336,7 @@ class KazeeCallSession {
|
|
|
226
336
|
}
|
|
227
337
|
|
|
228
338
|
async _onSubscriberEvent(feedId, frame) {
|
|
339
|
+
if (frame?.janus === "trickle") return this._onTrickle(this.subscribers.get(feedId), frame);
|
|
229
340
|
try { await this._consumeSubscriberFrame(feedId, frame); }
|
|
230
341
|
catch (error) { console.error("Kazee call subscriber:", error.message); await this._removeSubscriber(feedId); }
|
|
231
342
|
}
|
|
@@ -242,6 +353,7 @@ class KazeeCallSession {
|
|
|
242
353
|
if (frame.jsep && !sub.started) {
|
|
243
354
|
sub.started = true;
|
|
244
355
|
await sub.pc.setRemoteDescription(frame.jsep);
|
|
356
|
+
this._flushTrickle(sub);
|
|
245
357
|
const answer = await sub.pc.createAnswer();
|
|
246
358
|
await sub.pc.setLocalDescription(answer);
|
|
247
359
|
await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
|
|
@@ -249,11 +361,105 @@ class KazeeCallSession {
|
|
|
249
361
|
}
|
|
250
362
|
}
|
|
251
363
|
|
|
252
|
-
|
|
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) {
|
|
253
449
|
const sub = this.subscribers.get(feedId);
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
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();
|
|
257
463
|
// Empty finalization (noise commit): nothing dispatches, no reply is
|
|
258
464
|
// coming — don't tick into the void.
|
|
259
465
|
else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
@@ -264,20 +470,23 @@ class KazeeCallSession {
|
|
|
264
470
|
});
|
|
265
471
|
}
|
|
266
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
|
+
|
|
267
483
|
// Writes a short cue into the outbound track. Yields to real speech: the
|
|
268
484
|
// moment an utterance is active (or starting), remaining frames are dropped.
|
|
269
485
|
async _playCue(pcm48) {
|
|
270
486
|
if (this.closed || this.speaking || this._speakActive || !this.publisher) return;
|
|
271
487
|
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
272
488
|
if (this.closed || this.speaking || this._speakActive) return;
|
|
273
|
-
|
|
274
|
-
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
275
|
-
const opus = this.codec.encode(frame);
|
|
276
|
-
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
277
|
-
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
278
|
-
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
279
|
-
}), opus));
|
|
280
|
-
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
489
|
+
this._writeFrame(pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2));
|
|
281
490
|
await wait(20);
|
|
282
491
|
}
|
|
283
492
|
}
|
|
@@ -309,9 +518,30 @@ class KazeeCallSession {
|
|
|
309
518
|
}
|
|
310
519
|
let pcm;
|
|
311
520
|
try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
|
|
312
|
-
|
|
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;
|
|
313
525
|
const event = sub.vad.push(pcm);
|
|
314
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
|
+
}
|
|
315
545
|
if (!sub.vad.active && !event) {
|
|
316
546
|
sub.prebuffer.push(pcm16);
|
|
317
547
|
if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
|
|
@@ -327,13 +557,22 @@ class KazeeCallSession {
|
|
|
327
557
|
}
|
|
328
558
|
this._feedAudio(sub, pcm16);
|
|
329
559
|
if (event?.type === "commit") {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
+
}
|
|
337
576
|
}
|
|
338
577
|
}
|
|
339
578
|
|
|
@@ -373,30 +612,100 @@ class KazeeCallSession {
|
|
|
373
612
|
})();
|
|
374
613
|
}
|
|
375
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
|
+
|
|
376
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}`;
|
|
377
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
|
+
}
|
|
378
653
|
const synthesized = await this.synthesize(text);
|
|
379
654
|
if (this.closed || gen !== this._speakGen) return false;
|
|
380
655
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
381
656
|
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
382
|
-
|
|
383
|
-
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;
|
|
384
672
|
try {
|
|
385
|
-
for (
|
|
673
|
+
for await (const chunk of chunks) {
|
|
386
674
|
if (this.closed || gen !== this._speakGen) return false;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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);
|
|
396
703
|
}
|
|
397
|
-
|
|
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;
|
|
398
707
|
} finally {
|
|
399
|
-
this._lastSpokeAt = Date.now();
|
|
708
|
+
if (started) this._lastSpokeAt = Date.now();
|
|
400
709
|
}
|
|
401
710
|
}
|
|
402
711
|
|
|
@@ -424,6 +733,7 @@ class KazeeCallSession {
|
|
|
424
733
|
const sub = this.subscribers.get(feedId);
|
|
425
734
|
if (!sub) return;
|
|
426
735
|
this.subscribers.delete(feedId);
|
|
736
|
+
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
427
737
|
try { sub.stt.close(); } catch (_) {}
|
|
428
738
|
try { sub.codec.close(); } catch (_) {}
|
|
429
739
|
try { await sub.pc.close(); } catch (_) {}
|
|
@@ -435,6 +745,7 @@ class KazeeCallSession {
|
|
|
435
745
|
this.closed = true;
|
|
436
746
|
console.log(`Kazee call session closed: room=${this.roomId} reason=${reason}`);
|
|
437
747
|
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
748
|
+
this._clearPublishTimer();
|
|
438
749
|
this._stopThinking();
|
|
439
750
|
this.cancelSpeech();
|
|
440
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 };
|