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.
- package/dist/chunks/{app-VQ33JVDI.js → app-OT4DOHQ7.js} +594 -63
- package/dist/chunks/{chunk-TW4QNNNT.js → chunk-SK7QPMLG.js} +1 -1
- package/dist/chunks/client-RZTYTSZL.js +4098 -0
- package/dist/chunks/{server-VWQ2NYRN.js → server-N6AKCMYG.js} +1 -1
- package/dist/cli.js +5 -4
- package/package.json +2 -2
- package/dist/chunks/client-6SYGGJ7L.js +0 -4035
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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(
|
|
664
|
-
nowShowing: nowShowing(
|
|
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:
|
|
1090
|
+
connected: false
|
|
669
1091
|
};
|
|
670
1092
|
}
|
|
671
|
-
var
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
|
737
|
-
|
|
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
|
|
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
|
|
768
|
-
|
|
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
|
}
|