@inetafrica/open-claudia 3.4.14 → 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 +8 -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 +154 -80
- 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 +153 -7
- 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 +2 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.4.15
|
|
4
|
+
|
|
5
|
+
- Preserve complete Kazee speech turns with protocol-2 segment matching, bounded pending finals, and cancellation when speech resumes during an endpoint decision. Keep compatibility with older speech servers, including a delayed protocol-ready message.
|
|
6
|
+
- Stop obsolete speech and remove superseded queued call turns on interruption or hangup. Use a fresh provider conversation with bounded spoken history while preserving identity, policy and recall. In-progress tools continue under their existing lifecycle.
|
|
7
|
+
- Add `open-claudia call-say` for explicit complete answer sentences before the provider finishes. Each request is bound to the active call and run, redacted, and checked by the outbound reply guard. Retries are idempotent; final delivery removes the exact prefix already spoken. Provider previews and reasoning remain unspoken; this does not add token streaming to the provider transport.
|
|
8
|
+
- Replace repeated thinking sounds with optional delayed fixed cues from the separate speech controller. Endpoint and cue models require the corresponding speech-service deployment; model quality, live latency and Android acceptance remain separate verification gates.
|
|
9
|
+
- Isolate permission-test fixtures from inherited test-launcher approval without changing production permission gates.
|
|
10
|
+
|
|
3
11
|
## 3.4.14
|
|
4
12
|
|
|
5
13
|
- Subscribe to managed call audio only when gateway-authenticated publisher metadata identifies a User. Exclude bots, the current bot's other feeds, and missing or malformed classifications before creating subscriber connections or speech streams.
|
package/bin/cli.js
CHANGED
|
@@ -277,6 +277,33 @@ switch (command) {
|
|
|
277
277
|
break;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
case "call-say": {
|
|
281
|
+
if (args[1] === "--help" || args[1] === "-h") {
|
|
282
|
+
console.log("Usage: open-claudia call-say [--id <request-id>] <text>\nOr: open-claudia call-say [--id <request-id>] --stdin\nSpeak one checked 1–450 character chunk into the current call turn. Wait for completion; reuse an ID only when retrying identical text.");
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
const parts = args.slice(1);
|
|
286
|
+
let requestId = require("crypto").randomUUID();
|
|
287
|
+
if (parts[0] === "--id") { parts.shift(); requestId = parts.shift(); }
|
|
288
|
+
const text = parts.length === 1 && parts[0] === "--stdin"
|
|
289
|
+
? require("fs").readFileSync(0, "utf8").trim() : parts.join(" ").trim();
|
|
290
|
+
if (!text || text.length > 450 || !/^[A-Za-z0-9_-]{1,80}$/.test(requestId || "")) {
|
|
291
|
+
console.error("Provide a valid request ID and 1–450 characters of spoken text.");
|
|
292
|
+
process.exit(2);
|
|
293
|
+
}
|
|
294
|
+
(async () => {
|
|
295
|
+
try {
|
|
296
|
+
const result = await require("./loopback-client").postJson("call-say", { text, requestId });
|
|
297
|
+
if (result.ok !== true || result.spoken !== true) throw new Error("Speech was not completed");
|
|
298
|
+
console.log(result.duplicate ? "Already spoken." : "Spoken.");
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error(error.message);
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
280
307
|
case "say": {
|
|
281
308
|
// Agents probe with --help before speaking; don't synthesize the flag.
|
|
282
309
|
if (args[1] === "--help" || args[1] === "-h") {
|
|
@@ -563,6 +590,7 @@ Send tools (only work inside an active bot-spawned task):
|
|
|
563
590
|
open-claudia send-photo <path> [caption]
|
|
564
591
|
open-claudia send-voice <path>
|
|
565
592
|
open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
|
|
593
|
+
open-claudia call-say "<text>" Speak a checked sentence into the active call turn
|
|
566
594
|
open-claudia tts "<text>" --out <path> Synthesize to a FILE without sending (voice-overs,
|
|
567
595
|
narration; ext picks format; --voice <id> optional)
|
|
568
596
|
open-claudia transcribe <file> Speech-to-text: print the transcript of an audio/video file
|
package/bin/loopback-client.js
CHANGED
|
@@ -75,6 +75,7 @@ function postJson(kind, body) {
|
|
|
75
75
|
const sameProject = !ctx.project || !requestedProject || requestedProject === ctx.project;
|
|
76
76
|
const payload = JSON.stringify({
|
|
77
77
|
...body,
|
|
78
|
+
...(kind === "call-say" ? { callSpeechToken: process.env.OC_CALL_SPEECH_TOKEN || "" } : {}),
|
|
78
79
|
channelId: ctx.channelId,
|
|
79
80
|
adapter: ctx.adapterId,
|
|
80
81
|
userId: ctx.userId,
|
|
@@ -86,13 +86,21 @@ class KazeeCallManager {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
_sessionEnded(session, reason) {
|
|
89
|
+
// Several terminal paths clear this.session before close() calls back.
|
|
90
|
+
// Invalidate the closed call's work even when it no longer owns the slot.
|
|
91
|
+
session?._voiceTurn?.controller.abort("call-ended");
|
|
89
92
|
if (this.session !== session) return;
|
|
90
|
-
this.session
|
|
93
|
+
this._detachSession(session);
|
|
91
94
|
if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
|
|
92
95
|
...session.callIdentity, chatId: session.chatId, toUserId: session.callerId, reason,
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
_detachSession(session) {
|
|
100
|
+
session?._voiceTurn?.controller.abort("call-ended");
|
|
101
|
+
if (this.session === session) this.session = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
_manageLease(session, access) {
|
|
97
105
|
if (!access) return;
|
|
98
106
|
const socket = this.adapter._socket;
|
|
@@ -103,7 +111,7 @@ class KazeeCallManager {
|
|
|
103
111
|
if (this.session !== session || session.closed) return;
|
|
104
112
|
console.warn(`Kazee managed call lease ${reason}`);
|
|
105
113
|
socket.emit("call:end", { ...session.callIdentity, deviceId: this.deviceId, chatId: session.chatId, toUserId: session.callerId, reason: "network-timeout" });
|
|
106
|
-
this.session
|
|
114
|
+
this._detachSession(session);
|
|
107
115
|
session.close(`lease-${reason}`).catch(() => console.error("Kazee call lease cleanup failed"));
|
|
108
116
|
}, access.leaseExpiresAt, randomUUID);
|
|
109
117
|
}
|
|
@@ -141,7 +149,8 @@ class KazeeCallManager {
|
|
|
141
149
|
const boundIdentity = access ? { ...publicCallIdentity(access), deviceId: this.deviceId, generation: access.generation } : identity;
|
|
142
150
|
session = new this.SessionImpl({ roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
143
151
|
...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
|
|
144
|
-
onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta),
|
|
152
|
+
onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta, session),
|
|
153
|
+
onSpeechStart: () => this._speechStart(session),
|
|
145
154
|
onMediaReady: () => this._mediaReady(session),
|
|
146
155
|
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
|
|
147
156
|
});
|
|
@@ -156,7 +165,7 @@ class KazeeCallManager {
|
|
|
156
165
|
if (session._mediaReady) session._leaseHeartbeat?.activate();
|
|
157
166
|
} catch (_) {
|
|
158
167
|
const cancelled = pending.controller.signal.aborted;
|
|
159
|
-
|
|
168
|
+
this._detachSession(session);
|
|
160
169
|
if (session) await session.close("connect-failed").catch(() => {});
|
|
161
170
|
if (!cancelled) reject("connection_failed");
|
|
162
171
|
} finally { if (this._pending === pending) this._pending = null; }
|
|
@@ -187,7 +196,8 @@ class KazeeCallManager {
|
|
|
187
196
|
const remote = { ...identity, chatId: chat, fromUserId: target, fromUserName: "" };
|
|
188
197
|
session = new this.SessionImpl({ roomId, chatId: chat, callerId: target, botUserId: this.adapter.botUserId,
|
|
189
198
|
...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
|
|
190
|
-
onTranscript: (text, meta) => this._transcript(text, remote, meta),
|
|
199
|
+
onTranscript: (text, meta) => this._transcript(text, remote, meta, session),
|
|
200
|
+
onSpeechStart: () => this._speechStart(session),
|
|
191
201
|
onMediaReady: () => this._mediaReady(session),
|
|
192
202
|
onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
|
|
193
203
|
});
|
|
@@ -202,7 +212,7 @@ class KazeeCallManager {
|
|
|
202
212
|
this._ringTimer.unref?.();
|
|
203
213
|
return { ...identity, chatId: chat, toUserId: target };
|
|
204
214
|
} catch (error) {
|
|
205
|
-
|
|
215
|
+
this._detachSession(session);
|
|
206
216
|
if (session) await session.close("connect-failed").catch(() => {});
|
|
207
217
|
throw error;
|
|
208
218
|
} finally { if (this._pending === pending) this._pending = null; }
|
|
@@ -223,7 +233,7 @@ class KazeeCallManager {
|
|
|
223
233
|
// Caller hanging up while ringing: chat-central finalizes the CallLog as
|
|
224
234
|
// missed and dismisses the callee's ringing UI/push.
|
|
225
235
|
this.adapter._socket?.emit("call:end", { ...session.callIdentity, toUserId, chatId });
|
|
226
|
-
this.session
|
|
236
|
+
this._detachSession(session);
|
|
227
237
|
await session.close("no-answer").catch(() => {});
|
|
228
238
|
}
|
|
229
239
|
|
|
@@ -231,23 +241,65 @@ class KazeeCallManager {
|
|
|
231
241
|
if (this._ringTimer) { clearTimeout(this._ringTimer); this._ringTimer = null; }
|
|
232
242
|
}
|
|
233
243
|
|
|
234
|
-
|
|
244
|
+
_speechStart(session) {
|
|
245
|
+
if (session?._voiceTurn) session._voiceTurn.muted = true;
|
|
246
|
+
// Sound alone is not a new request. Cancel queued work only once a new
|
|
247
|
+
// complete transcript exists; a cough must not discard an accepted ask.
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_rememberSpeech(session, role, text) {
|
|
251
|
+
const history = session._voiceHistory ||= [];
|
|
252
|
+
if (role === "assistant" && history.at(-1)?.role === "assistant") {
|
|
253
|
+
history.at(-1).text = `${history.at(-1).text} ${text}`.slice(-6000);
|
|
254
|
+
} else history.push({ role, text: String(text).slice(-6000) });
|
|
255
|
+
while (history.length > 8 || history.reduce((size, entry) => size + entry.text.length, 0) > 12000) history.shift();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_transcript(text, incoming, meta = {}, sourceSession = this.session) {
|
|
235
259
|
const session = this.session;
|
|
236
|
-
if (!session || !text) return;
|
|
260
|
+
if (!session || session.closed || sourceSession !== session || !text) return;
|
|
237
261
|
// In a group room, tell the provider who is talking; 1:1 stays unprefixed.
|
|
238
262
|
const speaker = String(meta.display || "").trim();
|
|
239
263
|
const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
|
|
264
|
+
session._voiceTurn?.controller.abort("superseded");
|
|
265
|
+
const turn = { controller: new AbortController(), muted: false };
|
|
266
|
+
session._voiceTurn = turn;
|
|
240
267
|
const callAdapter = Object.create(this.adapter);
|
|
268
|
+
callAdapter.callHistory = (session._voiceHistory || []).map(entry => ({ ...entry }));
|
|
269
|
+
callAdapter.callTurn = { signal: turn.controller.signal };
|
|
270
|
+
this._rememberSpeech(session, "user", attributed);
|
|
241
271
|
// Provider narration/previews are useful in chat but must never be spoken
|
|
242
|
-
// into a call.
|
|
272
|
+
// into a call. send() receives settled answers; explicit sentence speech
|
|
273
|
+
// enters separately through the call-turn lease and outbound reply guard.
|
|
243
274
|
callAdapter.suppressStreamPreview = true;
|
|
244
275
|
// Recall/tooltrace banners route through the same send() as the answer —
|
|
245
276
|
// without this flag the TTS reads memory-debug lines to the caller.
|
|
246
277
|
callAdapter.suppressChatVisibility = true;
|
|
247
278
|
// Prompt builder switches to phone-call brevity when it sees this.
|
|
248
279
|
callAdapter.liveCall = true;
|
|
280
|
+
const spokenSentences = [];
|
|
281
|
+
callAdapter.isCallTurnActive = () => !turn.muted && !turn.controller.signal.aborted &&
|
|
282
|
+
!session.closed && this.session === session && session._voiceTurn === turn;
|
|
283
|
+
callAdapter.cancelCallSpeech = () => {
|
|
284
|
+
if (this.session === session && session._voiceTurn === turn) session.cancelSpeech();
|
|
285
|
+
};
|
|
286
|
+
const speak = async response => {
|
|
287
|
+
if (!callAdapter.isCallTurnActive()) return false;
|
|
288
|
+
const spoken = await session.speak(response);
|
|
289
|
+
if (spoken !== false && callAdapter.isCallTurnActive()) {
|
|
290
|
+
this._rememberSpeech(session, "assistant", response);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
};
|
|
295
|
+
callAdapter.speakSentence = async response => {
|
|
296
|
+
const spoken = await speak(response);
|
|
297
|
+
if (spoken) spokenSentences.push(response);
|
|
298
|
+
return spoken;
|
|
299
|
+
};
|
|
249
300
|
callAdapter.send = async (_channelId, response) => {
|
|
250
|
-
|
|
301
|
+
const remainder = require("../../../core/call-speech").unspokenSuffix(response, spokenSentences);
|
|
302
|
+
if (remainder) await speak(remainder);
|
|
251
303
|
return true;
|
|
252
304
|
};
|
|
253
305
|
callAdapter.edit = async () => true;
|
|
@@ -275,7 +327,7 @@ class KazeeCallManager {
|
|
|
275
327
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
276
328
|
// Terminal server state is authoritative; ended attempts are never revived.
|
|
277
329
|
const session = this.session;
|
|
278
|
-
this.session
|
|
330
|
+
this._detachSession(session);
|
|
279
331
|
await session.close("remote-ended");
|
|
280
332
|
}
|
|
281
333
|
|
|
@@ -283,7 +335,7 @@ class KazeeCallManager {
|
|
|
283
335
|
if (!this.session || !this._matches(payload)) return;
|
|
284
336
|
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
285
337
|
const session = this.session;
|
|
286
|
-
this.session
|
|
338
|
+
this._detachSession(session);
|
|
287
339
|
await session.close("remote-rejected");
|
|
288
340
|
}
|
|
289
341
|
|
|
@@ -292,7 +344,7 @@ class KazeeCallManager {
|
|
|
292
344
|
this._clearRingTimer();
|
|
293
345
|
if (!this.session) return;
|
|
294
346
|
const session = this.session;
|
|
295
|
-
this.session
|
|
347
|
+
this._detachSession(session);
|
|
296
348
|
await session.close("adapter-stopped");
|
|
297
349
|
}
|
|
298
350
|
}
|
|
@@ -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;
|
|
@@ -392,8 +376,9 @@ class KazeeCallSession {
|
|
|
392
376
|
codec: new OpusAudioCodec(),
|
|
393
377
|
vad: new AdaptiveTurnDetector(),
|
|
394
378
|
stt: new this.SpeechStreamImpl({
|
|
395
|
-
onTranscript: (text) => this._onFeedFinal(feedId, text),
|
|
396
|
-
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),
|
|
397
382
|
}),
|
|
398
383
|
prebuffer: [],
|
|
399
384
|
pending: [],
|
|
@@ -407,7 +392,9 @@ class KazeeCallSession {
|
|
|
407
392
|
noiseCommit: false,
|
|
408
393
|
fallbackUsed: false,
|
|
409
394
|
finalTimer: null,
|
|
395
|
+
segments: new Map(),
|
|
410
396
|
};
|
|
397
|
+
sub.turnGate = new SpeechTurnGate({ decide: this.turnDecision, commit: reason => this._commitTurn(feedId, reason) });
|
|
411
398
|
this.subscribers.set(feedId, sub);
|
|
412
399
|
pc.onTrack.subscribe((track) => {
|
|
413
400
|
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
@@ -452,9 +439,26 @@ class KazeeCallSession {
|
|
|
452
439
|
}
|
|
453
440
|
}
|
|
454
441
|
|
|
455
|
-
|
|
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 = {}) {
|
|
456
459
|
const sub = this.subscribers.get(feedId);
|
|
457
460
|
if (!sub || this.closed) return;
|
|
461
|
+
if (meta.protocol === 2 && (meta.streamId !== sub.stt.streamId || meta.segmentId !== sub.stt.segmentId)) return;
|
|
458
462
|
const partial = String(text || "");
|
|
459
463
|
const prevWords = countWords(sub.lastPartial);
|
|
460
464
|
const words = countWords(partial);
|
|
@@ -469,7 +473,7 @@ class KazeeCallSession {
|
|
|
469
473
|
}
|
|
470
474
|
sub.wordCount += words - prevWords;
|
|
471
475
|
sub.lastWordAt = now;
|
|
472
|
-
if (sub.awaitingWordsSince) this._commitTurn(feedId, "words-arrived");
|
|
476
|
+
if (sub.awaitingWordsSince && sub.stt.protocol !== 2) this._commitTurn(feedId, "words-arrived");
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
// Interims arrive in bursts whose cadence tracks recogniser health (jig
|
|
@@ -485,23 +489,49 @@ class KazeeCallSession {
|
|
|
485
489
|
const sub = this.subscribers.get(feedId);
|
|
486
490
|
if (!sub || this.closed) return;
|
|
487
491
|
sub.awaitingWordsSince = 0;
|
|
492
|
+
sub.turnGate?.reset();
|
|
488
493
|
if (sub.vad.active) sub.vad.reset();
|
|
489
494
|
this._lastHeard = Date.now();
|
|
490
495
|
clog(`Kazee call turn commit: feed=${feedId} reason=${reason} words=${sub.wordCount}`);
|
|
491
|
-
sub.stt.
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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.
|
|
500
528
|
sub.partialAtCommit = sub.lastPartial;
|
|
529
|
+
sub.lastPartial = "";
|
|
501
530
|
sub.fallbackUsed = false;
|
|
502
531
|
if (sub.finalTimer) clearTimeout(sub.finalTimer);
|
|
503
|
-
sub.finalTimer = setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
|
|
504
|
-
|
|
532
|
+
sub.finalTimer = versioned ? null : setTimeout(() => this._finalFallback(feedId), this.finalWaitMs);
|
|
533
|
+
sub.finalTimer?.unref?.();
|
|
534
|
+
if (versioned) sub.noiseCommit = false;
|
|
505
535
|
sub.wordCount = 0;
|
|
506
536
|
sub.lastWordAt = 0;
|
|
507
537
|
sub.wordGaps = [];
|
|
@@ -511,19 +541,28 @@ class KazeeCallSession {
|
|
|
511
541
|
const sub = this.subscribers.get(feedId);
|
|
512
542
|
if (!sub || this.closed) return;
|
|
513
543
|
sub.finalTimer = null;
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
// belongs to the next turn).
|
|
517
|
-
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.
|
|
518
546
|
const snapshot = sub.partialAtCommit || "";
|
|
519
|
-
const text =
|
|
547
|
+
const text = snapshot;
|
|
520
548
|
sub.fallbackUsed = true;
|
|
521
549
|
clog(`Kazee call final fallback: feed=${feedId} chars=${text.trim().length}`);
|
|
522
550
|
this._dispatchTranscript(feedId, text);
|
|
523
551
|
}
|
|
524
552
|
|
|
525
|
-
_onFeedFinal(feedId, text) {
|
|
553
|
+
_onFeedFinal(feedId, text, meta = {}) {
|
|
526
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
|
+
}
|
|
527
566
|
if (sub?.finalTimer) {
|
|
528
567
|
clearTimeout(sub.finalTimer);
|
|
529
568
|
sub.finalTimer = null;
|
|
@@ -536,11 +575,11 @@ class KazeeCallSession {
|
|
|
536
575
|
this._dispatchTranscript(feedId, text);
|
|
537
576
|
}
|
|
538
577
|
|
|
539
|
-
_dispatchTranscript(feedId, text) {
|
|
578
|
+
_dispatchTranscript(feedId, text, options = {}) {
|
|
540
579
|
const sub = this.subscribers.get(feedId);
|
|
541
580
|
const trimmed = String(text || "").trim();
|
|
542
|
-
if (sub?.noiseCommit) {
|
|
543
|
-
sub.noiseCommit = false;
|
|
581
|
+
if (options.noiseCommit ?? sub?.noiseCommit) {
|
|
582
|
+
if (options.noiseCommit === undefined) sub.noiseCommit = false;
|
|
544
583
|
// A turn the recogniser never put words in was background noise; babble
|
|
545
584
|
// occasionally leaks a single stray word into the transcript too.
|
|
546
585
|
if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
|
|
@@ -550,14 +589,16 @@ class KazeeCallSession {
|
|
|
550
589
|
}
|
|
551
590
|
}
|
|
552
591
|
clog(`Kazee call transcript: feed=${feedId} chars=${trimmed.length}${trimmed.length ? "" : " (noise commit)"}`);
|
|
553
|
-
if (trimmed) this._startThinking();
|
|
592
|
+
if (trimmed) this._startThinking(trimmed);
|
|
554
593
|
// Empty finalization (noise commit): nothing dispatches, no reply is
|
|
555
594
|
// coming — don't tick into the void.
|
|
556
595
|
else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
|
|
596
|
+
if (!trimmed) return;
|
|
557
597
|
this.onTranscript(text, {
|
|
558
598
|
feedId,
|
|
559
599
|
display: sub?.display || "",
|
|
560
600
|
participants: this.subscribers.size,
|
|
601
|
+
...(options.utteranceId ? { utteranceId: options.utteranceId } : {}),
|
|
561
602
|
});
|
|
562
603
|
}
|
|
563
604
|
|
|
@@ -580,32 +621,34 @@ class KazeeCallSession {
|
|
|
580
621
|
this._reportMediaReady();
|
|
581
622
|
}
|
|
582
623
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
this.
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
this.
|
|
601
|
-
},
|
|
602
|
-
|
|
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?.();
|
|
603
644
|
}
|
|
604
645
|
|
|
605
646
|
_stopThinking() {
|
|
606
|
-
|
|
607
|
-
|
|
647
|
+
this._cueRevision += 1;
|
|
648
|
+
if (this._thinkTimer) clearTimeout(this._thinkTimer);
|
|
608
649
|
this._thinkTimer = null;
|
|
650
|
+
this._cueController?.abort();
|
|
651
|
+
this._cueController = null;
|
|
609
652
|
}
|
|
610
653
|
|
|
611
654
|
_onFeedRtp(feedId, packet) {
|
|
@@ -626,6 +669,28 @@ class KazeeCallSession {
|
|
|
626
669
|
sub.vad.boostTo = this.speaking ? SPEAKING_THRESHOLD_DB : null;
|
|
627
670
|
const event = sub.vad.push(pcm);
|
|
628
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
|
+
}
|
|
629
694
|
if (sub.awaitingWordsSince) {
|
|
630
695
|
// Energy closed the turn but the recogniser hasn't produced a word yet:
|
|
631
696
|
// hold the commit and keep streaming audio so a slow decode still lands.
|
|
@@ -634,7 +699,8 @@ class KazeeCallSession {
|
|
|
634
699
|
// Caller resumed before any words arrived — same turn continues.
|
|
635
700
|
sub.awaitingWordsSince = 0;
|
|
636
701
|
this._lastHeard = Date.now();
|
|
637
|
-
|
|
702
|
+
this.cancelSpeech();
|
|
703
|
+
this.onSpeechStart({ feedId });
|
|
638
704
|
this._stopThinking();
|
|
639
705
|
return;
|
|
640
706
|
}
|
|
@@ -653,7 +719,8 @@ class KazeeCallSession {
|
|
|
653
719
|
this._lastHeard = Date.now();
|
|
654
720
|
clog(`Kazee call VAD start: feed=${feedId}`);
|
|
655
721
|
// Barge-in: the human wins — stop talking and listen.
|
|
656
|
-
|
|
722
|
+
this.cancelSpeech();
|
|
723
|
+
this.onSpeechStart({ feedId });
|
|
657
724
|
this._stopThinking();
|
|
658
725
|
for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
|
|
659
726
|
}
|
|
@@ -694,6 +761,7 @@ class KazeeCallSession {
|
|
|
694
761
|
|
|
695
762
|
cancelSpeech() {
|
|
696
763
|
this._speakGen += 1;
|
|
764
|
+
this._synthController?.abort();
|
|
697
765
|
for (const item of this._speakQueue.splice(0)) item.resolve(false);
|
|
698
766
|
}
|
|
699
767
|
|
|
@@ -704,8 +772,11 @@ class KazeeCallSession {
|
|
|
704
772
|
try {
|
|
705
773
|
while (this._speakQueue.length && !this.closed) {
|
|
706
774
|
const item = this._speakQueue.shift();
|
|
775
|
+
const controller = new AbortController();
|
|
776
|
+
this._synthController = controller;
|
|
707
777
|
try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
|
|
708
|
-
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; }
|
|
709
780
|
}
|
|
710
781
|
} finally {
|
|
711
782
|
this._speakActive = false;
|
|
@@ -737,7 +808,7 @@ class KazeeCallSession {
|
|
|
737
808
|
// Cache only audio rendered by the real service: the key is voice+text, so
|
|
738
809
|
// PCM from an injected synthesize would leak across sessions.
|
|
739
810
|
const usingService = this.synthesizeStream !== null || this.synthesize === synthesizePcm;
|
|
740
|
-
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));
|
|
741
812
|
const cacheKey = `${ttsVoice.getVoice()}::${text}`;
|
|
742
813
|
const synthStart = Date.now();
|
|
743
814
|
if (cacheable && SYNTH_CACHE.has(cacheKey)) {
|
|
@@ -752,7 +823,7 @@ class KazeeCallSession {
|
|
|
752
823
|
clog(`Kazee call stream synth failed (${error.message}); buffering`);
|
|
753
824
|
}
|
|
754
825
|
}
|
|
755
|
-
const synthesized = await this.synthesize(text);
|
|
826
|
+
const synthesized = await this.synthesize(text, { signal: this._synthController?.signal });
|
|
756
827
|
if (this.closed || gen !== this._speakGen) return false;
|
|
757
828
|
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
758
829
|
clog(`Kazee call speak: synth=${Date.now() - synthStart}ms chars=${text.length}`);
|
|
@@ -763,7 +834,7 @@ class KazeeCallSession {
|
|
|
763
834
|
// Plays TTS as it downloads: the service streams PCM, but buffering the full
|
|
764
835
|
// response cost ~3s of silence before the first word on every reply.
|
|
765
836
|
async _playStreaming(text, gen, { cacheable, cacheKey, synthStart }) {
|
|
766
|
-
const { sampleRate, chunks } = await this.synthesizeStream(text);
|
|
837
|
+
const { sampleRate, chunks } = await this.synthesizeStream(text, { signal: this._synthController?.signal });
|
|
767
838
|
if (this.closed || gen !== this._speakGen) return false;
|
|
768
839
|
let rawCarry = Buffer.alloc(0); // an odd byte straddling a chunk boundary splits a sample
|
|
769
840
|
let frameCarry = Buffer.alloc(0);
|
|
@@ -844,6 +915,9 @@ class KazeeCallSession {
|
|
|
844
915
|
this._transportIceExpiry.delete(sub.pc);
|
|
845
916
|
this._armTransportExpiry();
|
|
846
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();
|
|
847
921
|
try { sub.stt.close(); } catch (_) {}
|
|
848
922
|
try { sub.codec.close(); } catch (_) {}
|
|
849
923
|
try { await sub.pc.close(); } catch (_) {}
|