A self-hosted radio station. One continuous broadcast, a minimal web player, and optional Telegram controls and AI host transitions.
- Live radio — FFmpeg turns your audio directory into a shared MP3 broadcast.
- Web player — responsive layout, track metadata, embedded artwork, cover-colored backgrounds and a real-time audio spectrum.
- Installable PWA — desktop and home-screen playback, with an offline page shell. Live audio still requires a connection.
- Telegram bot — upload audio or voice messages, list tracks, skip, mute and delete files.
- AI host — optional spoken transitions with music ducking and a smooth return to full volume. Playback continues normally if preparation fails.
- Station stats — uptime, persistent visit/listen totals and active stream connections. No database required.
Requires Node.js 20+ and FFmpeg / ffprobe on PATH. Bun and a Telegram
bot token are not required for the radio stream.
git clone https://github.com/StackStoreHQ/bun-fm.git
cd bun-fm
npm installCopy config.example.yaml to config.yaml (required),
then put audio files in media/. The directory is the playlist.
BunFM exits on startup when config.yaml is missing.
Run npm run doctor to test connectivity for configured LLM, TTS, and Telegram
services. Unconfigured services are skipped. LLM and TTS checks make real API
requests, so providers may charge for them; failures return a non-zero exit code.
npm run build
node dist/index.jsRun commands from the project root. Open http://127.0.0.1:7080/ and press play to join the broadcast. Pausing disconnects; playing again joins what is on air, rather than resuming an old position.
For an external player, use http://127.0.0.1:7080/stream:
ffplay http://127.0.0.1:7080/streamSettings are loaded from config.yaml at startup; restart after editing.
Keep this file private. See the commented example for
all options and defaults.
| Section | What it controls |
|---|---|
server |
Bind address, port, public URL and connection buffer |
site |
Station title, description, fallback cover and spectrum |
storage |
Audio/temp directories and optional deletion after playback |
playback |
Random playback, or directory order with random: false |
telegram |
Optional bot token |
transition |
Enable AI hosting; speech/music timing, gain, fade and prompt |
llm / tts |
OpenAI-compatible provider URLs, models and credentials |
llm.thinking |
Thinking effort: none (default), minimal, low, medium, high or xhigh |
For custom artwork, add an image to public/ and set site.defaultCover
to its URL, such as /cover.jpg.
AI host: set transition.enabled: true and configure both llm and tts,
including their API keys. Providers must support /chat/completions and
/audio/speech. Prompts use {A} and {B} for track names. Track names and
speech text are sent to the providers, which may charge for requests.
Transitions use 3-4 short conversational sentences: acknowledge the previous
track, build a natural bridge in mood or imagery, then introduce the next track.
They target about 150 characters total, including spaces and punctuation, not
150 words. A system instruction applies this sentence count and length target
even to custom prompts; text over 300 characters is rejected and that transition
is skipped before TTS.
Thinking mode: set llm.thinking to none (default, no reasoning),
minimal, low, medium, high or xhigh. The aliases no, media and
max map to none, medium and xhigh, respectively. The display names
Low, Medium, High, Extra High and Max are also accepted; Extra High
maps to xhigh, just like Max. Values are case-insensitive; invalid values
stop startup with a configuration error.
llm:
# Keep your existing baseUrl, model and apiKey settings.
thinking: noneBunFM sends the normalized value as OpenAI's reasoning_effort field in both
transition and npm run doctor requests. It does not send other providers'
thinking fields alongside it. Your provider and model must support
reasoning_effort and the chosen value; not every model supports every effort
or allows reasoning to be disabled. Unsupported options may cause API errors,
including with non-reasoning models that reject the field. Higher effort can
increase latency and token usage; the existing output token budget and
request deadlines still apply.
Create a bot with @BotFather, set
telegram.botToken, and restart. Send audio files or voice messages to add
tracks; duplicate filenames are rejected.
| Command | Action |
|---|---|
/start |
Show station information |
/ls |
List tracks with their numbers |
/rm <number> |
Delete a track, e.g. /rm 3 |
/mute |
Toggle broadcast mute |
/next |
Skip the current track |
Bot commands have no user allowlist; keep access to your bot trusted.
- The default bind address is
127.0.0.1. For remote access, configureserver.hostor put a reverse proxy in front of the service. - Use HTTPS for PWA installation and the audio spectrum; localhost also works. Offline support caches the player shell, not the live broadcast.
- Proxy
/streamwithout response buffering and allow long-lived connections. Keep/api/*and the root-level player/PWA assets reachable. - Visit/listen totals persist in
data/stats.json; preserve it across deploys. “Online” counts stream connections, not unique people. npm run devstarts watch mode;npm run start-tsruns TypeScript directly.npm run buildcompiles todist/;npx tsc --noEmitchecks types.npm startuses POSIX environment syntax. On Windows, usenode dist/index.jsafter building instead.
