@ouro.bot/cli 0.1.0-alpha.887 → 0.1.0-alpha.889
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.json +12 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/core.js +51 -28
- package/dist/heart/daemon/cli-exec.js +18 -0
- package/dist/heart/daemon/cli-help.js +7 -0
- package/dist/heart/daemon/cli-parse.js +154 -0
- package/dist/repertoire/tools-voice-local.js +198 -0
- package/dist/repertoire/tools.js +2 -0
- package/dist/senses/voice/call-auth.js +3 -1
- package/dist/senses/voice/local-audio-control.js +344 -0
- package/dist/senses/voice/local-audio-devices.js +471 -0
- package/dist/senses/voice/local-audio-disclosure.js +174 -0
- package/dist/senses/voice/local-audio-identity.js +103 -0
- package/dist/senses/voice/local-audio-join.js +325 -0
- package/dist/senses/voice/local-audio-mute.js +199 -0
- package/dist/senses/voice/local-audio-notify.js +121 -0
- package/dist/senses/voice/local-audio-prompts.js +116 -0
- package/dist/senses/voice/local-audio-transport.js +406 -0
- package/dist/senses/voice/local-audio.js +281 -0
- package/dist/senses/voice/local-media-stream.js +145 -0
- package/dist/senses/voice/twilio-phone-runtime.js +16 -0
- package/dist/senses/voice/twilio-phone.js +116 -17
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PacedFrameFeeder = exports.CaptureChunker = exports.PacedPlaybackQueue = exports.MULAW_SILENCE_BYTE = exports.MULAW_FRAME_BYTES = void 0;
|
|
4
|
+
exports.mulawToLinear = mulawToLinear;
|
|
5
|
+
exports.isDigitalSilence = isDigitalSilence;
|
|
6
|
+
exports.mulawFrameRms = mulawFrameRms;
|
|
7
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
8
|
+
/** 20 ms of 8 kHz mu-law audio. */
|
|
9
|
+
exports.MULAW_FRAME_BYTES = 160;
|
|
10
|
+
/** mu-law encoding of zero amplitude. */
|
|
11
|
+
exports.MULAW_SILENCE_BYTE = 0xff;
|
|
12
|
+
const FRAME_MS = 20;
|
|
13
|
+
const DEFAULT_MAX_LEAD_MS = 60;
|
|
14
|
+
function silenceFrame() {
|
|
15
|
+
return Buffer.alloc(exports.MULAW_FRAME_BYTES, exports.MULAW_SILENCE_BYTE);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Writes one 160-byte mu-law frame per 20 ms to a sink in real time, never more than
|
|
19
|
+
* `maxLeadMs` ahead of the clock. Idle periods write silence so the timeline (and mark
|
|
20
|
+
* delivery) keeps advancing.
|
|
21
|
+
*/
|
|
22
|
+
class PacedPlaybackQueue {
|
|
23
|
+
options;
|
|
24
|
+
items = [];
|
|
25
|
+
pendingMarks = new Set();
|
|
26
|
+
maxLeadMs;
|
|
27
|
+
outputLatencyMs;
|
|
28
|
+
running = false;
|
|
29
|
+
drainWaiters = [];
|
|
30
|
+
timer = undefined;
|
|
31
|
+
nextDue = 0;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.maxLeadMs = options.maxLeadMs ?? DEFAULT_MAX_LEAD_MS;
|
|
35
|
+
this.outputLatencyMs = options.outputLatencyMs ?? 0;
|
|
36
|
+
}
|
|
37
|
+
start() {
|
|
38
|
+
if (this.running)
|
|
39
|
+
return;
|
|
40
|
+
this.running = true;
|
|
41
|
+
this.nextDue = this.options.now();
|
|
42
|
+
(0, runtime_1.emitNervesEvent)({
|
|
43
|
+
component: "senses",
|
|
44
|
+
event: "senses.voice_local_playback_started",
|
|
45
|
+
message: "local paced playback queue started",
|
|
46
|
+
meta: { maxLeadMs: this.maxLeadMs, outputLatencyMs: this.outputLatencyMs },
|
|
47
|
+
});
|
|
48
|
+
this.tick();
|
|
49
|
+
}
|
|
50
|
+
stop() {
|
|
51
|
+
if (!this.running)
|
|
52
|
+
return;
|
|
53
|
+
this.running = false;
|
|
54
|
+
this.options.clearTimer(this.timer);
|
|
55
|
+
this.timer = undefined;
|
|
56
|
+
for (const pending of this.pendingMarks)
|
|
57
|
+
this.options.clearTimer(pending.timer);
|
|
58
|
+
this.pendingMarks.clear();
|
|
59
|
+
this.items.length = 0;
|
|
60
|
+
this.releaseDrainWaiters();
|
|
61
|
+
}
|
|
62
|
+
/** Resolves once every queued frame has been written (or the queue stopped), so a file output gets the whole reply. */
|
|
63
|
+
drain() {
|
|
64
|
+
if (!this.running || !this.hasQueuedFrames())
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
return new Promise((resolve) => { this.drainWaiters.push(resolve); });
|
|
67
|
+
}
|
|
68
|
+
enqueue(payload) {
|
|
69
|
+
for (let offset = 0; offset < payload.length; offset += exports.MULAW_FRAME_BYTES) {
|
|
70
|
+
const slice = payload.subarray(offset, offset + exports.MULAW_FRAME_BYTES);
|
|
71
|
+
const frame = slice.length === exports.MULAW_FRAME_BYTES
|
|
72
|
+
? Buffer.from(slice)
|
|
73
|
+
: Buffer.concat([slice, Buffer.alloc(exports.MULAW_FRAME_BYTES - slice.length, exports.MULAW_SILENCE_BYTE)]);
|
|
74
|
+
this.items.push({ kind: "frame", frame });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
mark(name, onPlayed) {
|
|
78
|
+
this.items.push({ kind: "mark", name, onPlayed });
|
|
79
|
+
}
|
|
80
|
+
/** Drops queued audio and fires every pending mark now (Twilio returns marks on clear). */
|
|
81
|
+
clear() {
|
|
82
|
+
const dropped = this.items.splice(0);
|
|
83
|
+
const firing = [];
|
|
84
|
+
for (const pending of this.pendingMarks) {
|
|
85
|
+
this.options.clearTimer(pending.timer);
|
|
86
|
+
firing.push(pending);
|
|
87
|
+
}
|
|
88
|
+
this.pendingMarks.clear();
|
|
89
|
+
for (const item of dropped) {
|
|
90
|
+
if (item.kind === "mark")
|
|
91
|
+
firing.push(item);
|
|
92
|
+
}
|
|
93
|
+
(0, runtime_1.emitNervesEvent)({
|
|
94
|
+
component: "senses",
|
|
95
|
+
event: "senses.voice_local_playback_cleared",
|
|
96
|
+
message: "local paced playback queue cleared",
|
|
97
|
+
meta: { droppedFrames: dropped.filter((item) => item.kind === "frame").length, firedMarks: firing.length },
|
|
98
|
+
});
|
|
99
|
+
for (const mark of firing)
|
|
100
|
+
mark.onPlayed(mark.name);
|
|
101
|
+
}
|
|
102
|
+
queuedMs() {
|
|
103
|
+
return this.items.filter((item) => item.kind === "frame").length * FRAME_MS;
|
|
104
|
+
}
|
|
105
|
+
tick() {
|
|
106
|
+
const now = this.options.now();
|
|
107
|
+
// Underrun (event loop stall): restart the timeline instead of bursting to catch up.
|
|
108
|
+
if (this.nextDue < now)
|
|
109
|
+
this.nextDue = now;
|
|
110
|
+
for (;;) {
|
|
111
|
+
this.drainMarks();
|
|
112
|
+
if (this.nextDue + FRAME_MS - now > this.maxLeadMs)
|
|
113
|
+
break;
|
|
114
|
+
const head = this.items[0];
|
|
115
|
+
if (head && head.kind === "frame") {
|
|
116
|
+
this.items.shift();
|
|
117
|
+
this.options.write(head.frame);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
this.options.write(silenceFrame());
|
|
121
|
+
}
|
|
122
|
+
this.nextDue += FRAME_MS;
|
|
123
|
+
}
|
|
124
|
+
if (!this.hasQueuedFrames())
|
|
125
|
+
this.releaseDrainWaiters();
|
|
126
|
+
this.timer = this.options.setTimer(() => this.tick(), this.nextDue + FRAME_MS - this.maxLeadMs - now);
|
|
127
|
+
}
|
|
128
|
+
hasQueuedFrames() {
|
|
129
|
+
return this.items.some((item) => item.kind === "frame");
|
|
130
|
+
}
|
|
131
|
+
releaseDrainWaiters() {
|
|
132
|
+
const waiters = this.drainWaiters;
|
|
133
|
+
this.drainWaiters = [];
|
|
134
|
+
for (const resolve of waiters)
|
|
135
|
+
resolve();
|
|
136
|
+
}
|
|
137
|
+
drainMarks() {
|
|
138
|
+
for (;;) {
|
|
139
|
+
const head = this.items[0];
|
|
140
|
+
if (!head || head.kind !== "mark")
|
|
141
|
+
return;
|
|
142
|
+
this.items.shift();
|
|
143
|
+
// `nextDue` is the playhead: the scheduled end of the last frame written before this mark.
|
|
144
|
+
const delay = this.nextDue + this.outputLatencyMs - this.options.now();
|
|
145
|
+
if (delay <= 0) {
|
|
146
|
+
head.onPlayed(head.name);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const pending = { name: head.name, onPlayed: head.onPlayed, timer: undefined };
|
|
150
|
+
pending.timer = this.options.setTimer(() => {
|
|
151
|
+
this.pendingMarks.delete(pending);
|
|
152
|
+
pending.onPlayed(pending.name);
|
|
153
|
+
}, delay);
|
|
154
|
+
this.pendingMarks.add(pending);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
exports.PacedPlaybackQueue = PacedPlaybackQueue;
|
|
159
|
+
/** Re-chunks arbitrary byte chunks into 160-byte frames. */
|
|
160
|
+
class CaptureChunker {
|
|
161
|
+
onFrame;
|
|
162
|
+
pending = Buffer.alloc(0);
|
|
163
|
+
constructor(onFrame) {
|
|
164
|
+
this.onFrame = onFrame;
|
|
165
|
+
}
|
|
166
|
+
push(chunk) {
|
|
167
|
+
this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]);
|
|
168
|
+
while (this.pending.length >= exports.MULAW_FRAME_BYTES) {
|
|
169
|
+
this.onFrame(Buffer.from(this.pending.subarray(0, exports.MULAW_FRAME_BYTES)));
|
|
170
|
+
this.pending = this.pending.subarray(exports.MULAW_FRAME_BYTES);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Emits any remainder padded with silence. */
|
|
174
|
+
flush() {
|
|
175
|
+
if (this.pending.length === 0)
|
|
176
|
+
return;
|
|
177
|
+
const padded = Buffer.concat([this.pending, Buffer.alloc(exports.MULAW_FRAME_BYTES - this.pending.length, exports.MULAW_SILENCE_BYTE)]);
|
|
178
|
+
this.pending = Buffer.alloc(0);
|
|
179
|
+
this.onFrame(padded);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
exports.CaptureChunker = CaptureChunker;
|
|
183
|
+
/** Decodes one mu-law byte to 16-bit linear PCM (G.711). Both zero codes (0xff, 0x7f) decode to 0. */
|
|
184
|
+
function mulawToLinear(byte) {
|
|
185
|
+
const inverted = ~byte & 0xff;
|
|
186
|
+
const exponent = (inverted >> 4) & 0x07;
|
|
187
|
+
const mantissa = inverted & 0x0f;
|
|
188
|
+
const magnitude = (((mantissa << 3) + 0x84) << exponent) - 0x84;
|
|
189
|
+
return inverted & 0x80 ? -magnitude : magnitude;
|
|
190
|
+
}
|
|
191
|
+
/** True when every byte decodes to linear PCM within `threshold` of zero. */
|
|
192
|
+
function isDigitalSilence(frames, threshold = 2) {
|
|
193
|
+
for (const frame of frames) {
|
|
194
|
+
for (const byte of frame) {
|
|
195
|
+
if (Math.abs(mulawToLinear(byte)) > threshold)
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/** Root-mean-square level of a mu-law frame in 16-bit linear units. */
|
|
202
|
+
function mulawFrameRms(frame) {
|
|
203
|
+
if (frame.length === 0)
|
|
204
|
+
return 0;
|
|
205
|
+
let sum = 0;
|
|
206
|
+
for (const byte of frame) {
|
|
207
|
+
const sample = mulawToLinear(byte);
|
|
208
|
+
sum += sample * sample;
|
|
209
|
+
}
|
|
210
|
+
return Math.sqrt(sum / frame.length);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Turns a fast source (sox decoding a recorded file) into a real-time capture stream: one 160-byte
|
|
214
|
+
* frame per 20 ms, then silence for as long as it runs, like a quiet room. This is the file-driven
|
|
215
|
+
* "microphone" for tests without a human.
|
|
216
|
+
*/
|
|
217
|
+
class PacedFrameFeeder {
|
|
218
|
+
options;
|
|
219
|
+
buffered = Buffer.alloc(0);
|
|
220
|
+
drained = false;
|
|
221
|
+
running = false;
|
|
222
|
+
timer = undefined;
|
|
223
|
+
nextDue = 0;
|
|
224
|
+
onData = (chunk) => {
|
|
225
|
+
this.buffered = Buffer.concat([this.buffered, chunk]);
|
|
226
|
+
};
|
|
227
|
+
onEnd = () => {
|
|
228
|
+
this.drained = true;
|
|
229
|
+
};
|
|
230
|
+
constructor(options) {
|
|
231
|
+
this.options = options;
|
|
232
|
+
}
|
|
233
|
+
sourceDrained() {
|
|
234
|
+
return this.drained && this.buffered.length === 0;
|
|
235
|
+
}
|
|
236
|
+
start() {
|
|
237
|
+
if (this.running)
|
|
238
|
+
return;
|
|
239
|
+
this.running = true;
|
|
240
|
+
this.nextDue = this.options.now();
|
|
241
|
+
this.options.source.on("data", this.onData);
|
|
242
|
+
this.options.source.on("end", this.onEnd);
|
|
243
|
+
this.options.source.on("error", this.onEnd);
|
|
244
|
+
(0, runtime_1.emitNervesEvent)({
|
|
245
|
+
component: "senses",
|
|
246
|
+
event: "senses.voice_local_file_capture_started",
|
|
247
|
+
message: "paced file capture started",
|
|
248
|
+
meta: {},
|
|
249
|
+
});
|
|
250
|
+
this.tick();
|
|
251
|
+
}
|
|
252
|
+
stop() {
|
|
253
|
+
if (!this.running)
|
|
254
|
+
return;
|
|
255
|
+
this.running = false;
|
|
256
|
+
this.options.clearTimer(this.timer);
|
|
257
|
+
this.options.source.off("data", this.onData);
|
|
258
|
+
this.options.source.off("end", this.onEnd);
|
|
259
|
+
this.options.source.off("error", this.onEnd);
|
|
260
|
+
this.options.sink.end();
|
|
261
|
+
}
|
|
262
|
+
tick() {
|
|
263
|
+
const now = this.options.now();
|
|
264
|
+
// Stall: restart the timeline instead of bursting.
|
|
265
|
+
if (this.nextDue < now - FRAME_MS * 5)
|
|
266
|
+
this.nextDue = now;
|
|
267
|
+
while (this.nextDue <= now) {
|
|
268
|
+
this.options.sink.write(this.nextFrame());
|
|
269
|
+
this.nextDue += FRAME_MS;
|
|
270
|
+
}
|
|
271
|
+
this.timer = this.options.setTimer(() => this.tick(), this.nextDue - now);
|
|
272
|
+
}
|
|
273
|
+
nextFrame() {
|
|
274
|
+
const take = Math.min(this.buffered.length, exports.MULAW_FRAME_BYTES);
|
|
275
|
+
const frame = Buffer.alloc(exports.MULAW_FRAME_BYTES, exports.MULAW_SILENCE_BYTE);
|
|
276
|
+
this.buffered.copy(frame, 0, 0, take);
|
|
277
|
+
this.buffered = this.buffered.subarray(take);
|
|
278
|
+
return frame;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
exports.PacedFrameFeeder = PacedFrameFeeder;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalMediaStreamSocket = void 0;
|
|
4
|
+
const events_1 = require("events");
|
|
5
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
6
|
+
const local_audio_1 = require("./local-audio");
|
|
7
|
+
const FRAME_MS = 20;
|
|
8
|
+
/**
|
|
9
|
+
* Speaks the Twilio Media Stream protocol in-process: captured audio becomes `start`, `media` and
|
|
10
|
+
* `stop` messages for the session; the session's `media`, `clear` and `mark` messages drive the
|
|
11
|
+
* paced playback queue. It sends no token and no identity parameters; identity is injected into
|
|
12
|
+
* the session by whoever constructs it.
|
|
13
|
+
*/
|
|
14
|
+
class LocalMediaStreamSocket extends events_1.EventEmitter {
|
|
15
|
+
options;
|
|
16
|
+
opened = false;
|
|
17
|
+
closed = false;
|
|
18
|
+
sequence = 0;
|
|
19
|
+
chunk = 0;
|
|
20
|
+
chunker;
|
|
21
|
+
onCaptureData = (data) => this.chunker.push(data);
|
|
22
|
+
onCaptureEnd = () => this.endCapture();
|
|
23
|
+
onCaptureError = (error) => {
|
|
24
|
+
(0, runtime_1.emitNervesEvent)({
|
|
25
|
+
level: "error",
|
|
26
|
+
component: "senses",
|
|
27
|
+
event: "senses.voice_local_capture_error",
|
|
28
|
+
message: "local capture stream failed",
|
|
29
|
+
meta: { callSid: this.options.callSid, error: error.message },
|
|
30
|
+
});
|
|
31
|
+
this.endCapture();
|
|
32
|
+
};
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super();
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.chunker = new local_audio_1.CaptureChunker((frame) => this.emitMedia(frame));
|
|
37
|
+
}
|
|
38
|
+
isOpen() {
|
|
39
|
+
return this.opened && !this.closed;
|
|
40
|
+
}
|
|
41
|
+
open() {
|
|
42
|
+
if (this.opened || this.closed)
|
|
43
|
+
return;
|
|
44
|
+
this.opened = true;
|
|
45
|
+
const { streamSid, callSid } = this.options;
|
|
46
|
+
this.options.playback.start();
|
|
47
|
+
this.emitMessage({
|
|
48
|
+
event: "start",
|
|
49
|
+
start: {
|
|
50
|
+
streamSid,
|
|
51
|
+
callSid,
|
|
52
|
+
tracks: ["inbound"],
|
|
53
|
+
mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
this.options.capture.on("data", this.onCaptureData);
|
|
57
|
+
this.options.capture.on("end", this.onCaptureEnd);
|
|
58
|
+
this.options.capture.on("close", this.onCaptureEnd);
|
|
59
|
+
this.options.capture.on("error", this.onCaptureError);
|
|
60
|
+
(0, runtime_1.emitNervesEvent)({
|
|
61
|
+
component: "senses",
|
|
62
|
+
event: "senses.voice_local_stream_opened",
|
|
63
|
+
message: "local media stream opened",
|
|
64
|
+
meta: { callSid, streamSid },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
send(data) {
|
|
68
|
+
if (!this.isOpen())
|
|
69
|
+
return;
|
|
70
|
+
let message;
|
|
71
|
+
try {
|
|
72
|
+
message = JSON.parse(data.toString());
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
message = null;
|
|
76
|
+
}
|
|
77
|
+
if (!message || typeof message !== "object") {
|
|
78
|
+
(0, runtime_1.emitNervesEvent)({
|
|
79
|
+
level: "warn",
|
|
80
|
+
component: "senses",
|
|
81
|
+
event: "senses.voice_local_stream_message_rejected",
|
|
82
|
+
message: "local media stream ignored a message that was not a JSON object",
|
|
83
|
+
meta: { callSid: this.options.callSid },
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { event, media, mark } = message;
|
|
88
|
+
if (event === "media" && typeof media?.payload === "string") {
|
|
89
|
+
const audio = Buffer.from(media.payload, "base64");
|
|
90
|
+
this.options.onPlaybackAudio?.(audio.length);
|
|
91
|
+
this.options.playback.enqueue(audio);
|
|
92
|
+
}
|
|
93
|
+
else if (event === "clear") {
|
|
94
|
+
this.options.playback.clear();
|
|
95
|
+
}
|
|
96
|
+
else if (event === "mark" && typeof mark?.name === "string") {
|
|
97
|
+
this.options.playback.mark(mark.name, (name) => {
|
|
98
|
+
if (this.isOpen())
|
|
99
|
+
this.emitMessage({ event: "mark", mark: { name } });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
close() {
|
|
104
|
+
if (this.closed)
|
|
105
|
+
return;
|
|
106
|
+
this.closed = true;
|
|
107
|
+
this.options.capture.off("data", this.onCaptureData);
|
|
108
|
+
this.options.capture.off("end", this.onCaptureEnd);
|
|
109
|
+
this.options.capture.off("close", this.onCaptureEnd);
|
|
110
|
+
this.options.capture.off("error", this.onCaptureError);
|
|
111
|
+
this.options.playback.stop();
|
|
112
|
+
(0, runtime_1.emitNervesEvent)({
|
|
113
|
+
component: "senses",
|
|
114
|
+
event: "senses.voice_local_stream_closed",
|
|
115
|
+
message: "local media stream closed",
|
|
116
|
+
meta: { callSid: this.options.callSid },
|
|
117
|
+
});
|
|
118
|
+
this.emit("close");
|
|
119
|
+
this.options.onEnded();
|
|
120
|
+
}
|
|
121
|
+
endCapture() {
|
|
122
|
+
this.chunker.flush();
|
|
123
|
+
this.emitMessage({ event: "stop", stop: { callSid: this.options.callSid } });
|
|
124
|
+
this.close();
|
|
125
|
+
}
|
|
126
|
+
emitMedia(frame) {
|
|
127
|
+
this.chunk += 1;
|
|
128
|
+
this.options.onCaptureFrame?.(frame);
|
|
129
|
+
this.emitMessage({
|
|
130
|
+
event: "media",
|
|
131
|
+
media: {
|
|
132
|
+
track: "inbound",
|
|
133
|
+
chunk: String(this.chunk),
|
|
134
|
+
timestamp: String(this.chunk * FRAME_MS),
|
|
135
|
+
payload: frame.toString("base64"),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
emitMessage(body) {
|
|
140
|
+
this.sequence += 1;
|
|
141
|
+
const message = { ...body, sequenceNumber: String(this.sequence), streamSid: this.options.streamSid };
|
|
142
|
+
this.emit("message", Buffer.from(JSON.stringify(message)));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.LocalMediaStreamSocket = LocalMediaStreamSocket;
|
|
@@ -38,6 +38,7 @@ exports.resolveTwilioPhoneTransportRuntime = resolveTwilioPhoneTransportRuntime;
|
|
|
38
38
|
exports.startConfiguredTwilioPhoneTransport = startConfiguredTwilioPhoneTransport;
|
|
39
39
|
exports.placeConfiguredTwilioPhoneCall = placeConfiguredTwilioPhoneCall;
|
|
40
40
|
exports.closeTwilioPhoneBridgeServer = closeTwilioPhoneBridgeServer;
|
|
41
|
+
exports.resolveLocalAudioRealtimeOptions = resolveLocalAudioRealtimeOptions;
|
|
41
42
|
const path = __importStar(require("path"));
|
|
42
43
|
const crypto = __importStar(require("node:crypto"));
|
|
43
44
|
const identity_1 = require("../../heart/identity");
|
|
@@ -818,3 +819,18 @@ function closeTwilioPhoneBridgeServer(server) {
|
|
|
818
819
|
}),
|
|
819
820
|
]).then(() => undefined);
|
|
820
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* The OpenAI Realtime settings for a local audio join. Local audio needs no public URL or Twilio
|
|
824
|
+
* credentials, only the Realtime key and voice settings, so the phone resolver is asked for a
|
|
825
|
+
* placeholder-enabled media-stream setup and only its Realtime half is returned.
|
|
826
|
+
*/
|
|
827
|
+
async function resolveLocalAudioRealtimeOptions(agentName, deps = defaultTwilioPhoneOutboundCallRuntimeDeps) {
|
|
828
|
+
const settings = await readFreshRuntimeSettings(agentName, {
|
|
829
|
+
enabled: true,
|
|
830
|
+
publicBaseUrl: "https://local-audio.invalid",
|
|
831
|
+
transportMode: "media-stream",
|
|
832
|
+
conversationEngine: "openai-realtime",
|
|
833
|
+
outboundConversationEngine: "openai-realtime",
|
|
834
|
+
}, undefined, false, deps, { preferCached: true });
|
|
835
|
+
return settings.openaiRealtime;
|
|
836
|
+
}
|