Base URL: http://{listenAddress} (default 127.0.0.1:8088)
GET /healthzHealth check.
Response:
{"ok": true}
engine.state spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions.
GET /statusCurrent transmitter status (read-only snapshot). Runtime indicator, alert, and queue stats from the running TX controller are mirrored here for quick health checks.
Response:
{
"service": "fm-rds-tx",
"backend": "pluto",
"frequencyMHz": 100.0,
"stereoEnabled": true,
"rdsEnabled": true,
"preEmphasisTauUS": 50,
"limiterEnabled": true,
"fmModulationEnabled": true,
"runtimeIndicator": "normal",
"runtimeAlert": "",
"queue": {
"capacity": 3,
"depth": 1,
"fillLevel": 0.33,
"health": "low"
}
}
runtimeIndicator is derived from the engine queue health plus any late buffers observed in the last 5 seconds and can be “normal”, “degraded”, or “queueCritical”. runtimeAlert surfaces a short reason (e.g. “queue health low” or “late buffers”) when the indicator is not “normal”, but late-buffer alerts expire after a few seconds once cycle times settle so the signal doesn't stay stuck on degraded. The cumulative lateBuffers counter returned by /runtime still shows how many late cycles have occurred since start for post-mortem diagnosis.
GET /runtimeLive engine and driver telemetry. Only populated when TX is active.
Response:
{
"engine": {
"state": "running",
"runtimeStateDurationSeconds": 12.4,
"chunksProduced": 12345,
"totalSamples": 1408950000,
"underruns": 0,
"lastError": "",
"uptimeSeconds": 3614.2,
"faultCount": 2,
"lastFault": {
"time": "2026-04-06T00:00:00Z",
"reason": "queueCritical",
"severity": "faulted",
"message": "queue health critical for 5 checks"
},
"faultHistory": [
{
"time": "2026-04-06T00:00:00Z",
"reason": "queueCritical",
"severity": "faulted",
"message": "queue health critical for 5 checks"
}
],
"transitionHistory": [
{
"time": "2026-04-06T00:00:00Z",
"from": "running",
"to": "degraded",
"severity": "warn"
}
]
},
"driver": {
"txEnabled": true,
"streamActive": true,
"framesWritten": 12345,
"samplesWritten": 1408950000,
"underruns": 0,
"effectiveSampleRateHz": 2280000
}
}
engine.state spiegelt jetzt die Runtime-State-Maschine wider (idle, arming, prebuffering, running, degraded, muted, faulted, stopping) und bietet eine erste beobachtbare Basis für Fault-Transitions.
runtimeStateDurationSeconds sagt, wie viele Sekunden die Engine bereits im aktuellen Runtime-Zustand verweilt. So erkennt man schnell, ob muted/degraded zu lange dauern oder ob ein Übergang gerade frisch begonnen hat.
transitionHistory liefert die jüngsten Übergänge (from/to, severity, timestamp) damit API und UI die Runtime History synchronisieren können.
POST /runtime/fault/resetManually acknowledge a faulted runtime state so the supervisor can re-enter the recovery path (the engine moves back to degraded once the reset succeeds).
Response:
{"ok": true}
Errors:
405 Method Not Allowed if the request is not a POST503 Service Unavailable when no TX controller is attached (--tx mode not active)409 Conflict when the engine is not currently faulted or the reset was rejected (e.g. still throttled)GET /configFull current configuration (all fields, including non-patchable).
Response: Complete Config JSON object.
POST /configLive parameter update. Changes are applied to the running TX engine immediately — no restart required. Only include fields you want to change (PATCH semantics).
The control snapshot (GET /config) only reflects new values once they pass validation and, if the TX engine is running, after the live update succeeded. That keeps the API from reporting desired values that were rejected or still pending.
Request body: JSON with any subset of patchable fields.
Response:
{"ok": true, "live": true}
"live": true = changes were forwarded to the running engine.
"live": false = engine not active, changes saved for next start.
| Field | Type | Range | Description |
|---|---|---|---|
frequencyMHz |
float | 65–110 | TX center frequency. Tunes hardware LO live. |
outputDrive |
float | 0–10 | Composite output level multiplier (empfohlen 1..4). |
stereoEnabled |
bool | Enable/disable stereo (pilot + 38kHz subcarrier). | |
pilotLevel |
float | 0–0.2 | 19 kHz pilot injection level. |
rdsInjection |
float | 0–0.15 | 57 kHz RDS subcarrier injection level. |
rdsEnabled |
bool | Enable/disable RDS subcarrier. | |
limiterEnabled |
bool | Enable/disable MPX peak limiter. | |
limiterCeiling |
float | 0–2 | Limiter ceiling (max composite amplitude). |
| Field | Type | Max length | Description |
|---|---|---|---|
ps |
string | 8 chars | Program Service name (station name on receiver display). |
radioText |
string | 64 chars | RadioText message (scrolling text on receiver). |
When radioText is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display.
| Field | Type | Description |
|---|---|---|
toneLeftHz |
float | Left tone frequency (test generator). |
toneRightHz |
float | Right tone frequency (test generator). |
toneAmplitude |
float | Test tone amplitude (0–1). |
preEmphasisTauUS |
float | Pre-emphasis time constant. Requires restart. |
# Tune to 99.5 MHz
curl -X POST localhost:8088/config -d '{"frequencyMHz": 99.5}'
# Switch to mono
curl -X POST localhost:8088/config -d '{"stereoEnabled": false}'
# Update now-playing text
curl -X POST localhost:8088/config \
-d '{"ps": "MYRADIO", "radioText": "Artist - Song Title"}'
# Reduce power + disable limiter
curl -X POST localhost:8088/config \
-d '{"outputDrive": 0.8, "limiterEnabled": false}'
# Full update
curl -X POST localhost:8088/config -d '{
"frequencyMHz": 101.3,
"outputDrive": 2.2,
"stereoEnabled": true,
"pilotLevel": 0.041,
"rdsInjection": 0.021,
"rdsEnabled": true,
"limiterEnabled": true,
"limiterCeiling": 1.0,
"ps": "PIRATE",
"radioText": "Broadcasting from the attic"
}'
Invalid values return 400 Bad Request with a descriptive message:
curl -X POST localhost:8088/config -d '{"frequencyMHz": 200}'
# → 400: frequencyMHz out of range (65-110)
POST /tx/startStart transmission. Requires --tx mode with hardware.
Response:
{"ok": true, "action": "started"}
Errors:
405 if not POST503 if no TX controller (not in --tx mode)409 if already runningPOST /tx/stopStop transmission.
Response:
{"ok": true, "action": "stopped"}
GET /dry-runGenerate a synthetic frame summary without hardware. Useful for config verification.
Response: FrameSummary JSON with mode, rates, source info, preview samples.
All live updates are lock-free in the DSP path:
| What | Mechanism | Latency |
|---|---|---|
| DSP params | atomic.Pointer[LiveParams] loaded once per chunk |
≤ 50ms |
| RDS text | atomic.Value in encoder, read at group boundary |
≤ 88ms |
| TX frequency | atomic.Pointer in engine, driver.Tune() between chunks |
≤ 50ms |
No mutex, no channel, no allocation in the real-time path. The HTTP goroutine writes atomics, the DSP goroutine reads them.
These cannot be hot-reloaded (they affect DSP pipeline structure):
compositeRateHz — changes sample rate of entire DSP chaindeviceSampleRateHz — changes hardware rate / upsampler ratiomaxDeviationHz — changes FM modulator scalingpreEmphasisTauUS — changes filter coefficientsrds.pi / rds.pty — rarely change, baked into encoder initaudio.inputPath — audio source selectionbackend.kind / backend.device — hardware selectionPOST /audio/streamPush raw audio data into the live stream buffer. Format: S16LE stereo PCM at the configured --audio-rate (default 44100 Hz).
Requires --audio-stdin, --audio-http, or another configured stream source to feed the buffer.
Request: Binary body, application/octet-stream, raw S16LE stereo PCM bytes.
Response:
{
"ok": true,
"frames": 4096,
"stats": {
"available": 12000,
"capacity": 131072,
"buffered": 0.09,
"written": 890000,
"underruns": 0,
"overflows": 0
}
}
Example:
# Push a file
ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \
curl -X POST --data-binary @- http://pluto:8088/audio/stream
Errors:
405 if not POST503 if no audio stream configuredPipe any audio source through ffmpeg into the transmitter:
# Internet radio stream
ffmpeg -i "http://stream.example.com/radio.mp3" -f s16le -ar 44100 -ac 2 - | \
fmrtx --tx --tx-auto-start --audio-stdin --config config.json
# Local music file
ffmpeg -i music.flac -f s16le -ar 44100 -ac 2 - | \
fmrtx --tx --tx-auto-start --audio-stdin
# Playlist (ffmpeg concat)
ffmpeg -f concat -i playlist.txt -f s16le -ar 44100 -ac 2 - | \
fmrtx --tx --tx-auto-start --audio-stdin
# PulseAudio / ALSA capture (Linux)
parecord --format=s16le --rate=44100 --channels=2 - | \
fmrtx --tx --tx-auto-start --audio-stdin
# Custom sample rate (e.g. 48kHz source)
ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \
fmrtx --tx --tx-auto-start --audio-stdin --audio-rate 48000
Push audio from a remote machine via the HTTP API. Run the server with --audio-http (and typically --tx/--tx-auto-start) so the /audio/stream endpoint is available.
# From another machine on the network
ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | \
curl -X POST --data-binary @- http://pluto-host:8088/audio/stream
The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in GET /runtime under audioStream:
{
"audioStream": {
"available": 12000,
"capacity": 131072,
"buffered": 0.09,
"written": 890000,
"underruns": 0,
"overflows": 0
}
}
When no audio is streaming, the transmitter falls back to the configured tone generator or silence.