# fm-rds-tx HTTP Control API Base URL: `http://{listenAddress}` (default `127.0.0.1:8088`) --- ## Endpoints ### `GET /healthz` Health check. **Response:** ```json {"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 /status` Current transmitter status (read-only snapshot). Runtime indicator, alert, and queue stats from the running TX controller are mirrored here for quick health checks. **Response:** ```json { "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 /runtime` Live engine and driver telemetry. Only populated when TX is active. **Response:** ```json { "engine": { "state": "running", "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" } ] }, "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. --- ### `POST /runtime/fault/reset` Manually 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:** ```json {"ok": true} ``` **Errors:** - `405 Method Not Allowed` if the request is not a POST - `503 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 /config` Full current configuration (all fields, including non-patchable). **Response:** Complete `Config` JSON object. --- ### `POST /config` **Live 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:** ```json {"ok": true, "live": true} ``` `"live": true` = changes were forwarded to the running engine. `"live": false` = engine not active, changes saved for next start. #### Patchable fields — DSP (applied within ~50ms) | 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). | #### Patchable fields — RDS text (applied within ~88ms) | 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. #### Patchable fields — other (saved, not live-applied) | 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.** | #### Examples ```bash # 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" }' ``` #### Error handling Invalid values return `400 Bad Request` with a descriptive message: ```bash curl -X POST localhost:8088/config -d '{"frequencyMHz": 200}' # → 400: frequencyMHz out of range (65-110) ``` --- ### `POST /tx/start` Start transmission. Requires `--tx` mode with hardware. **Response:** ```json {"ok": true, "action": "started"} ``` **Errors:** - `405` if not POST - `503` if no TX controller (not in `--tx` mode) - `409` if already running --- ### `POST /tx/stop` Stop transmission. **Response:** ```json {"ok": true, "action": "stopped"} ``` --- ### `GET /dry-run` Generate a synthetic frame summary without hardware. Useful for config verification. **Response:** `FrameSummary` JSON with mode, rates, source info, preview samples. --- ## Live update architecture 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. ## Parameters that require restart These cannot be hot-reloaded (they affect DSP pipeline structure): - `compositeRateHz` — changes sample rate of entire DSP chain - `deviceSampleRateHz` — changes hardware rate / upsampler ratio - `maxDeviationHz` — changes FM modulator scaling - `preEmphasisTauUS` — changes filter coefficients - `rds.pi` / `rds.pty` — rarely change, baked into encoder init - `audio.inputPath` — audio source selection - `backend.kind` / `backend.device` — hardware selection --- ### `POST /audio/stream` Push 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:** ```json { "ok": true, "frames": 4096, "stats": { "available": 12000, "capacity": 131072, "buffered": 0.09, "written": 890000, "underruns": 0, "overflows": 0 } } ``` **Example:** ```bash # 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 POST - `503` if no audio stream configured --- ## Audio Streaming ### Stdin pipe (primary method) Pipe any audio source through ffmpeg into the transmitter: ```bash # 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 ``` ### HTTP audio push 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. ```bash # 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 ``` ### Audio buffer The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in `GET /runtime` under `audioStream`: ```json { "audioStream": { "available": 12000, "capacity": 131072, "buffered": 0.09, "written": 890000, "underruns": 0, "overflows": 0 } } ``` - **underruns**: DSP consumed faster than audio arrived (silence inserted) - **overflows**: Audio arrived faster than DSP consumed (data dropped) - **buffered**: Fill ratio (0.0 = empty, 1.0 = full) When no audio is streaming, the transmitter falls back to the configured tone generator or silence.