dfejza.com
All builds
ML & Audio

RadioJP

Live· beta

Japanese radio streaming with real-time AI transcription. A faster-whisper pipeline continuously transcribes live broadcasts, MeCab adds furigana readings, and the frontend renders word-level karaoke highlighting synced to playback. Also features HLS streaming, station search, favorites, regional grouping, and RadiKo authentication for premium streams.

TypeScriptReactNext.jsHLS.jsfaster-whisperPythonMeCabZustand

AI-generated: this write-up was drafted by AI from the project's source code and may contain inaccuracies.

RadioJP streams Japanese radio with live AI captions: a faster-whisper pipeline transcribes broadcasts as they air, MeCab adds furigana readings, and the player renders the transcript synced to playback so you can read along with what you hear. The transcription pipeline runs in a separate Python service; this writeup covers the in-repo Next.js frontend — HLS playback, caption sync and gating, the Zustand player store, and RadiKo authentication. It is live and usable, currently at beta.

Architecture

The frontend talks to a backend service over three channels: HTTP for station lists, stream manifests, and archive files; an HLS manifest+segment stream for live audio; and a Server-Sent Events stream for captions. A single <audio> element, owned by a Zustand store mounted in the app layout, carries playback across page navigations. Live audio runs through an HLS.js singleton; archive playback sets the audio src directly. Captions match against playback time and a caption gate holds audio back when it outruns the transcript.

flowchart TD
    accTitle: RadioJP frontend playback and caption pipeline
    accDescr: A backend service feeds the Zustand playback store over HTTP, HLS, and SSE. The store routes live audio to an HLS.js singleton and archive audio to direct file playback; the live path drives the SSE caption layer, matched to broadcast time.
    BE["Backend service<br/>stations · live HLS · archive · captions (SSE)"]
    STORE["usePlaybackStore (Zustand, app layout)<br/>one &lt;audio&gt; element · live/archive mode<br/>persists volume + favorites"]
    HLS["HLS.js singleton<br/>3-tier error recovery"]
    ARC["audio.src = file<br/>Ogg/Opus · seek offset"]
    CAP["useCaptions (SSE)<br/>match by EXT-X-PROGRAM-DATE-TIME<br/>gate audio at transcript horizon · furigana"]
    BE --> STORE
    STORE -->|live| HLS
    STORE -->|archive| ARC
    HLS --> CAP

Live playback and recovery

Live audio is HLS over 6-second segments. The player intentionally sits ~15 minutes behind the live edge (liveSyncDurationCount: 150) for buffer headroom, with a force-jump at ~25 minutes. HLS.js is held as a module-level singleton rather than React state, since it owns a media element and must survive re-renders.

Errors escalate in three tiers driven by a consecutive-error counter: buffering (≤2, silent auto-retry), recovering (3-5, amber indicator), and degraded (>5, stop retrying and show "tap to retry"). A missing recording short-circuits straight to degraded instead of retrying. Fatal network errors call startLoad(-1); media errors call recoverMediaError(); non-fatal errors are left to HLS.js's own backoff rather than overridden. A health beacon logs state every 10s, and a drift check resyncs to live if playback falls more than 25 minutes behind wall clock, with a 60-second cooldown.

"Jump to live" deliberately does not trust HLS.js's liveSyncPosition, which silently clamps to the start of a shallow manifest. Instead the target is derived from the seekable range end minus the configured lag, falling back toward the live edge if that lands in the roll-off margin. Tab-sleep recovery checks whether currentTime is advancing after the tab is foregrounded and resyncs if playback stalled.

Caption sync and gating

Captions arrive over SSE as discrete segments, each with UTC start/end timestamps and optional word-level timings and furigana readings. They are matched to the audio's actual broadcast time via EXT-X-PROGRAM-DATE-TIME (HLS.js playingDate), so no separate sync endpoint is needed. Live matching is by UTC millisecond with a 2-second tolerance and a most-recent-before fallback; archive matching is by file offset in seconds. The overlay re-evaluates the active caption every 500ms.

The transcriber lags real-time audio by roughly 10-30 seconds (Whisper batches audio into chunks). Near the live edge, playback can outrun the newest caption, which would show captions stuck behind the audio. A caption gate fixes this: a 500ms loop compares the audio's broadcast time to the horizon (the latest segment's utc_end). It pauses audio within 2 seconds of the horizon and resumes once captions are 5 seconds clear. The gate releases cleanly when captions are toggled off or the component unmounts, so it never leaves audio stuck paused.

The SSE connection self-heals: a staleness watchdog reconnects after 30 seconds without data, a manual reconnect fires when the browser closes the stream, and foregrounding a backgrounded tab forces a fresh connection. Retained segments are capped with a ring buffer (trimmed to 500), sized to cover the player's worst-case live-edge lag.

State and auth

The Zustand store persists only volume and favorites to localStorage; runtime state (current station, playback status, the audio element) is rebuilt on mount. A restored station does not auto-play — the user must explicitly resume. Archive mode loads recordings (Ogg/Opus) by setting the audio src directly, with a captured seek offset applied on loadedmetadata so a later store update can't seek into the wrong hour.

RadiKo authentication is a key exchange: a JWT is stored in memory and sent as a Bearer token, with httpOnly cookies used for desktop. The bearer fallback exists specifically for iOS Safari, where Intelligent Tracking Prevention blocks cross-origin cookies. A fetch wrapper attaches the token and redirects to login on any 401 outside the auth routes.