Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

10KB

fm-rds-tx HTTP Control API

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


Endpoints

GET /healthz

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

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

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

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

# 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 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 POST
  • 503 if no audio stream 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 --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,
    "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.