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,860 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ accelr8Dir
4
+ } from "./chunk-62O2F73J.js";
5
+
6
+ // src/voicelog.ts
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ var logPath = null;
10
+ var enabled = true;
11
+ function file() {
12
+ if (!logPath) {
13
+ const dir = accelr8Dir();
14
+ try {
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ } catch {
17
+ }
18
+ logPath = path.join(dir, "voice.log");
19
+ }
20
+ return logPath;
21
+ }
22
+ function stamp(nowMs) {
23
+ const d = new Date(nowMs);
24
+ const p = (n, w = 2) => String(n).padStart(w, "0");
25
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
26
+ }
27
+ function vlog(nowMs, stage, msg, data) {
28
+ if (!enabled) return;
29
+ try {
30
+ const extra = data ? " " + Object.entries(data).map(([k, v]) => `${k}=${String(v).slice(0, 80)}`).join(" ") : "";
31
+ fs.appendFileSync(file(), `${stamp(nowMs)} [${stage}] ${msg}${extra}
32
+ `);
33
+ } catch {
34
+ }
35
+ }
36
+ function vlogSession(nowMs, header) {
37
+ vlog(nowMs, "session", "\u2500\u2500\u2500\u2500 voice session \u2500\u2500\u2500\u2500", header);
38
+ }
39
+ function voiceLogPath() {
40
+ return file();
41
+ }
42
+
43
+ // src/voice.ts
44
+ import { spawn, spawnSync } from "node:child_process";
45
+ import fs2 from "node:fs";
46
+ import path2 from "node:path";
47
+ var SAMPLE_RATE = 16e3;
48
+ var CHUNK_BYTES = 1280;
49
+ var KILL_GRACE_MS = 300;
50
+ var SPEAKER_IDLE_MS = 2e3;
51
+ var PREBUFFER_BYTES = 4800;
52
+ var DRAIN_KILL_MS = 2e3;
53
+ var MAX_PLAYERS = 8;
54
+ var STDERR_TAIL = 400;
55
+ var VERIFY_MIN_BYTES = SAMPLE_RATE * 2 / 10;
56
+ var liveHelpers = /* @__PURE__ */ new Set();
57
+ var groupLeaders = /* @__PURE__ */ new WeakSet();
58
+ var exitHookInstalled = false;
59
+ function installExitHook() {
60
+ if (exitHookInstalled) return;
61
+ exitHookInstalled = true;
62
+ process.once("exit", () => killAllHelpers());
63
+ }
64
+ function spawnHelper(command, args, stdio, opts = {}) {
65
+ const detached = !!opts.group && process.platform !== "win32";
66
+ const child = spawn(command, args, { stdio, detached, windowsHide: true });
67
+ if (detached) groupLeaders.add(child);
68
+ liveHelpers.add(child);
69
+ const forget = () => {
70
+ liveHelpers.delete(child);
71
+ };
72
+ child.once("exit", forget);
73
+ child.once("error", forget);
74
+ installExitHook();
75
+ return child;
76
+ }
77
+ function killHelper(child, signal = "SIGKILL") {
78
+ if (child.exitCode !== null || child.signalCode !== null) return;
79
+ if (groupLeaders.has(child) && typeof child.pid === "number") {
80
+ try {
81
+ process.kill(-child.pid, signal);
82
+ return;
83
+ } catch {
84
+ }
85
+ }
86
+ try {
87
+ child.kill(signal);
88
+ } catch {
89
+ }
90
+ }
91
+ function killAllHelpers() {
92
+ for (const child of [...liveHelpers]) killHelper(child, "SIGKILL");
93
+ }
94
+ function liveHelperPids() {
95
+ return [...liveHelpers].map((c) => c.pid).filter((p) => typeof p === "number");
96
+ }
97
+ function playbackArgs(ffplayPath) {
98
+ const ch = ffplayChannelFlag(ffplayPath);
99
+ return [
100
+ "-hide_banner",
101
+ "-loglevel",
102
+ "error",
103
+ "-nodisp",
104
+ "-autoexit",
105
+ "-sync",
106
+ "audio",
107
+ "-infbuf",
108
+ "-f",
109
+ "s16le",
110
+ "-ar",
111
+ String(SAMPLE_RATE),
112
+ ...ch,
113
+ "-i",
114
+ "-"
115
+ ];
116
+ }
117
+ function voiceSinkCommand(tools, rate, platform = process.platform, hasCoreAudio = ffmpegHasAudioToolbox) {
118
+ if (platform === "darwin" && tools.ffmpegPath && hasCoreAudio(tools.ffmpegPath)) {
119
+ return { kind: "coreaudio", bin: tools.ffmpegPath, args: [
120
+ "-hide_banner",
121
+ "-loglevel",
122
+ "error",
123
+ "-f",
124
+ "s16le",
125
+ "-ar",
126
+ String(rate),
127
+ ...ffplayChannelFlag(tools.ffmpegPath),
128
+ "-i",
129
+ "-",
130
+ "-f",
131
+ "audiotoolbox",
132
+ "-"
133
+ ] };
134
+ }
135
+ if (!tools.ffplayPath) return null;
136
+ return { kind: "ffplay", bin: tools.ffplayPath, args: [
137
+ "-hide_banner",
138
+ "-loglevel",
139
+ "error",
140
+ "-nodisp",
141
+ "-autoexit",
142
+ "-f",
143
+ "s16le",
144
+ "-ar",
145
+ String(rate),
146
+ ...ffplayChannelFlag(tools.ffplayPath),
147
+ "-i",
148
+ "-"
149
+ ] };
150
+ }
151
+ var audioToolboxCache = null;
152
+ function ffmpegHasAudioToolbox(ffmpegPath) {
153
+ if (audioToolboxCache !== null) return audioToolboxCache;
154
+ audioToolboxCache = false;
155
+ try {
156
+ const out = spawnSync(ffmpegPath, ["-hide_banner", "-devices"], { encoding: "utf8", timeout: 3e3 });
157
+ audioToolboxCache = /\baudiotoolbox\b/.test((out.stdout || "") + (out.stderr || ""));
158
+ } catch {
159
+ }
160
+ return audioToolboxCache;
161
+ }
162
+ var chFlagCache = null;
163
+ function ffplayChannelFlag(ffplayPath) {
164
+ if (chFlagCache) return chFlagCache;
165
+ let modern = true;
166
+ try {
167
+ const out = spawnSync(ffplayPath, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 3e3 });
168
+ const m = ((out.stdout || "") + (out.stderr || "")).match(/version\s+n?(\d+)\.(\d+)/i);
169
+ if (m) {
170
+ const major = +m[1], minor = +m[2];
171
+ modern = major > 5 || major === 5 && minor >= 1;
172
+ }
173
+ } catch {
174
+ }
175
+ chFlagCache = modern ? ["-ch_layout", "mono"] : ["-ac", "1"];
176
+ return chFlagCache;
177
+ }
178
+ function detectVoiceTools() {
179
+ const ffmpegPath = which("ffmpeg");
180
+ const ffplayPath = which("ffplay");
181
+ const out = { capture: !!ffmpegPath, playback: !!ffplayPath };
182
+ if (ffmpegPath) out.ffmpegPath = ffmpegPath;
183
+ if (ffplayPath) out.ffplayPath = ffplayPath;
184
+ if (!ffmpegPath || !ffplayPath) {
185
+ const missing = [ffmpegPath ? "" : "ffmpeg", ffplayPath ? "" : "ffplay"].filter(Boolean).join(" + ");
186
+ out.reason = `${missing} not found on PATH (voice needs ffmpeg; brew/apt install ffmpeg)`;
187
+ }
188
+ return out;
189
+ }
190
+ function which(bin) {
191
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
192
+ for (const dir of (process.env.PATH ?? "").split(path2.delimiter).filter(Boolean)) {
193
+ for (const ext of exts) {
194
+ const candidate = path2.join(dir, bin + ext);
195
+ try {
196
+ if (!fs2.existsSync(candidate)) continue;
197
+ fs2.accessSync(candidate, fs2.constants.X_OK);
198
+ if (fs2.statSync(candidate).isFile()) return candidate;
199
+ } catch {
200
+ }
201
+ }
202
+ }
203
+ return void 0;
204
+ }
205
+ function captureArgs(platform, device, sampleRate = SAMPLE_RATE) {
206
+ const base = ["-hide_banner", "-loglevel", "error"];
207
+ const output = ["-ac", "1", "-ar", String(sampleRate), "-f", "s16le", "-"];
208
+ if (platform === "darwin") return [...base, "-f", "avfoundation", "-i", ":" + (device ?? "default"), ...output];
209
+ if (platform === "win32") return [...base, "-f", "dshow", "-i", "audio=" + (device ?? "default"), ...output];
210
+ return [...base, "-f", "alsa", "-i", device ?? "default", ...output];
211
+ }
212
+ function chunkPcm(buf, chunkBytes) {
213
+ if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) throw new RangeError("chunkBytes must be a positive integer");
214
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
215
+ const out = [];
216
+ for (let i = 0; i < b.length; i += chunkBytes) out.push(b.subarray(i, i + chunkBytes).toString("base64"));
217
+ return out;
218
+ }
219
+ var VoiceCapture = class {
220
+ constructor(tools) {
221
+ this.tools = tools;
222
+ }
223
+ tools;
224
+ session = null;
225
+ get active() {
226
+ return this.session !== null;
227
+ }
228
+ /** pid of the running ffmpeg (diagnostics/tests) */
229
+ get pid() {
230
+ return this.session?.child.pid;
231
+ }
232
+ /** Spawn ffmpeg mic->PCM and stream ~40ms base64 chunks to onChunk.
233
+ * No-op while a capture is already active. */
234
+ start(opts) {
235
+ if (this.session) return;
236
+ if (!this.tools.capture || !this.tools.ffmpegPath) {
237
+ const why = this.tools.reason ?? "ffmpeg not found on PATH";
238
+ queueMicrotask(() => opts.onError(why));
239
+ return;
240
+ }
241
+ this.session = this.spawnSession(opts, captureArgs(process.platform, opts.device), "primary");
242
+ }
243
+ /** Flush the sub-chunk remainder, then SIGINT -> SIGKILL after 300ms. */
244
+ stop() {
245
+ const s = this.session;
246
+ if (!s) return;
247
+ this.session = null;
248
+ s.stopped = true;
249
+ if (s.buf.length) {
250
+ s.onChunk(s.buf.toString("base64"));
251
+ s.buf = Buffer.alloc(0);
252
+ }
253
+ try {
254
+ s.child.stdout?.destroy();
255
+ } catch {
256
+ }
257
+ killHelper(s.child, "SIGINT");
258
+ const t = setTimeout(() => killHelper(s.child, "SIGKILL"), KILL_GRACE_MS);
259
+ t.unref();
260
+ }
261
+ spawnSession(opts, args, backend) {
262
+ let child;
263
+ try {
264
+ child = spawnHelper(this.tools.ffmpegPath, args, ["ignore", "pipe", "pipe"]);
265
+ } catch (e) {
266
+ vlog(Date.now(), "capture", "ffmpeg spawn threw", { backend, err: errMsg(e) });
267
+ queueMicrotask(() => opts.onError("mic capture failed to start: " + errMsg(e)));
268
+ return null;
269
+ }
270
+ vlog(Date.now(), "capture", "ffmpeg spawned", { backend, device: opts.device ?? "default", args: args.join(" ") });
271
+ const s = { child, onChunk: opts.onChunk, buf: Buffer.alloc(0), stderrTail: "", produced: false, stopped: false };
272
+ child.stdout?.on("data", (chunk) => {
273
+ if (s.stopped) return;
274
+ if (!s.produced) vlog(Date.now(), "capture", "first audio bytes from mic", { backend, bytes: chunk.length });
275
+ s.produced = true;
276
+ s.buf = s.buf.length ? Buffer.concat([s.buf, chunk]) : chunk;
277
+ const whole = Math.floor(s.buf.length / CHUNK_BYTES) * CHUNK_BYTES;
278
+ if (whole === 0) return;
279
+ for (const b64 of chunkPcm(s.buf.subarray(0, whole), CHUNK_BYTES)) opts.onChunk(b64);
280
+ s.buf = s.buf.subarray(whole);
281
+ });
282
+ child.stderr?.setEncoding("utf8");
283
+ child.stderr?.on("data", (c) => {
284
+ s.stderrTail = (s.stderrTail + c).slice(-STDERR_TAIL);
285
+ });
286
+ child.on("error", (e) => {
287
+ if (s.stopped || this.session !== s) return;
288
+ this.session = null;
289
+ vlog(Date.now(), "capture", "ffmpeg error", { backend, code: e.code, produced: s.produced, stderr: s.stderrTail.trim() });
290
+ opts.onError(e.code === "ENOENT" ? "ffmpeg not found" : "mic capture error: " + errMsg(e));
291
+ });
292
+ child.on("close", (code) => {
293
+ if (s.stopped || this.session !== s) return;
294
+ vlog(Date.now(), "capture", "ffmpeg exited", { backend, code, producedAudio: s.produced, stderr: s.stderrTail.trim() });
295
+ if (backend === "primary" && process.platform === "linux" && !s.produced && code !== 0) {
296
+ vlog(Date.now(), "capture", "alsa failed, retrying on pulse");
297
+ const pulse = captureArgs("linux", opts.device).map((a) => a === "alsa" ? "pulse" : a);
298
+ this.session = this.spawnSession(opts, pulse, "pulse");
299
+ return;
300
+ }
301
+ this.session = null;
302
+ const tail = s.stderrTail.trim();
303
+ opts.onError(code === 0 ? "mic capture ended unexpectedly" : "mic capture failed: " + (tail || `ffmpeg exited with code ${code}`));
304
+ });
305
+ return s;
306
+ }
307
+ };
308
+ var VoicePlayback = class {
309
+ constructor(tools) {
310
+ this.tools = tools;
311
+ }
312
+ tools;
313
+ players = /* @__PURE__ */ new Map();
314
+ // players end() let go of: still draining their last audio, still ours to kill
315
+ draining = /* @__PURE__ */ new Set();
316
+ /** ids with an active player */
317
+ get speakers() {
318
+ return [...this.players.keys()];
319
+ }
320
+ /** pids of every player this instance still owns, draining ones included */
321
+ get pids() {
322
+ return [...this.players.values(), ...this.draining].map((p) => p.child.pid).filter((n) => typeof n === "number");
323
+ }
324
+ /** Feed a received chunk; lazily spawns one ffplay per speaker. Chunks
325
+ * are dropped silently when playback is unavailable, the speaker cap
326
+ * is hit, or a player cannot be (re)spawned. */
327
+ feed(speakerId, b64) {
328
+ if (!this.tools.playback || !this.tools.ffplayPath) return;
329
+ const bytes = Buffer.from(b64, "base64");
330
+ if (bytes.length === 0) return;
331
+ let p = this.players.get(speakerId);
332
+ if (p && !p.alive) {
333
+ this.reap(speakerId, p);
334
+ p = void 0;
335
+ }
336
+ if (!p) {
337
+ if (this.players.size >= MAX_PLAYERS) return;
338
+ const spawned = this.spawnPlayer(speakerId);
339
+ if (!spawned) return;
340
+ p = spawned;
341
+ }
342
+ if (!p.flowing) {
343
+ p.pre.push(bytes);
344
+ if (p.pre.reduce((n, b) => n + b.length, 0) >= PREBUFFER_BYTES) {
345
+ const cushion = Buffer.concat(p.pre);
346
+ p.pre = [];
347
+ p.flowing = true;
348
+ const np2 = this.writeOrRespawn(speakerId, p, cushion);
349
+ if (np2) this.touch(speakerId, np2);
350
+ } else {
351
+ this.touch(speakerId, p);
352
+ }
353
+ return;
354
+ }
355
+ const np = this.writeOrRespawn(speakerId, p, bytes);
356
+ if (np) this.touch(speakerId, np);
357
+ }
358
+ /** Write to a player; on EPIPE (it died) respawn once and write there. Returns
359
+ * the live player, or null if it couldn't be (re)spawned. */
360
+ writeOrRespawn(speakerId, p, buf) {
361
+ if (this.write(p, buf)) return p;
362
+ this.reap(speakerId, p);
363
+ const fresh = this.players.size < MAX_PLAYERS ? this.spawnPlayer(speakerId) : null;
364
+ if (!fresh || !this.write(fresh, buf)) return null;
365
+ fresh.flowing = true;
366
+ return fresh;
367
+ }
368
+ /** Graceful close for one speaker: end stdin so ffplay drains its buffered
369
+ * audio and -autoexit's; a group-kill backstop fires after 2s. The player
370
+ * stays owned (stopAll() and the exit hook still reach it) until it exits. */
371
+ end(speakerId) {
372
+ const p = this.players.get(speakerId);
373
+ if (!p) return;
374
+ this.players.delete(speakerId);
375
+ if (p.idleTimer) {
376
+ clearTimeout(p.idleTimer);
377
+ p.idleTimer = null;
378
+ }
379
+ if (p.alive) this.draining.add(p);
380
+ try {
381
+ p.child.stdin?.end();
382
+ } catch {
383
+ }
384
+ const t = setTimeout(() => {
385
+ this.draining.delete(p);
386
+ killHelper(p.child, "SIGKILL");
387
+ }, DRAIN_KILL_MS);
388
+ t.unref();
389
+ }
390
+ /** Hard cleanup on quit: kill every player immediately, draining ones too. */
391
+ stopAll() {
392
+ for (const [, p] of this.players) {
393
+ if (p.idleTimer) {
394
+ clearTimeout(p.idleTimer);
395
+ p.idleTimer = null;
396
+ }
397
+ p.alive = false;
398
+ killHelper(p.child, "SIGKILL");
399
+ }
400
+ this.players.clear();
401
+ for (const p of this.draining) killHelper(p.child, "SIGKILL");
402
+ this.draining.clear();
403
+ }
404
+ spawnPlayer(speakerId) {
405
+ const args = playbackArgs(this.tools.ffplayPath);
406
+ let child;
407
+ try {
408
+ child = spawnHelper(this.tools.ffplayPath, args, ["pipe", "ignore", "pipe"], { group: true });
409
+ } catch (e) {
410
+ vlog(Date.now(), "playback", "ffplay spawn threw", { speaker: speakerId, err: errMsg(e) });
411
+ return null;
412
+ }
413
+ vlog(Date.now(), "playback", "ffplay spawned", { speaker: speakerId, args: args.join(" ") });
414
+ const p = { child, alive: true, idleTimer: null, pre: [], flowing: false };
415
+ let stderrTail = "";
416
+ child.stderr?.setEncoding("utf8");
417
+ child.stderr?.on("data", (c) => {
418
+ stderrTail = (stderrTail + c).slice(-STDERR_TAIL);
419
+ });
420
+ child.stdin?.on("error", () => {
421
+ p.alive = false;
422
+ });
423
+ child.on("error", (e) => {
424
+ p.alive = false;
425
+ this.draining.delete(p);
426
+ vlog(Date.now(), "playback", "ffplay error", { speaker: speakerId, code: e.code, msg: errMsg(e) });
427
+ if (this.players.get(speakerId) === p) this.reap(speakerId, p);
428
+ });
429
+ child.on("close", (code) => {
430
+ p.alive = false;
431
+ this.draining.delete(p);
432
+ vlog(Date.now(), "playback", "ffplay exited", { speaker: speakerId, code, stderr: stderrTail.trim() });
433
+ if (this.players.get(speakerId) === p) {
434
+ if (p.idleTimer) {
435
+ clearTimeout(p.idleTimer);
436
+ p.idleTimer = null;
437
+ }
438
+ this.players.delete(speakerId);
439
+ }
440
+ });
441
+ this.players.set(speakerId, p);
442
+ return p;
443
+ }
444
+ write(p, bytes) {
445
+ const sin = p.child.stdin;
446
+ if (!p.alive || !sin || sin.destroyed || !sin.writable) return false;
447
+ try {
448
+ sin.write(bytes);
449
+ return true;
450
+ } catch {
451
+ p.alive = false;
452
+ return false;
453
+ }
454
+ }
455
+ touch(speakerId, p) {
456
+ if (p.idleTimer) clearTimeout(p.idleTimer);
457
+ p.idleTimer = setTimeout(() => this.end(speakerId), SPEAKER_IDLE_MS);
458
+ p.idleTimer.unref();
459
+ }
460
+ reap(speakerId, p) {
461
+ if (p.idleTimer) {
462
+ clearTimeout(p.idleTimer);
463
+ p.idleTimer = null;
464
+ }
465
+ p.alive = false;
466
+ killHelper(p.child, "SIGKILL");
467
+ if (this.players.get(speakerId) === p) this.players.delete(speakerId);
468
+ }
469
+ };
470
+ function listMicDevices() {
471
+ try {
472
+ if (process.platform === "darwin") {
473
+ const text = runForOutput(["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""]);
474
+ const out = [];
475
+ let inAudio = false;
476
+ for (const line of text.split("\n")) {
477
+ if (/audio devices/i.test(line)) {
478
+ inAudio = true;
479
+ continue;
480
+ }
481
+ if (/video devices/i.test(line)) {
482
+ inAudio = false;
483
+ continue;
484
+ }
485
+ if (!inAudio) continue;
486
+ const m = line.match(/\[(\d+)\]\s+(.+?)\s*$/);
487
+ if (m) out.push({ index: m[1], name: m[2] });
488
+ }
489
+ return out;
490
+ }
491
+ if (process.platform === "linux") {
492
+ const text = runForOutput(["-hide_banner", "-sources", "pulse"]);
493
+ const out = [];
494
+ for (const line of text.split("\n")) {
495
+ if (/auto-detected/i.test(line)) continue;
496
+ const m = line.match(/^\s*\*?\s*(\S+)\s+\[(.+)\]\s*$/);
497
+ if (m) out.push({ index: m[1], name: m[2] });
498
+ }
499
+ return out;
500
+ }
501
+ if (process.platform === "win32") {
502
+ const text = runForOutput(["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"]);
503
+ const out = [];
504
+ for (const line of text.split("\n")) {
505
+ const m = line.match(/"(.+)"\s+\(audio\)/);
506
+ if (m) out.push({ index: m[1], name: m[1] });
507
+ }
508
+ return out;
509
+ }
510
+ return [];
511
+ } catch {
512
+ return [];
513
+ }
514
+ }
515
+ function autoPickMic(list = listMicDevices) {
516
+ if (process.platform === "darwin") {
517
+ const mics = list();
518
+ if (!mics.length) return void 0;
519
+ const builtin = mics.find((m) => /macbook|built.?in|internal/i.test(m.name));
520
+ if (builtin) return builtin.index;
521
+ const notPhone = mics.find((m) => !/iphone|ipad|continuity|desk view/i.test(m.name));
522
+ return notPhone?.index;
523
+ }
524
+ if (process.platform === "win32") {
525
+ const mics = list();
526
+ return mics[0]?.index;
527
+ }
528
+ return void 0;
529
+ }
530
+ function runForOutput(args) {
531
+ const res = spawnSync("ffmpeg", args, { encoding: "utf8", timeout: 5e3 });
532
+ return (res.stderr ?? "") + (res.stdout ?? "");
533
+ }
534
+ function errMsg(e) {
535
+ return e instanceof Error ? e.message : String(e);
536
+ }
537
+ var RELAY_STATS_SILENCE_MS = 4e3;
538
+ var RELAY_JOIN = { t: "voiceJoin", via: "relay" };
539
+ var RelayVoice = class {
540
+ constructor(host, parts = {}, device) {
541
+ this.host = host;
542
+ this.parts = parts;
543
+ this.device = device;
544
+ }
545
+ host;
546
+ parts;
547
+ kind = "relay";
548
+ on = false;
549
+ // local INTENT to be in voice (we've sent voiceJoin)
550
+ ready = false;
551
+ // server ACKed our membership (voiceJoined): the ONLY truth
552
+ // that our frames will fan out. Survives a reconnect only
553
+ // after we re-send voiceJoin and get re-ACKed.
554
+ transmit = false;
555
+ // mic hot (push-to-talk latch)
556
+ projectId = null;
557
+ device;
558
+ tools = null;
559
+ retries = 0;
560
+ // consecutive capture restarts (flaky-mic self-heal)
561
+ sent = 0;
562
+ // frames transmitted this session (diagnostics)
563
+ peers = 0;
564
+ recv = /* @__PURE__ */ new Map();
565
+ // frames received per speaker sid
566
+ capture = null;
567
+ playback = null;
568
+ rejoinAt = 0;
569
+ // last self-heal re-join (throttle notInVoice storms)
570
+ liveAt = 0;
571
+ // when the mic last went hot
572
+ lastSentAt = 0;
573
+ // last frame handed to the socket
574
+ lastStatsAt = 0;
575
+ // last voiceStats from the server
576
+ warned = "";
577
+ timer = null;
578
+ now() {
579
+ return this.host.now ? this.host.now() : Date.now();
580
+ }
581
+ /** Join voice for `projectId`. 'install' = no ffmpeg at all (caller offers the installer). */
582
+ start(projectId, meta = {}) {
583
+ if (this.on) return "ok";
584
+ this.tools = this.parts.tools ?? this.tools ?? detectVoiceTools();
585
+ if (!this.tools.capture && !this.tools.playback) return "install";
586
+ this.playback = this.parts.playback ?? new VoicePlayback(this.tools);
587
+ this.capture = this.parts.capture ?? new VoiceCapture(this.tools);
588
+ this.on = true;
589
+ this.ready = false;
590
+ this.transmit = false;
591
+ this.sent = 0;
592
+ this.warned = "";
593
+ this.recv.clear();
594
+ this.projectId = projectId;
595
+ if (this.device === void 0 && this.tools.capture) this.device = (this.parts.pickMic ?? autoPickMic)();
596
+ vlogSession(this.now(), {
597
+ platform: process.platform,
598
+ version: meta.version ?? "",
599
+ name: meta.name ?? "",
600
+ ffmpeg: this.tools.ffmpegPath ?? "MISSING",
601
+ ffplay: this.tools.ffplayPath ?? "MISSING",
602
+ capture: this.tools.capture,
603
+ playback: this.tools.playback,
604
+ mic: this.device ?? "default"
605
+ });
606
+ this.host.send({ ...RELAY_JOIN });
607
+ this.host.status("voice \xB7 connecting\u2026");
608
+ if (this.parts.timers !== false && !this.timer) {
609
+ this.timer = setInterval(() => this.tick(), 1e3);
610
+ this.timer.unref?.();
611
+ }
612
+ return "ok";
613
+ }
614
+ /** Leave voice. `quiet` skips the status line (the caller shows its own). */
615
+ leave(quiet = false) {
616
+ if (!this.on) return;
617
+ vlog(this.now(), "session", "leaving voice", { sent: this.sent, recvSpeakers: this.recv.size, recvTotal: this.recvTotal() });
618
+ this.capture?.stop();
619
+ this.playback?.stopAll();
620
+ this.capture = null;
621
+ this.playback = null;
622
+ if (this.timer) {
623
+ clearInterval(this.timer);
624
+ this.timer = null;
625
+ }
626
+ this.on = false;
627
+ this.ready = false;
628
+ this.transmit = false;
629
+ this.projectId = null;
630
+ this.peers = 0;
631
+ this.host.setPeers([]);
632
+ this.host.send({ t: "voiceLeave" });
633
+ if (!quiet) this.host.status("voice off");
634
+ }
635
+ /** ctrl-g / talk / mute: latch the mic on or off. */
636
+ toggleTalk() {
637
+ if (!this.on || !this.capture) return;
638
+ if (!this.tools?.capture) {
639
+ this.host.status("no mic \xB7 install ffmpeg to talk");
640
+ return;
641
+ }
642
+ if (this.transmit) {
643
+ this.transmit = false;
644
+ this.retries = 0;
645
+ this.warned = "";
646
+ this.capture.stop();
647
+ this.host.status("muted \xB7 /talk (or ctrl-g) to speak");
648
+ return;
649
+ }
650
+ if (!this.ready) {
651
+ const now = this.now();
652
+ if (now - this.rejoinAt > 1e3) {
653
+ this.rejoinAt = now;
654
+ this.host.send({ ...RELAY_JOIN });
655
+ }
656
+ }
657
+ this.transmit = true;
658
+ this.retries = 0;
659
+ this.warned = "";
660
+ this.liveAt = this.now();
661
+ this.lastStatsAt = 0;
662
+ this.lastSentAt = 0;
663
+ this.host.status("\u25CF live \xB7 /mute (or ctrl-g) to stop");
664
+ this.startCapture();
665
+ }
666
+ /** /talk and /mute: set the mic to a state instead of flipping it, so a
667
+ * typed /mute can never open the mic. */
668
+ setTalk(on) {
669
+ if (!this.on) return;
670
+ if (on === this.transmit) {
671
+ this.host.status(on ? "\u25CF live \xB7 /mute (or ctrl-g) to stop" : "muted \xB7 /talk (or ctrl-g) to speak");
672
+ return;
673
+ }
674
+ this.toggleTalk();
675
+ }
676
+ /** Forget detected tools (after the ffmpeg installer ran). */
677
+ resetTools() {
678
+ if (!this.on) this.tools = null;
679
+ }
680
+ /** Switch mic; applied live if the mic is hot. */
681
+ setDevice(device) {
682
+ this.device = device;
683
+ if (this.transmit && this.capture) {
684
+ this.capture.stop();
685
+ this.retries = 0;
686
+ this.startCapture();
687
+ }
688
+ }
689
+ /** The room socket (re)joined a project. Same project: re-assert membership
690
+ * (a fresh socket is NOT in voice until we say so). Another project: voice
691
+ * never follows you there, so the session ends. */
692
+ onJoined(projectId) {
693
+ if (!this.on) return;
694
+ if (projectId !== this.projectId) {
695
+ this.leave(true);
696
+ this.host.status("voice off \xB7 you switched projects");
697
+ return;
698
+ }
699
+ this.ready = false;
700
+ vlog(this.now(), "transport", "re-sending voiceJoin after (re)connect");
701
+ this.host.send({ ...RELAY_JOIN });
702
+ }
703
+ /** Voice frames and acks from the server. Returns true when handled. */
704
+ handle(msg) {
705
+ switch (msg.t) {
706
+ case "voiceJoined": {
707
+ if (!this.on) return true;
708
+ const first = !this.ready;
709
+ this.ready = true;
710
+ const peers = Array.isArray(msg.peers) ? msg.peers : [];
711
+ this.peers = peers.length;
712
+ this.host.setPeers(peers);
713
+ vlog(this.now(), "transport", "voiceJoined ACK", { peers: peers.length });
714
+ if (first) this.host.status(this.tools?.capture ? "voice on \xB7 /talk (or ctrl-g) to speak" : "voice on \xB7 listen only (no mic found)");
715
+ return true;
716
+ }
717
+ case "voiceDrop": {
718
+ vlog(this.now(), "transport", "voiceDrop", { reason: msg.reason });
719
+ if (!this.on) return true;
720
+ if (msg.reason === "notInVoice") {
721
+ if (this.host.currentProject() !== this.projectId) {
722
+ this.leave(true);
723
+ this.host.status("voice off \xB7 you left that project");
724
+ return true;
725
+ }
726
+ this.ready = false;
727
+ const now = this.now();
728
+ if (now - this.rejoinAt > 1e3) {
729
+ this.rejoinAt = now;
730
+ vlog(now, "transport", "auto re-join after notInVoice drop");
731
+ this.host.send({ ...RELAY_JOIN });
732
+ }
733
+ } else if (msg.reason === "rateCapped") {
734
+ this.host.status("voice \xB7 sending too fast (throttled)");
735
+ }
736
+ return true;
737
+ }
738
+ case "voiceStats": {
739
+ vlog(this.now(), "transport", "voiceStats", { heardFromYou: msg.heardFromYou });
740
+ this.lastStatsAt = this.now();
741
+ if (this.warned === "deaf") {
742
+ this.warned = "";
743
+ if (this.transmit) this.host.status("\u25CF live \xB7 the server hears you again");
744
+ }
745
+ return true;
746
+ }
747
+ case "voiceFrame": {
748
+ if (!this.on || typeof msg.sid !== "string" || typeof msg.data !== "string") return true;
749
+ const n = (this.recv.get(msg.sid) ?? 0) + 1;
750
+ this.recv.set(msg.sid, n);
751
+ if (n === 1) vlog(this.now(), "receive", "first frame received from a peer", { sid: msg.sid.slice(0, 8) });
752
+ this.playback?.feed(msg.sid, msg.data);
753
+ return true;
754
+ }
755
+ }
756
+ return false;
757
+ }
758
+ /** Watchdog, once a second while in voice. */
759
+ tick(now = this.now()) {
760
+ if (!this.on || !this.transmit) return;
761
+ const since = Math.max(this.liveAt, this.lastStatsAt);
762
+ if (now - since < RELAY_STATS_SILENCE_MS) return;
763
+ if (this.lastSentAt === 0 || now - this.lastSentAt > RELAY_STATS_SILENCE_MS) {
764
+ if (this.warned !== "silent") {
765
+ this.warned = "silent";
766
+ this.host.status("mic is live but sends no audio \xB7 /voice mics to switch");
767
+ }
768
+ return;
769
+ }
770
+ if (this.warned !== "deaf") {
771
+ this.warned = "deaf";
772
+ vlog(now, "transport", "no voiceStats while sending", { sinceMs: now - since, sent: this.sent });
773
+ this.host.status("mic is live but the server hears nothing \xB7 /voice to rejoin");
774
+ }
775
+ }
776
+ /** Status-bar indicator (connecting until the server ACKs, then live or count). */
777
+ indicator(peerCount) {
778
+ if (!this.on) return null;
779
+ const live = this.ready && this.transmit;
780
+ const label = !this.ready ? " \u25CC connecting\u2026" : this.transmit ? " \u25CF live \xB7 /mute" : ` \u25CB voice ${peerCount} \xB7 /talk`;
781
+ return { label, live };
782
+ }
783
+ debugLines(peerCount) {
784
+ return [
785
+ `transport: relay \xB7 on: ${this.on} \xB7 acked: ${this.ready} \xB7 live: ${this.transmit}`,
786
+ `tools: ffmpeg ${this.tools?.ffmpegPath ? "ok" : "MISSING"} \xB7 ffplay ${this.tools?.ffplayPath ? "ok" : "MISSING"}`,
787
+ `mic: ${this.device ?? "default"} \xB7 peers: ${peerCount}`,
788
+ `sent: ${this.sent} \xB7 received: ${this.recvTotal()} from ${this.recv.size}`,
789
+ `helpers: ${liveHelperPids().join(" ") || "none"}`,
790
+ `log: ${voiceLogPath()}`,
791
+ "send me that file if voice fails"
792
+ ];
793
+ }
794
+ /** Quit path: stop every helper now; no messages (the socket is closing). */
795
+ shutdown() {
796
+ try {
797
+ this.capture?.stop();
798
+ this.playback?.stopAll();
799
+ } catch {
800
+ }
801
+ if (this.timer) {
802
+ clearInterval(this.timer);
803
+ this.timer = null;
804
+ }
805
+ this.capture = null;
806
+ this.playback = null;
807
+ this.on = false;
808
+ }
809
+ recvTotal() {
810
+ return [...this.recv.values()].reduce((a, b) => a + b, 0);
811
+ }
812
+ // (Re)start mic capture. On an unexpected ffmpeg death (a device blip: the
813
+ // classic macOS Continuity-mic drop) we self-heal by restarting, instead of
814
+ // going silently dead until the user rejoins. A successful chunk resets the
815
+ // retry budget so occasional blips never exhaust it.
816
+ startCapture() {
817
+ const cap = this.capture;
818
+ if (!cap) return;
819
+ cap.start({
820
+ device: this.device,
821
+ onChunk: (b64) => {
822
+ this.retries = 0;
823
+ if (!this.transmit) return;
824
+ this.sent++;
825
+ this.lastSentAt = this.now();
826
+ if (this.sent === 1) vlog(this.now(), "transmit", "first frame sent to server");
827
+ else if (this.sent % 125 === 0) vlog(this.now(), "transmit", "still sending", { frames: this.sent });
828
+ this.host.send({ t: "voiceFrame", data: b64 });
829
+ },
830
+ onError: (m) => {
831
+ if (!this.transmit) return;
832
+ this.retries++;
833
+ if (this.retries <= 6) {
834
+ this.host.status(`mic dropped \xB7 reconnecting ${this.retries}\u2026`);
835
+ const t = setTimeout(() => {
836
+ if (this.transmit && this.capture === cap && !cap.active) this.startCapture();
837
+ }, 200);
838
+ t.unref?.();
839
+ } else {
840
+ this.transmit = false;
841
+ this.retries = 0;
842
+ this.host.status("mic keeps dropping \xB7 /voice mics to pick another \xB7 " + m.slice(0, 30));
843
+ }
844
+ }
845
+ });
846
+ }
847
+ };
848
+
849
+ export {
850
+ vlog,
851
+ voiceLogPath,
852
+ spawnHelper,
853
+ killHelper,
854
+ killAllHelpers,
855
+ voiceSinkCommand,
856
+ detectVoiceTools,
857
+ captureArgs,
858
+ listMicDevices,
859
+ RelayVoice
860
+ };