accelr8 3.4.0 → 3.6.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.
@@ -1,1324 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- detectVoiceTools,
4
- tzCity,
5
- tzToCoords
6
- } from "./chunk-HY6ULYGH.js";
7
- import {
8
- DEFAULT_SERVER
9
- } from "./chunk-SK7QPMLG.js";
10
-
11
- // src/radio/app.ts
12
- import tk from "terminal-kit";
13
- import { WebSocket } from "ws";
14
-
15
- // src/radio/asciivideo.ts
16
- import { spawn } from "node:child_process";
17
- var RAMPS = {
18
- broadcast: " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
19
- blocks: " \u2591\u2592\u2593\u2588",
20
- classic: " .:-=+*#%@"
21
- };
22
- function xterm256(r, g, b) {
23
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
24
- if (max - min < 12) {
25
- if (max < 8) return 16;
26
- if (max > 248) return 231;
27
- return 232 + Math.min(23, Math.round((max - 8) / 230 * 23));
28
- }
29
- const q = (v) => Math.round(v / 255 * 5);
30
- return 16 + 36 * q(r) + 6 * q(g) + q(b);
31
- }
32
- function frameToCells(rgb, w, h, ramp = RAMPS.broadcast) {
33
- const out = new Array(w * h);
34
- const last = ramp.length - 1;
35
- for (let i = 0, p = 0; i < w * h; i++, p += 3) {
36
- const r = rgb[p], g = rgb[p + 1], b = rgb[p + 2];
37
- const lum = (r * 299 + g * 587 + b * 114) / 1e3;
38
- const ch = ramp[Math.min(last, Math.max(0, Math.round(lum / 255 * last)))];
39
- out[i] = { ch, color: xterm256(r, g, b) };
40
- }
41
- return out;
42
- }
43
- function ffmpegArgs(o) {
44
- const fps = o.fps ?? 15;
45
- const isLavfi = o.input.startsWith("lavfi:");
46
- const args = ["-hide_banner", "-loglevel", "error"];
47
- if (isLavfi) {
48
- args.push("-f", "lavfi", "-i", o.input.slice("lavfi:".length));
49
- } else {
50
- if (o.loop !== false) args.push("-stream_loop", "-1");
51
- if (o.seekMs && o.seekMs > 0) args.push("-ss", (o.seekMs / 1e3).toFixed(3));
52
- args.push("-i", o.input);
53
- }
54
- args.push(
55
- "-vf",
56
- `scale=${o.width}:${o.height}:flags=bilinear,setsar=1`,
57
- "-r",
58
- String(fps),
59
- "-f",
60
- "rawvideo",
61
- "-pix_fmt",
62
- "rgb24",
63
- "-"
64
- );
65
- return args;
66
- }
67
- var AsciiVideo = class {
68
- proc = null;
69
- buf = Buffer.alloc(0);
70
- opts = null;
71
- start(opts) {
72
- this.stop();
73
- this.opts = opts;
74
- this.buf = Buffer.alloc(0);
75
- const frameBytes = opts.width * opts.height * 3;
76
- if (frameBytes <= 0) return;
77
- const proc = spawn(opts.ffmpegPath, ffmpegArgs(opts), { stdio: ["ignore", "pipe", "pipe"] });
78
- this.proc = proc;
79
- proc.stdout.on("data", (chunk) => {
80
- this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
81
- while (this.buf.length >= frameBytes) {
82
- const frame = this.buf.subarray(0, frameBytes);
83
- this.buf = this.buf.subarray(frameBytes);
84
- try {
85
- opts.onFrame(frameToCells(frame, opts.width, opts.height, opts.ramp), opts.width, opts.height);
86
- } catch {
87
- }
88
- }
89
- });
90
- let err = "";
91
- proc.stderr.on("data", (d) => {
92
- err = (err + d.toString()).slice(-240);
93
- });
94
- proc.on("error", (e) => {
95
- opts.onError?.(e.message);
96
- });
97
- proc.on("exit", (code) => {
98
- if (this.proc !== proc) return;
99
- this.proc = null;
100
- if (code && code !== 0) opts.onError?.(`ffmpeg exited ${code}${err ? ": " + err.split("\n").pop() : ""}`);
101
- });
102
- }
103
- /** Re-decode at a new cell grid (terminal resize). Keeps input + seek. */
104
- resize(width, height) {
105
- if (!this.opts) return;
106
- if (width === this.opts.width && height === this.opts.height) return;
107
- this.start({ ...this.opts, width, height });
108
- }
109
- get active() {
110
- return !!this.proc;
111
- }
112
- stop() {
113
- if (this.proc) {
114
- try {
115
- this.proc.kill("SIGKILL");
116
- } catch {
117
- }
118
- this.proc = null;
119
- }
120
- this.buf = Buffer.alloc(0);
121
- }
122
- };
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
-
507
- // src/radio/station.ts
508
- function totalLoopMs(items) {
509
- let sum = 0;
510
- for (const it of items) sum += Math.max(0, it.durationMs);
511
- return sum;
512
- }
513
- function mod(n, m) {
514
- if (m <= 0) return 0;
515
- return (n % m + m) % m;
516
- }
517
- function timelineAt(items, epoch, serverNowMs) {
518
- if (!items.length) return null;
519
- const loopMs = totalLoopMs(items);
520
- if (loopMs <= 0) return null;
521
- const posInLoopMs = mod(serverNowMs - epoch, loopMs);
522
- let acc = 0;
523
- for (let i = 0; i < items.length; i++) {
524
- const dur = Math.max(0, items[i].durationMs);
525
- if (dur === 0) continue;
526
- if (posInLoopMs < acc + dur) {
527
- const offsetMs = posInLoopMs - acc;
528
- let ni = (i + 1) % items.length;
529
- let guard = items.length;
530
- while (items[ni].durationMs <= 0 && guard-- > 0) ni = (ni + 1) % items.length;
531
- return {
532
- item: items[i],
533
- index: i,
534
- offsetMs,
535
- remainingMs: dur - offsetMs,
536
- posInLoopMs,
537
- loopMs,
538
- next: items[ni]
539
- };
540
- }
541
- acc += dur;
542
- }
543
- for (let i = items.length - 1; i >= 0; i--) {
544
- if (items[i].durationMs > 0) {
545
- return {
546
- item: items[i],
547
- index: i,
548
- offsetMs: items[i].durationMs,
549
- remainingMs: 0,
550
- posInLoopMs,
551
- loopMs,
552
- next: items[(i + 1) % items.length]
553
- };
554
- }
555
- }
556
- return null;
557
- }
558
- function nowPlaying(sched, serverNowMs) {
559
- return timelineAt(sched.tracks, sched.epoch, serverNowMs);
560
- }
561
- function nowShowing(sched, serverNowMs) {
562
- return timelineAt(sched.clips, sched.epoch, serverNowMs);
563
- }
564
- function uptimeLabel(epoch, serverNowMs) {
565
- const s = Math.max(0, Math.floor((serverNowMs - epoch) / 1e3));
566
- const d = Math.floor(s / 86400);
567
- const h = Math.floor(s % 86400 / 3600);
568
- const m = Math.floor(s % 3600 / 60);
569
- const sec = s % 60;
570
- if (d > 0) return `${d}d ${h}h`;
571
- if (h > 0) return `${h}h ${m}m`;
572
- if (m > 0) return `${m}m ${sec}s`;
573
- return `${sec}s`;
574
- }
575
- function clock(ms) {
576
- const s = Math.max(0, Math.floor(ms / 1e3));
577
- const m = Math.floor(s / 60);
578
- return `${m}:${String(s % 60).padStart(2, "0")}`;
579
- }
580
-
581
- // src/radio/funfacts.ts
582
- var EARTH_RADIUS_KM = 6371;
583
- function haversineKm(a, b) {
584
- const rad = (deg) => deg * Math.PI / 180;
585
- const dLat = rad(b.lat - a.lat);
586
- const dLon = rad(b.lon - a.lon);
587
- const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
588
- return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(s)));
589
- }
590
- function thousands(n) {
591
- return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
592
- }
593
- function localClock(tz, atMs) {
594
- try {
595
- const parts = new Intl.DateTimeFormat("en-US", {
596
- timeZone: tz,
597
- hour: "numeric",
598
- minute: "2-digit",
599
- hour12: true
600
- }).formatToParts(new Date(atMs));
601
- const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
602
- const hour12 = Number(get("hour"));
603
- const minute = get("minute");
604
- const period = get("dayPeriod").toLowerCase();
605
- if (!Number.isFinite(hour12) || !minute || period !== "am" && period !== "pm") return null;
606
- const h24 = hour12 % 12 + (period === "pm" ? 12 : 0);
607
- return { minutesOfDay: h24 * 60 + Number(minute), label: `${hour12}:${minute}${period}` };
608
- } catch {
609
- return null;
610
- }
611
- }
612
- function tzOffsetMinutes(tz, atMs) {
613
- try {
614
- const parts = new Intl.DateTimeFormat("en-US", {
615
- timeZone: tz,
616
- year: "numeric",
617
- month: "2-digit",
618
- day: "2-digit",
619
- hour: "2-digit",
620
- minute: "2-digit",
621
- second: "2-digit",
622
- hour12: false
623
- }).formatToParts(new Date(atMs));
624
- const get = (t) => Number(parts.find((p) => p.type === t)?.value);
625
- const wallUtc = Date.UTC(
626
- get("year"),
627
- get("month") - 1,
628
- get("day"),
629
- get("hour") % 24,
630
- get("minute"),
631
- get("second")
632
- );
633
- if (!Number.isFinite(wallUtc)) return null;
634
- return Math.round((wallUtc - atMs) / 6e4);
635
- } catch {
636
- return null;
637
- }
638
- }
639
- function hoursLabel(minutes) {
640
- const half = Math.round(minutes / 60 * 2) / 2;
641
- return Number.isInteger(half) ? String(half) : half.toFixed(1);
642
- }
643
- function funFacts(listeners, serverNowMs) {
644
- if (listeners.length < 2) return [];
645
- const facts = [];
646
- const placed = listeners.flatMap((l) => {
647
- const at = tzToCoords(l.tz);
648
- return at ? [{ l, at }] : [];
649
- });
650
- let bestKm = 0;
651
- let far = null;
652
- for (let i = 0; i < placed.length; i++) {
653
- for (let j = i + 1; j < placed.length; j++) {
654
- const km = haversineKm(placed[i].at, placed[j].at);
655
- if (km > bestKm) {
656
- bestKm = km;
657
- far = [placed[i], placed[j]];
658
- }
659
- }
660
- }
661
- const farKm = Math.round(bestKm / 100) * 100;
662
- if (far && farKm > 0) {
663
- facts.push(
664
- `${far[0].l.name} in ${tzCity(far[0].l.tz)} and ${far[1].l.name} in ${tzCity(far[1].l.tz)} are furthest apart right now (${thousands(farKm)} km)`
665
- );
666
- }
667
- let odd = null;
668
- for (const l of listeners) {
669
- if (!l.tz) continue;
670
- const clock2 = localClock(l.tz, serverNowMs);
671
- if (!clock2) continue;
672
- const m = clock2.minutesOfDay;
673
- if (m < 60 || m >= 360) continue;
674
- const dist = Math.abs(m - 240);
675
- if (!odd || dist < odd.dist) odd = { l, label: clock2.label, dist };
676
- }
677
- if (odd) facts.push(`it's ${odd.label} for ${odd.l.name} in ${tzCity(odd.l.tz)}`);
678
- const zones = new Set(listeners.map((l) => l.tz).filter((tz) => !!tz));
679
- if (zones.size >= 2) facts.push(`${listeners.length} listening across ${zones.size} time zones`);
680
- const offsets = listeners.flatMap((l) => {
681
- const off = l.tz ? tzOffsetMinutes(l.tz, serverNowMs) : null;
682
- return off === null ? [] : [{ l, off }];
683
- });
684
- let spread = 0;
685
- let wide = null;
686
- for (let i = 0; i < offsets.length; i++) {
687
- for (let j = i + 1; j < offsets.length; j++) {
688
- const d = Math.abs(offsets[i].off - offsets[j].off);
689
- if (d > spread) {
690
- spread = d;
691
- wide = [offsets[i], offsets[j]];
692
- }
693
- }
694
- }
695
- if (wide && spread >= 120) {
696
- facts.push(
697
- `${wide[0].l.name} in ${tzCity(wide[0].l.tz)} and ${wide[1].l.name} in ${tzCity(wide[1].l.tz)} are ${hoursLabel(spread)} hours apart on the clock`
698
- );
699
- }
700
- return facts;
701
- }
702
- function pickFact(listeners, serverNowMs, tick) {
703
- const facts = funFacts(listeners, serverNowMs);
704
- if (facts.length === 0) return "";
705
- const n = facts.length;
706
- return facts[(Math.trunc(tick) % n + n) % n];
707
- }
708
-
709
- // src/radio/overlay.ts
710
- var ACCENT = 39;
711
- var PINK = 213;
712
- var RED = 196;
713
- var WHITE = 231;
714
- var DIM = 245;
715
- var FAINT = 238;
716
- var BAR_BG = 235;
717
- var DOT_OFF = 24;
718
- var BLINK_PERIOD_MS = 1e3;
719
- var BLINK_ON_MS = 600;
720
- var TICKER_MS_PER_CELL = 120;
721
- var FACT_ROTATE_MS = 8e3;
722
- var SEP = " /// ";
723
- var EQ_GLYPHS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
724
- var FALLBACK_FACTS = [
725
- "ACCELR8 FM \u2022 broadcasting live from San Francisco",
726
- "stay locked in \u2022 the vibe is curated",
727
- "206 rooms, one frequency",
728
- "founders tune in \u2022 the dial stays hot"
729
- ];
730
- function liveBadgeOn(tMs) {
731
- const cycle = (tMs % BLINK_PERIOD_MS + BLINK_PERIOD_MS) % BLINK_PERIOD_MS;
732
- return cycle < BLINK_ON_MS;
733
- }
734
- function tickerOffset(tMs) {
735
- return Math.floor(Math.max(0, tMs) / TICKER_MS_PER_CELL);
736
- }
737
- function broadcastActive(state2) {
738
- const b = state2.broadcast;
739
- if (!b || !b.headline) return false;
740
- return state2.serverNowMs < b.startedAt + b.durationMs;
741
- }
742
- function sanitize(s) {
743
- return s.replace(/\u2014/g, "\u2022");
744
- }
745
- function tickerText(state2, tick, facts) {
746
- if (!state2.connected) return "signal lost \u2022 re-syncing with the tower";
747
- if (broadcastActive(state2)) {
748
- return `\u25C7 BREAKING \u25C7 ${sanitize(state2.broadcast.headline)}`;
749
- }
750
- const pick = facts ?? pickFact;
751
- try {
752
- const fact = pick(state2.listeners, state2.serverNowMs, tick);
753
- if (fact && fact.trim().length > 0) return sanitize(fact);
754
- } catch {
755
- }
756
- const n = FALLBACK_FACTS.length;
757
- return FALLBACK_FACTS[(tick % n + n) % n];
758
- }
759
- function put(cells, w, h, x, y, ch, st) {
760
- if (x < 0 || x >= w || y < 0 || y >= h) return;
761
- const cell = { x, y, ch };
762
- if (st.color !== void 0) cell.color = st.color;
763
- if (st.bg !== void 0) cell.bg = st.bg;
764
- if (st.bold) cell.bold = true;
765
- cells.push(cell);
766
- }
767
- function write(cells, w, h, x, y, text, st) {
768
- let cx = x;
769
- for (const ch of text) {
770
- put(cells, w, h, cx, y, ch, st);
771
- cx++;
772
- }
773
- return cx;
774
- }
775
- function makeBar(w, base) {
776
- return { chs: new Array(w).fill(" "), sts: new Array(w).fill(base) };
777
- }
778
- function barWrite(bar, x, text, st, limitX = Infinity) {
779
- let cx = x;
780
- for (const ch of text) {
781
- if (cx >= 0 && cx < bar.chs.length && cx < limitX) {
782
- bar.chs[cx] = ch;
783
- bar.sts[cx] = st;
784
- }
785
- cx++;
786
- }
787
- return cx;
788
- }
789
- function barEmit(cells, w, h, bar, y) {
790
- if (y < 0 || y >= h) return;
791
- for (let x = 0; x < bar.chs.length && x < w; x++) {
792
- put(cells, w, h, x, y, bar.chs[x], bar.sts[x]);
793
- }
794
- }
795
- function clip(text, max) {
796
- const glyphs = [...text];
797
- if (glyphs.length <= max) return text;
798
- if (max <= 0) return "";
799
- if (max === 1) return "\u2026";
800
- return glyphs.slice(0, max - 1).join("") + "\u2026";
801
- }
802
- function badgeSegments(connected, tMs) {
803
- if (!connected) {
804
- return [[" \u25CC NO SIGNAL ", { color: WHITE, bg: FAINT, bold: true }]];
805
- }
806
- const dot = liveBadgeOn(tMs) ? ["\u25CF", { color: PINK, bg: ACCENT, bold: true }] : ["\xB7", { color: DOT_OFF, bg: ACCENT }];
807
- return [
808
- [" ", { bg: ACCENT }],
809
- dot,
810
- [" LIVE ", { color: WHITE, bg: ACCENT, bold: true }]
811
- ];
812
- }
813
- function drawEq(cells, w, h, x, y, tMs) {
814
- let cx = x;
815
- for (let i = 0; i < 5; i++) {
816
- const phase = tMs / 160 + i * 1.7;
817
- const lvl = Math.round((Math.sin(phase) + 1) / 2 * (EQ_GLYPHS.length - 1));
818
- cx = write(cells, w, h, cx, y, EQ_GLYPHS[lvl], { color: ACCENT });
819
- }
820
- return cx;
821
- }
822
- function drawTopBar(cells, w, h, tMs, state2) {
823
- const bar = makeBar(w, { color: DIM, bg: BAR_BG });
824
- let cx = 1;
825
- for (const [text, st] of badgeSegments(state2.connected, tMs)) {
826
- cx = barWrite(bar, cx, text, st);
827
- }
828
- let rightStart;
829
- if (state2.connected) {
830
- const n = state2.listeners.length;
831
- const label = `\u25C9 ${n} listening`;
832
- rightStart = Math.max(cx + 2, w - 1 - [...label].length);
833
- let rx = barWrite(bar, rightStart, "\u25C9 ", { color: ACCENT, bg: BAR_BG, bold: true });
834
- rx = barWrite(bar, rx, String(n), { color: WHITE, bg: BAR_BG, bold: true });
835
- barWrite(bar, rx, " listening", { color: DIM, bg: BAR_BG });
836
- } else {
837
- const dots = ".".repeat(1 + Math.floor(Math.max(0, tMs) / 400) % 3);
838
- rightStart = Math.max(cx + 2, w - 17);
839
- barWrite(bar, rightStart, `reconnecting${dots}`, { color: DIM, bg: BAR_BG });
840
- }
841
- const limit = rightStart - 1;
842
- let sx = cx + 2;
843
- sx = barWrite(bar, sx, sanitize(state2.schedule.name), { color: WHITE, bg: BAR_BG, bold: true }, limit);
844
- if (state2.connected) {
845
- sx = barWrite(bar, sx, " \u2022 ", { color: DIM, bg: BAR_BG }, limit);
846
- sx = barWrite(bar, sx, "ON AIR ", { color: ACCENT, bg: BAR_BG, bold: true }, limit);
847
- barWrite(bar, sx, uptimeLabel(state2.schedule.epoch, state2.serverNowMs), { color: WHITE, bg: BAR_BG }, limit);
848
- }
849
- barEmit(cells, w, h, bar, 0);
850
- }
851
- function drawLowerThird(cells, w, h, tMs, state2) {
852
- const yChip = h - 4;
853
- const yTitle = h - 3;
854
- const yBar = h - 2;
855
- const np = state2.nowPlaying;
856
- if (!np) {
857
- const cx2 = write(cells, w, h, 1, yChip, " STANDBY ", { color: WHITE, bg: FAINT, bold: true });
858
- drawEq(cells, w, h, cx2 + 1, yChip, tMs);
859
- write(cells, w, h, 1, yTitle, clip("ACCELR8 FM \u2022 curating the vibe", Math.max(0, w - 2)), { color: DIM });
860
- return;
861
- }
862
- const cx = write(cells, w, h, 1, yChip, " NOW PLAYING ", { color: WHITE, bg: ACCENT, bold: true });
863
- drawEq(cells, w, h, cx + 1, yChip, tMs);
864
- let tx = write(cells, w, h, 1, yTitle, "\u25B6 ", { color: ACCENT, bold: true });
865
- tx = write(cells, w, h, tx, yTitle, clip(sanitize(np.item.title), Math.max(0, w - 1 - tx)), { color: WHITE, bold: true });
866
- if (np.item.artist) {
867
- write(cells, w, h, tx, yTitle, clip(` \u2022 ${sanitize(np.item.artist)}`, Math.max(0, w - 1 - tx)), { color: DIM });
868
- }
869
- const times = ` ${clock(np.offsetMs)} / ${clock(np.item.durationMs)}`;
870
- const barW = Math.min(26, Math.max(0, w - 2 - times.length));
871
- let px = 1;
872
- if (barW >= 6) {
873
- const frac = np.item.durationMs > 0 ? Math.min(1, Math.max(0, np.offsetMs / np.item.durationMs)) : 0;
874
- const filled = Math.round(frac * barW);
875
- px = write(cells, w, h, px, yBar, "\u2588".repeat(filled), { color: ACCENT });
876
- px = write(cells, w, h, px, yBar, "\u2591".repeat(barW - filled), { color: FAINT });
877
- }
878
- write(cells, w, h, px, yBar, times, { color: DIM });
879
- }
880
- function drawTicker(cells, w, h, y, tMs, text, state2, withChip) {
881
- if (y < 0 || y >= h) return;
882
- const bar = makeBar(w, { color: WHITE, bg: BAR_BG });
883
- let startX = 0;
884
- if (withChip) {
885
- const breaking = state2.connected && broadcastActive(state2);
886
- const chip = breaking ? " \u25C6 BREAKING " : " \u25C6 A8 WIRE ";
887
- startX = barWrite(bar, 0, chip, { color: WHITE, bg: breaking ? RED : ACCENT, bold: true }) + 1;
888
- }
889
- const unit = [...text + SEP];
890
- if (unit.length > 0) {
891
- const off = tickerOffset(tMs) % unit.length;
892
- for (let x = startX; x < w; x++) {
893
- const ch = unit[(off + (x - startX)) % unit.length];
894
- const st = ch === "\u25C7" ? { color: ACCENT, bg: BAR_BG, bold: true } : ch === "/" ? { color: FAINT, bg: BAR_BG } : { color: WHITE, bg: BAR_BG };
895
- if (x >= 0 && x < bar.chs.length) {
896
- bar.chs[x] = ch;
897
- bar.sts[x] = st;
898
- }
899
- }
900
- }
901
- barEmit(cells, w, h, bar, y);
902
- }
903
- function renderOverlay(ctx, opts) {
904
- const { w, h, tMs, state: state2 } = ctx;
905
- const cells = [];
906
- if (w <= 0 || h <= 0) return cells;
907
- const tick = Math.floor(Math.max(0, tMs) / FACT_ROTATE_MS);
908
- const marquee = tickerText(state2, tick, opts?.facts);
909
- const tiny = w < 40 || h < 12;
910
- if (tiny) {
911
- let cx = 1;
912
- for (const [text, st] of badgeSegments(state2.connected, tMs)) {
913
- cx = write(cells, w, h, cx, 0, text, st);
914
- }
915
- if (h >= 2) drawTicker(cells, w, h, h - 1, tMs, marquee, state2, false);
916
- return cells;
917
- }
918
- drawTopBar(cells, w, h, tMs, state2);
919
- if (state2.connected) drawLowerThird(cells, w, h, tMs, state2);
920
- drawTicker(cells, w, h, h - 1, tMs, marquee, state2, true);
921
- return cells;
922
- }
923
-
924
- // src/radio/audio.ts
925
- import { spawn as spawn2 } from "node:child_process";
926
- function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
927
- const pos = nowPlaying(sched, serverNowMs);
928
- if (!pos || !pos.item.url) return { kind: "none" };
929
- const trackChanged = pos.item.id !== mem.playingTrackId;
930
- if (trackChanged || !mem.procAlive) {
931
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
932
- }
933
- const impliedOffset = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
934
- if (Math.abs(impliedOffset - pos.offsetMs) > driftBudgetMs) {
935
- return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
936
- }
937
- return { kind: "none" };
938
- }
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
- ];
949
- if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
950
- args.push("-i", url);
951
- return args;
952
- }
953
- var SyncedAudio = class {
954
- proc = null;
955
- mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
956
- opts = null;
957
- timer = null;
958
- vol = 70;
959
- // 0..100
960
- muted = false;
961
- start(opts) {
962
- this.stop();
963
- this.opts = opts;
964
- this.tick();
965
- this.timer = setInterval(() => this.tick(), 1e3);
966
- }
967
- /** Swap in a new schedule (worker pushed a new playlist) without a restart. */
968
- setSchedule(sched) {
969
- if (this.opts) this.opts.schedule = sched;
970
- }
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() {
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;
1000
- const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
1001
- if (action.kind === "play") this.spawn(action.track, action.offsetMs);
1002
- }
1003
- spawn(track, offsetMs) {
1004
- if (!this.opts) return;
1005
- this.killProc();
1006
- const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs, this.vol), { stdio: ["ignore", "ignore", "pipe"] });
1007
- this.proc = proc;
1008
- this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
1009
- this.opts.onTrack?.(track);
1010
- let err = "";
1011
- proc.stderr?.on("data", (d) => {
1012
- err = (err + d.toString()).slice(-200);
1013
- });
1014
- proc.on("error", (e) => {
1015
- this.mem.procAlive = false;
1016
- this.opts?.onError?.(e.message);
1017
- });
1018
- proc.on("exit", () => {
1019
- if (this.proc !== proc) return;
1020
- this.mem.procAlive = false;
1021
- if (err) this.opts?.onError?.(err.split("\n").pop() || err);
1022
- });
1023
- }
1024
- killProc() {
1025
- if (this.proc) {
1026
- try {
1027
- this.proc.kill("SIGKILL");
1028
- } catch {
1029
- }
1030
- this.proc = null;
1031
- }
1032
- }
1033
- get nowTrackId() {
1034
- return this.mem.playingTrackId;
1035
- }
1036
- stop() {
1037
- if (this.timer) {
1038
- clearInterval(this.timer);
1039
- this.timer = null;
1040
- }
1041
- this.killProc();
1042
- this.mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
1043
- }
1044
- };
1045
-
1046
- // src/radio/app.ts
1047
- var term = tk.terminal;
1048
- var ScreenBuffer = tk.ScreenBuffer;
1049
- var RENDER_FPS = 20;
1050
- var VIDEO_FPS = 15;
1051
- var screen;
1052
- var video = null;
1053
- var visuals = null;
1054
- var audio = null;
1055
- var lastFrame = null;
1056
- var frameW = 0;
1057
- var frameH = 0;
1058
- var renderTimer = null;
1059
- var running = false;
1060
- var startedAt = Date.now();
1061
- var skewMs = 0;
1062
- var serverNow = () => Date.now() + skewMs;
1063
- var W = () => Math.max(20, term.width || process.stdout.columns || 100);
1064
- var H = () => Math.max(6, term.height || process.stdout.rows || 30);
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();
1081
- return {
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
1088
- you,
1089
- broadcast: null,
1090
- connected: false
1091
- };
1092
- }
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 {
1128
- }
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
- }
1169
- if (tools.ffplayPath) {
1170
- audio = new SyncedAudio();
1171
- audio.start({ ffplayPath: tools.ffplayPath, schedule: state.schedule, serverNow });
1172
- }
1173
- }
1174
- function makeScreen() {
1175
- screen = new ScreenBuffer({ dst: term, width: W(), height: H() });
1176
- video?.resize(W(), H());
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
- }
1214
- function blitVideo() {
1215
- const w = screen.width, h = screen.height;
1216
- if (lastFrame && frameW === w && frameH === h) {
1217
- for (let y = 0; y < h; y++) {
1218
- for (let x = 0; x < w; x++) {
1219
- const c = lastFrame[y * w + x];
1220
- if (c) screen.put({ x, y, attr: { color: c.color, bgColor: c.color }, dx: 1, dy: 0, wrap: false }, c.ch);
1221
- }
1222
- }
1223
- } else {
1224
- for (let y = 0; y < h; y++)
1225
- screen.put({ x: 0, y, attr: { color: 233, bgColor: 233 }, dx: 1, dy: 0, wrap: false }, " ".repeat(w));
1226
- }
1227
- }
1228
- function videoColorAt(x, y) {
1229
- if (lastFrame && frameW === screen.width && x < frameW && y < frameH) {
1230
- const c = lastFrame[y * frameW + x];
1231
- if (c) return c.color;
1232
- }
1233
- return 233;
1234
- }
1235
- function blitOverlay(cells) {
1236
- const w = screen.width, h = screen.height;
1237
- for (const c of cells) {
1238
- if (c.x < 0 || c.y < 0 || c.x >= w || c.y >= h) continue;
1239
- const bg = c.bg ?? videoColorAt(c.x, c.y);
1240
- 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 || " ");
1241
- }
1242
- }
1243
- function renderFrame() {
1244
- if (!running) return;
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();
1252
- state = { ...state, serverNowMs, nowPlaying: nowPlaying(state.schedule, serverNowMs), nowShowing: nowShowing(state.schedule, serverNowMs) };
1253
- blitVideo();
1254
- const cells = renderOverlay({ w: screen.width, h: screen.height, tMs, state });
1255
- blitOverlay(cells);
1256
- drawVolToast();
1257
- if (screen.dst) screen.draw({ delta: true });
1258
- }
1259
- function teardown() {
1260
- running = false;
1261
- if (renderTimer) {
1262
- clearInterval(renderTimer);
1263
- renderTimer = null;
1264
- }
1265
- if (reconnectTimer) {
1266
- clearTimeout(reconnectTimer);
1267
- reconnectTimer = null;
1268
- }
1269
- try {
1270
- stationWs?.close();
1271
- } catch {
1272
- }
1273
- stationWs = null;
1274
- video?.stop();
1275
- video = null;
1276
- visuals = null;
1277
- audio?.stop();
1278
- audio = null;
1279
- try {
1280
- term.grabInput(false);
1281
- term.hideCursor(false);
1282
- term.fullscreen(false);
1283
- term.styleReset();
1284
- } catch {
1285
- }
1286
- }
1287
- function quit() {
1288
- teardown();
1289
- process.exit(0);
1290
- }
1291
- function runRadio(opts) {
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);
1295
- const die = (err) => {
1296
- teardown();
1297
- console.error("accelr8 radio crashed:", err instanceof Error ? err.stack || err.message : err);
1298
- process.exit(1);
1299
- };
1300
- process.on("uncaughtException", die);
1301
- process.on("unhandledRejection", die);
1302
- term.fullscreen(true);
1303
- term.hideCursor();
1304
- term.grabInput(true);
1305
- makeScreen();
1306
- running = true;
1307
- startBackground(opts);
1308
- connectStation(opts);
1309
- term.on("key", (name) => {
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();
1314
- });
1315
- term.on("resize", () => makeScreen());
1316
- process.on("SIGINT", quit);
1317
- process.on("SIGTERM", quit);
1318
- flashVol();
1319
- renderTimer = setInterval(renderFrame, Math.round(1e3 / RENDER_FPS));
1320
- renderFrame();
1321
- }
1322
- export {
1323
- runRadio
1324
- };