Base URL: http://{listenAddress} (default 127.0.0.1:8088)
GET /healthzHealth check.
Response:
{"ok": true}
This endpoint is a simple liveness signal — it does not include runtime-state data or audit counters. Use it for readiness/liveness probes.
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”.
runtimeState mirrors the same runtime-state machine string that /runtime exposes as engine.state when a TX controller is active, so quick health checks reuse the same terminology.
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. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under ingest. The engine payload may also include the latest measurement snapshot under engine.measurement for convenience, but high-frequency metering clients should prefer /measurements.
Response:
{
"engine": {
"state": "running",
"runtimeStateDurationSeconds": 12.4,
"appliedFrequencyMHz": 100.0,
"chunksProduced": 12345,
"totalSamples": 1408950000,
"underruns": 0,
"activePS": "MYRADIO",
"activeRadioText": "Artist - Song Title",
"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,
"underrunStreak": 0,
"maxUnderrunStreak": 0,
"effectiveSampleRateHz": 2280000
},
"controlAudit": {
"methodNotAllowed": 0,
"unsupportedMediaType": 0,
"bodyTooLarge": 0,
"unexpectedBody": 0
},
"ingest": {
"active": {
"id": "icecast-main",
"kind": "icecast",
"family": "streaming",
"transport": "http",
"codec": "auto",
"detail": "http://example.invalid/stream"
},
"source": {
"state": "running",
"connected": true,
"chunksIn": 123,
"samplesIn": 251904
},
"runtime": {
"state": "running",
"droppedFrames": 0,
"convertErrors": 0,
"writeBlocked": false
}
}
}
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.
engine.appliedFrequencyMHz meldet die zuletzt tatsächlich getunte Frequenz auf der Hardware, sodass man sie mit dem gewünschten /config-Wert vergleichen und ausstehende Live-Updates sofort entdecken kann.
driver.underrunStreak reports how many consecutive reads returned silence, and driver.maxUnderrunStreak captures the longest such run since the engine started. Together they help differentiate short glitches from persistent underrun storms and can be plotted alongside queue health sparkline telemetry.
lastFault.reason kann jetzt auch writeTimeout lauten, wenn der Treiber Schreibaufrufe wiederholt verweigert oder blockiert. Die Control-Plane hebt solche Driver-Faults hervor, damit man Blockaden im Writer-Pfad ohne Log-Search sieht.
controlAudit mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload.
GET /measurementsHigh-frequency live metering snapshot for the multiplex chain. This endpoint is intended for Overview/Flow signal metering and should be preferred over polling /runtime at high rate.
Response when no current snapshot is available:
{
"noData": true,
"stale": true
}
Response when a snapshot is available:
{
"noData": false,
"stale": false,
"measurement": {
"timestamp": "2026-04-13T05:30:00Z",
"sampleRateHz": 228000,
"chunkSamples": 11400,
"chunkDurationMs": 50,
"sequence": 12345,
"flags": {
"stereoEnabled": true,
"stereoMode": "DSB",
"rdsEnabled": true,
"rds2Enabled": false,
"bs412Enabled": true,
"compositeClipperEnabled": true,
"watermarkEnabled": false,
"licenseInjectionActive": false
},
"lrPreEncodePostWatermark": {
"lRms": 0.41,
"rRms": 0.39,
"lPeakAbs": 0.98,
"rPeakAbs": 0.96,
"lrBalanceDb": 0.42,
"lClipEvents": 12,
"rClipEvents": 8
},
"audioMpxPreBs412": {
"rms": 0.52,
"peakAbs": 1.0,
"monoRms": 0.34,
"stereoRms": 0.18,
"crestFactor": 1.92,
"clipperLookaheadGain": 0.94,
"clipperEnvelope": 1.03,
"clipperOrProtectionActive": true
},
"audioMpxPostBs412": {
"rms": 0.46,
"peakAbs": 0.91,
"bs412GainApplied": 0.88,
"bs412AttenuationDb": -1.11,
"estimatedAudioPower": 0.21
},
"compositeFinalPreIq": {
"rms": 0.49,
"peakAbs": 1.08,
"pilotRms": 0.064,
"pilotPeakAbs": 0.09,
"pilotInjectionEquivalentPercent": 9.0,
"rdsRms": 0.028,
"rdsPeakAbs": 0.04,
"overNominalEvents": 91,
"overHeadroomEvents": 0
}
}
}
Notes:
pilotInjectionEquivalentPercent is an operator-facing derived value and is separate from the raw pilotRms field.rdsInjectionEquivalentPercent is intentionally not exposed yet in MVP until its derivation is mathematically fixed.clipperLookaheadGain and clipperEnvelope are preferred raw diagnostics; clipperOrProtectionActive is only a derived convenience indicator.licenseInjectionActive means chunk-local actual activity, not merely feature presence.overNominalEvents / overHeadroomEvents are internal normalized composite envelope counters, not legal overmodulation verdicts.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.
Content-Type: application/json (charset parameters allowed). Requests without it are rejected with 415 Unsupported Media Type.
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 the stereo limiter stage in the L/R path. Hard clip stages remain active. | |
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. If StreamTitle relay is active, the runtime engine.activeRadioText exposed by /runtime can temporarily differ from the saved /config value.
| 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 ingest http-raw source. Format: S16LE PCM (ingest.httpRaw.format), currently validated as s16le, with channels/sample-rate from ingest config.
Requires HTTP ingest wiring (typically --audio-http, which maps ingest kind to http-raw).
Request: Binary body, application/octet-stream, raw S16LE stereo PCM bytes. Set Content-Type to application/octet-stream or audio/L16; other media types are rejected. Requests larger than 512 MiB are rejected with 413 Request Entity Too Large.
Response:
{
"ok": true,
"frames": 4096
}
Example:
# Push a file
ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \
curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://pluto:8088/audio/stream
Errors:
405 if not POST415 if Content-Type is missing or unsupported (must be application/octet-stream or audio/L16)413 if the upload body exceeds the 512 MiB limit503 if HTTP raw ingest is not 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 -H "Content-Type: application/octet-stream" --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,
"bufferedDurationSeconds": 0.27,
"highWatermark": 15000,
"highWatermarkDurationSeconds": 0.34,
"written": 890000,
"underruns": 0,
"overflows": 0
}
}
available frames divided by the sample rate)highWatermark frames divided by the sample rate)When no audio is streaming, the transmitter falls back to the configured tone generator or silence.