accelr8 3.0.0 → 3.0.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.
@@ -36,7 +36,11 @@ var LIMITS = {
36
36
  agentQueueCap: 5,
37
37
  agentMaxDepth: 3,
38
38
  agentBudgetPerTrigger: 5,
39
- agentRepeatPerChain: 2
39
+ agentRepeatPerChain: 2,
40
+ voiceFramesPerSec: 50,
41
+ // ~25/s real, 2x headroom; exempt from framesPerWindow
42
+ voiceFrameMaxB64: 4096
43
+ // a 40ms s16le mono chunk is ~1.7KB base64; cap slack
40
44
  };
41
45
  var DEFAULT_ACCESS = { mode: "owner", users: [] };
42
46
  function encode(msg) {
@@ -7,7 +7,7 @@ import {
7
7
  VERSION,
8
8
  decode,
9
9
  encode
10
- } from "./chunk-SF6NNSYZ.js";
10
+ } from "./chunk-GXDRLI6N.js";
11
11
  import {
12
12
  DEFAULT_MODEL,
13
13
  addAccount,
@@ -25,7 +25,7 @@ import {
25
25
  // src/client.ts
26
26
  import tk from "terminal-kit";
27
27
  import { WebSocket } from "ws";
28
- import { spawn } from "node:child_process";
28
+ import { spawn as spawn2 } from "node:child_process";
29
29
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import { join } from "node:path";
@@ -1173,6 +1173,291 @@ function microLine(w, engine, frame2, info) {
1173
1173
  return clampTo(tiny, Math.max(0, w));
1174
1174
  }
1175
1175
 
1176
+ // src/voice.ts
1177
+ import { spawn, spawnSync } from "node:child_process";
1178
+ import fs from "node:fs";
1179
+ import path from "node:path";
1180
+ var SAMPLE_RATE = 16e3;
1181
+ var CHUNK_BYTES = 1280;
1182
+ var KILL_GRACE_MS = 300;
1183
+ var SPEAKER_IDLE_MS = 2e3;
1184
+ var DRAIN_KILL_MS = 2e3;
1185
+ var MAX_PLAYERS = 8;
1186
+ var STDERR_TAIL = 400;
1187
+ var VERIFY_MIN_BYTES = SAMPLE_RATE * 2 / 10;
1188
+ var PLAYBACK_ARGS = [
1189
+ "-hide_banner",
1190
+ "-loglevel",
1191
+ "error",
1192
+ "-nodisp",
1193
+ "-autoexit",
1194
+ "-f",
1195
+ "s16le",
1196
+ "-ar",
1197
+ String(SAMPLE_RATE),
1198
+ "-ch_layout",
1199
+ "mono",
1200
+ "-i",
1201
+ "-"
1202
+ ];
1203
+ function detectVoiceTools() {
1204
+ const ffmpegPath = which("ffmpeg");
1205
+ const ffplayPath = which("ffplay");
1206
+ const out = { capture: !!ffmpegPath, playback: !!ffplayPath };
1207
+ if (ffmpegPath) out.ffmpegPath = ffmpegPath;
1208
+ if (ffplayPath) out.ffplayPath = ffplayPath;
1209
+ if (!ffmpegPath || !ffplayPath) {
1210
+ const missing = [ffmpegPath ? "" : "ffmpeg", ffplayPath ? "" : "ffplay"].filter(Boolean).join(" + ");
1211
+ out.reason = `${missing} not found on PATH (voice needs ffmpeg; brew/apt install ffmpeg)`;
1212
+ }
1213
+ return out;
1214
+ }
1215
+ function which(bin) {
1216
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
1217
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
1218
+ for (const ext of exts) {
1219
+ const candidate = path.join(dir, bin + ext);
1220
+ try {
1221
+ if (!fs.existsSync(candidate)) continue;
1222
+ fs.accessSync(candidate, fs.constants.X_OK);
1223
+ if (fs.statSync(candidate).isFile()) return candidate;
1224
+ } catch {
1225
+ }
1226
+ }
1227
+ }
1228
+ return void 0;
1229
+ }
1230
+ function captureArgs(platform, device) {
1231
+ const base = ["-hide_banner", "-loglevel", "error"];
1232
+ const output = ["-ac", "1", "-ar", String(SAMPLE_RATE), "-f", "s16le", "-"];
1233
+ if (platform === "darwin") return [...base, "-f", "avfoundation", "-i", ":" + (device ?? "default"), ...output];
1234
+ if (platform === "win32") return [...base, "-f", "dshow", "-i", "audio=" + (device ?? "default"), ...output];
1235
+ return [...base, "-f", "alsa", "-i", device ?? "default", ...output];
1236
+ }
1237
+ function chunkPcm(buf, chunkBytes) {
1238
+ if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) throw new RangeError("chunkBytes must be a positive integer");
1239
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
1240
+ const out = [];
1241
+ for (let i = 0; i < b.length; i += chunkBytes) out.push(b.subarray(i, i + chunkBytes).toString("base64"));
1242
+ return out;
1243
+ }
1244
+ var VoiceCapture = class {
1245
+ constructor(tools) {
1246
+ this.tools = tools;
1247
+ }
1248
+ tools;
1249
+ session = null;
1250
+ get active() {
1251
+ return this.session !== null;
1252
+ }
1253
+ /** Spawn ffmpeg mic->PCM and stream ~40ms base64 chunks to onChunk.
1254
+ * No-op while a capture is already active. */
1255
+ start(opts) {
1256
+ if (this.session) return;
1257
+ if (!this.tools.capture || !this.tools.ffmpegPath) {
1258
+ const why = this.tools.reason ?? "ffmpeg not found on PATH";
1259
+ queueMicrotask(() => opts.onError(why));
1260
+ return;
1261
+ }
1262
+ this.session = this.spawnSession(opts, captureArgs(process.platform, opts.device), "primary");
1263
+ }
1264
+ /** Flush the sub-chunk remainder, then SIGINT -> SIGKILL after 300ms. */
1265
+ stop() {
1266
+ const s = this.session;
1267
+ if (!s) return;
1268
+ this.session = null;
1269
+ s.stopped = true;
1270
+ if (s.buf.length) {
1271
+ s.onChunk(s.buf.toString("base64"));
1272
+ s.buf = Buffer.alloc(0);
1273
+ }
1274
+ try {
1275
+ s.child.kill("SIGINT");
1276
+ } catch {
1277
+ }
1278
+ const t = setTimeout(() => {
1279
+ try {
1280
+ s.child.kill("SIGKILL");
1281
+ } catch {
1282
+ }
1283
+ }, KILL_GRACE_MS);
1284
+ t.unref();
1285
+ }
1286
+ spawnSession(opts, args, backend) {
1287
+ let child;
1288
+ try {
1289
+ child = spawn(this.tools.ffmpegPath, args, { stdio: ["ignore", "pipe", "pipe"] });
1290
+ } catch (e) {
1291
+ queueMicrotask(() => opts.onError("mic capture failed to start: " + errMsg(e)));
1292
+ return null;
1293
+ }
1294
+ const s = { child, onChunk: opts.onChunk, buf: Buffer.alloc(0), stderrTail: "", produced: false, stopped: false };
1295
+ child.stdout?.on("data", (chunk) => {
1296
+ if (s.stopped) return;
1297
+ s.produced = true;
1298
+ s.buf = s.buf.length ? Buffer.concat([s.buf, chunk]) : chunk;
1299
+ const whole = Math.floor(s.buf.length / CHUNK_BYTES) * CHUNK_BYTES;
1300
+ if (whole === 0) return;
1301
+ for (const b64 of chunkPcm(s.buf.subarray(0, whole), CHUNK_BYTES)) opts.onChunk(b64);
1302
+ s.buf = s.buf.subarray(whole);
1303
+ });
1304
+ child.stderr?.setEncoding("utf8");
1305
+ child.stderr?.on("data", (c) => {
1306
+ s.stderrTail = (s.stderrTail + c).slice(-STDERR_TAIL);
1307
+ });
1308
+ child.on("error", (e) => {
1309
+ if (s.stopped || this.session !== s) return;
1310
+ this.session = null;
1311
+ opts.onError(e.code === "ENOENT" ? "ffmpeg not found" : "mic capture error: " + errMsg(e));
1312
+ });
1313
+ child.on("close", (code) => {
1314
+ if (s.stopped || this.session !== s) return;
1315
+ if (backend === "primary" && process.platform === "linux" && !s.produced && code !== 0) {
1316
+ const pulse = captureArgs("linux", opts.device).map((a) => a === "alsa" ? "pulse" : a);
1317
+ this.session = this.spawnSession(opts, pulse, "pulse");
1318
+ return;
1319
+ }
1320
+ this.session = null;
1321
+ const tail = s.stderrTail.trim();
1322
+ opts.onError(code === 0 ? "mic capture ended unexpectedly" : "mic capture failed: " + (tail || `ffmpeg exited with code ${code}`));
1323
+ });
1324
+ return s;
1325
+ }
1326
+ };
1327
+ var VoicePlayback = class {
1328
+ constructor(tools) {
1329
+ this.tools = tools;
1330
+ }
1331
+ tools;
1332
+ players = /* @__PURE__ */ new Map();
1333
+ /** ids with an active player */
1334
+ get speakers() {
1335
+ return [...this.players.keys()];
1336
+ }
1337
+ /** Feed a received chunk; lazily spawns one ffplay per speaker. Chunks
1338
+ * are dropped silently when playback is unavailable, the speaker cap
1339
+ * is hit, or a player cannot be (re)spawned. */
1340
+ feed(speakerId, b64) {
1341
+ if (!this.tools.playback || !this.tools.ffplayPath) return;
1342
+ const bytes = Buffer.from(b64, "base64");
1343
+ if (bytes.length === 0) return;
1344
+ let p = this.players.get(speakerId);
1345
+ if (p && !p.alive) {
1346
+ this.reap(speakerId, p);
1347
+ p = void 0;
1348
+ }
1349
+ if (!p) {
1350
+ if (this.players.size >= MAX_PLAYERS) return;
1351
+ const spawned = this.spawnPlayer(speakerId);
1352
+ if (!spawned) return;
1353
+ p = spawned;
1354
+ }
1355
+ if (!this.write(p, bytes)) {
1356
+ this.reap(speakerId, p);
1357
+ const fresh = this.players.size < MAX_PLAYERS ? this.spawnPlayer(speakerId) : null;
1358
+ if (!fresh || !this.write(fresh, bytes)) return;
1359
+ p = fresh;
1360
+ }
1361
+ this.touch(speakerId, p);
1362
+ }
1363
+ /** Graceful close for one speaker (voiceStop): end stdin so ffplay
1364
+ * drains buffered audio and -autoexit's; SIGKILL backstop after 2s. */
1365
+ end(speakerId) {
1366
+ const p = this.players.get(speakerId);
1367
+ if (!p) return;
1368
+ this.players.delete(speakerId);
1369
+ if (p.idleTimer) {
1370
+ clearTimeout(p.idleTimer);
1371
+ p.idleTimer = null;
1372
+ }
1373
+ try {
1374
+ p.child.stdin?.end();
1375
+ } catch {
1376
+ }
1377
+ const t = setTimeout(() => {
1378
+ try {
1379
+ p.child.kill("SIGKILL");
1380
+ } catch {
1381
+ }
1382
+ }, DRAIN_KILL_MS);
1383
+ t.unref();
1384
+ }
1385
+ /** Hard cleanup on quit: kill every player immediately. */
1386
+ stopAll() {
1387
+ for (const [, p] of this.players) {
1388
+ if (p.idleTimer) {
1389
+ clearTimeout(p.idleTimer);
1390
+ p.idleTimer = null;
1391
+ }
1392
+ p.alive = false;
1393
+ try {
1394
+ p.child.kill("SIGKILL");
1395
+ } catch {
1396
+ }
1397
+ }
1398
+ this.players.clear();
1399
+ }
1400
+ spawnPlayer(speakerId) {
1401
+ let child;
1402
+ try {
1403
+ child = spawn(this.tools.ffplayPath, PLAYBACK_ARGS, { stdio: ["pipe", "ignore", "ignore"] });
1404
+ } catch {
1405
+ return null;
1406
+ }
1407
+ const p = { child, alive: true, idleTimer: null };
1408
+ child.stdin?.on("error", () => {
1409
+ p.alive = false;
1410
+ });
1411
+ child.on("error", () => {
1412
+ p.alive = false;
1413
+ if (this.players.get(speakerId) === p) this.reap(speakerId, p);
1414
+ });
1415
+ child.on("close", () => {
1416
+ p.alive = false;
1417
+ if (this.players.get(speakerId) === p) {
1418
+ if (p.idleTimer) {
1419
+ clearTimeout(p.idleTimer);
1420
+ p.idleTimer = null;
1421
+ }
1422
+ this.players.delete(speakerId);
1423
+ }
1424
+ });
1425
+ this.players.set(speakerId, p);
1426
+ return p;
1427
+ }
1428
+ write(p, bytes) {
1429
+ const sin = p.child.stdin;
1430
+ if (!p.alive || !sin || sin.destroyed || !sin.writable) return false;
1431
+ try {
1432
+ sin.write(bytes);
1433
+ return true;
1434
+ } catch {
1435
+ p.alive = false;
1436
+ return false;
1437
+ }
1438
+ }
1439
+ touch(speakerId, p) {
1440
+ if (p.idleTimer) clearTimeout(p.idleTimer);
1441
+ p.idleTimer = setTimeout(() => this.end(speakerId), SPEAKER_IDLE_MS);
1442
+ p.idleTimer.unref();
1443
+ }
1444
+ reap(speakerId, p) {
1445
+ if (p.idleTimer) {
1446
+ clearTimeout(p.idleTimer);
1447
+ p.idleTimer = null;
1448
+ }
1449
+ p.alive = false;
1450
+ try {
1451
+ p.child.kill("SIGKILL");
1452
+ } catch {
1453
+ }
1454
+ if (this.players.get(speakerId) === p) this.players.delete(speakerId);
1455
+ }
1456
+ };
1457
+ function errMsg(e) {
1458
+ return e instanceof Error ? e.message : String(e);
1459
+ }
1460
+
1176
1461
  // src/client.ts
1177
1462
  var term = tk.terminal;
1178
1463
  var ScreenBuffer = tk.ScreenBuffer;
@@ -1243,6 +1528,65 @@ function startUpdate() {
1243
1528
  updating = { line: "", done: "fail", message: "could not load the updater \xB7 npm i -g accelr8@latest" };
1244
1529
  });
1245
1530
  }
1531
+ var voiceTools = null;
1532
+ var voiceOn = false;
1533
+ var voiceTransmit = false;
1534
+ var voiceCapture = null;
1535
+ var voicePlayback = null;
1536
+ function toggleVoice() {
1537
+ if (voiceOn) return leaveVoice();
1538
+ if (!state.project) {
1539
+ state.status = "join a project first";
1540
+ return;
1541
+ }
1542
+ voiceTools = voiceTools ?? detectVoiceTools();
1543
+ if (!voiceTools.capture && !voiceTools.playback) {
1544
+ state.status = "voice needs ffmpeg \xB7 install it, then /voice";
1545
+ return;
1546
+ }
1547
+ voicePlayback = new VoicePlayback(voiceTools);
1548
+ voiceCapture = new VoiceCapture(voiceTools);
1549
+ voiceOn = true;
1550
+ voiceTransmit = false;
1551
+ sendMsg({ t: "voiceJoin" });
1552
+ state.status = voiceTools.capture ? "voice on \xB7 ctrl-g to talk" : "voice on \xB7 listen only (no mic found)";
1553
+ }
1554
+ function leaveVoice() {
1555
+ if (!voiceOn) return;
1556
+ voiceCapture?.stop();
1557
+ voicePlayback?.stopAll();
1558
+ voiceCapture = null;
1559
+ voicePlayback = null;
1560
+ voiceOn = false;
1561
+ voiceTransmit = false;
1562
+ state.voicePeers = [];
1563
+ sendMsg({ t: "voiceLeave" });
1564
+ state.status = "voice off";
1565
+ }
1566
+ function toggleTalk() {
1567
+ if (!voiceOn || !voiceCapture) return;
1568
+ if (!voiceTools?.capture) {
1569
+ state.status = "no mic \xB7 install ffmpeg to talk";
1570
+ return;
1571
+ }
1572
+ if (voiceTransmit) {
1573
+ voiceCapture.stop();
1574
+ voiceTransmit = false;
1575
+ state.status = "muted \xB7 ctrl-g to talk";
1576
+ } else {
1577
+ voiceTransmit = true;
1578
+ state.status = "\u25CF live \xB7 ctrl-g to mute";
1579
+ voiceCapture.start({
1580
+ onChunk: (b64) => {
1581
+ if (voiceTransmit) sendMsg({ t: "voiceFrame", data: b64 });
1582
+ },
1583
+ onError: (m) => {
1584
+ voiceTransmit = false;
1585
+ state.status = "mic error \xB7 " + m.slice(0, 44);
1586
+ }
1587
+ });
1588
+ }
1589
+ }
1246
1590
  function openHelp() {
1247
1591
  overlay = "help";
1248
1592
  overlayFocus = 0;
@@ -1253,6 +1597,7 @@ function openSettings() {
1253
1597
  }
1254
1598
  function goHome() {
1255
1599
  if (state.project) {
1600
+ if (voiceOn) leaveVoice();
1256
1601
  overlay = null;
1257
1602
  state.screen = "home";
1258
1603
  sendMsg({ t: "tab", tab: "home" });
@@ -1270,6 +1615,7 @@ var state = {
1270
1615
  sessions: [],
1271
1616
  agents: [],
1272
1617
  secrets: [],
1618
+ voicePeers: [],
1273
1619
  status: "",
1274
1620
  modal: null
1275
1621
  };
@@ -1664,6 +2010,12 @@ function handleServer(msg) {
1664
2010
  case "secrets":
1665
2011
  state.secrets = Array.isArray(msg.list) ? msg.list : [];
1666
2012
  break;
2013
+ case "voice":
2014
+ state.voicePeers = Array.isArray(msg.peers) ? msg.peers : [];
2015
+ break;
2016
+ case "voiceFrame":
2017
+ if (voiceOn) voicePlayback?.feed(msg.sid, msg.data);
2018
+ break;
1667
2019
  case "error": {
1668
2020
  if (msg.code === "chatRate" || msg.code === "msgTooLarge") {
1669
2021
  const failed = msg.ref ? chatLog.failOptimistic(msg.ref) : null;
@@ -2297,7 +2649,12 @@ function renderStatusBar(rung) {
2297
2649
  puts(0, sy, " ".repeat(w), { bgColor: C.panel });
2298
2650
  const agentBit = state.agents.length ? ` \xB7 \u2726${state.agents.length} agents` : "";
2299
2651
  const left = state.project ? ` ${state.project.name} \xB7 ${state.project.code} \xB7 ${onlineUserIds().size} online${agentBit} ` : " no project open ";
2300
- puts(0, sy, left.slice(0, w - 24), { bgColor: C.panel, color: C.dim });
2652
+ const drawn = left.slice(0, w - 24);
2653
+ puts(0, sy, drawn, { bgColor: C.panel, color: C.dim });
2654
+ if (voiceOn) {
2655
+ const label = voiceTransmit ? " \u25CF live \xB7 ctrl-g mute" : ` \u25CB voice ${state.voicePeers.length} \xB7 ctrl-g talk`;
2656
+ if (drawn.length + label.length < w - 24) puts(drawn.length, sy, label, { bgColor: C.panel, color: voiceTransmit ? C.good : C.accent, bold: voiceTransmit });
2657
+ }
2301
2658
  const hint = state.modal ? "[enter] ok \xB7 [esc] cancel " : "[tab] tabs \xB7 [^C] quit ";
2302
2659
  puts(w - hint.length, sy, hint, { bgColor: C.panel, color: C.dim });
2303
2660
  if (state.status) {
@@ -2954,7 +3311,7 @@ function copyToClipboard(text, tools) {
2954
3311
  return;
2955
3312
  }
2956
3313
  try {
2957
- const p = spawn(tool, args, { stdio: ["pipe", "ignore", "ignore"] });
3314
+ const p = spawn2(tool, args, { stdio: ["pipe", "ignore", "ignore"] });
2958
3315
  p.on("error", () => copyToClipboard(text, candidatesList.slice(1)));
2959
3316
  p.on("close", (code) => {
2960
3317
  state.status = code === 0 ? "copied join command" : "copy failed";
@@ -3092,6 +3449,10 @@ function slashCommand(text) {
3092
3449
  ghostsOff = cmd.endsWith("off");
3093
3450
  state.status = ghostsOff ? "draft broadcasting off" : "draft broadcasting on";
3094
3451
  draftDirty = true;
3452
+ } else if (cmd === "/voice") {
3453
+ toggleVoice();
3454
+ } else if (cmd === "/talk" || cmd === "/mute") {
3455
+ toggleTalk();
3095
3456
  } else {
3096
3457
  state.status = `unknown command: ${cmd.split(" ")[0]}`;
3097
3458
  }
@@ -3118,6 +3479,7 @@ function sendChat() {
3118
3479
  }
3119
3480
  function onKey(name) {
3120
3481
  if (name === "CTRL_C") return shutdown();
3482
+ if (name === "CTRL_G" && voiceOn) return toggleTalk();
3121
3483
  if (boot) return skipBoot();
3122
3484
  const rung = rungFor(W(), H());
3123
3485
  if (rung === "micro" || rung === "orb") return;
@@ -3358,6 +3720,11 @@ function restoreTerminal() {
3358
3720
  }
3359
3721
  function shutdown() {
3360
3722
  restoreTerminal();
3723
+ try {
3724
+ voiceCapture?.stop();
3725
+ voicePlayback?.stopAll();
3726
+ } catch {
3727
+ }
3361
3728
  for (const h of runningExec.values()) {
3362
3729
  try {
3363
3730
  h.abort();
@@ -6,7 +6,7 @@ import {
6
6
  LIMITS,
7
7
  PORT,
8
8
  PROTO
9
- } from "./chunk-SF6NNSYZ.js";
9
+ } from "./chunk-GXDRLI6N.js";
10
10
 
11
11
  // src/server.ts
12
12
  import { WebSocketServer, WebSocket } from "ws";
@@ -109,7 +109,6 @@ function sanitizeText(v, maxCp) {
109
109
  return out;
110
110
  }
111
111
  var RoomCore = class {
112
- // userId -> trigger times
113
112
  constructor(host) {
114
113
  this.host = host;
115
114
  }
@@ -135,6 +134,11 @@ var RoomCore = class {
135
134
  invokeWindow = /* @__PURE__ */ new Map();
136
135
  // projectId -> invocation times
137
136
  triggerWindow = /* @__PURE__ */ new Map();
137
+ // userId -> trigger times
138
+ // voice channel (realtime, loss-tolerant): sids currently in voice, and a
139
+ // per-sid rate window (voiceFrame is exempt from the general frame flood cap)
140
+ voiceSids = /* @__PURE__ */ new Set();
141
+ voiceWindow = /* @__PURE__ */ new Map();
138
142
  // ---------- lifecycle ----------
139
143
  async onMessage(conn, raw) {
140
144
  let msg;
@@ -145,14 +149,16 @@ var RoomCore = class {
145
149
  }
146
150
  if (!msg || typeof msg !== "object" || typeof msg.t !== "string") return;
147
151
  const now = this.host.now();
148
- const win = (this.frameWindow.get(conn) ?? []).filter((t) => now - t < 1e4);
149
- if (win.length >= LIMITS.framesPerWindow) {
152
+ if (msg.t !== "voiceFrame") {
153
+ const win = (this.frameWindow.get(conn) ?? []).filter((t) => now - t < 1e4);
154
+ if (win.length >= LIMITS.framesPerWindow) {
155
+ this.frameWindow.set(conn, win);
156
+ this.host.close(conn, 1008, "frame flood");
157
+ return;
158
+ }
159
+ win.push(now);
150
160
  this.frameWindow.set(conn, win);
151
- this.host.close(conn, 1008, "frame flood");
152
- return;
153
161
  }
154
- win.push(now);
155
- this.frameWindow.set(conn, win);
156
162
  try {
157
163
  await this.dispatch(conn, msg);
158
164
  } catch (e) {
@@ -322,6 +328,34 @@ var RoomCore = class {
322
328
  for (const c of this.roomConns(s.projectId)) this.host.send(c, { t: "cleared" });
323
329
  break;
324
330
  }
331
+ case "voiceJoin": {
332
+ if (!s.projectId || this.voiceSids.has(s.sid)) break;
333
+ this.voiceSids.add(s.sid);
334
+ this.broadcastVoice(s.projectId);
335
+ await this.systemLine(s.projectId, `${s.name} joined voice`);
336
+ break;
337
+ }
338
+ case "voiceLeave": {
339
+ if (!s.projectId || !this.voiceSids.has(s.sid)) break;
340
+ this.voiceSids.delete(s.sid);
341
+ this.voiceWindow.delete(s.sid);
342
+ this.broadcastVoice(s.projectId);
343
+ await this.systemLine(s.projectId, `${s.name} left voice`);
344
+ break;
345
+ }
346
+ case "voiceFrame": {
347
+ if (!s.projectId || !this.voiceSids.has(s.sid)) break;
348
+ const data = typeof msg.data === "string" ? msg.data : "";
349
+ if (!data || data.length > LIMITS.voiceFrameMaxB64) break;
350
+ if (!this.rateOk(this.voiceWindow, s.sid, LIMITS.voiceFramesPerSec, 1e3)) break;
351
+ for (const c of this.roomConns(s.projectId)) {
352
+ const peer = this.host.getSession(c);
353
+ if (peer && peer.sid !== s.sid && this.voiceSids.has(peer.sid)) {
354
+ this.host.send(c, { t: "voiceFrame", sid: s.sid, data });
355
+ }
356
+ }
357
+ break;
358
+ }
325
359
  case "chatSend": {
326
360
  if (!s.projectId) break;
327
361
  const cmid = String(msg.cmid || "").slice(0, 40);
@@ -519,12 +553,18 @@ var RoomCore = class {
519
553
  if (inv && inv.projectId === pid) void this.finalizeInvocation(inv, "partial");
520
554
  this.queues.delete(agentId);
521
555
  }
556
+ const wasInVoice = this.voiceSids.delete(s.sid);
557
+ this.voiceWindow.delete(s.sid);
522
558
  s.projectId = null;
523
559
  this.host.setSession(conn, s);
524
560
  for (const c of this.roomPeers(pid, conn)) {
525
561
  this.host.send(c, { t: "cursorGone", sid: s.sid });
526
562
  }
527
563
  this.broadcastSessions(pid);
564
+ if (wasInVoice) {
565
+ this.broadcastVoice(pid);
566
+ void this.systemLine(pid, `${s.name} left voice`);
567
+ }
528
568
  if (agentsChanged) {
529
569
  const p = this.projectsSync?.get(pid);
530
570
  if (p) this.broadcastAgents(pid);
@@ -757,6 +797,11 @@ var RoomCore = class {
757
797
  const sessions = this.sessionsFor(projectId);
758
798
  for (const c of this.roomConns(projectId)) this.host.send(c, { t: "sessions", sessions });
759
799
  }
800
+ /** who is in voice for this project (join/leave presence, not per-frame). */
801
+ broadcastVoice(projectId) {
802
+ const peers = this.roomConns(projectId).map((c) => this.host.getSession(c)).filter((s) => !!s && this.voiceSids.has(s.sid)).map((s) => ({ sid: s.sid, userId: s.userId, name: s.name, color: s.color }));
803
+ for (const c of this.roomConns(projectId)) this.host.send(c, { t: "voice", peers });
804
+ }
760
805
  onlineIds(projectId) {
761
806
  return [...new Set(this.sessionsFor(projectId).map((s) => s.userId))];
762
807
  }
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  DEFAULT_SERVER,
4
4
  PORT,
5
5
  VERSION
6
- } from "./chunks/chunk-SF6NNSYZ.js";
6
+ } from "./chunks/chunk-GXDRLI6N.js";
7
7
 
8
8
  // src/cli.ts
9
9
  var HELP = `
@@ -52,7 +52,7 @@ async function main() {
52
52
  console.error(`invalid port: ${argVal(args, "--port")}`);
53
53
  process.exit(1);
54
54
  }
55
- const { runServer } = await import("./chunks/server-ZP2DV4IO.js");
55
+ const { runServer } = await import("./chunks/server-NWTBQYCW.js");
56
56
  runServer(port);
57
57
  return;
58
58
  }
@@ -76,7 +76,7 @@ async function main() {
76
76
  const { cachedUpdate, refreshUpdateCache } = await import("./chunks/update-UEPNGB6Y.js");
77
77
  refreshUpdateCache();
78
78
  const update = cachedUpdate(VERSION);
79
- const { runClient } = await import("./chunks/client-7BVK2DEW.js");
79
+ const { runClient } = await import("./chunks/client-36OXOMLV.js");
80
80
  runClient({
81
81
  name: argVal(args, "--name"),
82
82
  server: argVal(args, "--server"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "accelr8",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Collaborative ascii canvas in your terminal. Real multiplayer, live cursors, mouse-driven TUI.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -53,7 +53,8 @@
53
53
  "accountstest": "tsx src/accountstest.ts",
54
54
  "executortest": "tsx src/executortest.ts",
55
55
  "agentcontexttest": "tsx src/agentcontexttest.ts",
56
- "chaintest": "tsx src/chaintest.ts"
56
+ "chaintest": "tsx src/chaintest.ts",
57
+ "voicetransporttest": "tsx src/voicetransporttest.ts"
57
58
  },
58
59
  "dependencies": {
59
60
  "terminal-kit": "3.1.2",