It really whips the terminal's ass.
curl -fsSL https://nixamp.com/install.sh | sh
nixamp ~/Music
nixamp track.flac
⣿ NIXAMP ▶ PLAYING 1 tracks ~/Music
╭─ Now Playing ────────────────────────────────────────────────────────────────────────────────────╮
│ Meshuggah — Bleed │
│ obZen │
│ 00:41 █████████████████████████───────────────────────────────────────────────────── 32% 07:27 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Spectrum Analyser ─────────────────────────────────╮ ╭─ Playlist (12) ──────────────────────────╮
│ ⠉⠁⠉⠁⠤⠄⣀⡀ │ │ 1 Meshuggah — Bleed 07:27 │
│ ⣶⡆⣶⡆⣀⡀ ⠒⠂⠉⠁ │ │ 2 SOAD — Aerials 03:55 │
│ ⣿⡇⣿⡇⣿⡇⣿⡇⣤⡄⣶⡆⣀⡀⠒⠂⠉⠁ ⠤⠄⣀⡀ │ │ │
│ ⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣀⡀⣿⡇⣿⡇⣤⡄⣤⡄⣀⡀ ⠉⠁⠤⠄⠒⠂ ⣀⡀ │ │ │
│ ⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇ │ │ │
│ ▆▆▅▅▅▅▄▄▅▄▄▄▄▄▄▃▃▃▃▃▃▃▃▂ L▮▮▮▮·· R▮▮▮▮·· │ │ │
╰─────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────╯
Space Stop ↑↓ Select n/p Next/Prev Enter Play q Quit
curl -fsSL https://nixamp.com/install.sh | sh
Everything lands under ~/.local. No root, no package manager, no system files
touched. On a machine with a desktop session it installs the app and the CLI
together, and the CLI then runs on the Node inside the app, so there is no
system Node to keep in step. Over SSH it detects that there is no desktop and
installs the CLI alone.
curl -fsSL https://nixamp.com/install.sh | sh -s -- --cli-only
curl -fsSL https://nixamp.com/install.sh | sh -s -- --desktop
curl -fsSL https://nixamp.com/install.sh | sh -s -- --version 0.1.0
curl -fsSL https://nixamp.com/install.sh | sh -s -- --prefix ~/opt
On Windows, in PowerShell:
irm https://nixamp.com/install.ps1 | iex
That lands under %LOCALAPPDATA%\nixamp, adds itself to your user PATH, and
needs no administrator rights. Nothing is code signed, so SmartScreen will warn
the first time.
Builds are published for Linux, macOS and Windows, on both x64 and arm64.
Then:
nixamp update re-runs the installer, keeping the choices you made
nixamp update 0.2.0 or pins a version
nixamp uninstall says what would go
nixamp uninstall --yes removes exactly what the installer created
Removal reads a manifest the installer wrote, so it is exact and works with no network. Your music is never touched.
If you would rather not pipe a script into a shell, npm i -g nixamp and
bunx nixamp ~/Music both work; that route needs Node 24 or newer.
An account on nixamp.com is what lets you publish, be paid, and administer a server you own. Three ways in, because a terminal is a bad place to be asked for a password:
nixamp login # choose: a provider in a browser, or a password
nixamp login --with github # straight to GitHub (or google)
nixamp login --password # an address and a password, here
nixamp whoami
nixamp logout
--with github is OAuth 2.0 through the device grant (RFC 8628), which is how
a television has signed you in for years: the terminal shows a short code, you
approve it in a browser on whatever device has a keyboard, and the terminal ends
up holding the session. It never sees your password or the provider's token, and
it works over ssh.
The PWA and the desktop app offer the same providers, since an account made by signing in with GitHub has no password to type anywhere. The CLI keeps its token beside the daemon's state, mode 600, so signing in there and in the desktop app are the same thing on disk. A password, where one is used, is read with the echo off and is never written down.
No magic link. A link in an inbox is no use on a television, or on a phone that is not the one you read mail on.
nixamp token create --name ci # printed once, and only once
nixamp token list
nixamp token revoke <id>
NIXAMP_TOKEN in the environment is a signed-in nixamp with no login at all,
which is the only thing that works in CI. Tokens are stored as hashes and can be
withdrawn from anywhere; signing out does not touch them, which is the point of
them. Sessions are the same kind of thing with an expiry, so nixamp logout
really does end one.
Providers are configured per deployment, and only a provider with both halves is offered:
GITHUB_CLIENT_ID=… GITHUB_CLIENT_SECRET=… nixamp serve --directory
The callback to register is https://your-site/api/v1/<provider>/oauth/callback.
Running the account side of nixamp.com needs Postgres:
DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve --directory
Accounts live where the directory lives and nowhere else: a nixamp on a laptop has nobody to be an account of.
The web app uses Join party for joining a stream or a party on a connected site. In the Parties panel, enter an Invite code and select Join party to open its room. A listed party's Join party link opens the film on the site hosting it; Open room opens its nixamp room.
A live party also shows the current file's source and folder path, so a course can read Course › Section › Lecture, with its position in that folder's playlist. Numbered download filenames become readable lecture titles. Share sheets, browser tabs and device media information follow the current lecture; course names come from the server's metadata, never a guess from the filename.
A watch party lives on the site that has the film. bittorrented.com has them: a six-character code, a host, and everybody at the same second. nixamp has rooms, chat, invitations, a directory, and five clients that can already open one. A bridged party is both.
The identity link is OAuth 2.1, with nixamp.com as the authorization server. The site sends somebody here, they approve it once, and the site holds a token that acts on their nixamp account. It is 2.1 and not 2.0, so:
- authorization code only, with PKCE (S256) required of every client, public or confidential. No implicit grant, no password grant.
- redirect URIs match the registered string exactly; only a loopback port may vary, because a CLI cannot know its port before it listens.
- a code is spent once; presenting it twice withdraws everything it produced.
- refresh tokens rotate, and a retired one presented again withdraws the whole family.
The endpoints are where RFC 8414 says to look for them:
GET /.well-known/oauth-authorization-server
GET /api/v1/oauth/authorize the consent page
POST /api/v1/oauth/token authorization_code, refresh_token
POST /api/v1/oauth/revoke
GET /api/v1/oauth/userinfo
Scopes are profile, email, parties and offline_access. The Account
panel on nixamp.com lists what is connected and takes it away again.
bittorrented.com is registered out of the box. Another client is added with
NIXAMP_OAUTH_CLIENTS, a JSON list:
NIXAMP_OAUTH_CLIENTS='[{"id":"example","name":"Example","redirectUris":["https://example.com/cb"]}]'
Once a party is bridged it is an ordinary live event with a room, so every surface already knows what to do with it:
nixamp party list the ones you could join right now
nixamp party join ABC123 --open the room here, the film where it lives
nixamp party host ABC123 --url URL put one on the air as a nixamp room
nixamp party sync ABC123 --at 930 where playback is (hosts only)
and an agent reaches the same five actions over the Model Context Protocol:
nixamp mcp a stdio MCP server: the parties, the rooms, the transcripts
It acts as whoever the machine is signed in as, so nixamp login comes first.
The film never crosses over: what nixamp carries is the room. The same tools
are at https://nixamp.com/mcp over HTTP, with a nixamp token
(nixamp token create) as the bearer, for an agent with no nixamp installed;
/.well-known/oauth-protected-resource says where the authorization server
is.
BackToSchool.help is a branded, mobile-first client for NixAmp live events. It uses the same NixAmp accounts, PostgreSQL data, rooms, invitations, layouts, and channel transport as the main app; it has no separate backend or user store.
The production Docker image builds both clients and serves the BackToSchool
client for backtoschool.help and www.backtoschool.help. Attach both domains
to the existing NixAmp service and point their DNS at the hosting provider's
targets. Accounts, event APIs, and live audio stay in that same process. FFmpeg
is installed in the image so hosts can broadcast from their browser.
NIXAMP_WEB_SITES maps public origins to built client directories, for example
{"https://backtoschool.help":"/app/backtoschool/dist"}. The configured origin
also supplies event metadata and invitation links. Other hosts use --web.
An invalid mapping or missing build stops startup rather than serving the wrong
client. NIXAMP_SITE continues to identify the shared NixAmp account service.
Build the server and both web clients from the repository root:
bun install --frozen-lockfile
bun run build
bun run web:build
bun run backtoschool:build
A directory deployment can serve the BackToSchool client instead of the default
NixAmp PWA by pointing --web at its build output:
DATABASE_URL=postgres://user:pass@host/nixamp \
NIXAMP_JWT_SECRET=replace-with-a-long-random-secret \
NIXAMP_SITE=https://backtoschool.help \
bun src/main.ts serve /srv/nixamp/media \
--directory --web "$PWD/backtoschool/dist" --host 127.0.0.1 --port 4321 --no-publish
Put an HTTPS reverse proxy for backtoschool.help in front of that port and
forward the whole origin, including /api and /live. Do not buffer responses
under /api/channels/; those responses carry live audio. Keeping the client and
API on one origin lets the HttpOnly NixAmp session cookie authenticate hosting,
chat, invitations, and moderation. HTTPS is also required for browser microphone
access outside localhost. Event, room, layout, and invitation tables are created
on first use in the configured PostgreSQL database. RESEND_API_KEY and
NIXAMP_MAIL_FROM are optional if invitation email should be sent rather than
only returning a shareable link.
For a shared deployment, set BACKTOSCHOOL_MAIL_FROM to a sender on the verified
backtoschool.help Resend domain. School password resets and invitations use
that sender; BACKTOSCHOOL_RESEND_API_KEY optionally selects its own key, otherwise
it uses RESEND_API_KEY. Verify the domain's SPF and DKIM records in Resend before
enabling the sender. Invitation mail identifies the invitation rather than a follow.
The event form's optional Write with AI button uses its current title,
description, and topic as the prompt, with host and schedule context. It previews
a title, description, and topic for explicit application before saving. Configure
server-only OPENAI_API_KEY and ANTHROPIC_API_KEY: OpenAI runs first, with Claude
as fallback for provider failures or invalid output. Defaults are gpt-5-mini
and claude-haiku-4-5; override them with NIXAMP_WRITER_OPENAI_MODEL and
NIXAMP_WRITER_CLAUDE_MODEL. Signed-in accounts may request six drafts per ten
minutes, with one active request per account and four across the server. Requests
time out after 25 seconds per provider and can be cancelled. Drafting never saves
an event or changes the schedule, visibility, or broadcast links.
A live event carries a kind: talk, class, or concert. The kind is
what a branded client reads to pick a layout, and what /api/v1/events?kind=
filters the directory by, so one NixAmp serves a school and a venue without
either knowing about the other. concert brings its own presets
(concert-viewer, concert-ticketholder, concert-artist) with a stage,
a setlist, a tip jar, a merch shelf and a till.
A concert also has doors and an encore. POST /api/v1/events/:id/doors opens
the room before the music, .../start begins it, .../encore says the band
came back, and .../end closes it. Opening, playing and coming back on are
allowed to everyone on the stage; cancelling and archiving stay with the host.
An artist is an invitation role beside moderator: they perform without
being handed the guest list.
A ticket is a paid pass to one room, over x402 and settled by CoinPay, exactly like the crawler paywall but scoped to a single event:
POST /api/v1/events/:id/tickets # X-PAYMENT proof in, ticket out
GET /api/v1/events/:id/tickets # what it costs and whether you hold one
POST /api/v1/events/:id/tickets/comp # the guest list, hosts only
Set ticketPriceCents and a payTo address on the event and the room answers
402 to anyone without a ticket, quoting the price; the money goes to the
event's own address, never to the platform. The ticket rides in
x-nixamp-ticket, or in ?ticket= for an <audio> or <video> element that
cannot set a header. COINPAY_X402_KEY switches sales on; without it every
event is simply a free one. NIXAMP_TICKET_SECRET signs the passes (it
defaults to the CoinPay key), and each event's tickets are signed with a
secret derived from it and the event id, so a ticket to Friday is not a ticket
to Saturday.
Servers start with an IPTV-org catalog using its public
main playlist. It is added once
alongside existing catalogs and fetched in the background. Removing it stays
removed on restart; add https://iptv-org.github.io/iptv/index.m3u to restore it.
Catalog browsing and file navigation apply immediately after a click. Group
lists are cached until the provider refreshes, and playback updates reuse the
existing file list without moving focus, scrolling, or the browsing page.
nixamp.com/directory lists nixamps that agreed to be listed. In the PWA, Browse the directory next to the address field picks one without typing anything.
nixamp serve asks before listing you, and shows the exact link it would
publish:
List this stream at https://nixamp.com/directory so anyone can find it?
It publishes http://198.51.100.7:4321/view/Lk1EM_mP977e1VT — listen only,
not the controls. [Y/n]
Yes is the default; --publish and --no-publish skip the question, --name
sets what it is called. A terminal that cannot ask never publishes, because
nobody being there to answer is not consent.
What gets published is a listen-only link. Every server mints two keys: the
one in your own share link drives the player, and the listen key can hear it
and nothing else. /api/command and /api/source answer 403 to a listen key,
so a stranger in the directory cannot pause your music or point your machine at
something else.
Entries expire a few minutes after a stream stops renewing, so the list is always what is actually live.
A live that ends says so. A film or a podcast that plays to its end, a list whose last entry did, or a publisher who stopped, used to start again from the top for ever. Now the channel plays its outro: five seconds of "THIS LIVE STREAM HAS ENDED" on the plate with the mark (a soft chime, on a channel with no picture), looped for an hour, so whoever joins late is told by the picture and by the page (an ENDED chip and a line in the Log), and the room's trollbox stays open. Then the channel closes on its own. The clip is drawn by the server itself with its ffmpeg the first time it is needed and kept beside the keys; Start over on the channel brings the show back from its beginning. A live feed that drops mid-stream is still dialled again, as before: only a clean end is an end.
Every live room has a trollbox: the chat for whoever has joined that stream, kept at nixamp.com and keyed by the server and the channel, so everybody watching one stream is in the same box whichever page they came from. Reading it needs nobody. A line needs a nixamp.com sign-in, and is signed with the account's public handle, never its address.
A line can be said rather than typed. The microphone button beside the box is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself, and sends nixamp.com a small WAV with the room's name on it; the ear posts the words to the room in the same request, and the line appears. Said is sent, unless the Edit before sending switch under the box is on, in which case the words wait in the box for Send. A line, once sent, is public record: there is no taking it down, not by its author and not by the server's owner.
On the phone, too. Every live room has a six-digit code on the party
line (see below), and only when somebody is on the phone in a room, each
trollbox line is read aloud to them: "chovy says: …", in a voice that is
theirs as far as a machine can manage, and different from everybody
else's in the room. The Account panel (or nixamp profile, or the
profile_set MCP tool) sets it: a woman's voice, a man's, any, or a voice
id; or an OpenProfile URL, whose Voice, Gender or Pronouns decide.
Given a sex, the account picks one voice from that sex's pool and keeps
it; given nothing, one from the whole pool. nixamp voices lists them.
Two pools. Telnyx's Kokoro voices are an open-weights model with no bill
beyond the call: eleven women, eight men. ElevenLabs reads better and bills
per character: when the Telnyx account holds an integration secret named
elevenlabs with the ElevenLabs key, nixamp.com finds it on its own and
uses ElevenLabs' premade voices by their labelled gender; NIXAMP_TTS=kokoro
keeps the free ones regardless. NIXAMP_VOICES_FEMALE / NIXAMP_VOICES_MALE
(comma lists of Telnyx voice ids) replace either pool outright.
The ear is Whisper run through
Transformers.js, an
Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
Nothing is sent to a speech vendor and nothing is billed. It works in the
PWA, the desktop app and on a phone, wherever the browser can record; the
button only appears where a line can be sent from, which is signed in on
nixamp.com.
The same ear is one route, for anything else that has a recording:
POST /api/v1/speech/transcribe a WAV in (16-bit PCM; 16 kHz mono is ideal), {text} out
POST /api/v1/speech/transcribe?server=URL&channel=ID and the words posted to that room
Signed in only, up to a minute at a time, twelve asks a minute per account,
?language=de when Whisper should not guess. The CLI and the MCP server
front the same route:
nixamp transcribe clip.m4a the words in a recording
nixamp transcribe clip.m4a --say https://server1.chovy.nixamp.com:4321
nixamp transcribe clip.m4a --say URL --channel cat-1
Anything ffmpeg can read is converted here first; a WAV needs no ffmpeg.
nixamp mcp offers transcribe_audio (with the same optional room),
trollbox_say and trollbox_read.
Every live channel can be captioned. The server carrying it listens to its own stream, turns the sound into five-second windows with its ffmpeg, and has nixamp.com's ear turn each window into a line stamped with the moment its sound was at the live edge. The lines go out as Server-Sent Events:
GET /api/channels/ID/captions an event stream: `hello` with the recent lines, then a `line` each
GET /api/channels/ID/transcript the recent lines as JSON (?after=MS for only the new ones)
Both are read with the same key as the sound. The page opens the stream as soon as you join a live and shows a Transcript panel, on by default: each line is held until your own playback has reached the sound it came from (the backlog you were handed, plus a little buffering) and then shown, on the picture when there is one and in the panel always. Close to the voice, not on it: a line is a window, not a word. The switch in the panel turns captions off for that device; the Panels list hides the panel.
A captioner runs only while somebody is asking, and stops a minute after
the last one leaves; silence between songs is never sent. The server needs
an ffmpeg and a sign-in (nixamp login) for the ear to answer it. In the
terminal, nixamp transcript --channel ID --follow prints the lines as
they come; an agent reads them with the transcript_read tool.
The model is an optional dependency, because it is hundreds of megabytes
with the ONNX runtime under it and the CLI tarball is pure JavaScript. A
nixamp serve on a laptop answers 503 to this route and every client asks
nixamp.com instead. NIXAMP_STT_MODEL picks another Whisper
(onnx-community/whisper-base by default; whisper-small hears better and
takes twice as long), NIXAMP_STT_CACHE says where its files are kept, and
NIXAMP_STT=off leaves the ear out of a deployment altogether.
Captions default to Original (auto-detect). Each audio window detects its own language and explicitly transcribes it. A language selected in the menu only affects translation; neither it nor a cached transcript can force the recognizer into English. Short windows have a decoding limit, and silent audio and repetitive hallucinations are discarded. Language is stored on each line, so an interview can switch languages. Legacy live-caption cache entries are heard again instead of replaying their incorrect words.
At most four channels are captioned per server, with two recognition requests per channel in flight. Live work expires after twelve seconds. Translation keeps one active request and the latest pending line per target; joining a live reads cached translations without starting a whole-transcript job.
What the ear hears is kept on nixamp.com under the identity of what was playing, not of the channel that happened to play it: a file by its fingerprint (its size and a megabyte at each end), a link by its address, a live as the one broadcast it was. Lines are seconds into the media. The next captioner to meet the same film reads the lines out of the store instead of hearing them, whichever server it is on; what it hears beyond them is added.
nixamp transcribe FILE the whole film, a minute at a time, kept when it is done
nixamp transcribe FILE --srt > film.srt as subtitles; --vtt, --txt, --json
nixamp transcribe FILE --out DIR a subtitle file per language in DIR
nixamp transcript --kept MEDIA_OR_ID what nixamp.com keeps, for a file, a link or a past live
nixamp transcript --list everything this account has had written down
GET /api/v1/transcripts what you have had written down
GET /api/v1/transcripts/ID the transcript; ?format=srt|vtt|txt, ?language=de
POST /api/v1/transcripts/ID/lines keep lines: {media, language, lines: [{start, end, text}], complete?}
DELETE /api/v1/transcripts/ID forget it (whoever kept it)
ID is the sha256 of the media identity, or the identity itself
(file:v1:<hash>, url:<address>, live:<server>/<channel>@<started>).
Signed in to read and to keep, like the ear. A whole-file pass marks the row
complete and replaces the pieces a captioner left; a live grows as it goes
and a page that asks for it reads what there is so far. An agent has
transcript_get and transcripts_list, and transcribe_audio keeps a film
the same way.
Every file nixamp meets gets a page at /hash/<sha256>, the SHA-256 of its
bytes, the way OpenFile (logicsrc.com/docs/openfile) names a file, so the same
film on two machines is one page. The page, and the OpenFile descriptor beside
it, carry the size, the type, when the file last changed, what ffprobe found
inside, what nichedb.dev says it is, which servers have carried it and as which
channel, and its transcripts in every language, as subtitle files. Whoever
meets the file fills it in: nixamp hash, nixamp transcribe, and a server
that puts the file on the air.
nixamp hash FILE the hash, the address, and what is known, kept
nixamp hash FILE --no-keep the hash and the address only
nixamp hash --get ID what nixamp.com knows, by hash or fingerprint
GET /hash/ID the page; JSON when Accept says so
GET /hash/ID.json the OpenFile file object with nixamp's facts under `nixamp`
GET /hash/ID.openfile.json the same, as a descriptor
GET /hash/ID.srt the transcript as subtitles; .vtt, .txt; ?language=de
GET /api/v1/media/ID the record; PUT it, signed in, with what you know
GET /.well-known/openfile.json every file nixamp.com knows, as a publisher's listing
ID is the hash with or without sha256:, the transcript store's fingerprint,
or a transcript id. Reading is open, since the hash of the bytes is the file;
keeping is signed in.
A file changes. The machine holding it keeps an index of what it has told
nixamp.com and looks at each file again on a schedule set by how recently it
changed: a quarter of the time since its last change, between a quarter of an
hour and a month, so a file being edited is checked often and a film from 2019
once a month. A stat is all it costs until something moved; then the file is
hashed again, the new record says what it was and the old one what it became.
An agent has media_hash and media_get.
Ask for a language and the lines come translated, by an open-source model on nixamp.com's own CPU (Helsinki-NLP's OPUS-MT pairs, through Transformers.js): German and Swedish among the languages, and anything with a model from or into English; a pair with no model of its own goes through English. A translation is made once and kept beside the original.
GET /api/channels/ID/captions?language=sv a live's lines in Swedish, each translated as it is heard
GET /api/v1/transcripts/ID?language=de a kept transcript in German; 202 with progress while a long one is made
GET /api/v1/translate the languages, and what each can be turned into here
POST /api/v1/translate {texts, from, to} -> {texts}
nixamp transcript --channel ID --language sv a live, in Swedish, as it speaks
nixamp transcribe FILE --translate de,sv a film in German and Swedish too
nixamp translate --to sv "Hello there" a line; or lines on stdin
nixamp translate --languages what nixamp.com can do
The page has the same choice beside the Captions switch, remembered per
device; a translated line is marked with its language and shows what was
heard under the pointer. An agent has translate_text. NIXAMP_MT_WARM
names pairs to load at boot (en-de,en-sv), NIXAMP_MT=off leaves
translation out. The Docker image includes the ear, German/Swedish pairs
with English, and Spanish pairs with English and German. Spanish-to-German
uses its direct model; it does not first translate the audio into English.
Every signed-in account gets ten free live-use sessions per UTC day, shared across Nixamp's paid panels and upgrades. There is no timed cutoff: a session continues while its listener remains, within the existing API usage limits. Reconnects to the same session within 90 seconds reuse it. A stopped session expires after that grace period; starting again then uses another allowance. The counter resets at midnight UTC without interrupting an ongoing session. Balance polling, captions and individual voice chunks never consume new sessions. Free usage has zero customer charge and leaves purchased credit untouched. The panel shows the remaining allowance alongside any purchased credit.
Buy translated audio ($ in the player or Transcript title bar) offers
prepaid, account-bound passes: $5 / 24 hours, $25 / 7 days, or $100 /
30 days. Each purchase provides that many dollars of usage credit, not
unlimited listening. There is no automatic renewal. Credit expires; buying
before expiry adds the credit and keeps the later expiry. At 1,000 translated
characters/minute with normal recognition overlap, the passes provide about
16, 81, or 327 minutes respectively. Actual speech density changes the allowance.
Paid access is 5× base speech API cost (400% markup): $0.25 per 1,000 Flash characters and $1.10 per submitted Scribe audio hour. Recognition includes repeated context, normally three submitted hours per listening hour. The price is the same for every listener, including reused audio; reuse reduces provider spending. Captions and self-hosted text translation retain their existing free access and throttles.
CoinPay hosts crypto checkout with the merchant's configured currencies. Network
fees are shown separately at checkout. Nixamp creates fixed-price orders on the
server and verifies the stored payment ID, confirmed status, USD currency, and
exact price before crediting the account. Returning from checkout or sending a
client-side paid flag never unlocks access. Pending purchases can be resumed
from the panel on another device signed into the same account.
PostgreSQL atomically reserves usage credit before paid calls, refunds rejected provider requests, and credits a confirmed payment once across concurrent checks. Accepted speech is charged even if playback is canceled. Money is stored as integer micro-USD. The ledger uses base cost rounded up to a micro-dollar, then multiplied by five. Credentials and balances never travel in checkout URLs. New checkout creation is capped at five per account and fifty per account server per UTC day, plus IP and request throttles; retries reuse the original invoice.
After the ten free sessions, new sessions require purchased credit. Configure COINPAY_X402_KEY
with payments:create permission and at least one business wallet; the scoped
key supplies the merchant identity. Free sessions and existing credit still work during a
checkout outage. A self-hosted operator explicitly sponsoring API usage may set
NIXAMP_TRANSLATION_BILLING=off.
Live Nixamp channels share one recognition, translation, and voice pipeline per source and target language on the account server. Each listening account uses its own free allowance or credit; joining adds no extra recognition or voice generation. The pipeline persists while anyone remains and closes its source and pending work when the last listener leaves. Disconnecting one viewer does not stop the others. Two connections per account, four active source/language pipelines, and 1,000 connections per pipeline bound resource use. A slow or unfunded listener is disconnected independently. Background sound stays local and independently switchable. Public source addresses are resolved and pinned before fetching; redirects and ffmpeg network/file fetches are disabled.
Live pipeline sharing currently runs within one account-server process (as nixamp.com's deployment does). Multiple replicas need stream affinity before scaling this path; the payment ledger already works across replicas. Files and individually timed browser media retain local capture, because viewers can be at different playback positions. Shared live streams use the same speaker voices for everyone; individual playback retains voice overrides.
Use Translate audio beside the player's language menu to hear whatever Nixamp is playing in your language. One click starts translation; it selects your preferred supported language if the menu is still on Original. Turn it off to restore the original audio. The video and the room's shared playback clock keep running. Captions in the Transcript panel enables text-only recognition. Ordinary file playback uploads no audio; enabling captions or translated audio opts into processing short clips from the playing media.
Native captions use local Whisper Base through Transformers.js. Optional
speaker-aware audio uses ElevenLabs Scribe v2 for native transcription with
speaker turns, local OPUS-MT for the selected translation, and ElevenLabs
Flash v2.5 HTTP streaming for natural stock voices. The application sends
short audio clips and translated text to ElevenLabs only for this optional
feature. This uses the direct API; it does not need an MCP server or clone voices.
Supported translation pairs come from /api/v1/translate; voice languages are
also checked before enabling the audio toggle.
For translated audio, a rolling six-second window advances every two seconds. Native Whisper captions retain their five-second input. Speaker labels are reconciled using overlapping timestamps, with different voices assigned to separate speakers. Voices are picked from the available stock catalogue without inferring a person's gender from pitch; each detected speaker gets an unused voice until the catalogue is exhausted. Audio options folds away optional individual overrides. A speaker returning after leaving the rolling context may receive a new label. Simultaneous speech and noisy crowds can still confuse recognition. Native captions never translate to English as an intermediate recognition step.
Recognition, text translation, and streaming voice playback run as separate stages. Each stage has at most one active request per shared pipeline or individual playback session. Overlapping recognition windows recover unprocessed words; unfinished phrases briefly stay in context instead of translating every two-second fragment separately. The voice player preserves pending speaker turns and fetches the next phrase with up to three seconds of audio still queued. Speech queues and decoded audio are bounded. Old transcript history is never spoken. Pause, seek, source changes, and disabling the feature cancel queued speech. Temporary connection and provider failures recover automatically without restoring original speech. Stale phrases are discarded so playback can catch up; three consecutive provider failures or an access/budget error end the session with a visible message. Network reconnection is limited to five attempts with backoff, reusing the active free session where available. This is a delayed live interpreter, not a promise of exact lip sync or word-by-word streaming captions.
OpenStream currently compresses server-to-server relays, not this browser translation path. Individual playback uploads bounded mono 16 kHz WAV clips. Shared live channels are decoded on the account server and distribute the generated PCM over one authenticated event stream per viewer. Ordinary media playback already uses its audio/video codecs. The short-window overlap ratio and audio-second spending limits remain unchanged; smaller windows do not increase the steady-state audio submitted.
Translated playback keeps an approximate version of the original background sound. FastEnhancer Web's Base model estimates speech locally in a dedicated browser worker. Every audible channel, including rear and side channels, enters the speech estimate. A complementary spectral mask removes estimated voice frequencies independently from the left and right source channels, preserving their stereo phase. One speech model serves both channels to leave processing capacity for video playback. Background processing adds no API calls or provider charges. It stops with translation; recognition starts without waiting for it. Brief processing stalls drop stale frames and recover automatically, with bounded work and at most about 130 ms of background delay. A model or device failure silences that branch while translated speech continues. Separation of mixed dialogue and background remains approximate. Disable Keep background sound under Audio options when needed. This uses FastEnhancer Web and FFT.js, under the MIT license.
Background level, in the same collapsed options, balances the separated sound from 0–200% (100% by default), with a fixed +9.5 dB makeup gain after separation. There is no automatic gain control, fade, or ducking triggered by translated voices. It never mixes the original dialogue back in as a fallback. Increasing it also amplifies any speech the model fails to remove. This is an approximate local separator, not lossless dialogue removal.
Translated audio stays selected for the entire session, including the initial wait, gaps between lines, buffering, seeking, and changes of speaker or target language. With background sound off, gaps are silent; with it on, the separated background continues. The original audio returns when translation is disabled (including an announced provider failure), not whenever an utterance finishes.
The account server needs ELEVENLABS_API_KEY; NIXAMP_DUBBING=off disables
this feature. The key stays on the server. Sign-in is required for speaker
analysis, voice selection, and short-lived playback grants. Grants expire after
90 seconds, authorize at most 2,000 characters, and are scoped to one playback
session or channel. Provider requests also have account/IP throttles, concurrency
limits, and cached duplicate voice generation. Native speech and local
translation retain their existing account and queue limits.
Postgres stores atomic usage reservations and hashed grants, so the feature's budgets survive restarts and are shared between replicas. Provider failures still consume the abuse budgets conservatively; the separate paid balance refunds requests rejected before provider acceptance. The configurable daily limits are:
| Setting | Default | Counts |
|---|---|---|
NIXAMP_DUB_DAILY_CHARS |
200,000 | New voice characters across this server |
NIXAMP_DUB_USER_DAILY_CHARS |
120,000 | New voice characters per account |
NIXAMP_DUB_DAILY_AUDIO_SECONDS |
86,400 | Scribe audio seconds across this server |
NIXAMP_DUB_USER_DAILY_AUDIO_SECONDS |
43,200 | Scribe audio seconds per account |
Audio limits count overlapping context too: a continuous hour of speaker-aware listening submits about three hours of Scribe audio. At the published API rates of $0.05 per 1,000 Flash characters and $0.22 per Scribe audio hour, a listener producing 1,000 translated characters per minute costs about $3.66/hour, before plan minimums or discounts. These default server quotas limit this feature to about $15.28/day at those rates; they do not cover other applications using the same provider key. Set a daily limit to zero to block new use of that resource. Limits return 429 and never trigger an unlimited fallback provider.
GET /api/v1/speech/voices authenticated stock voices and supported audio languages
POST /api/v1/speech/shared paid {source: liveChannelUrl, language} -> shared captions and PCM events
GET /api/v1/translation-passes plans, balance and pending purchases
POST /api/v1/translation-passes/checkout authenticated {plan, coin, requestKey} -> hosted checkout
GET /api/v1/translation-passes/orders/:id authenticated owner payment verification
POST /api/v1/speech/speakers paid, bounded mono 16 kHz WAV -> native speaker turns
POST /api/v1/speech/grant authenticated {channel: playbackScope} -> short-lived grant
POST /api/v1/speech/synthesize scoped grant + {channel, text, language, voice, profile} -> streaming PCM
A channel is one publisher and everybody listening to them. Two or three devices can be live at the same time -- a phone, a desktop, a second window -- each with its own audience.
GET /api/channels what is live now
POST /api/channels/<id> publish to one
GET /api/channels/<id> listen to one
GET /api/channels/<id>/art a picture of it, as JPEG
The picture is what a share link unfurls into on a chat or a timeline, and
what the lock screen shows while it plays. A pasted link's thumbnail is sent
on as it is; a podcast's sleeve is read out of the file; a moving picture
gets one frame of what the channel is sending, taken from the channel's own
backlog rather than by opening the source a second time. The page for a
share link (/?url=...&play=channel:<id>) carries it as og:image with a
Twitter card, on nixamp.com and on the server itself.
One ffmpeg decodes each publisher once and the result is written to every listener on that channel. A decode per listener would cost a core each and, for a live stream, would not even agree with itself about what "now" is.
A listener who joins halfway through gets the stream from that moment, which is what live means. Two publishers on one channel is refused; on two channels it is the whole point.
Publishing is administering the server, so it needs the control link or the owner's account. Listening only needs the share link, like any other audio.
A channel can be carried from one nixamp to another with fewer bytes on the wire and every byte restored at the far end. It is off until you turn it on, per channel, and nothing about ordinary playback changes when you do.
nixamp compression analyze --channel cnn what a codec would make of it
nixamp compression set --channel cnn --mode auto compress when it pays, store when it does not
nixamp compression status --channel cnn what it is doing, in bytes
nixamp compression off the whole server, at once
On the receiving nixamp:
nixamp compression pull --channel cnn --from https://host:4321/api/channels/cnn/relay --from-key KEY
and cnn is a channel there, heard at /api/channels/cnn like any other.
The relay is GET /api/channels/<id>/relay as application/vnd.nixamp.stream,
a framed stream of Zstandard blocks each carrying the length and SHA-256 of
what it stands for, ending in a marker; a block that would not shrink is
sent as it is, and the metrics say so rather than claiming a saving. A
library file gets the same treatment at /api/media/<n>/relay, built once
and kept. nixamp compression analyze FILE measures a file here with no
server at all. The wire format, the policy, the limits and the switch are
in docs/stream-compression.md.
HLS can be packaged as fragmented MP4 instead of MPEG-TS
(--hls fmp4 on compression set, or server-wide): the same boxes the
channel already carries, copied into files, never re-encoded.
A nixamp can be the thing you broadcast to, not just from.
nixamp serve ~/Music --rtmp-in 1935
Then point OBS, Larix, or another ffmpeg at the URL it prints. RTMP is what every native broadcaster already speaks, so there is no nixamp-shaped client to install. ffmpeg does the listening, so this costs no extra dependency.
A browser cannot speak RTMP at all, so the web app uses HTTP instead: one long
POST /api/ingest where the platform allows a streaming request body, and
POST /api/ingest/chunk where it does not. All three end up in the same place.
One publisher at a time. A second is refused rather than mixed.
Out to as many places as you like, at once:
nixamp serve ~/Music --rtmp youtube=<key> --rtmp x=<key> --rtmp tiktok=<key>
youtube, x, facebook, tiktok, twitch and kick are known by name and
need only a key; anything else takes a full rtmp://host/app/key.
One ffmpeg, one encode, many outputs, through the tee muxer. An ffmpeg per
destination is the obvious shape and it encodes the same frames four times.
Every output carries onfail=ignore, so one destination with an expired key
cannot take the others down with it.
The encoder settings come from PairUX, which learned them against the real
platforms: a one-second keyframe interval because YouTube stalls on ffmpeg's
default, a forced constant frame rate because a variable-rate source makes
YouTube report a stream falling behind, and yuv420p because that is what RTMP
platforms accept. Music has no picture, so a flat colour is generated: RTMP
wants a video track either way.
Stream keys are read from the command line or the environment and never from a
request. /api/broadcast/destinations shows names and URLs with the keys
redacted.
A stream serving a handful of friends costs nothing and asks nothing. Past five people listening at once it is bandwidth somebody is paying for, so the gate opens: the sixth listener gets a 402 with an x402 offer, and a dollar buys a day.
NIXAMP_PAY_TO=0xYourAddress COINPAY_X402_KEY=cp_live_… nixamp serve ~/Music --x402
Three things are deliberate. The count is of live listeners, so a stream
quietens back to free on its own. Only the audio is gated: a 402 on /api/state
would break the page that has to render the offer. And nobody is cut off
mid-track, because the gate is asked once, when a request arrives.
NIXAMP_PRICE_CENTS and NIXAMP_PASS_MINUTES change the terms; the defaults are
100 and 1440, which is the dollar and the day. A server that has agreed to be in
the directory can also be switched on and off from nixamp.com: the configuration
rides back on the heartbeat it is already sending.
nixamp serve holds a terminal. nixamp daemon does not.
nixamp daemon start ~/Music --open-port
nixamp daemon status
nixamp daemon stop
Start writes down where it went and the key it minted, waits until the server
is actually answering before saying it started, and prints the share link. It is
one daemon per user, and the state lives in $XDG_STATE_HOME/nixamp.
The addresses nixamp prints are the ones its own interfaces have, so a machine
behind NAT only ever sees 192.168.x -- no use to anybody else, and nothing it
can publish. Tell it the address it answers on from outside:
nixamp serve ~/Music --public-url https://nixamp.example.com # or NIXAMP_PUBLIC_URL
That address is what the share links print and what the directory listing carries. Getting one is your business, not nixamp's: a forwarded port, a reverse proxy, or a tunnel, e.g.
cloudflared tunnel --url http://localhost:8420
Without it, --publish is skipped entirely rather than listing a stream nobody
outside the house can open.
d in the player hands the music to a daemon and gives you your terminal back.
Nothing stops. nixamp attach puts the player back in front of it:
nixamp attach # the daemon on this machine
nixamp attach --url URL --key KEY # a nixamp somewhere else
An attached player is the same view and the same keys; the difference is that
the keys are sent to the daemon and what you see is what the daemon is doing.
Any number of terminals may attach at once. q or d leaves without stopping
anything, which is what nixamp daemon stop is for.
Two ways to be allowed, and they answer different questions.
You hold its control link. That is possession: you are at the machine, or somebody at it sent you the link. It works with no account and no network.
You own it. nixamp login and then nixamp serve claims the server for the
account signed in on that machine, and from then on that account can administer
it from a phone anywhere, by signing in to nixamp.com in the browser.
The server cannot check a nixamp.com token itself, and should not: it holds no part of that secret. It asks nixamp.com who the token belongs to and compares the answer to the owner it recorded at startup. Delegating identity while keeping authorisation local is what lets a nixamp on a laptop trust an account it has never seen.
Answers are remembered for a minute, so admin requests do not each cost a round trip, and a revoked session stops working in about a minute rather than at the next restart. If nixamp.com cannot be reached, nobody becomes the owner — the control link is the way in until it can.
Listening is never affected: /api/state, /api/stream and the page itself
stay open to whoever has the share link.
nixamp admin
Who is connected, from where, to what, for how long and how much has gone out.
It reads the daemon's own state file, so it needs no arguments; point it
anywhere else with --url and --key.
╭─ Server ─────────────────────────╮ ╭─ Now playing ────────────────────╮
│ http://127.0.0.1:4321 │ │ long.flac │
│ /home/anthony/Music │ │ — │
│ Uptime 3s │ │ State stopped │
│ Tracks 1 │ │ Position 0s │
│ Listeners 2 │ │ │
╰──────────────────────────────────╯ ╰──────────────────────────────────╯
╭─ Connections (2 live) ────────────────────────────────────────────────╮
│ Where Network Kind Client Track For Sent │
│ 10.0.0.42 private media VLC 3 long.flac 3s 2.6 MiB │
│ 100.65.1.7 tailscale stream Safari 17 long.flac 41s 18 MiB │
╰───────────────────────────────────────────────────────────────────────╯
Press a to add: hand the running server a folder, an album URL or a file and
it joins the playlist under its own heading, with the library still there and
the listeners still connected. Press r to replace instead, which is the
bigger thing — this server now serves that, and the library it had is gone
until you restart it.
An added block can be taken back out from the playlist itself: its heading
carries an ×.
nixamp plays a jingle when it starts, once per session, the way Winamp did.
Any mp3 in your home directory with nixamp in its name is used instead of the
ones that ship, and with more than one it picks at random -- a rotation you can
predict is one you stop hearing. --no-jingle, or NIXAMP_NO_JINGLE=1, turns
it off.
On the web it plays on a fresh page if the browser allows it, and otherwise on your first click -- browsers spent a decade learning to refuse pages that make noise unasked, and this one does not argue with them.
One decode feeds both your speakers and the display. ffmpeg writes raw 32-bit float samples to a pipe; nixamp reads every sample on its way past, runs an FFT over it, and hands the same bytes to ffplay.
Running a second decoder just for the visualiser would be simpler and wrong: the two would drift apart within seconds and the bars would stop matching what you hear.
The analyser is a radix-2 Cooley-Tukey FFT with a Hann window, about 120 lines and no dependency. Bands are spaced logarithmically because hearing is, and they are scaled in decibels for the same reason — linear bins put almost every bar above 10 kHz where there is nothing to see. Bars rise instantly and fall gradually, with a peak marker that sinks. That decay is what made Winamp's analyser readable rather than merely busy.
Braille gives four vertical pixels per character cell, so a bar moves smoothly instead of stepping through eight block glyphs.
The terminal player is the original and still the point, but the same engine now drives two more surfaces.
nixamp serve ~/Music --host 0.0.0.0
nixamp keeps playing through your speakers and hands out a remote: open the address it prints on your phone and you get the playlist, the transport and the same spectrum, pushed as it happens. State goes out over Server-Sent Events rather than a WebSocket, because SSE is plain HTTP — no dependency, and it reconnects by itself when the phone goes to sleep.
Tick Listen on this device and the browser streams the track's bytes and plays it there instead, with its own analyser drawing the same picture.
--host 127.0.0.1 is the default, so nothing is reachable until you say so.
Track paths never leave the machine; the remote sees titles.
| Endpoint | Does |
|---|---|
GET /api/state |
one snapshot |
GET /api/events |
snapshots, pushed |
POST /api/command |
play toggle stop next prev select |
GET /api/media/:n |
the track's bytes, with ranges (--no-media turns it off) |
The PWA — nixamp.com
bun run web:dev # or: bun run web:build && bun run serve
A player in the browser, installable, and the remote client above. It opens your own files — nothing is uploaded; the browser decodes them where they are — and it plays video as well as audio. Vanilla TypeScript and Vite, one 16 kB bundle, and a service worker that precaches the shell so the app opens with no network at all.
The icons are drawn from source (web/scripts/icons.ts) rather than committed
as opaque binaries, which is how the 192 and the 512 stay in step.
bun run desktop:dev
bun run desktop:build # AppImage + deb into desktop/release
Electron around the same PWA, with a real nixamp serve running as a child
process — so the window is the browser player, the terminal player's engine and
the remote-control server at once.
The CLI travels inside the bundle and is run by Electron's own Node, which is the point: installing the app installs a working nixamp with no system Node anywhere near it. Copy Bundled CLI Path in the menu tells you how to call it.
Building is unsigned on purpose — no code signing, no notarisation.
ffmpeg and ffprobe to decode, ffplay to make sound. All three ship together.
sudo apt install ffmpeg # or: brew install ffmpeg
Without ffplay, nixamp still runs and still draws the spectrum — it just says so rather than pretending to play.
A bare ffmpeg on PATH is used when there is one; mise shims are detected and invoked through mise exec, because the shim itself fails when no version is pinned.
| Key | Does |
|---|---|
Space |
Play or stop |
Enter |
Play the selected track from the start |
↑ ↓ |
Move through the playlist |
n p (or → ←) |
Next and previous track |
s |
Stop |
d |
Detach: hand the music to a daemon and keep the terminal |
q |
Quit |
Whatever your ffmpeg was built with: mp3, flac, ogg, opus, m4a, aac, wav, wma, aiff, alac, and the audio track of mp4 and webm.
Video too, including raw transport streams — a .ts, .m2ts or .mts off a
capture card, a receiver or an IPTV recorder, at 1080p or 4K. H.264 is copied
into the fragmented MP4 a browser is sent, at whatever size it already is, so
a 4K recording costs no encoding to watch or to put on the air. H.265 is
copied too when the browser asking for it says it can decode one, and
otherwise re-encoded down to 1080p, because a 4K encode does not keep up with
playing it. A channel, which has one encode and a whole audience, re-encodes
H.265 by default; NIXAMP_HEVC_CHANNELS=1 copies it through instead, for an
audience of phones and televisions.
A .ts is opened rather than taken on its name: it is as often a TypeScript
file as a transport stream, and a checkout is not a playlist.
Early. It plays a directory, shows tags and timings, and draws what it hears — in a terminal, in a browser and in a window, from one engine. The browser player seeks and has a volume slider; the terminal one still does not. Not yet, anywhere: shuffle, repeat, m3u playlists, or the visualiser presets that would make the name honest.
hqtui — the terminal UI library. Like r3q and g1tz, nixamp exists partly to keep hqtui honest: a real application finds the gaps a widget gallery does not.
MIT
Keyboard access, named controls, headings, skip links, visible focus, descriptive slider values, reduced motion, and concise screen-reader status announcements are built into the web player. Incoming transcripts, chat, and playback updates preserve scrolling, focus, caret, and the browsing page. The UX and accessibility baseline applies to all interface changes.
When an older broadcaster still provides unversioned English-first captions, a signed-in listener uses native-language recognition of the playing audio instead of that stale transcript cache. Update the broadcaster for shared native captions; translated audio uses the listener's current audio and selected language.
