Open-source self-hosted server · AI radio

Jockora: an AI radio station for the music you already own

Point Jockora at your music library and it does not build a playlist — it builds a station. AI DJs with fixed personalities introduce your records, backsell the one that just played, and tell you what is coming next. The language model runs on your own machine, the library is never written to, and nothing about your collection has to leave the house.

Published · Covers Jockora v0.3.0

v0.3.0, running, and pre-1.0. The station runs unattended and recovers from a killed encoder, but it speaks plain HTTP and has had no security review — do not port-forward it to the internet. Jockora is AGPL-3.0-only with a commercial exception available.

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:

JockoraNavidrome / Jellyfin / PlexSpotify AI DJ
What it doesRuns a station — picks the record and talks between themServes your library so you can browse and chooseStreams their catalogue with a DJ voice over it
Your musicFiles you own, read-onlyFiles you ownTheir catalogue, licensed to you
Where the AI runsYour machine, by defaultTheir servers
Skip / seekNeither, by designBothBoth
Talks about the recordYes, grounded in a per-track dossierNoYes
Works offlineEntirely, on local AIYesNo
CostFree, AGPL-3.0Free / paid tiersSubscription

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

ComponentWhat it isWhy it is outside the binary
ffmpeg + ffprobeDecode, crossfade, duck, encodeGPL. Run out of process and never linked, which is mere aggregation — that is what lets AGPL and GPL hold at once.
Speech sidecarPython 3.10 + Kokoro ONNX337 MB of someone else's model weights, operator-supplied for the same reason ffmpeg is. Runs on CPU, faster than real time.
Language modelllama.cpp, Ollama, or a hosted endpointYours 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:

JockGood for genresMoods
Marcus "Midnight" Valesynthwave, alternative, electronic, ambientmelancholic, nocturnal, wistful, hypnotic
Dutch "The Hammer" Mahoneyrock, metal, punk, hard rock, grungeaggressive, energetic, defiant, raw
Roxy Sinclairpop, dance, disco, funk, r&b, souleuphoric, romantic, upbeat, playful
Prosper Okonkwohip hop, rap, trip hop, jazz, neo soulcool, confident, smooth, reflective
Chip Hallidayindie, britpop, new wave, power pop, skaenergetic, bright, manic, sunny
Marlon Vexalternative, electronic, industrial, post-punk, gothrestless, ominous, paranoid, hypnotic
Sunny Marchettidowntempo, new age, chillout, acoustic, folkdreamy, calm, gentle, restorative
Wendell J. Pikeclassical, opera, orchestral, jazz, instrumentalserene, grand, contemplative, melancholic
Baby Concepcionopm, ballad, easy listening, bolero, kundimansentimental, 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.

ConfigurationPer track5,000 tracks
CPU only, Apple M4 Pro (-ngl 0)13.9 s19 hours
Metal GPU, same machine5.3 s7 hours
CPU only, typical NUC or NAS (~⅓ of an M4 Pro)41.8 s58 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.

  1. Get the checkout and configure the paths. Compose reads .env automatically, so you set them once and every later docker compose logs, restart and down works without re-exporting anything.
    git clone https://github.com/andrewloable/Jockora.git
    cd Jockora
    cp .env.example .env

    Then edit .env:

    VariableWhat 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.
  2. 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 ../..
  3. 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 ./config

    Required 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 doctor refuses to serve and names this exact fix rather than failing quietly.

  4. Start it. Compose starts llama-server alongside 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 jockora

    The 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: . for image: under the jockora service:

    docker pull ghcr.io/andrewloable/jockora:latest
    # or
    docker pull andrewloable/jockora:latest
  5. 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 ps listing.
    docker compose exec jockora jockora admin create -name you
  6. Open the dial. http://localhost:8080 is the listener UI — sign in and pick a station. http://localhost:8080/admin is 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

HostWhat to installThe one thing that catches people
LinuxDocker Engine + compose pluginchown -R 10001 ./config is genuinely required here
macOSDocker 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
WindowsDocker Desktop with the WSL2 backendKeep 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

PlatformPrebuilt binary?How to run it
Linux x86-64Yes — linux-amd64 tarballDownload, install ffmpeg + the sidecar, run
Linux arm64Yes — linux-arm64 tarballSame. Raspberry Pi class hardware works; expect slow enrichment
macOS, Apple SiliconYes — darwin-arm64 tarballDownload, brew install ffmpeg, run
macOS, IntelNo tarballBuilds from source cleanly: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build ./cmd/jockora
WindowsNo native buildDocker 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

  1. 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/dist is 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
  2. 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) '
  3. Make the speech sidecar's virtualenv. Python 3.10; kokoro-onnx pulls onnxruntime with 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"
  4. 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 ..
  5. 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
  6. 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-path is JOCKORA_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
  7. Check it before starting it. jockora doctor names what is missing and what to do about it, and serve refuses 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
  8. Make an account, then serve.
    ./jockora admin create -name you     # asks for a password, twice, without echo
    ./jockora serve

    Export JOCKORA_DB_PATH rather 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 one export to avoid.

Pointing Jockora at your AI

Four dialects, one flag. -llm-api selects which, and -llm-url says where.

You runJOCKORA_LLM_APIJOCKORA_LLM_URLJOCKORA_LLM_MODEL
llama.cpp (the default, and the recommendation)llamacpphttp://127.0.0.1:8081unset — or a .gguf path, see below
Ollamaollamahttp://127.0.0.1:11434a model name, e.g. qwen2.5:7b-instruct
LM Studio, vLLM, OpenRouter, any OpenAI-compatible hostopenaithat host's base URLits model id
Cloudflare Workers AIopenai.../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.

FlagDefaultWhat it changes, audibly
-break-every-n-tracks4The knob most likely to be wrong. Going from 4 to 8 halves how often you hear a voice and changes nothing else
-break-overlap3Seconds of music playing under each end of a break. Set to 0 for breaks that start exactly at the transition
-fact-confidence0.6How 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-breaks4One break slot in N becomes an advert. The most taste-sensitive number here
-ad-interval-minutes90Floor between adverts regardless of the ratio
-lookahead-seconds150How 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 / −16Music and voice loudness, matched on purpose
-music-ducked-lufs−28How 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

CauseHow you tellFix
No personaLog: no DJ: no persona configuredSet -persona to the personas directory
Model unreachable/now.jsonhealth.llm is not "ok"Every break fails before it is written. Check the endpoint, then jockora doctor
Speech failing/now.jsonhealth.ttsA 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.jsonmetrics.break_drop_rate above ~0.10The 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.underruns climbing means the mixer is not keeping the ring fed; metrics.ring_occupancy_s shows how close to dry it ran. metrics.encoder_restarts above 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>, or library scanned with found: 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_lufs in tracks.
  • 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_s and ramp_confidence are 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.