@inetafrica/open-claudia 3.1.12 → 3.1.14
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/.env.example +2 -0
- package/CHANGELOG.md +16 -0
- package/channels/kazee/adapter.js +4 -0
- package/channels/kazee/call/audio.js +133 -0
- package/channels/kazee/call/janus-client.js +136 -0
- package/channels/kazee/call/manager.js +112 -0
- package/channels/kazee/call/session.js +286 -0
- package/channels/kazee/call/speech-client.js +79 -0
- package/core/runner.js +3 -1
- package/package.json +15 -3
- package/test-kazee-call-audio.js +43 -0
- package/test-kazee-call-janus.js +54 -0
- package/test-kazee-call-manager.js +36 -0
- package/test-kazee-call-session.js +231 -0
- package/test-kazee-call-speech.js +36 -0
- package/test-kazee-call-webrtc.js +55 -0
- package/test-provider-language.js +9 -1
package/.env.example
CHANGED
|
@@ -13,6 +13,8 @@ KAZEE_BOT_USER_ID=
|
|
|
13
13
|
KAZEE_OWNER_USER_ID=
|
|
14
14
|
# Set to 1 to log every inbound Kazee socket event (debug aid; noisy).
|
|
15
15
|
KAZEE_DEBUG_EVENTS=
|
|
16
|
+
# Optional override; Kazee calls default to the production Janus WebSocket.
|
|
17
|
+
KAZEE_JANUS_URL=wss://janus.kazee.africa/ws
|
|
16
18
|
|
|
17
19
|
WORKSPACE=/path/to/your/workspace
|
|
18
20
|
# Both provider paths are optional. Leave blank to discover the CLI on PATH.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.1.14 — Call bridge production hardening: serialized speech, barge-in, group attribution, limits
|
|
4
|
+
|
|
5
|
+
- **TTS is serialized through a queue with cancellation.** Concurrent `speak()` calls used to interleave two 20ms-frame RTP loops over the same sequence/timestamp counters — garbled audio whenever the provider emitted more than one message. Utterances now play strictly one at a time; `cancelSpeech()` (generation-based) stops the current utterance mid-frame and resolves everything queued, and `close()` cancels so no pending `speak()` can hang the adapter.
|
|
6
|
+
- **Barge-in + echo gate.** While the bot is speaking, the VAD threshold rises from −38 to −26 dBFS so the caller's device echoing the bot's own TTS can't trigger STT; genuine speech over the gate cancels playback immediately — you can interrupt her mid-answer like a real call.
|
|
7
|
+
- **Group calls get per-feed pipelines and speaker attribution.** Each subscribed feed now has its own Opus decoder (they're stateful — one shared decoder produced garbage on 2+ feeds), its own VAD, and its own STT stream. Transcripts carry `{feedId, display, participants}`; with more than one participant in the room the provider sees `alice@…: text`, so group calls stop reading as one merged anonymous voice. STT send/commit failures now log and drop the frame instead of killing the whole call (the stream self-heals on the next send).
|
|
8
|
+
- **Calls end on their own when they should.** 30-minute duration cap (`KAZEE_CALL_MAX_MS`) and 2-minute idle timeout (`KAZEE_CALL_IDLE_MS`), both announced with a spoken goodbye; if the caller barges in during the idle goodbye the call stays up. A leaked session can no longer hold Janus/STT resources forever.
|
|
9
|
+
- **First syllables reach STT.** A 240ms pre-onset ring buffer flushes into the STT stream when a turn starts, so VAD onset latency stops clipping the start of every utterance.
|
|
10
|
+
- The five existing call tests plus new `test-kazee-call-session.js` (serialization, cancellation, echo gate, barge-in, prebuffer, attribution, idle/max limits, close semantics) are wired into the main `npm test` chain — call regressions now block every release, not just `test:kazee-call` runs.
|
|
11
|
+
|
|
12
|
+
## v3.1.13 — Owner-only Kazee calls reach Open Claudia
|
|
13
|
+
|
|
14
|
+
- Accepts incoming audio calls from the configured Kazee owner only; external, video, malformed, and concurrent calls are rejected before any media session is created.
|
|
15
|
+
- Joins the existing Janus VideoRoom as a headless publisher/subscriber using pure-TypeScript WebRTC, decodes inbound Opus, commits VAD-delimited 16 kHz PCM to hosted streaming STT, and routes final utterances through the owner's normal Kazee identity and conversation.
|
|
16
|
+
- Speaks only the settled, guardrail-vetted answer through hosted Pocket TTS PCM and Opus RTP. Provider narration and transient progress previews are suppressed in calls; audio is never persisted.
|
|
17
|
+
- Adds deterministic audio, Janus transaction, WebRTC RTP, speech framing, owner gate, lifecycle, and AI-routing tests. Updates `ws` to the patched 8.21 line across direct and Socket.IO use.
|
|
18
|
+
|
|
3
19
|
## v3.1.10 — `say` synthesizes in the bot process, not the stripped subprocess
|
|
4
20
|
|
|
5
21
|
- v3.1.9 carried this exact fix but its build never published: the version pin in `test-provider-language.js` still read `3.1.8`, so the guard failed and the docker stage was skipped. Re-cut as v3.1.10 per "never retag a failed version."
|
|
@@ -13,6 +13,7 @@ const { canonicalForChannel } = require("../../core/identity");
|
|
|
13
13
|
const { inlineKeyboardToPortable } = require("../types");
|
|
14
14
|
const { createKazeeSocket } = require("./socket");
|
|
15
15
|
const { normalize } = require("./format");
|
|
16
|
+
const { KazeeCallManager } = require("./call/manager");
|
|
16
17
|
|
|
17
18
|
class KazeeAdapter {
|
|
18
19
|
constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
|
|
@@ -27,6 +28,7 @@ class KazeeAdapter {
|
|
|
27
28
|
this.botUserId = botUserId || null;
|
|
28
29
|
this._listeners = { message: new Set(), action: new Set() };
|
|
29
30
|
this._socket = null;
|
|
31
|
+
this._callManager = new KazeeCallManager({ adapter: this });
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
on(event, fn) {
|
|
@@ -67,11 +69,13 @@ class KazeeAdapter {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
async stop() {
|
|
72
|
+
await this._callManager.stop();
|
|
70
73
|
try { this._socket?.disconnect?.(); } catch (e) {}
|
|
71
74
|
this._socket = null;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
_wireSocket(socket) {
|
|
78
|
+
this._callManager.wire(socket);
|
|
75
79
|
if (process.env.KAZEE_DEBUG_EVENTS) {
|
|
76
80
|
socket.onAny((event, ...args) => {
|
|
77
81
|
try { console.log(`[kazee event] ${event}`, JSON.stringify(args).slice(0, 400)); }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const OpusScript = require("opusscript");
|
|
4
|
+
|
|
5
|
+
const WEBRTC_RATE = 48000;
|
|
6
|
+
const STT_RATE = 16000;
|
|
7
|
+
const FRAME_MS = 20;
|
|
8
|
+
const WEBRTC_SAMPLES = WEBRTC_RATE * FRAME_MS / 1000;
|
|
9
|
+
|
|
10
|
+
class OpusAudioCodec {
|
|
11
|
+
constructor({ channels = 1 } = {}) {
|
|
12
|
+
this.channels = channels;
|
|
13
|
+
this.encoder = new OpusScript(WEBRTC_RATE, channels, OpusScript.Application.VOIP);
|
|
14
|
+
this.decoder = new OpusScript(WEBRTC_RATE, channels, OpusScript.Application.VOIP);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
encode(pcm48k) {
|
|
18
|
+
if (!Buffer.isBuffer(pcm48k)) pcm48k = Buffer.from(pcm48k);
|
|
19
|
+
const samples = pcm48k.length / 2 / this.channels;
|
|
20
|
+
if (samples !== WEBRTC_SAMPLES) {
|
|
21
|
+
throw new Error(`Opus input must be exactly ${WEBRTC_SAMPLES} samples per channel (20ms)`);
|
|
22
|
+
}
|
|
23
|
+
return Buffer.from(this.encoder.encode(pcm48k, samples));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
decode(opus) {
|
|
27
|
+
return Buffer.from(this.decoder.decode(Buffer.from(opus)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
close() {
|
|
31
|
+
this.encoder?.delete();
|
|
32
|
+
this.decoder?.delete();
|
|
33
|
+
this.encoder = null;
|
|
34
|
+
this.decoder = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// WebRTC Opus is 48 kHz. Faster Whisper's streaming endpoint accepts PCM at
|
|
39
|
+
// any declared rate, but 16 kHz cuts bandwidth and inference conversion work.
|
|
40
|
+
function downsample48To16(pcm48k) {
|
|
41
|
+
if (!Buffer.isBuffer(pcm48k)) pcm48k = Buffer.from(pcm48k);
|
|
42
|
+
const input = new Int16Array(pcm48k.buffer, pcm48k.byteOffset, Math.floor(pcm48k.length / 2));
|
|
43
|
+
const output = Buffer.allocUnsafe(Math.floor(input.length / 3) * 2);
|
|
44
|
+
const samples = new Int16Array(output.buffer, output.byteOffset, output.length / 2);
|
|
45
|
+
for (let i = 0, j = 0; j < samples.length; i += 3, j += 1) {
|
|
46
|
+
samples[j] = Math.round((input[i] + input[i + 1] + input[i + 2]) / 3);
|
|
47
|
+
}
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function upsampleTo48(pcm, sourceRate) {
|
|
52
|
+
if (!Buffer.isBuffer(pcm)) pcm = Buffer.from(pcm);
|
|
53
|
+
if (sourceRate === WEBRTC_RATE) return Buffer.from(pcm);
|
|
54
|
+
if (!Number.isFinite(sourceRate) || sourceRate < 8000 || sourceRate > WEBRTC_RATE) {
|
|
55
|
+
throw new Error(`Unsupported PCM source rate: ${sourceRate}`);
|
|
56
|
+
}
|
|
57
|
+
const input = new Int16Array(pcm.buffer, pcm.byteOffset, Math.floor(pcm.length / 2));
|
|
58
|
+
const outputLength = Math.max(0, Math.round(input.length * WEBRTC_RATE / sourceRate));
|
|
59
|
+
const output = Buffer.allocUnsafe(outputLength * 2);
|
|
60
|
+
const samples = new Int16Array(output.buffer, output.byteOffset, outputLength);
|
|
61
|
+
const scale = sourceRate / WEBRTC_RATE;
|
|
62
|
+
for (let i = 0; i < outputLength; i += 1) {
|
|
63
|
+
const position = i * scale;
|
|
64
|
+
const left = Math.floor(position);
|
|
65
|
+
const right = Math.min(left + 1, input.length - 1);
|
|
66
|
+
const fraction = position - left;
|
|
67
|
+
samples[i] = Math.round(input[left] + (input[right] - input[left]) * fraction);
|
|
68
|
+
}
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rmsDbfs(pcm) {
|
|
73
|
+
if (!pcm?.length) return -Infinity;
|
|
74
|
+
const samples = new Int16Array(pcm.buffer, pcm.byteOffset, Math.floor(pcm.length / 2));
|
|
75
|
+
let sum = 0;
|
|
76
|
+
for (const sample of samples) {
|
|
77
|
+
const normalized = sample / 32768;
|
|
78
|
+
sum += normalized * normalized;
|
|
79
|
+
}
|
|
80
|
+
if (!sum) return -Infinity;
|
|
81
|
+
return 20 * Math.log10(Math.sqrt(sum / samples.length));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class TurnDetector {
|
|
85
|
+
constructor({ thresholdDb = -38, onsetFrames = 3, silenceMs = 700, maxTurnMs = 30000 } = {}) {
|
|
86
|
+
this.thresholdDb = thresholdDb;
|
|
87
|
+
this.onsetFrames = onsetFrames;
|
|
88
|
+
this.silenceFrames = Math.ceil(silenceMs / FRAME_MS);
|
|
89
|
+
this.maxFrames = Math.ceil(maxTurnMs / FRAME_MS);
|
|
90
|
+
this.reset();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reset() {
|
|
94
|
+
this.active = false;
|
|
95
|
+
this.loudFrames = 0;
|
|
96
|
+
this.quietFrames = 0;
|
|
97
|
+
this.turnFrames = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
push(pcm48k) {
|
|
101
|
+
const loud = rmsDbfs(pcm48k) >= this.thresholdDb;
|
|
102
|
+
if (!this.active) {
|
|
103
|
+
this.loudFrames = loud ? this.loudFrames + 1 : 0;
|
|
104
|
+
if (this.loudFrames >= this.onsetFrames) {
|
|
105
|
+
this.active = true;
|
|
106
|
+
this.quietFrames = 0;
|
|
107
|
+
this.turnFrames = this.loudFrames;
|
|
108
|
+
return { type: "start" };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
this.turnFrames += 1;
|
|
113
|
+
this.quietFrames = loud ? 0 : this.quietFrames + 1;
|
|
114
|
+
if (this.quietFrames >= this.silenceFrames || this.turnFrames >= this.maxFrames) {
|
|
115
|
+
const reason = this.turnFrames >= this.maxFrames ? "max-duration" : "silence";
|
|
116
|
+
this.reset();
|
|
117
|
+
return { type: "commit", reason };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
FRAME_MS,
|
|
125
|
+
STT_RATE,
|
|
126
|
+
WEBRTC_RATE,
|
|
127
|
+
WEBRTC_SAMPLES,
|
|
128
|
+
OpusAudioCodec,
|
|
129
|
+
TurnDetector,
|
|
130
|
+
downsample48To16,
|
|
131
|
+
rmsDbfs,
|
|
132
|
+
upsampleTo48,
|
|
133
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const WebSocket = require("ws");
|
|
5
|
+
|
|
6
|
+
function transactionId() {
|
|
7
|
+
return crypto.randomBytes(8).toString("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class JanusClient {
|
|
11
|
+
constructor({ url, WebSocketImpl = WebSocket, requestTimeoutMs = 10000, keepaliveMs = 25000 } = {}) {
|
|
12
|
+
if (!url) throw new Error("Janus URL is required");
|
|
13
|
+
this.url = url;
|
|
14
|
+
this.WebSocketImpl = WebSocketImpl;
|
|
15
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
16
|
+
this.keepaliveMs = keepaliveMs;
|
|
17
|
+
this.socket = null;
|
|
18
|
+
this.sessionId = null;
|
|
19
|
+
this.pending = new Map();
|
|
20
|
+
this.handleListeners = new Map();
|
|
21
|
+
this.keepalive = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connect() {
|
|
25
|
+
if (this.socket) return;
|
|
26
|
+
const socket = new this.WebSocketImpl(this.url, "janus-protocol");
|
|
27
|
+
this.socket = socket;
|
|
28
|
+
socket.on("message", (data) => this._onMessage(data));
|
|
29
|
+
socket.on("close", () => this._onClose(new Error("Janus socket closed")));
|
|
30
|
+
socket.on("error", (error) => this._onClose(error));
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
socket.once("open", resolve);
|
|
33
|
+
socket.once("error", reject);
|
|
34
|
+
});
|
|
35
|
+
const created = await this.request({ janus: "create" }, { withSession: false });
|
|
36
|
+
this.sessionId = created?.data?.id;
|
|
37
|
+
if (!this.sessionId) throw new Error("Janus create response did not include a session id");
|
|
38
|
+
this.keepalive = setInterval(() => {
|
|
39
|
+
this.request({ janus: "keepalive" }).catch((error) => console.error("Janus keepalive:", error.message));
|
|
40
|
+
}, this.keepaliveMs);
|
|
41
|
+
this.keepalive.unref?.();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async attach(plugin = "janus.plugin.videoroom") {
|
|
45
|
+
const response = await this.request({ janus: "attach", plugin });
|
|
46
|
+
const handleId = response?.data?.id;
|
|
47
|
+
if (!handleId) throw new Error("Janus attach response did not include a handle id");
|
|
48
|
+
return handleId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onHandle(handleId, listener) {
|
|
52
|
+
const id = String(handleId);
|
|
53
|
+
if (!this.handleListeners.has(id)) this.handleListeners.set(id, new Set());
|
|
54
|
+
this.handleListeners.get(id).add(listener);
|
|
55
|
+
return () => this.handleListeners.get(id)?.delete(listener);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
message(handleId, body, jsep) {
|
|
59
|
+
return this.request({ janus: "message", handle_id: handleId, body, ...(jsep ? { jsep } : {}) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async detach(handleId) {
|
|
63
|
+
if (!handleId || !this.sessionId) return;
|
|
64
|
+
try { await this.request({ janus: "detach", handle_id: handleId }); }
|
|
65
|
+
finally { this.handleListeners.delete(String(handleId)); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
request(payload, { withSession = true } = {}) {
|
|
69
|
+
if (!this.socket || this.socket.readyState !== this.WebSocketImpl.OPEN) {
|
|
70
|
+
return Promise.reject(new Error("Janus socket is not open"));
|
|
71
|
+
}
|
|
72
|
+
const transaction = transactionId();
|
|
73
|
+
const frame = {
|
|
74
|
+
...payload,
|
|
75
|
+
...(withSession && this.sessionId ? { session_id: this.sessionId } : {}),
|
|
76
|
+
transaction,
|
|
77
|
+
};
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
this.pending.delete(transaction);
|
|
81
|
+
reject(new Error(`Janus ${payload.janus} timed out`));
|
|
82
|
+
}, this.requestTimeoutMs);
|
|
83
|
+
this.pending.set(transaction, { resolve, reject, timer });
|
|
84
|
+
this.socket.send(JSON.stringify(frame), (error) => {
|
|
85
|
+
if (!error) return;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
this.pending.delete(transaction);
|
|
88
|
+
reject(error);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async close() {
|
|
94
|
+
if (this.keepalive) clearInterval(this.keepalive);
|
|
95
|
+
this.keepalive = null;
|
|
96
|
+
if (this.sessionId && this.socket?.readyState === this.WebSocketImpl.OPEN) {
|
|
97
|
+
try { await this.request({ janus: "destroy" }); } catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
this.sessionId = null;
|
|
100
|
+
const socket = this.socket;
|
|
101
|
+
this.socket = null;
|
|
102
|
+
if (socket && socket.readyState < this.WebSocketImpl.CLOSING) socket.close();
|
|
103
|
+
this._onClose(new Error("Janus client closed"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_onMessage(raw) {
|
|
107
|
+
let frame;
|
|
108
|
+
try { frame = JSON.parse(raw.toString()); }
|
|
109
|
+
catch (_) { return; }
|
|
110
|
+
const pending = frame.transaction && this.pending.get(frame.transaction);
|
|
111
|
+
if (pending && frame.janus !== "ack") {
|
|
112
|
+
clearTimeout(pending.timer);
|
|
113
|
+
this.pending.delete(frame.transaction);
|
|
114
|
+
if (frame.janus === "error") pending.reject(new Error(frame.error?.reason || "Janus error"));
|
|
115
|
+
else pending.resolve(frame);
|
|
116
|
+
}
|
|
117
|
+
const sender = frame.sender != null ? String(frame.sender) : null;
|
|
118
|
+
if (sender) {
|
|
119
|
+
for (const listener of this.handleListeners.get(sender) || []) {
|
|
120
|
+
try { listener(frame); } catch (error) { console.error("Janus handle listener:", error.message); }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_onClose(error) {
|
|
126
|
+
if (this.keepalive) clearInterval(this.keepalive);
|
|
127
|
+
this.keepalive = null;
|
|
128
|
+
for (const pending of this.pending.values()) {
|
|
129
|
+
clearTimeout(pending.timer);
|
|
130
|
+
pending.reject(error);
|
|
131
|
+
}
|
|
132
|
+
this.pending.clear();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { JanusClient };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { canonicalForChannel } = require("../../../core/identity");
|
|
4
|
+
const { KazeeCallSession } = require("./session");
|
|
5
|
+
|
|
6
|
+
const DEFAULT_JANUS_URL = "wss://janus.kazee.africa/ws";
|
|
7
|
+
const DEFAULT_ICE_SERVERS = [
|
|
8
|
+
{ urls: "stun:coturn.kazee.africa:3478" },
|
|
9
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
class KazeeCallManager {
|
|
13
|
+
constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
|
|
14
|
+
iceServers = DEFAULT_ICE_SERVERS } = {}) {
|
|
15
|
+
this.adapter = adapter;
|
|
16
|
+
this.SessionImpl = SessionImpl;
|
|
17
|
+
this.janusUrl = janusUrl;
|
|
18
|
+
this.iceServers = iceServers;
|
|
19
|
+
this.session = null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
wire(socket) {
|
|
23
|
+
socket.on("call:incoming", (payload) => this.incoming(payload));
|
|
24
|
+
socket.on("call:ended", (payload) => this.ended(payload));
|
|
25
|
+
socket.on("call:rejected", (payload) => this.ended(payload));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async incoming(payload = {}) {
|
|
29
|
+
const callerId = String(payload.fromUserId || "");
|
|
30
|
+
const chatId = String(payload.chatId || "");
|
|
31
|
+
const roomId = Number(payload.roomId);
|
|
32
|
+
const callType = String(payload.callType || "");
|
|
33
|
+
const owner = String(this.adapter.ownerUserId || "");
|
|
34
|
+
const reject = (reason) => this.adapter._socket?.emit("call:reject", {
|
|
35
|
+
toUserId: callerId, callType, chatId, roomId, reason,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!callerId || !chatId || !Number.isSafeInteger(roomId)) return reject("invalid_call");
|
|
39
|
+
if (callerId !== owner) return reject("not_allowed");
|
|
40
|
+
if (callType !== "audio") return reject("unsupported_media");
|
|
41
|
+
if (this.session) return reject("busy");
|
|
42
|
+
|
|
43
|
+
const session = new this.SessionImpl({
|
|
44
|
+
roomId, chatId, callerId, botUserId: this.adapter.botUserId,
|
|
45
|
+
janusUrl: this.janusUrl, iceServers: this.iceServers,
|
|
46
|
+
onTranscript: (text, meta) => this._transcript(text, payload, meta),
|
|
47
|
+
onEnded: () => { if (this.session === session) this.session = null; },
|
|
48
|
+
});
|
|
49
|
+
this.session = session;
|
|
50
|
+
try {
|
|
51
|
+
await session.start();
|
|
52
|
+
this.adapter._socket?.emit("call:accept", {
|
|
53
|
+
toUserId: callerId, callType: "audio", chatId, roomId,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error("Kazee call connect:", error.message);
|
|
57
|
+
this.session = null;
|
|
58
|
+
await session.close("connect-failed").catch(() => {});
|
|
59
|
+
reject("connection_failed");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_transcript(text, incoming, meta = {}) {
|
|
64
|
+
const session = this.session;
|
|
65
|
+
if (!session || !text) return;
|
|
66
|
+
// In a group room, tell the provider who is talking; 1:1 stays unprefixed.
|
|
67
|
+
const speaker = String(meta.display || "").trim();
|
|
68
|
+
const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
|
|
69
|
+
const callAdapter = Object.create(this.adapter);
|
|
70
|
+
// Provider narration/previews are useful in chat but must never be spoken
|
|
71
|
+
// into a call. Only the settled, guarded final answer reaches send().
|
|
72
|
+
callAdapter.suppressStreamPreview = true;
|
|
73
|
+
callAdapter.send = async (_channelId, response) => {
|
|
74
|
+
await session.speak(response);
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
callAdapter.edit = async () => true;
|
|
78
|
+
callAdapter.typing = async () => {};
|
|
79
|
+
callAdapter.sendVoice = async (_channelId, voicePath) => {
|
|
80
|
+
console.warn(`Kazee call ignored file voice response ${voicePath}; streamed TTS is active`);
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
this.adapter._emit("message", {
|
|
84
|
+
adapter: callAdapter,
|
|
85
|
+
channelId: String(incoming.chatId),
|
|
86
|
+
canonicalUserId: canonicalForChannel("kazee", String(incoming.fromUserId)),
|
|
87
|
+
userId: String(incoming.fromUserId),
|
|
88
|
+
type: "text",
|
|
89
|
+
text: attributed,
|
|
90
|
+
messageId: `call-${incoming.roomId}-${Date.now()}`,
|
|
91
|
+
from: { id: String(incoming.fromUserId), name: incoming.fromUserName || "" },
|
|
92
|
+
raw: { ...incoming, source: "kazee-call", ephemeralAudio: true, speaker: speaker || undefined, feedId: meta.feedId },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async ended(payload = {}) {
|
|
97
|
+
if (!this.session) return;
|
|
98
|
+
if (payload.roomId != null && Number(payload.roomId) !== this.session.roomId) return;
|
|
99
|
+
const session = this.session;
|
|
100
|
+
this.session = null;
|
|
101
|
+
await session.close("remote-ended");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop() {
|
|
105
|
+
if (!this.session) return;
|
|
106
|
+
const session = this.session;
|
|
107
|
+
this.session = null;
|
|
108
|
+
await session.close("adapter-stopped");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { KazeeCallManager, DEFAULT_ICE_SERVERS, DEFAULT_JANUS_URL };
|