accelr8 3.1.1 → 3.3.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.
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ detectVoiceTools,
4
+ tzCity,
5
+ tzToCoords
6
+ } from "./chunk-HY6ULYGH.js";
7
+
8
+ // src/radio/app.ts
9
+ import tk from "terminal-kit";
10
+
11
+ // src/radio/asciivideo.ts
12
+ import { spawn } from "node:child_process";
13
+ var RAMPS = {
14
+ broadcast: " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
15
+ blocks: " \u2591\u2592\u2593\u2588",
16
+ classic: " .:-=+*#%@"
17
+ };
18
+ function xterm256(r, g, b) {
19
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
20
+ if (max - min < 12) {
21
+ if (max < 8) return 16;
22
+ if (max > 248) return 231;
23
+ return 232 + Math.min(23, Math.round((max - 8) / 230 * 23));
24
+ }
25
+ const q = (v) => Math.round(v / 255 * 5);
26
+ return 16 + 36 * q(r) + 6 * q(g) + q(b);
27
+ }
28
+ function frameToCells(rgb, w, h, ramp = RAMPS.broadcast) {
29
+ const out = new Array(w * h);
30
+ const last = ramp.length - 1;
31
+ for (let i = 0, p = 0; i < w * h; i++, p += 3) {
32
+ const r = rgb[p], g = rgb[p + 1], b = rgb[p + 2];
33
+ const lum = (r * 299 + g * 587 + b * 114) / 1e3;
34
+ const ch = ramp[Math.min(last, Math.max(0, Math.round(lum / 255 * last)))];
35
+ out[i] = { ch, color: xterm256(r, g, b) };
36
+ }
37
+ return out;
38
+ }
39
+ function ffmpegArgs(o) {
40
+ const fps = o.fps ?? 15;
41
+ const isLavfi = o.input.startsWith("lavfi:");
42
+ const args = ["-hide_banner", "-loglevel", "error"];
43
+ if (isLavfi) {
44
+ args.push("-f", "lavfi", "-i", o.input.slice("lavfi:".length));
45
+ } else {
46
+ if (o.loop !== false) args.push("-stream_loop", "-1");
47
+ if (o.seekMs && o.seekMs > 0) args.push("-ss", (o.seekMs / 1e3).toFixed(3));
48
+ args.push("-i", o.input);
49
+ }
50
+ args.push(
51
+ "-vf",
52
+ `scale=${o.width}:${o.height}:flags=bilinear,setsar=1`,
53
+ "-r",
54
+ String(fps),
55
+ "-f",
56
+ "rawvideo",
57
+ "-pix_fmt",
58
+ "rgb24",
59
+ "-"
60
+ );
61
+ return args;
62
+ }
63
+ var AsciiVideo = class {
64
+ proc = null;
65
+ buf = Buffer.alloc(0);
66
+ opts = null;
67
+ start(opts) {
68
+ this.stop();
69
+ this.opts = opts;
70
+ this.buf = Buffer.alloc(0);
71
+ const frameBytes = opts.width * opts.height * 3;
72
+ if (frameBytes <= 0) return;
73
+ const proc = spawn(opts.ffmpegPath, ffmpegArgs(opts), { stdio: ["ignore", "pipe", "pipe"] });
74
+ this.proc = proc;
75
+ proc.stdout.on("data", (chunk) => {
76
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
77
+ while (this.buf.length >= frameBytes) {
78
+ const frame = this.buf.subarray(0, frameBytes);
79
+ this.buf = this.buf.subarray(frameBytes);
80
+ try {
81
+ opts.onFrame(frameToCells(frame, opts.width, opts.height, opts.ramp), opts.width, opts.height);
82
+ } catch {
83
+ }
84
+ }
85
+ });
86
+ let err = "";
87
+ proc.stderr.on("data", (d) => {
88
+ err = (err + d.toString()).slice(-240);
89
+ });
90
+ proc.on("error", (e) => {
91
+ opts.onError?.(e.message);
92
+ });
93
+ proc.on("exit", (code) => {
94
+ if (this.proc !== proc) return;
95
+ this.proc = null;
96
+ if (code && code !== 0) opts.onError?.(`ffmpeg exited ${code}${err ? ": " + err.split("\n").pop() : ""}`);
97
+ });
98
+ }
99
+ /** Re-decode at a new cell grid (terminal resize). Keeps input + seek. */
100
+ resize(width, height) {
101
+ if (!this.opts) return;
102
+ if (width === this.opts.width && height === this.opts.height) return;
103
+ this.start({ ...this.opts, width, height });
104
+ }
105
+ get active() {
106
+ return !!this.proc;
107
+ }
108
+ stop() {
109
+ if (this.proc) {
110
+ try {
111
+ this.proc.kill("SIGKILL");
112
+ } catch {
113
+ }
114
+ this.proc = null;
115
+ }
116
+ this.buf = Buffer.alloc(0);
117
+ }
118
+ };
119
+
120
+ // src/radio/station.ts
121
+ function totalLoopMs(items) {
122
+ let sum = 0;
123
+ for (const it of items) sum += Math.max(0, it.durationMs);
124
+ return sum;
125
+ }
126
+ function mod(n, m) {
127
+ if (m <= 0) return 0;
128
+ return (n % m + m) % m;
129
+ }
130
+ function timelineAt(items, epoch, serverNowMs) {
131
+ if (!items.length) return null;
132
+ const loopMs = totalLoopMs(items);
133
+ if (loopMs <= 0) return null;
134
+ const posInLoopMs = mod(serverNowMs - epoch, loopMs);
135
+ let acc = 0;
136
+ for (let i = 0; i < items.length; i++) {
137
+ const dur = Math.max(0, items[i].durationMs);
138
+ if (dur === 0) continue;
139
+ if (posInLoopMs < acc + dur) {
140
+ const offsetMs = posInLoopMs - acc;
141
+ let ni = (i + 1) % items.length;
142
+ let guard = items.length;
143
+ while (items[ni].durationMs <= 0 && guard-- > 0) ni = (ni + 1) % items.length;
144
+ return {
145
+ item: items[i],
146
+ index: i,
147
+ offsetMs,
148
+ remainingMs: dur - offsetMs,
149
+ posInLoopMs,
150
+ loopMs,
151
+ next: items[ni]
152
+ };
153
+ }
154
+ acc += dur;
155
+ }
156
+ for (let i = items.length - 1; i >= 0; i--) {
157
+ if (items[i].durationMs > 0) {
158
+ return {
159
+ item: items[i],
160
+ index: i,
161
+ offsetMs: items[i].durationMs,
162
+ remainingMs: 0,
163
+ posInLoopMs,
164
+ loopMs,
165
+ next: items[(i + 1) % items.length]
166
+ };
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+ function nowPlaying(sched, serverNowMs) {
172
+ return timelineAt(sched.tracks, sched.epoch, serverNowMs);
173
+ }
174
+ function nowShowing(sched, serverNowMs) {
175
+ return timelineAt(sched.clips, sched.epoch, serverNowMs);
176
+ }
177
+ function uptimeLabel(epoch, serverNowMs) {
178
+ const s = Math.max(0, Math.floor((serverNowMs - epoch) / 1e3));
179
+ const d = Math.floor(s / 86400);
180
+ const h = Math.floor(s % 86400 / 3600);
181
+ const m = Math.floor(s % 3600 / 60);
182
+ const sec = s % 60;
183
+ if (d > 0) return `${d}d ${h}h`;
184
+ if (h > 0) return `${h}h ${m}m`;
185
+ if (m > 0) return `${m}m ${sec}s`;
186
+ return `${sec}s`;
187
+ }
188
+ function clock(ms) {
189
+ const s = Math.max(0, Math.floor(ms / 1e3));
190
+ const m = Math.floor(s / 60);
191
+ return `${m}:${String(s % 60).padStart(2, "0")}`;
192
+ }
193
+
194
+ // src/radio/funfacts.ts
195
+ var EARTH_RADIUS_KM = 6371;
196
+ function haversineKm(a, b) {
197
+ const rad = (deg) => deg * Math.PI / 180;
198
+ const dLat = rad(b.lat - a.lat);
199
+ const dLon = rad(b.lon - a.lon);
200
+ const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
201
+ return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(s)));
202
+ }
203
+ function thousands(n) {
204
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
205
+ }
206
+ function localClock(tz, atMs) {
207
+ try {
208
+ const parts = new Intl.DateTimeFormat("en-US", {
209
+ timeZone: tz,
210
+ hour: "numeric",
211
+ minute: "2-digit",
212
+ hour12: true
213
+ }).formatToParts(new Date(atMs));
214
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
215
+ const hour12 = Number(get("hour"));
216
+ const minute = get("minute");
217
+ const period = get("dayPeriod").toLowerCase();
218
+ if (!Number.isFinite(hour12) || !minute || period !== "am" && period !== "pm") return null;
219
+ const h24 = hour12 % 12 + (period === "pm" ? 12 : 0);
220
+ return { minutesOfDay: h24 * 60 + Number(minute), label: `${hour12}:${minute}${period}` };
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+ function tzOffsetMinutes(tz, atMs) {
226
+ try {
227
+ const parts = new Intl.DateTimeFormat("en-US", {
228
+ timeZone: tz,
229
+ year: "numeric",
230
+ month: "2-digit",
231
+ day: "2-digit",
232
+ hour: "2-digit",
233
+ minute: "2-digit",
234
+ second: "2-digit",
235
+ hour12: false
236
+ }).formatToParts(new Date(atMs));
237
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
238
+ const wallUtc = Date.UTC(
239
+ get("year"),
240
+ get("month") - 1,
241
+ get("day"),
242
+ get("hour") % 24,
243
+ get("minute"),
244
+ get("second")
245
+ );
246
+ if (!Number.isFinite(wallUtc)) return null;
247
+ return Math.round((wallUtc - atMs) / 6e4);
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+ function hoursLabel(minutes) {
253
+ const half = Math.round(minutes / 60 * 2) / 2;
254
+ return Number.isInteger(half) ? String(half) : half.toFixed(1);
255
+ }
256
+ function funFacts(listeners, serverNowMs) {
257
+ if (listeners.length < 2) return [];
258
+ const facts = [];
259
+ const placed = listeners.flatMap((l) => {
260
+ const at = tzToCoords(l.tz);
261
+ return at ? [{ l, at }] : [];
262
+ });
263
+ let bestKm = 0;
264
+ let far = null;
265
+ for (let i = 0; i < placed.length; i++) {
266
+ for (let j = i + 1; j < placed.length; j++) {
267
+ const km = haversineKm(placed[i].at, placed[j].at);
268
+ if (km > bestKm) {
269
+ bestKm = km;
270
+ far = [placed[i], placed[j]];
271
+ }
272
+ }
273
+ }
274
+ const farKm = Math.round(bestKm / 100) * 100;
275
+ if (far && farKm > 0) {
276
+ facts.push(
277
+ `${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)`
278
+ );
279
+ }
280
+ let odd = null;
281
+ for (const l of listeners) {
282
+ if (!l.tz) continue;
283
+ const clock2 = localClock(l.tz, serverNowMs);
284
+ if (!clock2) continue;
285
+ const m = clock2.minutesOfDay;
286
+ if (m < 60 || m >= 360) continue;
287
+ const dist = Math.abs(m - 240);
288
+ if (!odd || dist < odd.dist) odd = { l, label: clock2.label, dist };
289
+ }
290
+ if (odd) facts.push(`it's ${odd.label} for ${odd.l.name} in ${tzCity(odd.l.tz)}`);
291
+ const zones = new Set(listeners.map((l) => l.tz).filter((tz) => !!tz));
292
+ if (zones.size >= 2) facts.push(`${listeners.length} listening across ${zones.size} time zones`);
293
+ const offsets = listeners.flatMap((l) => {
294
+ const off = l.tz ? tzOffsetMinutes(l.tz, serverNowMs) : null;
295
+ return off === null ? [] : [{ l, off }];
296
+ });
297
+ let spread = 0;
298
+ let wide = null;
299
+ for (let i = 0; i < offsets.length; i++) {
300
+ for (let j = i + 1; j < offsets.length; j++) {
301
+ const d = Math.abs(offsets[i].off - offsets[j].off);
302
+ if (d > spread) {
303
+ spread = d;
304
+ wide = [offsets[i], offsets[j]];
305
+ }
306
+ }
307
+ }
308
+ if (wide && spread >= 120) {
309
+ facts.push(
310
+ `${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`
311
+ );
312
+ }
313
+ return facts;
314
+ }
315
+ function pickFact(listeners, serverNowMs, tick) {
316
+ const facts = funFacts(listeners, serverNowMs);
317
+ if (facts.length === 0) return "";
318
+ const n = facts.length;
319
+ return facts[(Math.trunc(tick) % n + n) % n];
320
+ }
321
+
322
+ // src/radio/overlay.ts
323
+ var ACCENT = 39;
324
+ var PINK = 213;
325
+ var RED = 196;
326
+ var WHITE = 231;
327
+ var DIM = 245;
328
+ var FAINT = 238;
329
+ var BAR_BG = 235;
330
+ var DOT_OFF = 24;
331
+ var BLINK_PERIOD_MS = 1e3;
332
+ var BLINK_ON_MS = 600;
333
+ var TICKER_MS_PER_CELL = 120;
334
+ var FACT_ROTATE_MS = 8e3;
335
+ var SEP = " /// ";
336
+ var EQ_GLYPHS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
337
+ var FALLBACK_FACTS = [
338
+ "ACCELR8 FM \u2022 broadcasting live from San Francisco",
339
+ "stay locked in \u2022 the vibe is curated",
340
+ "206 rooms, one frequency",
341
+ "founders tune in \u2022 the dial stays hot"
342
+ ];
343
+ function liveBadgeOn(tMs) {
344
+ const cycle = (tMs % BLINK_PERIOD_MS + BLINK_PERIOD_MS) % BLINK_PERIOD_MS;
345
+ return cycle < BLINK_ON_MS;
346
+ }
347
+ function tickerOffset(tMs) {
348
+ return Math.floor(Math.max(0, tMs) / TICKER_MS_PER_CELL);
349
+ }
350
+ function broadcastActive(state2) {
351
+ const b = state2.broadcast;
352
+ if (!b || !b.headline) return false;
353
+ return state2.serverNowMs < b.startedAt + b.durationMs;
354
+ }
355
+ function sanitize(s) {
356
+ return s.replace(/\u2014/g, "\u2022");
357
+ }
358
+ function tickerText(state2, tick, facts) {
359
+ if (!state2.connected) return "signal lost \u2022 re-syncing with the tower";
360
+ if (broadcastActive(state2)) {
361
+ return `\u25C7 BREAKING \u25C7 ${sanitize(state2.broadcast.headline)}`;
362
+ }
363
+ const pick = facts ?? pickFact;
364
+ try {
365
+ const fact = pick(state2.listeners, state2.serverNowMs, tick);
366
+ if (fact && fact.trim().length > 0) return sanitize(fact);
367
+ } catch {
368
+ }
369
+ const n = FALLBACK_FACTS.length;
370
+ return FALLBACK_FACTS[(tick % n + n) % n];
371
+ }
372
+ function put(cells, w, h, x, y, ch, st) {
373
+ if (x < 0 || x >= w || y < 0 || y >= h) return;
374
+ const cell = { x, y, ch };
375
+ if (st.color !== void 0) cell.color = st.color;
376
+ if (st.bg !== void 0) cell.bg = st.bg;
377
+ if (st.bold) cell.bold = true;
378
+ cells.push(cell);
379
+ }
380
+ function write(cells, w, h, x, y, text, st) {
381
+ let cx = x;
382
+ for (const ch of text) {
383
+ put(cells, w, h, cx, y, ch, st);
384
+ cx++;
385
+ }
386
+ return cx;
387
+ }
388
+ function makeBar(w, base) {
389
+ return { chs: new Array(w).fill(" "), sts: new Array(w).fill(base) };
390
+ }
391
+ function barWrite(bar, x, text, st, limitX = Infinity) {
392
+ let cx = x;
393
+ for (const ch of text) {
394
+ if (cx >= 0 && cx < bar.chs.length && cx < limitX) {
395
+ bar.chs[cx] = ch;
396
+ bar.sts[cx] = st;
397
+ }
398
+ cx++;
399
+ }
400
+ return cx;
401
+ }
402
+ function barEmit(cells, w, h, bar, y) {
403
+ if (y < 0 || y >= h) return;
404
+ for (let x = 0; x < bar.chs.length && x < w; x++) {
405
+ put(cells, w, h, x, y, bar.chs[x], bar.sts[x]);
406
+ }
407
+ }
408
+ function clip(text, max) {
409
+ const glyphs = [...text];
410
+ if (glyphs.length <= max) return text;
411
+ if (max <= 0) return "";
412
+ if (max === 1) return "\u2026";
413
+ return glyphs.slice(0, max - 1).join("") + "\u2026";
414
+ }
415
+ function badgeSegments(connected, tMs) {
416
+ if (!connected) {
417
+ return [[" \u25CC NO SIGNAL ", { color: WHITE, bg: FAINT, bold: true }]];
418
+ }
419
+ const dot = liveBadgeOn(tMs) ? ["\u25CF", { color: PINK, bg: ACCENT, bold: true }] : ["\xB7", { color: DOT_OFF, bg: ACCENT }];
420
+ return [
421
+ [" ", { bg: ACCENT }],
422
+ dot,
423
+ [" LIVE ", { color: WHITE, bg: ACCENT, bold: true }]
424
+ ];
425
+ }
426
+ function drawEq(cells, w, h, x, y, tMs) {
427
+ let cx = x;
428
+ for (let i = 0; i < 5; i++) {
429
+ const phase = tMs / 160 + i * 1.7;
430
+ const lvl = Math.round((Math.sin(phase) + 1) / 2 * (EQ_GLYPHS.length - 1));
431
+ cx = write(cells, w, h, cx, y, EQ_GLYPHS[lvl], { color: ACCENT });
432
+ }
433
+ return cx;
434
+ }
435
+ function drawTopBar(cells, w, h, tMs, state2) {
436
+ const bar = makeBar(w, { color: DIM, bg: BAR_BG });
437
+ let cx = 1;
438
+ for (const [text, st] of badgeSegments(state2.connected, tMs)) {
439
+ cx = barWrite(bar, cx, text, st);
440
+ }
441
+ let rightStart;
442
+ if (state2.connected) {
443
+ const n = state2.listeners.length;
444
+ const label = `\u25C9 ${n} listening`;
445
+ rightStart = Math.max(cx + 2, w - 1 - [...label].length);
446
+ let rx = barWrite(bar, rightStart, "\u25C9 ", { color: ACCENT, bg: BAR_BG, bold: true });
447
+ rx = barWrite(bar, rx, String(n), { color: WHITE, bg: BAR_BG, bold: true });
448
+ barWrite(bar, rx, " listening", { color: DIM, bg: BAR_BG });
449
+ } else {
450
+ const dots = ".".repeat(1 + Math.floor(Math.max(0, tMs) / 400) % 3);
451
+ rightStart = Math.max(cx + 2, w - 17);
452
+ barWrite(bar, rightStart, `reconnecting${dots}`, { color: DIM, bg: BAR_BG });
453
+ }
454
+ const limit = rightStart - 1;
455
+ let sx = cx + 2;
456
+ sx = barWrite(bar, sx, sanitize(state2.schedule.name), { color: WHITE, bg: BAR_BG, bold: true }, limit);
457
+ if (state2.connected) {
458
+ sx = barWrite(bar, sx, " \u2022 ", { color: DIM, bg: BAR_BG }, limit);
459
+ sx = barWrite(bar, sx, "ON AIR ", { color: ACCENT, bg: BAR_BG, bold: true }, limit);
460
+ barWrite(bar, sx, uptimeLabel(state2.schedule.epoch, state2.serverNowMs), { color: WHITE, bg: BAR_BG }, limit);
461
+ }
462
+ barEmit(cells, w, h, bar, 0);
463
+ }
464
+ function drawLowerThird(cells, w, h, tMs, state2) {
465
+ const yChip = h - 4;
466
+ const yTitle = h - 3;
467
+ const yBar = h - 2;
468
+ const np = state2.nowPlaying;
469
+ if (!np) {
470
+ const cx2 = write(cells, w, h, 1, yChip, " STANDBY ", { color: WHITE, bg: FAINT, bold: true });
471
+ drawEq(cells, w, h, cx2 + 1, yChip, tMs);
472
+ write(cells, w, h, 1, yTitle, clip("ACCELR8 FM \u2022 curating the vibe", Math.max(0, w - 2)), { color: DIM });
473
+ return;
474
+ }
475
+ const cx = write(cells, w, h, 1, yChip, " NOW PLAYING ", { color: WHITE, bg: ACCENT, bold: true });
476
+ drawEq(cells, w, h, cx + 1, yChip, tMs);
477
+ let tx = write(cells, w, h, 1, yTitle, "\u25B6 ", { color: ACCENT, bold: true });
478
+ tx = write(cells, w, h, tx, yTitle, clip(sanitize(np.item.title), Math.max(0, w - 1 - tx)), { color: WHITE, bold: true });
479
+ if (np.item.artist) {
480
+ write(cells, w, h, tx, yTitle, clip(` \u2022 ${sanitize(np.item.artist)}`, Math.max(0, w - 1 - tx)), { color: DIM });
481
+ }
482
+ const times = ` ${clock(np.offsetMs)} / ${clock(np.item.durationMs)}`;
483
+ const barW = Math.min(26, Math.max(0, w - 2 - times.length));
484
+ let px = 1;
485
+ if (barW >= 6) {
486
+ const frac = np.item.durationMs > 0 ? Math.min(1, Math.max(0, np.offsetMs / np.item.durationMs)) : 0;
487
+ const filled = Math.round(frac * barW);
488
+ px = write(cells, w, h, px, yBar, "\u2588".repeat(filled), { color: ACCENT });
489
+ px = write(cells, w, h, px, yBar, "\u2591".repeat(barW - filled), { color: FAINT });
490
+ }
491
+ write(cells, w, h, px, yBar, times, { color: DIM });
492
+ }
493
+ function drawTicker(cells, w, h, y, tMs, text, state2, withChip) {
494
+ if (y < 0 || y >= h) return;
495
+ const bar = makeBar(w, { color: WHITE, bg: BAR_BG });
496
+ let startX = 0;
497
+ if (withChip) {
498
+ const breaking = state2.connected && broadcastActive(state2);
499
+ const chip = breaking ? " \u25C6 BREAKING " : " \u25C6 A8 WIRE ";
500
+ startX = barWrite(bar, 0, chip, { color: WHITE, bg: breaking ? RED : ACCENT, bold: true }) + 1;
501
+ }
502
+ const unit = [...text + SEP];
503
+ if (unit.length > 0) {
504
+ const off = tickerOffset(tMs) % unit.length;
505
+ for (let x = startX; x < w; x++) {
506
+ const ch = unit[(off + (x - startX)) % unit.length];
507
+ const st = ch === "\u25C7" ? { color: ACCENT, bg: BAR_BG, bold: true } : ch === "/" ? { color: FAINT, bg: BAR_BG } : { color: WHITE, bg: BAR_BG };
508
+ if (x >= 0 && x < bar.chs.length) {
509
+ bar.chs[x] = ch;
510
+ bar.sts[x] = st;
511
+ }
512
+ }
513
+ }
514
+ barEmit(cells, w, h, bar, y);
515
+ }
516
+ function renderOverlay(ctx, opts) {
517
+ const { w, h, tMs, state: state2 } = ctx;
518
+ const cells = [];
519
+ if (w <= 0 || h <= 0) return cells;
520
+ const tick = Math.floor(Math.max(0, tMs) / FACT_ROTATE_MS);
521
+ const marquee = tickerText(state2, tick, opts?.facts);
522
+ const tiny = w < 40 || h < 12;
523
+ if (tiny) {
524
+ let cx = 1;
525
+ for (const [text, st] of badgeSegments(state2.connected, tMs)) {
526
+ cx = write(cells, w, h, cx, 0, text, st);
527
+ }
528
+ if (h >= 2) drawTicker(cells, w, h, h - 1, tMs, marquee, state2, false);
529
+ return cells;
530
+ }
531
+ drawTopBar(cells, w, h, tMs, state2);
532
+ if (state2.connected) drawLowerThird(cells, w, h, tMs, state2);
533
+ drawTicker(cells, w, h, h - 1, tMs, marquee, state2, true);
534
+ return cells;
535
+ }
536
+
537
+ // src/radio/audio.ts
538
+ import { spawn as spawn2 } from "node:child_process";
539
+ function nextAudioAction(sched, serverNowMs, mem, driftBudgetMs = 1500) {
540
+ const pos = nowPlaying(sched, serverNowMs);
541
+ if (!pos || !pos.item.url) return { kind: "none" };
542
+ const trackChanged = pos.item.id !== mem.playingTrackId;
543
+ if (trackChanged || !mem.procAlive) {
544
+ return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
545
+ }
546
+ const impliedOffset = mem.spawnedOffsetMs + (serverNowMs - mem.spawnedAtServerMs);
547
+ if (Math.abs(impliedOffset - pos.offsetMs) > driftBudgetMs) {
548
+ return { kind: "play", track: pos.item, offsetMs: pos.offsetMs };
549
+ }
550
+ return { kind: "none" };
551
+ }
552
+ function ffplayArgs(url, offsetMs) {
553
+ const args = ["-hide_banner", "-loglevel", "error", "-nodisp", "-autoexit"];
554
+ if (offsetMs > 0) args.push("-ss", (offsetMs / 1e3).toFixed(3));
555
+ args.push("-i", url);
556
+ return args;
557
+ }
558
+ var SyncedAudio = class {
559
+ proc = null;
560
+ mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
561
+ opts = null;
562
+ timer = null;
563
+ start(opts) {
564
+ this.stop();
565
+ this.opts = opts;
566
+ this.tick();
567
+ this.timer = setInterval(() => this.tick(), 1e3);
568
+ }
569
+ /** Swap in a new schedule (worker pushed a new playlist) without a restart. */
570
+ setSchedule(sched) {
571
+ if (this.opts) this.opts.schedule = sched;
572
+ }
573
+ tick() {
574
+ if (!this.opts) return;
575
+ const action = nextAudioAction(this.opts.schedule, this.opts.serverNow(), this.mem);
576
+ if (action.kind === "play") this.spawn(action.track, action.offsetMs);
577
+ }
578
+ spawn(track, offsetMs) {
579
+ if (!this.opts) return;
580
+ this.killProc();
581
+ const proc = spawn2(this.opts.ffplayPath, ffplayArgs(track.url, offsetMs), { stdio: ["ignore", "ignore", "pipe"] });
582
+ this.proc = proc;
583
+ this.mem = { playingTrackId: track.id, spawnedAtServerMs: this.opts.serverNow(), spawnedOffsetMs: offsetMs, procAlive: true };
584
+ this.opts.onTrack?.(track);
585
+ let err = "";
586
+ proc.stderr?.on("data", (d) => {
587
+ err = (err + d.toString()).slice(-200);
588
+ });
589
+ proc.on("error", (e) => {
590
+ this.mem.procAlive = false;
591
+ this.opts?.onError?.(e.message);
592
+ });
593
+ proc.on("exit", () => {
594
+ if (this.proc !== proc) return;
595
+ this.mem.procAlive = false;
596
+ if (err) this.opts?.onError?.(err.split("\n").pop() || err);
597
+ });
598
+ }
599
+ killProc() {
600
+ if (this.proc) {
601
+ try {
602
+ this.proc.kill("SIGKILL");
603
+ } catch {
604
+ }
605
+ this.proc = null;
606
+ }
607
+ }
608
+ get nowTrackId() {
609
+ return this.mem.playingTrackId;
610
+ }
611
+ stop() {
612
+ if (this.timer) {
613
+ clearInterval(this.timer);
614
+ this.timer = null;
615
+ }
616
+ this.killProc();
617
+ this.mem = { playingTrackId: null, spawnedAtServerMs: 0, spawnedOffsetMs: 0, procAlive: false };
618
+ }
619
+ };
620
+
621
+ // src/radio/app.ts
622
+ var term = tk.terminal;
623
+ var ScreenBuffer = tk.ScreenBuffer;
624
+ var RENDER_FPS = 20;
625
+ var VIDEO_FPS = 15;
626
+ var SEED_INPUT = "lavfi:mandelbrot=size=480x270:rate=15:maxiter=800";
627
+ var screen;
628
+ var video = null;
629
+ var audio = null;
630
+ var lastFrame = null;
631
+ var frameW = 0;
632
+ var frameH = 0;
633
+ var renderTimer = null;
634
+ var running = false;
635
+ var startedAt = Date.now();
636
+ var W = () => Math.max(20, term.width || process.stdout.columns || 100);
637
+ 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();
660
+ return {
661
+ schedule,
662
+ serverNowMs,
663
+ nowPlaying: nowPlaying(schedule, serverNowMs),
664
+ nowShowing: nowShowing(schedule, serverNowMs),
665
+ listeners,
666
+ you,
667
+ broadcast: null,
668
+ connected: true
669
+ };
670
+ }
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: () => {
689
+ }
690
+ });
691
+ if (tools.ffplayPath) {
692
+ 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
+ });
699
+ }
700
+ }
701
+ function makeScreen() {
702
+ screen = new ScreenBuffer({ dst: term, width: W(), height: H() });
703
+ video?.resize(W(), H());
704
+ }
705
+ function blitVideo() {
706
+ const w = screen.width, h = screen.height;
707
+ if (lastFrame && frameW === w && frameH === h) {
708
+ for (let y = 0; y < h; y++) {
709
+ for (let x = 0; x < w; x++) {
710
+ const c = lastFrame[y * w + x];
711
+ if (c) screen.put({ x, y, attr: { color: c.color, bgColor: c.color }, dx: 1, dy: 0, wrap: false }, c.ch);
712
+ }
713
+ }
714
+ } else {
715
+ for (let y = 0; y < h; y++)
716
+ screen.put({ x: 0, y, attr: { color: 233, bgColor: 233 }, dx: 1, dy: 0, wrap: false }, " ".repeat(w));
717
+ }
718
+ }
719
+ function videoColorAt(x, y) {
720
+ if (lastFrame && frameW === screen.width && x < frameW && y < frameH) {
721
+ const c = lastFrame[y * frameW + x];
722
+ if (c) return c.color;
723
+ }
724
+ return 233;
725
+ }
726
+ function blitOverlay(cells) {
727
+ const w = screen.width, h = screen.height;
728
+ for (const c of cells) {
729
+ if (c.x < 0 || c.y < 0 || c.x >= w || c.y >= h) continue;
730
+ const bg = c.bg ?? videoColorAt(c.x, c.y);
731
+ 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 || " ");
732
+ }
733
+ }
734
+ function renderFrame() {
735
+ if (!running) return;
736
+ const you = state.you;
737
+ const serverNowMs = Date.now();
738
+ state = { ...state, serverNowMs, nowPlaying: nowPlaying(state.schedule, serverNowMs), nowShowing: nowShowing(state.schedule, serverNowMs) };
739
+ blitVideo();
740
+ const cells = renderOverlay({ w: screen.width, h: screen.height, tMs: Date.now() - startedAt, state });
741
+ blitOverlay(cells);
742
+ if (screen.dst) screen.draw({ delta: true });
743
+ }
744
+ function teardown() {
745
+ running = false;
746
+ if (renderTimer) {
747
+ clearInterval(renderTimer);
748
+ renderTimer = null;
749
+ }
750
+ video?.stop();
751
+ video = null;
752
+ audio?.stop();
753
+ audio = null;
754
+ try {
755
+ term.grabInput(false);
756
+ term.hideCursor(false);
757
+ term.fullscreen(false);
758
+ term.styleReset();
759
+ } catch {
760
+ }
761
+ }
762
+ function quit() {
763
+ teardown();
764
+ process.exit(0);
765
+ }
766
+ function runRadio(opts) {
767
+ const you = { id: "you", name: opts.name || "you", tz: Intl.DateTimeFormat().resolvedOptions().timeZone };
768
+ state = demoState(you);
769
+ const die = (err) => {
770
+ teardown();
771
+ console.error("accelr8 radio crashed:", err instanceof Error ? err.stack || err.message : err);
772
+ process.exit(1);
773
+ };
774
+ process.on("uncaughtException", die);
775
+ process.on("unhandledRejection", die);
776
+ term.fullscreen(true);
777
+ term.hideCursor();
778
+ term.grabInput(true);
779
+ makeScreen();
780
+ startVideo(opts);
781
+ running = true;
782
+ term.on("key", (name) => {
783
+ if (name === "CTRL_C" || name === "q" || name === "ESCAPE") return quit();
784
+ });
785
+ term.on("resize", () => makeScreen());
786
+ process.on("SIGINT", quit);
787
+ process.on("SIGTERM", quit);
788
+ renderTimer = setInterval(renderFrame, Math.round(1e3 / RENDER_FPS));
789
+ renderFrame();
790
+ }
791
+ export {
792
+ runRadio
793
+ };