-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathradioModule.js
More file actions
462 lines (422 loc) · 18.8 KB
/
Copy pathradioModule.js
File metadata and controls
462 lines (422 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
// =========================================================
// RADIO MODULE (radioModule.js) — the VIBE MACHINE
// A Winamp-flavoured player for Mikel's own soundtrack: animated spectrum
// analyzer (attack/decay bars + peak-hold caps), position bar, scrolling
// title, live-computed bitrate, and a true-shuffle playlist that loops
// forever. VLC (rc interface) does the actual audio.
//
// Playback model: one file at a time (clear + add). Order is a reshuffled
// queue so it's truly random and cycles back to the top forever. End-of-track is
// detected by polling VLC's LABELED `status` reply for `( state stopped )` — the
// bare `is_playing` integer it replaced got mis-correlated at EOF (VLC replies
// empty to get_time/get_length and glues is_playing's 0 onto the prompt), which
// left auto-advance stuck and the music silent after a track ended.
// =========================================================
const blessed = require('blessed');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
function createRadio(grid, screen, logBox) {
const radioBox = grid.set(0, 0, 2, 4, blessed.box, {
label: ' RADIO (SECURE) ',
border: { type: 'line' },
tags: true,
style: { fg: 'white', border: { fg: 'cyan' }, bg: 'black' },
content: '\n BOOTING VIBE MACHINE...'
});
const musicDir = path.join(__dirname, '..', 'music');
const vlcPath = '/Applications/VLC.app/Contents/MacOS/VLC';
// VLC rc volume scale is 0-256 (256 = 100%). Normal playback sits at 180; a
// broadcast DUCKS it to a soft bed so anchors are heard over it. The duck used
// to be brutal (55 ≈ 30%); the music is the whole vibe, so keep it present.
const NORMAL_VOLUME = 180;
const DUCK_VOLUME = 160; // gentle, smooth duck — keep the music well present
let playlist = [];
let order = []; // shuffled indices into playlist
let orderPos = 0;
let currentTrackIndex = 0;
let isPlaying = false;
let isPaused = false;
let vlcProcess = null;
let hasStartedPlayback = false;
let marqueeOffset = 0;
let tick = 0;
let startedAt = 0; // ms timestamp of last playCurrent (start-up grace)
let currentTimeSec = 0;
let currentLengthSec = 0;
// VLC's playback state, read from the LABELED `status` reply (`( state playing|
// stopped|paused )`) — NOT from the bare `is_playing` integer. The old is_playing
// approach rode in a 3-wide positional reply queue, but at the exact moment a
// track ends VLC replies EMPTY to get_time/get_length and glues is_playing's `0`
// onto the prompt as `> 0` — so the queue desynced and the regex rejected it,
// leaving the state stuck at "playing" forever and auto-advance never firing
// (the "music doesn't loop between tracks" bug). `status` is unambiguous.
let vlcState = 'loading'; // 'loading' | 'playing' | 'paused' | 'stopped'
let sawPlaying = false; // did VLC ever report THIS track as playing?
let advancing = false;
// Skip-storm guard: if files can't be decoded (e.g. a future .mid drop with no
// VLC plugin), VLC reports not-playing right after load and maybeAutoNext would
// cycle the whole playlist forever. Count near-instant "ends" and give up once
// we've tried every track; any user action (N/R/P) clears it and retries.
let autoNextFails = 0;
let playbackStalled = false;
// VLC rc replies to get_time / get_length / is_playing are all bare integers,
// returned in the order asked. Track what each pending integer is for.
let pendingReplies = [];
// =======================================================
// PLAYLIST + SHUFFLE
// =======================================================
function loadPlaylist() {
try {
if (!fs.existsSync(musicDir)) fs.mkdirSync(musicDir);
playlist = fs.readdirSync(musicDir).filter(f =>
/\.(mp3|wav|mid)$/i.test(f));
reshuffle();
currentTrackIndex = order.length ? order[0] : 0;
if (logBox) logBox.log(`RADIO: FOUND ${playlist.length} TRACKS — SHUFFLED`);
} catch (err) {
if (logBox) logBox.log('RADIO ERROR: FAILED TO READ MUSIC FOLDER');
}
}
// Fisher-Yates. Keeps the just-played track off the top of a fresh shuffle so
// a reshuffle never repeats the song that was playing.
function reshuffle() {
order = playlist.map((_, i) => i);
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[order[i], order[j]] = [order[j], order[i]];
}
if (order.length > 1 && order[0] === currentTrackIndex) {
[order[0], order[order.length - 1]] = [order[order.length - 1], order[0]];
}
orderPos = 0;
}
// Advance the shuffle queue; reshuffle + wrap when it's exhausted → loops forever.
function advance() {
if (!playlist.length) return;
orderPos++;
if (orderPos >= order.length) reshuffle(); // cycle back to the top, re-randomised
currentTrackIndex = order[orderPos] || 0;
playCurrent();
}
function safeBaseName(f) {
return String(f || '').replace(/\.(mp3|wav|mid)$/i, '').trim();
}
function trackNameAt(offset) {
if (!playlist.length) return '---';
const pos = orderPos + offset;
if (pos < 0 || pos >= order.length) return '(reshuffle)';
return safeBaseName(playlist[order[pos]]);
}
function getCurrentTrackName() {
return playlist.length ? safeBaseName(playlist[currentTrackIndex]) : 'NO MEDIA';
}
// =======================================================
// VLC PLUMBING
// =======================================================
function sendVlc(cmd) {
if (!vlcProcess || !vlcProcess.stdin) return;
try { vlcProcess.stdin.write(cmd + '\n'); } catch (_) {}
}
function query(cmd, type) { pendingReplies.push(type); sendVlc(cmd); }
function resetTrackTimers() {
currentTimeSec = 0;
currentLengthSec = 0;
vlcState = 'loading'; // not 'stopped' — a fresh track must not read as ended
sawPlaying = false; // fresh track — hasn't been confirmed playing yet
pendingReplies = [];
}
function initPlayer() {
if (!fs.existsSync(vlcPath)) {
radioBox.setContent('\n FATAL: VLC NOT FOUND\n brew install --cask vlc');
if (logBox) logBox.log('RADIO FATAL: VLC NOT INSTALLED');
screen.render();
return;
}
vlcProcess = spawn(vlcPath, ['-I', 'rc', '--no-video', '--quiet'], {
stdio: ['pipe', 'pipe', 'ignore']
});
vlcProcess.on('error', () => {
if (logBox) logBox.log('RADIO FATAL: VLC DAEMON FAILED TO START');
});
vlcProcess.stdout.on('data', (chunk) => {
const lines = String(chunk).split(/\r?\n/);
for (const raw of lines) {
// Strip VLC's rc prompt ("> ") which can glue onto the front of a reply
// (e.g. "> 0" right after the empty get_time/get_length replies at EOF).
const line = raw.replace(/^[>\s]+/, '').trim();
if (!line) continue;
// Labeled state from `status` — the robust end-of-track signal.
const st = line.match(/^\(\s*state\s+(\w+)\s*\)/);
if (st) { vlcState = st[1]; if (vlcState === 'playing') sawPlaying = true; continue; }
// Bare integers answer the positionally-queued get_time / get_length.
if (!pendingReplies.length || !/^\d+$/.test(line)) continue;
const type = pendingReplies.shift();
if (type === 'time') currentTimeSec = Number(line);
else if (type === 'length') currentLengthSec = Number(line);
}
});
setTimeout(() => { sendVlc('volume ' + NORMAL_VOLUME); renderState(); }, 500);
}
function playCurrent() {
if (!vlcProcess || !playlist.length) return;
sendVlc('clear');
sendVlc('add ' + path.join(musicDir, playlist[currentTrackIndex]));
isPlaying = true;
isPaused = false;
hasStartedPlayback = true;
advancing = false;
marqueeOffset = 0;
startedAt = Date.now();
resetTrackTimers();
if (logBox) logBox.log('RADIO: NOW PLAYING ' + playlist[currentTrackIndex]);
renderState();
}
function playResume() {
if (!vlcProcess || !playlist.length) return;
autoNextFails = 0;
if (playbackStalled) { playbackStalled = false; playCurrent(); return; }
if (!hasStartedPlayback) { playCurrent(); return; }
if (isPaused) {
sendVlc('pause'); isPlaying = true; isPaused = false;
// If a broadcast is talking right now, come back at the ducked level rather
// than blasting full volume over the anchor; unduck restores it after.
if (duckActive) sendVlc('volume ' + DUCK_VOLUME);
if (logBox) logBox.log('RADIO: RESUMED');
renderState();
}
}
function pauseOnly() {
if (!vlcProcess || !playlist.length || !isPlaying || isPaused) return;
sendVlc('pause'); isPaused = true; isPlaying = true;
if (logBox) logBox.log('RADIO: PAUSED');
renderState();
}
function nextTrack() {
if (!playlist.length) return;
autoNextFails = 0; playbackStalled = false;
advancing = true;
if (logBox) logBox.log('RADIO: NEXT TRACK');
advance();
}
function shuffleTrack() {
if (!playlist.length) return;
autoNextFails = 0; playbackStalled = false;
advancing = true;
reshuffle();
currentTrackIndex = order[0] || 0;
if (logBox) logBox.log('RADIO: RESHUFFLED');
playCurrent();
}
// poll VLC for clock + playing state (one ordered batch)
function pollVlc() {
if (!vlcProcess || !hasStartedPlayback || isPaused) return;
// Start every 1s batch clean. get_time / get_length reply as bare integers
// correlated only by arrival order, so a dropped/extra reply would desync
// them; clearing the queue caps misalignment at one poll and self-heals.
// State comes from `status` (LABELED `( state … )`), so it can't be confused
// with the integer replies and survives the EOF case where get_time/get_length
// come back empty — this is what makes auto-advance reliable.
pendingReplies = [];
query('get_time', 'time');
query('get_length', 'length');
sendVlc('status');
}
// End-of-track: VLC reports is_playing=0 once a single-item playlist finishes.
// Decide "ended vs never-decoded" by STATE, not by elapsed time: a track that
// VLC ever reported as playing (sawPlaying) is a genuine end no matter how long
// it ran — so a real song can NEVER be miscounted as a dead file. Only a track
// that never reported playing within a generous window counts toward the skip-
// storm guard. (The old time-only check misread a slow load gap as "undecodable"
// and, after ~one playlist of those false hits, stalled the radio for good —
// the "goes quiet after all the tracks play" bug.)
function maybeAutoNext() {
if (!isPlaying || isPaused || advancing || playbackStalled) return;
if (Date.now() - startedAt < 2500) return; // start-up grace (load gap)
if (vlcState !== 'stopped') return; // still playing/loading
if (sawPlaying) {
autoNextFails = 0; // it really played — genuine end
} else {
if (Date.now() - startedAt < 6000) return; // give a slow load more time
if (++autoNextFails > playlist.length) { // never decoded across the whole list
playbackStalled = true;
if (logBox) logBox.log('RADIO: NO PLAYABLE TRACKS — RETRYING IN 30S ([N] RETRIES NOW)');
// Self-recover: don't stay silent forever. Retry from the top after a
// pause in case it was transient (e.g. VLC hiccup, not truly dead files).
setTimeout(() => {
if (playbackStalled) { playbackStalled = false; autoNextFails = 0; playCurrent(); }
}, 30000);
return;
}
}
advancing = true;
if (logBox) logBox.log('RADIO: TRACK ENDED — AUTO NEXT');
advance();
}
// =======================================================
// VISUALS — Winamp-style
// =======================================================
function formatClock(s) {
const safe = Math.max(0, Number(s) || 0);
return String(Math.floor(safe / 60)).padStart(2, '0') + ':' + String(safe % 60).padStart(2, '0');
}
function getMarquee(text, width) {
const clean = String(text || '');
if (clean.length <= width) return clean.padEnd(width, ' ');
const spacer = ' • ';
const loop = clean + spacer + clean + spacer;
const start = marqueeOffset % (clean.length + spacer.length);
return loop.slice(start, start + width).padEnd(width, ' ');
}
// --- spectrum analyzer state ---
let bars = null, nBars = 0;
function ensureSpectrum(n) {
if (nBars !== n) { nBars = n; bars = new Float32Array(n); }
}
function updateSpectrum(active) {
if (!bars) return;
for (let i = 0; i < nBars; i++) {
let target = 0;
if (active) {
// skew toward low values with occasional spikes; arch the spectrum so the
// low-mids carry more energy (reads like bass) and the highs taper.
target = Math.random() * Math.random();
target *= 0.55 + 0.45 * Math.sin((i / nBars) * Math.PI);
if (Math.random() < 0.06) target = Math.min(1, target + Math.random() * 0.5);
}
// fast attack, slow decay (classic analyzer feel)
const k = target > bars[i] ? 0.55 : 0.20;
bars[i] += (target - bars[i]) * k;
}
}
const PART = ['▁', '▂', '▃', '▄', '▅', '▆', '▇'];
function renderSpectrum(rows, width) {
ensureSpectrum(width);
const out = [];
for (let row = 0; row < rows; row++) {
const rowFromBottom = rows - 1 - row;
const frac = rows > 1 ? rowFromBottom / (rows - 1) : 0;
// Purple/magenta family (matches the LOCAL TELEMETRY font). Ramps mainly by
// BRIGHTNESS — dark purple low, light lavender high — so bar heights read
// clearly regardless of hue (owner is red/green/blue/purple colour-blind).
const col = frac > 0.82 ? '#f3b6ff' : frac > 0.58 ? '#dd86ff'
: frac > 0.32 ? '#b95cf2' : '#8a3df0';
let s = '';
for (let i = 0; i < width; i++) {
const barH = bars[i] * rows;
const fill = barH - rowFromBottom; // how much of this cell is filled
if (fill >= 1) {
s += `{${col}-fg}█{/}`;
} else if (fill > 0.06) {
s += `{${col}-fg}${PART[Math.min(6, Math.max(0, Math.round(fill * 8) - 1))]}{/}`;
} else {
s += ' ';
}
}
out.push(s);
}
return out;
}
function progressBar(width) {
const frac = currentLengthSec > 0 ? Math.min(1, currentTimeSec / currentLengthSec) : 0;
const filled = Math.round(frac * width);
return `{#39e6ff-fg}${'█'.repeat(filled)}{/}{gray-fg}${'░'.repeat(Math.max(0, width - filled))}{/}`;
}
function bitrateLabel() {
if (!playlist.length) return '';
try {
const f = playlist[currentTrackIndex];
const ext = (f.split('.').pop() || '').toUpperCase();
const size = fs.statSync(path.join(musicDir, f)).size;
if (currentLengthSec > 0) {
return `${ext} · ${Math.round(size * 8 / currentLengthSec / 1000)} kbps · STEREO`;
}
return `${ext} · STEREO`;
} catch (_) { return ''; }
}
function renderState() {
const innerW = Math.max(24, (radioBox.width || 70) - 2);
const innerH = Math.max(5, (radioBox.height || 12) - 2);
if (!playlist.length) {
radioBox.setContent('\n {gray-fg}NO MEDIA — drop audio into the music/ folder{/}');
screen.render();
return;
}
const active = isPlaying && !isPaused;
let status = '{yellow-fg}STOPPED{/}';
if (isPlaying && !isPaused) status = '{#33d14a-fg}PLAYING{/}';
if (isPlaying && isPaused) status = '{yellow-fg}PAUSED{/}';
const onAir = active ? '{red-fg}●{/}' : '{white-fg}○{/}';
// line 0: status + scrolling title + clock
const clock = `${formatClock(currentTimeSec)} / ${currentLengthSec > 0 ? formatClock(currentLengthSec) : '--:--'}`;
const titleW = Math.max(10, innerW - 2 - 12 - clock.length - 6);
const head = ` ${onAir} ${status} {cyan-fg}NOW:{/} ${getMarquee(getCurrentTrackName(), titleW)} {gray-fg}${clock}{/}`;
// bottom fixed lines: progress, format, NXT (own line), controls
const prog = ` ${progressBar(innerW - 2)}`;
const info = ` {gray-fg}${bitrateLabel()}{/}`;
const nxtName = trackNameAt(1);
const nxtLine = ` {white-fg}NXT:{/} {cyan-fg}${nxtName.slice(0, Math.max(6, innerW - 7))}{/}`;
const controls = ` {white-fg}[P]{/}PLAY {white-fg}[O]{/}PAUSE {white-fg}[N]{/}NEXT {white-fg}[R]{/}SHUFFLE`;
// spectrum fills whatever rows are left above the 4 footer lines
const specRows = Math.max(2, innerH - 5);
updateSpectrum(active);
const spec = renderSpectrum(specRows, innerW);
const out = [head, ...spec, prog, info, nxtLine, controls].slice(0, innerH);
radioBox.setContent(out.join('\n'));
screen.render();
}
// =======================================================
// KEYS + LOOPS
// =======================================================
screen.key(['p', 'P'], () => playResume());
screen.key(['o', 'O'], () => pauseOnly());
screen.key(['n', 'N'], () => nextTrack());
screen.key(['r', 'R'], () => shuffleTrack());
screen.key(['escape', 'C-c', 'q'], () => { if (vlcProcess) vlcProcess.kill(); });
// ~9fps drives the analyzer + marquee + clock paint
const renderTimer = setInterval(() => {
if (isPlaying && !isPaused && tick % 3 === 0) marqueeOffset += 1;
tick += 1;
try { renderState(); } catch (_) {}
}, 110);
const pollTimer = setInterval(() => { try { pollVlc(); } catch (_) {} }, 1000);
const autoNextTimer = setInterval(() => { try { maybeAutoNext(); } catch (_) {} }, 700);
loadPlaylist();
initPlayer();
// Let a spoken broadcast DUCK the music to a soft bed, then bring it back. The
// music never pauses. Only restores if WE ducked (and not while paused/stopped).
let duckActive = false;
function duckForBroadcast() {
if (isPlaying && !isPaused) {
duckActive = true;
sendVlc('volume ' + DUCK_VOLUME);
if (logBox) logBox.log('RADIO: DUCKED UNDER BROADCAST');
} else {
duckActive = false;
}
}
function unduckAfterBroadcast() {
if (duckActive) {
duckActive = false;
sendVlc('volume ' + NORMAL_VOLUME);
if (logBox) logBox.log('RADIO: VOLUME RESTORED');
}
}
// Explicit teardown for app.js's exit handler. Previously VLC was only killed
// by the radio's OWN escape/q key handler, which worked solely because it was
// registered before app.js's — fragile, and it left an orphaned VLC playing on
// any path that didn't fire that exact handler (e.g. SIGTERM / closed terminal).
function stop() {
try { clearInterval(renderTimer); } catch (_) {}
try { clearInterval(pollTimer); } catch (_) {}
try { clearInterval(autoNextTimer); } catch (_) {}
if (vlcProcess) { try { vlcProcess.kill(); } catch (_) {} vlcProcess = null; }
}
return {
playCurrent, // app.js triggers startup playback (already shuffled)
duckForBroadcast,
unduckAfterBroadcast,
stop
};
}
module.exports = { createRadio };