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-
|
|
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
|
-
|
|
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 =
|
|
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-
|
|
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
|
-
|
|
149
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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",
|