What is Jockora?
Jockora is a free, open-source, self-hosted music streaming server that behaves like a radio station instead of a music library. You already own the music. What you do not have is somebody on the other end of it — a voice that says what that last record was, why the next one follows it, and what is coming after the break.
That voice is an AI radio DJ: a persona with a fixed character, a fixed speech style, and a written list of things it will never say. It reads from a dossier built for each of your tracks by a language model you run yourself, so the station knows about your collection without your collection being handed to anybody.
It layers on top of a library you already have. It never writes to it, never retags it, never transcodes it. One Go binary holds the audio path, the station brain, and the browser app; the state is a single SQLite file. Beside it sit three things it deliberately does not own: ffmpeg, a speech sidecar, and a language model.
- Open source
- AGPL-3.0
- Self-hosted
- Local AI
- Go + SQLite
- No cloud required
- Read-only library
A station, not a playlist
Five sentences describe the whole design. Everything else follows from them.
Your library is read-only input
Jockora layers on top of what you already have and never modifies it. In Docker that is enforced by a :ro mount, not promised by the code.
One AI pass per track, cached forever
Each track gets a dossier. Lyrics are read and then discarded; the dossier is the only thing a DJ may assert facts from.
A station is a filter
Genre and mood over those dossiers, materialised into a playlist you can pin and exclude tracks in. The admin owns the dial.
A jock is a persona card
A voice, a speech style, a personality, and a list of things it will never say. One jock per station — listeners pick stations, never jocks.
One shared HLS stream
Per station, rendered only while somebody is listening. Clients are dumb players; presence is what keeps a station on air.
Breaks are optional, music is not
If a break runs late it is dropped and the stream never stalls. Silence where a voice should be beats a station that stops.
What it is not
Not a player, not a library manager, not a recommender. There is no skip and no seek. That is not an unfinished feature — changing station is the escape hatch, and the absence of a skip button is exactly what lets a DJ safely say what is coming up without anything invalidating it thirty seconds later.
If the DJ having nothing to say about a track sounds like a failure, it is worth reading the design the other way round: no dossier means personality-only talk, which is a designed state rather than a broken one. A DJ that invents a fact about your record is the failure this whole architecture exists to prevent.
Jockora vs. a music server vs. a streaming service
The three things people reach for when they want music to keep playing solve genuinely different problems. The honest comparison:
| Jockora | Navidrome / Jellyfin / Plex | Spotify AI DJ | |
|---|---|---|---|
| What it does | Runs a station — picks the record and talks between them | Serves your library so you can browse and choose | Streams their catalogue with a DJ voice over it |
| Your music | Files you own, read-only | Files you own | Their catalogue, licensed to you |
| Where the AI runs | Your machine, by default | — | Their servers |
| Skip / seek | Neither, by design | Both | Both |
| Talks about the record | Yes, grounded in a per-track dossier | No | Yes |
| Works offline | Entirely, on local AI | Yes | No |
| Cost | Free, AGPL-3.0 | Free / paid tiers | Subscription |
They compose rather than compete: Jockora can read its library from Navidrome over OpenSubsonic, so you keep the server you already run and add a station on top of it.
How it works
One binary, three things it deliberately does not own, and a rule about facts that everything else is built to protect.
Music library (local folder or OpenSubsonic -- READ-ONLY, never written)
|
+--> Enrichment worker --> Track Dossier (SQLite)
| one LLM pass per track, cached forever, resumable
| lyrics are read then DISCARDED -- only the dossier is stored
v
Station engine --> broadcast buffer: breaks pre-rendered to disk
|
+--> DJ Director: persona + dossiers --> LLM --> script --> TTS
|
ffmpeg mix (crossfade, duck) <------+
|
v
HLS -- one shared stream per station, while someone is listening
The grounding rule
A DJ may only assert what a named source supplied. Enrichment has exactly two external sources, and there is no web search anywhere in this program:
- LRCLIB supplies lyrics. It has synced words for about 41% of a real library.
- MusicBrainz supplies four primitives about the artist — country, group or person, founding year, and a disambiguation note. Nothing else.
So for roughly half a library the DJ has nothing to say about the song itself and falls back to personality. There is an opt-in switch in the console — "Let the DJ describe songs the library could not look up" — that lets the model fill two fields from its own knowledge for exactly those tracks. It is off by default because it is a deliberate weakening of the rule the whole design rests on, and it is bounded hard: two fields only, lyrics always win, and the schema gains a required recognised boolean so that declining is something the model can express. Telling a small model to leave a field empty when it does not know does not work; making the refusal representable does.
The three things it does not own
| Component | What it is | Why it is outside the binary |
|---|---|---|
| ffmpeg + ffprobe | Decode, crossfade, duck, encode | GPL. Run out of process and never linked, which is mere aggregation — that is what lets AGPL and GPL hold at once. |
| Speech sidecar | Python 3.10 + Kokoro ONNX | 337 MB of someone else's model weights, operator-supplied for the same reason ffmpeg is. Runs on CPU, faster than real time. |
| Language model | llama.cpp, Ollama, or a hosted endpoint | Yours to run and to size. Jockora attaches over HTTP and never owns its lifecycle — unless you ask it to, with -llm-model. |
The binary itself is static: CGO_ENABLED=0 on every target, with modernc.org/sqlite as the driver precisely so that stays true. A cross-compile gate proves it on every build.
The nine jocks, and how to write a tenth
A jock is not a prompt someone tuned until it sounded good. It is a persona card: one human-readable, diffable TOML file that is treated as immutable ground truth. Nine ship with the release, matched to what a station actually sounds like:
| Jock | Good for genres | Moods |
|---|---|---|
| Marcus "Midnight" Vale | synthwave, alternative, electronic, ambient | melancholic, nocturnal, wistful, hypnotic |
| Dutch "The Hammer" Mahoney | rock, metal, punk, hard rock, grunge | aggressive, energetic, defiant, raw |
| Roxy Sinclair | pop, dance, disco, funk, r&b, soul | euphoric, romantic, upbeat, playful |
| Prosper Okonkwo | hip hop, rap, trip hop, jazz, neo soul | cool, confident, smooth, reflective |
| Chip Halliday | indie, britpop, new wave, power pop, ska | energetic, bright, manic, sunny |
| Marlon Vex | alternative, electronic, industrial, post-punk, goth | restless, ominous, paranoid, hypnotic |
| Sunny Marchetti | downtempo, new age, chillout, acoustic, folk | dreamy, calm, gentle, restorative |
| Wendell J. Pike | classical, opera, orchestral, jazz, instrumental | serene, grand, contemplative, melancholic |
| Baby Concepcion | opm, ballad, easy listening, bolero, kundiman | sentimental, tender, nostalgic, yearning |
The interesting half of a persona card is the "never" list
Character comes from constraint more than from description. This is the real card for the late-night jock, abridged:
id = "midnight_vale"
name = "Marcus \"Midnight\" Vale"
voice_id = "kokoro:am_michael"
good_for_genres = ["synthwave", "alternative", "electronic", "ambient"]
good_for_moods = ["melancholic", "nocturnal", "wistful", "hypnotic"]
speech_style = """
Low and unhurried, like someone talking to one person rather than an
audience. Short sentences. Leaves a beat before the important word, and
never raises his voice to make a point.
"""
forbidden = [
"never mentions the weather more than once in a session",
"never says 'that was' followed by the artist name; he backsells like a person, not a database",
"never uses exclamation marks or radio-voice enthusiasm",
"never claims a fact he has not been given",
"never addresses the listener as 'guys', 'folks' or 'everybody'",
]
Writing your own is dropping another TOML in the personas directory. There is no archive and no manifest — a JockPack is the persona file, so every card written before the format existed is already a valid pack. A pack may also carry [[advert]] entries, which are validated at install rather than at airtime: a pack that would name a real company on air fails to load.
The persona, the said-lines index and the TTS voice always move together. A jock changed in only one of those speaks as one character in another's voice, or inherits someone else's phrase history and gets its own writing rejected as repetition.
Choosing the local model — and why you probably do not need a GPU
This is the question everyone asks first, and the answer is smaller than expected. No, you do not need a GPU. A GPU makes the one-time enrichment pass faster. Nothing about running the station day to day needs one, and speech synthesis runs on CPU faster than real time.
Every number below was measured on 2026-09-06, not estimated: llama-bench on Qwen2.5-7B-Instruct-Q5_K_M, an 806-token enrichment prompt, 128 tokens generated per track, averaged over 124 real tracks from a 7,696-track library.
| Configuration | Per track | 5,000 tracks |
|---|---|---|
CPU only, Apple M4 Pro (-ngl 0) | 13.9 s | 19 hours |
| Metal GPU, same machine | 5.3 s | 7 hours |
| CPU only, typical NUC or NAS (~⅓ of an M4 Pro) | 41.8 s | 58 hours (extrapolated) |
| Hosted endpoint | — | ~$0.60 |
The GPU buys roughly 2.6× here, not an order of magnitude. And 58 hours is not the wait it looks like, because enrichment runs in the background while the station plays:
- The station goes on air as soon as the library is scanned — minutes, not hours.
- A track with no dossier yet is not a broken track. The DJ talks from personality alone and asserts nothing.
- The queue is resumable. Stop the server, start it next week, and it continues from where it stopped.
So the honest framing is not "58 hours before you can listen". It is "the DJ knows more about your library each evening for the first few days".
A smaller model is a better lever than a bigger machine
Those figures are for a 7B at Q5. A 3B at Q4 is roughly three times faster, and in this project's own evaluation the model size mattered far less than whether the chat template was applied at all. Keep the context at the default 8192; below about 4096 the enrichment prompt stops fitting.
The privacy tradeoff, stated plainly
A hosted endpoint costs about $0.60 for 5,000 tracks, which is not the deciding factor. The deciding factor is that your library's artist and title metadata leaves the box, one request per track, plus lyrics. For an audience that self-hosts specifically to avoid that, it is a real tradeoff and not a footnote. Jockora says so at INFO on every start when a hosted model is configured, deliberately, so a self-hoster never discovers it from a network capture.
The read-then-discard guarantee still holds locally — nothing is stored — but the text is sent to a third party on the way.
Setup with Docker — the one path that works on Windows, Linux and macOS
You need three things Jockora deliberately does not ship: Docker, a music folder, and a GGUF language model. Everything else is in the compose file, including llama-server, ffmpeg, and the speech sidecar's virtualenv.
-
Get the checkout and configure the paths. Compose reads
.envautomatically, so you set them once and every laterdocker compose logs,restartanddownworks without re-exporting anything.git clone https://github.com/andrewloable/Jockora.git cd Jockora cp .env.example .envThen edit
.env:Variable What it does MUSICYour library. Mounted read-only; Jockora never writes to it. CONFIGWhere the database and the Kokoro voice models live. Must be writable by uid 10001. MODELS/MODELA directory containing your .gguf, and the filename inside it. -
Fetch the two speech models into
<CONFIG>/models. They are 337 MB, operator-supplied and deliberately not vendored, for the same licensing reason ffmpeg is not.mkdir -p config/models && cd config/models curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin cd ../.. -
Give the config directory to uid 10001. The container runs unprivileged and cannot chown a bind mount from inside. This is the most common first-run failure.
sudo chown -R 10001 ./configRequired on Linux, and on Windows when the directory lives inside the WSL2 distribution's own filesystem. Docker Desktop's macOS mounts and its Windows drive mounts usually make ownership a non-issue — and if it is not,
jockora doctorrefuses to serve and names this exact fix rather than failing quietly. -
Start it. Compose starts
llama-serveralongside Jockora and waits for it to report healthy first, because Jockora's own preflight refuses to serve without a working model.docker compose up -d docker compose logs -f jockoraThe compose file builds the image from the checkout, so there is no registry dependency. Every tagged release also publishes prebuilt multi-arch images, if you would rather pull than build — swap
build: .forimage:under thejockoraservice:docker pull ghcr.io/andrewloable/jockora:latest # or docker pull andrewloable/jockora:latest -
Make yourself an account. Nobody can sign in until you do, and there is no self-registration anywhere. The password is read from the terminal without echo — never from a flag or an environment variable, either of which would leave it in shell history and in a
pslisting.docker compose exec jockora jockora admin create -name you -
Open the dial.
http://localhost:8080is the listener UI — sign in and pick a station.http://localhost:8080/adminis the operator console: sources, stations, playlists, jocks, adverts and accounts. Only an admin account can open it.
The published port is 127.0.0.1:8080 on purpose. Change it to 0.0.0.0 only when you have decided that is what you want, and never port-forward it to the internet — see Security.
What differs per operating system
| Host | What to install | The one thing that catches people |
|---|---|---|
| Linux | Docker Engine + compose plugin | chown -R 10001 ./config is genuinely required here |
| macOS | Docker Desktop (Apple Silicon or Intel) | No Metal inside Docker. The bundled llm service is CPU-only — for GPU speed, run llama-server on the host instead |
| Windows | Docker Desktop with the WSL2 backend | Keep the music and config paths on one side of the filesystem boundary; mixing /mnt/c and WSL-native paths is where the odd permission errors come from |
On a Mac, this is the single highest-value change you can make: delete the llm service from docker-compose.yml, run llama-server natively so it can use Metal, and point Jockora at it. That is the difference between the 13.9 s and 5.3 s rows in the table above.
# On the macOS host
brew install llama.cpp
llama-server -m ~/models/qwen2.5-7b-instruct-q5_k_m.gguf \
--host 0.0.0.0 --port 8081 -c 8192
# Then in docker-compose.yml, on the jockora service:
# JOCKORA_LLM_URL: "http://host.docker.internal:8081"
# and delete the llm service and its depends_on block.
The same reasoning applies on Windows and Linux with an NVIDIA card: either swap the pinned CPU llama.cpp image for a CUDA-tagged variant with a GPU reservation, or run llama-server on the host and point JOCKORA_LLM_URL at it. Jockora attaches over HTTP and does not care which.
Running natively on Linux, macOS and Windows
The same program, assembled by hand. Worth doing when you want the model on the GPU, when Docker is not welcome on the box, or when the machine is already a NAS with ffmpeg on it.
What is published, and what is not
| Platform | Prebuilt binary? | How to run it |
|---|---|---|
| Linux x86-64 | Yes — linux-amd64 tarball | Download, install ffmpeg + the sidecar, run |
| Linux arm64 | Yes — linux-arm64 tarball | Same. Raspberry Pi class hardware works; expect slow enrichment |
| macOS, Apple Silicon | Yes — darwin-arm64 tarball | Download, brew install ffmpeg, run |
| macOS, Intel | No tarball | Builds from source cleanly: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build ./cmd/jockora |
| Windows | No native build | Docker Desktop (WSL2 backend), or the Linux binary inside WSL2 |
The tarballs are static (CGO_ENABLED=0) and carry the speech sidecar and the jock cards beside the binary, with the browser console already built and embedded. ffmpeg is not in the archives — install it yourself, or use the container image, which has it.
Why there is no Windows binary
It is not an oversight and it is not hard to verify. The preflight's free-disk check calls syscall.Statfs, which exists on Unix and not on Windows, so GOOS=windows fails to compile in internal/doctor. The cross-compile gate in CI covers linux/amd64, linux/arm64 and darwin/arm64 — Windows is simply not a target today. Docker Desktop with the WSL2 backend is the supported Windows path, and it runs the same linux/amd64 image everyone else runs.
If you would rather not run Docker at all on Windows: install a WSL2 Ubuntu distribution, and treat it exactly as the Linux instructions below. It is a real Linux kernel, so the linux-amd64 tarball runs unmodified.
Native install, step by step
-
Get the binary. Take a tarball from the releases page and verify it, or build from a checkout. Building the browser app first is not optional if you build from source:
web/distis build output and the binary embeds it, so skipping it compiles, links, starts, and then serves an "assets not built" notice to every listener.# From a release tar -xzf jockora-v0.3.0-linux-amd64.tar.gz sha256sum -c checksums.txt # Or from source (needs Go 1.25 and Node) cd web/app && npm ci && npx ng build && cd ../.. CGO_ENABLED=0 go build -o jockora ./cmd/jockora -
Install ffmpeg, and check the two filters. Both are used on every break, so a build of ffmpeg without them fails at airtime rather than at startup.
# Debian/Ubuntu: sudo apt install ffmpeg # Fedora: sudo dnf install ffmpeg # Arch: sudo pacman -S ffmpeg # macOS: brew install ffmpeg ffmpeg -hide_banner -filters | grep -E ' (loudnorm|aresample) ' -
Make the speech sidecar's virtualenv. Python 3.10;
kokoro-onnxpullsonnxruntimewith it. On macOS,brew install [email protected]first.python3.10 -m venv tts ./tts/bin/pip install kokoro-onnx ./tts/bin/python -c "import kokoro_onnx" -
Fetch the voice models — the same 337 MB pair as the Docker path.
mkdir -p models && cd models curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin cd .. -
Start a language model. Note the port: Jockora looks at 8081, because it is already using 8080 itself.
llama-server -m /path/to/model.gguf --host 127.0.0.1 --port 8081 -c 8192 -
Point Jockora at all of it. Every flag has an environment variable:
JOCKORA_plus the flag name uppercased with dashes turned into underscores, so-library-pathisJOCKORA_LIBRARY_PATH. The flag wins when both are set.export JOCKORA_LIBRARY_PATH=/path/to/music export JOCKORA_DB_PATH=$PWD/jockora.db export JOCKORA_PERSONA=personas/ export JOCKORA_TTS_PYTHON=$PWD/tts/bin/python export JOCKORA_KOKORO_MODEL=$PWD/models/kokoro-v1.0.onnx export JOCKORA_KOKORO_VOICES=$PWD/models/voices-v1.0.bin -
Check it before starting it.
jockora doctornames what is missing and what to do about it, andserverefuses to start on a failed check — a broken setup names the failing check rather than going quiet.$ ./jockora doctor JOCKORA PREFLIGHT [ok ] ffmpeg /opt/homebrew/bin/ffmpeg [ok ] ffprobe /opt/homebrew/bin/ffprobe [ok ] ffmpeg filters loudnorm, aresample, afade, volume present [ok ] llama-server http://127.0.0.1:8081/completion honoured a json_schema round-trip [ok ] tts sidecar can import kokoro_onnx, and the voice models are present [ok ] library path /path/to/music [ok ] segment dir segments writable [ok ] database jockora.db [ok ] free disk 139.0 GiB free -
Make an account, then serve.
./jockora admin create -name you # asks for a password, twice, without echo ./jockora serveExport
JOCKORA_DB_PATHrather than setting it in front of a pipe. A variable set on the left of a|belongs to the left-hand command, so the account lands in a different database than the one the server reads. The account is created, the message says so, and sign-in then fails with 401. It cost the author half an hour to find; it takes oneexportto avoid.
Pointing Jockora at your AI
Four dialects, one flag. -llm-api selects which, and -llm-url says where.
| You run | JOCKORA_LLM_API | JOCKORA_LLM_URL | JOCKORA_LLM_MODEL |
|---|---|---|---|
| llama.cpp (the default, and the recommendation) | llamacpp | http://127.0.0.1:8081 | unset — or a .gguf path, see below |
| Ollama | ollama | http://127.0.0.1:11434 | a model name, e.g. qwen2.5:7b-instruct |
| LM Studio, vLLM, OpenRouter, any OpenAI-compatible host | openai | that host's base URL | its model id |
| Cloudflare Workers AI | openai | .../accounts/<id>/ai/v1 | @cf/meta/llama-3.1-8b-instruct |
Let Jockora run llama-server for you
Give -llm-model a .gguf path on the llama.cpp dialect and Jockora starts and supervises llama-server itself — one less process to babysit. -llm-model wins over -llm-url when both are set, because asking Jockora to run a specific model is the more specific instruction.
./jockora serve -llm-model /path/to/qwen2.5-7b-instruct-q5_k_m.gguf \
-llm-gpu-layers 99 -llm-context 8192
-llm-gpu-layers 0 forces CPU. 99 is the default and means "offload everything you can".
Ollama, if that is what you already run
Ollama is reached over HTTP like any other external server, so nothing is supervised — it has its own lifecycle and Jockora must not act as though it owns it. It talks to /api/generate with a JSON schema in format, deliberately not the OpenAI-shaped chat endpoint: the chat envelope hides failures behind an empty string, and /api/generate applies the model's own chat template server-side.
ollama pull qwen2.5:7b-instruct
export JOCKORA_LLM_API=ollama
export JOCKORA_LLM_URL=http://127.0.0.1:11434
export JOCKORA_LLM_MODEL=qwen2.5:7b-instruct
./jockora doctor # a live round trip, not a reachability ping
Why the doctor does a schema round trip, not a ping
Jockora's anti-hallucination design leans on the JSON schema making an ungrounded fact unrepresentable at the sampler. A host whose JSON mode is best-effort demotes that to post-validation, which rejects rather than prevents — and that failure is silent from the outside, because the model still answers every request. So jockora doctor asks the endpoint to honour a real json_schema round trip. Run it after changing anything about the model configuration.
Also: free hosted tiers are rate limited, and enrichment is one request per track. A 7,000-track library will exceed a daily allowance. The queue is serial and resumable, so this costs days rather than correctness — but it is another argument for keeping the model local.
The settings that actually change what you hear
Every default is the value the project was measured with, and a test asserts them so they cannot drift away from the gates that were run against them. Changing nothing changes nothing. These are the handful worth touching.
| Flag | Default | What it changes, audibly |
|---|---|---|
-break-every-n-tracks | 4 | The knob most likely to be wrong. Going from 4 to 8 halves how often you hear a voice and changes nothing else |
-break-overlap | 3 | Seconds of music playing under each end of a break. Set to 0 for breaks that start exactly at the transition |
-fact-confidence | 0.6 | How sure a dossier must be before the DJ may assert from it. Lower is chattier and leans on weaker facts; higher gives more personality-only talk |
-ad-every-n-breaks | 4 | One break slot in N becomes an advert. The most taste-sensitive number here |
-ad-interval-minutes | 90 | Floor between adverts regardless of the ratio |
-lookahead-seconds | 150 | How far ahead a break is generated. Lower drops more breaks on a slow machine; higher makes them refer to what is coming from further away |
-music-lufs / -speech-lufs | −16 / −16 | Music and voice loudness, matched on purpose |
-music-ducked-lufs | −28 | How far music drops under the DJ. Closer to −16 and the voice fights the music; further and the music vanishes |
Start by turning the DJ down, not off
Spotify's AI DJ draws its loudest complaints for talking too much. If you find yourself wishing Jockora would be quiet, raise -break-every-n-tracks before concluding the writing is bad. The break cadence is also changeable in the console's overview page without a restart, and the stored value outranks the flag — a setting an operator chose must survive a restart.
How a break is actually heard
outgoing record -------------.
`-- 3s under the DJ
DJ +---------------------------------+
| 3s | in the clear | 3s |
+---------------------------------+
.---------------
incoming record ----------------------------------'
The outgoing record ends three seconds into the break, the DJ talks in the clear, and the incoming record comes up under the last three seconds. The music pauses in the middle, for however long is left — about nine seconds of a fifteen-second break. That silence is the point rather than a fault: it is what leaves the DJ in the clear. If breaks start clipping vocal endings, lower -break-overlap or set it to 0.
Reading your library from Navidrome instead of a folder
If you already run Navidrome, Airsonic or Gonic, point Jockora at it and skip the folder scan entirely. Tracks are stored as stream URLs, so ffmpeg fetches audio over HTTP exactly as it would read a file.
jockora serve -subsonic-url https://navidrome.example -subsonic-user you
# password via JOCKORA_SUBSONIC_PASSWORD -- a password on a command line is visible in ps
The connection is read-only by construction: the provider interface has no method that writes, and a test asserts every request is a GET and that no rating, star, scrobble or playlist endpoint is ever called. One consequence worth knowing: a long-lived auth token for that account is written into the local database, because the stored URL has to carry its own credentials. That is the same trust boundary as the password in your config.
Find out how much your DJ will know, before you commit a week to it
enrich -sample measures synced-lyric coverage over N sampled tracks and stops, instead of enriching. It answers "how many of my tracks will the DJ actually know anything about", which decides how much of the talking is personality-only:
jockora enrich -sample 200 -library-path /music
Troubleshooting
Run jockora doctor first. It checks ffmpeg, the model endpoint, the speech sidecar, the library, the segment directory, the database and free disk — and it names the fix rather than only the problem.
Most failures look identical from the outside: the music keeps playing and the DJ says nothing. That is deliberate, because breaks are optional and music is not. So each entry below names the log line or /now.json field that tells them apart.
The DJ never talks — four causes, four fixes
| Cause | How you tell | Fix |
|---|---|---|
| No persona | Log: no DJ: no persona configured | Set -persona to the personas directory |
| Model unreachable | /now.json → health.llm is not "ok" | Every break fails before it is written. Check the endpoint, then jockora doctor |
| Speech failing | /now.json → health.tts | A wrong voice_id fails every synthesis with a 503 while the station plays on perfectly — this once cost 20 breaks out of 20 with nothing appearing wrong |
| Breaks written and dropped | /now.json → metrics.break_drop_rate above ~0.10 | The writer is repeating itself or overrunning; check last_break to see what did air |
Also check enrichment.done against .total. Early on, most tracks have no dossier, so the DJ has little to say — expected, not broken.
Other things that go wrong
- The stream stalls after a few minutes.
metrics.underrunsclimbing means the mixer is not keeping the ring fed;metrics.ring_occupancy_sshows how close to dry it ran.metrics.encoder_restartsabove zero means ffmpeg died and was respawned — one is a recovery working, a rising count is a real fault. Also check free disk: segments are swept by the server, so a stuck sweeper fills the directory. - Nothing plays at all.
no playable tracks under <path>, orlibrary scannedwithfound: 0, both mean the mount is empty or the wrong directory. - First run seems to hang. It is scanning. About 8 minutes for 7,700 tracks over a network mount; later scans of an unchanged library take about 48 seconds. Progress is logged every 500 files.
- Speech is buried under the music. The contract is fixed — music −16 LUFS, speech −16, ducked to −28, true-peak ceiling −1 dBTP. If speech sounds buried, the music's measured loudness is probably missing, so it was never normalised. Check
loudness_lufsintracks. - It says the same thing about the same artist. Known and measured. Artist facts come from MusicBrainz, which supplies about one usable sentence per artist, so that sentence recurs whenever the artist does. Prompt wording, fact cooldowns and withholding facts have each been measured against this and none helped; richer per-track dossiers are the actual lever.
- Breaks land on top of the vocal. The DJ aims at a track's instrumental ramp or outro; where those are unmeasured it falls back to the between-track gap. If
ramp_s,outro_sandramp_confidenceare null for most of the library, most breaks are landing between tracks rather than woven in.
Security: there are accounts, and there is no TLS
Both halves matter, and the second one is the reason this section exists.
What is there. Sign-in is a bcrypt-hashed password for a signed, HttpOnly session cookie lasting 30 days, rate-limited to 10 failed attempts a minute. Every account is admin-created — there is no self-registration anywhere, by design. Signing out revokes the session server-side rather than only clearing the cookie. The stream itself requires a session: /hls/ answers 401 without one, because a listener is their session — presence is what keeps a station on air. Jockora refuses to bind a non-loopback address unless you pass --allow-lan, and warns on every start when you do.
What is not there. TLS. It speaks plain HTTP, so the cookie's Secure flag never sets and every password crosses the wire in clear. None of it has been through a security review. Do not port-forward this to the internet. If it must leave your network, put it behind a reverse proxy that terminates TLS.
Two things stay public on purpose: /now.json, and the app shell and sign-in page. Gating /now.json would break the player and protect nothing, since it already exposes its class of information to anyone who can reach the stream.
There is one console switch that changes who can reach the product: "Anyone can listen without signing in", off by default. It opens the dial, tuning and the stream to callers with no account — and it cannot open the console, which keeps asking for an operator account whatever it is set to. Combined with --allow-lan that is a door onto your whole local network. Leave it off unless that is what you want.
Honest status at v0.3.0
Measured, not asserted:
- It runs unattended and recovers. An 8/8 fault-injection gate covering decoder stalls, a killed ffmpeg, and playlist discontinuity.
- It writes, speaks and airs real breaks end to end.
- 100% groundedness across every break measured; 86% pass the checkable rubric.
- The console's own gates — sources, stations, jocks, adverts, accounts, logs — have been walked end to end and signed off.
Still not known, and stated as such:
- Whether it is good to listen to, over weeks. Taste is deliberately not self-scored: whoever wrote the persona cannot hear it fresh. Single sessions have been heard and approved; a month has not.
- The real repetition rate over a month rather than a few hundred breaks.
- Whether the break cadence is right. It remains the single most likely thing to be misconfigured.
Community contribution runs through jock cards, voice profiles and clients rather than patches — selling commercial exceptions requires a single copyright holder. Jockora is AGPL-3.0-only with a commercial exception available; ffmpeg is operator-supplied and run out of process, never linked or vendored.
Frequently asked questions
What is Jockora?
A free, open-source, self-hosted music streaming server that turns a music library you already own into a live radio station rather than a playlist. AI radio DJs with fixed personalities introduce records, backsell the track that just played and tease what is coming next, driven by a language model you run yourself. It is one Go binary with SQLite state, AGPL-3.0-only with a commercial exception available, at v0.3.0.
How is Jockora different from Navidrome, Jellyfin, or Plex?
Those are music servers: you browse, you choose, you press play. Jockora is a station — it decides what plays next and talks between the records. It is not a player, not a library manager and not a recommender, and it deliberately has no skip and no seek. It can even read its library from Navidrome, Airsonic or Gonic over OpenSubsonic instead of scanning a folder, so it adds to the server you already run rather than replacing it.
Does Jockora need a GPU?
No. A GPU only makes the one-time enrichment pass faster, and nothing about running the station day to day needs one. Measured with Qwen2.5-7B-Instruct-Q5_K_M: 13.9 seconds per track CPU-only on an Apple M4 Pro versus 5.3 seconds on that machine's Metal GPU — roughly 2.6×, not an order of magnitude. Speech synthesis runs on CPU, faster than real time.
Can I run Jockora on Windows?
Yes — through Docker Desktop with the WSL2 backend, or inside a WSL2 Linux distribution using the linux-amd64 binary. There is no native Windows build: releases cover linux/amd64, linux/arm64 and darwin/arm64, and the tree does not compile for windows/amd64 because the preflight's free-disk check uses the Unix-only syscall.Statfs.
Which local AI models work with Jockora?
Anything llama.cpp can serve as a GGUF, anything Ollama can run, or any OpenAI-compatible endpoint. The default dialect is llama.cpp's own /completion, where a grammar makes the JSON schema binding at the sampler rather than advisory — which is what keeps the DJ from asserting facts nothing supplied. Qwen2.5-7B-Instruct at Q5_K_M is what the project's numbers were measured with; a 3B at Q4 is roughly three times faster, and model size mattered far less than whether the chat template was applied at all.
Does Jockora modify or retag my music files?
No. The library is read-only input by design — never written, never retagged, never transcoded. In Docker that is enforced by a :ro mount rather than promised by the code. Over OpenSubsonic, the provider interface has no method that writes, and a test asserts every request is a GET and that no rating, star, scrobble or playlist endpoint is ever called.
How long does enrichment take, and do I have to wait for it?
You do not wait for it. The station goes on air as soon as the scan finishes — minutes, not hours — and enrichment runs in the background while music plays, one pass per track, cached forever and resumable across restarts. For 5,000 tracks: about 19 hours CPU-only on an M4 Pro, about 7 on its GPU, an extrapolated 58 on a typical NUC or NAS. A track with no dossier is not broken; the DJ talks from personality alone and asserts nothing.
Can Jockora read my library from Navidrome?
Yes. Point -subsonic-url at any OpenSubsonic server with -subsonic-user and JOCKORA_SUBSONIC_PASSWORD, and the folder scan is skipped entirely. One consequence worth knowing: a long-lived auth token for that account is written into the local database, because the stored URL has to carry its own credentials.
Why does the music play but the DJ never say anything?
Four distinct causes: no persona configured (no DJ: no persona configured in the log), an unreachable model (health.llm in /now.json), a failing speech sidecar (health.tts — a wrong voice_id fails every synthesis while the station plays on perfectly), or breaks being written and dropped (metrics.break_drop_rate above about 0.10). A mostly unenriched library also gives the DJ little to say early on. Run jockora doctor first: it names the fix, not just the problem.
Is Jockora safe to expose to the internet?
No — do not port-forward it. It speaks plain HTTP, so a session cookie's Secure flag never sets and every password crosses the wire in clear, and it has had no security review. There are real accounts (bcrypt, signed HttpOnly 30-day session cookies, 10 failed attempts a minute, admin-created only), and the stream answers 401 without a session. If it must leave your network, put a TLS-terminating reverse proxy in front of it.
What licence is Jockora under?
AGPL-3.0-only, with a commercial exception available. ffmpeg is GPL and is operator-supplied and run out of process — never linked, never vendored — which is mere aggregation under GPL section 5, and is how both licences hold at once. Nothing GPL is ever linked into the binary.