bbgfm 0.1.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/app.d.ts +11 -0
- package/dist/app.js +82 -0
- package/dist/components/ChannelList.d.ts +14 -0
- package/dist/components/ChannelList.js +14 -0
- package/dist/components/Header.d.ts +7 -0
- package/dist/components/Header.js +16 -0
- package/dist/components/Player.d.ts +18 -0
- package/dist/components/Player.js +29 -0
- package/dist/components/SpectrumVisualizer.d.ts +19 -0
- package/dist/components/SpectrumVisualizer.js +111 -0
- package/dist/components/StatusBar.d.ts +7 -0
- package/dist/components/StatusBar.js +8 -0
- package/dist/data/channels.d.ts +16 -0
- package/dist/data/channels.js +25 -0
- package/dist/hooks/usePlayer.d.ts +19 -0
- package/dist/hooks/usePlayer.js +189 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +46 -0
- package/package.json +28 -0
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: ink render/useInput/useApp, all components, usePlayer hook, channel data
|
|
3
|
+
* [OUTPUT]: App root component — layout + state machine + keyboard handler
|
|
4
|
+
* [POS]: Central orchestrator assembling Header, ChannelList, Player, StatusBar
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
interface Props {
|
|
8
|
+
initialChannel?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function App({ initialChannel }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* [INPUT]: ink render/useInput/useApp, all components, usePlayer hook, channel data
|
|
4
|
+
* [OUTPUT]: App root component — layout + state machine + keyboard handler
|
|
5
|
+
* [POS]: Central orchestrator assembling Header, ChannelList, Player, StatusBar
|
|
6
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useEffect, useRef } from 'react';
|
|
9
|
+
import { Box, useInput, useApp } from 'ink';
|
|
10
|
+
import { CHANNELS, DEFAULT_CHANNEL_ID } from './data/channels.js';
|
|
11
|
+
import { usePlayer } from './hooks/usePlayer.js';
|
|
12
|
+
import { Header } from './components/Header.js';
|
|
13
|
+
import { ChannelList } from './components/ChannelList.js';
|
|
14
|
+
import { Player } from './components/Player.js';
|
|
15
|
+
import { StatusBar } from './components/StatusBar.js';
|
|
16
|
+
export function App({ initialChannel }) {
|
|
17
|
+
const { exit } = useApp();
|
|
18
|
+
const player = usePlayer();
|
|
19
|
+
// Stable refs for player functions — useInput always gets the latest
|
|
20
|
+
const playerRef = useRef(player);
|
|
21
|
+
playerRef.current = player;
|
|
22
|
+
const defaultIndex = CHANNELS.findIndex(c => c.id === (initialChannel ?? DEFAULT_CHANNEL_ID));
|
|
23
|
+
const [selectedIndex, setSelectedIndex] = useState(Math.max(0, defaultIndex));
|
|
24
|
+
const selectedRef = useRef(selectedIndex);
|
|
25
|
+
selectedRef.current = selectedIndex;
|
|
26
|
+
// Auto-play default channel on mount
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const channel = CHANNELS[selectedRef.current];
|
|
29
|
+
if (channel)
|
|
30
|
+
playerRef.current.play(channel);
|
|
31
|
+
}, []);
|
|
32
|
+
// Helper: switch channel and auto-play
|
|
33
|
+
const switchAndPlay = (nextIndex) => {
|
|
34
|
+
setSelectedIndex(nextIndex);
|
|
35
|
+
selectedRef.current = nextIndex;
|
|
36
|
+
const channel = CHANNELS[nextIndex];
|
|
37
|
+
if (channel)
|
|
38
|
+
playerRef.current.play(channel);
|
|
39
|
+
};
|
|
40
|
+
// Keyboard bindings — all reads go through refs, zero stale closures
|
|
41
|
+
useInput((input, key) => {
|
|
42
|
+
const p = playerRef.current;
|
|
43
|
+
// Quit
|
|
44
|
+
if (input === 'q') {
|
|
45
|
+
p.stop().then(() => exit());
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Navigate + auto-play
|
|
49
|
+
if (input === 'k' || key.upArrow) {
|
|
50
|
+
const cur = selectedRef.current;
|
|
51
|
+
switchAndPlay(cur > 0 ? cur - 1 : CHANNELS.length - 1);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (input === 'j' || key.downArrow) {
|
|
55
|
+
const cur = selectedRef.current;
|
|
56
|
+
switchAndPlay(cur < CHANNELS.length - 1 ? cur + 1 : 0);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Toggle play/stop
|
|
60
|
+
if (input === ' ') {
|
|
61
|
+
if (p.status === 'playing' || p.status === 'connecting') {
|
|
62
|
+
p.stop();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const channel = CHANNELS[selectedRef.current];
|
|
66
|
+
if (channel)
|
|
67
|
+
p.play(channel);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Volume
|
|
72
|
+
if (input === '+' || input === '=') {
|
|
73
|
+
p.adjustVolume(5);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (input === '-') {
|
|
77
|
+
p.adjustVolume(-5);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Header, {}), _jsx(Player, { channel: player.channel, status: player.status, volume: player.volume, audioLevelRef: player.audioLevelRef, error: player.error }), _jsx(ChannelList, { channels: CHANNELS, selectedIndex: selectedIndex, activeChannelId: player.channel?.id ?? null }), _jsx(StatusBar, {})] }));
|
|
82
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: ink Text/Box, Channel type from data/channels
|
|
3
|
+
* [OUTPUT]: ChannelList component — selectable channel items
|
|
4
|
+
* [POS]: Channel navigation panel, rendered by App
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
import type { Channel } from '../data/channels.js';
|
|
8
|
+
interface Props {
|
|
9
|
+
channels: Channel[];
|
|
10
|
+
selectedIndex: number;
|
|
11
|
+
activeChannelId: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function ChannelList({ channels, selectedIndex, activeChannelId }: Props): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Component
|
|
5
|
+
// ============================================================
|
|
6
|
+
export function ChannelList({ channels, selectedIndex, activeChannelId }) {
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, underline: true, color: "white", children: " CHANNELS " }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: channels.map((ch, i) => {
|
|
8
|
+
const isSelected = i === selectedIndex;
|
|
9
|
+
const isActive = ch.id === activeChannelId;
|
|
10
|
+
const pointer = isSelected ? '▸' : ' ';
|
|
11
|
+
const indicator = isActive ? '♫' : ' ';
|
|
12
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isSelected ? 'green' : 'white', children: pointer }), _jsx(Text, { color: isActive ? 'green' : isSelected ? 'green' : 'gray', children: indicator }), _jsx(Text, { color: isSelected ? 'green' : 'white', bold: isSelected, children: ch.name }), _jsxs(Text, { dimColor: true, children: [" ", ch.tag] })] }, ch.id));
|
|
13
|
+
}) })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: ink Text/Box
|
|
3
|
+
* [OUTPUT]: Header component — ASCII banner with version
|
|
4
|
+
* [POS]: Top-level decorative banner, rendered by App
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
export declare function Header(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
// ============================================================
|
|
4
|
+
// ASCII Banner
|
|
5
|
+
// ============================================================
|
|
6
|
+
const BANNER = `
|
|
7
|
+
██████╗ ██████╗ ██████╗ ███████╗███╗ ███╗
|
|
8
|
+
██╔══██╗██╔══██╗██╔════╝ ██╔════╝████╗ ████║
|
|
9
|
+
██████╔╝██████╔╝██║ ███╗ █████╗ ██╔████╔██║
|
|
10
|
+
██╔══██╗██╔══██╗██║ ██║ ██╔══╝ ██║╚██╔╝██║
|
|
11
|
+
██████╔╝██████╔╝╚██████╔╝██╗██║ ██║ ╚═╝ ██║
|
|
12
|
+
╚═════╝ ╚═════╝ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝
|
|
13
|
+
`.trimStart();
|
|
14
|
+
export function Header() {
|
|
15
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: BANNER }), _jsx(Text, { dimColor: true, children: " Blockchain AI Radio \u2014 Terminal Player v0.1.0" })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: ink Text/Box, Channel type, PlayerStatus, audioLevelRef, SpectrumVisualizer
|
|
3
|
+
* [OUTPUT]: Player component -- now playing display + spectrum + volume bar
|
|
4
|
+
* [POS]: Playback status panel, rendered by App
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
import { type MutableRefObject } from 'react';
|
|
8
|
+
import type { Channel } from '../data/channels.js';
|
|
9
|
+
import type { PlayerStatus } from '../hooks/usePlayer.js';
|
|
10
|
+
interface Props {
|
|
11
|
+
channel: Channel | null;
|
|
12
|
+
status: PlayerStatus;
|
|
13
|
+
volume: number;
|
|
14
|
+
audioLevelRef: MutableRefObject<number>;
|
|
15
|
+
error: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function Player({ channel, status, volume, audioLevelRef, error }: Props): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { SpectrumVisualizer } from './SpectrumVisualizer.js';
|
|
4
|
+
// ============================================================
|
|
5
|
+
// Helpers
|
|
6
|
+
// ============================================================
|
|
7
|
+
function statusLabel(status) {
|
|
8
|
+
const map = {
|
|
9
|
+
idle: { text: 'IDLE', color: 'gray' },
|
|
10
|
+
connecting: { text: 'CONNECTING...', color: 'yellow' },
|
|
11
|
+
playing: { text: 'ON AIR', color: 'green' },
|
|
12
|
+
paused: { text: 'PAUSED', color: 'yellow' },
|
|
13
|
+
stopped: { text: 'STOPPED', color: 'red' },
|
|
14
|
+
error: { text: 'ERROR', color: 'red' },
|
|
15
|
+
};
|
|
16
|
+
return map[status];
|
|
17
|
+
}
|
|
18
|
+
function volumeBar(volume) {
|
|
19
|
+
const filled = Math.round(volume / 5);
|
|
20
|
+
const empty = 20 - filled;
|
|
21
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
22
|
+
}
|
|
23
|
+
// ============================================================
|
|
24
|
+
// Component
|
|
25
|
+
// ============================================================
|
|
26
|
+
export function Player({ channel, status, volume, audioLevelRef, error }) {
|
|
27
|
+
const label = statusLabel(status);
|
|
28
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === 'playing' ? 'green' : 'gray', paddingX: 2, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "white", children: "NOW PLAYING" }), _jsxs(Text, { color: label.color, bold: true, children: ["[", label.text, "]"] })] }), channel ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, alignItems: "center", children: [_jsx(Text, { color: "green", bold: true, children: channel.name }), _jsx(SpectrumVisualizer, { status: status, audioLevelRef: audioLevelRef })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: channel.desc }) })] })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No channel selected \u2014 press Enter to play" }) })), _jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { dimColor: true, children: "VOL" }), _jsx(Text, { color: "green", children: volumeBar(volume) }), _jsxs(Text, { bold: true, children: [volume, "%"] })] }), error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) }))] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: PlayerStatus + audioLevelRef (shared MutableRefObject<number>) from usePlayer
|
|
3
|
+
* [OUTPUT]: SpectrumVisualizer -- ASCII waveform bars driven by real audio energy
|
|
4
|
+
* [POS]: Decorative element rendered inside Player card.
|
|
5
|
+
* Reads audioLevel from shared ref -- bypasses React render pipeline.
|
|
6
|
+
* Animation model (25fps, exp-decay 0.82, lerp-attack 0.7):
|
|
7
|
+
* 1. Playing + signal -> audio-reactive with per-bar jitter
|
|
8
|
+
* 2. Playing + silent -> simulated jitter over bell-curve envelope
|
|
9
|
+
* 3. Idle -> subtle breathing over bell-curve envelope
|
|
10
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
11
|
+
*/
|
|
12
|
+
import { type MutableRefObject } from 'react';
|
|
13
|
+
import type { PlayerStatus } from '../hooks/usePlayer.js';
|
|
14
|
+
interface Props {
|
|
15
|
+
status: PlayerStatus;
|
|
16
|
+
audioLevelRef: MutableRefObject<number>;
|
|
17
|
+
}
|
|
18
|
+
export declare function SpectrumVisualizer({ status, audioLevelRef }: Props): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* [INPUT]: PlayerStatus + audioLevelRef (shared MutableRefObject<number>) from usePlayer
|
|
4
|
+
* [OUTPUT]: SpectrumVisualizer -- ASCII waveform bars driven by real audio energy
|
|
5
|
+
* [POS]: Decorative element rendered inside Player card.
|
|
6
|
+
* Reads audioLevel from shared ref -- bypasses React render pipeline.
|
|
7
|
+
* Animation model (25fps, exp-decay 0.82, lerp-attack 0.7):
|
|
8
|
+
* 1. Playing + signal -> audio-reactive with per-bar jitter
|
|
9
|
+
* 2. Playing + silent -> simulated jitter over bell-curve envelope
|
|
10
|
+
* 3. Idle -> subtle breathing over bell-curve envelope
|
|
11
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { Box, Text } from 'ink';
|
|
15
|
+
// ============================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// ============================================================
|
|
18
|
+
const BAR_COUNT = 16;
|
|
19
|
+
const BLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
20
|
+
const TICK_MS = 40; // 25fps -- visual fluidity threshold
|
|
21
|
+
const ATTACK_LERP = 0.7; // aggressive rise -- near-instant transient response
|
|
22
|
+
const DECAY_FACTOR = 0.82; // fast exponential fall -- ~80ms to half-height
|
|
23
|
+
const FLOOR = 0.06; // minimum bar height
|
|
24
|
+
// Bell-curve envelope -- natural shape for idle / fallback
|
|
25
|
+
const ENVELOPE = (() => {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = 0; i < BAR_COUNT; i++) {
|
|
28
|
+
const t = i / (BAR_COUNT - 1);
|
|
29
|
+
const peak = Math.exp(-Math.pow((t - 0.55) * 2.8, 2)) * 0.95;
|
|
30
|
+
out.push(Math.max(FLOOR, peak));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
})();
|
|
34
|
+
// Per-bar jitter frequencies -- avoid lockstep by giving each bar
|
|
35
|
+
// a unique oscillation speed and phase offset
|
|
36
|
+
const JITTER_FREQ = Array.from({ length: BAR_COUNT }, (_, i) => 3.1 + i * 0.37);
|
|
37
|
+
const JITTER_PHASE = Array.from({ length: BAR_COUNT }, (_, i) => i * 1.13);
|
|
38
|
+
// Green (#5BFF9F) at edges → Cyan (#00E5FF) at center
|
|
39
|
+
const BAR_COLORS = (() => {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let i = 0; i < BAR_COUNT; i++) {
|
|
42
|
+
const t = i / (BAR_COUNT - 1);
|
|
43
|
+
// Bell curve peaks at center → cyan; edges → green
|
|
44
|
+
const center = Math.exp(-Math.pow((t - 0.5) * 3.0, 2));
|
|
45
|
+
const r = Math.round(0x5b * (1 - center) + 0x00 * center);
|
|
46
|
+
const g = Math.round(0xff * (1 - center) + 0xe5 * center);
|
|
47
|
+
const b = Math.round(0x9f * (1 - center) + 0xff * center);
|
|
48
|
+
out.push(`#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
})();
|
|
52
|
+
// ============================================================
|
|
53
|
+
// Helpers
|
|
54
|
+
// ============================================================
|
|
55
|
+
function toBlock(value) {
|
|
56
|
+
const idx = Math.round(value * (BLOCKS.length - 1));
|
|
57
|
+
return BLOCKS[Math.max(0, Math.min(BLOCKS.length - 1, idx))] || ' ';
|
|
58
|
+
}
|
|
59
|
+
/** Clamp value to [FLOOR, 1] */
|
|
60
|
+
function clamp(v) {
|
|
61
|
+
return v < FLOOR ? FLOOR : v > 1 ? 1 : v;
|
|
62
|
+
}
|
|
63
|
+
export function SpectrumVisualizer({ status, audioLevelRef }) {
|
|
64
|
+
const [heights, setHeights] = useState([...ENVELOPE]);
|
|
65
|
+
const prevRef = useRef([...ENVELOPE]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const startTime = Date.now();
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
const elapsed = Date.now() - startTime;
|
|
70
|
+
const t = elapsed / 1000;
|
|
71
|
+
const level = audioLevelRef.current; // direct ref read -- zero React overhead
|
|
72
|
+
const prev = prevRef.current;
|
|
73
|
+
const next = [];
|
|
74
|
+
const isActive = status === 'playing' || status === 'connecting';
|
|
75
|
+
const hasSignal = isActive && level > 0.01;
|
|
76
|
+
if (hasSignal) {
|
|
77
|
+
// ── State 1: Audio-reactive ──
|
|
78
|
+
// Exponential decay on fall, lerp on rise, per-bar jitter
|
|
79
|
+
for (let i = 0; i < BAR_COUNT; i++) {
|
|
80
|
+
const shape = ENVELOPE[i];
|
|
81
|
+
const jitter = Math.sin(t * JITTER_FREQ[i] + JITTER_PHASE[i]) * 0.18;
|
|
82
|
+
const target = clamp(level * shape + jitter * level);
|
|
83
|
+
// Rise: lerp toward target; Fall: exponential decay
|
|
84
|
+
const h = target > prev[i]
|
|
85
|
+
? prev[i] + (target - prev[i]) * ATTACK_LERP
|
|
86
|
+
: Math.max(target, prev[i] * DECAY_FACTOR);
|
|
87
|
+
next.push(clamp(h));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (isActive) {
|
|
91
|
+
// ── State 2: Simulated fallback (playing but no signal) ──
|
|
92
|
+
for (let i = 0; i < BAR_COUNT; i++) {
|
|
93
|
+
const base = ENVELOPE[i];
|
|
94
|
+
const jitter = Math.sin(t * JITTER_FREQ[i] + JITTER_PHASE[i]) * 0.15;
|
|
95
|
+
next.push(clamp(base + jitter));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// ── State 3: Idle breathing ──
|
|
100
|
+
for (let i = 0; i < BAR_COUNT; i++) {
|
|
101
|
+
const breath = Math.sin(t * 0.83 + i * 0.3) * 0.04;
|
|
102
|
+
next.push(clamp(ENVELOPE[i] + breath));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
prevRef.current = next;
|
|
106
|
+
setHeights(next);
|
|
107
|
+
}, TICK_MS);
|
|
108
|
+
return () => clearInterval(timer);
|
|
109
|
+
}, [status]);
|
|
110
|
+
return (_jsx(Box, { gap: 1, children: heights.map((h, i) => (_jsx(Text, { color: BAR_COLORS[i], children: toBlock(h) }, i))) }));
|
|
111
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: ink Text/Box
|
|
3
|
+
* [OUTPUT]: StatusBar component — keyboard shortcut help
|
|
4
|
+
* [POS]: Bottom bar with key bindings, rendered by App
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
export declare function StatusBar(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Component
|
|
5
|
+
// ============================================================
|
|
6
|
+
export function StatusBar() {
|
|
7
|
+
return (_jsxs(Box, { gap: 2, marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "j/k" }), " switch channel"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "Space" }), " play/stop"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "+/-" }), " volume"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", bold: true, children: "q" }), " quit"] })] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: None (pure data)
|
|
3
|
+
* [OUTPUT]: Channel type, CHANNELS array, DEFAULT_CHANNEL_ID
|
|
4
|
+
* [POS]: Data registry for radio HLS streams, consumed by usePlayer and ChannelList
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
export interface Channel {
|
|
8
|
+
id: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
name: string;
|
|
11
|
+
tag: string;
|
|
12
|
+
url: string;
|
|
13
|
+
desc: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const CHANNELS: Channel[];
|
|
16
|
+
export declare const DEFAULT_CHANNEL_ID = "bbg-en";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: None (pure data)
|
|
3
|
+
* [OUTPUT]: Channel type, CHANNELS array, DEFAULT_CHANNEL_ID
|
|
4
|
+
* [POS]: Data registry for radio HLS streams, consumed by usePlayer and ChannelList
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
export const CHANNELS = [
|
|
8
|
+
{
|
|
9
|
+
id: 'bbg-en',
|
|
10
|
+
slug: 'en',
|
|
11
|
+
name: 'BBG · ENGLISH',
|
|
12
|
+
tag: 'BLOCKCHAIN · NEWS & TALK',
|
|
13
|
+
url: 'https://koe.bbg.fm/channels/1/playlist.m3u8',
|
|
14
|
+
desc: 'Blockchain Live Broadcast | News · Analysis · Culture',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'bbg-cn',
|
|
18
|
+
slug: 'zh',
|
|
19
|
+
name: 'BBG · 中文',
|
|
20
|
+
tag: '区块链 · 新闻资讯',
|
|
21
|
+
url: 'https://koe.bbg.fm/channels/2/playlist.m3u8',
|
|
22
|
+
desc: '区块链实时广播 | 新闻 · 深度 · 文化',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export const DEFAULT_CHANNEL_ID = 'bbg-en';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: node-mpv for HLS playback, Channel type from data/channels
|
|
3
|
+
* [OUTPUT]: usePlayer hook — play/pause/stop/setVolume/audioLevel + reactive state
|
|
4
|
+
* [POS]: Audio backend abstraction, consumed by App root component
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
import type { Channel } from '../data/channels.js';
|
|
8
|
+
export type PlayerStatus = 'idle' | 'connecting' | 'playing' | 'paused' | 'stopped' | 'error';
|
|
9
|
+
export declare function usePlayer(): {
|
|
10
|
+
audioLevelRef: import("react").MutableRefObject<number>;
|
|
11
|
+
play: (channel: Channel) => Promise<void>;
|
|
12
|
+
togglePause: () => Promise<void>;
|
|
13
|
+
adjustVolume: (delta: number) => void;
|
|
14
|
+
stop: () => Promise<void>;
|
|
15
|
+
status: PlayerStatus;
|
|
16
|
+
volume: number;
|
|
17
|
+
channel: Channel | null;
|
|
18
|
+
error: string | null;
|
|
19
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [INPUT]: node-mpv for HLS playback, Channel type from data/channels
|
|
3
|
+
* [OUTPUT]: usePlayer hook — play/pause/stop/setVolume/audioLevel + reactive state
|
|
4
|
+
* [POS]: Audio backend abstraction, consumed by App root component
|
|
5
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import NodeMpv from 'node-mpv';
|
|
11
|
+
// Cross-platform mpv IPC path
|
|
12
|
+
const MPV_SOCKET = process.platform === 'win32'
|
|
13
|
+
? '\\\\.\\pipe\\bbgfm-mpv'
|
|
14
|
+
: join(tmpdir(), 'bbgfm-mpv.sock');
|
|
15
|
+
// ============================================================
|
|
16
|
+
// Hook — refs for mutable state, command chain for IPC safety
|
|
17
|
+
// ============================================================
|
|
18
|
+
export function usePlayer() {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- node-mpv .d.ts is malformed
|
|
20
|
+
const mpvRef = useRef(null);
|
|
21
|
+
const volumeRef = useRef(100);
|
|
22
|
+
const statusRef = useRef('idle');
|
|
23
|
+
const cmdChain = useRef(Promise.resolve());
|
|
24
|
+
const volTimer = useRef(null);
|
|
25
|
+
const levelTimer = useRef(null);
|
|
26
|
+
// Shared ref -- SpectrumVisualizer reads this directly, no React render hop
|
|
27
|
+
const audioLevelRef = useRef(0);
|
|
28
|
+
const [state, setState] = useState({
|
|
29
|
+
status: 'idle',
|
|
30
|
+
volume: 100,
|
|
31
|
+
channel: null,
|
|
32
|
+
error: null,
|
|
33
|
+
});
|
|
34
|
+
// Sync status ref + state
|
|
35
|
+
const setStatus = useCallback((s) => {
|
|
36
|
+
statusRef.current = s;
|
|
37
|
+
setState(prev => ({ ...prev, status: s }));
|
|
38
|
+
}, []);
|
|
39
|
+
// Serialize mpv IPC commands — prevents socket flooding
|
|
40
|
+
const runCmd = useCallback((fn) => {
|
|
41
|
+
cmdChain.current = cmdChain.current.catch(() => { }).then(fn).catch(() => { });
|
|
42
|
+
return cmdChain.current;
|
|
43
|
+
}, []);
|
|
44
|
+
// --------------------------------------------------------
|
|
45
|
+
// Audio level polling — reads astats Peak_level from mpv
|
|
46
|
+
// --------------------------------------------------------
|
|
47
|
+
const startLevelPolling = useCallback(() => {
|
|
48
|
+
if (levelTimer.current)
|
|
49
|
+
return;
|
|
50
|
+
let polling = false; // guard -- prevent async IPC overlap
|
|
51
|
+
let baseline = 0; // adaptive baseline (slow EMA of linear amplitude)
|
|
52
|
+
levelTimer.current = setInterval(async () => {
|
|
53
|
+
if (polling)
|
|
54
|
+
return;
|
|
55
|
+
const mpv = mpvRef.current;
|
|
56
|
+
if (!mpv?.isRunning())
|
|
57
|
+
return;
|
|
58
|
+
polling = true;
|
|
59
|
+
try {
|
|
60
|
+
const raw = await mpv.getProperty('af-metadata/astats/lavfi.astats.Overall.Peak_level');
|
|
61
|
+
const dB = parseFloat(raw);
|
|
62
|
+
if (isNaN(dB))
|
|
63
|
+
return;
|
|
64
|
+
// Convert dB to linear amplitude (0..1)
|
|
65
|
+
const linear = Math.pow(10, dB / 20);
|
|
66
|
+
// Adaptive baseline: slow-moving EMA (~2s time constant at 50ms intervals)
|
|
67
|
+
// Tracks the "floor" of the signal -- steady music sits near this
|
|
68
|
+
baseline = baseline < 0.001
|
|
69
|
+
? linear
|
|
70
|
+
: baseline * 0.96 + linear * 0.04;
|
|
71
|
+
// Output = relative deviation from baseline, amplified
|
|
72
|
+
// For speech: linear spikes far above baseline -> high level
|
|
73
|
+
// For music: transients (drums, accents) spike above steady baseline
|
|
74
|
+
const deviation = linear - baseline * 0.7;
|
|
75
|
+
const headroom = Math.max(baseline * 1.5, 0.05);
|
|
76
|
+
audioLevelRef.current = Math.max(0, Math.min(1, deviation / headroom));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// af-metadata not available yet -- ignore silently
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
polling = false;
|
|
83
|
+
}
|
|
84
|
+
}, 50);
|
|
85
|
+
}, []);
|
|
86
|
+
const stopLevelPolling = useCallback(() => {
|
|
87
|
+
if (levelTimer.current) {
|
|
88
|
+
clearInterval(levelTimer.current);
|
|
89
|
+
levelTimer.current = null;
|
|
90
|
+
}
|
|
91
|
+
audioLevelRef.current = 0;
|
|
92
|
+
}, []);
|
|
93
|
+
// Lazy-init mpv — with astats audio filter for level metering
|
|
94
|
+
const getMpv = useCallback(() => {
|
|
95
|
+
if (!mpvRef.current) {
|
|
96
|
+
// @ts-expect-error — node-mpv .d.ts declares class but module shape is off
|
|
97
|
+
mpvRef.current = new NodeMpv({
|
|
98
|
+
audio_only: true,
|
|
99
|
+
verbose: false,
|
|
100
|
+
socket: MPV_SOCKET,
|
|
101
|
+
}, ['--af=@astats:lavfi=[astats=metadata=1:measure_overall=Peak_level+RMS_level:reset=1]']);
|
|
102
|
+
}
|
|
103
|
+
return mpvRef.current;
|
|
104
|
+
}, []);
|
|
105
|
+
// Play a channel
|
|
106
|
+
const play = useCallback(async (channel) => {
|
|
107
|
+
setStatus('connecting');
|
|
108
|
+
setState(prev => ({ ...prev, channel, error: null }));
|
|
109
|
+
await runCmd(async () => {
|
|
110
|
+
const mpv = getMpv();
|
|
111
|
+
if (!mpv.isRunning()) {
|
|
112
|
+
await mpv.start();
|
|
113
|
+
await mpv.volume(volumeRef.current);
|
|
114
|
+
}
|
|
115
|
+
await mpv.load(channel.url);
|
|
116
|
+
}).then(() => {
|
|
117
|
+
setStatus('playing');
|
|
118
|
+
startLevelPolling();
|
|
119
|
+
}, (err) => {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
setStatus('error');
|
|
122
|
+
setState(prev => ({ ...prev, error: msg }));
|
|
123
|
+
});
|
|
124
|
+
}, [getMpv, setStatus, runCmd, startLevelPolling]);
|
|
125
|
+
// Toggle pause/resume
|
|
126
|
+
const togglePause = useCallback(async () => {
|
|
127
|
+
const s = statusRef.current;
|
|
128
|
+
if (s !== 'playing' && s !== 'paused')
|
|
129
|
+
return;
|
|
130
|
+
await runCmd(async () => {
|
|
131
|
+
const mpv = getMpv();
|
|
132
|
+
if (!mpv.isRunning())
|
|
133
|
+
return;
|
|
134
|
+
if (s === 'playing') {
|
|
135
|
+
await mpv.pause();
|
|
136
|
+
setStatus('paused');
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await mpv.resume();
|
|
140
|
+
setStatus('playing');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}, [getMpv, setStatus, runCmd]);
|
|
144
|
+
// Volume — state updates instantly, mpv command debounced
|
|
145
|
+
const adjustVolume = useCallback((delta) => {
|
|
146
|
+
const next = Math.max(0, Math.min(100, volumeRef.current + delta));
|
|
147
|
+
volumeRef.current = next;
|
|
148
|
+
setState(prev => ({ ...prev, volume: next }));
|
|
149
|
+
// Debounce: only send the final value to mpv after 50ms of inactivity
|
|
150
|
+
if (volTimer.current)
|
|
151
|
+
clearTimeout(volTimer.current);
|
|
152
|
+
volTimer.current = setTimeout(() => {
|
|
153
|
+
runCmd(async () => {
|
|
154
|
+
const mpv = getMpv();
|
|
155
|
+
if (mpv.isRunning())
|
|
156
|
+
await mpv.volume(volumeRef.current);
|
|
157
|
+
});
|
|
158
|
+
}, 50);
|
|
159
|
+
}, [getMpv, runCmd]);
|
|
160
|
+
// Stop and quit mpv
|
|
161
|
+
const stop = useCallback(async () => {
|
|
162
|
+
stopLevelPolling();
|
|
163
|
+
if (volTimer.current)
|
|
164
|
+
clearTimeout(volTimer.current);
|
|
165
|
+
await runCmd(async () => {
|
|
166
|
+
const mpv = getMpv();
|
|
167
|
+
if (mpv.isRunning()) {
|
|
168
|
+
await mpv.stop();
|
|
169
|
+
await mpv.quit();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
mpvRef.current = null;
|
|
173
|
+
setStatus('stopped');
|
|
174
|
+
}, [getMpv, setStatus, runCmd, stopLevelPolling]);
|
|
175
|
+
// Cleanup on unmount
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
return () => {
|
|
178
|
+
if (levelTimer.current)
|
|
179
|
+
clearInterval(levelTimer.current);
|
|
180
|
+
if (volTimer.current)
|
|
181
|
+
clearTimeout(volTimer.current);
|
|
182
|
+
const mpv = mpvRef.current;
|
|
183
|
+
if (mpv?.isRunning()) {
|
|
184
|
+
mpv.stop().then(() => mpv.quit()).catch(() => { });
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}, []);
|
|
188
|
+
return { ...state, audioLevelRef, play, togglePause, adjustVolume, stop };
|
|
189
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* [INPUT]: ink render, App component, child_process for mpv check
|
|
4
|
+
* [OUTPUT]: CLI entry point -- parses args, checks mpv, renders App
|
|
5
|
+
* [POS]: Executable entry, registered as bin in package.json
|
|
6
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* [INPUT]: ink render, App component, child_process for mpv check
|
|
5
|
+
* [OUTPUT]: CLI entry point -- parses args, checks mpv, renders App
|
|
6
|
+
* [POS]: Executable entry, registered as bin in package.json
|
|
7
|
+
* [PROTOCOL]: Update this header on changes, then check AGENTS.md
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { render } from 'ink';
|
|
11
|
+
import { App } from './app.js';
|
|
12
|
+
// ============================================================
|
|
13
|
+
// Preflight -- mpv is required for audio playback
|
|
14
|
+
// ============================================================
|
|
15
|
+
function ensureMpv() {
|
|
16
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
17
|
+
try {
|
|
18
|
+
execFileSync(cmd, ['mpv'], { stdio: 'ignore' });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
console.error('\n mpv is required but not found.\n');
|
|
22
|
+
console.error(' Install via:');
|
|
23
|
+
console.error(' brew install mpv # macOS');
|
|
24
|
+
console.error(' sudo apt install mpv # Debian/Ubuntu');
|
|
25
|
+
console.error(' sudo pacman -S mpv # Arch');
|
|
26
|
+
console.error(' scoop install mpv # Windows (scoop)');
|
|
27
|
+
console.error(' choco install mpv # Windows (choco)');
|
|
28
|
+
console.error(' winget install mpv # Windows (winget)\n');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ============================================================
|
|
33
|
+
// Arg Parsing (minimal, no dependency)
|
|
34
|
+
// ============================================================
|
|
35
|
+
function parseArgs() {
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const channelIdx = args.indexOf('--channel');
|
|
38
|
+
const channel = channelIdx !== -1 ? args[channelIdx + 1] : undefined;
|
|
39
|
+
return { channel };
|
|
40
|
+
}
|
|
41
|
+
// ============================================================
|
|
42
|
+
// Boot
|
|
43
|
+
// ============================================================
|
|
44
|
+
ensureMpv();
|
|
45
|
+
const { channel } = parseArgs();
|
|
46
|
+
render(_jsx(App, { initialChannel: channel }));
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bbgfm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"bin": {
|
|
9
|
+
"bbgfm": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "tsx src/index.tsx",
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"prepublishOnly": "pnpm build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ink": "^5.2.0",
|
|
19
|
+
"node-mpv": "^2.0.0-beta.2",
|
|
20
|
+
"react": "^18.3.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.19.13",
|
|
24
|
+
"@types/react": "^18.3.18",
|
|
25
|
+
"tsx": "^4.19.2",
|
|
26
|
+
"typescript": "^5.7.3"
|
|
27
|
+
}
|
|
28
|
+
}
|