accelr8 3.8.0 → 3.8.2

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.
@@ -5,11 +5,15 @@ import {
5
5
  tzToCoords
6
6
  } from "./chunk-HY6ULYGH.js";
7
7
  import {
8
- DEFAULT_SERVER
9
- } from "./chunk-YUZORFN4.js";
8
+ DEFAULT_SERVER,
9
+ VERSION
10
+ } from "./chunk-7JSMON7M.js";
10
11
 
11
12
  // src/radio/app.ts
12
13
  import tk from "terminal-kit";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
13
17
  import { WebSocket } from "ws";
14
18
 
15
19
  // src/radio/asciivideo.ts
@@ -314,7 +318,7 @@ var matrix = {
314
318
  frame(tMs, w, h) {
315
319
  const out = new Array(w * h);
316
320
  const t = tMs / 1e3;
317
- const CH = "01<>+*=~^:;|/\\ACELR8#$%&?!";
321
+ const CH2 = "01<>+*=~^:;|/\\ACELR8#$%&?!";
318
322
  for (let x = 0; x < w; x++) {
319
323
  let h0 = 0, l0 = 1, h1 = 0, l1 = 1;
320
324
  for (let d = 0; d < 2; d++) {
@@ -341,7 +345,7 @@ var matrix = {
341
345
  continue;
342
346
  }
343
347
  const rate = 1.5 + 4 * hash2(x, y);
344
- const ch = CH[Math.floor(hashU(x * 31 + y * 57 + Math.floor(t * rate) * 7919) * CH.length)];
348
+ const ch = CH2[Math.floor(hashU(x * 31 + y * 57 + Math.floor(t * rate) * 7919) * CH2.length)];
345
349
  const isHead = q0 > 0 && d0 < 1 || q1 > 0 && d1 < 1;
346
350
  out[y * w + x] = isHead ? { ch, color: col(200 + 55 * q, 252, 252) } : { ch, color: col(18 + 40 * q, 55 + 155 * q, 60 + 195 * q) };
347
351
  }
@@ -5341,128 +5345,197 @@ function renderOverlay(ctx, opts) {
5341
5345
  }
5342
5346
 
5343
5347
  // src/radio/audio.ts
5344
- import { spawn as spawn2 } from "node:child_process";
5348
+ import { spawn as spawn2, spawnSync } from "node:child_process";
5349
+ var SR = 44100;
5350
+ var CH = 2;
5345
5351
  function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
5346
5352
  const pos = nowPlaying(sched, serverNowMs);
5347
5353
  if (!pos || !pos.item.url) return { kind: "none" };
5348
- const trackChanged = pos.item.id !== mem.playingTrackId;
5349
- if (trackChanged || !mem.procAlive) {
5350
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5354
+ if (pos.item.id !== mem.playingTrackId || !mem.procAlive) return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5355
+ const implied = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
5356
+ if (Math.abs(implied - pos.offsetMs) > driftBudgetMs) return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5357
+ return { kind: "none" };
5358
+ }
5359
+ function applyGain(buf, fromGain, target, rampPerSample = 1 / 1400) {
5360
+ let g = fromGain;
5361
+ for (let i = 0; i + 1 < buf.length; i += 2) {
5362
+ if (g < target) g = g + rampPerSample > target ? target : g + rampPerSample;
5363
+ else if (g > target) g = g - rampPerSample < target ? target : g - rampPerSample;
5364
+ let s = Math.round(buf.readInt16LE(i) * g);
5365
+ if (s > 32767) s = 32767;
5366
+ else if (s < -32768) s = -32768;
5367
+ buf.writeInt16LE(s, i);
5351
5368
  }
5352
- const impliedOffset = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
5353
- if (Math.abs(impliedOffset - pos.offsetMs) > driftBudgetMs) {
5354
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
5369
+ return g;
5370
+ }
5371
+ function decoderArgs(url, offsetMs) {
5372
+ const args = ["-hide_banner", "-loglevel", "error", "-re"];
5373
+ if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
5374
+ args.push("-i", url, "-f", "s16le", "-ar", String(SR), "-ac", String(CH), "-");
5375
+ return args;
5376
+ }
5377
+ var chFlagCache = null;
5378
+ function ffplayStereoFlag(ffplayPath) {
5379
+ if (chFlagCache) return chFlagCache;
5380
+ let modern = true;
5381
+ try {
5382
+ const out = spawnSync(ffplayPath, ["-hide_banner", "-version"], { encoding: "utf8", timeout: 3e3 });
5383
+ const m = ((out.stdout || "") + (out.stderr || "")).match(/version\s+n?(\d+)\.(\d+)/i);
5384
+ if (m) {
5385
+ const major = +m[1], minor = +m[2];
5386
+ modern = major > 5 || major === 5 && minor >= 1;
5387
+ }
5388
+ } catch {
5355
5389
  }
5356
- return { kind: "none" };
5390
+ chFlagCache = modern ? ["-ch_layout", "stereo"] : ["-ac", String(CH)];
5391
+ return chFlagCache;
5357
5392
  }
5358
- function ffplayArgs(url, offsetMs, volume = 70) {
5359
- const args = [
5393
+ function sinkArgs(ffplayPath) {
5394
+ return [
5360
5395
  "-hide_banner",
5361
5396
  "-loglevel",
5362
5397
  "error",
5363
5398
  "-nodisp",
5364
- "-autoexit",
5365
- "-volume",
5366
- String(Math.max(0, Math.min(100, Math.round(volume))))
5399
+ "-sync",
5400
+ "audio",
5401
+ "-infbuf",
5402
+ "-f",
5403
+ "s16le",
5404
+ "-ar",
5405
+ String(SR),
5406
+ ...ffplayStereoFlag(ffplayPath),
5407
+ "-i",
5408
+ "-"
5367
5409
  ];
5368
- if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
5369
- args.push("-i", url);
5370
- return args;
5371
5410
  }
5372
5411
  var SyncedAudio = class {
5373
- proc = null;
5412
+ sink = null;
5413
+ decoder = null;
5374
5414
  mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
5375
5415
  opts = null;
5376
5416
  timer = null;
5377
- vol = 70;
5378
- // 0..100
5417
+ gain = 0.7;
5418
+ // current (ramping) gain
5419
+ target = 0.7;
5420
+ // desired volume (0..1)
5379
5421
  muted = false;
5380
5422
  start(opts) {
5381
5423
  this.stop();
5382
5424
  this.opts = opts;
5425
+ this.startSink();
5383
5426
  this.tick();
5384
5427
  this.timer = setInterval(() => this.tick(), 1e3);
5385
5428
  }
5386
- /** Swap in a new schedule (worker pushed a new playlist) without a restart. */
5387
5429
  setSchedule(sched) {
5388
5430
  if (this.opts) this.opts.schedule = sched;
5389
5431
  }
5390
5432
  get volume() {
5391
- return this.vol;
5433
+ return Math.round(this.target * 100);
5392
5434
  }
5393
5435
  get isMuted() {
5394
5436
  return this.muted;
5395
5437
  }
5396
- /** Set volume 0..100. Respawns the current track at its live offset so the
5397
- * change is audible immediately (unless muted). */
5438
+ get nowTrackId() {
5439
+ return this.mem.playingTrackId;
5440
+ }
5441
+ /** Smooth: just move the target; the per-chunk gain ramp glides to it. */
5398
5442
  setVolume(v) {
5399
- this.vol = Math.max(0, Math.min(100, Math.round(v)));
5400
- if (!this.muted) this.respawnCurrent();
5443
+ this.target = Math.max(0, Math.min(100, Math.round(v))) / 100;
5444
+ this.muted = false;
5401
5445
  }
5402
- /** Toggle mute. Muting stops the process (no CPU); unmuting resyncs + resumes. */
5403
5446
  toggleMute() {
5404
5447
  this.muted = !this.muted;
5405
- if (this.muted) {
5406
- this.killProc();
5407
- this.mem.playingTrackId = null;
5408
- this.mem.procAlive = false;
5409
- } else this.tick();
5410
5448
  return this.muted;
5411
5449
  }
5412
- respawnCurrent() {
5450
+ // gain ramps to 0 / back, no cut
5451
+ effTarget() {
5452
+ return this.muted ? 0 : this.target;
5453
+ }
5454
+ startSink() {
5413
5455
  if (!this.opts) return;
5414
- const pos = nowPlaying(this.opts.schedule, this.opts.serverNow());
5415
- if (pos && pos.item.url) this.spawn(pos.item, pos.offsetMs);
5456
+ const sink = spawn2(this.opts.ffplayPath, sinkArgs(this.opts.ffplayPath), { stdio: ["pipe", "ignore", "pipe"] });
5457
+ this.sink = sink;
5458
+ sink.stdin?.on("error", () => {
5459
+ });
5460
+ sink.stdin?.on("drain", () => this.decoder?.stdout?.resume());
5461
+ let err = "";
5462
+ sink.stderr?.on("data", (d) => {
5463
+ err = (err + d.toString()).slice(-200);
5464
+ });
5465
+ sink.on("exit", () => {
5466
+ if (this.sink !== sink) return;
5467
+ this.sink = null;
5468
+ if (err) this.opts?.onError?.(err.split("\n").pop() || err);
5469
+ if (this.opts) {
5470
+ this.startSink();
5471
+ this.mem.procAlive = false;
5472
+ }
5473
+ });
5416
5474
  }
5417
5475
  tick() {
5418
- if (!this.opts || this.muted) return;
5476
+ if (!this.opts) return;
5477
+ if (!this.sink) this.startSink();
5419
5478
  const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
5420
- if (action.kind === "play") this.spawn(action.track, action.offsetMs);
5479
+ if (action.kind === "play") this.spawnDecoder(action.track, action.offsetMs);
5421
5480
  }
5422
- spawn(track, offsetMs) {
5481
+ spawnDecoder(track, offsetMs) {
5423
5482
  if (!this.opts) return;
5424
- this.killProc();
5425
- const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs, this.vol), { stdio: ["ignore", "ignore", "pipe"] });
5426
- this.proc = proc;
5483
+ this.killDecoder();
5484
+ const dec = spawn2(this.opts.ffmpegPath, decoderArgs(track.url, offsetMs), { stdio: ["ignore", "pipe", "ignore"] });
5485
+ this.decoder = dec;
5427
5486
  this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
5428
5487
  this.opts.onTrack?.(track);
5429
- let err = "";
5430
- proc.stderr?.on("data", (d) => {
5431
- err = (err + d.toString()).slice(-200);
5488
+ dec.stdout?.on("data", (chunk) => {
5489
+ if (this.decoder !== dec) return;
5490
+ this.gain = applyGain(chunk, this.gain, this.effTarget());
5491
+ const sink = this.sink;
5492
+ if (!sink || !sink.stdin || sink.stdin.destroyed) return;
5493
+ const ok = sink.stdin.write(chunk);
5494
+ if (ok === false) dec.stdout?.pause();
5432
5495
  });
5433
- proc.on("error", (e) => {
5434
- this.mem.procAlive = false;
5435
- this.opts?.onError?.(e.message);
5496
+ dec.on("error", () => {
5497
+ if (this.decoder === dec) this.mem.procAlive = false;
5436
5498
  });
5437
- proc.on("exit", () => {
5438
- if (this.proc !== proc) return;
5439
- this.mem.procAlive = false;
5440
- if (err) this.opts?.onError?.(err.split("\n").pop() || err);
5499
+ dec.on("exit", () => {
5500
+ if (this.decoder === dec) this.mem.procAlive = false;
5441
5501
  });
5442
5502
  }
5443
- killProc() {
5444
- if (this.proc) {
5503
+ killDecoder() {
5504
+ if (this.decoder) {
5445
5505
  try {
5446
- this.proc.kill("SIGKILL");
5506
+ this.decoder.kill("SIGKILL");
5447
5507
  } catch {
5448
5508
  }
5449
- this.proc = null;
5509
+ this.decoder = null;
5450
5510
  }
5451
5511
  }
5452
- get nowTrackId() {
5453
- return this.mem.playingTrackId;
5454
- }
5455
5512
  stop() {
5456
5513
  if (this.timer) {
5457
5514
  clearInterval(this.timer);
5458
5515
  this.timer = null;
5459
5516
  }
5460
- this.killProc();
5517
+ this.killDecoder();
5518
+ if (this.sink) {
5519
+ try {
5520
+ this.sink.kill("SIGKILL");
5521
+ } catch {
5522
+ }
5523
+ this.sink = null;
5524
+ }
5461
5525
  this.mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
5462
5526
  }
5463
5527
  };
5464
5528
 
5465
5529
  // src/radio/app.ts
5530
+ function rlog(msg) {
5531
+ try {
5532
+ const dir = path.join(os.homedir(), ".accelr8");
5533
+ fs.mkdirSync(dir, { recursive: true });
5534
+ fs.appendFileSync(path.join(dir, "radio.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${msg}
5535
+ `);
5536
+ } catch {
5537
+ }
5538
+ }
5466
5539
  var term = tk.terminal;
5467
5540
  var ScreenBuffer = tk.ScreenBuffer;
5468
5541
  var RENDER_FPS = 20;
@@ -5593,9 +5666,9 @@ function startBackground(opts) {
5593
5666
  } else {
5594
5667
  visuals = new Showrunner(ALL_SCENES);
5595
5668
  }
5596
- if (tools.ffplayPath) {
5669
+ if (tools.ffmpegPath && tools.ffplayPath) {
5597
5670
  audio = new SyncedAudio();
5598
- audio.start({ ffplayPath: tools.ffplayPath, schedule: state.schedule, serverNow });
5671
+ audio.start({ ffplayPath: tools.ffplayPath, ffmpegPath: tools.ffmpegPath, schedule: state.schedule, serverNow });
5599
5672
  }
5600
5673
  }
5601
5674
  function makeScreen() {
@@ -5666,21 +5739,26 @@ function blitOverlay(cells) {
5666
5739
  screen.put({ x: c.x, y: c.y, attr: { color: c.color ?? 231, bgColor: bg, bold: !!c.bold }, dx: 1, dy: 0, wrap: false }, c.ch || " ");
5667
5740
  }
5668
5741
  }
5742
+ var renderErrs = 0;
5669
5743
  function renderFrame() {
5670
5744
  if (!running) return;
5671
- const tMs = Date.now() - startedAt;
5672
- if (visuals) {
5673
- lastFrame = visuals.frame(tMs, screen.width, screen.height);
5674
- frameW = screen.width;
5675
- frameH = screen.height;
5676
- }
5677
- const serverNowMs = serverNow();
5678
- state = { ...state, serverNowMs, nowPlaying: nowPlaying(state.schedule, serverNowMs), nowShowing: nowShowing(state.schedule, serverNowMs) };
5679
- blitVideo();
5680
- const cells = renderOverlay({ w: screen.width, h: screen.height, tMs, state });
5681
- blitOverlay(cells);
5682
- drawControls();
5683
- if (screen.dst) screen.draw({ delta: true });
5745
+ try {
5746
+ const tMs = Date.now() - startedAt;
5747
+ if (visuals) {
5748
+ lastFrame = visuals.frame(tMs, screen.width, screen.height);
5749
+ frameW = screen.width;
5750
+ frameH = screen.height;
5751
+ }
5752
+ const serverNowMs = serverNow();
5753
+ state = { ...state, serverNowMs, nowPlaying: nowPlaying(state.schedule, serverNowMs), nowShowing: nowShowing(state.schedule, serverNowMs) };
5754
+ blitVideo();
5755
+ const cells = renderOverlay({ w: screen.width, h: screen.height, tMs, state });
5756
+ blitOverlay(cells);
5757
+ drawControls();
5758
+ if (screen.dst) screen.draw({ delta: true });
5759
+ } catch (e) {
5760
+ if (renderErrs++ < 5) rlog(`renderFrame error @${screen?.width}x${screen?.height}: ${e?.stack || e?.message || e}`);
5761
+ }
5684
5762
  }
5685
5763
  function teardown() {
5686
5764
  running = false;
@@ -5715,23 +5793,46 @@ function quit() {
5715
5793
  process.exit(0);
5716
5794
  }
5717
5795
  function runRadio(opts) {
5718
- const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
5796
+ let tz = "UTC";
5797
+ try {
5798
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
5799
+ } catch {
5800
+ }
5719
5801
  me = { id: "me_" + Math.random().toString(36).slice(2, 10), name: opts.name || "you", tz, color: 39 };
5720
5802
  state = baseState(me);
5803
+ try {
5804
+ const tools = detectVoiceTools();
5805
+ rlog(`\u2500\u2500 radio start v${VERSION} \xB7 ${process.platform} \xB7 node ${process.version} \xB7 term ${process.stdout.columns}x${process.stdout.rows} \xB7 TERM=${process.env.TERM} \xB7 ffmpeg=${!!tools.ffmpegPath} ffplay=${!!tools.ffplayPath}`);
5806
+ } catch (e) {
5807
+ rlog(`start probe failed: ${e?.stack || e}`);
5808
+ }
5721
5809
  const die = (err) => {
5810
+ const detail = err instanceof Error ? err.stack || err.message : String(err);
5811
+ rlog(`CRASH: ${detail}`);
5722
5812
  teardown();
5723
- console.error("accelr8 radio crashed:", err instanceof Error ? err.stack || err.message : err);
5724
- process.exit(1);
5813
+ console.error("\naccelr8 radio crashed:\n" + detail + "\n\nlog: " + path.join(os.homedir(), ".accelr8", "radio.log") + "\n(press any key to close)");
5814
+ try {
5815
+ process.stdin.setRawMode?.(true);
5816
+ process.stdin.resume();
5817
+ process.stdin.once("data", () => process.exit(1));
5818
+ setTimeout(() => process.exit(1), 3e4);
5819
+ } catch {
5820
+ process.exit(1);
5821
+ }
5725
5822
  };
5726
5823
  process.on("uncaughtException", die);
5727
5824
  process.on("unhandledRejection", die);
5728
- term.fullscreen(true);
5729
- term.hideCursor();
5730
- term.grabInput(true);
5731
- makeScreen();
5732
- running = true;
5733
- startBackground(opts);
5734
- connectStation(opts);
5825
+ try {
5826
+ term.fullscreen(true);
5827
+ term.hideCursor();
5828
+ term.grabInput(true);
5829
+ makeScreen();
5830
+ running = true;
5831
+ startBackground(opts);
5832
+ connectStation(opts);
5833
+ } catch (e) {
5834
+ return die(e);
5835
+ }
5735
5836
  term.on("key", (name) => {
5736
5837
  if (name === "CTRL_C" || name === "q" || name === "ESCAPE") return quit();
5737
5838
  if (name === "m") return muteToggle();
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/protocol.ts
4
- var VERSION = true ? "3.8.0" : "3.8.0";
4
+ var VERSION = true ? "3.8.2" : "3.8.2";
5
5
  var PROTO = 3;
6
6
  var PORT = 8787;
7
7
  var DEFAULT_SERVER = "wss://accelr8-canvas.patonchain.workers.dev";
@@ -32,7 +32,7 @@ import {
32
32
  VERSION,
33
33
  decode,
34
34
  encode
35
- } from "./chunk-YUZORFN4.js";
35
+ } from "./chunk-7JSMON7M.js";
36
36
 
37
37
  // src/client.ts
38
38
  import tk from "terminal-kit";
@@ -6,7 +6,7 @@ import {
6
6
  LIMITS,
7
7
  PORT,
8
8
  PROTO
9
- } from "./chunk-YUZORFN4.js";
9
+ } from "./chunk-7JSMON7M.js";
10
10
 
11
11
  // src/server.ts
12
12
  import { WebSocketServer, WebSocket } from "ws";
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  DEFAULT_SERVER,
4
4
  PORT,
5
5
  VERSION
6
- } from "./chunks/chunk-YUZORFN4.js";
6
+ } from "./chunks/chunk-7JSMON7M.js";
7
7
 
8
8
  // src/cli.ts
9
9
  var HELP = `
@@ -53,7 +53,7 @@ async function main() {
53
53
  console.error(`invalid port: ${argVal(args, "--port")}`);
54
54
  process.exit(1);
55
55
  }
56
- const { runServer } = await import("./chunks/server-6VK3AAAJ.js");
56
+ const { runServer } = await import("./chunks/server-IRLRECZ7.js");
57
57
  runServer(port);
58
58
  return;
59
59
  }
@@ -67,7 +67,7 @@ async function main() {
67
67
  console.error("accelr8 radio needs an interactive terminal (TTY).");
68
68
  process.exit(1);
69
69
  }
70
- const { runRadio } = await import("./chunks/app-DCVVDS5A.js");
70
+ const { runRadio } = await import("./chunks/app-SYLCS3QX.js");
71
71
  runRadio({
72
72
  name: argVal(args, "--name"),
73
73
  server: argVal(args, "--server"),
@@ -90,7 +90,7 @@ async function main() {
90
90
  const { cachedUpdate, refreshUpdateCache } = await import("./chunks/update-UEPNGB6Y.js");
91
91
  refreshUpdateCache();
92
92
  const update = cachedUpdate(VERSION);
93
- const { runClient } = await import("./chunks/client-Y2PNGYRP.js");
93
+ const { runClient } = await import("./chunks/client-BKQLCKVT.js");
94
94
  runClient({
95
95
  name: argVal(args, "--name"),
96
96
  server: argVal(args, "--server"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "accelr8",
3
- "version": "3.8.0",
3
+ "version": "3.8.2",
4
4
  "description": "Collaborative ascii canvas in your terminal. Real multiplayer, live cursors, mouse-driven TUI.",
5
5
  "type": "module",
6
6
  "license": "ISC",