@inetafrica/open-claudia 3.4.13 → 3.4.15
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 +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +171 -84
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +58 -6
- package/core/system-prompt.js +9 -1
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +217 -9
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
|
@@ -4,7 +4,8 @@ const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("w
|
|
|
4
4
|
const { JanusClient } = require("./janus-client");
|
|
5
5
|
const { projectAssignedIce, supportedIceServers } = require("./access");
|
|
6
6
|
const { AdaptiveTurnDetector, OpusAudioCodec, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
|
|
7
|
-
const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm } = require("./speech-client");
|
|
7
|
+
const { SpeechStream, synthesizePcm, synthesizeStream: synthesizeStreamPcm, detectTurn, selectAcknowledgement, CUE_PHRASES } = require("./speech-client");
|
|
8
|
+
const { SpeechTurnGate } = require("./turn-gate");
|
|
8
9
|
const ttsVoice = require("../../../core/tts-voice");
|
|
9
10
|
|
|
10
11
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -42,40 +43,18 @@ const WORD_RESCUE_MIN_MS = 1500;
|
|
|
42
43
|
const WORD_RESCUE_NO_CADENCE_MS = 4000;
|
|
43
44
|
const WORD_WAIT_MAX_MS = 4000;
|
|
44
45
|
const FINAL_WAIT_MS = 2500;
|
|
46
|
+
const SEGMENT_FINAL_WAIT_MS = 12000;
|
|
45
47
|
const MIN_DISPATCH_WORDS = 2;
|
|
46
48
|
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
47
49
|
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
48
50
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
// reads as a dropped call.
|
|
52
|
-
const THINK_TICK_INTERVAL_MS = 1600;
|
|
53
|
-
const THINKING_MAX_MS = 5 * 60_000;
|
|
54
|
-
|
|
55
|
-
function tonePcm48(segments, gain = 0.08) {
|
|
56
|
-
const rate = 48000;
|
|
57
|
-
const fade = Math.floor(rate * 0.012);
|
|
58
|
-
const buffers = segments.map(({ freq, ms }) => {
|
|
59
|
-
const samples = Math.floor((rate * ms) / 1000);
|
|
60
|
-
const buf = Buffer.alloc(samples * 2);
|
|
61
|
-
for (let i = 0; i < samples; i += 1) {
|
|
62
|
-
let a = gain;
|
|
63
|
-
if (i < fade) a *= i / fade;
|
|
64
|
-
if (i > samples - fade) a *= Math.max(0, (samples - i) / fade);
|
|
65
|
-
buf.writeInt16LE(Math.round(Math.sin((2 * Math.PI * freq * i) / rate) * a * 32767), i * 2);
|
|
66
|
-
}
|
|
67
|
-
return buf;
|
|
68
|
-
});
|
|
69
|
-
return Buffer.concat(buffers);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Rising two-tone "heard you", and a quieter single "still here" pulse.
|
|
73
|
-
const ACK_CHIME_PCM48 = tonePcm48([{ freq: 660, ms: 90 }, { freq: 990, ms: 110 }], 0.09);
|
|
74
|
-
const THINK_TICK_PCM48 = tonePcm48([{ freq: 520, ms: 90 }], 0.045);
|
|
51
|
+
// One optional spoken cue after a delay, never periodic beeps.
|
|
52
|
+
const ACK_DELAY_MS = 2800;
|
|
75
53
|
|
|
76
54
|
class KazeeCallSession {
|
|
77
55
|
constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
|
|
78
56
|
maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
|
|
57
|
+
turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart,
|
|
79
58
|
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
80
59
|
this.roomId = Number(roomId);
|
|
81
60
|
this.chatId = String(chatId);
|
|
@@ -103,6 +82,11 @@ class KazeeCallSession {
|
|
|
103
82
|
this._transportIceExpiry = new Map();
|
|
104
83
|
this._transportExpiryTimer = null;
|
|
105
84
|
this.onTranscript = onTranscript || (() => {});
|
|
85
|
+
this.onSpeechStart = onSpeechStart || (() => {});
|
|
86
|
+
this.turnDecision = turnDecision;
|
|
87
|
+
this.acknowledgement = acknowledgement;
|
|
88
|
+
this._cueController = null;
|
|
89
|
+
this._cueRevision = 0;
|
|
106
90
|
this.onEnded = onEnded || (() => {});
|
|
107
91
|
this.onMediaReady = onMediaReady || (() => {});
|
|
108
92
|
this._sentRtp = false;
|
|
@@ -256,8 +240,8 @@ class KazeeCallSession {
|
|
|
256
240
|
}
|
|
257
241
|
for (const publisher of data.publishers || []) {
|
|
258
242
|
const feedId = Number(publisher.id);
|
|
259
|
-
if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId)
|
|
260
|
-
await this._subscribe(feedId, publisher.display);
|
|
243
|
+
if (Number.isSafeInteger(feedId) && feedId > 0 && feedId !== this.publisherId && !this.subscribers.has(feedId)) {
|
|
244
|
+
await this._subscribe(feedId, publisher.display, publisher.metadata);
|
|
261
245
|
}
|
|
262
246
|
}
|
|
263
247
|
if (data.videoroom === "joined") {
|
|
@@ -357,8 +341,21 @@ class KazeeCallSession {
|
|
|
357
341
|
this.speak(this.greeting).catch((error) => console.error("Kazee call greeting:", error.message));
|
|
358
342
|
}
|
|
359
343
|
|
|
360
|
-
async _subscribe(feedId, display) {
|
|
361
|
-
if (this.subscribers.has(feedId)) return;
|
|
344
|
+
async _subscribe(feedId, display, metadata) {
|
|
345
|
+
if (this.closed || feedId === this.publisherId || this.subscribers.has(feedId)) return;
|
|
346
|
+
if (this.managedRoom) {
|
|
347
|
+
// Only the enforced gateway owns this metadata. Display remains an email
|
|
348
|
+
// for presentation, and cannot tell us whether a publisher is another bot.
|
|
349
|
+
// Missing or malformed classification must not start STT on an unknown feed.
|
|
350
|
+
const principal = metadata?.kazee;
|
|
351
|
+
if (!principal || Array.isArray(principal) || principal.version !== 1 ||
|
|
352
|
+
typeof principal.userId !== "string" || !/^[a-f\d]{24}$/i.test(principal.userId) ||
|
|
353
|
+
!["User", "Bot"].includes(principal.principalKind) || principal.principalKind === "Bot" ||
|
|
354
|
+
principal.userId.toLowerCase() === this.botUserId.toLowerCase()) return;
|
|
355
|
+
} else if (String(display || "").startsWith("open-claudia:")) {
|
|
356
|
+
// Legacy/direct rooms allow client metadata, so it is not a trusted source.
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
362
359
|
// Reserve the slot before the first await: the join response and the async
|
|
363
360
|
// publishers event both list the same feed, and while attach() is in
|
|
364
361
|
// flight the second frame passes the caller's has() check and subscribes
|
|
@@ -379,8 +376,9 @@ class KazeeCallSession {
|
|
|
379
376
|
codec: new OpusAudioCodec(),
|
|
380
377
|
vad: new AdaptiveTurnDetector(),
|
|
381
378
|
stt: new this.SpeechStreamImpl({
|
|
382
|
-
onTranscript: (text) => this._onFeedFinal(feedId, text),
|
|
383
|
-
onPartial: (text) => this._onFeedPartial(feedId, text),
|
|
379
|
+
onTranscript: (text, meta) => this._onFeedFinal(feedId, text, meta),
|
|
380
|
+
onPartial: (text, meta) => this._onFeedPartial(feedId, text, meta),
|
|
381
|
+
onDisconnect: () => this._resetRecognition(feedId),
|
|
384
382
|
}),
|
|
385
383
|
prebuffer: [],
|
|
386
384
|
pending: [],
|
|
@@ -394,7 +392,9 @@ class KazeeCallSession {
|
|
|
394
392
|
noiseCommit: false,
|
|
395
393
|
fallbackUsed: false,
|
|
396
394
|
finalTimer: null,
|
|
395
|
+
segments: new Map(),
|
|
397
396
|
};
|
|
397
|
+
sub.turnGate = new SpeechTurnGate({ decide: this.turnDecision, commit: reason => this._commitTurn(feedId, reason) });
|
|
398
398
|
this.subscribers.set(feedId, sub);
|
|
399
399
|
pc.onTrack.subscribe((track) => {
|
|
400
400
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
@@ -439,9 +439,26 @@ class KazeeCallSession {
|
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
-
|
|
442
|
+
_resetRecognition(feedId) {
|
|
443
|
+
const sub = this.subscribers.get(feedId);
|
|
444
|
+
if (!sub) return;
|
|
445
|
+
for (const segment of sub.segments.values()) clearTimeout(segment.timer);
|
|
446
|
+
sub.segments.clear();
|
|
447
|
+
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
448
|
+
sub.finalTimer = null;
|
|
449
|
+
sub.lastPartial = sub.partialAtCommit = "";
|
|
450
|
+
sub.wordCount = sub.lastWordAt = sub.awaitingWordsSince = 0;
|
|
451
|
+
sub.wordGaps = [];
|
|
452
|
+
sub.noiseCommit = sub.fallbackUsed = false;
|
|
453
|
+
sub.vad.reset();
|
|
454
|
+
sub.turnGate?.reset();
|
|
455
|
+
this._stopThinking();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_onFeedPartial(feedId, text, meta = {}) {
|
|
443
459
|
const sub = this.subscribers.get(feedId);
|
|
444
460
|
if (!sub || this.closed) return;
|
|
461
|
+
if (meta.protocol === 2 && (meta.streamId !== sub.stt.streamId || meta.segmentId !== sub.stt.segmentId)) return;
|
|
445
462
|
const partial = String(text || "");
|
|
446
463
|
const prevWords = countWords(sub.lastPartial);
|
|
447
464
|
const words = countWords(partial);
|
|
@@ -456,7 +473,7 @@ class KazeeCallSession {
|
|
|
456
473
|
}
|
|
457
474
|
sub.wordCount += words - prevWords;
|
|
458
475
|
sub.lastWordAt = now;
|
|
459
|
-
if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
|
|
476
|
+
if (sub.awaitingWordsSince && sub.stt.protocol !== 2) this._commitTurn(feedId, "words-arrived");
|
|
460
477
|
}
|
|
461
478
|
|
|
462
479
|
// Interims arrive in bursts whose cadence tracks recogniser health (jig
|
|
@@ -472,23 +489,49 @@ class KazeeCallSession {
|
|
|
472
489
|
const sub = this.subscribers.get(feedId);
|
|
473
490
|
if (!sub || this.closed) return;
|
|
474
491
|
sub.awaitingWordsSince = 0;
|
|
492
|
+
sub.turnGate?.reset();
|
|
475
493
|
if (sub.vad.active) sub.vad.reset();
|
|
476
494
|
this._lastHeard = Date.now();
|
|
477
495
|
clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
|
|
478
|
-
sub.stt.
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
496
|
+
const versioned = sub.stt.protocol === 2 && typeof sub.stt.commitTurn === "function";
|
|
497
|
+
if (versioned) {
|
|
498
|
+
if (sub.segments.size >= 4) {
|
|
499
|
+
this.close("speech-backlog").catch(() => {});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const commit = sub.stt.commitTurn();
|
|
503
|
+
const key = `${commit.streamId}:${commit.segmentId}`;
|
|
504
|
+
const segment = { noiseCommit: sub.noiseCommit, committedAt: Date.now(), timer: null };
|
|
505
|
+
sub.segments.set(key, segment);
|
|
506
|
+
segment.timer = setTimeout(() => {
|
|
507
|
+
if (!sub.segments.delete(key)) return;
|
|
508
|
+
clog(`Kazee call final timeout: feed=${feedId} segment=${key}`);
|
|
509
|
+
this._stopThinking();
|
|
510
|
+
// Never substitute an old or unfinished transcript. Ask for a repeat
|
|
511
|
+
// only when the caller is quiet, so recovery cannot interrupt them.
|
|
512
|
+
if (!this.closed && !segment.noiseCommit && !this._speakActive &&
|
|
513
|
+
![...this.subscribers.values()].some(peer => peer?.vad?.active)) {
|
|
514
|
+
this.speak("I missed that. Could you repeat it?").catch(() => {});
|
|
515
|
+
}
|
|
516
|
+
}, SEGMENT_FINAL_WAIT_MS);
|
|
517
|
+
segment.timer.unref?.();
|
|
518
|
+
commit.pending.catch(() => {
|
|
519
|
+
clearTimeout(segment.timer);
|
|
520
|
+
sub.segments.delete(key);
|
|
521
|
+
clog(`Kazee call STT commit failed: feed=${feedId} segment=${key}`);
|
|
522
|
+
});
|
|
523
|
+
} else {
|
|
524
|
+
sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
|
|
525
|
+
}
|
|
526
|
+
// Recognition must finish before any acknowledgement claims a heard turn.
|
|
527
|
+
// The legacy fallback is retained only for older speech deployments.
|
|
487
528
|
sub.partialAtCommit = sub.lastPartial;
|
|
529
|
+
sub.lastPartial = "";
|
|
488
530
|
sub.fallbackUsed = false;
|
|
489
531
|
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
490
|
-
sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
|
|
491
|
-
|
|
532
|
+
sub.finalTimer = versioned ? null : setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
|
|
533
|
+
sub.finalTimer?.unref?.();
|
|
534
|
+
if (versioned) sub.noiseCommit = false;
|
|
492
535
|
sub.wordCount = 0;
|
|
493
536
|
sub.lastWordAt = 0;
|
|
494
537
|
sub.wordGaps = [];
|
|
@@ -498,19 +541,28 @@ class KazeeCallSession {
|
|
|
498
541
|
const sub = this.subscribers.get(feedId);
|
|
499
542
|
if (!sub || this.closed) return;
|
|
500
543
|
sub.finalTimer = null;
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// belongs to the next turn).
|
|
504
|
-
const current = sub.lastPartial || "";
|
|
544
|
+
// Legacy services cannot label late partials. Use only the committed
|
|
545
|
+
// snapshot; newer text could belong to a different utterance.
|
|
505
546
|
const snapshot = sub.partialAtCommit || "";
|
|
506
|
-
const text =
|
|
547
|
+
const text = snapshot;
|
|
507
548
|
sub.fallbackUsed = true;
|
|
508
549
|
clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
|
|
509
550
|
this._dispatchTranscript(feedId, text);
|
|
510
551
|
}
|
|
511
552
|
|
|
512
|
-
_onFeedFinal(feedId, text) {
|
|
553
|
+
_onFeedFinal(feedId, text, meta = {}) {
|
|
513
554
|
const sub = this.subscribers.get(feedId);
|
|
555
|
+
if (!sub || this.closed) return;
|
|
556
|
+
if (meta.protocol === 2) {
|
|
557
|
+
const key = `${meta.streamId}:${meta.segmentId}`;
|
|
558
|
+
const segment = sub.segments.get(key);
|
|
559
|
+
if (!segment || meta.streamId !== sub.stt.streamId) return;
|
|
560
|
+
sub.segments.delete(key);
|
|
561
|
+
clearTimeout(segment.timer);
|
|
562
|
+
clog(`Kazee call final ready: feed=${feedId} segment=${key} wait=${Date.now() - segment.committedAt}ms`);
|
|
563
|
+
this._dispatchTranscript(feedId, text, { noiseCommit: segment.noiseCommit, utteranceId: key });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
514
566
|
if (sub?.finalTimer) {
|
|
515
567
|
clearTimeout(sub.finalTimer);
|
|
516
568
|
sub.finalTimer = null;
|
|
@@ -523,11 +575,11 @@ class KazeeCallSession {
|
|
|
523
575
|
this._dispatchTranscript(feedId, text);
|
|
524
576
|
}
|
|
525
577
|
|
|
526
|
-
_dispatchTranscript(feedId, text) {
|
|
578
|
+
_dispatchTranscript(feedId, text, options = {}) {
|
|
527
579
|
const sub = this.subscribers.get(feedId);
|
|
528
580
|
const trimmed = String(text || "").trim();
|
|
529
|
-
if (sub?.noiseCommit) {
|
|
530
|
-
sub.noiseCommit = false;
|
|
581
|
+
if (options.noiseCommit ?? sub?.noiseCommit) {
|
|
582
|
+
if (options.noiseCommit === undefined) sub.noiseCommit = false;
|
|
531
583
|
// A turn the recogniser never put words in was background noise; babble
|
|
532
584
|
// occasionally leaks a single stray word into the transcript too.
|
|
533
585
|
if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
|
|
@@ -537,14 +589,16 @@ class KazeeCallSession {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
|
|
540
|
-
if (trimmed) this._startThinking();
|
|
592
|
+
if (trimmed) this._startThinking(trimmed);
|
|
541
593
|
// Empty finalization (noise commit): nothing dispatches, no reply is
|
|
542
594
|
// coming — don't tick into the void.
|
|
543
595
|
else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
596
|
+
if (!trimmed) return;
|
|
544
597
|
this.onTranscript(text, {
|
|
545
598
|
feedId,
|
|
546
599
|
display: sub?.display || "",
|
|
547
600
|
participants: this.subscribers.size,
|
|
601
|
+
...(options.utteranceId ? { utteranceId: options.utteranceId } : {}),
|
|
548
602
|
});
|
|
549
603
|
}
|
|
550
604
|
|
|
@@ -567,32 +621,34 @@ class KazeeCallSession {
|
|
|
567
621
|
this._reportMediaReady();
|
|
568
622
|
}
|
|
569
623
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
this.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
this.
|
|
588
|
-
},
|
|
589
|
-
|
|
624
|
+
_startThinking(text = "", state = "waiting") {
|
|
625
|
+
this._stopThinking();
|
|
626
|
+
if (this.closed || !text) return;
|
|
627
|
+
const revision = this._cueRevision;
|
|
628
|
+
this._thinkTimer = setTimeout(async () => {
|
|
629
|
+
this._thinkTimer = null;
|
|
630
|
+
if (this.closed || this.speaking || this._speakActive ||
|
|
631
|
+
[...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending)) return;
|
|
632
|
+
const controller = new AbortController();
|
|
633
|
+
this._cueController = controller;
|
|
634
|
+
try {
|
|
635
|
+
const cue = await this.acknowledgement(text, state, { signal: controller.signal });
|
|
636
|
+
if (cue && revision === this._cueRevision && !this.closed && !this._speakActive &&
|
|
637
|
+
![...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending)) {
|
|
638
|
+
await this.speak(cue);
|
|
639
|
+
}
|
|
640
|
+
} catch (_) { /* A missing cue never delays an answer. */ }
|
|
641
|
+
finally { if (this._cueController === controller) this._cueController = null; }
|
|
642
|
+
}, ACK_DELAY_MS);
|
|
643
|
+
this._thinkTimer.unref?.();
|
|
590
644
|
}
|
|
591
645
|
|
|
592
646
|
_stopThinking() {
|
|
593
|
-
|
|
594
|
-
|
|
647
|
+
this._cueRevision += 1;
|
|
648
|
+
if (this._thinkTimer) clearTimeout(this._thinkTimer);
|
|
595
649
|
this._thinkTimer = null;
|
|
650
|
+
this._cueController?.abort();
|
|
651
|
+
this._cueController = null;
|
|
596
652
|
}
|
|
597
653
|
|
|
598
654
|
_onFeedRtp(feedId, packet) {
|
|
@@ -613,6 +669,28 @@ class KazeeCallSession {
|
|
|
613
669
|
sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
|
|
614
670
|
const event = sub.vad.push(pcm);
|
|
615
671
|
const pcm16 = downsample48To16(pcm);
|
|
672
|
+
if (sub.stt.protocol === 2 && sub.turnGate) {
|
|
673
|
+
if (event?.type === "start") {
|
|
674
|
+
sub.turnGate.resume();
|
|
675
|
+
this._lastHeard = Date.now();
|
|
676
|
+
this.cancelSpeech();
|
|
677
|
+
this._stopThinking();
|
|
678
|
+
this.onSpeechStart({ feedId });
|
|
679
|
+
for (const frame of sub.prebuffer.splice(0)) {
|
|
680
|
+
sub.turnGate.audio(frame);
|
|
681
|
+
this._feedAudio(sub, frame);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (sub.vad.active || event || sub.turnGate.pending) {
|
|
685
|
+
sub.turnGate.audio(pcm16);
|
|
686
|
+
this._feedAudio(sub, pcm16);
|
|
687
|
+
if (event?.type === "commit") sub.turnGate.candidate(event.reason);
|
|
688
|
+
} else {
|
|
689
|
+
sub.prebuffer.push(pcm16);
|
|
690
|
+
if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
616
694
|
if (sub.awaitingWordsSince) {
|
|
617
695
|
// Energy closed the turn but the recogniser hasn't produced a word yet:
|
|
618
696
|
// hold the commit and keep streaming audio so a slow decode still lands.
|
|
@@ -621,7 +699,8 @@ class KazeeCallSession {
|
|
|
621
699
|
// Caller resumed before any words arrived — same turn continues.
|
|
622
700
|
sub.awaitingWordsSince = 0;
|
|
623
701
|
this._lastHeard = Date.now();
|
|
624
|
-
|
|
702
|
+
this.cancelSpeech();
|
|
703
|
+
this.onSpeechStart({ feedId });
|
|
625
704
|
this._stopThinking();
|
|
626
705
|
return;
|
|
627
706
|
}
|
|
@@ -640,7 +719,8 @@ class KazeeCallSession {
|
|
|
640
719
|
this._lastHeard = Date.now();
|
|
641
720
|
clog(`Kazee call VAD start: feed=${feedId}`);
|
|
642
721
|
// Barge-in: the human wins — stop talking and listen.
|
|
643
|
-
|
|
722
|
+
this.cancelSpeech();
|
|
723
|
+
this.onSpeechStart({ feedId });
|
|
644
724
|
this._stopThinking();
|
|
645
725
|
for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
|
|
646
726
|
}
|
|
@@ -681,6 +761,7 @@ class KazeeCallSession {
|
|
|
681
761
|
|
|
682
762
|
cancelSpeech() {
|
|
683
763
|
this._speakGen += 1;
|
|
764
|
+
this._synthController?.abort();
|
|
684
765
|
for (const item of this._speakQueue.splice(0)) item.resolve(false);
|
|
685
766
|
}
|
|
686
767
|
|
|
@@ -691,8 +772,11 @@ class KazeeCallSession {
|
|
|
691
772
|
try {
|
|
692
773
|
while (this._speakQueue.length && !this.closed) {
|
|
693
774
|
const item = this._speakQueue.shift();
|
|
775
|
+
const controller = new AbortController();
|
|
776
|
+
this._synthController = controller;
|
|
694
777
|
try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
|
|
695
|
-
catch (error) { item.reject(error); }
|
|
778
|
+
catch (error) { if (controller.signal.aborted) item.resolve(false); else item.reject(error); }
|
|
779
|
+
finally { if (this._synthController === controller) this._synthController = null; }
|
|
696
780
|
}
|
|
697
781
|
} finally {
|
|
698
782
|
this._speakActive = false;
|
|
@@ -724,7 +808,7 @@ class KazeeCallSession {
|
|
|
724
808
|
// Cache only audio rendered by the real service: the key is voice+text, so
|
|
725
809
|
// PCM from an injected synthesize would leak across sessions.
|
|
726
810
|
const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
|
|
727
|
-
const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE);
|
|
811
|
+
const cacheable = usingService && (text === this.greeting || text === MAX_DURATION_GOODBYE || text === IDLE_GOODBYE || Object.values(CUE_PHRASES).includes(text));
|
|
728
812
|
const cacheKey = `${ttsVoice.getVoice()}::${text}`;
|
|
729
813
|
const synthStart = Date.now();
|
|
730
814
|
if (cacheable && SYNTH_CACHE.has(cacheKey)) {
|
|
@@ -739,7 +823,7 @@ class KazeeCallSession {
|
|
|
739
823
|
clog(`Kazee call stream synth failed (${error.message}); buffering`);
|
|
740
824
|
}
|
|
741
825
|
}
|
|
742
|
-
const synthesized = await this.synthesize(text);
|
|
826
|
+
const synthesized = await this.synthesize(text, { signal: this._synthController?.signal });
|
|
743
827
|
if (this.closed || gen !== this._speakGen) return false;
|
|
744
828
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
745
829
|
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
@@ -750,7 +834,7 @@ class KazeeCallSession {
|
|
|
750
834
|
// Plays TTS as it downloads: the service streams PCM, but buffering the full
|
|
751
835
|
// response cost ~3s of silence before the first word on every reply.
|
|
752
836
|
async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
|
|
753
|
-
const { sampleRate, chunks } = await this.synthesizeStream(text);
|
|
837
|
+
const { sampleRate, chunks } = await this.synthesizeStream(text, { signal: this._synthController?.signal });
|
|
754
838
|
if (this.closed || gen !== this._speakGen) return false;
|
|
755
839
|
let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
|
|
756
840
|
let frameCarry = Buffer.alloc(0);
|
|
@@ -831,6 +915,9 @@ class KazeeCallSession {
|
|
|
831
915
|
this._transportIceExpiry.delete(sub.pc);
|
|
832
916
|
this._armTransportExpiry();
|
|
833
917
|
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
918
|
+
for (const segment of sub.segments.values()) clearTimeout(segment.timer);
|
|
919
|
+
sub.segments.clear();
|
|
920
|
+
sub.turnGate?.close();
|
|
834
921
|
try { sub.stt.close(); } catch (_) {}
|
|
835
922
|
try { sub.codec.close(); } catch (_) {}
|
|
836
923
|
try { await sub.pc.close(); } catch (_) {}
|
|
@@ -6,58 +6,104 @@ 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, onPartial } = {}) {
|
|
9
|
+
constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript, onPartial, onDisconnect } = {}) {
|
|
10
10
|
this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
|
|
11
11
|
this.token = token;
|
|
12
12
|
this.WebSocketImpl = WebSocketImpl;
|
|
13
13
|
this.onTranscript = onTranscript || (() => {});
|
|
14
14
|
this.onPartial = onPartial || (() => {});
|
|
15
|
+
this.onDisconnect = onDisconnect || (() => {});
|
|
15
16
|
this.socket = null;
|
|
16
17
|
this.ready = null;
|
|
18
|
+
this.protocol = 1;
|
|
19
|
+
this._protocolCommitted = false;
|
|
20
|
+
this.segmentId = 1;
|
|
21
|
+
this.streamId = 0;
|
|
22
|
+
this.closed = false;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
async connect() {
|
|
26
|
+
if (this.closed) throw new Error("Speech stream is closed");
|
|
20
27
|
if (this.ready) return this.ready;
|
|
21
28
|
if (!this.baseUrl || !this.token) throw new Error("Hosted speech service is not configured");
|
|
22
29
|
const url = this.baseUrl.replace(/^http/, "ws") + "/v1/audio/transcriptions/stream";
|
|
23
30
|
this.ready = new Promise((resolve, reject) => {
|
|
24
31
|
const ws = new this.WebSocketImpl(url, { headers: { Authorization: `Bearer ${this.token}` } });
|
|
25
32
|
this.socket = ws;
|
|
33
|
+
this.segmentId = 1;
|
|
34
|
+
this.protocol = 1;
|
|
35
|
+
this._protocolCommitted = false;
|
|
36
|
+
const streamId = ++this.streamId;
|
|
26
37
|
ws.once("open", () => {
|
|
27
|
-
|
|
38
|
+
if (this.closed || this.socket !== ws) return reject(new Error("Speech stream is closed"));
|
|
39
|
+
ws.send(JSON.stringify({ sample_rate: 16000, language: "en", protocol: 2 }));
|
|
28
40
|
resolve();
|
|
29
41
|
});
|
|
30
42
|
ws.once("error", reject);
|
|
31
|
-
ws.on("message", (raw) => this._onMessage(raw));
|
|
32
|
-
ws.on("close", () => {
|
|
43
|
+
ws.on("message", (raw) => { if (this.socket === ws && !this.closed) this._onMessage(raw, streamId); });
|
|
44
|
+
ws.on("close", () => {
|
|
45
|
+
reject(new Error("Speech stream disconnected"));
|
|
46
|
+
if (this.socket !== ws) return;
|
|
47
|
+
this.socket = null;
|
|
48
|
+
this.ready = null;
|
|
49
|
+
this.protocol = 1;
|
|
50
|
+
this._protocolCommitted = false;
|
|
51
|
+
this.onDisconnect({ streamId });
|
|
52
|
+
});
|
|
33
53
|
});
|
|
34
54
|
return this.ready;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
async audio(pcm16k) {
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
const pending = this.connect();
|
|
59
|
+
const socket = this.socket;
|
|
60
|
+
await pending;
|
|
61
|
+
if (this.closed || this.socket !== socket) return;
|
|
62
|
+
if (socket?.readyState === this.WebSocketImpl.OPEN) socket.send(pcm16k);
|
|
40
63
|
}
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
commitTurn() {
|
|
66
|
+
const connected = this.connect();
|
|
67
|
+
const streamId = this.streamId;
|
|
68
|
+
const segmentId = this.segmentId++;
|
|
69
|
+
// The session has already selected bookkeeping for this protocol. Pin it
|
|
70
|
+
// for this socket before any asynchronous send or delayed ready frame.
|
|
71
|
+
// V2 servers accept V1 commits too, so an early turn can safely retain
|
|
72
|
+
// legacy behavior until reconnect; it cannot switch final-routing mid-turn.
|
|
73
|
+
this._protocolCommitted = true;
|
|
74
|
+
const protocol = this.protocol;
|
|
75
|
+
const pending = connected.then(() => {
|
|
76
|
+
if (this.closed || streamId !== this.streamId || !this.socket) throw new Error("Speech stream disconnected before commit");
|
|
77
|
+
this.socket.send(JSON.stringify({ type: "commit", ...(protocol === 2 ? { segment_id: segmentId } : {}) }));
|
|
78
|
+
});
|
|
79
|
+
return { streamId, segmentId, pending };
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
|
|
82
|
+
commit() { return this.commitTurn().pending; }
|
|
83
|
+
|
|
84
|
+
_onMessage(raw, streamId = this.streamId) {
|
|
48
85
|
let message;
|
|
49
86
|
try { message = JSON.parse(raw.toString()); } catch (_) { return; }
|
|
87
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return;
|
|
88
|
+
if (message.type === "ready" && message.protocol === 2 && message.segment_id === 1) {
|
|
89
|
+
if (!this._protocolCommitted) this.protocol = 2;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const meta = { streamId, segmentId: message.segment_id, protocol: this.protocol };
|
|
93
|
+
if (this.protocol === 2 && (!Number.isSafeInteger(meta.segmentId) || meta.segmentId < 1)) return;
|
|
50
94
|
const text = String(message.text || message.transcript || "").trim();
|
|
51
|
-
if (!text) return;
|
|
52
95
|
if (message.final === true || message.type === "final" || message.type === "transcription") {
|
|
53
|
-
|
|
54
|
-
|
|
96
|
+
// Empty finals are meaningful: they close that segment without reusing
|
|
97
|
+
// an earlier partial. The session owns exactly-once dispatch.
|
|
98
|
+
Promise.resolve(this.onTranscript(text, meta)).catch((error) => console.error("Kazee call transcript:", error.message));
|
|
99
|
+
} else if (text && message.type === "partial") {
|
|
55
100
|
// 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));
|
|
101
|
+
Promise.resolve(this.onPartial(text, meta)).catch((error) => console.error("Kazee call partial:", error.message));
|
|
57
102
|
}
|
|
58
103
|
}
|
|
59
104
|
|
|
60
105
|
close() {
|
|
106
|
+
this.closed = true;
|
|
61
107
|
const ws = this.socket;
|
|
62
108
|
this.socket = null;
|
|
63
109
|
this.ready = null;
|
|
@@ -66,13 +112,13 @@ class SpeechStream {
|
|
|
66
112
|
}
|
|
67
113
|
}
|
|
68
114
|
|
|
69
|
-
async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
|
|
115
|
+
async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
|
|
70
116
|
if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
|
|
71
117
|
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
|
|
72
118
|
method: "POST",
|
|
73
119
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
74
120
|
body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
|
|
75
|
-
signal: AbortSignal.timeout(60000),
|
|
121
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000),
|
|
76
122
|
});
|
|
77
123
|
if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
|
|
78
124
|
const contentType = response.headers?.get?.("content-type") || "";
|
|
@@ -83,13 +129,13 @@ async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speec
|
|
|
83
129
|
|
|
84
130
|
// Same endpoint as synthesizePcm, but yields PCM chunks as they arrive so
|
|
85
131
|
// 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 } = {}) {
|
|
132
|
+
async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal } = {}) {
|
|
87
133
|
if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
|
|
88
134
|
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
|
|
89
135
|
method: "POST",
|
|
90
136
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
91
137
|
body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
|
|
92
|
-
signal: AbortSignal.timeout(90000),
|
|
138
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(90000)]) : AbortSignal.timeout(90000),
|
|
93
139
|
});
|
|
94
140
|
if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
|
|
95
141
|
const contentType = response.headers?.get?.("content-type") || "";
|
|
@@ -111,4 +157,35 @@ async function synthesizeStream(text, { baseUrl = SPEECH_SERVICE_URL, token = sp
|
|
|
111
157
|
return { sampleRate: rate, chunks: chunks() };
|
|
112
158
|
}
|
|
113
159
|
|
|
114
|
-
|
|
160
|
+
const CUE_PHRASES = Object.freeze({ none: "", acknowledge: "Mhm.", wait: "One moment.", working: "I'm checking that." });
|
|
161
|
+
|
|
162
|
+
async function controllerRequest(path, body, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch, signal, binary = false } = {}) {
|
|
163
|
+
if (!baseUrl || !token) throw new Error("Hosted speech controller is not configured");
|
|
164
|
+
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/${path}`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": binary ? "application/octet-stream" : "application/json" },
|
|
167
|
+
body: binary ? body : JSON.stringify(body),
|
|
168
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(1800)]) : AbortSignal.timeout(1800),
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok) throw new Error("Hosted speech controller unavailable");
|
|
171
|
+
const result = await response.json();
|
|
172
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Invalid controller response");
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function detectTurn(pcm16, options = {}) {
|
|
177
|
+
const result = await controllerRequest("turn", pcm16, { ...options, binary: true });
|
|
178
|
+
if (typeof result.complete !== "boolean" || typeof result.probability !== "number" ||
|
|
179
|
+
!Number.isFinite(result.probability) || result.probability < 0 || result.probability > 1) throw new Error("Invalid turn decision");
|
|
180
|
+
return { complete: result.complete, probability: result.probability };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function selectAcknowledgement(text, state = "waiting", options = {}) {
|
|
184
|
+
const allowed = { listening: ["none", "acknowledge"], waiting: ["none", "wait"], working: ["none", "working"] }[state];
|
|
185
|
+
if (!allowed) return "";
|
|
186
|
+
const result = await controllerRequest("acknowledgement", { text: String(text).slice(-1000), state }, options);
|
|
187
|
+
// Never speak remote free text. A cue is selected from our fixed dictionary.
|
|
188
|
+
return allowed.includes(result.choice) ? CUE_PHRASES[result.choice] : "";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { SpeechStream, synthesizePcm, synthesizeStream, detectTurn, selectAcknowledgement, CUE_PHRASES };
|