deadair

deadair

Official

Docker app from MaroonedSoftware's Repository

Overview

An AI radio station: it picks the records, writes what the presenter says between them, speaks it, and streams the result. Everything is in this one container: the station, its console, the audio chain and the stream server. Pick the tag that matches what your server already runs: deadair/deadair:full brings everything: PostgreSQL, Redis and a voice. Nothing else needed. deadair/deadair:latest includes a voice. Point it at your own PostgreSQL and Redis. deadair/deadair:slim is the station alone, for pointing at a speech server on another machine. Each has an Apple Music twin (apple, full-apple, slim-apple) that adds a player for an Apple Music subscription. It is most of a gigabyte bigger; add --shm-size=1g under Extra Parameters with it. Everything it keeps lives under one path, so backing it up is backing up one share. First run: open the console on the WebUI port and sign in. The database schema is applied automatically. The stream is served by this same container at /live.mp3, so whatever you put in front of the WebUI port (a reverse proxy or a tunnel) carries the station too. On Spotify there are TWO authorizations on the plugin page, and the station needs both. The connection lets the plugin read your library; the playback authorization below it lets the station fetch the audio. With only the first, your playlists list perfectly and nothing ever plays. The page Spotify sends your browser to will not load. That is expected, not a failure, so copy that address out of the bar and paste it back into the console.

deadair

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 (Spotify, YouTube Music, Apple Music or Pandora, or a Navidrome, Jellyfin, Emby or Plex server), 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. A guest can drop by the studio for the same kind of chat, a show can have co-hosts who take turns at the breaks and banter, and a host can sit in on somebody else's show on fixed nights or at random.

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 / YouTube Music /          (local or hosted)   (Kokoro / Chatterbox
             Apple Music / Pandora /                                 / Rhapsode / ElevenLabs)
             Navidrome / Jellyfin /
             Emby / Plex)
                       │                               │                    │
                       └───────────── 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
deadair/deadair:apple latest, plus an Apple Music player PostgreSQL, Redis
deadair/deadair:full-apple full, plus an Apple Music player nothing
deadair/deadair:slim-apple slim, plus an Apple Music player PostgreSQL, Redis, a speech server

The last three are the Apple Music twins of the first three: the same image plus a player that plays Apple Music records in Google Chrome and records them for the station. Each is most of a gigabyte bigger, so take one only if you have an Apple Music subscription to play from; the install guide says what else it needs.

All six 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 publishes 0.55.0 and 0.55 alongside each of them (with the variant in front, as in full-0.55), once its tests pass, and is tagged v0.55.0 with a GitHub release of its changelog entry: pin deadair/deadair:0.55 to track releases only, and read CHANGELOG.md for what changed between them. How a release is cut is in CONTRIBUTING.md. Images are linux/amd64.

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.

On Unraid

The whole install is one form. You need an amd64 server with Community Applications installed, so the Apps tab exists.

  1. Apps, search for deadair, and choose Install. Unraid opens the container's settings form with the template's defaults filled in.
  2. Pick the tag. The template installs latest, which expects a PostgreSQL and a Redis you already run. On a server with nothing else on it, choose full when Unraid asks, or change the end of Repository from :latest to :full. Left on latest with the database fields empty, the first boot stops at deadair: no database. On an Apple Music tag, add --shm-size=1g under Extra Parameters.
  3. Fill in the Basic view. Leave WebUI at 8080 and Data at /mnt/user/appdata/deadair (on full, /mnt/cache/appdata/deadair is better: the database lives there, and PostgreSQL is slow over Unraid's /mnt/user layer). Set Public address and Console address to the same thing, the address you type into a browser with nothing after the port, such as http://192.168.1.10:8080. Set Timezone. Leave Secret key and Session key empty, and the station makes both on its first boot. On full, leave every database and cache field empty too.
  4. Apply, then open the container's log from the Docker tab and wait for Boot complete. The first boot applies the schema and takes a few minutes. There is no permissions step: the container runs as 99:100, which already owns the appdata share.
  5. Open the public address and create the administrator on Set up deadair.

Get the two addresses right. A wrong one still lets the console load, so the mistake hides until a sign-in link or a Spotify authorization sends your browser somewhere that does not answer. If anything looks off, docker exec deadair deadair-doctor in the Unraid terminal checks the share, the keys, the database, the cache and both addresses, one line each.

Optionally point Media at a share on the array: it holds what the station can produce again (downloaded records, art, rendered speech), grows to roughly the size of the library you play, and belongs outside your backups. Updates are the button Unraid shows when a new image is out. Install on Unraid walks the same path with screenshots, and On Unraid covers every field, the Advanced view and a proxy in front. The template itself is unraid/deadair.xml; if Apps does not find it, save that file into /boot/config/plugins/dockerMan/templates-user/ and it appears under Docker → Add Container.

Anywhere else

deploy/docker-compose.yml is the same container written for Compose, and one command sets it up on any machine with Docker and the Compose plugin:

curl -fsSL https://deadair.radio/install.sh | sh

deploy/README.md has the rest, including doing it by hand.

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 (on Google Play, or built from apps/android), the iPhone app (apps/ios, if you build and run it), and the macOS desktop app (apps/desktop), which is cut by hand as a desktop-v… GitHub release for Apple silicon, signed only ad hoc so macOS asks you to allow it on the first launch, or which you can build yourself. The desk also comes as hardware: the Stream Deck plugin (on the Elgato Marketplace, or built from apps/streamdeck) puts what is on air, Skip, Like, Dislike and Stop or start on keys, with an API key from Settings, Sign-in and security.

A rail down the left carries four destinations, with a key each: D, P, L and V. Desk is the running order, live, with the playhead, every item's state and whatever needs you. Programme is Today, the Timetable across the week, Specials (shows on dates, like Halloween, which take over from the week for their hours), and Sustaining, which is what plays when nothing is scheduled. Library is Tracks, Artists, Playlists, Charts and News. Voice is who the station is when it talks and how it says things: Characters, Auditions, Voices, Segments, Pronunciations, Soundboard, Subjects, Productions, and What it said, which is every break it has written and every one it declined. Below a rule sit Check-up and Settings. Check-up is what is wrong right now: Machinery for every part as it stands, What it has been doing for what aired and what failed, What it cost for every call the station made, and Logs for what the processes themselves wrote.

Settings → Sign-in and 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 mail server with a self-signed certificate, or one from your own certificate authority, fails with "unable to verify the first certificate". Turn off Check the server's certificate under Settings → Mail for that one. Off, the station accepts any certificate at all, so keep it on for a server reached across the internet.

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. If your Redis asks for a password, redis-cli -u "$REDIS_URL" FLUSHALL, or -a with the password you gave REDIS_PASSWORD.) 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 Settings → Sign-in and 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
apps/android           the Android listener app: Kotlin, Compose, Media3
apps/ios               the iPhone listener app: SwiftUI, AVFoundation
apps/desktop           the macOS listener and operator desk: Avalonia, .NET
apps/streamdeck        the Elgato Stream Deck plugin
packages/plugin-sdk    the plugin contract and the host capabilities
packages/sdk           a typed client, generated from the contracts
plugins/*              spotify, navidrome, jellyfin, emby, plex, ytmusic, pandora,
                       apple-music, musicbrainz, lastfm, deezer, wikipedia, lrclib, rss, websearch,
                       weather, podcast, telegram, discord, slack, kokoro, chatterbox,
                       rhapsode, elevenlabs, llm, analyzer, cast
examples/plugins/*     a plugin built from outside the workspace, as anybody else's is
analysis/              the measurement sidecar (Python): cue points and loudness
ytaudio/               the audio-url resolver (Python, on yt-dlp) behind YouTube Music
stream/, nginx/        the audio chain (radio.liq), the stream server and the edge

A station also loads plugins it did not ship with: @deadair/plugin-sdk is on npm, and how to write, build and install one is at https://deadair.radio/docs/plugin-development. Anything else that talks to a station can use @deadair/sdk, the typed API client the console is built on, also on npm and carrying the station's version. The API it covers is at https://deadair.radio/docs/api-reference.

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 the Ideas discussions hold 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.

Media gallery

1 / 5

Requirements


        An x86-64 server: there is no arm64 image yet. On the latest and slim tags, a PostgreSQL 13 or newer 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 your own media server (Navidrome or another Subsonic
        server, Jellyfin, Emby, or Plex), because the station holds none of its own.
    

Download Statistics

101,156
Total Downloads

Related apps

Explore more like this

Explore all

Details

Repository
deadair/deadair:latest
Last Updated2026-10-09
First Seen2026-09-10

Runtime arguments

Web UI
http://[IP]:[PORT:80]/
Network
bridge
Shell
sh
Privileged
false

Template configuration

WebUIPorttcp

The console, the API and the stream, all on this one port.

Target
80
Default
8080
Value
8080
DataPathrw

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
MediaPathrw

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
Public addressVariable

The address you type into a browser to reach this station, all of it: scheme, host and port, with nothing after the port. On your own network that is http:// with your server's address and the WebUI port above, such as http://192.168.1.10:8080; behind a proxy that terminates TLS it is the https:// name. Sign-in links and a music provider's authorization are sent back here, so a wrong value is a sign-in that lands nowhere. It does not decide whether the console loads.

Target
APP_BASE_URL
Console addressVariable

The same address as the public address above, because one port serves the console and the API.

Target
SPA_BASE_URL
TimezoneVariable

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
Secret keyVariable

Encrypts every credential the station stores, including your music provider's. Leave it empty and the station makes one on its first boot, in secrets/ under Data. Set it only to keep the key off that share: 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
Session keyVariable

Signs sessions. Leave it empty and the station makes one on its first boot, in secrets/ under Data. To set your own it must be RSA: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | base64 -w0, since a key is several lines and this field is one. Losing it signs everybody out and nothing worse.

Target
AUTHENTICATION_SESSION_JWT_PRIVATE_KEY
Database hostVariable

Your PostgreSQL server, version 13 or newer (17 is what the station is tested on). No extensions needed. Leave every database and cache field blank on the full tag, which runs its own.

Target
DATABASE_HOST
Database portVariable
Target
DATABASE_PORT
Default
5432
Value
5432
Database nameVariable

Must already exist, owned by the database user below; the station creates its own schema inside it, not the database itself.

Target
DATABASE_NAME
Default
deadair
Value
deadair
Database userVariable

Owns the schema: the station applies its migrations as this user at every start. It needs CREATEROLE, or a role named app_user created beforehand by a superuser (CREATE ROLE app_user NOLOGIN), because the first migration makes one.

Target
DATABASE_USER
Database passwordVariable
Target
DATABASE_PASSWORD
Runtime database userVariable

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
Runtime database passwordVariable
Target
DATABASE_APP_PASSWORD
Cache hostVariable

Your Redis server. Sessions live here rather than in the database, so losing it signs everybody out and costs nothing else.

Target
REDIS_HOST
Cache portVariable
Target
REDIS_PORT
Default
6379
Value
6379
Cache userVariable

Only if your Redis asks for one. Redis 6 and newer with ACLs turned on wants a username and a password; anything older, or one with only requirepass set, wants the password alone and this left empty. Empty means the station connects with no AUTH at all, which is what a Redis on your own network usually wants.

Target
REDIS_USERNAME
Cache passwordVariable
Target
REDIS_PASSWORD
Cache over TLSVariable

Whether the connection to the cache is itself TLS. A managed Redis reached across the internet almost always is; one on this network almost never is. This is the connection rather than the password, and a password travels in the clear without it.

Target
REDIS_TLS
Default
false
Value
false
Cache URLVariable

The whole cache as one URL instead, which is the shape a hosted Redis hands you: rediss://default:the-password@cache.example.com:6380/0. Fill this in and it is the WHOLE answer: the four fields above are not read at all, so there is no way for the two to half-disagree. It is also the only way to name a database index, if you are sharing one Redis between applications. rediss:// is TLS, redis:// is not, and anything in the password that is not a plain letter or digit has to be percent-encoded (@ is %40).

Target
REDIS_URL
Proxy in frontVariable

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
Proxy address headerVariable

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
Apply migrations at startVariable

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