accelr8 3.3.0 → 3.4.0

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.
@@ -4,9 +4,13 @@ import {
4
4
  tzCity,
5
5
  tzToCoords
6
6
  } from "./chunk-HY6ULYGH.js";
7
+ import {
8
+ DEFAULT_SERVER
9
+ } from "./chunk-SK7QPMLG.js";
7
10
 
8
11
  // src/radio/app.ts
9
12
  import tk from "terminal-kit";
13
+ import { WebSocket } from "ws";
10
14
 
11
15
  // src/radio/asciivideo.ts
12
16
  import { spawn } from "node:child_process";
@@ -117,6 +121,389 @@ var AsciiVideo = class {
117
121
  }
118
122
  };
119
123
 
124
+ // src/radio/visualizers.ts
125
+ var TAU = Math.PI * 2;
126
+ function clamp01(v) {
127
+ return v < 0 ? 0 : v > 1 ? 1 : v;
128
+ }
129
+ function col(r, g, b) {
130
+ const c = (v) => v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
131
+ return xterm256(c(r), c(g), c(b));
132
+ }
133
+ function hashU(n) {
134
+ let h = n | 0;
135
+ h = Math.imul(h ^ h >>> 15, 2246822507);
136
+ h = Math.imul(h ^ h >>> 13, 3266489909);
137
+ h ^= h >>> 16;
138
+ return (h >>> 0) / 4294967296;
139
+ }
140
+ function hash2(x, y) {
141
+ return hashU(Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263));
142
+ }
143
+ function vnoise(x, y) {
144
+ const xi = Math.floor(x), yi = Math.floor(y);
145
+ const fx = x - xi, fy = y - yi;
146
+ const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
147
+ const a = hash2(xi, yi), b = hash2(xi + 1, yi);
148
+ const c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
149
+ return a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy;
150
+ }
151
+ function brand(t, bright = 1) {
152
+ const k = Math.cos(TAU * t);
153
+ return col((150.5 + 104.5 * k) * bright, (130.5 - 37.5 * k) * bright, (216 - 39 * k) * bright);
154
+ }
155
+ var INK = xterm256(10, 12, 20);
156
+ function dark() {
157
+ return { ch: " ", color: INK };
158
+ }
159
+ var RAMP = " .:-=+*#%@";
160
+ var SHADE = " \u2591\u2592\u2593\u2588";
161
+ var PART = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
162
+ var plasma = {
163
+ name: "plasma",
164
+ frame(tMs, w, h) {
165
+ const out = new Array(w * h);
166
+ const t = tMs / 1e3;
167
+ const iw = 2 / Math.max(1, w - 1), ih = 2 / Math.max(1, h - 1);
168
+ const last = RAMP.length - 1;
169
+ for (let y = 0; y < h; y++) {
170
+ const v = y * ih;
171
+ for (let x = 0; x < w; x++) {
172
+ const u = x * iw;
173
+ const du = u - 1, dv = v - 1;
174
+ let p = Math.sin(u * 3.1 + t * 0.9) + Math.sin(v * 2.3 - u * 1.1 + t * 0.6) + Math.sin(Math.sqrt(du * du + dv * dv) * 4.2 - t * 1.4) + Math.sin((u + v) * 1.7 + t * 0.35);
175
+ p = (p + 4) / 8;
176
+ out[y * w + x] = {
177
+ ch: RAMP[Math.min(last, Math.max(0, Math.round(p * last)))],
178
+ color: brand(p * 0.9 + t * 0.03, 0.3 + 0.8 * p)
179
+ };
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ };
185
+ var starfield = {
186
+ name: "starfield",
187
+ frame(tMs, w, h) {
188
+ const out = new Array(w * h);
189
+ const t = tMs / 1e3;
190
+ for (let y = 0; y < h; y++) {
191
+ for (let x = 0; x < w; x++) {
192
+ const r = hash2(x, y);
193
+ if (r > 0.994) {
194
+ const tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 2 + r * 900));
195
+ out[y * w + x] = { ch: "\xB7", color: col(60 * tw + 30, 70 * tw + 35, 110 * tw + 45) };
196
+ } else out[y * w + x] = dark();
197
+ }
198
+ }
199
+ const cx = (w - 1) / 2, cy = (h - 1) / 2;
200
+ const maxR = Math.sqrt(cx * cx + cy * cy * 4) + 2;
201
+ const travel = 0.14 * t - 0.112 * Math.cos(t * 0.5);
202
+ const n = Math.min(900, Math.max(40, Math.round(w * h / 26)));
203
+ const STREAK = "\u2500\u2572\u2502\u2571\u2500\u2572\u2502\u2571";
204
+ for (let i = 0; i < n; i++) {
205
+ const a = hashU(i * 5 + 1) * TAU;
206
+ const rate = 0.5 + hashU(i * 5 + 2);
207
+ const ph = hashU(i * 5 + 3);
208
+ const ca = Math.cos(a), sa = Math.sin(a);
209
+ const u0 = ((travel * rate + ph) % 1 + 1) % 1;
210
+ for (let k = 0; k < 4; k++) {
211
+ const u = u0 - k * 0.014;
212
+ if (u < 0 || k > 0 && u0 < 0.3) continue;
213
+ const r = u * u * maxR;
214
+ const x = Math.round(cx + ca * r);
215
+ const y = Math.round(cy + sa * r * 0.5);
216
+ if (x < 0 || x >= w || y < 0 || y >= h) continue;
217
+ const b = clamp01(u * (1 - k * 0.24) * (0.72 + 0.28 * Math.sin(t * 3 + i * 0.7)));
218
+ let ch;
219
+ if (k > 0 && u > 0.65) ch = STREAK[(Math.round(a / (Math.PI / 4)) % 8 + 8) % 8];
220
+ else ch = b > 0.75 ? "\u25CF" : b > 0.5 ? "\u2022" : b > 0.28 ? "\u2218" : "\xB7";
221
+ out[y * w + x] = { ch, color: col(90 + 165 * b, 150 + 105 * b, 215 + 40 * b) };
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+ };
227
+ var matrix = {
228
+ name: "matrix",
229
+ frame(tMs, w, h) {
230
+ const out = new Array(w * h);
231
+ const t = tMs / 1e3;
232
+ const CH = "01<>+*=~^:;|/\\ACELR8#$%&?!";
233
+ for (let x = 0; x < w; x++) {
234
+ let h0 = 0, l0 = 1, h1 = 0, l1 = 1;
235
+ for (let d = 0; d < 2; d++) {
236
+ const seed = x * 2 + d * 8191;
237
+ const speed = 5 + 9 * hashU(seed * 3 + 1);
238
+ const len = 4 + Math.floor(hashU(seed * 3 + 2) * 9);
239
+ const span = h + len + 4;
240
+ const head = (t * speed + hashU(seed * 3 + 3) * span * 9) % span;
241
+ if (d === 0) {
242
+ h0 = head;
243
+ l0 = len;
244
+ } else {
245
+ h1 = head;
246
+ l1 = len;
247
+ }
248
+ }
249
+ for (let y = 0; y < h; y++) {
250
+ const d0 = h0 - y, d1 = h1 - y;
251
+ const q0 = d0 >= 0 && d0 < l0 ? 1 - d0 / l0 : 0;
252
+ const q1 = d1 >= 0 && d1 < l1 ? 1 - d1 / l1 : 0;
253
+ const q = Math.max(q0, q1);
254
+ if (q <= 0.04) {
255
+ out[y * w + x] = dark();
256
+ continue;
257
+ }
258
+ const rate = 1.5 + 4 * hash2(x, y);
259
+ const ch = CH[Math.floor(hashU(x * 31 + y * 57 + Math.floor(t * rate) * 7919) * CH.length)];
260
+ const isHead = q0 > 0 && d0 < 1 || q1 > 0 && d1 < 1;
261
+ 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) };
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+ };
267
+ var spectrum = {
268
+ name: "spectrum",
269
+ frame(tMs, w, h) {
270
+ const out = new Array(w * h);
271
+ const t = tMs / 1e3;
272
+ const barW = w >= 40 ? 2 : 1;
273
+ const unit = w >= 40 ? 3 : 1;
274
+ const nBars = Math.max(1, Math.ceil(w / unit));
275
+ const kick = Math.exp(-2.5 * (t * 2.05 % 1));
276
+ const denom = Math.max(1, h - 1);
277
+ for (let x = 0; x < w; x++) {
278
+ const i = Math.floor(x / unit);
279
+ const isGap = x % unit >= barW;
280
+ const m = Math.min(i, nBars - 1 - i);
281
+ const f = nBars <= 1 ? 1 : m / Math.max(0.5, (nBars - 1) / 2);
282
+ const profile = 0.3 + 0.7 * Math.pow(f, 0.8);
283
+ const s1 = 0.5 + 0.5 * Math.sin(t * 2.2 + m * 1.31);
284
+ const s2 = 0.5 + 0.5 * Math.sin(t * 3.7 + m * 2.71 + Math.sin(t * 0.9 + m));
285
+ const v = clamp01(0.05 + profile * (0.24 + 0.36 * s1 + 0.2 * s2 + 0.34 * kick));
286
+ const filled = v * h;
287
+ const full = Math.floor(filled);
288
+ const frac = filled - full;
289
+ for (let y = 0; y < h; y++) {
290
+ const up = h - 1 - y;
291
+ if (isGap || up > full || up === full && frac < 0.06) {
292
+ out[y * w + x] = dark();
293
+ continue;
294
+ }
295
+ const q = up / denom;
296
+ const color = brand(0.5 - 0.45 * clamp01(q * 1.15), 0.5 + 0.55 * q);
297
+ const ch = up < full ? "\u2588" : PART[Math.min(7, Math.floor(frac * 8))];
298
+ out[y * w + x] = { ch, color };
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ };
304
+ var tunnel = {
305
+ name: "tunnel",
306
+ frame(tMs, w, h) {
307
+ const out = new Array(w * h);
308
+ const t = tMs / 1e3;
309
+ const cx = (w - 1) / 2, cy = (h - 1) / 2;
310
+ const norm = Math.max(1, Math.min(w, h * 2) * 0.5);
311
+ const last = SHADE.length - 1;
312
+ for (let y = 0; y < h; y++) {
313
+ const dy = (y - cy) * 2;
314
+ for (let x = 0; x < w; x++) {
315
+ const dx = x - cx;
316
+ const r = Math.sqrt(dx * dx + dy * dy) / norm;
317
+ const rr = Math.max(r, 0.05);
318
+ const ang = Math.atan2(dy, dx) + t * 0.25 + 0.6 * rr * Math.sin(t * 0.4);
319
+ const depth = 0.9 / rr + t * 1.8;
320
+ const ring = Math.floor(depth * 2.2);
321
+ const sector = Math.floor(ang / TAU * 14 + 140);
322
+ const checker = ((ring + sector) % 2 + 2) % 2;
323
+ const fog = clamp01((r - 0.04) * 2.4);
324
+ const shade = fog * (checker ? 0.95 : 0.4) * (0.8 + 0.2 * Math.sin(ring * 0.9 + t));
325
+ const hue = checker ? 0.5 + 0.05 * Math.sin(ang * 3) : 0.04 + 0.05 * Math.sin(ring * 0.5);
326
+ out[y * w + x] = {
327
+ ch: SHADE[Math.min(last, Math.max(0, Math.round(shade * last)))],
328
+ color: brand(hue, 0.25 + 0.75 * shade)
329
+ };
330
+ }
331
+ }
332
+ return out;
333
+ }
334
+ };
335
+ var scope = {
336
+ name: "scope",
337
+ frame(tMs, w, h) {
338
+ const out = new Array(w * h);
339
+ const t = tMs / 1e3;
340
+ const midY = (h - 1) / 2;
341
+ for (let y = 0; y < h; y++) {
342
+ for (let x = 0; x < w; x++) {
343
+ if (y === Math.round(midY)) out[y * w + x] = { ch: "\u2500", color: col(30, 40, 62) };
344
+ else if (x % 10 === 5 && y % 4 === 1) out[y * w + x] = { ch: "\xB7", color: col(38, 48, 72) };
345
+ else out[y * w + x] = dark();
346
+ }
347
+ }
348
+ const TR = [
349
+ { f1: 2, f2: 5, w1: 1.1, w2: 1.4, rgb: [46, 168, 255] },
350
+ { f1: 3, f2: 4, w1: -0.9, w2: 2, rgb: [255, 93, 177] },
351
+ { f1: 1, f2: 7, w1: 0.6, w2: -1.6, rgb: [158, 108, 255] }
352
+ ];
353
+ const iw = TAU / Math.max(1, w - 1);
354
+ const yAt = (j, x) => {
355
+ const tr = TR[j];
356
+ const A = midY * (0.55 + 0.35 * Math.sin(t * 0.5 + j * 1.9));
357
+ const ph = x * iw;
358
+ const yv = midY + A * (0.62 * Math.sin(ph * tr.f1 + t * tr.w1 + j * 2.1) + 0.38 * Math.sin(ph * tr.f2 - t * tr.w2 + j));
359
+ return Math.min(h - 1, Math.max(0, yv));
360
+ };
361
+ for (let pass = 0; pass < 2; pass++) {
362
+ for (let j = 0; j < 3; j++) {
363
+ const [cr, cg, cb] = TR[j].rgb;
364
+ let prev = Math.round(yAt(j, 0));
365
+ for (let x = 0; x < w; x++) {
366
+ const yv = yAt(j, x);
367
+ const iy = Math.round(yv);
368
+ if (pass === 0) {
369
+ const glow = { ch: "\xB7", color: col(cr * 0.35, cg * 0.35, cb * 0.35) };
370
+ if (iy - 1 >= 0) out[(iy - 1) * w + x] = { ...glow };
371
+ if (iy + 1 < h) out[(iy + 1) * w + x] = { ...glow };
372
+ } else {
373
+ const shimmer = 0.82 + 0.18 * Math.sin(t * 5 + x * 0.3 + j);
374
+ const core = col(cr * shimmer, cg * shimmer, cb * shimmer);
375
+ const lo = Math.min(prev, iy), hi = Math.max(prev, iy);
376
+ for (let yy = lo + 1; yy < hi; yy++) out[yy * w + x] = { ch: "\u2502", color: core };
377
+ const dy = iy - prev;
378
+ const ch = dy > 1 || dy < -1 ? "\u2502" : dy === 1 ? "\u2572" : dy === -1 ? "\u2571" : "\u2500";
379
+ out[iy * w + x] = { ch, color: core };
380
+ }
381
+ prev = iy;
382
+ }
383
+ }
384
+ }
385
+ return out;
386
+ }
387
+ };
388
+ var ripples = {
389
+ name: "ripples",
390
+ frame(tMs, w, h) {
391
+ const out = new Array(w * h);
392
+ const t = tMs / 1e3;
393
+ const cx = (w - 1) / 2, cy = (h - 1) / 2;
394
+ const S = [
395
+ { x: cx + w * 0.32 * Math.sin(t * 0.31), y: cy + h * 0.3 * Math.cos(t * 0.23) },
396
+ { x: cx + w * 0.34 * Math.sin(t * 0.19 + 2.1), y: cy + h * 0.32 * Math.cos(t * 0.37 + 4) },
397
+ { x: cx + w * 0.28 * Math.sin(t * 0.41 + 4.4), y: cy + h * 0.28 * Math.cos(t * 0.29 + 1.3) }
398
+ ];
399
+ const RIP = " .\xB7:~=+*#@";
400
+ const last = RIP.length - 1;
401
+ for (let y = 0; y < h; y++) {
402
+ for (let x = 0; x < w; x++) {
403
+ let wave = 0;
404
+ for (let s = 0; s < 3; s++) {
405
+ const dx = x - S[s].x, dy = (y - S[s].y) * 2;
406
+ const d = Math.sqrt(dx * dx + dy * dy);
407
+ wave += Math.sin(d * 0.55 - t * 4 + s * 1.7) / (1 + d * 0.05);
408
+ }
409
+ const p = clamp01(0.5 + wave * 0.31);
410
+ let r, g, b;
411
+ if (p < 0.5) {
412
+ const q = p * 2;
413
+ r = 10 + 36 * q;
414
+ g = 26 + 142 * q;
415
+ b = 48 + 207 * q;
416
+ } else {
417
+ const q = (p - 0.5) * 2;
418
+ r = 46 + 179 * q;
419
+ g = 168 + 84 * q;
420
+ b = 255;
421
+ }
422
+ out[y * w + x] = {
423
+ ch: RIP[Math.min(last, Math.max(0, Math.round(p * last)))],
424
+ color: col(r, g, b)
425
+ };
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+ };
431
+ var bloom = {
432
+ name: "bloom",
433
+ frame(tMs, w, h) {
434
+ const out = new Array(w * h);
435
+ const t = tMs / 1e3;
436
+ const sx = 0.085, sy = 0.17;
437
+ for (let y = 0; y < h; y++) {
438
+ for (let x = 0; x < w; x++) {
439
+ const q = vnoise(x * sx * 0.7 + t * 0.32, y * sy * 0.7 - t * 0.11);
440
+ const n = 0.62 * vnoise(x * sx + q * 1.8 + t * 0.2, y * sy + q * 1.8 + t * 0.07) + 0.38 * vnoise(x * sx * 2.3 - t * 0.26, y * sy * 2.3 + q * 1.5);
441
+ const p = clamp01((n - 0.16) * 1.55);
442
+ const ch = p < 0.2 ? " " : p < 0.33 ? "\xB7" : p < 0.48 ? "\u2591" : p < 0.63 ? "\u2592" : p < 0.8 ? "\u2593" : "\u2588";
443
+ out[y * w + x] = {
444
+ ch,
445
+ color: brand(0.16 + 0.3 * n + 0.04 * Math.sin(t * 0.4), 0.22 + 0.88 * p)
446
+ };
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+ };
452
+ var VISUALIZERS = [
453
+ plasma,
454
+ starfield,
455
+ matrix,
456
+ spectrum,
457
+ tunnel,
458
+ scope,
459
+ ripples,
460
+ bloom
461
+ ];
462
+ var VisualizerRotation = class {
463
+ holdMs;
464
+ fadeMs;
465
+ order;
466
+ constructor(opts) {
467
+ this.holdMs = Math.max(1, opts?.holdMs ?? 2e4);
468
+ this.fadeMs = Math.max(0, opts?.fadeMs ?? 1500);
469
+ const all = VISUALIZERS.map((_, i) => i);
470
+ const order = (opts?.order ?? all).filter((i) => Number.isInteger(i) && i >= 0 && i < VISUALIZERS.length);
471
+ this.order = order.length ? order : all;
472
+ }
473
+ /** Which visualizer holds slot tMs, which is next, and the fade mix 0..1. */
474
+ slot(tMs) {
475
+ const span = this.holdMs + this.fadeMs;
476
+ const idx = Math.floor(tMs / span);
477
+ const n = this.order.length;
478
+ const k = (idx % n + n) % n;
479
+ const phase = tMs - idx * span;
480
+ const a = VISUALIZERS[this.order[k]];
481
+ const b = VISUALIZERS[this.order[(k + 1) % n]];
482
+ const mix = this.fadeMs > 0 ? clamp01((phase - this.holdMs) / this.fadeMs) : 0;
483
+ return { a, b, mix };
484
+ }
485
+ frame(tMs, w, h) {
486
+ const { a, b, mix } = this.slot(tMs);
487
+ const fa = a.frame(tMs, w, h);
488
+ if (mix <= 0 || a === b) return fa;
489
+ const fb = b.frame(tMs, w, h);
490
+ const m = mix * mix * (3 - 2 * mix);
491
+ const out = new Array(w * h);
492
+ for (let y = 0; y < h; y++) {
493
+ for (let x = 0; x < w; x++) {
494
+ const i = y * w + x;
495
+ out[i] = hash2(x * 7 + 3, y * 13 + 1) < m ? fb[i] : fa[i];
496
+ }
497
+ }
498
+ return out;
499
+ }
500
+ /** name(s) currently on screen, for optional display. */
501
+ current(tMs) {
502
+ const { a, b, mix } = this.slot(tMs);
503
+ return mix > 0 && a !== b ? `${a.name} -> ${b.name}` : a.name;
504
+ }
505
+ };
506
+
120
507
  // src/radio/station.ts
121
508
  function totalLoopMs(items) {
122
509
  let sum = 0;
@@ -549,8 +936,16 @@ function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
549
936
  }
550
937
  return { kind: "none" };
551
938
  }
552
- function ffplayArgs(url, offsetMs) {
553
- const args = ["-hide_banner", "-loglevel", "error", "-nodisp", "-autoexit"];
939
+ function ffplayArgs(url, offsetMs, volume = 70) {
940
+ const args = [
941
+ "-hide_banner",
942
+ "-loglevel",
943
+ "error",
944
+ "-nodisp",
945
+ "-autoexit",
946
+ "-volume",
947
+ String(Math.max(0, Math.min(100, Math.round(volume))))
948
+ ];
554
949
  if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
555
950
  args.push("-i", url);
556
951
  return args;
@@ -560,6 +955,9 @@ var SyncedAudio = class {
560
955
  mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
561
956
  opts = null;
562
957
  timer = null;
958
+ vol = 70;
959
+ // 0..100
960
+ muted = false;
563
961
  start(opts) {
564
962
  this.stop();
565
963
  this.opts = opts;
@@ -570,15 +968,42 @@ var SyncedAudio = class {
570
968
  setSchedule(sched) {
571
969
  if (this.opts) this.opts.schedule = sched;
572
970
  }
573
- tick() {
971
+ get volume() {
972
+ return this.vol;
973
+ }
974
+ get isMuted() {
975
+ return this.muted;
976
+ }
977
+ /** Set volume 0..100. Respawns the current track at its live offset so the
978
+ * change is audible immediately (unless muted). */
979
+ setVolume(v) {
980
+ this.vol = Math.max(0, Math.min(100, Math.round(v)));
981
+ if (!this.muted) this.respawnCurrent();
982
+ }
983
+ /** Toggle mute. Muting stops the process (no CPU); unmuting resyncs + resumes. */
984
+ toggleMute() {
985
+ this.muted = !this.muted;
986
+ if (this.muted) {
987
+ this.killProc();
988
+ this.mem.playingTrackId = null;
989
+ this.mem.procAlive = false;
990
+ } else this.tick();
991
+ return this.muted;
992
+ }
993
+ respawnCurrent() {
574
994
  if (!this.opts) return;
995
+ const pos = nowPlaying(this.opts.schedule, this.opts.serverNow());
996
+ if (pos && pos.item.url) this.spawn(pos.item, pos.offsetMs);
997
+ }
998
+ tick() {
999
+ if (!this.opts || this.muted) return;
575
1000
  const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
576
1001
  if (action.kind === "play") this.spawn(action.track, action.offsetMs);
577
1002
  }
578
1003
  spawn(track, offsetMs) {
579
1004
  if (!this.opts) return;
580
1005
  this.killProc();
581
- const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs), { stdio: ["ignore", "ignore", "pipe"] });
1006
+ const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs, this.vol), { stdio: ["ignore", "ignore", "pipe"] });
582
1007
  this.proc = proc;
583
1008
  this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
584
1009
  this.opts.onTrack?.(track);
@@ -623,9 +1048,9 @@ var term = tk.terminal;
623
1048
  var ScreenBuffer = tk.ScreenBuffer;
624
1049
  var RENDER_FPS = 20;
625
1050
  var VIDEO_FPS = 15;
626
- var SEED_INPUT = "lavfi:mandelbrot=size=480x270:rate=15:maxiter=800";
627
1051
  var screen;
628
1052
  var video = null;
1053
+ var visuals = null;
629
1054
  var audio = null;
630
1055
  var lastFrame = null;
631
1056
  var frameW = 0;
@@ -633,75 +1058,159 @@ var frameH = 0;
633
1058
  var renderTimer = null;
634
1059
  var running = false;
635
1060
  var startedAt = Date.now();
1061
+ var skewMs = 0;
1062
+ var serverNow = () => Date.now() + skewMs;
636
1063
  var W = () => Math.max(20, term.width || process.stdout.columns || 100);
637
1064
  var H = () => Math.max(6, term.height || process.stdout.rows || 30);
638
- function demoState(you) {
639
- const min = 6e4;
640
- const schedule = {
641
- name: "ACCELR8 FM",
642
- epoch: startedAt - 7 * 6e4 - 12e3,
643
- // "on air" for a bit already
644
- tracks: [
645
- { id: "t1", title: "neon district", artist: "sunset arch", url: "", durationMs: 3 * min + 2e4 },
646
- { id: "t2", title: "downtown drift", artist: "kilowatt", url: "", durationMs: 4 * min + 5e3 },
647
- { id: "t3", title: "after hours protocol", artist: "v.helix", url: "", durationMs: 3 * min + 48e3 }
648
- ],
649
- clips: []
650
- };
651
- const listeners = [
652
- you,
653
- { id: "l1", name: "nori", tz: "Asia/Tokyo", color: 213 },
654
- { id: "l2", name: "brand", tz: "America/Los_Angeles", color: 39 },
655
- { id: "l3", name: "mara", tz: "Europe/London", color: 202 },
656
- { id: "l4", name: "dev", tz: "America/New_York", color: 46 },
657
- { id: "l5", name: "kenji", tz: "Asia/Tokyo", color: 51 }
658
- ];
659
- const serverNowMs = Date.now();
1065
+ var FALLBACK = {
1066
+ name: "ACCELR8 FM",
1067
+ epoch: startedAt,
1068
+ tracks: [{
1069
+ id: "flim",
1070
+ title: "Flim",
1071
+ artist: "The Bad Plus",
1072
+ durationMs: 244637,
1073
+ url: "https://accelr8-canvas.patonchain.workers.dev/audio/flim.mp3"
1074
+ }],
1075
+ clips: []
1076
+ };
1077
+ var me;
1078
+ var state;
1079
+ function baseState(you) {
1080
+ const now = serverNow();
660
1081
  return {
661
- schedule,
662
- serverNowMs,
663
- nowPlaying: nowPlaying(schedule, serverNowMs),
664
- nowShowing: nowShowing(schedule, serverNowMs),
665
- listeners,
1082
+ schedule: FALLBACK,
1083
+ serverNowMs: now,
1084
+ nowPlaying: nowPlaying(FALLBACK, now),
1085
+ nowShowing: nowShowing(FALLBACK, now),
1086
+ listeners: [you],
1087
+ // just you until the station reports real listeners
666
1088
  you,
667
1089
  broadcast: null,
668
- connected: true
1090
+ connected: false
669
1091
  };
670
1092
  }
671
- var state;
672
- function startVideo(opts) {
673
- const tools = detectVoiceTools();
674
- if (!tools.ffmpegPath) return;
675
- video = new AsciiVideo();
676
- video.start({
677
- ffmpegPath: tools.ffmpegPath,
678
- input: opts.clip || SEED_INPUT,
679
- width: W(),
680
- height: H(),
681
- fps: VIDEO_FPS,
682
- ramp: RAMPS.broadcast,
683
- onFrame: (frame, w, h) => {
684
- lastFrame = frame;
685
- frameW = w;
686
- frameH = h;
687
- },
688
- onError: () => {
1093
+ var stationWs = null;
1094
+ var reconnectTimer = null;
1095
+ function connectStation(opts) {
1096
+ const base = (opts.server || DEFAULT_SERVER).replace(/\/$/, "");
1097
+ const url = base + "/radio";
1098
+ let ws;
1099
+ try {
1100
+ ws = new WebSocket(url);
1101
+ } catch {
1102
+ scheduleReconnect(opts);
1103
+ return;
1104
+ }
1105
+ stationWs = ws;
1106
+ ws.on("open", () => {
1107
+ ws.send(JSON.stringify({ t: "hello", id: me.id, name: me.name, tz: me.tz, color: me.color }));
1108
+ });
1109
+ ws.on("message", (raw) => {
1110
+ let m;
1111
+ try {
1112
+ m = JSON.parse(raw.toString());
1113
+ } catch {
1114
+ return;
1115
+ }
1116
+ if (m && m.t === "radioState") applyStation(m);
1117
+ });
1118
+ ws.on("close", () => {
1119
+ if (stationWs === ws) {
1120
+ state = { ...state, connected: false };
1121
+ scheduleReconnect(opts);
1122
+ }
1123
+ });
1124
+ ws.on("error", () => {
1125
+ try {
1126
+ ws.close();
1127
+ } catch {
689
1128
  }
690
1129
  });
1130
+ }
1131
+ function scheduleReconnect(opts) {
1132
+ if (reconnectTimer || !running) return;
1133
+ reconnectTimer = setTimeout(() => {
1134
+ reconnectTimer = null;
1135
+ if (running) connectStation(opts);
1136
+ }, 2e3);
1137
+ }
1138
+ function applyStation(m) {
1139
+ if (typeof m.serverNow === "number") skewMs = m.serverNow - Date.now();
1140
+ const sched = m.station && Array.isArray(m.station.tracks) && m.station.tracks.length ? { name: m.station.name || "ACCELR8 FM", epoch: m.station.epoch || startedAt, tracks: m.station.tracks, clips: m.station.clips || [] } : state.schedule;
1141
+ const listeners = Array.isArray(m.listeners) && m.listeners.length ? m.listeners.map((l) => ({ id: l.id, name: l.name, tz: l.tz, color: l.color })) : [me];
1142
+ const broadcast = m.broadcast || null;
1143
+ state = { ...state, schedule: sched, listeners, broadcast, connected: true };
1144
+ if (audio) audio.setSchedule(sched);
1145
+ }
1146
+ function startBackground(opts) {
1147
+ const tools = detectVoiceTools();
1148
+ if (opts.clip && tools.ffmpegPath) {
1149
+ video = new AsciiVideo();
1150
+ video.start({
1151
+ ffmpegPath: tools.ffmpegPath,
1152
+ input: opts.clip,
1153
+ width: W(),
1154
+ height: H(),
1155
+ fps: VIDEO_FPS,
1156
+ ramp: RAMPS.broadcast,
1157
+ onFrame: (frame, w, h) => {
1158
+ lastFrame = frame;
1159
+ frameW = w;
1160
+ frameH = h;
1161
+ },
1162
+ onError: () => {
1163
+ video = null;
1164
+ }
1165
+ });
1166
+ } else {
1167
+ visuals = new VisualizerRotation();
1168
+ }
691
1169
  if (tools.ffplayPath) {
692
1170
  audio = new SyncedAudio();
693
- audio.start({
694
- ffplayPath: tools.ffplayPath,
695
- schedule: state.schedule,
696
- serverNow: () => Date.now()
697
- // TODO: swap for skew-corrected worker clock
698
- });
1171
+ audio.start({ ffplayPath: tools.ffplayPath, schedule: state.schedule, serverNow });
699
1172
  }
700
1173
  }
701
1174
  function makeScreen() {
702
1175
  screen = new ScreenBuffer({ dst: term, width: W(), height: H() });
703
1176
  video?.resize(W(), H());
704
1177
  }
1178
+ var volMsg = "";
1179
+ var volMsgUntil = 0;
1180
+ function flashVol() {
1181
+ if (!audio) return;
1182
+ const v = audio.volume, bars = 12, on = Math.round(v / 100 * bars);
1183
+ volMsg = audio.isMuted ? "\u266B muted (m to unmute)" : `\u266B ${"\u2588".repeat(on)}${"\u2591".repeat(bars - on)} ${v}%`;
1184
+ volMsgUntil = Date.now() + 1800;
1185
+ }
1186
+ function volUp() {
1187
+ if (audio) {
1188
+ if (audio.isMuted) audio.toggleMute();
1189
+ audio.setVolume(audio.volume + 10);
1190
+ flashVol();
1191
+ }
1192
+ }
1193
+ function volDown() {
1194
+ if (audio) {
1195
+ if (audio.isMuted) audio.toggleMute();
1196
+ audio.setVolume(audio.volume - 10);
1197
+ flashVol();
1198
+ }
1199
+ }
1200
+ function muteToggle() {
1201
+ if (audio) {
1202
+ audio.toggleMute();
1203
+ flashVol();
1204
+ }
1205
+ }
1206
+ function drawVolToast() {
1207
+ if (Date.now() >= volMsgUntil || !volMsg) return;
1208
+ const y = Math.max(1, Math.floor(screen.height / 3));
1209
+ const s = ` ${volMsg} `;
1210
+ const x = Math.max(0, Math.floor((screen.width - s.length) / 2));
1211
+ for (let i = 0; i < s.length && x + i < screen.width; i++)
1212
+ screen.put({ x: x + i, y, attr: { color: 231, bgColor: 16, bold: true }, dx: 1, dy: 0, wrap: false }, s[i]);
1213
+ }
705
1214
  function blitVideo() {
706
1215
  const w = screen.width, h = screen.height;
707
1216
  if (lastFrame && frameW === w && frameH === h) {
@@ -733,12 +1242,18 @@ function blitOverlay(cells) {
733
1242
  }
734
1243
  function renderFrame() {
735
1244
  if (!running) return;
736
- const you = state.you;
737
- const serverNowMs = Date.now();
1245
+ const tMs = Date.now() - startedAt;
1246
+ if (visuals) {
1247
+ lastFrame = visuals.frame(tMs, screen.width, screen.height);
1248
+ frameW = screen.width;
1249
+ frameH = screen.height;
1250
+ }
1251
+ const serverNowMs = serverNow();
738
1252
  state = { ...state, serverNowMs, nowPlaying: nowPlaying(state.schedule, serverNowMs), nowShowing: nowShowing(state.schedule, serverNowMs) };
739
1253
  blitVideo();
740
- const cells = renderOverlay({ w: screen.width, h: screen.height, tMs: Date.now() - startedAt, state });
1254
+ const cells = renderOverlay({ w: screen.width, h: screen.height, tMs, state });
741
1255
  blitOverlay(cells);
1256
+ drawVolToast();
742
1257
  if (screen.dst) screen.draw({ delta: true });
743
1258
  }
744
1259
  function teardown() {
@@ -747,8 +1262,18 @@ function teardown() {
747
1262
  clearInterval(renderTimer);
748
1263
  renderTimer = null;
749
1264
  }
1265
+ if (reconnectTimer) {
1266
+ clearTimeout(reconnectTimer);
1267
+ reconnectTimer = null;
1268
+ }
1269
+ try {
1270
+ stationWs?.close();
1271
+ } catch {
1272
+ }
1273
+ stationWs = null;
750
1274
  video?.stop();
751
1275
  video = null;
1276
+ visuals = null;
752
1277
  audio?.stop();
753
1278
  audio = null;
754
1279
  try {
@@ -764,8 +1289,9 @@ function quit() {
764
1289
  process.exit(0);
765
1290
  }
766
1291
  function runRadio(opts) {
767
- const you = { id: "you", name: opts.name || "you", tz: Intl.DateTimeFormat().resolvedOptions().timeZone };
768
- state = demoState(you);
1292
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1293
+ me = { id: "me_" + Math.random().toString(36).slice(2, 10), name: opts.name || "you", tz, color: 39 };
1294
+ state = baseState(me);
769
1295
  const die = (err) => {
770
1296
  teardown();
771
1297
  console.error("accelr8 radio crashed:", err instanceof Error ? err.stack || err.message : err);
@@ -777,14 +1303,19 @@ function runRadio(opts) {
777
1303
  term.hideCursor();
778
1304
  term.grabInput(true);
779
1305
  makeScreen();
780
- startVideo(opts);
781
1306
  running = true;
1307
+ startBackground(opts);
1308
+ connectStation(opts);
782
1309
  term.on("key", (name) => {
783
1310
  if (name === "CTRL_C" || name === "q" || name === "ESCAPE") return quit();
1311
+ if (name === "m") return muteToggle();
1312
+ if (name === "+" || name === "=" || name === "UP") return volUp();
1313
+ if (name === "-" || name === "_" || name === "DOWN") return volDown();
784
1314
  });
785
1315
  term.on("resize", () => makeScreen());
786
1316
  process.on("SIGINT", quit);
787
1317
  process.on("SIGTERM", quit);
1318
+ flashVol();
788
1319
  renderTimer = setInterval(renderFrame, Math.round(1e3 / RENDER_FPS));
789
1320
  renderFrame();
790
1321
  }