Compare commits

...

28 commits

Author SHA1 Message Date
67beae5f6f
chore(streamer): drop cosmetic swscaler deprecated-format spam from logs 2026-04-30 19:31:29 +06:00
bdecc8c819
fix(streamer): explicit scale filter to fully silence swscaler 2026-04-30 19:30:20 +06:00
a544649a22
fix(streamer): tag mjpeg input as full-range to silence swscaler 2026-04-30 19:29:27 +06:00
2cd7cd3eab
fix(streamer): handle stdin EPIPE on ffmpeg exit, raise queue size, color_range tv 2026-04-30 19:28:10 +06:00
a83d063814
feat(liquidsoap): filename-based fallback when title/album tags are empty 2026-04-30 19:24:46 +06:00
1c4c875c10
fix(streamer): db-scaled spectrum so bars match frontend visibility 2026-04-30 19:18:03 +06:00
9eaa16d818
feat(streamer): BRANDING env to hide denpa.fm refs, drop station pill 2026-04-30 19:12:50 +06:00
3b4ce1ce39
fix(streamer): drop fonts-vt323 (not in bookworm) and use node fetch for healthcheck
- fonts-vt323 doesn't exist in debian bookworm; vt323 is loaded via google fonts cdn anyway.
- node:22-bookworm-slim has no wget; switch healthcheck to node -e fetch.
2026-04-30 15:41:17 +06:00
ed76fbd379
feat(compose): add streamer-minecraft-tg service 2026-04-30 15:32:11 +06:00
0ff5ecd9d0
feat(streamer): supervisor wiring all subprocesses 2026-04-30 15:30:45 +06:00
7cdc8f0d44
feat(streamer): ffmpeg rtmp encoder spawn 2026-04-30 15:28:35 +06:00
baef915561
feat(streamer): chromium cdp screencast driver 2026-04-30 15:28:31 +06:00
e0d93b03ae
feat(streamer): react views ported from prototype with sse hooks 2026-04-30 15:26:57 +06:00
870a41a2f7
feat(streamer): icecast listener-count poller 2026-04-30 15:21:49 +06:00
6bcf18c1bf
feat(streamer): page http server with sse + cover proxy 2026-04-30 15:21:42 +06:00
40ac86717a
feat(streamer): pcm tcp tap with reconnect backoff 2026-04-30 15:21:32 +06:00
8bca1767bd
feat(streamer): fft + log-bin spectrum analyzer 2026-04-30 15:19:41 +06:00
48955634af
feat(streamer): now-playing watcher with cover resolver 2026-04-30 15:17:37 +06:00
23d98320df
feat(streamer): env config parsing + validation 2026-04-30 15:14:48 +06:00
cc1f278fb8
feat(streamer): scaffold package, tsconfig, dockerfile 2026-04-30 15:12:42 +06:00
08f3f6e7da
feat(liquidsoap): raw-pcm tcp tap on :9100 for streamer 2026-04-30 15:09:25 +06:00
83df3b4602
feat(liquidsoap): emit cover_path + up_next in now-playing json
also fix path.extension -> file.extension (path module has no .extension in 2.3.3)
2026-04-30 15:04:25 +06:00
68a02ff250
fix(liquidsoap): deck filters audio, guards empty, avoids cross-deck repeat 2026-04-30 14:59:17 +06:00
fcb783ffca
feat(liquidsoap): deck-shuffle playlist with peek-ahead 2026-04-30 14:56:38 +06:00
f6ce00a6b1
fix(scripts): quote remote glob, drop UUOC in cover upload 2026-04-30 14:55:37 +06:00
703d7532f7
chore: mark restore-album-covers.sh executable 2026-04-30 14:54:28 +06:00
824192b856
chore: script to restore per-album covers from archives 2026-04-30 14:53:26 +06:00
d3e919d4c8
chore: gitignore .design-prototype-stream/ 2026-04-30 14:51:06 +06:00
36 changed files with 6353 additions and 13 deletions

5
.gitignore vendored
View file

@ -6,6 +6,7 @@ docs/superpowers/
# design handoff bundle from claude design — local reference only # design handoff bundle from claude design — local reference only
.design-prototype/ .design-prototype/
.design-prototype-stream/
# generated configs and secrets # generated configs and secrets
config/*.env config/*.env
@ -15,6 +16,10 @@ config/icecast.xml
# runtime data # runtime data
data/ data/
# streamer build artifacts
streamer/node_modules/
streamer/dist/
# OS junk # OS junk
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View file

@ -6,29 +6,145 @@ settings.log.level.set(3)
source_pw = environment.get(default="", "SOURCE_PW") source_pw = environment.get(default="", "SOURCE_PW")
def emit_now_playing(station, m) = # deck-shuffle: full shuffle once, hand out in order, reshuffle on exhaustion.
# returns a record with .source (request.dynamic) and .peek (returns next N filenames).
def make_deck(dir) =
files = ref([])
cursor = ref(0)
last_played = ref("")
def reload_and_shuffle() =
all = file.ls(dir, recursive=true, absolute=true)
audio_exts = ["flac", "mp3", "opus", "m4a", "wav", "ogg"]
audio = list.filter(
fun(f) -> list.mem(string.case(lower=true, file.extension(leading_dot=false, f)), audio_exts),
all
)
files := list.shuffle(audio)
if !last_played != "" and list.length(!files) > 1 and list.nth(default="", !files, 0) == !last_played then
files := list.append(
[list.nth(default="", !files, 1), list.nth(default="", !files, 0)],
list.tl(list.tl(!files))
)
end
cursor := 0
end
def next_file() =
if !cursor >= list.length(!files) then reload_and_shuffle() end
if list.length(!files) == 0 then "" else
f = list.nth(default="", !files, !cursor)
cursor := !cursor + 1
last_played := f
f
end
end
def next_request() =
f = next_file()
if f == "" then null() else request.create(f) end
end
def peek(n) =
list.init(
min(n, list.length(!files) - !cursor),
fun(i) -> list.nth(default="", !files, !cursor + i)
)
end
reload_and_shuffle()
{ source = request.dynamic(next_request), peek = peek }
end
# filename-based fallbacks for tracks with empty embedded tags.
# strips leading "08. " / "12 - " etc. from the file stem.
def filename_title(filename) =
base = path.basename(filename)
ext = file.extension(leading_dot=true, base)
stem =
if ext != "" then
string.sub(base, start=0, length=string.length(base) - string.length(ext))
else base end
re = regexp("^[0-9]+[. \\-]+ *")
re.replace(fun(_) -> "", stem)
end
def filename_album(filename) =
path.basename(path.dirname(filename))
end
def or_else(s, fallback) =
if s == "" then fallback else s end
end
# walk up from the audio file to find cover.{jpg,png,webp}.
# falls back to <station>/cover.* (one level above tracks/).
def cover_for(filename) =
def find_in(dir) =
candidates = ["#{dir}/cover.jpg", "#{dir}/cover.png", "#{dir}/cover.webp"]
list.fold(
fun(found, c) -> if found == "" and file.exists(c) then c else found end,
"",
candidates
)
end
album_dir = path.dirname(filename)
c1 = find_in(album_dir)
if c1 != "" then c1 else
tracks_dir = path.dirname(album_dir)
station_root = path.dirname(tracks_dir)
find_in(station_root)
end
end
# build a json object for one upcoming track from its filename.
# reads tags via request.metadata; uses request.duration for length.
def track_meta_json(filename) =
r = request.create(filename)
ignore(request.resolve(r))
m = request.metadata(r)
request.destroy(r)
d = null.get(default=-1., request.duration(filename))
obj = json()
obj.add("title", or_else(m["title"], filename_title(filename)))
obj.add("artist", m["artist"])
obj.add("album", or_else(m["album"], filename_album(filename)))
obj.add("duration", if d > 0. then string(int_of_float(d)) else "" end)
obj.add("cover_path", cover_for(filename))
obj
end
def emit_now_playing(station, m, upcoming) =
filename = m["filename"] filename = m["filename"]
if filename != "" then if filename != "" then
# decoder-reported metadata may be empty; fall back to request.duration which
# reads it directly from the file. -1.0 means unknown.
meta_dur = m["duration"] meta_dur = m["duration"]
dur_str = dur_str =
if meta_dur != "" then if meta_dur != "" then
meta_dur meta_dur
else else
# request.duration returns float? in liquidsoap 2.3 — unwrap with default -1.
d = null.get(default=-1., request.duration(filename)) d = null.get(default=-1., request.duration(filename))
if d > 0. then string(int_of_float(d)) else "" end if d > 0. then string(int_of_float(d)) else "" end
end end
payload = json() payload = json()
payload.add("station", station) payload.add("station", station)
payload.add("artist", m["artist"]) payload.add("artist", m["artist"])
payload.add("title", m["title"]) payload.add("title", or_else(m["title"], filename_title(filename)))
payload.add("album", m["album"]) payload.add("album", or_else(m["album"], filename_album(filename)))
payload.add("filename", filename) payload.add("filename", filename)
payload.add("duration", dur_str) payload.add("duration", dur_str)
payload.add("started_at", string(int_of_float(time()))) payload.add("started_at", string(int_of_float(time())))
file.write(data=payload.stringify(), atomic=true, temp_dir="/now-playing", "/now-playing/#{station}.json") payload.add("cover_path", cover_for(filename))
up_arr = list.map(track_meta_json, upcoming)
payload.add("up_next", up_arr)
file.write(
data=payload.stringify(),
atomic=true,
temp_dir="/now-playing",
"/now-playing/#{station}.json"
)
end end
end end
@ -68,13 +184,10 @@ def append_history(station, m) =
end end
# === station: minecraft === # === station: minecraft ===
minecraft = playlist( minecraft_deck = make_deck("/library/minecraft/tracks")
reload_mode="watch", minecraft = minecraft_deck.source
mode="randomize",
"/library/minecraft/tracks"
)
minecraft.on_track(fun (m) -> begin minecraft.on_track(fun (m) -> begin
emit_now_playing("minecraft", m) emit_now_playing("minecraft", m, minecraft_deck.peek(4))
append_history("minecraft", m) append_history("minecraft", m)
end) end)
minecraft = crossfade(minecraft) minecraft = crossfade(minecraft)
@ -101,3 +214,9 @@ output.icecast(
url="https://denpa.femboy.page", url="https://denpa.femboy.page",
minecraft minecraft
) )
output.url(
%ffmpeg(format="s16le", %audio(codec="pcm_s16le", ac=2, ar=48000)),
url="tcp://0.0.0.0:9100?listen=1",
minecraft
)

View file

@ -0,0 +1,25 @@
# streamer for the minecraft station, target = telegram rtmp ingest.
# copy to streamer-minecraft-tg.env on summer, fill in RTMP_URL.
STATION=minecraft
RTMP_URL=rtmps://dc4-1.rtmp.t.me/s/REPLACE_WITH_KEY
LIQUIDSOAP_PCM=tcp://liquidsoap:9100
NOW_PLAYING_DIR=/now-playing
LIBRARY_DIR=/library
STYLE=denpa
TZ=Asia/Tokyo
VIDEO_BITRATE=4500k
AUDIO_BITRATE=160k
FRAMERATE=30
RESOLUTION=1920x1080
STATION_TUNE_IN_URL=denpa.femboy.page
# set to false to hide all "denpa.fm" branding + links in the overlay
BRANDING=true
ICECAST_STATUS_URL=http://icecast:8000/status-json.xsl
LOG_LEVEL=info
HEALTH_PORT=12010

View file

@ -68,3 +68,30 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 15s start_period: 15s
streamer-minecraft-tg:
build: ./streamer
image: denpa-radio-streamer:latest
restart: always
env_file: config/streamer-minecraft-tg.env
depends_on:
liquidsoap:
condition: service_healthy
volumes:
- type: bind
source: /mnt/trashbox/denpa-radio/library
target: /library
read_only: true
- type: bind
source: ./data/now-playing
target: /now-playing
read_only: true
shm_size: '1gb'
ports:
- '172.17.0.1:12010:12010'
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:12010/health').then(r=>r.json()).then(j=>{if(!j.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s

45
scripts/restore-album-covers.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# one-time: extract cover.jpg from each album archive in
# ~/Downloads/Telegram\ Desktop and upload to summer storage box.
# safe to re-run; skips albums whose cover already exists on summer.
set -euo pipefail
SRC="${SRC:-/c/Users/user/Downloads/Telegram Desktop}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/keys/devilreef}"
SSH_OPTS=(-o IdentityAgent=none -o IdentitiesOnly=yes -i "$SSH_KEY")
REMOTE_LIB="/mnt/trashbox/denpa-radio/library/minecraft/tracks"
declare -A MAP=(
["C418 - Minecraft - Volume Alpha.tar.gz"]="Volume Alpha"
["C418 - Minecraft - Volume Beta.tar.gz"]="Volume Beta"
["Lena_Raine,_Minecraft_Minecraft_Caves_Cliffs_Original_Game.gz"]="Caves & Cliffs"
["Minecraft - Minecraft_ Pixel Drift.tar.gz"]="Pixel Drift"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Original_Game_Soundt.gz"]="Minecraft Dungeons"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Creeping_Winter_Ori.gz"]="Minecraft Dungeons - Creeping Winter"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Echoing_Void_Origin.gz"]="Minecraft Dungeons - Echoing Void"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Howling_Peaks_Origi.gz"]="Minecraft Dungeons - Howling Peaks"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Jungle_Awakens_Orig.gz"]="Minecraft Dungeons - Jungle Awakens"
["Peter_Hont,_Samuel_berg,_Minecraft_Minecraft_Dungeons_Ultima.gz"]="Minecraft Dungeons - Ultimate Additions"
)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for archive in "${!MAP[@]}"; do
album="${MAP[$archive]}"
remote_path="$REMOTE_LIB/$album/cover.jpg"
if ssh "${SSH_OPTS[@]}" summer "test -f \"$remote_path\""; then
echo "skip: $album (cover exists)"
continue
fi
echo "extract: $archive$album"
tar -xzf "$SRC/$archive" -C "$tmp" cover.jpg
echo "upload: $album"
ssh "${SSH_OPTS[@]}" summer "mkdir -p \"$REMOTE_LIB/$album\" && cat > \"$remote_path\"" < "$tmp/cover.jpg"
rm "$tmp/cover.jpg"
done
echo "done"
ssh "${SSH_OPTS[@]}" summer "ls \"$REMOTE_LIB\"/*/cover.jpg"

5
streamer/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
tests
*.test.ts
.vitest

29
streamer/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-bookworm-slim
ENV NODE_ENV=production \
DEBIAN_FRONTEND=noninteractive \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
ffmpeg \
fonts-noto-cjk \
ca-certificates \
tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=build /app/dist ./dist
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/index.js"]

4344
streamer/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
streamer/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "denpa-streamer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build:views": "node --experimental-strip-types src/views/build.ts",
"build:server": "tsc -p tsconfig.json",
"build": "npm run build:views && npm run build:server",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src tests"
},
"dependencies": {
"puppeteer-core": "^23.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.24.0",
"eslint": "^9.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.0"
}
}

60
streamer/src/chrome.ts Normal file
View file

@ -0,0 +1,60 @@
import { EventEmitter } from "node:events";
import puppeteer, { Browser, CDPSession, Page } from "puppeteer-core";
export interface ChromeOpts {
pageUrl: string;
width: number;
height: number;
framerate: number;
jpegQuality?: number; // 0..100, default 85
executablePath?: string; // default /usr/bin/chromium
}
export class ChromeRenderer extends EventEmitter {
private browser: Browser | null = null;
private page: Page | null = null;
private cdp: CDPSession | null = null;
constructor(private opts: ChromeOpts) { super(); }
async start(): Promise<void> {
this.browser = await puppeteer.launch({
executablePath: this.opts.executablePath ?? "/usr/bin/chromium",
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--hide-scrollbars",
`--window-size=${this.opts.width},${this.opts.height}`,
"--autoplay-policy=no-user-gesture-required",
],
defaultViewport: { width: this.opts.width, height: this.opts.height },
});
this.page = await this.browser.newPage();
await this.page.goto(this.opts.pageUrl, { waitUntil: "networkidle2" });
this.cdp = await this.page.target().createCDPSession();
await this.cdp.send("Page.startScreencast", {
format: "jpeg",
quality: this.opts.jpegQuality ?? 85,
everyNthFrame: 1,
});
this.cdp.on("Page.screencastFrame", async (frame) => {
const buf = Buffer.from(frame.data, "base64");
this.emit("frame", buf);
await this.cdp!.send("Page.screencastFrameAck", { sessionId: frame.sessionId });
});
this.browser.on("disconnected", () => this.emit("disconnected"));
}
async stop(): Promise<void> {
try { await this.cdp?.send("Page.stopScreencast"); } catch { /* ignore */ }
await this.browser?.close().catch(() => {});
this.browser = null;
this.page = null;
this.cdp = null;
}
}

86
streamer/src/config.ts Normal file
View file

@ -0,0 +1,86 @@
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
export interface Config {
station: string;
rtmpUrl: string;
pcmHost: string;
pcmPort: number;
nowPlayingDir: string;
libraryDir: string;
style: "denpa" | "modern";
tz: string;
videoBitrate: string;
audioBitrate: string;
framerate: number;
resolution: { width: number; height: number };
stationDisplayName?: string;
stationTagline?: string;
stationTuneInUrl: string;
branding: boolean;
healthPort: number;
logLevel: "debug" | "info" | "warn" | "error";
icecastStatusUrl?: string;
}
function require_(env: Record<string, string | undefined>, key: string): string {
const v = env[key];
if (!v) throw new ConfigError(`missing required env ${key}`);
return v;
}
function parsePcm(url: string): { host: string; port: number } {
const m = url.match(/^tcp:\/\/([^:/]+):(\d+)$/);
if (!m) throw new ConfigError(`invalid LIQUIDSOAP_PCM (expected tcp://host:port): ${url}`);
return { host: m[1]!, port: Number(m[2]) };
}
function parseRes(s: string): { width: number; height: number } {
const m = s.match(/^(\d+)x(\d+)$/);
if (!m) throw new ConfigError(`invalid RESOLUTION: ${s}`);
return { width: Number(m[1]), height: Number(m[2]) };
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const style = require_(env, "STYLE");
if (style !== "denpa" && style !== "modern") {
throw new ConfigError(`STYLE must be 'denpa' or 'modern', got ${style}`);
}
const rtmpUrl = require_(env, "RTMP_URL");
if (!/^rtmps?:\/\//.test(rtmpUrl)) {
throw new ConfigError(`RTMP_URL must start with rtmp:// or rtmps://`);
}
const { host: pcmHost, port: pcmPort } = parsePcm(require_(env, "LIQUIDSOAP_PCM"));
const logLevel = (env.LOG_LEVEL ?? "info") as Config["logLevel"];
if (!["debug", "info", "warn", "error"].includes(logLevel)) {
throw new ConfigError(`invalid LOG_LEVEL: ${logLevel}`);
}
return {
station: require_(env, "STATION"),
rtmpUrl,
pcmHost,
pcmPort,
nowPlayingDir: env.NOW_PLAYING_DIR ?? "/now-playing",
libraryDir: env.LIBRARY_DIR ?? "/library",
style,
tz: env.TZ ?? "UTC",
videoBitrate: env.VIDEO_BITRATE ?? "4500k",
audioBitrate: env.AUDIO_BITRATE ?? "160k",
framerate: Number(env.FRAMERATE ?? 30),
resolution: parseRes(env.RESOLUTION ?? "1920x1080"),
stationDisplayName: env.STATION_DISPLAY_NAME || undefined,
stationTagline: env.STATION_TAGLINE || undefined,
stationTuneInUrl: env.STATION_TUNE_IN_URL ?? "denpa.femboy.page",
branding: (env.BRANDING ?? "true").toLowerCase() !== "false",
healthPort: Number(env.HEALTH_PORT ?? 12010),
logLevel,
icecastStatusUrl: env.ICECAST_STATUS_URL || undefined,
};
}

69
streamer/src/ffmpeg.ts Normal file
View file

@ -0,0 +1,69 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { Writable } from "node:stream";
export interface FfmpegOpts {
rtmpUrl: string;
width: number;
height: number;
framerate: number;
videoBitrate: string;
audioBitrate: string;
}
export class Ffmpeg extends EventEmitter {
private proc: ChildProcessWithoutNullStreams | null = null;
constructor(private opts: FfmpegOpts) { super(); }
start(): { videoIn: Writable; audioIn: Writable } {
const args = [
"-loglevel", "warning",
// video in (mjpeg pipe on fd 3) — declare full-range to silence swscaler
"-color_range", "jpeg",
"-f", "image2pipe", "-c:v", "mjpeg",
"-thread_queue_size", "1024",
"-r", String(this.opts.framerate), "-i", "pipe:3",
// audio in (s16le pcm pipe on fd 4)
"-f", "s16le", "-ar", "48000", "-ac", "2",
"-thread_queue_size", "1024",
"-i", "pipe:4",
// video encode (explicit colorspace conversion silences swscaler nags)
"-vf", "scale=in_range=full:out_range=tv,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast",
"-color_range", "tv",
"-b:v", this.opts.videoBitrate,
"-maxrate", this.opts.videoBitrate, "-bufsize", "9000k",
"-g", String(this.opts.framerate * 2), "-keyint_min", String(this.opts.framerate * 2),
"-r", String(this.opts.framerate),
// audio encode
"-c:a", "aac", "-b:a", this.opts.audioBitrate, "-ar", "48000",
// output
"-f", "flv", this.opts.rtmpUrl,
];
this.proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
const stdio = (this.proc as unknown as { stdio: Writable[] }).stdio;
const videoIn = stdio[3]!;
const audioIn = stdio[4]!;
this.proc.stderr.on("data", (d) => this.emit("log", d.toString()));
this.proc.on("exit", (code) => this.emit("exit", code));
// ffmpeg exiting (e.g. RTMP drop) closes its stdins — subsequent writes
// emit EPIPE on these sockets, which would crash the supervisor as
// unhandled. Catch them; the proc.on("exit") handler does the actual restart.
videoIn.on("error", (err) => this.emit("log", `videoIn: ${err.message}`));
audioIn.on("error", (err) => this.emit("log", `audioIn: ${err.message}`));
return { videoIn, audioIn };
}
stop(): void {
this.proc?.kill("SIGTERM");
this.proc = null;
}
}

122
streamer/src/fft.ts Normal file
View file

@ -0,0 +1,122 @@
export interface SpectrumOpts {
bars: number;
sampleRate: number;
fftSize?: number;
smoothing?: number;
}
export class SpectrumAnalyzer {
private fftSize: number;
private window: Float32Array;
private buffer: Float32Array;
private bufFill = 0;
private smooth: Float32Array;
private bandStarts: number[];
private bandEnds: number[];
constructor(private opts: SpectrumOpts) {
this.fftSize = opts.fftSize ?? 1024;
this.window = new Float32Array(this.fftSize);
for (let i = 0; i < this.fftSize; i++) {
this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.fftSize - 1));
}
this.buffer = new Float32Array(this.fftSize);
this.smooth = new Float32Array(opts.bars);
[this.bandStarts, this.bandEnds] = this.makeBands();
}
private makeBands(): [number[], number[]] {
const minHz = 40;
const maxHz = Math.min(16000, this.opts.sampleRate / 2);
const fftBin = (hz: number) => Math.round((hz / this.opts.sampleRate) * this.fftSize);
const starts: number[] = [];
const ends: number[] = [];
for (let i = 0; i < this.opts.bars; i++) {
const a = minHz * Math.pow(maxHz / minHz, i / this.opts.bars);
const b = minHz * Math.pow(maxHz / minHz, (i + 1) / this.opts.bars);
starts.push(Math.max(1, fftBin(a)));
ends.push(Math.max(starts[i]! + 1, fftBin(b)));
}
return [starts, ends];
}
feed(pcm: Buffer): void {
const samples = pcm.length / 4;
let bi = this.bufFill;
for (let i = 0; i < samples; i++) {
const l = pcm.readInt16LE(i * 4);
const r = pcm.readInt16LE(i * 4 + 2);
this.buffer[bi] = ((l + r) / 2) / 32768;
bi++;
if (bi >= this.fftSize) {
this.computeFrame();
bi = 0;
}
}
this.bufFill = bi;
}
bars(): number[] {
const out = new Array<number>(this.opts.bars);
for (let i = 0; i < this.opts.bars; i++) out[i] = this.smooth[i]!;
return out;
}
private computeFrame(): void {
const re = new Float32Array(this.fftSize);
const im = new Float32Array(this.fftSize);
for (let i = 0; i < this.fftSize; i++) re[i] = this.buffer[i]! * this.window[i]!;
fftInPlace(re, im);
const alpha = this.opts.smoothing ?? 0.6;
// db scaling — same idea as web audio's getByteFrequencyData.
// hann-windowed full-scale sine has peak mag ~ fftSize/2; reference to that.
const ref = this.fftSize / 2;
const dbMin = -80;
const dbMax = -20;
for (let i = 0; i < this.opts.bars; i++) {
let mag = 0;
const s = this.bandStarts[i]!;
const e = this.bandEnds[i]!;
for (let k = s; k < e; k++) mag += Math.sqrt(re[k]! * re[k]! + im[k]! * im[k]!);
mag /= e - s;
const db = 20 * Math.log10(Math.max(mag, 1e-9) / ref);
const norm = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin)));
this.smooth[i] = alpha * this.smooth[i]! + (1 - alpha) * norm;
}
}
}
function fftInPlace(re: Float32Array, im: Float32Array): void {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
[re[i], re[j]] = [re[j]!, re[i]!];
[im[i], im[j]] = [im[j]!, im[i]!];
}
}
for (let len = 2; len <= n; len <<= 1) {
const halfLen = len >> 1;
const angle = (-2 * Math.PI) / len;
const wre = Math.cos(angle);
const wim = Math.sin(angle);
for (let i = 0; i < n; i += len) {
let cre = 1;
let cim = 0;
for (let k = 0; k < halfLen; k++) {
const tre = cre * re[i + k + halfLen]! - cim * im[i + k + halfLen]!;
const tim = cre * im[i + k + halfLen]! + cim * re[i + k + halfLen]!;
re[i + k + halfLen] = re[i + k]! - tre;
im[i + k + halfLen] = im[i + k]! - tim;
re[i + k] = re[i + k]! + tre;
im[i + k] = im[i + k]! + tim;
const ncre = cre * wre - cim * wim;
cim = cre * wim + cim * wre;
cre = ncre;
}
}
}
}

43
streamer/src/icecast.ts Normal file
View file

@ -0,0 +1,43 @@
import { EventEmitter } from "node:events";
export interface IcecastOpts {
statusUrl: string;
mountName: string;
intervalMs?: number;
}
export interface IcecastListeners { current: number; peak: number; }
export class IcecastPoller extends EventEmitter {
private timer: NodeJS.Timeout | null = null;
private peak = 0;
constructor(private opts: IcecastOpts) { super(); }
start(): void {
this.poll();
this.timer = setInterval(() => this.poll(), this.opts.intervalMs ?? 30000);
}
stop(): void {
if (this.timer) clearInterval(this.timer);
}
private async poll(): Promise<void> {
try {
const r = await fetch(this.opts.statusUrl, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`icecast status ${r.status}`);
const j = await r.json() as {
icestats: { source?: { listenurl: string; listeners: number }[] | { listenurl: string; listeners: number } };
};
const sources = j.icestats.source;
const list = Array.isArray(sources) ? sources : sources ? [sources] : [];
const found = list.find((s) => s.listenurl.endsWith(this.opts.mountName));
const current = found?.listeners ?? 0;
this.peak = Math.max(this.peak, current);
this.emit("listeners", { current, peak: this.peak });
} catch (err) {
this.emit("warn", err);
}
}
}

133
streamer/src/index.ts Normal file
View file

@ -0,0 +1,133 @@
import { createServer } from "node:http";
import { join } from "node:path";
import { loadConfig } from "./config.js";
import { NowPlayingWatcher } from "./nowplaying.js";
import { PageServer } from "./page-server.js";
import { PcmTap } from "./pcm-tap.js";
import { SpectrumAnalyzer } from "./fft.js";
import { ChromeRenderer } from "./chrome.js";
import { Ffmpeg } from "./ffmpeg.js";
import { IcecastPoller } from "./icecast.js";
const cfg = loadConfig();
const log = (level: string, msg: string, extra?: unknown) =>
console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(extra ? { extra } : {}) }));
const PAGE_PORT = 8080;
const STATIC_DIR = join(import.meta.dirname ?? __dirname, "views");
const BARS = cfg.style === "denpa" ? 48 : 72;
let lastFrameAt = 0;
async function main() {
const np = new NowPlayingWatcher({
station: cfg.station,
nowPlayingDir: cfg.nowPlayingDir,
libraryDir: cfg.libraryDir,
});
const page = new PageServer({
port: PAGE_PORT,
staticDir: STATIC_DIR,
libraryDir: cfg.libraryDir,
style: cfg.style,
station: cfg.station,
tz: cfg.tz,
tuneInUrl: cfg.stationTuneInUrl,
branding: cfg.branding,
});
const pcm = new PcmTap({ host: cfg.pcmHost, port: cfg.pcmPort });
const spec = new SpectrumAnalyzer({ bars: BARS, sampleRate: 48000 });
const ice = cfg.icecastStatusUrl
? new IcecastPoller({ statusUrl: cfg.icecastStatusUrl, mountName: `/${cfg.station}.mp3` })
: null;
const ffmpeg = new Ffmpeg({
rtmpUrl: cfg.rtmpUrl,
width: cfg.resolution.width,
height: cfg.resolution.height,
framerate: cfg.framerate,
videoBitrate: cfg.videoBitrate,
audioBitrate: cfg.audioBitrate,
});
const chrome = new ChromeRenderer({
pageUrl: `http://127.0.0.1:${PAGE_PORT}/?style=${cfg.style}`,
width: cfg.resolution.width,
height: cfg.resolution.height,
framerate: cfg.framerate,
});
await page.start();
await np.start();
np.on("change", (s) => page.pushNowPlaying(s));
if (np.current()) page.pushNowPlaying(np.current());
const pushTimer: NodeJS.Timeout = setInterval(() => page.pushSpectrum(spec.bars()), Math.round(1000 / 30));
ice?.on("listeners", (l) => page.pushListeners(l));
ice?.start();
const { videoIn, audioIn } = ffmpeg.start();
ffmpeg.on("log", (m: string) => {
const msg = m.trim();
// mjpeg decoder emits an unsuppressable swscaler "deprecated pixel format"
// nag before any filters run; cosmetic, drop it from logs.
if (msg.includes("deprecated pixel format used")) return;
if (/^\[swscaler @ 0x[0-9a-f]+\]\s*$/.test(msg)) return;
log("info", "ffmpeg", msg);
});
ffmpeg.on("exit", (code: number | null) => {
log("error", "ffmpeg exited", { code });
process.exit(1);
});
pcm.on("data", (chunk: Buffer) => {
spec.feed(chunk);
audioIn.write(chunk);
});
pcm.on("connecting", () => log("info", "pcm connecting"));
pcm.on("connected", () => log("info", "pcm connected"));
pcm.on("disconnected", () => log("warn", "pcm disconnected"));
pcm.start();
await chrome.start();
chrome.on("frame", (buf: Buffer) => {
lastFrameAt = Date.now();
videoIn.write(buf);
});
chrome.on("disconnected", () => {
log("error", "chrome disconnected");
process.exit(1);
});
// health endpoint
const health = createServer((req, res) => {
if (req.url !== "/health") { res.statusCode = 404; res.end(); return; }
const stale = Date.now() - lastFrameAt > 5000;
res.statusCode = stale ? 503 : 200;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ ok: !stale, lastFrameAt }));
});
health.listen(cfg.healthPort, "0.0.0.0", () => log("info", `health :${cfg.healthPort}`));
const shutdown = async () => {
clearInterval(pushTimer);
pcm.stop();
await chrome.stop();
ffmpeg.stop();
np.stop();
ice?.stop();
await page.stop();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main().catch((err) => {
log("error", "fatal", { err: err instanceof Error ? err.message : String(err) });
process.exit(1);
});

100
streamer/src/nowplaying.ts Normal file
View file

@ -0,0 +1,100 @@
import { EventEmitter } from "node:events";
import { existsSync, readFileSync, watch, FSWatcher } from "node:fs";
import { join } from "node:path";
export interface NowPlayingTrack {
title: string;
artist: string;
album: string;
duration: string;
cover_path: string;
}
export interface NowPlayingState {
station: string;
title: string;
artist: string;
album: string;
filename: string;
duration: string;
started_at: string;
cover_path: string;
cover_url: string;
up_next: (NowPlayingTrack & { cover_url: string })[];
}
export interface NowPlayingOpts {
station: string;
nowPlayingDir: string;
libraryDir: string;
}
export class NowPlayingWatcher extends EventEmitter {
private state: NowPlayingState | null = null;
private watcher: FSWatcher | null = null;
private debounce: NodeJS.Timeout | null = null;
private path: string;
constructor(private opts: NowPlayingOpts) {
super();
this.path = join(opts.nowPlayingDir, `${opts.station}.json`);
}
async start(): Promise<void> {
this.read();
this.watcher = watch(this.opts.nowPlayingDir, (_event, filename) => {
if (filename !== `${this.opts.station}.json`) return;
if (this.debounce) clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.read(), 50);
});
}
stop(): void {
this.watcher?.close();
this.watcher = null;
if (this.debounce) clearTimeout(this.debounce);
}
current(): NowPlayingState | null {
return this.state;
}
private read(): void {
if (!existsSync(this.path)) return;
try {
const raw = readFileSync(this.path, "utf8");
const parsed = JSON.parse(raw);
const cover_path = this.resolveCover(parsed.cover_path, parsed.filename);
const up_next = (parsed.up_next ?? []).map((t: NowPlayingTrack) => ({
...t,
cover_url: this.coverUrl(this.resolveCover(t.cover_path, "")),
}));
this.state = {
...parsed,
cover_path,
cover_url: this.coverUrl(cover_path),
up_next,
};
this.emit("change", this.state);
} catch (err) {
this.emit("warn", err);
}
}
private resolveCover(suggested: string, _filename: string): string {
if (suggested && existsSync(this.toAbsolute(suggested))) return suggested;
const stationCover = `/library/${this.opts.station}/cover.jpg`;
if (existsSync(this.toAbsolute(stationCover))) return stationCover;
return "";
}
private toAbsolute(libraryPath: string): string {
if (!libraryPath.startsWith("/library/")) return libraryPath;
return join(this.opts.libraryDir, libraryPath.slice("/library/".length));
}
private coverUrl(libraryPath: string): string {
if (!libraryPath) return "";
return `/cover?path=${encodeURIComponent(libraryPath)}`;
}
}

129
streamer/src/page-server.ts Normal file
View file

@ -0,0 +1,129 @@
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { extname, join } from "node:path";
import { EventEmitter } from "node:events";
export interface PageServerOpts {
port: number;
staticDir: string;
libraryDir: string;
style: "denpa" | "modern";
station: string;
tz: string;
tuneInUrl: string;
branding: boolean;
}
export class PageServer extends EventEmitter {
private nowClients = new Set<ServerResponse>();
private specClients = new Set<ServerResponse>();
private latestNowJson: string | null = null;
private latestListeners: { current: number; peak: number } = { current: 0, peak: 0 };
private server = createServer((req, res) => this.handle(req, res));
constructor(private opts: PageServerOpts) { super(); }
start(): Promise<void> {
return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve()));
}
stop(): Promise<void> {
for (const c of this.nowClients) c.end();
for (const c of this.specClients) c.end();
return new Promise((resolve) => this.server.close(() => resolve()));
}
pushNowPlaying(json: unknown): void {
this.latestNowJson = JSON.stringify(json);
const payload = `data: ${this.latestNowJson}\n\n`;
for (const c of this.nowClients) c.write(payload);
}
pushSpectrum(bars: number[]): void {
const payload = `data: ${JSON.stringify(bars)}\n\n`;
for (const c of this.specClients) c.write(payload);
}
pushListeners(l: { current: number; peak: number }): void {
this.latestListeners = l;
}
private handle(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/") return this.serveIndex(res);
if (url.pathname.startsWith("/assets/")) return this.serveStatic(url.pathname, res);
if (url.pathname === "/now-playing") return this.serveSse(res, this.nowClients, this.latestNowJson);
if (url.pathname === "/spectrum") return this.serveSse(res, this.specClients, null);
if (url.pathname === "/listeners") {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(this.latestListeners));
return;
}
if (url.pathname === "/cover") return this.serveCover(url, res);
res.statusCode = 404;
res.end("not found");
}
private serveIndex(res: ServerResponse): void {
const indexPath = join(this.opts.staticDir, "index.html");
if (!existsSync(indexPath)) {
res.statusCode = 500;
res.end("index not built");
return;
}
const inject = `<script>window.__STREAMER_CONFIG__=${JSON.stringify({
style: this.opts.style,
station: this.opts.station,
tz: this.opts.tz,
tuneInUrl: this.opts.tuneInUrl,
branding: this.opts.branding,
})}</script>`;
let html = "";
createReadStream(indexPath, "utf8")
.on("data", (chunk) => (html += chunk))
.on("end", () => {
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(html.replace("</head>", `${inject}</head>`));
});
}
private serveStatic(pathname: string, res: ServerResponse): void {
const file = join(this.opts.staticDir, pathname.replace(/^\/assets\//, ""));
if (!file.startsWith(this.opts.staticDir) || !existsSync(file)) {
res.statusCode = 404; res.end("not found"); return;
}
const ct = ({
".js": "application/javascript",
".css": "text/css",
".woff2": "font/woff2",
} as const)[extname(file) as ".js" | ".css" | ".woff2"] ?? "application/octet-stream";
res.setHeader("content-type", ct);
res.setHeader("cache-control", "max-age=86400");
createReadStream(file).pipe(res);
}
private serveSse(res: ServerResponse, set: Set<ServerResponse>, initial: string | null): void {
res.setHeader("content-type", "text/event-stream");
res.setHeader("cache-control", "no-cache");
res.setHeader("connection", "keep-alive");
res.flushHeaders();
set.add(res);
if (initial) res.write(`data: ${initial}\n\n`);
res.on("close", () => set.delete(res));
}
private serveCover(url: URL, res: ServerResponse): void {
const requested = url.searchParams.get("path") ?? "";
if (!requested.startsWith("/library/")) { res.statusCode = 400; res.end(); return; }
const file = join(this.opts.libraryDir, requested.slice("/library/".length));
if (!file.startsWith(this.opts.libraryDir) || !existsSync(file)) {
res.statusCode = 404; res.end(); return;
}
const ext = extname(file).toLowerCase();
res.setHeader("content-type", ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg");
res.setHeader("cache-control", "no-store");
createReadStream(file).pipe(res);
}
}

51
streamer/src/pcm-tap.ts Normal file
View file

@ -0,0 +1,51 @@
import { EventEmitter } from "node:events";
import { Socket, connect } from "node:net";
export interface PcmTapOpts {
host: string;
port: number;
reconnectInitialMs?: number;
reconnectMaxMs?: number;
}
export class PcmTap extends EventEmitter {
private socket: Socket | null = null;
private stopped = false;
private backoff: number;
constructor(private opts: PcmTapOpts) {
super();
this.backoff = opts.reconnectInitialMs ?? 1000;
}
start(): void {
if (this.stopped) return;
this.connect();
}
stop(): void {
this.stopped = true;
this.socket?.destroy();
}
private connect(): void {
this.emit("connecting");
const sock = connect({ host: this.opts.host, port: this.opts.port });
this.socket = sock;
sock.on("connect", () => {
this.emit("connected");
this.backoff = this.opts.reconnectInitialMs ?? 1000;
});
sock.on("data", (chunk) => this.emit("data", chunk));
sock.on("error", (err) => this.emit("warn", err));
sock.on("close", () => {
if (this.stopped) return;
this.emit("disconnected");
const max = this.opts.reconnectMaxMs ?? 30000;
const wait = Math.min(this.backoff, max);
this.backoff = Math.min(this.backoff * 2, max);
setTimeout(() => this.connect(), wait);
});
}
}

View file

@ -0,0 +1,41 @@
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { StreamDenpa } from "./denpa.tsx";
import { StreamModern } from "./modern.tsx";
declare global {
interface Window {
__STREAMER_CONFIG__: { style: "denpa" | "modern"; station: string; tz: string; tuneInUrl: string; branding: boolean };
}
}
function useListeners(): { current: number; peak: number } {
const [v, setV] = useState({ current: 0, peak: 0 });
useEffect(() => {
let peak = 0;
const tick = async () => {
try {
const r = await fetch(`/listeners`);
if (r.ok) {
const j = await r.json();
peak = Math.max(peak, j.current ?? 0);
setV({ current: j.current ?? 0, peak });
}
} catch { /* ignore */ }
};
tick();
const t = setInterval(tick, 30000);
return () => clearInterval(t);
}, []);
return v;
}
function App() {
const cfg = window.__STREAMER_CONFIG__;
const listeners = useListeners();
return cfg.style === "denpa"
? <StreamDenpa cfg={cfg} listeners={listeners} />
: <StreamModern cfg={cfg} listeners={listeners} />;
}
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

View file

@ -0,0 +1,19 @@
import { build } from "esbuild";
import { copyFileSync, mkdirSync } from "node:fs";
const outDir = "dist/views";
mkdirSync(outDir, { recursive: true });
copyFileSync("src/views/index.html", `${outDir}/index.html`);
await build({
entryPoints: ["src/views/App.tsx"],
bundle: true,
format: "esm",
target: "es2022",
jsx: "automatic",
outfile: `${outDir}/main.js`,
loader: { ".tsx": "tsx", ".ts": "ts" },
define: { "process.env.NODE_ENV": '"production"' },
minify: true,
});
console.log("views bundled →", outDir);

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock, fmtClockSec, fmtJpDate } from "./shared/format.ts";
const KAOMOJI_STREAM = ["(´。• ᵕ •。`)", "٩(◕‿◕)۶", "(。◕‿◕。)", "(>ω<)", "(✿◠‿◠)"];
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamDenpa({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#1a0820" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 4);
const nameJp = "ラジオ";
const channel = "CH.01";
const bitrate = 192;
const codec = "MP3/OPUS";
const genre = "auto";
const uptime = "—";
return (
<div className="sd-root">
<style>{`
.sd-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background:
radial-gradient(ellipse at 25% 0%, #5ef7ff22 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, #ff3ea522 0%, transparent 55%),
#1a0820;
color: #fff4e8;
font-family: var(--f-mono);
position: relative; overflow: hidden;
padding: 56px 64px 0;
font-variant-emoji: text;
}
.sd-root::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.07; mix-blend-mode: overlay;
}
.sd-root::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
opacity: 0.4;
}
.sd-header { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 36px; margin-bottom: 36px; position: relative; z-index: 2; }
.sd-wm .eyebrow { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 6px; color: #ff3ea5; }
.sd-wm .big {
display: block; font-family: var(--f-pixel); font-size: 110px; line-height: 0.9;
margin-top: 10px; padding-right: 0.12em;
color: transparent;
background: linear-gradient(180deg, #fff4e8 0%, #fff4e8 50%, #ff3ea5 50%, #ff3ea5 100%);
-webkit-background-clip: text; background-clip: text;
filter: drop-shadow(0 0 14px #ff3ea580);
}
.sd-channel { font-family: var(--f-hand); font-size: 34px; transform: rotate(-2deg); line-height: 1.05; max-width: 280px; align-self: flex-end; padding-bottom: 14px; }
.sd-status { text-align: right; font-family: var(--f-mono); font-size: 18px; line-height: 1.5; color: #5ef7ff; padding-bottom: 12px; }
.sd-status .live { color: #ff3ea5; font-family: var(--f-pixel); font-size: 22px; letter-spacing: 3px; }
.sd-status .blink { animation: sd-blink 1s steps(2) infinite; }
@keyframes sd-blink { 50% { opacity: 0.15; } }
.sd-body { display: grid; grid-template-columns: 1.3fr 1fr; gap: 48px; position: relative; z-index: 2; }
.sd-deck {
background: linear-gradient(180deg, #fff4e8 0%, #f5e0c4 100%);
color: #0a0410; padding: 32px;
border: 4px solid #0a0410;
box-shadow: 12px 12px 0 #ff3ea5, 24px 24px 0 #5ef7ff;
position: relative;
}
.sd-deck::before { content: ""; position: absolute; inset: 6px; border: 1.5px dashed #0a041044; pointer-events: none; }
.sd-screen { background: #0a0410; color: #5eff9b; font-family: var(--f-mono); font-size: 18px; padding: 22px 26px; border: 3px inset #0a0410; position: relative; overflow: hidden; }
.sd-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px); }
.sd-screen-row { display: flex; justify-content: space-between; opacity: 0.78; }
.sd-screen .now { font-family: var(--f-pixel); font-size: 56px; line-height: 1.05; color: #fff4e8; text-shadow: 0 0 12px #5eff9b88; margin: 10px 0 6px; }
.sd-screen .artist { font-size: 24px; color: #5ef7ff; margin-bottom: 14px; }
.sd-progress-row { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.sd-progress { flex: 1; height: 6px; background: #5eff9b22; border: 1px solid #5eff9b66; }
.sd-progress-fill { height: 100%; background: #5eff9b; transition: width 200ms linear; }
.sd-time { font-size: 18px; color: #5ef7ff; min-width: 56px; }
.sd-reels { display: flex; align-items: center; gap: 22px; padding: 22px; margin-top: 26px; background: #0a0410; border: 3px solid #0a0410; }
.sd-reel {
width: 150px; height: 150px; border-radius: 50%;
background:
radial-gradient(circle, #fff4e8 0 22px, transparent 22px),
conic-gradient(from 0deg, #2a1030 0 25%, #1a0820 25% 50%, #2a1030 50% 75%, #1a0820 75%);
border: 3px solid #fff4e8;
animation: sd-spin 1.6s linear infinite;
position: relative;
}
.sd-reel::before { content: ""; position: absolute; inset: 10px; border-radius: 50%; border: 1px dashed #fff4e833; }
@keyframes sd-spin { to { transform: rotate(360deg); } }
.sd-strip { flex: 1; height: 10px; background: linear-gradient(90deg, #fff4e8 0%, #d4a578 50%, #fff4e8 100%); border-top: 2px solid #0a0410; border-bottom: 2px solid #0a0410; }
.sd-spectrum { display: flex; align-items: flex-end; gap: 3px; height: 110px; margin-top: 22px; padding: 12px; background: #0a0410; border: 3px solid #0a0410; }
.sd-spec-bar { flex: 1; min-height: 4px; background: linear-gradient(180deg, #ff3ea5 0%, #ffe24a 50%, #5eff9b 100%); transition: height 80ms linear; }
.sd-right { display: flex; flex-direction: column; gap: 28px; min-width: 0; }
.sd-card { background: #1a0820; border: 2px solid #0a0410; padding: 24px 28px; position: relative; }
.sd-card::before { content: ""; position: absolute; inset: 5px; border: 1px dashed #ff3ea533; pointer-events: none; }
.sd-h { font-family: var(--f-pixel); font-size: 18px; letter-spacing: 4px; color: #ffe24a; margin-bottom: 4px; }
.sd-h-en { font-family: var(--f-pixel); font-size: 24px; color: #fff4e8; letter-spacing: 1px; }
.sd-h-sub { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; margin-top: 6px; }
.sd-queue { margin-top: 14px; display: flex; flex-direction: column; gap: 8px; }
.sd-queue-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; padding: 8px 4px; border-bottom: 1px dashed #ff3ea522; align-items: baseline; }
.sd-queue-row:last-child { border-bottom: none; }
.sd-queue-row .n { font-family: var(--f-mono); font-size: 14px; color: #5ef7ff; }
.sd-queue-row .t { font-family: var(--f-pixel); font-size: 22px; color: #fff4e8; }
.sd-queue-row .a { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; }
.sd-queue-row .d { font-family: var(--f-mono); font-size: 14px; color: #ff3ea5; align-self: center; }
.sd-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 14px; }
.sd-stat { padding: 14px 16px; background: #0a0410; border: 1.5px dashed #5ef7ff44; }
.sd-stat .lbl { font-family: var(--f-pixel); font-size: 12px; letter-spacing: 2px; color: #ffe24a; }
.sd-stat .val { font-family: var(--f-pixel); font-size: 32px; color: #fff4e8; margin-top: 4px; line-height: 1; }
.sd-stat .sub { font-family: var(--f-mono); font-size: 12px; color: #5ef7ff; margin-top: 4px; }
.sd-bomb { position: absolute; pointer-events: none; z-index: 5; }
.sd-bomb.b1 { top: 36px; right: 340px; }
.sd-bomb.b2 { top: 250px; right: 36px; }
.sd-bottom {
position: absolute; left: 0; right: 0; bottom: 0;
height: 64px; background: #0a0410;
border-top: 2px dashed #ff3ea566;
display: grid; grid-template-columns: auto 1fr auto;
align-items: center; gap: 24px; padding: 0 36px;
z-index: 6;
}
.sd-listen { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 2px; color: #5ef7ff; }
.sd-listen b { color: #ff3ea5; margin-right: 10px; }
.sd-ticker { overflow: hidden; white-space: nowrap; }
.sd-ticker-inner { display: inline-block; padding-left: 100%; animation: sd-marq 36s linear infinite; color: #ffe24a; font-family: var(--f-mono); font-size: 16px; letter-spacing: 1px; }
@keyframes sd-marq { to { transform: translateX(-100%); } }
.sd-clock { font-family: var(--f-pixel); font-size: 26px; color: #fff4e8; letter-spacing: 2px; padding: 6px 14px; background: #1a0820; border: 1.5px dashed #5ef7ff66; }
.sd-sticker { display: inline-block; padding: 10px 18px; font-family: var(--f-pixel); font-size: 20px; letter-spacing: 1px; border: 2px solid #0a0410; box-shadow: 4px 4px 0 #0a0410; }
`}</style>
<div className="sd-header">
{cfg.branding && (
<>
<div className="sd-wm">
<div className="eyebrow"> TRANSMISSION</div>
<span className="big">denpa.fm</span>
</div>
<div className="sd-channel">
{channel} <br/>
{nameJp}<br/>
{"<3"} block radio
</div>
</>
)}
<div className="sd-status">
<div className="live">[<span className="blink">REC</span>] ON AIR</div>
<div>{fmtJpDate(now)}</div>
<div>{fmtClockSec(now)} JST</div>
<div>{listeners.current} listening · peak {listeners.peak}</div>
</div>
</div>
<div className="sd-body">
<div className="sd-deck">
<div className="sd-screen">
<div className="sd-screen-row">
<span>&gt;&gt; NOW PLAYING</span>
<span>{bitrate}kbps · {codec}</span>
</div>
<div className="now">{np.title}</div>
<div className="artist">{np.artist} {np.album}</div>
<div className="sd-progress-row">
<span className="sd-time">{fmtMs(elapsed)}</span>
<div className="sd-progress"><div className="sd-progress-fill" style={{ width: pct + "%" }}></div></div>
<span className="sd-time">{fmtMs(duration)}</span>
</div>
</div>
<div className="sd-reels">
<div className="sd-reel"></div>
<div className="sd-strip"></div>
<div className="sd-reel"></div>
</div>
<div className="sd-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sd-spec-bar" style={{ height: `${Math.max(4, v * 100)}%` }}></div>
))}
</div>
</div>
<div className="sd-right">
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// up next</div>
<div className="sd-h-sub">auto-DJ · no ads · no skips · forever</div>
<div className="sd-queue">
{upNext.map((q, i) => (
<div key={i} className="sd-queue-row">
<span className="n">0{i + 1}</span>
<span>
<span className="t">{q.title}</span><br/>
<span className="a">{q.artist}</span>
</span>
<span className="d">{fmtMs(Number(q.duration))}</span>
</div>
))}
</div>
</div>
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// station info</div>
<div className="sd-stats">
<div className="sd-stat">
<div className="lbl">CHANNEL</div>
<div className="val">{channel}</div>
<div className="sub">{genre}</div>
</div>
<div className="sd-stat">
<div className="lbl">UPTIME</div>
<div className="val">{uptime}</div>
<div className="sub">since 2024</div>
</div>
<div className="sd-stat">
<div className="lbl">LISTENING</div>
<div className="val">{listeners.current}</div>
<div className="sub">peak {listeners.peak} today</div>
</div>
<div className="sd-stat">
<div className="lbl">BITRATE</div>
<div className="val">{bitrate}k</div>
<div className="sub">{codec}</div>
</div>
</div>
</div>
</div>
</div>
{/* Sticker bombs */}
<div className="sd-bomb b1"><span className="sd-sticker" style={{ background: "#5ef7ff", color: "#0a0410", transform: "rotate(-6deg)" }}>// 24/7 ON AIR //</span></div>
<div className="sd-bomb b2"><span className="sd-sticker" style={{ background: "#ffe24a", color: "#0a0410", transform: "rotate(5deg)" }}>SIDE A · CH.01</span></div>
{/* Bottom strip */}
{cfg.branding && (
<div className="sd-bottom">
<div className="sd-listen"><b>&gt;&gt;</b> tune in @ {cfg.tuneInUrl}</div>
<div className="sd-ticker">
<div className="sd-ticker-inner">
now broadcasting from a small room in tokyo 24/7 always-on {cfg.station} station stream URL: {cfg.tuneInUrl} requests via @denpa_bot no ads · no algorithms · just blocks {KAOMOJI_STREAM.join(" ※ ")}
</div>
</div>
<div className="sd-clock">{fmtClock(now)}</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>denpa streamer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DotGothic16&family=VT323&family=Reenie+Beanie&family=Inter:wght@400;500;600;700;800&display=swap" />
<style>
:root {
--f-pixel: "DotGothic16", "VT323", monospace;
--f-mono: "VT323", ui-monospace, monospace;
--f-hand: "Reenie Beanie", cursive;
}
html, body { margin: 0; padding: 0; background: #0a0410; }
body { font-family: "Inter", system-ui, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,268 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock } from "./shared/format.ts";
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamModern({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#0c0d10" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 3);
const bitrate = 192;
return (
<div className="sm-root">
<style>{`
.sm-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background: #0c0d10;
color: #f5f5f0;
font-family: "Inter", system-ui, sans-serif;
position: relative; overflow: hidden;
padding: 80px;
display: grid;
grid-template-columns: 720px 1fr;
gap: 80px;
align-items: center;
}
/* Ambient color wash from artwork */
.sm-bg {
position: absolute; inset: -20%;
background:
radial-gradient(ellipse at 25% 40%, #4a6b7a 0%, transparent 50%),
radial-gradient(ellipse at 75% 60%, #6a8a5a 0%, transparent 55%);
filter: blur(80px); opacity: 0.5; z-index: 0;
}
.sm-grain {
position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.04; mix-blend-mode: overlay; z-index: 1;
}
/* Brand mark — top-left */
.sm-brand {
position: absolute; top: 48px; left: 80px;
display: flex; align-items: center; gap: 14px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 4px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-brand-dot { width: 10px; height: 10px; border-radius: 50%; background: #e8546b; box-shadow: 0 0 12px #e8546b; }
/* Live + clock — top-right */
.sm-meta-top {
position: absolute; top: 48px; right: 80px;
display: flex; align-items: center; gap: 28px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 3px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-live { display: flex; align-items: center; gap: 10px; color: #e8546b; }
.sm-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #e8546b; animation: sm-pulse 1.6s ease-in-out infinite; }
@keyframes sm-pulse { 50% { opacity: 0.2; transform: scale(0.8); } }
.sm-clock { font-variant-numeric: tabular-nums; color: #f5f5f0; font-weight: 500; letter-spacing: 2px; }
/* Artwork */
.sm-art-wrap { position: relative; z-index: 2; aspect-ratio: 1 / 1; }
.sm-art {
width: 100%; aspect-ratio: 1 / 1;
border-radius: 12px;
background:
linear-gradient(135deg, #4a6b7a 0%, #2a3a48 100%);
box-shadow:
0 60px 120px rgba(0,0,0,0.6),
0 0 0 1px rgba(255,255,255,0.04);
position: relative;
overflow: hidden;
object-fit: cover;
display: block;
}
/* Stylized cover for "Sweden" — soft hills + sun */
.sm-art svg { width: 100%; height: 100%; display: block; }
/* Right column */
.sm-right { position: relative; z-index: 2; max-width: 880px; }
.sm-eyebrow {
font-family: "Inter", sans-serif; font-size: 13px; letter-spacing: 4px;
text-transform: uppercase; color: #f5f5f0aa; margin-bottom: 20px;
display: flex; gap: 18px; align-items: center;
}
.sm-eyebrow .pill {
padding: 4px 10px; border: 1px solid #f5f5f033; border-radius: 999px;
font-size: 11px; letter-spacing: 2px;
}
.sm-title {
font-family: "Inter", "Helvetica Neue", sans-serif;
font-weight: 800;
font-size: 124px; line-height: 0.95;
letter-spacing: -0.04em;
color: #f5f5f0;
margin: 0 0 24px;
}
.sm-artist {
font-family: "Inter", sans-serif; font-weight: 500;
font-size: 36px; color: #d4d0c4;
margin-bottom: 6px;
}
.sm-album {
font-family: "Inter", sans-serif; font-weight: 400;
font-size: 22px; color: #d4d0c499;
margin-bottom: 56px;
}
/* Progress */
.sm-progress {
height: 4px; background: #f5f5f01a;
border-radius: 2px; overflow: hidden;
margin-bottom: 12px;
}
.sm-progress-fill {
height: 100%; background: #f5f5f0;
width: 0%;
transition: width 200ms linear;
border-radius: 2px;
}
.sm-times {
display: flex; justify-content: space-between;
font-family: "Inter", sans-serif; font-size: 16px;
color: #f5f5f0aa; font-variant-numeric: tabular-nums;
letter-spacing: 1px;
margin-bottom: 56px;
}
/* Spectrum (subtle, bottom of right column) */
.sm-spectrum {
display: flex; align-items: flex-end; gap: 3px;
height: 64px;
margin-bottom: 32px;
}
.sm-spec-bar {
flex: 1; min-height: 2px;
background: #f5f5f088;
border-radius: 1px;
transition: height 80ms linear;
}
/* Up next — minimal */
.sm-upnext {
border-top: 1px solid #f5f5f01a;
padding-top: 24px;
}
.sm-upnext-h {
font-family: "Inter", sans-serif; font-size: 12px;
letter-spacing: 4px; text-transform: uppercase;
color: #f5f5f088;
margin-bottom: 16px;
}
.sm-upnext-list { display: flex; flex-direction: column; gap: 10px; }
.sm-upnext-row {
display: grid; grid-template-columns: 1fr auto;
gap: 24px; align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f00f;
}
.sm-upnext-row:last-child { border-bottom: none; }
.sm-upnext-row .t {
font-family: "Inter", sans-serif; font-size: 22px; font-weight: 500;
color: #f5f5f0;
}
.sm-upnext-row .a { font-size: 16px; color: #d4d0c499; margin-left: 14px; font-weight: 400; }
.sm-upnext-row .d { font-size: 16px; color: #f5f5f088; font-variant-numeric: tabular-nums; }
/* Bottom strip */
.sm-bottom {
position: absolute; left: 80px; right: 80px; bottom: 56px;
display: flex; justify-content: space-between; align-items: center;
font-family: "Inter", sans-serif; font-size: 13px;
letter-spacing: 3px; text-transform: uppercase;
color: #f5f5f088;
z-index: 3;
}
.sm-bottom .listeners { color: #f5f5f0; }
`}</style>
<div className="sm-bg"></div>
<div className="sm-grain"></div>
{cfg.branding && (
<div className="sm-brand">
<span className="sm-brand-dot"></span>
denpa.fm {cfg.station}
</div>
)}
<div className="sm-meta-top">
<div className="sm-live"><span className="sm-live-dot"></span>Live</div>
<div className="sm-clock">{fmtClock(now)} JST</div>
</div>
{/* Artwork (left) */}
<div className="sm-art-wrap">
<img className="sm-art" src={np.cover_url} alt="" />
</div>
{/* Right column */}
<div className="sm-right">
<div className="sm-eyebrow">
<span>Now playing</span>
</div>
<h1 className="sm-title">{np.title}</h1>
<div className="sm-artist">{np.artist}</div>
<div className="sm-album">{np.album} · {bitrate}kbps</div>
<div className="sm-progress"><div className="sm-progress-fill" style={{ width: pct + "%" }}></div></div>
<div className="sm-times">
<span>{fmtMs(elapsed)}</span>
<span>{fmtMs(duration)}</span>
</div>
<div className="sm-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sm-spec-bar" style={{ height: `${Math.max(3, v * 100)}%`, opacity: 0.3 + v * 0.6 }}></div>
))}
</div>
<div className="sm-upnext">
<div className="sm-upnext-h">Up next</div>
<div className="sm-upnext-list">
{upNext.map((q, i) => (
<div key={i} className="sm-upnext-row">
<div>
<span className="t">{q.title}</span>
<span className="a">{q.artist}</span>
</div>
<div className="d">{fmtMs(Number(q.duration))}</div>
</div>
))}
</div>
</div>
</div>
{cfg.branding && (
<div className="sm-bottom">
<span>Tune in at <span className="listeners">{cfg.tuneInUrl}</span></span>
<span><span className="listeners">{listeners.current}</span> listeners · 24/7 · auto-DJ</span>
<span>{cfg.tuneInUrl}</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
export const fmtMs = (sec: number): string => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
export const fmtClock = (d: Date): string =>
`${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
export const fmtClockSec = (d: Date): string =>
`${fmtClock(d)}:${d.getSeconds().toString().padStart(2, "0")}`;
export const fmtJpDate = (d: Date): string =>
`${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useElapsed(startedAt: number, duration: number): number {
const [now, setNow] = useState(() => Date.now() / 1000);
useEffect(() => {
const t = setInterval(() => setNow(Date.now() / 1000), 200);
return () => clearInterval(t);
}, []);
return Math.min(duration, Math.max(0, now - startedAt));
}

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export interface NowPlayingTrack {
title: string;
artist: string;
album: string;
duration: string;
cover_url: string;
}
export interface NowPlayingState {
station: string;
title: string;
artist: string;
album: string;
duration: string;
started_at: string;
cover_url: string;
up_next: NowPlayingTrack[];
}
export function useNowPlaying(): NowPlayingState | null {
const [state, setState] = useState<NowPlayingState | null>(null);
useEffect(() => {
const es = new EventSource("/now-playing");
es.onmessage = (e) => setState(JSON.parse(e.data));
return () => es.close();
}, []);
return state;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
export function useSpectrum(): number[] {
const [bars, setBars] = useState<number[]>([]);
useEffect(() => {
const es = new EventSource("/spectrum");
es.onmessage = (e) => setBars(JSON.parse(e.data));
return () => es.close();
}, []);
return bars;
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { loadConfig, ConfigError } from "../src/config.ts";
const baseEnv = {
STATION: "minecraft",
RTMP_URL: "rtmp://example/live/key",
LIQUIDSOAP_PCM: "tcp://liquidsoap:9100",
NOW_PLAYING_DIR: "/now-playing",
LIBRARY_DIR: "/library",
STYLE: "denpa",
TZ: "Asia/Tokyo",
VIDEO_BITRATE: "4500k",
AUDIO_BITRATE: "160k",
FRAMERATE: "30",
RESOLUTION: "1920x1080",
STATION_TUNE_IN_URL: "denpa.femboy.page",
HEALTH_PORT: "12010",
LOG_LEVEL: "info",
};
describe("loadConfig", () => {
it("parses required fields", () => {
const cfg = loadConfig(baseEnv);
expect(cfg.station).toBe("minecraft");
expect(cfg.rtmpUrl).toBe("rtmp://example/live/key");
expect(cfg.style).toBe("denpa");
expect(cfg.framerate).toBe(30);
});
it("rejects invalid style", () => {
expect(() => loadConfig({ ...baseEnv, STYLE: "weird" })).toThrow(ConfigError);
});
it("rejects malformed rtmp url", () => {
expect(() => loadConfig({ ...baseEnv, RTMP_URL: "not-a-url" })).toThrow(ConfigError);
});
it("rejects missing required field", () => {
const { STATION: _omit, ...rest } = baseEnv;
expect(() => loadConfig(rest)).toThrow(ConfigError);
});
it("parses pcm tcp url", () => {
const cfg = loadConfig(baseEnv);
expect(cfg.pcmHost).toBe("liquidsoap");
expect(cfg.pcmPort).toBe(9100);
});
});

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { SpectrumAnalyzer } from "../src/fft.ts";
function sineFrame(freqHz: number, sampleRate = 48000, samples = 1024): Buffer {
const buf = Buffer.alloc(samples * 2 * 2); // s16le stereo
for (let i = 0; i < samples; i++) {
const v = Math.round(Math.sin((2 * Math.PI * freqHz * i) / sampleRate) * 16000);
buf.writeInt16LE(v, i * 4);
buf.writeInt16LE(v, i * 4 + 2);
}
return buf;
}
describe("SpectrumAnalyzer", () => {
it("produces N bars of normalized [0..1] floats", () => {
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
a.feed(sineFrame(1000));
const bars = a.bars();
expect(bars).toHaveLength(48);
bars.forEach((v) => {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
});
});
it("a 200hz sine puts most energy in low bars (logarithmic binning)", () => {
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
for (let n = 0; n < 4; n++) a.feed(sineFrame(200));
const bars = a.bars();
const lowSum = bars.slice(0, 16).reduce((s, v) => s + v, 0);
const highSum = bars.slice(32).reduce((s, v) => s + v, 0);
expect(lowSum).toBeGreaterThan(0.1);
expect(lowSum).toBeGreaterThan(highSum + 0.1);
});
it("silence produces all-near-zero bars", () => {
const a = new SpectrumAnalyzer({ bars: 72, sampleRate: 48000 });
a.feed(Buffer.alloc(1024 * 4));
const bars = a.bars();
bars.forEach((v) => expect(v).toBeLessThan(0.01));
});
});

View file

@ -0,0 +1,3 @@
name: Minecraft
description: blocks
color: "#5ef7ff"

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

View file

@ -0,0 +1,14 @@
{
"station": "minecraft",
"title": "Sweden",
"artist": "C418",
"album": "Volume Alpha",
"filename": "/library/minecraft/tracks/Volume Alpha/18. Sweden.flac",
"duration": "215",
"started_at": "1730358000",
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg",
"up_next": [
{ "title": "Wet Hands", "artist": "C418", "album": "Volume Alpha", "duration": "90",
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg" }
]
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, cpSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { NowPlayingWatcher } from "../src/nowplaying.ts";
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "denpa-np-"));
cpSync("tests/fixtures/now-playing", join(tmpRoot, "now-playing"), { recursive: true });
cpSync("tests/fixtures/library", join(tmpRoot, "library"), { recursive: true });
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
describe("NowPlayingWatcher", () => {
it("reads initial state from disk", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
const state = watcher.current();
expect(state).not.toBeNull();
expect(state!.title).toBe("Sweden");
expect(state!.cover_url).toMatch(/^\/cover\?path=/);
expect(state!.up_next).toHaveLength(1);
watcher.stop();
});
it("rewrites cover_path to /cover?path=… url", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
expect(watcher.current()!.cover_url).toContain(
encodeURIComponent("/library/minecraft/tracks/Volume Alpha/cover.jpg"),
);
watcher.stop();
});
it("emits change events on file rewrite", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
let changes = 0;
watcher.on("change", () => changes++);
const newPayload = JSON.stringify({
station: "minecraft",
title: "Pigstep",
artist: "Lena Raine",
album: "Nether Update",
filename: "/library/minecraft/tracks/Nether/01. Pigstep.flac",
duration: "148",
started_at: "1730358500",
cover_path: "",
up_next: [],
});
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), newPayload);
await new Promise((r) => setTimeout(r, 200));
expect(changes).toBeGreaterThan(0);
expect(watcher.current()!.title).toBe("Pigstep");
watcher.stop();
});
it("falls back to station cover when track cover missing", async () => {
const payload = JSON.stringify({
station: "minecraft",
title: "Untagged Track",
artist: "?",
album: "?",
filename: "/library/minecraft/tracks/UnknownAlbum/track.flac",
duration: "120",
started_at: "1730358000",
cover_path: "",
up_next: [],
});
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), payload);
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
expect(watcher.current()!.cover_url).toContain(
encodeURIComponent("/library/minecraft/cover.jpg"),
);
watcher.stop();
});
});

19
streamer/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/views/**/*", "node_modules", "dist"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
});