accelr8 3.8.2 → 4.0.0-next.1

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.
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ toWsUrl
4
+ } from "./chunk-EQPM4FIJ.js";
5
+ import {
6
+ RTC_NODE,
7
+ RTC_NODE_VERSION,
8
+ VoiceStore,
9
+ decodeVoiceMetadata,
10
+ initialVoiceState,
11
+ mapLiveKitDisconnect,
12
+ parseVoiceIdentity,
13
+ rtcNodeMissingHint,
14
+ sanitizeVoiceName
15
+ } from "./chunk-CAQJPW54.js";
16
+ import {
17
+ captureArgs,
18
+ killHelper,
19
+ listMicDevices,
20
+ spawnHelper,
21
+ vlog,
22
+ voiceSinkCommand
23
+ } from "./chunk-UMPU6SAD.js";
24
+ import "./chunk-62O2F73J.js";
25
+
26
+ // src/livekit-terminal.ts
27
+ import { createRequire } from "node:module";
28
+ import path from "node:path";
29
+ var TERMINAL_RATE = 48e3;
30
+ var FRAME_SAMPLES = 480;
31
+ var FRAME_BYTES = FRAME_SAMPLES * 2;
32
+ var SEND_QUEUE_FRAMES = 10;
33
+ var JITTER_TARGET = 4;
34
+ var JITTER_MAX = 12;
35
+ var SINK_MAX_BYTES = TERMINAL_RATE * 2 / 4;
36
+ var HEADPHONES_ONLY = "headphones only: in-terminal voice has no echo cancellation";
37
+ var rtcLoading = null;
38
+ function loadRtcNode() {
39
+ if (!rtcLoading) {
40
+ rtcLoading = (async () => {
41
+ const name = RTC_NODE;
42
+ const mod = await import(name);
43
+ try {
44
+ const req = createRequire(import.meta.url);
45
+ const log = req(path.join(path.dirname(req.resolve(name)), "log.cjs")).log;
46
+ if (log) log.level = "silent";
47
+ } catch {
48
+ }
49
+ return mod;
50
+ })();
51
+ rtcLoading.catch(() => {
52
+ rtcLoading = null;
53
+ });
54
+ }
55
+ return rtcLoading;
56
+ }
57
+ var JitterQueue = class {
58
+ frames = [];
59
+ playing = false;
60
+ shed = 0;
61
+ push(f) {
62
+ this.frames.push(f);
63
+ if (this.frames.length > JITTER_MAX) {
64
+ const drop = this.frames.length - JITTER_TARGET;
65
+ this.frames.splice(0, drop);
66
+ this.shed += drop;
67
+ }
68
+ }
69
+ /** One frame for this tick, or null (prebuffering or underrun). */
70
+ pull() {
71
+ if (!this.playing) {
72
+ if (this.frames.length < JITTER_TARGET) return null;
73
+ this.playing = true;
74
+ }
75
+ const f = this.frames.shift() ?? null;
76
+ if (!f) this.playing = false;
77
+ return f;
78
+ }
79
+ };
80
+ function mixFrames(frames, samples = FRAME_SAMPLES) {
81
+ const out = new Int16Array(samples);
82
+ if (frames.length === 0) return out;
83
+ for (let i = 0; i < samples; i++) {
84
+ let v = 0;
85
+ for (const f of frames) v += f[i] ?? 0;
86
+ out[i] = v > 32767 ? 32767 : v < -32768 ? -32768 : v;
87
+ }
88
+ return out;
89
+ }
90
+ var PcmFramer = class {
91
+ rest = Buffer.alloc(0);
92
+ push(chunk, onFrame) {
93
+ let buf = this.rest.length ? Buffer.concat([this.rest, chunk]) : chunk;
94
+ while (buf.length >= FRAME_BYTES) {
95
+ const f = new Int16Array(FRAME_SAMPLES);
96
+ Buffer.from(f.buffer).set(buf.subarray(0, FRAME_BYTES));
97
+ onFrame(f);
98
+ buf = buf.subarray(FRAME_BYTES);
99
+ }
100
+ this.rest = Buffer.from(buf);
101
+ }
102
+ };
103
+ var FrameClock = class {
104
+ constructor(onFrame, now = () => performance.now()) {
105
+ this.onFrame = onFrame;
106
+ this.now = now;
107
+ }
108
+ onFrame;
109
+ now;
110
+ timer = null;
111
+ start = 0;
112
+ done = 0;
113
+ run() {
114
+ this.start = this.now();
115
+ this.done = 0;
116
+ this.timer = setInterval(() => this.tick(), 5);
117
+ this.timer.unref?.();
118
+ }
119
+ tick() {
120
+ const due = Math.floor((this.now() - this.start) / 10);
121
+ let n = due - this.done;
122
+ if (n > 10) {
123
+ this.done = due - 1;
124
+ n = 1;
125
+ }
126
+ for (; n > 0; n--) {
127
+ this.done++;
128
+ this.onFrame();
129
+ }
130
+ }
131
+ stop() {
132
+ if (this.timer) clearInterval(this.timer);
133
+ this.timer = null;
134
+ }
135
+ };
136
+ async function joinTerminalVoice(o) {
137
+ const platform = o.platform ?? process.platform;
138
+ const spawn = o.spawn ?? spawnHelper;
139
+ if (!o.tools.ffmpegPath) throw new Error("in-terminal voice needs ffmpeg");
140
+ const sinkCmd = voiceSinkCommand(o.tools, TERMINAL_RATE, platform);
141
+ if (!sinkCmd) throw new Error("in-terminal voice needs ffplay (or ffmpeg with CoreAudio) to play the call");
142
+ const rtc = o.rtc ?? await loadRtcNode();
143
+ const store = new VoiceStore(initialVoiceState(o.room, o.identity));
144
+ const room = new rtc.Room();
145
+ const queues = /* @__PURE__ */ new Map();
146
+ const readers = /* @__PURE__ */ new Map();
147
+ const speaking = /* @__PURE__ */ new Set();
148
+ let device = o.device;
149
+ let muted = true;
150
+ let ended = false;
151
+ let everConnected = false;
152
+ let leaving = null;
153
+ let sink = null;
154
+ let sinkDropped = 0;
155
+ let sinkRestarts = [];
156
+ let mic = null;
157
+ let source = null;
158
+ let track = null;
159
+ let trackSid = null;
160
+ let sendQueue = [];
161
+ let sendDropped = 0;
162
+ let pumping = false;
163
+ let sent = 0;
164
+ let chain = Promise.resolve();
165
+ let resolveEnded;
166
+ const endedP = new Promise((r) => {
167
+ resolveEnded = r;
168
+ });
169
+ const clock = new FrameClock(() => tickPlayback());
170
+ const serial = (work) => {
171
+ const next = chain.then(() => ended ? void 0 : work());
172
+ chain = next.catch(() => void 0);
173
+ return next;
174
+ };
175
+ const micPub = (p) => {
176
+ let audio;
177
+ for (const pub of p.trackPublications.values()) {
178
+ if (pub.source === rtc.TrackSource.SOURCE_MICROPHONE) return pub;
179
+ if (!audio && pub.kind === rtc.TrackKind.KIND_AUDIO) audio = pub;
180
+ }
181
+ return audio;
182
+ };
183
+ const person = (p, isLocal) => {
184
+ const m = decodeVoiceMetadata(p.metadata);
185
+ const isMuted = isLocal ? muted : (() => {
186
+ const pub = micPub(p);
187
+ return !pub || pub.muted === true;
188
+ })();
189
+ return {
190
+ identity: p.identity,
191
+ sid: m?.sid || parseVoiceIdentity(p.identity)?.sid || null,
192
+ userId: m?.userId || null,
193
+ name: sanitizeVoiceName(m?.name || p.name || ""),
194
+ color: m ? m.color : null,
195
+ client: m ? m.client : null,
196
+ isLocal,
197
+ muted: isMuted,
198
+ speaking: !isMuted && speaking.has(p.identity)
199
+ };
200
+ };
201
+ const refresh = () => {
202
+ if (ended) return;
203
+ const list = [];
204
+ if (room.localParticipant) list.push(person(room.localParticipant, true));
205
+ for (const p of room.remoteParticipants.values()) list.push(person(p, false));
206
+ const connection = room.connectionState === rtc.ConnectionState.CONN_CONNECTED ? "connected" : room.connectionState === rtc.ConnectionState.CONN_RECONNECTING ? "reconnecting" : everConnected ? "reconnecting" : "connecting";
207
+ store.update({ participants: list, connection, muted, inputDeviceId: device ?? null });
208
+ };
209
+ function startSink() {
210
+ if (ended || sink) return;
211
+ const child = spawn(sinkCmd.bin, sinkCmd.args, ["pipe", "ignore", "pipe"], { group: true });
212
+ sink = child;
213
+ child.stdin?.on("error", () => {
214
+ });
215
+ child.on("error", () => {
216
+ if (sink === child) sink = null;
217
+ });
218
+ child.on("close", (code) => {
219
+ if (sink !== child) return;
220
+ sink = null;
221
+ vlog(Date.now(), "playback", "terminal sink exited", { code });
222
+ if (ended) return;
223
+ const now = Date.now();
224
+ sinkRestarts = sinkRestarts.filter((t) => now - t < 6e4);
225
+ if (sinkRestarts.length >= 5) {
226
+ store.update({ error: "the speaker player keeps stopping \xB7 /voice debug" });
227
+ return;
228
+ }
229
+ sinkRestarts.push(now);
230
+ setTimeout(startSink, 250).unref?.();
231
+ });
232
+ vlog(Date.now(), "playback", "terminal sink started", { kind: sinkCmd.kind });
233
+ }
234
+ function tickPlayback() {
235
+ if (ended) return;
236
+ const frames = [];
237
+ for (const q of queues.values()) {
238
+ const f = q.pull();
239
+ if (f) frames.push(f);
240
+ }
241
+ const sin = sink?.stdin;
242
+ if (!sin || sin.destroyed || !sin.writable) return;
243
+ if (sin.writableLength > SINK_MAX_BYTES) {
244
+ sinkDropped++;
245
+ return;
246
+ }
247
+ const mixed = mixFrames(frames);
248
+ try {
249
+ sin.write(Buffer.from(mixed.buffer, mixed.byteOffset, mixed.byteLength));
250
+ } catch {
251
+ }
252
+ }
253
+ function listen(trackObj, sid, identity) {
254
+ if (readers.has(sid) || ended) return;
255
+ const stream = new rtc.AudioStream(trackObj, { sampleRate: TERMINAL_RATE, numChannels: 1, frameSizeMs: 10 });
256
+ const reader = stream.getReader();
257
+ readers.set(sid, reader);
258
+ let q = queues.get(identity);
259
+ if (!q) {
260
+ q = new JitterQueue();
261
+ queues.set(identity, q);
262
+ }
263
+ const queue = q;
264
+ void (async () => {
265
+ try {
266
+ for (; ; ) {
267
+ const { value, done } = await reader.read();
268
+ if (done || !value || ended) break;
269
+ let data = value.data;
270
+ if (value.channels > 1) {
271
+ const mono = new Int16Array(value.samplesPerChannel);
272
+ for (let i = 0; i < mono.length; i++) {
273
+ let s = 0;
274
+ for (let c = 0; c < value.channels; c++) s += data[i * value.channels + c];
275
+ mono[i] = s / value.channels;
276
+ }
277
+ data = mono;
278
+ }
279
+ queue.push(data);
280
+ }
281
+ } catch {
282
+ }
283
+ })();
284
+ }
285
+ function unlisten(sid, identity) {
286
+ const r = readers.get(sid);
287
+ readers.delete(sid);
288
+ void r?.cancel?.().catch(() => void 0);
289
+ if (identity) queues.delete(identity);
290
+ }
291
+ function pump() {
292
+ if (pumping) return;
293
+ pumping = true;
294
+ void (async () => {
295
+ try {
296
+ while (sendQueue.length && source && !ended && !muted) {
297
+ const f = sendQueue.shift();
298
+ await source.captureFrame(new rtc.AudioFrame(f, TERMINAL_RATE, 1, FRAME_SAMPLES));
299
+ sent++;
300
+ }
301
+ } catch (e) {
302
+ vlog(Date.now(), "transmit", "captureFrame failed", { err: e instanceof Error ? e.message : String(e) });
303
+ } finally {
304
+ pumping = false;
305
+ }
306
+ })();
307
+ }
308
+ function startMic() {
309
+ if (mic || ended) return;
310
+ const framer = new PcmFramer();
311
+ const child = spawn(o.tools.ffmpegPath, captureArgs(platform, device, TERMINAL_RATE), ["ignore", "pipe", "pipe"]);
312
+ mic = child;
313
+ let tail = "";
314
+ child.stderr?.setEncoding("utf8");
315
+ child.stderr?.on("data", (c) => {
316
+ tail = (tail + c).slice(-300);
317
+ });
318
+ child.stdout?.on("data", (chunk) => {
319
+ if (mic !== child || muted) return;
320
+ framer.push(chunk, (f) => {
321
+ sendQueue.push(f);
322
+ if (sendQueue.length > SEND_QUEUE_FRAMES) {
323
+ sendDropped += sendQueue.length - SEND_QUEUE_FRAMES;
324
+ sendQueue = sendQueue.slice(-SEND_QUEUE_FRAMES);
325
+ }
326
+ });
327
+ pump();
328
+ });
329
+ child.on("error", () => {
330
+ if (mic === child) mic = null;
331
+ });
332
+ child.on("close", (code) => {
333
+ if (mic !== child) return;
334
+ mic = null;
335
+ if (!ended && !muted) {
336
+ store.update({ error: "the mic stopped: " + (tail.trim().split("\n").pop() || `ffmpeg exited ${code}`) });
337
+ void serial(() => setMutedNow(true));
338
+ }
339
+ });
340
+ }
341
+ function stopMic() {
342
+ const m = mic;
343
+ mic = null;
344
+ sendQueue = [];
345
+ if (!m) return;
346
+ try {
347
+ m.stdout?.destroy();
348
+ } catch {
349
+ }
350
+ killHelper(m, "SIGINT");
351
+ setTimeout(() => killHelper(m, "SIGKILL"), 300).unref?.();
352
+ }
353
+ async function setMutedNow(m) {
354
+ if (m === muted) {
355
+ refresh();
356
+ return;
357
+ }
358
+ if (m) {
359
+ muted = true;
360
+ stopMic();
361
+ const sid = trackSid;
362
+ const t = track;
363
+ trackSid = null;
364
+ track = null;
365
+ source = null;
366
+ if (sid) await room.localParticipant?.unpublishTrack(sid, true).catch(() => void 0);
367
+ await t?.close?.(true).catch(() => void 0);
368
+ } else {
369
+ if (!room.localParticipant) throw new Error("not connected");
370
+ const s = new rtc.AudioSource(TERMINAL_RATE, 1, 200);
371
+ const t = rtc.LocalAudioTrack.createAudioTrack("microphone", s);
372
+ const pub = await room.localParticipant.publishTrack(t, new rtc.TrackPublishOptions({ source: rtc.TrackSource.SOURCE_MICROPHONE, dtx: true, red: true }));
373
+ if (ended) {
374
+ await t.close?.(true).catch(() => void 0);
375
+ return;
376
+ }
377
+ source = s;
378
+ track = t;
379
+ trackSid = pub?.sid ?? t.sid ?? null;
380
+ muted = false;
381
+ startMic();
382
+ }
383
+ refresh();
384
+ }
385
+ function teardown(reason) {
386
+ if (ended) return;
387
+ ended = true;
388
+ clock.stop();
389
+ stopMic();
390
+ const s = sink;
391
+ sink = null;
392
+ if (s) {
393
+ try {
394
+ s.stdin?.end();
395
+ } catch {
396
+ }
397
+ killHelper(s, "SIGKILL");
398
+ }
399
+ for (const sid of [...readers.keys()]) unlisten(sid);
400
+ queues.clear();
401
+ store.update({ connection: "disconnected", endReason: reason });
402
+ resolveEnded(reason);
403
+ }
404
+ const E = rtc.RoomEvent;
405
+ room.on(E.ParticipantConnected, refresh);
406
+ room.on(E.ParticipantDisconnected, (p) => {
407
+ queues.delete(p.identity);
408
+ speaking.delete(p.identity);
409
+ refresh();
410
+ });
411
+ room.on(E.TrackSubscribed, (t, pub, p) => {
412
+ if (t.kind === rtc.TrackKind.KIND_AUDIO && pub.sid) listen(t, pub.sid, p.identity);
413
+ refresh();
414
+ });
415
+ room.on(E.TrackUnsubscribed, (_t, pub, p) => {
416
+ if (pub.sid) unlisten(pub.sid, p.identity);
417
+ refresh();
418
+ });
419
+ for (const ev of [
420
+ "TrackPublished",
421
+ "TrackUnpublished",
422
+ "TrackMuted",
423
+ "TrackUnmuted",
424
+ "ParticipantMetadataChanged",
425
+ "ParticipantNameChanged",
426
+ "ConnectionStateChanged",
427
+ "Reconnecting",
428
+ "Reconnected"
429
+ ]) {
430
+ if (E[ev]) room.on(E[ev], refresh);
431
+ }
432
+ room.on(E.ActiveSpeakersChanged, (list) => {
433
+ speaking.clear();
434
+ for (const p of list ?? []) speaking.add(p.identity);
435
+ refresh();
436
+ });
437
+ room.on(E.Disconnected, (reason) => {
438
+ if (!leaving) teardown(mapLiveKitDisconnect(reason, everConnected));
439
+ });
440
+ const timeoutMs = o.connectTimeoutMs ?? 15e3;
441
+ const connecting = room.connect(toWsUrl(o.url), o.token, { autoSubscribe: true, dynacast: false });
442
+ let timer = null;
443
+ try {
444
+ await Promise.race([connecting, new Promise((_, rej) => {
445
+ timer = setTimeout(() => rej(new Error("could not reach the voice server")), timeoutMs);
446
+ })]);
447
+ } catch (e) {
448
+ teardown("error");
449
+ void connecting.then(() => room.disconnect().catch(() => void 0), () => void 0);
450
+ throw e;
451
+ } finally {
452
+ if (timer) clearTimeout(timer);
453
+ }
454
+ everConnected = true;
455
+ startSink();
456
+ if (o.timers !== false) clock.run();
457
+ if (o.startMuted === false) await serial(() => setMutedNow(false));
458
+ refresh();
459
+ const controller = {
460
+ kind: "terminal",
461
+ ended: endedP,
462
+ snapshot: () => store.snapshot(),
463
+ subscribe: (fn) => store.subscribe(fn),
464
+ async setMuted(m) {
465
+ await serial(() => setMutedNow(m)).catch((e) => {
466
+ store.update({ error: "could not change the mic: " + (e instanceof Error ? e.message : String(e)) });
467
+ });
468
+ },
469
+ async listDevices(kind = "audioinput") {
470
+ if (kind !== "audioinput") return [];
471
+ return listMicDevices().map((d) => ({ id: d.index, label: d.name, kind: "audioinput", isDefault: false }));
472
+ },
473
+ async setInputDevice(id) {
474
+ await serial(async () => {
475
+ device = id || void 0;
476
+ if (mic) {
477
+ stopMic();
478
+ startMic();
479
+ }
480
+ refresh();
481
+ });
482
+ },
483
+ leave() {
484
+ if (!leaving) {
485
+ leaving = (async () => {
486
+ await chain;
487
+ const wasEnded = ended;
488
+ teardown("left");
489
+ if (!wasEnded) await room.disconnect().catch(() => void 0);
490
+ })();
491
+ }
492
+ return leaving;
493
+ },
494
+ quit() {
495
+ if (!leaving) leaving = (async () => {
496
+ teardown("left");
497
+ await room.disconnect().catch(() => void 0);
498
+ })();
499
+ },
500
+ tickPlayback,
501
+ helperPids: () => [mic, sink].map((c) => c?.pid).filter((p) => typeof p === "number"),
502
+ debug() {
503
+ const s = store.snapshot();
504
+ let shed = 0;
505
+ for (const q of queues.values()) shed += q.shed;
506
+ return [
507
+ `transport: livekit \xB7 in this terminal (${HEADPHONES_ONLY})`,
508
+ `call: ${s.connection}${muted ? " \xB7 muted (mic off)" : " \xB7 mic on"} \xB7 people: ${s.participants.length}`,
509
+ `mic: ${device ?? "default"} \xB7 sent ${sent} frames \xB7 dropped ${sendDropped}`,
510
+ `sink: ${sinkCmd.kind}${sink ? "" : " (down)"} \xB7 speakers ${queues.size} \xB7 shed ${shed} \xB7 sink drops ${sinkDropped}`,
511
+ `helpers: ${controller.helperPids().join(" ") || "none"}`
512
+ ];
513
+ }
514
+ };
515
+ return controller;
516
+ }
517
+ export {
518
+ FRAME_BYTES,
519
+ FRAME_SAMPLES,
520
+ FrameClock,
521
+ HEADPHONES_ONLY,
522
+ JITTER_MAX,
523
+ JITTER_TARGET,
524
+ JitterQueue,
525
+ PcmFramer,
526
+ RTC_NODE,
527
+ RTC_NODE_VERSION,
528
+ SEND_QUEUE_FRAMES,
529
+ SINK_MAX_BYTES,
530
+ TERMINAL_RATE,
531
+ joinTerminalVoice,
532
+ loadRtcNode,
533
+ mixFrames,
534
+ rtcNodeMissingHint
535
+ };