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

---

### `GET /status`

Current transmitter status (read-only snapshot).

**Response:**
```json
{
  "service": "fm-rds-tx",
  "backend": "pluto",
  "frequencyMHz": 100.0,
  "stereoEnabled": true,
  "rdsEnabled": true,
  "preEmphasisTauUS": 50,
  "limiterEnabled": true,
  "fmModulationEnabled": true
}
```

---

### `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
  },
  "driver": {
    "txEnabled": true,
    "streamActive": true,
    "framesWritten": 12345,
    "samplesWritten": 1408950000,
    "underruns": 0,
    "effectiveSampleRateHz": 2280000
  }
}
```

---

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

**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–3 | Composite output level multiplier. |
| `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` or a configured stream source.

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

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