Преглед изворни кода

Hardening audio stream Content-Type guard

tags/v0.9.0
Jan пре 1 месец
родитељ
комит
25dfb6c24c
5 измењених фајлова са 1377 додато и 1313 уклоњено
  1. +340
    -338
      README.md
  2. +397
    -396
      docs/API.md
  3. +577
    -576
      docs/pro-runtime-hardening-workboard.md
  4. +30
    -3
      internal/control/control.go
  5. +33
    -0
      internal/control/control_test.go

+ 340
- 338
README.md Прегледај датотеку

@@ -1,338 +1,340 @@
# fm-rds-tx

Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and SoapySDR-compatible TX devices.

## Status

**Current status:** `v0.7.0-pre` — hardware bring-up milestone

What is already in place:
- complete DSP chain: audio -> pre-emphasis -> stereo encoding -> RDS -> MPX -> limiter -> FM modulation
- real hardware TX paths for PlutoSDR / SoapySDR backends
- continuous TX engine with runtime telemetry
- dry-run, offline generation, and simulated TX modes
- HTTP control plane with live config patching and runtime/status endpoints
- browser UI on `/`
- live audio ingestion via stdin or HTTP stream input

Current engineering focus:
- deterministic runtime behavior
- fault handling / recovery
- observability and runtime telemetry
- hardware-validated signal quality

For the active runtime-hardening track, see:
- `docs/pro-runtime-hardening-workboard.md`

## Signal path

```text
Audio Source -> PreEmphasis(50us/75us/off) -> StereoEncoder(19k + 38k DSB-SC)
-> RDS(57k BPSK) -> MPX Combiner -> Limiter -> FM Modulator(+/-75kHz)
-> optional split-rate FM upsampling -> SDR backend -> RF output
```

For deeper DSP details, see:
- `docs/DSP-CHAIN.md`

## Prerequisites

### Go
- Go version from `go.mod` (currently Go 1.22)

### Native SDR dependencies
Depending on backend, native libraries are required:

- **SoapySDR backend**
- build with `-tags soapy`
- requires SoapySDR native library (`SoapySDR.dll` / `libSoapySDR.so` / `libSoapySDR.dylib`)
- on Windows, PothosSDR is the expected setup

- **Pluto backend**
- uses native `libiio`
- Windows expects `libiio.dll`
- Linux build/runtime expects `pkg-config` + `libiio`

### Hardware / legal
- validate RF output, deviation, filtering, and power with proper measurement equipment
- use only within applicable legal and regulatory constraints

## Quick start

## Build

```powershell
# Build CLI tools without hardware-specific build tags:
go build ./cmd/fmrtx
go build ./cmd/offline

# Build fmrtx with SoapySDR support:
go build -tags soapy ./cmd/fmrtx
```

## Quick verification

```powershell
# Print effective config
go run ./cmd/fmrtx -print-config

# Run tests
go test ./...

# Basic dry-run summary
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
```

For additional build/test commands, see:
- `docs/README.md`

## Common usage flows

### 1) List available SDR devices

```powershell
.\fmrtx.exe --list-devices
```

### 2) Dry-run / config verification

```powershell
.\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json

# Write dry-run JSON to stdout
.\fmrtx.exe --dry-run --dry-output -
```

### 3) Offline IQ/composite generation

```powershell
go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32

# Optional output rate override
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 -output-rate 228000
```

### 4) Simulated transmit path

```powershell
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
```

### 5) Real TX with config file

```powershell
# Start TX service with manual start over HTTP
.\fmrtx.exe --tx --config docs/config.plutosdr.json

# Start and begin transmitting immediately
.\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json
```

### 6) Live audio via stdin

```powershell
ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json
```

### 7) Custom audio input rate

```powershell
ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 48000 --config docs/config.plutosdr.json
```

### 8) HTTP audio ingest

Start the control plane with `--audio-http` to accept raw PCM pushes on `/audio/stream` and feed them into the live encoder:

```powershell
ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://localhost:8088/audio/stream
```

## CLI overview

## `fmrtx`
Important runtime modes and flags include:
- `--tx`
- `--tx-auto-start`
- `--dry-run`
- `--dry-output <path|- >`
- `--simulate-tx`
- `--simulate-output <path>`
- `--simulate-duration <duration>`
- `--config <path>`
- `--print-config`
- `--list-devices`
- `--audio-stdin`
- `--audio-rate <hz>`
- `--audio-http`

## `offline`
Useful flags include:
- `-duration <duration>`
- `-output <path>`
- `-output-rate <hz>`

If the README is too high-level for the exact CLI surface, check:
- `cmd/fmrtx/main.go`
- `cmd/offline/main.go`

## HTTP control plane

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

Security note:
- keep the control plane bound locally unless you intentionally place it behind a trusted and hardened access layer

### Main endpoints

```text
GET / browser UI
GET /healthz health check
GET /status current config/status snapshot
GET /runtime live engine / driver / audio telemetry
GET /config full config
POST /config patch config / live updates
GET /dry-run synthetic frame summary
POST /tx/start start transmission
POST /tx/stop stop transmission
POST /audio/stream push raw S16LE stereo PCM into live stream buffer
```

### What the control plane covers
- TX start / stop
- runtime status and driver telemetry
- config inspection
- live patching of selected parameters
- dry-run inspection
- browser-accessible control UI
- optional HTTP audio ingest (enable with `--audio-http`)

### Live config notes
`POST /config` supports live updates for selected fields such as:
- frequency
- stereo enable/disable
- pilot / RDS injection levels
- RDS enable/disable
- limiter settings
- PS / RadioText

Some parameters are saved but not live-applied and require restart.

For the full API contract, examples, live-patch semantics, and `/audio/stream` details, see:
- `docs/API.md`

## Configuration

Sample configs:
- `docs/config.sample.json`
- `docs/config.plutosdr.json`
- `docs/config.orangepi-pluto-soapy.json`

Important config areas include:
- `fm.*`
- `rds.*`
- `audio.*`
- `backend.*`
- `control.*`

Examples of relevant fields you may want to inspect:
- `fm.outputDrive`
- `fm.mpxGain`
- `fm.bs412Enabled`
- `fm.bs412ThresholdDBr`
- `fm.fmModulationEnabled`
- `backend.kind`
- `backend.driver`
- `backend.deviceArgs`
- `backend.uri`
- `backend.deviceSampleRateHz`
- `backend.outputPath`
- `control.listenAddress`

For deeper config/API behavior, refer to:
- `internal/config/config.go`
- `docs/API.md`
- `docs/config.sample.json`

## Development and testing

Useful commands:

```powershell
go test ./...
go run ./cmd/fmrtx -print-config
go run ./cmd/fmrtx -config docs/config.sample.json
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32
```

See also:
- `docs/README.md`

## PlutoSDR / backend notes

- PlutoSDR commonly runs with a device-side sample rate above composite rate, so split-rate mode may be used automatically
- SoapySDR backend is suitable for Soapy-compatible TX hardware
- backend/device settings are selected through config rather than hardcoded paths
- runtime telemetry should be used to inspect effective TX state during operation

## Repository layout

```text
cmd/
fmrtx/ main CLI
offline/ offline generator
internal/
app/ TX engine + runtime state
audio/ audio input, resampling, tone generation, stream buffering
config/ config schema and validation
control/ HTTP control plane + browser UI
dryrun/ dry-run JSON summaries
dsp/ DSP primitives
mpx/ MPX combiner
offline/ full offline composite generation
output/ output/backend abstractions
platform/ backend abstractions and device/runtime stats
platform/soapysdr/ CGO SoapySDR binding
platform/plutosdr/ Pluto/libiio backend code
rds/ RDS encoder
stereo/ stereo encoder
docs/
API.md
DSP-CHAIN.md
README.md
config.sample.json
config.plutosdr.json
config.orangepi-pluto-soapy.json
pro-runtime-hardening-workboard.md
scripts/
examples/
```

## Planning / workboard

For the current runtime-hardening / professionalization track, see:
- `docs/pro-runtime-hardening-workboard.md`

This is the living workboard for:
- status tracking
- confirmed findings
- open technical decisions
- verification notes
- implementation progress

## Release / project docs

Additional project docs:
- `CHANGELOG.md`
- `RELEASE.md`
- `docs/README.md`
- `docs/API.md`
- `docs/DSP-CHAIN.md`
- `docs/NOTES.md`

## Legal note

This project is intended only for lawful use within relevant license and regulatory constraints.
RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment.
# fm-rds-tx
Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and SoapySDR-compatible TX devices.
## Status
**Current status:** `v0.7.0-pre` — hardware bring-up milestone
What is already in place:
- complete DSP chain: audio -> pre-emphasis -> stereo encoding -> RDS -> MPX -> limiter -> FM modulation
- real hardware TX paths for PlutoSDR / SoapySDR backends
- continuous TX engine with runtime telemetry
- dry-run, offline generation, and simulated TX modes
- HTTP control plane with live config patching and runtime/status endpoints
- browser UI on `/`
- live audio ingestion via stdin or HTTP stream input
Current engineering focus:
- deterministic runtime behavior
- fault handling / recovery
- observability and runtime telemetry
- hardware-validated signal quality
For the active runtime-hardening track, see:
- `docs/pro-runtime-hardening-workboard.md`
## Signal path
```text
Audio Source -> PreEmphasis(50us/75us/off) -> StereoEncoder(19k + 38k DSB-SC)
-> RDS(57k BPSK) -> MPX Combiner -> Limiter -> FM Modulator(+/-75kHz)
-> optional split-rate FM upsampling -> SDR backend -> RF output
```
For deeper DSP details, see:
- `docs/DSP-CHAIN.md`
## Prerequisites
### Go
- Go version from `go.mod` (currently Go 1.22)
### Native SDR dependencies
Depending on backend, native libraries are required:
- **SoapySDR backend**
- build with `-tags soapy`
- requires SoapySDR native library (`SoapySDR.dll` / `libSoapySDR.so` / `libSoapySDR.dylib`)
- on Windows, PothosSDR is the expected setup
- **Pluto backend**
- uses native `libiio`
- Windows expects `libiio.dll`
- Linux build/runtime expects `pkg-config` + `libiio`
### Hardware / legal
- validate RF output, deviation, filtering, and power with proper measurement equipment
- use only within applicable legal and regulatory constraints
## Quick start
## Build
```powershell
# Build CLI tools without hardware-specific build tags:
go build ./cmd/fmrtx
go build ./cmd/offline
# Build fmrtx with SoapySDR support:
go build -tags soapy ./cmd/fmrtx
```
## Quick verification
```powershell
# Print effective config
go run ./cmd/fmrtx -print-config
# Run tests
go test ./...
# Basic dry-run summary
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
```
For additional build/test commands, see:
- `docs/README.md`
## Common usage flows
### 1) List available SDR devices
```powershell
.\fmrtx.exe --list-devices
```
### 2) Dry-run / config verification
```powershell
.\fmrtx.exe --dry-run --dry-output build/dryrun/frame.json
# Write dry-run JSON to stdout
.\fmrtx.exe --dry-run --dry-output -
```
### 3) Offline IQ/composite generation
```powershell
go run ./cmd/offline -duration 2s -output build/offline/composite.iqf32
# Optional output rate override
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 -output-rate 228000
```
### 4) Simulated transmit path
```powershell
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
```
### 5) Real TX with config file
```powershell
# Start TX service with manual start over HTTP
.\fmrtx.exe --tx --config docs/config.plutosdr.json
# Start and begin transmitting immediately
.\fmrtx.exe --tx --tx-auto-start --config docs/config.plutosdr.json
```
### 6) Live audio via stdin
```powershell
ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json
```
### 7) Custom audio input rate
```powershell
ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 48000 --config docs/config.plutosdr.json
```
### 8) HTTP audio ingest
Start the control plane with `--audio-http` to accept raw PCM pushes on `/audio/stream` and feed them into the live encoder:
Set `Content-Type` to `application/octet-stream` (or `audio/L16`) when posting audio data:
```powershell
ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | curl -X POST -H "Content-Type: application/octet-stream" --data-binary @- http://localhost:8088/audio/stream
```
## CLI overview
## `fmrtx`
Important runtime modes and flags include:
- `--tx`
- `--tx-auto-start`
- `--dry-run`
- `--dry-output <path|- >`
- `--simulate-tx`
- `--simulate-output <path>`
- `--simulate-duration <duration>`
- `--config <path>`
- `--print-config`
- `--list-devices`
- `--audio-stdin`
- `--audio-rate <hz>`
- `--audio-http`
## `offline`
Useful flags include:
- `-duration <duration>`
- `-output <path>`
- `-output-rate <hz>`
If the README is too high-level for the exact CLI surface, check:
- `cmd/fmrtx/main.go`
- `cmd/offline/main.go`
## HTTP control plane
Base URL: `http://{listenAddress}` (default typically `127.0.0.1:8088`)
Security note:
- keep the control plane bound locally unless you intentionally place it behind a trusted and hardened access layer
### Main endpoints
```text
GET / browser UI
GET /healthz health check
GET /status current config/status snapshot
GET /runtime live engine / driver / audio telemetry
GET /config full config
POST /config patch config / live updates
GET /dry-run synthetic frame summary
POST /tx/start start transmission
POST /tx/stop stop transmission
POST /audio/stream push raw S16LE stereo PCM into live stream buffer (Content-Type: application/octet-stream or audio/L16 required)
```
### What the control plane covers
- TX start / stop
- runtime status and driver telemetry
- config inspection
- live patching of selected parameters
- dry-run inspection
- browser-accessible control UI
- optional HTTP audio ingest (enable with `--audio-http`)
### Live config notes
`POST /config` supports live updates for selected fields such as:
- frequency
- stereo enable/disable
- pilot / RDS injection levels
- RDS enable/disable
- limiter settings
- PS / RadioText
Some parameters are saved but not live-applied and require restart.
For the full API contract, examples, live-patch semantics, and `/audio/stream` details, see:
- `docs/API.md`
## Configuration
Sample configs:
- `docs/config.sample.json`
- `docs/config.plutosdr.json`
- `docs/config.orangepi-pluto-soapy.json`
Important config areas include:
- `fm.*`
- `rds.*`
- `audio.*`
- `backend.*`
- `control.*`
Examples of relevant fields you may want to inspect:
- `fm.outputDrive`
- `fm.mpxGain`
- `fm.bs412Enabled`
- `fm.bs412ThresholdDBr`
- `fm.fmModulationEnabled`
- `backend.kind`
- `backend.driver`
- `backend.deviceArgs`
- `backend.uri`
- `backend.deviceSampleRateHz`
- `backend.outputPath`
- `control.listenAddress`
For deeper config/API behavior, refer to:
- `internal/config/config.go`
- `docs/API.md`
- `docs/config.sample.json`
## Development and testing
Useful commands:
```powershell
go test ./...
go run ./cmd/fmrtx -print-config
go run ./cmd/fmrtx -config docs/config.sample.json
go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32
```
See also:
- `docs/README.md`
## PlutoSDR / backend notes
- PlutoSDR commonly runs with a device-side sample rate above composite rate, so split-rate mode may be used automatically
- SoapySDR backend is suitable for Soapy-compatible TX hardware
- backend/device settings are selected through config rather than hardcoded paths
- runtime telemetry should be used to inspect effective TX state during operation
## Repository layout
```text
cmd/
fmrtx/ main CLI
offline/ offline generator
internal/
app/ TX engine + runtime state
audio/ audio input, resampling, tone generation, stream buffering
config/ config schema and validation
control/ HTTP control plane + browser UI
dryrun/ dry-run JSON summaries
dsp/ DSP primitives
mpx/ MPX combiner
offline/ full offline composite generation
output/ output/backend abstractions
platform/ backend abstractions and device/runtime stats
platform/soapysdr/ CGO SoapySDR binding
platform/plutosdr/ Pluto/libiio backend code
rds/ RDS encoder
stereo/ stereo encoder
docs/
API.md
DSP-CHAIN.md
README.md
config.sample.json
config.plutosdr.json
config.orangepi-pluto-soapy.json
pro-runtime-hardening-workboard.md
scripts/
examples/
```
## Planning / workboard
For the current runtime-hardening / professionalization track, see:
- `docs/pro-runtime-hardening-workboard.md`
This is the living workboard for:
- status tracking
- confirmed findings
- open technical decisions
- verification notes
- implementation progress
## Release / project docs
Additional project docs:
- `CHANGELOG.md`
- `RELEASE.md`
- `docs/README.md`
- `docs/API.md`
- `docs/DSP-CHAIN.md`
- `docs/NOTES.md`
## Legal note
This project is intended only for lawful use within relevant license and regulatory constraints.
RF output, deviation, filtering, and transmitted power must be validated with proper measurement equipment.

+ 397
- 396
docs/API.md Прегледај датотеку

@@ -1,396 +1,397 @@
# 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",
"runtimeStateDurationSeconds": 12.4,
"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"
}
],
"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
}
}
```
`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.

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


---

### `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 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,
"bufferedDurationSeconds": 0.27,
"highWatermark": 15000,
"highWatermarkDurationSeconds": 0.34,
"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,
"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.
# 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",
"runtimeStateDurationSeconds": 12.4,
"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"
}
],
"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
}
}
```
`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.
`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.
---
### `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 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. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected.
**Response:**
```json
{
"ok": true,
"frames": 4096,
"stats": {
"available": 12000,
"capacity": 131072,
"buffered": 0.09,
"bufferedDurationSeconds": 0.27,
"highWatermark": 15000,
"highWatermarkDurationSeconds": 0.34,
"written": 890000,
"underruns": 0,
"overflows": 0
}
}
```
**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`)
- `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 -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.

+ 577
- 576
docs/pro-runtime-hardening-workboard.md
Разлика између датотеке није приказан због своје велике величине
Прегледај датотеку


+ 30
- 3
internal/control/control.go Прегледај датотеку

@@ -52,11 +52,17 @@ type Server struct {
}

const (
maxConfigBodyBytes = 64 << 10 // 64 KiB
configContentTypeHeader = "application/json"
noBodyErrMsg = "request must not include a body"
maxConfigBodyBytes = 64 << 10 // 64 KiB
configContentTypeHeader = "application/json"
noBodyErrMsg = "request must not include a body"
audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16"
)

var audioStreamAllowedMediaTypes = []string{
"application/octet-stream",
"audio/l16",
}

func isJSONContentType(r *http.Request) bool {
ct := strings.TrimSpace(r.Header.Get("Content-Type"))
if ct == "" {
@@ -110,6 +116,23 @@ func rejectBody(w http.ResponseWriter, r *http.Request) bool {
return false
}

func isAudioStreamContentType(r *http.Request) bool {
ct := strings.TrimSpace(r.Header.Get("Content-Type"))
if ct == "" {
return false
}
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
return false
}
for _, allowed := range audioStreamAllowedMediaTypes {
if strings.EqualFold(mediaType, allowed) {
return true
}
}
return false
}

func (s *Server) SetTXController(tx TXController) {
s.mu.Lock()
s.tx = tx
@@ -248,6 +271,10 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !isAudioStreamContentType(r) {
http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType)
return
}
s.mu.RLock()
stream := s.streamSrc
s.mu.RUnlock()


+ 33
- 0
internal/control/control_test.go Прегледај датотеку

@@ -307,6 +307,7 @@ func TestAudioStreamRequiresSource(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil))
req.Header.Set("Content-Type", "application/octet-stream")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code)
@@ -321,6 +322,7 @@ func TestAudioStreamPushesPCM(t *testing.T) {
pcm := []byte{0, 0, 0, 0}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
req.Header.Set("Content-Type", "application/octet-stream")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d", rec.Code)
@@ -355,6 +357,37 @@ func TestAudioStreamRejectsNonPost(t *testing.T) {
}
}

func TestAudioStreamRejectsMissingContentType(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("expected 415 when Content-Type missing, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Content-Type must be") {
t.Fatalf("unexpected response body: %q", rec.Body.String())
}
}

func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
req.Header.Set("Content-Type", "text/plain")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("expected 415 for unsupported Content-Type, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Content-Type must be") {
t.Fatalf("unexpected response body: %q", rec.Body.String())
}
}

func TestTXStartWithoutController(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()


Loading…
Откажи
Сачувај