@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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { MediaStreamTrack, RTCPeerConnection, RtpHeader, RtpPacket } = require("werift");
|
|
4
|
+
const { JanusClient } = require("./janus-client");
|
|
5
|
+
const { OpusAudioCodec, TurnDetector, WEBRTC_SAMPLES, downsample48To16, upsampleTo48 } = require("./audio");
|
|
6
|
+
const { SpeechStream, synthesizePcm } = require("./speech-client");
|
|
7
|
+
|
|
8
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
|
|
10
|
+
const BASE_THRESHOLD_DB = -38;
|
|
11
|
+
// While TTS plays, the caller's device can leak bot audio back through its mic.
|
|
12
|
+
// Require a much louder signal before treating incoming audio as human speech.
|
|
13
|
+
const SPEAKING_THRESHOLD_DB = -26;
|
|
14
|
+
const PREBUFFER_FRAMES = 12; // 240ms of pre-onset audio so first syllables reach STT
|
|
15
|
+
const LIMIT_CHECK_MS = 10_000;
|
|
16
|
+
const DEFAULT_MAX_CALL_MS = 30 * 60_000;
|
|
17
|
+
const DEFAULT_IDLE_MS = 2 * 60_000;
|
|
18
|
+
const MAX_DURATION_GOODBYE = "We've hit the call time limit, so I'll hang up now. Message me any time.";
|
|
19
|
+
const IDLE_GOODBYE = "I haven't heard anything for a while, so I'll hang up now. Message me any time.";
|
|
20
|
+
|
|
21
|
+
class KazeeCallSession {
|
|
22
|
+
constructor({ roomId, chatId, callerId, botUserId, janusUrl, iceServers = [], onTranscript, onEnded,
|
|
23
|
+
maxCallMs, idleMs, synthesize = synthesizePcm,
|
|
24
|
+
JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
|
|
25
|
+
this.roomId = Number(roomId);
|
|
26
|
+
this.chatId = String(chatId);
|
|
27
|
+
this.callerId = String(callerId);
|
|
28
|
+
this.botUserId = String(botUserId);
|
|
29
|
+
this.janus = new JanusClientImpl({ url: janusUrl });
|
|
30
|
+
this.PeerConnectionImpl = PeerConnectionImpl;
|
|
31
|
+
this.SpeechStreamImpl = SpeechStreamImpl;
|
|
32
|
+
this.iceServers = iceServers;
|
|
33
|
+
this.onTranscript = onTranscript || (() => {});
|
|
34
|
+
this.onEnded = onEnded || (() => {});
|
|
35
|
+
this.synthesize = synthesize;
|
|
36
|
+
this.maxCallMs = Number(maxCallMs ?? process.env.KAZEE_CALL_MAX_MS) || DEFAULT_MAX_CALL_MS;
|
|
37
|
+
this.idleMs = Number(idleMs ?? process.env.KAZEE_CALL_IDLE_MS) || DEFAULT_IDLE_MS;
|
|
38
|
+
this.codec = new OpusAudioCodec();
|
|
39
|
+
this.publisher = null;
|
|
40
|
+
this.publisherHandle = null;
|
|
41
|
+
this.subscribers = new Map();
|
|
42
|
+
this.closed = false;
|
|
43
|
+
this.speaking = false;
|
|
44
|
+
this._speakQueue = [];
|
|
45
|
+
this._speakActive = false;
|
|
46
|
+
this._speakGen = 0;
|
|
47
|
+
this._startedAt = 0;
|
|
48
|
+
this._lastHeard = 0;
|
|
49
|
+
this._lastSpokeAt = 0;
|
|
50
|
+
this._limitTimer = null;
|
|
51
|
+
this._ending = false;
|
|
52
|
+
this.sequence = Math.floor(Math.random() * 65535);
|
|
53
|
+
this.timestamp = Math.floor(Math.random() * 0xffffffff);
|
|
54
|
+
this.ssrc = Math.floor(Math.random() * 0xffffffff);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async start() {
|
|
58
|
+
await this.janus.connect();
|
|
59
|
+
this.publisherHandle = await this.janus.attach();
|
|
60
|
+
this.janus.onHandle(this.publisherHandle, (frame) => this._onPublisherEvent(frame));
|
|
61
|
+
const joined = await this.janus.message(this.publisherHandle, {
|
|
62
|
+
request: "join", ptype: "publisher", room: this.roomId, display: `open-claudia:${this.botUserId}`,
|
|
63
|
+
});
|
|
64
|
+
await this._consumePublisherFrame(joined);
|
|
65
|
+
this._startedAt = Date.now();
|
|
66
|
+
this._lastHeard = this._startedAt;
|
|
67
|
+
this._lastSpokeAt = this._startedAt;
|
|
68
|
+
this._limitTimer = setInterval(() => { this._enforceLimits().catch(() => {}); }, LIMIT_CHECK_MS);
|
|
69
|
+
if (typeof this._limitTimer.unref === "function") this._limitTimer.unref();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _onPublisherEvent(frame) {
|
|
73
|
+
try { await this._consumePublisherFrame(frame); }
|
|
74
|
+
catch (error) { console.error("Kazee call publisher:", error.message); await this.close("janus-error"); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async _consumePublisherFrame(frame) {
|
|
78
|
+
const data = frame?.plugindata?.data || {};
|
|
79
|
+
if (data.error) throw new Error(data.error);
|
|
80
|
+
for (const publisher of data.publishers || []) {
|
|
81
|
+
const feedId = Number(publisher.id);
|
|
82
|
+
if (!this.subscribers.has(feedId) && !String(publisher.display || "").startsWith("open-claudia:")) {
|
|
83
|
+
await this._subscribe(feedId, publisher.display);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (data.videoroom === "joined" && !this.publisher) await this._publish();
|
|
87
|
+
const gone = Number(data.leaving || data.unpublished || 0);
|
|
88
|
+
if (gone && this.subscribers.has(gone)) await this._removeSubscriber(gone);
|
|
89
|
+
if (data.videoroom === "destroyed") await this.close("room-destroyed");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _publish() {
|
|
93
|
+
if (this.publisher) return;
|
|
94
|
+
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
95
|
+
const track = new MediaStreamTrack({ kind: "audio" });
|
|
96
|
+
const sender = pc.addTrack(track);
|
|
97
|
+
this.publisher = { pc, track, sender };
|
|
98
|
+
const offer = await pc.createOffer();
|
|
99
|
+
await pc.setLocalDescription(offer);
|
|
100
|
+
const configured = await this.janus.message(this.publisherHandle, { request: "configure", audio: true, video: false }, pc.localDescription);
|
|
101
|
+
const answer = configured?.jsep;
|
|
102
|
+
if (!answer) throw new Error("Janus publisher configure did not return SDP");
|
|
103
|
+
await pc.setRemoteDescription(answer);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async _subscribe(feedId, display) {
|
|
107
|
+
const handle = await this.janus.attach();
|
|
108
|
+
const pc = new this.PeerConnectionImpl({ iceServers: this.iceServers });
|
|
109
|
+
const sub = {
|
|
110
|
+
handle,
|
|
111
|
+
pc,
|
|
112
|
+
display: String(display || ""),
|
|
113
|
+
// Opus decoders are stateful, so each feed needs its own.
|
|
114
|
+
codec: new OpusAudioCodec(),
|
|
115
|
+
vad: new TurnDetector({ thresholdDb: BASE_THRESHOLD_DB }),
|
|
116
|
+
stt: new this.SpeechStreamImpl({ onTranscript: (text) => this._onFeedTranscript(feedId, text) }),
|
|
117
|
+
prebuffer: [],
|
|
118
|
+
};
|
|
119
|
+
this.subscribers.set(feedId, sub);
|
|
120
|
+
pc.onTrack.subscribe((track) => {
|
|
121
|
+
if (track.kind === "audio") track.onReceiveRtp.subscribe((packet) => this._onFeedRtp(feedId, packet));
|
|
122
|
+
});
|
|
123
|
+
this.janus.onHandle(handle, (frame) => this._onSubscriberEvent(feedId, frame));
|
|
124
|
+
const attached = await this.janus.message(handle, { request: "join", ptype: "subscriber", room: this.roomId, feed: feedId });
|
|
125
|
+
await this._consumeSubscriberFrame(feedId, attached);
|
|
126
|
+
await sub.stt.connect().catch((error) => console.error("Kazee call STT connect:", error.message));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async _onSubscriberEvent(feedId, frame) {
|
|
130
|
+
try { await this._consumeSubscriberFrame(feedId, frame); }
|
|
131
|
+
catch (error) { console.error("Kazee call subscriber:", error.message); await this._removeSubscriber(feedId); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _consumeSubscriberFrame(feedId, frame) {
|
|
135
|
+
const sub = this.subscribers.get(feedId);
|
|
136
|
+
if (!sub) return;
|
|
137
|
+
const data = frame?.plugindata?.data || {};
|
|
138
|
+
if (data.error) throw new Error(data.error);
|
|
139
|
+
if (frame.jsep && !sub.started) {
|
|
140
|
+
sub.started = true;
|
|
141
|
+
await sub.pc.setRemoteDescription(frame.jsep);
|
|
142
|
+
const answer = await sub.pc.createAnswer();
|
|
143
|
+
await sub.pc.setLocalDescription(answer);
|
|
144
|
+
await this.janus.message(sub.handle, { request: "start", room: this.roomId }, sub.pc.localDescription);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_onFeedTranscript(feedId, text) {
|
|
149
|
+
const sub = this.subscribers.get(feedId);
|
|
150
|
+
this.onTranscript(text, {
|
|
151
|
+
feedId,
|
|
152
|
+
display: sub?.display || "",
|
|
153
|
+
participants: this.subscribers.size,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_onFeedRtp(feedId, packet) {
|
|
158
|
+
if (this.closed || !packet?.payload?.length) return;
|
|
159
|
+
const sub = this.subscribers.get(feedId);
|
|
160
|
+
if (!sub) return;
|
|
161
|
+
let pcm;
|
|
162
|
+
try { pcm = sub.codec.decode(packet.payload); } catch (_) { return; }
|
|
163
|
+
sub.vad.thresholdDb = this.speaking ? SPEAKING_THRESHOLD_DB : BASE_THRESHOLD_DB;
|
|
164
|
+
const event = sub.vad.push(pcm);
|
|
165
|
+
const pcm16 = downsample48To16(pcm);
|
|
166
|
+
if (!sub.vad.active && !event) {
|
|
167
|
+
sub.prebuffer.push(pcm16);
|
|
168
|
+
if (sub.prebuffer.length > PREBUFFER_FRAMES) sub.prebuffer.shift();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (event?.type === "start") {
|
|
172
|
+
this._lastHeard = Date.now();
|
|
173
|
+
// Barge-in: the human wins — stop talking and listen.
|
|
174
|
+
if (this.speaking) this.cancelSpeech();
|
|
175
|
+
for (const frame of sub.prebuffer.splice(0)) this._feedAudio(sub, frame);
|
|
176
|
+
}
|
|
177
|
+
this._feedAudio(sub, pcm16);
|
|
178
|
+
if (event?.type === "commit") {
|
|
179
|
+
this._lastHeard = Date.now();
|
|
180
|
+
sub.stt.commit().catch((error) => console.error("Kazee call STT commit:", error.message));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A transient speech-service blip drops frames but must not end a live call;
|
|
185
|
+
// SpeechStream reconnects on the next send.
|
|
186
|
+
_feedAudio(sub, pcm16) {
|
|
187
|
+
sub.stt.audio(pcm16).catch((error) => console.error("Kazee call STT send:", error.message));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
speak(text) {
|
|
191
|
+
if (this.closed || !String(text || "").trim()) return Promise.resolve(false);
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
this._speakQueue.push({ text: String(text), resolve, reject });
|
|
194
|
+
this._drainSpeech();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
cancelSpeech() {
|
|
199
|
+
this._speakGen += 1;
|
|
200
|
+
for (const item of this._speakQueue.splice(0)) item.resolve(false);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_drainSpeech() {
|
|
204
|
+
if (this._speakActive) return;
|
|
205
|
+
this._speakActive = true;
|
|
206
|
+
(async () => {
|
|
207
|
+
try {
|
|
208
|
+
while (this._speakQueue.length && !this.closed) {
|
|
209
|
+
const item = this._speakQueue.shift();
|
|
210
|
+
try { item.resolve(await this._playUtterance(item.text, this._speakGen)); }
|
|
211
|
+
catch (error) { item.reject(error); }
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
this._speakActive = false;
|
|
215
|
+
this.speaking = false;
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async _playUtterance(text, gen) {
|
|
221
|
+
const synthesized = await this.synthesize(text);
|
|
222
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
223
|
+
const pcm48 = upsampleTo48(synthesized.pcm, synthesized.sampleRate);
|
|
224
|
+
this.speaking = true;
|
|
225
|
+
try {
|
|
226
|
+
for (let offset = 0; offset < pcm48.length; offset += WEBRTC_SAMPLES * 2) {
|
|
227
|
+
if (this.closed || gen !== this._speakGen) return false;
|
|
228
|
+
let frame = pcm48.subarray(offset, offset + WEBRTC_SAMPLES * 2);
|
|
229
|
+
if (frame.length < WEBRTC_SAMPLES * 2) frame = Buffer.concat([frame, Buffer.alloc(WEBRTC_SAMPLES * 2 - frame.length)]);
|
|
230
|
+
const opus = this.codec.encode(frame);
|
|
231
|
+
const payloadType = this.publisher?.sender?.codec?.payloadType || 111;
|
|
232
|
+
this.publisher?.track?.writeRtp(new RtpPacket(new RtpHeader({
|
|
233
|
+
payloadType, sequenceNumber: this.sequence++ & 0xffff, timestamp: this.timestamp >>> 0, ssrc: this.ssrc,
|
|
234
|
+
}), opus));
|
|
235
|
+
this.timestamp = (this.timestamp + WEBRTC_SAMPLES) >>> 0;
|
|
236
|
+
await wait(20);
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
} finally {
|
|
240
|
+
this._lastSpokeAt = Date.now();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async _enforceLimits() {
|
|
245
|
+
if (this.closed || this._ending) return;
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
const overMax = now - this._startedAt >= this.maxCallMs;
|
|
248
|
+
const speechIdle = !this.speaking && !this._speakActive && !this._speakQueue.length;
|
|
249
|
+
const idle = speechIdle && now - Math.max(this._lastHeard, this._lastSpokeAt) >= this.idleMs;
|
|
250
|
+
if (!overMax && !idle) return;
|
|
251
|
+
this._ending = true;
|
|
252
|
+
const heardBefore = this._lastHeard;
|
|
253
|
+
try { await this.speak(overMax ? MAX_DURATION_GOODBYE : IDLE_GOODBYE); } catch (_) {}
|
|
254
|
+
if (!overMax && this._lastHeard > heardBefore && !this.closed) {
|
|
255
|
+
// The caller barged in during the goodbye — stay on the line.
|
|
256
|
+
this._ending = false;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await this.close(overMax ? "max-duration" : "idle-timeout");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _removeSubscriber(feedId) {
|
|
263
|
+
const sub = this.subscribers.get(feedId);
|
|
264
|
+
if (!sub) return;
|
|
265
|
+
this.subscribers.delete(feedId);
|
|
266
|
+
try { sub.stt.close(); } catch (_) {}
|
|
267
|
+
try { sub.codec.close(); } catch (_) {}
|
|
268
|
+
try { await sub.pc.close(); } catch (_) {}
|
|
269
|
+
try { await this.janus.detach(sub.handle); } catch (_) {}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async close(reason = "ended") {
|
|
273
|
+
if (this.closed) return;
|
|
274
|
+
this.closed = true;
|
|
275
|
+
if (this._limitTimer) { clearInterval(this._limitTimer); this._limitTimer = null; }
|
|
276
|
+
this.cancelSpeech();
|
|
277
|
+
for (const feed of [...this.subscribers.keys()]) await this._removeSubscriber(feed);
|
|
278
|
+
try { await this.publisher?.pc?.close(); } catch (_) {}
|
|
279
|
+
try { await this.janus.detach(this.publisherHandle); } catch (_) {}
|
|
280
|
+
try { await this.janus.close(); } catch (_) {}
|
|
281
|
+
this.codec.close();
|
|
282
|
+
this.onEnded(reason);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { KazeeCallSession };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const WebSocket = require("ws");
|
|
4
|
+
const { SPEECH_SERVICE_URL } = require("../../../core/config");
|
|
5
|
+
const { speechBearerToken } = require("../../../core/media");
|
|
6
|
+
const ttsVoice = require("../../../core/tts-voice");
|
|
7
|
+
|
|
8
|
+
class SpeechStream {
|
|
9
|
+
constructor({ baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), WebSocketImpl = WebSocket, onTranscript } = {}) {
|
|
10
|
+
this.baseUrl = String(baseUrl || "").replace(/\/$/, "");
|
|
11
|
+
this.token = token;
|
|
12
|
+
this.WebSocketImpl = WebSocketImpl;
|
|
13
|
+
this.onTranscript = onTranscript || (() => {});
|
|
14
|
+
this.socket = null;
|
|
15
|
+
this.ready = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async connect() {
|
|
19
|
+
if (this.ready) return this.ready;
|
|
20
|
+
if (!this.baseUrl || !this.token) throw new Error("Hosted speech service is not configured");
|
|
21
|
+
const url = this.baseUrl.replace(/^http/, "ws") + "/v1/audio/transcriptions/stream";
|
|
22
|
+
this.ready = new Promise((resolve, reject) => {
|
|
23
|
+
const ws = new this.WebSocketImpl(url, { headers: { Authorization: `Bearer ${this.token}` } });
|
|
24
|
+
this.socket = ws;
|
|
25
|
+
ws.once("open", () => {
|
|
26
|
+
ws.send(JSON.stringify({ sample_rate: 16000, language: "en" }));
|
|
27
|
+
resolve();
|
|
28
|
+
});
|
|
29
|
+
ws.once("error", reject);
|
|
30
|
+
ws.on("message", (raw) => this._onMessage(raw));
|
|
31
|
+
ws.on("close", () => { this.socket = null; this.ready = null; });
|
|
32
|
+
});
|
|
33
|
+
return this.ready;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async audio(pcm16k) {
|
|
37
|
+
await this.connect();
|
|
38
|
+
if (this.socket?.readyState === this.WebSocketImpl.OPEN) this.socket.send(pcm16k);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async commit() {
|
|
42
|
+
await this.connect();
|
|
43
|
+
this.socket.send(JSON.stringify({ type: "commit" }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_onMessage(raw) {
|
|
47
|
+
let message;
|
|
48
|
+
try { message = JSON.parse(raw.toString()); } catch (_) { return; }
|
|
49
|
+
const text = String(message.text || message.transcript || "").trim();
|
|
50
|
+
if (text && (message.final === true || message.type === "final" || message.type === "transcription")) {
|
|
51
|
+
Promise.resolve(this.onTranscript(text)).catch((error) => console.error("Kazee call transcript:", error.message));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
close() {
|
|
56
|
+
const ws = this.socket;
|
|
57
|
+
this.socket = null;
|
|
58
|
+
this.ready = null;
|
|
59
|
+
if (ws?.readyState === this.WebSocketImpl.OPEN) ws.send(JSON.stringify({ type: "close" }));
|
|
60
|
+
try { ws?.close(); } catch (_) {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function synthesizePcm(text, { baseUrl = SPEECH_SERVICE_URL, token = speechBearerToken(), fetchImpl = fetch } = {}) {
|
|
65
|
+
if (!baseUrl || !token) throw new Error("Hosted speech service is not configured");
|
|
66
|
+
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}/v1/audio/speech`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ input: text, voice: ttsVoice.getVoice(), response_format: "pcm" }),
|
|
70
|
+
signal: AbortSignal.timeout(60000),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) throw new Error(`Hosted TTS failed: HTTP ${response.status}`);
|
|
73
|
+
const contentType = response.headers?.get?.("content-type") || "";
|
|
74
|
+
const rate = Number(contentType.match(/(?:^|;)\s*rate=(\d+)/i)?.[1] || 24000);
|
|
75
|
+
if (!Number.isFinite(rate) || rate < 8000 || rate > 48000) throw new Error(`Hosted TTS returned invalid PCM rate: ${rate}`);
|
|
76
|
+
return { pcm: Buffer.from(await response.arrayBuffer()), sampleRate: rate };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { SpeechStream, synthesizePcm };
|
package/core/runner.js
CHANGED
|
@@ -1765,7 +1765,9 @@ const STREAM_PREVIEW_FOOTER = "\n\n⏳ working — /stop to cancel";
|
|
|
1765
1765
|
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1766
1766
|
// settles the message when the run ends.
|
|
1767
1767
|
function createStreamPreview({ state, store }) {
|
|
1768
|
-
let enabled = !!store
|
|
1768
|
+
let enabled = !!store
|
|
1769
|
+
&& (store?.adapter?.type || "") !== "voice"
|
|
1770
|
+
&& store?.adapter?.suppressStreamPreview !== true;
|
|
1769
1771
|
if (enabled) {
|
|
1770
1772
|
// Interim text is NOT vetted by guardOutboundReply (only the final answer
|
|
1771
1773
|
// is), so it must never reach a guarded external speaker. Fail closed if
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.14",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"open-claudia": "./bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
+
"test:kazee-call": "node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
|
|
10
11
|
"setup": "node setup.js",
|
|
11
12
|
"start": "node bot.js",
|
|
12
|
-
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js",
|
|
13
|
+
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
|
|
13
14
|
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
@@ -71,6 +72,12 @@
|
|
|
71
72
|
"test-identity-prune.js",
|
|
72
73
|
"test-kazee-message-id.js",
|
|
73
74
|
"test-kazee-channel-health.js",
|
|
75
|
+
"test-kazee-call-audio.js",
|
|
76
|
+
"test-kazee-call-janus.js",
|
|
77
|
+
"test-kazee-call-webrtc.js",
|
|
78
|
+
"test-kazee-call-speech.js",
|
|
79
|
+
"test-kazee-call-manager.js",
|
|
80
|
+
"test-kazee-call-session.js",
|
|
74
81
|
"test-prompt-budget.js",
|
|
75
82
|
"test-agency-ledger.js",
|
|
76
83
|
"test-agency-deliberate.js",
|
|
@@ -134,10 +141,15 @@
|
|
|
134
141
|
"engines": {
|
|
135
142
|
"node": ">=24.0.0"
|
|
136
143
|
},
|
|
144
|
+
"overrides": {
|
|
145
|
+
"ws": "^8.21.1"
|
|
146
|
+
},
|
|
137
147
|
"dependencies": {
|
|
138
148
|
"node-cron": "^4.2.1",
|
|
139
149
|
"node-telegram-bot-api": "^0.67.0",
|
|
150
|
+
"opusscript": "^0.1.1",
|
|
140
151
|
"socket.io-client": "^4.7.5",
|
|
141
|
-
"
|
|
152
|
+
"werift": "^0.23.0",
|
|
153
|
+
"ws": "^8.21.1"
|
|
142
154
|
}
|
|
143
155
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const {
|
|
6
|
+
WEBRTC_SAMPLES,
|
|
7
|
+
OpusAudioCodec,
|
|
8
|
+
TurnDetector,
|
|
9
|
+
downsample48To16,
|
|
10
|
+
rmsDbfs,
|
|
11
|
+
upsampleTo48,
|
|
12
|
+
} = require("./channels/kazee/call/audio");
|
|
13
|
+
|
|
14
|
+
function tone({ rate = 48000, hz = 440, samples = WEBRTC_SAMPLES, amplitude = 12000 } = {}) {
|
|
15
|
+
const out = Buffer.alloc(samples * 2);
|
|
16
|
+
const view = new Int16Array(out.buffer, out.byteOffset, samples);
|
|
17
|
+
for (let i = 0; i < samples; i += 1) view[i] = Math.round(Math.sin(2 * Math.PI * hz * i / rate) * amplitude);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const codec = new OpusAudioCodec();
|
|
22
|
+
const source = tone();
|
|
23
|
+
const packet = codec.encode(source);
|
|
24
|
+
const decoded = codec.decode(packet);
|
|
25
|
+
assert.strictEqual(decoded.length, source.length, "Opus round trip preserves the 20ms frame size");
|
|
26
|
+
assert(rmsDbfs(decoded) > -20, "decoded tone has useful signal energy");
|
|
27
|
+
codec.close();
|
|
28
|
+
|
|
29
|
+
const pcm16 = downsample48To16(source);
|
|
30
|
+
assert.strictEqual(pcm16.length, 320 * 2, "48k→16k produces 320 samples for a 20ms frame");
|
|
31
|
+
const pcm48 = upsampleTo48(pcm16, 16000);
|
|
32
|
+
assert.strictEqual(pcm48.length, source.length, "16k→48k restores the WebRTC frame length");
|
|
33
|
+
|
|
34
|
+
const detector = new TurnDetector({ thresholdDb: -35, onsetFrames: 2, silenceMs: 60 });
|
|
35
|
+
const silence = Buffer.alloc(source.length);
|
|
36
|
+
assert.strictEqual(detector.push(silence), null);
|
|
37
|
+
assert.strictEqual(detector.push(source), null);
|
|
38
|
+
assert.deepStrictEqual(detector.push(source), { type: "start" });
|
|
39
|
+
assert.strictEqual(detector.push(silence), null);
|
|
40
|
+
assert.strictEqual(detector.push(silence), null);
|
|
41
|
+
assert.deepStrictEqual(detector.push(silence), { type: "commit", reason: "silence" });
|
|
42
|
+
|
|
43
|
+
console.log("kazee call audio: ok");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const { WebSocketServer } = require("ws");
|
|
6
|
+
const { JanusClient } = require("./channels/kazee/call/janus-client");
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const server = new WebSocketServer({ port: 0, handleProtocols: (protocols) => protocols.has("janus-protocol") ? "janus-protocol" : false });
|
|
10
|
+
await new Promise((resolve) => server.once("listening", resolve));
|
|
11
|
+
const port = server.address().port;
|
|
12
|
+
const seen = [];
|
|
13
|
+
|
|
14
|
+
server.on("connection", (socket) => socket.on("message", (raw) => {
|
|
15
|
+
const frame = JSON.parse(raw.toString());
|
|
16
|
+
seen.push(frame.janus);
|
|
17
|
+
if (frame.janus === "create") {
|
|
18
|
+
socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, data: { id: 42 } }));
|
|
19
|
+
} else if (frame.janus === "attach") {
|
|
20
|
+
socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, session_id: 42, data: { id: 84 } }));
|
|
21
|
+
} else if (frame.janus === "message") {
|
|
22
|
+
socket.send(JSON.stringify({ janus: "ack", transaction: frame.transaction, session_id: 42 }));
|
|
23
|
+
setTimeout(() => socket.send(JSON.stringify({
|
|
24
|
+
janus: "event",
|
|
25
|
+
transaction: frame.transaction,
|
|
26
|
+
session_id: 42,
|
|
27
|
+
sender: 84,
|
|
28
|
+
plugindata: { plugin: "janus.plugin.videoroom", data: { videoroom: "joined", room: 7 } },
|
|
29
|
+
})), 5);
|
|
30
|
+
} else if (frame.janus === "destroy") {
|
|
31
|
+
socket.send(JSON.stringify({ janus: "success", transaction: frame.transaction, session_id: 42 }));
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const client = new JanusClient({ url: `ws://127.0.0.1:${port}`, keepaliveMs: 60000 });
|
|
36
|
+
await client.connect();
|
|
37
|
+
assert.strictEqual(client.sessionId, 42);
|
|
38
|
+
const handle = await client.attach();
|
|
39
|
+
assert.strictEqual(handle, 84);
|
|
40
|
+
let handleEvent = null;
|
|
41
|
+
client.onHandle(handle, (event) => { handleEvent = event; });
|
|
42
|
+
const joined = await client.message(handle, { request: "join", ptype: "publisher", room: 7 });
|
|
43
|
+
assert.strictEqual(joined.plugindata.data.videoroom, "joined", "ack is ignored until the plugin event arrives");
|
|
44
|
+
assert.strictEqual(handleEvent?.sender, 84, "plugin event is also delivered to the handle listener");
|
|
45
|
+
await client.close();
|
|
46
|
+
await new Promise((resolve) => server.close(resolve));
|
|
47
|
+
assert.deepStrictEqual(seen.slice(0, 4), ["create", "attach", "message", "destroy"]);
|
|
48
|
+
console.log("kazee call Janus control plane: ok");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main().catch((error) => {
|
|
52
|
+
console.error(error);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const { KazeeCallManager } = require("./channels/kazee/call/manager");
|
|
5
|
+
class FakeSession {
|
|
6
|
+
static instances = [];
|
|
7
|
+
constructor(options) { this.options = options; this.roomId = options.roomId; FakeSession.instances.push(this); }
|
|
8
|
+
async start() { this.started = true; }
|
|
9
|
+
async speak(text) { this.spoken = text; }
|
|
10
|
+
async close(reason) { this.closed = reason; this.options.onEnded(reason); }
|
|
11
|
+
}
|
|
12
|
+
function fixture() {
|
|
13
|
+
const events = [], inbound = [];
|
|
14
|
+
const socket = { on() {}, emit(name, payload) { events.push({ name, payload }); } };
|
|
15
|
+
const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: socket, _emit(name, payload) { inbound.push({ name, payload }); } };
|
|
16
|
+
return { events, inbound, manager: new KazeeCallManager({ adapter, SessionImpl: FakeSession }) };
|
|
17
|
+
}
|
|
18
|
+
(async () => {
|
|
19
|
+
let f = fixture();
|
|
20
|
+
await f.manager.incoming({ fromUserId: "stranger", chatId: "chat", roomId: 10, callType: "audio" });
|
|
21
|
+
assert.strictEqual(f.events[0].payload.reason, "not_allowed");
|
|
22
|
+
assert.strictEqual(FakeSession.instances.length, 0);
|
|
23
|
+
f = fixture();
|
|
24
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "chat", roomId: 11, callType: "video" });
|
|
25
|
+
assert.strictEqual(f.events[0].payload.reason, "unsupported_media");
|
|
26
|
+
f = fixture();
|
|
27
|
+
await f.manager.incoming({ fromUserId: "owner-1", fromUserName: "Owner", chatId: "chat", roomId: 12, callType: "audio" });
|
|
28
|
+
assert.strictEqual(f.events[0].name, "call:accept");
|
|
29
|
+
f.manager.session.options.onTranscript("What is on my agenda?");
|
|
30
|
+
assert.strictEqual(f.inbound[0].payload.raw.ephemeralAudio, true);
|
|
31
|
+
await f.inbound[0].payload.adapter.send("chat", "Your agenda is clear.");
|
|
32
|
+
assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
|
|
33
|
+
await f.manager.ended({ roomId: 12 });
|
|
34
|
+
assert.strictEqual(f.manager.session, null);
|
|
35
|
+
console.log("kazee call manager: ok");
|
|
36
|
+
})().catch((error) => { console.error(error); process.exitCode = 1; });
|