Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

17KB

fm-rds-tx HTTP Control API

Base URL: http://{listenAddress} (default 127.0.0.1:8088)


Endpoints

GET /healthz

Health 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 /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:

{
  "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 /runtime

Live 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 /measurements

High-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/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:

{"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.

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.

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 the stereo limiter stage in the L/R path. Hard clip stages remain active.
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. If StreamTitle relay is active, the runtime engine.activeRadioText exposed by /runtime can temporarily differ from the saved /config value.

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

# 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:

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:

{"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:

{"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 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 POST
  • 415 if Content-Type is missing or unsupported (must be application/octet-stream or audio/L16)
  • 413 if the upload body exceeds the 512 MiB limit
  • 503 if HTTP raw ingest is not configured

Audio Streaming

Stdin pipe (primary method)

Pipe 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

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.

# 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

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:

{
  "audioStream": {
    "available": 12000,
    "capacity": 131072,
    "buffered": 0.09,
    "bufferedDurationSeconds": 0.27,
    "highWatermark": 15000,
    "highWatermarkDurationSeconds": 0.34,
    "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)
  • bufferedDurationSeconds: Approximate seconds of audio queued in the buffer (available frames divided by the sample rate)
  • highWatermark: Highest observed buffer occupancy (frames) since the buffer was created
  • highWatermarkDurationSeconds: Equivalent peak time (highWatermark frames divided by the sample rate)

When no audio is streaming, the transmitter falls back to the configured tone generator or silence.