All apps · 0 apps
deadair
OfficialDocker app from MaroonedSoftware's Repository
Overview
Readme
View on GitHub
deadair
An AI radio station you run yourself. It picks the records, writes what the presenter says between them, speaks it in that presenter's voice, and streams the result. One mount, one running order, everybody hears the same thing at the same moment.
It is a radio station rather than a playlist. There is no per-listener shuffle and no skip button: what is on is what is on, and the station decides.
deadair.radio is the website: the install guide, the music licensing notes and the API reference, with the API also described as an OpenAPI 3.1 document at deadair.radio/openapi.yaml for anyone generating a client.
It plays your music, not its own. deadair holds no catalogue. It programmes what your provider already gives you — a Spotify account or a Subsonic server such as Navidrome — and it grants you no rights to broadcast any of it. Read docs/licensing.md before you publish an address.
What it actually does
It keeps a running order, and one thing owns it. Not a queue that gets topped up: a forward lineup you can see and edit, several hours deep, where every item carries its own state. The director is the only writer of it, and the console, the schedule and the model all post commands to it rather than writing it themselves. That is what makes an edit at 3pm still true at 3.05.
It talks between records, and it cannot be made silent by a model. A break is written by whoever is presenting: a local or hosted model when one is configured, and the station's own phrasings when none is, when the model is slow, or when what it wrote failed a check. The floor cannot fail, which is the whole design — a station whose presenter went quiet because a GPU was busy is not a station.
It says things that are true, or it says nothing. When the presenter mentions a fact about a record, that fact is a stored claim with the sentence of source prose that supports it, and a claim with no source is not expressible in the schema. The alternative is a presenter saying something specific, checkable and untrue in exactly the voice it uses for things that are true.
Who is presenting is a row, and it accumulates. A persona carries a voice, a diction, how much rope it is given and how brief it is. It keeps a notebook of things it has said and traits it is growing into, and a set of anecdotes it can tell on air. It can take a phone-in: a produced block in the middle of a broadcast where a caller and the host trade turns, each one its own model call in its own voice, joined into one file before it airs.
It knows what it is playing. A measurement sidecar decodes each record and answers with its cue points and its loudness, so the silence at the head and tail of a record is trimmed before the player sees it and boundaries are sized from what the material actually does. No decoding happens in Node.
It says why it is quiet. Eleven ordered gates over one snapshot answer "why is nothing playing" with a single causal verdict rather than three partial inferences, and the answer is written to the station's own event log, so "why was it silent at three in the morning" is a question with an answer.
How it fits together
your provider a model a voice
(Spotify / Navidrome) (local or hosted) (Kokoro / Chatterbox)
│ │ │
└───────────── plugins ─────────┴────────────────────┘
│
┌────────────────────────────────────┴─────────────────────────────────┐
│ the station (Node) │
│ director ── one running order, and the only thing that writes it │
│ render ── words, then audio, one state per stage │
│ playout ── hands records over, one at a time, and holds a lease │
└────────────────────────────────────┬─────────────────────────────────┘
│ HTTP
┌──────────────────────┼──────────────────────┐
Liquidsoap PostgreSQL analysis
(mixing, on air) (everything kept) (cue points, loudness)
│
Icecast ──────────────► /live.mp3 (plus Opus/AAC/FLAC and HLS, opt-in)
Audio never touches Node in the sense that matters: nothing here decodes, mixes or encodes. The mixing chain and the stream server run beside the station, and the station drives them over HTTP.
The mount is leased, not held. Liquidsoap airs nothing unless the station is actively renewing a short claim, so a crashed or redeployed station takes itself off the air within seconds rather than leaving a music bed playing to nobody's plan. And by default it airs only while somebody is connected: a loaded station with a full running order and no listeners is silent on purpose, and the console says so rather than reporting a fault.
Running it
Everything is one container: the station, its console, the audio chain, the stream server and the measurement sidecar. deploy/README.md is the install guide — which tag to pull, the two secrets to generate first, the first-boot order, and the two-disk layout for a big library.
| Tag | Brings | You bring |
|---|---|---|
deadair/deadair:latest |
a voice | PostgreSQL, Redis |
deadair/deadair:full |
a voice, PostgreSQL, Redis | nothing |
deadair/deadair:slim |
— | PostgreSQL, Redis, a speech server |
Those three tags follow main, so they move on every push that changes the station; a push that
touches only the docs, the website or a listener app leaves them where they are. A release is a
v* tag, and it publishes 0.1.0 and 0.1 alongside them: pin deadair/deadair:0.1 to track
releases only, and read CHANGELOG.md for what changed between them. Images are
linux/amd64.
On Unraid, install the template in unraid/deadair.xml. Anywhere else,
deploy/docker-compose.yml is the same container written for Compose.
One published port carries the console, the API and the stream, so whatever you already put in front
of a port carries the station too.
One thing to know before you start, because it is the most common way to end up with a station that looks perfect and plays nothing: on Spotify there are two authorizations and you need both. The connection lets the plugin read your library; the playback authorization below it lets the station fetch audio. With only the first, your playlists list correctly and every record is dropped.
The console
A broadcast desk rather than a player. It deliberately does not play the mount: the listener surface
is the mount itself, plus the Android app (apps/android) and the desktop app (apps/desktop) if
you build and run either one.
On Air is the running order, live, with the playhead and every item's state. Schedule is the format clock and the weekly grid — which hour is which, what it is briefed to play, which period it draws from. Personas is the roster. Catalog is the library with the station's opinion of each record at three levels. Segments, Pads, Voices and Pronunciations are the station's own audio and how it says things. Scripts is every word the station has ever written, one row per attempt. Activity is what happened. Check-up is what is wrong right now.
Settings → Security is how you sign in. Enrol an authenticator app there (Google Authenticator, 1Password, Aegis, anything that shows six-digit codes) and from then on every sign-in to that account asks for the code after the password. Remove it and sign-in goes back to the password alone. Either change asks for a fresh code first if the one you signed in with is more than a few minutes old.
Settings → Mail is what makes the other half work. Point it at any SMTP server and the station can send a sign-in code to your email address as a second factor, and a sign-in link as a first one — "Email me a sign-in link" on the sign-in page, no password at all. Until a mail server is set the station sends nothing, and it says so plainly rather than failing quietly: it will not offer you an emailed code it cannot deliver, so a station with no mail configured signs you in on the password alone. That is deliberate. The alternative is arriving at a fresh install that asks for a code it cannot send, on the account that would have configured the sending.
A sign-in link is worth one warning: it is the whole of the sign-in, so anybody who can read that message can get in. It works once, it expires in half an hour, and it should not be forwarded. An account with an authenticator enrolled is still asked for the code afterwards — a link proves the inbox, which is one factor and not two.
If you lose your authenticator
If you have a mail server configured, you do not need this: ask for a sign-in link, or sign in with the password and take the emailed code as the second factor. This is the way in when there is no mail either. From the box, against the station's database:
update deadair.actors_authenticator_factors set active = false;
then clear the sessions, which live in Redis rather than the database:
redis-cli FLUSHALL
(pnpm flush:sessions in a development checkout.) That turns the challenge off for every account
on the station, since there is one. Sign in with the password, and enrol the new phone from
Security.
Configuration
Almost none of it is environment variables. .env holds boot and infrastructure — where the
database is, the key that encrypts stored credentials, the key that signs sessions — and everything
else is a row in the database, edited from the Settings page and applied live. A setting changed in
psql reaches a running station without a restart.
Plugins carry their own configuration, declared by the plugin and drawn by the same form the station's own settings use.
The model plugin is worth one note, because it is the only place a name means more than it looks. It
speaks to OpenAI-compatible servers (a local Ollama or vLLM, or OpenAI, Groq, Mistral and OpenRouter
by address), to Anthropic and to Gemini, and it speaks to as many at once as you add — each one a
row in a table, with whatever name you give it. That name is how a model is addressed: a model on the
row you called ollama is ollama:gpt-oss, and one on the row you called claude is
claude:claude-sonnet-5. Every job that asks a model for words has its own model setting under
Settings → Words, so a station can write its talk breaks on a hosted model and do its reading and
note-taking on a local one. Those settings offer what your providers actually have, so the names are
picked rather than typed.
An API key typed into that table is stored the way every other credential here is: encrypted, never shown again, and never returned by the API.
Development
pnpm install
docker compose up -d # Postgres, Redis, Icecast, Liquidsoap, a voice, the sidecar, Mailpit
pnpm --filter @deadair/api migrate:up
pnpm dev
Node 26+, pnpm, Turborepo. pnpm test, pnpm lint and pnpm build run through turbo; per package,
pnpm --filter @deadair/api test.
Mailpit is in that stack so the sign-in flows can be walked end to end without a real mail server
and without a code leaving the machine: point Settings → Mail at localhost, port 1025, TLS off, no
username, any from address, and read what the station sent at http://localhost:8025. With it
configured, OTP_DEV_BYPASS is no longer needed to get through an email challenge — the code is in
the inbox.
apps/api the station: Koa, ContractKit routers, dbmate migrations
apps/web the console: React, Vite, TanStack Router, Mantine
apps/site the website at deadair.radio: Docusaurus
packages/plugin-sdk the plugin contract and the host capabilities
packages/sdk a typed client, generated from the contracts
plugins/* spotify, navidrome, musicbrainz, lastfm, wikipedia, rss,
websearch, weather, kokoro, chatterbox, llm, analyzer
analysis/ the measurement sidecar (Python): cue points and loudness
stream/, nginx/ the audio chain (radio.liq), the stream server and the edge
Contracts, permission types and database types are generated and committed. Never hand-edit them; CI regenerates all three and fails on anything that moved.
CLAUDE.md is the deep reference: every non-obvious constraint, with the measured
failure behind it. Read the part covering whatever you are about to touch — most of those paragraphs
exist because the obvious fix was shipped first and was wrong. docs/internals/
holds the long-form arguments, one file per subsystem, and docs/todo/ holds work that
was designed against the real tree and then deliberately deferred, which is worth reading before
designing a feature from scratch: the call may already have been made.
Reporting something
Bugs and requests go to the issue tracker. Anything security-shaped goes privately instead — SECURITY.md says how, and says what the station assumes about where it runs, which is worth reading before you decide something is a bug. CONTRIBUTING.md is how to build it and the handful of rules that will bounce a change.
Licence
MIT, for the source in this repository.
That is not the whole picture for the published container image, which bundles Liquidsoap, Icecast,
go-librespot and — in the full variant — PostgreSQL and Redis, several of them copyleft.
THIRD-PARTY.md lists every one with its version and its terms.
And none of it is about the music, which is yours and is licensed by whoever licensed it to you: see docs/licensing.md before you publish an address.
Install Deadair on Unraid in a few clicks.
Find Deadair in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.
Requirements
An x86-64 server: there is no arm64 image yet. On the latest and slim tags, a PostgreSQL database that
already exists and a Redis server; the full tag brings both. On the slim tag, a speech server as well.
Music comes from your own Spotify account or Subsonic server, such as Navidrome, because the station
holds none of its own.
Categories
Download Statistics
Related apps
Explore more like this
Explore allDetails
deadair/deadair:latestRuntime arguments
- Web UI
http://[IP]:[PORT:80]/- Network
bridge- Shell
sh- Privileged
- false
Template configuration
The console, the API and the stream, all on this one port.
- Target
- 80
- Default
- 8080
- Value
- 8080
What the station IS: its settings, presenters, schedule, pronunciations and ratings, installed plugins, the stream's own credentials, the audio you recorded yourself, logs — and, on the full tag, the database holding most of it. Small, authored, and the thing to back up. Belongs on the cache pool.
- Target
- /data
- Default
- /mnt/user/appdata/deadair
- Value
- /mnt/user/appdata/deadair
Optional, and only about size. What the station HOLDS rather than what it is: records downloaded before they air, cover art, the speech it rendered, voice previews, the speech model's weights. All of it can be produced again by running the station, and it grows to roughly the size of the library you play — so put it on the array and leave it out of your backups. Blank keeps it under Data, which is fine until the library gets big.
- Target
- /media
The address a browser actually reaches this station at, including https:// — used to build sign-in redirects, so a wrong value here means a sign-in that lands nowhere.
- Target
- APP_BASE_URL
Normally the same as the public address, because one port serves both.
- Target
- SPA_BASE_URL
An IANA zone name such as America/New_York or Europe/London. The station reads the clock in this when it says the time and when it works out what half of the day it is; left unset a container reports UTC, which is a presenter saying "tonight" through your afternoon. Only the fallback — the station's own "Where the station is" setting, on the settings page, wins over it, because a station is a place and its listeners are in it rather than wherever the server happens to sit.
- Target
- TZ
- Default
- Etc/UTC
- Value
- Etc/UTC
Encrypts every credential the station stores, including your music provider's. Generate once with: openssl rand -hex 32 — it must be hex, and keep it. Losing it means entering all of them again.
- Target
- KMS_LOCAL_ROOT_KEY
Signs sessions, RS256, so it must be an RSA key. Generate one with: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | base64 -w0 — a key is several lines and this field is one, so base64 it and paste that. Losing it signs everybody out and nothing worse.
- Target
- AUTHENTICATION_SESSION_JWT_PRIVATE_KEY
Your PostgreSQL server. Leave every database and cache field blank on the full tag, which runs its own.
- Target
- DATABASE_HOST
- Target
- DATABASE_PORT
- Default
- 5432
- Value
- 5432
Must already exist; the station creates its own schema inside it, not the database itself.
- Target
- DATABASE_NAME
- Default
- deadair
- Value
- deadair
Owns the schema: the station applies its migrations as this user at every start.
- Target
- DATABASE_USER
- Target
- DATABASE_PASSWORD
Optional, and both this and the password below have to be filled in for either to take effect. A second, non-owner role for the request path, holding DML grants only, so a request cannot alter the schema — migrations still run as the owner above. Leave both empty to run everything as the owner, which is what happens today.
- Target
- DATABASE_APP_USER
- Target
- DATABASE_APP_PASSWORD
Your Redis server. Sessions live here rather than in the database, so losing it signs everybody out and costs nothing else.
- Target
- REDIS_HOST
- Target
- REDIS_PORT
- Default
- 6379
- Value
- 6379
Optional. Without this the station signs you in with a one-time code instead.
- Target
- GOOGLE_OIDC_CLIENT_ID
Optional.
- Target
- GOOGLE_OIDC_CLIENT_SECRET
Leave empty unless a tunnel or a reverse proxy sits in front of this station. When one does, every listener reaches the station as THAT, so they all share one rate limit and the listener count collapses to however many different players are tuned in. Naming the proxy here — an address or a CIDR, and 172.16.0.0/12 covers another container on this box — lets the station see who actually asked. Only name something you put there yourself: it means believing an address that arrives in a header.
- Target
- REAL_IP_FROM
Which header the proxy above puts the listener's address in. X-Forwarded-For suits almost everything; a Cloudflare tunnel also sends CF-Connecting-IP. Ignored while the field above is empty.
- Target
- REAL_IP_HEADER
- Default
- X-Forwarded-For
- Value
- X-Forwarded-For
Leave on unless you apply the schema yourself. An out-of-date schema otherwise degrades quietly rather than failing.
- Target
- MIGRATE_ON_BOOT
- Default
- true
- Value
- true