# 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}
```

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:**
```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".

`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:**
```json
{
  "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:**
```json
{
  "noData": true,
  "stale": true
}
```

**Response when a snapshot is available:**
```json
{
  "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.
- `GET /measurements` remains the canonical snapshot/fallback API for live metering.
- `noData` / `stale` are wrapper-level snapshot semantics and must not be inferred solely from the existence of a measurement object.

### `GET /ws/telemetry`

WebSocket endpoint for **live metering telemetry**.

WS-1 scope is intentionally small:
- server → client only
- only `measurement` messages
- no topics
- no bundles
- no runtime multiplexing

`GET /measurements` stays the canonical snapshot/fallback API. The WebSocket path carries the same latest measurement truth, but in a live transport envelope.

**Message shape:**
```json
{
  "type": "measurement",
  "ts": "2026-04-13T07:00:53.842Z",
  "seq": 128,
  "data": {
    "timestamp": "2026-04-13T07:00:53.842Z",
    "sampleRateHz": 228000,
    "chunkSamples": 11400,
    "chunkDurationMs": 50,
    "sequence": 128,
    "flags": {
      "stereoEnabled": true,
      "stereoMode": "DSB"
    },
    "lrPreEncodePostWatermark": {
      "lRms": 0.27,
      "rRms": 0.27,
      "lPeakAbs": 0.51,
      "rPeakAbs": 0.51
    },
    "compositeFinalPreIq": {
      "peakAbs": 0.63,
      "pilotInjectionEquivalentPercent": 9.0
    }
  }
}
```

Notes:
- `data` is intended to be semantically identical to the `measurement` object returned by `GET /measurements`.
- Transport metadata such as `type`, `ts`, and `seq` exists only in the WS envelope.
- On connect, the server may immediately send the latest known measurement snapshot so the client does not have to wait for the next natural update.
- Slow clients are best-effort only; the server may drop stale telemetry frames and keep the newest state.

---

### `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.

**Content-Type:** `application/json` (charset parameters allowed). Requests without it are rejected with 415 Unsupported Media Type.

**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 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

```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 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:**
```json
{
  "ok": true,
  "frames": 4096
}
```

**Example:**
```bash
# 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:

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

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