Переглянути джерело

merge ui-anpassungen into main

main
Jan 1 місяць тому
джерело
коміт
5914e94ba9
94 змінених файлів з 12104 додано та 3500 видалено
  1. +356
    -340
      README.md
  2. +90
    -0
      aoiprxkit/README.md
  3. +93
    -0
      aoiprxkit/cmd/demo/main.go
  4. +66
    -0
      aoiprxkit/config.go
  5. +39
    -0
      aoiprxkit/docs/INTEGRATION.md
  6. +26
    -0
      aoiprxkit/docs/PROTOCOLS.md
  7. +3
    -0
      aoiprxkit/go.mod
  8. +58
    -0
      aoiprxkit/jitter.go
  9. +34
    -0
      aoiprxkit/jitter_test.go
  10. +127
    -0
      aoiprxkit/meter.go
  11. +205
    -0
      aoiprxkit/meter_server.go
  12. +62
    -0
      aoiprxkit/nmos/is05.go
  13. +39
    -0
      aoiprxkit/nmos/models.go
  14. +56
    -0
      aoiprxkit/nmos/query.go
  15. +50
    -0
      aoiprxkit/pcm.go
  16. +39
    -0
      aoiprxkit/pcm_test.go
  17. +194
    -0
      aoiprxkit/receiver.go
  18. +68
    -0
      aoiprxkit/rtp.go
  19. +22
    -0
      aoiprxkit/rtp_test.go
  20. +115
    -0
      aoiprxkit/sap.go
  21. +150
    -0
      aoiprxkit/sap_listener.go
  22. +28
    -0
      aoiprxkit/sap_test.go
  23. +116
    -0
      aoiprxkit/sdp.go
  24. +22
    -0
      aoiprxkit/sdp_test.go
  25. +70
    -0
      aoiprxkit/srt.go
  26. +13
    -0
      aoiprxkit/srt_gosrt.go.example
  27. +13
    -0
      aoiprxkit/srt_profile.md
  28. +15
    -0
      aoiprxkit/srt_stub.go
  29. +58
    -0
      aoiprxkit/srt_test.go
  30. +53
    -0
      aoiprxkit/stats.go
  31. +137
    -0
      aoiprxkit/stream_finder.go
  32. +81
    -0
      aoiprxkit/stream_proto.go
  33. +34
    -0
      aoiprxkit/stream_proto_test.go
  34. +114
    -0
      aoiprxkit/stream_receiver.go
  35. +56
    -0
      aoiprxkit/stream_receiver_test.go
  36. +104
    -37
      cmd/fmrtx/main.go
  37. +22
    -0
      cmd/fmrtx/main_test.go
  38. +427
    -416
      docs/API.md
  39. +1097
    -0
      docs/audio-ingest-implementation-plan.md
  40. +296
    -0
      docs/audio-ingest-rework.md
  41. +69
    -11
      docs/config.plutosdr.json
  42. +27
    -0
      docs/config.sample.json
  43. +9
    -0
      go.mod
  44. +8
    -0
      go.sum
  45. +54
    -6
      internal/app/engine.go
  46. +88
    -38
      internal/audio/stream.go
  47. +240
    -3
      internal/config/config.go
  48. +175
    -0
      internal/config/config_test.go
  49. +220
    -47
      internal/control/control.go
  50. +282
    -16
      internal/control/control_test.go
  51. +13
    -10
      internal/control/server.go
  52. +947
    -2542
      internal/control/ui.html
  53. +29
    -0
      internal/dsp/bs412.go
  54. +11
    -1
      internal/go.mod
  55. +8
    -0
      internal/go.sum
  56. +337
    -0
      internal/ingest/adapters/aoip/source.go
  57. +154
    -0
      internal/ingest/adapters/aoip/source_test.go
  58. +133
    -0
      internal/ingest/adapters/httpraw/source.go
  59. +145
    -0
      internal/ingest/adapters/icecast/icy.go
  60. +77
    -0
      internal/ingest/adapters/icecast/icy_test.go
  61. +106
    -0
      internal/ingest/adapters/icecast/radiotext.go
  62. +65
    -0
      internal/ingest/adapters/icecast/radiotext_test.go
  63. +31
    -0
      internal/ingest/adapters/icecast/reconnect.go
  64. +26
    -0
      internal/ingest/adapters/icecast/reconnect_test.go
  65. +379
    -0
      internal/ingest/adapters/icecast/source.go
  66. +575
    -0
      internal/ingest/adapters/icecast/source_test.go
  67. +305
    -0
      internal/ingest/adapters/srt/source.go
  68. +123
    -0
      internal/ingest/adapters/srt/source_test.go
  69. +181
    -0
      internal/ingest/adapters/stdinpcm/source.go
  70. +33
    -0
      internal/ingest/adapters/stdinpcm/source_test.go
  71. +45
    -0
      internal/ingest/convert.go
  72. +55
    -0
      internal/ingest/convert_test.go
  73. +20
    -0
      internal/ingest/decoder/aac/decoder.go
  74. +66
    -0
      internal/ingest/decoder/decoder.go
  75. +54
    -0
      internal/ingest/decoder/decoder_test.go
  76. +157
    -0
      internal/ingest/decoder/fallback/ffmpeg.go
  77. +58
    -0
      internal/ingest/decoder/helpers.go
  78. +75
    -0
      internal/ingest/decoder/mp3/decoder.go
  79. +60
    -0
      internal/ingest/decoder/mp3/decoder_test.go
  80. BIN
      internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3
  81. +76
    -0
      internal/ingest/decoder/oggvorbis/decoder.go
  82. +60
    -0
      internal/ingest/decoder/oggvorbis/decoder_test.go
  83. BIN
      internal/ingest/decoder/oggvorbis/testdata/tone_44k_stereo.ogg
  84. +294
    -0
      internal/ingest/factory/factory.go
  85. +271
    -0
      internal/ingest/factory/factory_test.go
  86. +208
    -0
      internal/ingest/factory/ingest_smoke_test.go
  87. +488
    -0
      internal/ingest/runtime.go
  88. +401
    -0
      internal/ingest/runtime_test.go
  89. +18
    -0
      internal/ingest/source.go
  90. +41
    -0
      internal/ingest/stats.go
  91. +37
    -0
      internal/ingest/types.go
  92. +49
    -6
      internal/offline/generator.go
  93. +14
    -11
      internal/output/frame_queue.go
  94. +59
    -16
      internal/rds/encoder.go

+ 356
- 340
README.md Переглянути файл

@@ -1,340 +1,356 @@
# fm-rds-tx
Go-based FM stereo transmitter with RDS. Supports ADALM-Pluto (PlutoSDR) and SoapySDR-compatible TX devices.
## Status
**Current status:** `v0.9.0` — runtime hardening 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:
- merge/release stabilization after runtime hardening
- deferred hardware-in-the-loop / RF validation work
- deferred device-aware capability / calibration work
- deferred signal self-monitoring work
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.
# fm-rds-tx

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

## Status

**Current status:** `v0.9.0` — runtime hardening 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 `/`
- ingest runtime in front of TX stream sink, plus shared source/runtime stats
- ingest source factory for `stdin`, `http-raw`, and `icecast`
- Icecast source adapter with reconnect and decoder selection (`auto`/`native`/`ffmpeg`)
- decoder layer with explicit ffmpeg fallback path

Current engineering focus:
- merge/release stabilization after runtime hardening
- deferred hardware-in-the-loop / RF validation work
- deferred device-aware capability / calibration work
- deferred signal self-monitoring work
- finish native Icecast decoder wiring (`mp3`/`oggvorbis`/`aac` are placeholders; ffmpeg fallback is the currently functional decode path)

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

### 9) Icecast ingest via config

Use `ingest.kind = "icecast"` and set `ingest.icecast.url` in config.

Decoder semantics in Phase 1:
- `ingest.icecast.decoder = "auto"`: try native by content-type, fallback to ffmpeg on unsupported paths
- `ingest.icecast.decoder = "native"`: native only, no fallback
- `ingest.icecast.decoder = "ffmpeg"` (or `fallback`): ffmpeg only

Current implementation note: native codec packages exist but are placeholders; practical decode today is ffmpeg fallback.

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

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.

+ 90
- 0
aoiprxkit/README.md Переглянути файл

@@ -0,0 +1,90 @@
# aoiprxkit

Standalone Go module for adding professional AoIP receive capabilities step by step.

This package covers the roadmap up to **Phase 4** with a **Go-native target architecture**:

1. **AES67 RX-lite**
2. **static SDP loading + optional SAP listener**
3. **stream discovery by SAP/SDP session name**
4. **live browser metering over HTTP/WebSocket**
5. **NMOS IS-04 / IS-05 client scaffolding**
6. **SRT WAN ingest via native transport adapter + framed PCM profile**

## Included components

### Core RTP / AES67-lite receiver
- IPv4 multicast RTP join
- static config or config derived from SDP
- `L24` decoding
- small jitter / reorder buffer
- PCM frame callback
- runtime counters

### SDP support
- minimal parser for:
- `c=`
- `m=audio`
- `a=rtpmap`
- `a=ptime`
- conversion helper from parsed SDP to receiver config

### SAP listener
- optional listener for SAP announcements
- default SAP group/port support
- `application/sdp` extraction
- callback with parsed session details

### NMOS scaffolding
- lightweight Query API client
- lightweight Connection API client
- helpers for receiver-side staged activation payloads

### SRT WAN bridge (reworked)
- no `ffmpeg.exe` dependency in the default package path
- generic stream receiver for framed PCM
- SRT receiver abstraction with injectable transport opener
- default build ships a clear stub for the transport layer
- intended production path: wire a **pure-Go SRT transport** (for example a `gosrt` opener) in the target repo

## Framed WAN audio profile

The package now assumes a deliberately narrow WAN ingest profile:

- transport: SRT
- payload framing: custom framed stream defined in `stream_proto.go`
- codec today: PCM `S32LE`
- codec reserved for later: Opus

This keeps the stack deterministic and avoids generic container / demux complexity.

## Deliberate non-goals
- no full AES67 compliance claim
- no PTP discipline
- no full SAP session cache
- no bundled gosrt implementation in this zip
- no ST 2110-30 sender/receiver implementation
- no NMOS Node/Registry server implementation

## Why it is built like this
The goal is not to overbuild a broadcast plant in one step.
The goal is to provide a **repo-addable module** that gives a realistic progression:

- start with known multicast audio
- add discovery
- add control-plane interoperability
- add WAN ingest without external EXE dependencies as the default design target

## Suggested integration order

1. integrate the core receiver into your existing audio input abstraction
2. allow config-by-SDP
3. enable optional SAP auto-discovery
4. add NMOS registry/query support
5. wire a native SRT opener in your target repo

## Added in this build

- `StreamFinder` for exact matching by SDP `s=` session name
- `LiveMeter` for per-channel RMS / Peak / Latest values
- `MeterServer` with `/`, `/healthz`, `/api/meter` and `/ws/live`

+ 93
- 0
aoiprxkit/cmd/demo/main.go Переглянути файл

@@ -0,0 +1,93 @@
package main

import (
"context"
"flag"
"fmt"
"log"
"os/signal"
"syscall"
"time"

"aoiprxkit"
)

func main() {
mode := flag.String("mode", "rtp", "rtp|sap")
group := flag.String("group", "239.69.0.1", "IPv4 multicast group")
port := flag.Int("port", 5004, "UDP port")
iface := flag.String("iface", "", "network interface name")
pt := flag.Int("pt", 97, "expected RTP payload type")
rate := flag.Int("rate", 48000, "sample rate")
ch := flag.Int("ch", 2, "channels")
flag.Parse()

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

switch *mode {
case "sap":
listener, err := aoiprxkit.NewSAPListener(aoiprxkit.SAPListenerConfig{
Group: aoiprxkit.DefaultSAPGroup,
Port: aoiprxkit.DefaultSAPPort,
InterfaceName: *iface,
ReadBuffer: 1 << 20,
}, func(a aoiprxkit.SAPAnnouncement) {
fmt.Printf("SAP session: name=%q group=%s port=%d pt=%d encoding=%s rate=%d ch=%d delete=%v\n",
a.ParsedSDP.SessionName,
a.ParsedSDP.MulticastGroup,
a.ParsedSDP.Port,
a.ParsedSDP.PayloadType,
a.ParsedSDP.Encoding,
a.ParsedSDP.SampleRateHz,
a.ParsedSDP.Channels,
a.Delete,
)
})
if err != nil {
log.Fatal(err)
}
if err := listener.Start(ctx); err != nil {
log.Fatal(err)
}
defer listener.Stop()
<-ctx.Done()

default:
cfg := aoiprxkit.DefaultConfig()
cfg.MulticastGroup = *group
cfg.Port = *port
cfg.InterfaceName = *iface
cfg.PayloadType = uint8(*pt)
cfg.SampleRateHz = *rate
cfg.Channels = *ch

var packets uint64
rx, err := aoiprxkit.NewReceiver(cfg, func(frame aoiprxkit.PCMFrame) {
packets++
if packets%100 == 0 {
fmt.Printf("delivered packet seq=%d ts=%d samples=%d source=%s\n", frame.SequenceNumber, frame.Timestamp, len(frame.Samples), frame.Source)
}
})
if err != nil {
log.Fatal(err)
}
if err := rx.Start(ctx); err != nil {
log.Fatal(err)
}
defer rx.Stop()

ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
fmt.Println("stopping")
return
case <-ticker.C:
fmt.Printf("stats: %+v\n", rx.Stats())
}
}
}
}

+ 66
- 0
aoiprxkit/config.go Переглянути файл

@@ -0,0 +1,66 @@
package aoiprxkit

import (
"errors"
"fmt"
"net"
"time"
)

// Config defines a pragmatic RX-only subset for statically configured AES67-style RTP audio.
// It is intentionally narrower than full AES67.
type Config struct {
MulticastGroup string
Port int
InterfaceName string
PayloadType uint8
SampleRateHz int
Channels int
Encoding string
PacketTime time.Duration
JitterDepthPackets int
ReadBufferBytes int
}

func DefaultConfig() Config {
return Config{
MulticastGroup: "239.69.0.1",
Port: 5004,
PayloadType: 97,
SampleRateHz: 48000,
Channels: 2,
Encoding: "L24",
PacketTime: time.Millisecond,
JitterDepthPackets: 8,
ReadBufferBytes: 1 << 20,
}
}

func (c Config) Validate() error {
if ip := net.ParseIP(c.MulticastGroup); ip == nil || ip.To4() == nil {
return fmt.Errorf("invalid IPv4 multicast group: %q", c.MulticastGroup)
}
ip := net.ParseIP(c.MulticastGroup).To4()
if ip[0] < 224 || ip[0] > 239 {
return fmt.Errorf("multicast group must be IPv4 multicast: %q", c.MulticastGroup)
}
if c.Port < 1 || c.Port > 65535 {
return errors.New("port must be 1..65535")
}
if c.SampleRateHz <= 0 {
return errors.New("sample rate must be > 0")
}
if c.Channels < 1 || c.Channels > 2 {
return errors.New("channels must be 1 or 2")
}
if c.Encoding != "L24" {
return fmt.Errorf("unsupported encoding %q: only L24 is currently supported", c.Encoding)
}
if c.PacketTime <= 0 {
return errors.New("packet time must be > 0")
}
if c.JitterDepthPackets < 1 {
return errors.New("jitter depth must be >= 1")
}
return nil
}

+ 39
- 0
aoiprxkit/docs/INTEGRATION.md Переглянути файл

@@ -0,0 +1,39 @@
# Integration notes

## Existing FM / DSP project
The intended integration pattern is:

- your application decides which input mode is active
- this module delivers decoded PCM frames
- your application writes those samples into its existing audio ring buffer or live source abstraction

## Recommended first integration
Use only:

- `Config`
- `NewReceiver`
- `Start`
- `Stop`

and a callback like:

```go
rx, _ := aoiprxkit.NewReceiver(cfg, func(frame aoiprxkit.PCMFrame) {
audioInput.PushInt32(frame.Samples, frame.SampleRateHz, frame.Channels)
})
```

## SRT integration pattern
The WAN side is now split into two layers:

1. `SRTReceiver` / `StreamReceiver`
2. a transport opener that returns an `io.ReadCloser`

That means your target repo can later add a native `gosrt` opener without changing the PCM handling path.

## Later additions
- derive config from SDP
- attach a SAP listener to discover sessions
- query NMOS registry for streams/receivers
- activate receiver transport with IS-05
- use a native SRT opener for WAN delivery into the same audio input path

+ 26
- 0
aoiprxkit/docs/PROTOCOLS.md Переглянути файл

@@ -0,0 +1,26 @@
# Protocol matrix

## LAN
### RTP multicast + SDP
Good first step for known sources.

### SAP
Useful for lightweight multicast session discovery.

### NMOS IS-04 / IS-05
Adds discovery, registry and connection management.
Recommended when integrating into professional IP media environments.

## WAN
### SRT
Current Phase-4 target.
This package now expects a narrow framed-PCM profile over SRT instead of a generic FFmpeg sidecar path.

### RIST
Not implemented here.
Reasonable future Phase-5 candidate for broadcast-heavy WAN environments.

## Later / optional
### ST 2110-30
Not implemented here.
Reasonable future path once AES67 + NMOS + WAN ingest are stable.

+ 3
- 0
aoiprxkit/go.mod Переглянути файл

@@ -0,0 +1,3 @@
module aoiprxkit

go 1.22

+ 58
- 0
aoiprxkit/jitter.go Переглянути файл

@@ -0,0 +1,58 @@
package aoiprxkit

type jitterBuffer struct {
started bool
expected uint16
maxDepth int
packets map[uint16]RTPPacket
}

func newJitterBuffer(maxDepth int) *jitterBuffer {
return &jitterBuffer{maxDepth: maxDepth, packets: make(map[uint16]RTPPacket)}
}

func (j *jitterBuffer) push(pkt RTPPacket) (ready []RTPPacket, lateDrop bool, gapLoss uint64, reorder bool) {
if !j.started {
j.started = true
j.expected = pkt.SequenceNumber
}
if seqDistance(pkt.SequenceNumber, j.expected) < 0 {
return nil, true, 0, false
}
if _, exists := j.packets[pkt.SequenceNumber]; !exists {
j.packets[pkt.SequenceNumber] = pkt
if pkt.SequenceNumber != j.expected {
reorder = true
}
}
for {
pkt, ok := j.packets[j.expected]
if !ok {
break
}
ready = append(ready, pkt)
delete(j.packets, j.expected)
j.expected++
}
for len(j.packets) > j.maxDepth {
if _, ok := j.packets[j.expected]; ok {
break
}
j.expected++
gapLoss++
for {
pkt, ok := j.packets[j.expected]
if !ok {
break
}
ready = append(ready, pkt)
delete(j.packets, j.expected)
j.expected++
}
}
return ready, false, gapLoss, reorder
}

func seqDistance(a, b uint16) int {
return int(int16(a - b))
}

+ 34
- 0
aoiprxkit/jitter_test.go Переглянути файл

@@ -0,0 +1,34 @@
package aoiprxkit

import "testing"

func TestJitterBufferReordersAndReleases(t *testing.T) {
jb := newJitterBuffer(8)
p100 := RTPPacket{SequenceNumber: 100}
p102 := RTPPacket{SequenceNumber: 102}
p101 := RTPPacket{SequenceNumber: 101}

ready, late, gap, reorder := jb.push(p100)
if late || gap != 0 || reorder {
t.Fatalf("unexpected state on first push")
}
if len(ready) != 1 || ready[0].SequenceNumber != 100 {
t.Fatalf("unexpected ready on first push: %+v", ready)
}

ready, late, gap, reorder = jb.push(p102)
if late || gap != 0 || !reorder {
t.Fatalf("expected reorder on out-of-order push")
}
if len(ready) != 0 {
t.Fatalf("unexpected ready before missing packet arrives: %+v", ready)
}

ready, late, gap, reorder = jb.push(p101)
if late || gap != 0 {
t.Fatalf("unexpected late/gap after completing sequence")
}
if len(ready) != 2 || ready[0].SequenceNumber != 101 || ready[1].SequenceNumber != 102 {
t.Fatalf("unexpected ready after sequence repair: %+v", ready)
}
}

+ 127
- 0
aoiprxkit/meter.go Переглянути файл

@@ -0,0 +1,127 @@
package aoiprxkit

import (
"math"
"sync"
"time"
)

type ChannelMeter struct {
RMS float64 `json:"rms"`
Peak float64 `json:"peak"`
Latest float64 `json:"latest"`
}

type MeterSnapshot struct {
UpdatedAt string `json:"updatedAt"`
Source string `json:"source"`
SampleRateHz int `json:"sampleRateHz"`
Channels int `json:"channels"`
Meters []ChannelMeter `json:"meters"`
}

// LiveMeter consumes PCM frames and publishes simple per-channel level data.
type LiveMeter struct {
mu sync.RWMutex
latest MeterSnapshot
subs map[chan MeterSnapshot]struct{}
}

func NewLiveMeter() *LiveMeter {
return &LiveMeter{subs: make(map[chan MeterSnapshot]struct{})}
}

func (m *LiveMeter) Consume(frame PCMFrame) {
if frame.Channels <= 0 || len(frame.Samples) == 0 {
return
}
meters := make([]ChannelMeter, frame.Channels)
fullScale := detectFullScale(frame.Samples)
sums := make([]float64, frame.Channels)
counts := make([]int, frame.Channels)

for i, sample := range frame.Samples {
ch := i % frame.Channels
norm := float64(sample) / fullScale
abs := math.Abs(norm)
if abs > meters[ch].Peak {
meters[ch].Peak = abs
}
meters[ch].Latest = norm
sums[ch] += norm * norm
counts[ch]++
}
for ch := range meters {
if counts[ch] > 0 {
meters[ch].RMS = math.Sqrt(sums[ch] / float64(counts[ch]))
}
}

snap := MeterSnapshot{
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
Source: frame.Source,
SampleRateHz: frame.SampleRateHz,
Channels: frame.Channels,
Meters: meters,
}

m.mu.Lock()
m.latest = snap
subs := make([]chan MeterSnapshot, 0, len(m.subs))
for ch := range m.subs {
subs = append(subs, ch)
}
m.mu.Unlock()

for _, ch := range subs {
select {
case ch <- snap:
default:
}
}
}

func detectFullScale(samples []int32) float64 {
var maxAbs int64
for _, s := range samples {
v := int64(s)
if v < 0 {
v = -v
}
if v > maxAbs {
maxAbs = v
}
}
if maxAbs <= 8388608 {
return 8388608.0
}
return 2147483648.0
}

func (m *LiveMeter) Snapshot() MeterSnapshot {
m.mu.RLock()
defer m.mu.RUnlock()
return m.latest
}

func (m *LiveMeter) Subscribe() (<-chan MeterSnapshot, func()) {
ch := make(chan MeterSnapshot, 8)
m.mu.Lock()
m.subs[ch] = struct{}{}
latest := m.latest
m.mu.Unlock()

if latest.UpdatedAt != "" {
ch <- latest
}

unsubscribe := func() {
m.mu.Lock()
if _, ok := m.subs[ch]; ok {
delete(m.subs, ch)
close(ch)
}
m.mu.Unlock()
}
return ch, unsubscribe
}

+ 205
- 0
aoiprxkit/meter_server.go Переглянути файл

@@ -0,0 +1,205 @@
package aoiprxkit

import (
"bufio"
"context"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
)

type MeterServer struct {
meter *LiveMeter
srv *http.Server
}

func NewMeterServer(listenAddress string, meter *LiveMeter) *MeterServer {
if meter == nil {
meter = NewLiveMeter()
}
ms := &MeterServer{meter: meter}
mux := http.NewServeMux()
mux.HandleFunc("/", ms.handleIndex)
mux.HandleFunc("/healthz", ms.handleHealth)
mux.HandleFunc("/api/meter", ms.handleSnapshot)
mux.HandleFunc("/ws/live", ms.handleWS)
ms.srv = &http.Server{
Addr: listenAddress,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return ms
}

func (m *MeterServer) Meter() *LiveMeter { return m.meter }

func (m *MeterServer) Start() error {
go func() {
_ = m.srv.ListenAndServe()
}()
return nil
}

func (m *MeterServer) Shutdown(ctx context.Context) error {
return m.srv.Shutdown(ctx)
}

func (m *MeterServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

func (m *MeterServer) handleSnapshot(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(m.meter.Snapshot())
}

func (m *MeterServer) handleIndex(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, meterIndexHTML)
}

func (m *MeterServer) handleWS(w http.ResponseWriter, r *http.Request) {
if !headerContainsToken(r.Header, "Connection", "Upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
http.Error(w, "upgrade required", http.StatusUpgradeRequired)
return
}
key := strings.TrimSpace(r.Header.Get("Sec-WebSocket-Key"))
if key == "" {
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
return
}
conn, rw, err := hj.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
accept := computeWebSocketAccept(key)
_, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
_, _ = rw.WriteString("Upgrade: websocket\r\n")
_, _ = rw.WriteString("Connection: Upgrade\r\n")
_, _ = rw.WriteString("Sec-WebSocket-Accept: " + accept + "\r\n\r\n")
_ = rw.Flush()

ch, unsubscribe := m.meter.Subscribe()
defer unsubscribe()
defer conn.Close()

_ = conn.SetDeadline(time.Time{})
for snap := range ch {
payload, err := json.Marshal(snap)
if err != nil {
return
}
if err := writeWebSocketTextFrame(conn, payload); err != nil {
return
}
}
}

func headerContainsToken(h http.Header, key, token string) bool {
for _, v := range h.Values(key) {
parts := strings.Split(v, ",")
for _, part := range parts {
if strings.EqualFold(strings.TrimSpace(part), token) {
return true
}
}
}
return false
}

func computeWebSocketAccept(key string) string {
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
sum := sha1.Sum([]byte(key + magic))
return base64.StdEncoding.EncodeToString(sum[:])
}

func writeWebSocketTextFrame(conn net.Conn, payload []byte) error {
bw := bufio.NewWriter(conn)
header := []byte{0x81}
switch {
case len(payload) < 126:
header = append(header, byte(len(payload)))
case len(payload) <= 65535:
header = append(header, 126, byte(len(payload)>>8), byte(len(payload)))
default:
header = append(header, 127,
byte(uint64(len(payload))>>56), byte(uint64(len(payload))>>48), byte(uint64(len(payload))>>40), byte(uint64(len(payload))>>32),
byte(uint64(len(payload))>>24), byte(uint64(len(payload))>>16), byte(uint64(len(payload))>>8), byte(uint64(len(payload))),
)
}
if _, err := bw.Write(header); err != nil {
return err
}
if _, err := bw.Write(payload); err != nil {
return err
}
return bw.Flush()
}

const meterIndexHTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>aoiprxkit meter</title>
<style>
body { font-family: system-ui, sans-serif; margin: 20px; background: #111; color: #eee; }
.meta { margin-bottom: 16px; color: #bbb; }
.row { margin: 12px 0; }
.label { margin-bottom: 4px; }
.bar { width: 100%; height: 22px; background: #222; border-radius: 6px; overflow: hidden; }
.fill { height: 100%; background: linear-gradient(90deg, #2ecc71, #f1c40f, #e74c3c); width: 0%; }
.nums { font-size: 12px; color: #bbb; margin-top: 4px; }
</style>
</head>
<body>
<h1>aoiprxkit live meter</h1>
<div id="meta" class="meta">waiting for frames…</div>
<div id="meters"></div>
<script>
const meta = document.getElementById('meta');
const root = document.getElementById('meters');
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/live');
ws.onmessage = (ev) => {
const snap = JSON.parse(ev.data);
meta.textContent = (snap.source || 'unknown') + ' · ' + snap.sampleRateHz + ' Hz · ' + snap.channels + ' ch · ' + snap.updatedAt;
root.innerHTML = '';
(snap.meters || []).forEach((m, idx) => {
const row = document.createElement('div');
row.className = 'row';
const label = document.createElement('div');
label.className = 'label';
label.textContent = 'Channel ' + (idx + 1);
const bar = document.createElement('div');
bar.className = 'bar';
const fill = document.createElement('div');
fill.className = 'fill';
fill.style.width = Math.max(0, Math.min(100, m.peak * 100)).toFixed(1) + '%';
bar.appendChild(fill);
const nums = document.createElement('div');
nums.className = 'nums';
nums.textContent = 'RMS ' + m.rms.toFixed(4) + ' · Peak ' + m.peak.toFixed(4) + ' · Latest ' + m.latest.toFixed(4);
row.appendChild(label);
row.appendChild(bar);
row.appendChild(nums);
root.appendChild(row);
});
};
</script>
</body>
</html>`

+ 62
- 0
aoiprxkit/nmos/is05.go Переглянути файл

@@ -0,0 +1,62 @@
package nmos

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)

type ConnectionClient struct {
BaseURL string
HTTPClient *http.Client
}

func NewConnectionClient(baseURL string) *ConnectionClient {
return &ConnectionClient{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}

func (c *ConnectionClient) StageReceiver(ctx context.Context, receiverID string, reqBody StagedReceiverRequest) error {
body, err := json.Marshal(reqBody)
if err != nil {
return err
}
url := fmt.Sprintf("%s/x-nmos/connection/v1.1/receivers/%s/staged", c.BaseURL, receiverID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("NMOS IS-05 stage receiver returned %s", resp.Status)
}
return nil
}

func BuildRTPReceiverStagedRequest(senderID *string, sdp string) StagedReceiverRequest {
transportFile := map[string]string{
"data": sdp,
"type": "application/sdp",
}
return StagedReceiverRequest{
MasterEnable: true,
Activation: Activation{
Mode: "activate_immediate",
},
SenderID: senderID,
TransportFile: transportFile,
}
}

+ 39
- 0
aoiprxkit/nmos/models.go Переглянути файл

@@ -0,0 +1,39 @@
package nmos

type Resource struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
}

type Sender struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
Transport string `json:"transport,omitempty"`
DeviceID string `json:"device_id,omitempty"`
ManifestURL string `json:"manifest_href,omitempty"`
Subscription any `json:"subscription,omitempty"`
InterfaceBinds []string `json:"interface_bindings,omitempty"`
}

type Receiver struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Transport string `json:"transport,omitempty"`
Format string `json:"format,omitempty"`
}

type Activation struct {
Mode string `json:"mode"`
RequestedTime string `json:"requested_time,omitempty"`
}

type StagedReceiverRequest struct {
MasterEnable bool `json:"master_enable"`
Activation Activation `json:"activation"`
SenderID *string `json:"sender_id,omitempty"`
TransportFile map[string]string `json:"transport_file,omitempty"`
TransportParams []map[string]any `json:"transport_params,omitempty"`
}

+ 56
- 0
aoiprxkit/nmos/query.go Переглянути файл

@@ -0,0 +1,56 @@
package nmos

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)

type QueryClient struct {
BaseURL string
HTTPClient *http.Client
}

func NewQueryClient(baseURL string) *QueryClient {
return &QueryClient{
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}

func (c *QueryClient) GetSenders(ctx context.Context) ([]Sender, error) {
var out []Sender
if err := c.getJSON(ctx, "/x-nmos/query/v1.3/senders", &out); err != nil {
return nil, err
}
return out, nil
}

func (c *QueryClient) GetReceivers(ctx context.Context) ([]Receiver, error) {
var out []Receiver
if err := c.getJSON(ctx, "/x-nmos/query/v1.3/receivers", &out); err != nil {
return nil, err
}
return out, nil
}

func (c *QueryClient) getJSON(ctx context.Context, path string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("NMOS query %s returned %s", path, resp.Status)
}
return json.NewDecoder(resp.Body).Decode(target)
}

+ 50
- 0
aoiprxkit/pcm.go Переглянути файл

@@ -0,0 +1,50 @@
package aoiprxkit

import (
"encoding/binary"
"fmt"
)

// DecodeL24BE decodes signed 24-bit big-endian PCM into int32 samples sign-extended to 32 bits.
func DecodeL24BE(payload []byte, channels int) ([]int32, error) {
if channels < 1 {
return nil, fmt.Errorf("invalid channels: %d", channels)
}
if len(payload)%3 != 0 {
return nil, fmt.Errorf("payload length %d is not divisible by 3", len(payload))
}
totalSamples := len(payload) / 3
if totalSamples%channels != 0 {
return nil, fmt.Errorf("payload sample count %d is not divisible by channels %d", totalSamples, channels)
}
out := make([]int32, totalSamples)
j := 0
for i := 0; i < len(payload); i += 3 {
v := int32(payload[i])<<16 | int32(payload[i+1])<<8 | int32(payload[i+2])
if v&0x800000 != 0 {
v |= ^int32(0xFFFFFF)
}
out[j] = v
j++
}
return out, nil
}

// DecodeS32LE decodes signed 32-bit little-endian PCM into int32 samples.
func DecodeS32LE(payload []byte, channels int) ([]int32, error) {
if channels < 1 {
return nil, fmt.Errorf("invalid channels: %d", channels)
}
if len(payload)%4 != 0 {
return nil, fmt.Errorf("payload length %d is not divisible by 4", len(payload))
}
totalSamples := len(payload) / 4
if totalSamples%channels != 0 {
return nil, fmt.Errorf("payload sample count %d is not divisible by channels %d", totalSamples, channels)
}
out := make([]int32, totalSamples)
for i := 0; i < totalSamples; i++ {
out[i] = int32(binary.LittleEndian.Uint32(payload[i*4 : i*4+4]))
}
return out, nil
}

+ 39
- 0
aoiprxkit/pcm_test.go Переглянути файл

@@ -0,0 +1,39 @@
package aoiprxkit

import "testing"

func TestDecodeL24BE(t *testing.T) {
payload := []byte{
0x7f, 0xff, 0xff,
0x80, 0x00, 0x00,
0x00, 0x00, 0x01,
0xff, 0xff, 0xff,
}
got, err := DecodeL24BE(payload, 2)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
want := []int32{8388607, -8388608, 1, -1}
if len(got) != len(want) {
t.Fatalf("len mismatch: got=%d want=%d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sample %d mismatch: got=%d want=%d", i, got[i], want[i])
}
}
}

func TestDecodeS32LE(t *testing.T) {
payload := []byte{
0x01, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0xff,
}
got, err := DecodeS32LE(payload, 1)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 2 || got[0] != 1 || got[1] != -1 {
t.Fatalf("unexpected samples: %+v", got)
}
}

+ 194
- 0
aoiprxkit/receiver.go Переглянути файл

@@ -0,0 +1,194 @@
package aoiprxkit

import (
"context"
"fmt"
"net"
"sync"
"time"
)

type PCMFrame struct {
SequenceNumber uint16
Timestamp uint32
SampleRateHz int
Channels int
Samples []int32 // interleaved
ReceivedAt time.Time
Source string
}

type FrameHandler func(frame PCMFrame)

type Receiver struct {
cfg Config
onFrame FrameHandler

mu sync.Mutex
conn *net.UDPConn
cancel context.CancelFunc
done chan struct{}
doneOnce sync.Once
stats statsAtomic
}

func NewReceiver(cfg Config, onFrame FrameHandler) (*Receiver, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if onFrame == nil {
return nil, fmt.Errorf("onFrame must not be nil")
}
return &Receiver{
cfg: cfg,
onFrame: onFrame,
done: make(chan struct{}),
}, nil
}

func (r *Receiver) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()

if r.conn != nil {
return fmt.Errorf("receiver already started")
}

group := net.ParseIP(r.cfg.MulticastGroup)
ifi, err := resolveMulticastInterface(r.cfg.InterfaceName)
if err != nil {
return err
}

addr := &net.UDPAddr{IP: group, Port: r.cfg.Port}
conn, err := net.ListenMulticastUDP("udp4", ifi, addr)
if err != nil {
return fmt.Errorf("listen multicast UDP: %w", err)
}
if r.cfg.ReadBufferBytes > 0 {
_ = conn.SetReadBuffer(r.cfg.ReadBufferBytes)
}

cctx, cancel := context.WithCancel(ctx)
r.conn = conn
r.cancel = cancel
r.done = make(chan struct{})
r.doneOnce = sync.Once{}
go r.loop(cctx)
return nil
}

func (r *Receiver) Stop() error {
r.mu.Lock()
if r.conn == nil {
r.mu.Unlock()
return nil
}
conn := r.conn
cancel := r.cancel
done := r.done
r.conn = nil
r.cancel = nil
r.mu.Unlock()

if cancel != nil {
cancel()
}
_ = conn.Close()
<-done
return nil
}

func (r *Receiver) Stats() Stats {
return r.stats.snapshot()
}

func (r *Receiver) loop(ctx context.Context) {
defer r.doneOnce.Do(func() { close(r.done) })

jb := newJitterBuffer(r.cfg.JitterDepthPackets)
buf := make([]byte, 64*1024)

for {
select {
case <-ctx.Done():
return
default:
}

_ = r.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, _, err := r.conn.ReadFromUDP(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}

r.stats.packetsReceived.Add(1)
if n < 12 {
r.stats.packetsShort.Add(1)
continue
}

pkt, err := ParseRTPPacket(buf[:n])
if err != nil {
r.stats.packetsShort.Add(1)
continue
}
r.stats.packetsParsed.Add(1)

if pkt.PayloadType != r.cfg.PayloadType {
r.stats.packetsWrongPT.Add(1)
continue
}

ready, lateDrop, gapLoss, reorder := jb.push(pkt)
if lateDrop {
r.stats.packetsLateDrop.Add(1)
continue
}
if gapLoss > 0 {
r.stats.packetsGapLoss.Add(gapLoss)
}
if reorder {
r.stats.jitterReorders.Add(1)
}

for _, rp := range ready {
samples, err := DecodeL24BE(rp.Payload, r.cfg.Channels)
if err != nil {
r.stats.decodeErrors.Add(1)
continue
}
frame := PCMFrame{
SequenceNumber: rp.SequenceNumber,
Timestamp: rp.Timestamp,
SampleRateHz: r.cfg.SampleRateHz,
Channels: r.cfg.Channels,
Samples: samples,
ReceivedAt: time.Now(),
Source: fmt.Sprintf("rtp://%s:%d", r.cfg.MulticastGroup, r.cfg.Port),
}
r.onFrame(frame)
r.stats.packetsDelivered.Add(1)
r.stats.samplesDelivered.Add(uint64(len(samples)))
if r.cfg.Channels > 0 {
r.stats.framesDelivered.Add(uint64(len(samples) / r.cfg.Channels))
}
r.stats.lastSequence.Store(uint32(rp.SequenceNumber))
r.stats.sequenceValid.Store(1)
}
}
}

func resolveMulticastInterface(name string) (*net.Interface, error) {
if name == "" {
return nil, nil
}
ifi, err := net.InterfaceByName(name)
if err != nil {
return nil, fmt.Errorf("resolve interface %q: %w", name, err)
}
return ifi, nil
}

+ 68
- 0
aoiprxkit/rtp.go Переглянути файл

@@ -0,0 +1,68 @@
package aoiprxkit

import (
"encoding/binary"
"errors"
)

type RTPPacket struct {
Version uint8
Padding bool
Extension bool
CSRCCount uint8
Marker bool
PayloadType uint8
SequenceNumber uint16
Timestamp uint32
SSRC uint32
Payload []byte
}

func ParseRTPPacket(buf []byte) (RTPPacket, error) {
if len(buf) < 12 {
return RTPPacket{}, errors.New("RTP packet too short")
}
b0 := buf[0]
b1 := buf[1]
p := RTPPacket{
Version: b0 >> 6,
Padding: (b0 & 0x20) != 0,
Extension: (b0 & 0x10) != 0,
CSRCCount: b0 & 0x0F,
Marker: (b1 & 0x80) != 0,
PayloadType: b1 & 0x7F,
SequenceNumber: binary.BigEndian.Uint16(buf[2:4]),
Timestamp: binary.BigEndian.Uint32(buf[4:8]),
SSRC: binary.BigEndian.Uint32(buf[8:12]),
}
if p.Version != 2 {
return RTPPacket{}, errors.New("unsupported RTP version")
}
headerLen := 12 + int(p.CSRCCount)*4
if len(buf) < headerLen {
return RTPPacket{}, errors.New("RTP packet too short for CSRC list")
}
if p.Extension {
if len(buf) < headerLen+4 {
return RTPPacket{}, errors.New("RTP packet too short for extension")
}
extLenWords := int(binary.BigEndian.Uint16(buf[headerLen+2 : headerLen+4]))
headerLen += 4 + extLenWords*4
if len(buf) < headerLen {
return RTPPacket{}, errors.New("RTP packet too short for full extension")
}
}
payload := buf[headerLen:]
if p.Padding {
if len(payload) == 0 {
return RTPPacket{}, errors.New("RTP packet has invalid padding")
}
padLen := int(payload[len(payload)-1])
if padLen <= 0 || padLen > len(payload) {
return RTPPacket{}, errors.New("RTP packet has invalid pad length")
}
payload = payload[:len(payload)-padLen]
}
p.Payload = payload
return p, nil
}

+ 22
- 0
aoiprxkit/rtp_test.go Переглянути файл

@@ -0,0 +1,22 @@
package aoiprxkit

import "testing"

func TestParseRTPPacket(t *testing.T) {
buf := []byte{
0x80, 0x61, 0x12, 0x34,
0x00, 0x00, 0x00, 0x05,
0x11, 0x22, 0x33, 0x44,
0x01, 0x02, 0x03,
}
p, err := ParseRTPPacket(buf)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if p.Version != 2 || p.PayloadType != 97 || p.SequenceNumber != 0x1234 || p.Timestamp != 5 || p.SSRC != 0x11223344 {
t.Fatalf("unexpected packet: %+v", p)
}
if len(p.Payload) != 3 || p.Payload[0] != 1 || p.Payload[2] != 3 {
t.Fatalf("unexpected payload: %v", p.Payload)
}
}

+ 115
- 0
aoiprxkit/sap.go Переглянути файл

@@ -0,0 +1,115 @@
package aoiprxkit

import (
"encoding/binary"
"fmt"
"net"
)

const (
DefaultSAPGroup = "224.2.127.254"
DefaultSAPPort = 9875
)

type SAPPacket struct {
Version uint8
AddressTypeIPv6 bool
IsDelete bool
Encrypted bool
Compressed bool
AuthLenWords uint8
MessageIDHash uint16
OriginSource net.IP
PayloadType string
Payload []byte
}

type SAPAnnouncement struct {
ReceivedAt string `json:"receivedAt"`
SourceAddr string `json:"sourceAddr"`
MessageID uint16 `json:"messageIdHash"`
Delete bool `json:"delete"`
PayloadType string `json:"payloadType"`
SDP string `json:"sdp"`
ParsedSDP SDPInfo `json:"parsedSdp"`
}

func ParseSAPPacket(buf []byte) (SAPPacket, error) {
if len(buf) < 8 {
return SAPPacket{}, fmt.Errorf("SAP packet too short")
}

b0 := buf[0]
version := b0 >> 5
if version != 1 {
return SAPPacket{}, fmt.Errorf("unsupported SAP version %d", version)
}

addrTypeIPv6 := (b0 & 0x10) != 0
isDelete := (b0 & 0x04) != 0
encrypted := (b0 & 0x02) != 0
compressed := (b0 & 0x01) != 0
authLenWords := buf[1]
msgID := binary.BigEndian.Uint16(buf[2:4])

hdrLen := 4
var origin net.IP
if addrTypeIPv6 {
if len(buf) < hdrLen+16 {
return SAPPacket{}, fmt.Errorf("SAP packet too short for IPv6 source")
}
origin = net.IP(buf[hdrLen : hdrLen+16])
hdrLen += 16
} else {
if len(buf) < hdrLen+4 {
return SAPPacket{}, fmt.Errorf("SAP packet too short for IPv4 source")
}
origin = net.IP(buf[hdrLen : hdrLen+4])
hdrLen += 4
}

authBytes := int(authLenWords) * 4
if len(buf) < hdrLen+authBytes {
return SAPPacket{}, fmt.Errorf("SAP packet too short for auth section")
}
hdrLen += authBytes

if encrypted || compressed {
return SAPPacket{}, fmt.Errorf("encrypted/compressed SAP payloads are not supported")
}

payloadType := "application/sdp"
payloadStart := hdrLen

if len(buf) > payloadStart && !(len(buf)-payloadStart >= 4 && string(buf[payloadStart:payloadStart+4]) == "v=0\n" || len(buf)-payloadStart >= 5 && string(buf[payloadStart:payloadStart+5]) == "v=0\r\n") {
nul := -1
for i := payloadStart; i < len(buf); i++ {
if buf[i] == 0 {
nul = i
break
}
}
if nul == -1 {
return SAPPacket{}, fmt.Errorf("SAP payload type missing NUL terminator")
}
payloadType = string(buf[payloadStart:nul])
payloadStart = nul + 1
}

if payloadStart > len(buf) {
return SAPPacket{}, fmt.Errorf("invalid SAP payload start")
}

return SAPPacket{
Version: version,
AddressTypeIPv6: addrTypeIPv6,
IsDelete: isDelete,
Encrypted: encrypted,
Compressed: compressed,
AuthLenWords: authLenWords,
MessageIDHash: msgID,
OriginSource: origin,
PayloadType: payloadType,
Payload: append([]byte(nil), buf[payloadStart:]...),
}, nil
}

+ 150
- 0
aoiprxkit/sap_listener.go Переглянути файл

@@ -0,0 +1,150 @@
package aoiprxkit

import (
"context"
"fmt"
"net"
"sync"
"time"
)

type SAPListenerConfig struct {
Group string
Port int
InterfaceName string
ReadBuffer int
}

func DefaultSAPListenerConfig() SAPListenerConfig {
return SAPListenerConfig{
Group: DefaultSAPGroup,
Port: DefaultSAPPort,
ReadBuffer: 1 << 20,
}
}

type SAPHandler func(announcement SAPAnnouncement)

type SAPListener struct {
cfg SAPListenerConfig
onPacket SAPHandler

mu sync.Mutex
conn *net.UDPConn
cancel context.CancelFunc
done chan struct{}
doneOnce sync.Once
}

func NewSAPListener(cfg SAPListenerConfig, onPacket SAPHandler) (*SAPListener, error) {
if cfg.Group == "" {
cfg.Group = DefaultSAPGroup
}
if cfg.Port == 0 {
cfg.Port = DefaultSAPPort
}
if onPacket == nil {
return nil, fmt.Errorf("onPacket must not be nil")
}
if net.ParseIP(cfg.Group) == nil {
return nil, fmt.Errorf("invalid SAP group: %q", cfg.Group)
}
return &SAPListener{
cfg: cfg,
onPacket: onPacket,
done: make(chan struct{}),
}, nil
}

func (l *SAPListener) Start(ctx context.Context) error {
l.mu.Lock()
defer l.mu.Unlock()
if l.conn != nil {
return fmt.Errorf("SAP listener already started")
}

ifi, err := resolveMulticastInterface(l.cfg.InterfaceName)
if err != nil {
return err
}
group := net.ParseIP(l.cfg.Group)
addr := &net.UDPAddr{IP: group, Port: l.cfg.Port}
conn, err := net.ListenMulticastUDP("udp4", ifi, addr)
if err != nil {
return fmt.Errorf("listen SAP multicast UDP: %w", err)
}
if l.cfg.ReadBuffer > 0 {
_ = conn.SetReadBuffer(l.cfg.ReadBuffer)
}

cctx, cancel := context.WithCancel(ctx)
l.conn = conn
l.cancel = cancel
l.done = make(chan struct{})
l.doneOnce = sync.Once{}
go l.loop(cctx)
return nil
}

func (l *SAPListener) Stop() error {
l.mu.Lock()
if l.conn == nil {
l.mu.Unlock()
return nil
}
conn := l.conn
cancel := l.cancel
done := l.done
l.conn = nil
l.cancel = nil
l.mu.Unlock()

if cancel != nil {
cancel()
}
_ = conn.Close()
<-done
return nil
}

func (l *SAPListener) loop(ctx context.Context) {
defer l.doneOnce.Do(func() { close(l.done) })

buf := make([]byte, 64*1024)
for {
select {
case <-ctx.Done():
return
default:
}
_ = l.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, src, err := l.conn.ReadFromUDP(buf)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
pkt, err := ParseSAPPacket(buf[:n])
if err != nil {
continue
}
if pkt.PayloadType != "application/sdp" {
continue
}
sdp := string(pkt.Payload)
info, err := ParseMinimalSDP(sdp)
if err != nil {
continue
}
l.onPacket(SAPAnnouncement{
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
SourceAddr: src.String(),
MessageID: pkt.MessageIDHash,
Delete: pkt.IsDelete,
PayloadType: pkt.PayloadType,
SDP: sdp,
ParsedSDP: info,
})
}
}

+ 28
- 0
aoiprxkit/sap_test.go Переглянути файл

@@ -0,0 +1,28 @@
package aoiprxkit

import "testing"

func TestParseSAPPacket(t *testing.T) {
payload := []byte("application/sdp\x00v=0\n" +
"o=- 1 1 IN IP4 192.168.1.10\n" +
"s=Test\n" +
"c=IN IP4 239.69.0.1/32\n" +
"t=0 0\n" +
"m=audio 5004 RTP/AVP 97\n" +
"a=rtpmap:97 L24/48000/2\n")
pkt := []byte{
0x20, // V=1, IPv4, announce, no enc/compress
0x00, // auth len
0x12, 0x34,
192, 168, 1, 50,
}
pkt = append(pkt, payload...)

got, err := ParseSAPPacket(pkt)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got.Version != 1 || got.MessageIDHash != 0x1234 || got.PayloadType != "application/sdp" || got.OriginSource.String() != "192.168.1.50" {
t.Fatalf("unexpected SAP packet: %+v", got)
}
}

+ 116
- 0
aoiprxkit/sdp.go Переглянути файл

@@ -0,0 +1,116 @@
package aoiprxkit

import (
"fmt"
"net"
"strconv"
"strings"
"time"
)

type SDPInfo struct {
SessionName string
Origin string
MulticastGroup string
Port int
PayloadType uint8
Encoding string
SampleRateHz int
Channels int
PacketTimeMS int
}

// ParseMinimalSDP extracts the multicast address, port and one rtpmap line.
// It is deliberately small and not a full SDP parser.
func ParseMinimalSDP(s string) (SDPInfo, error) {
var out SDPInfo
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "s="):
out.SessionName = strings.TrimPrefix(line, "s=")

case strings.HasPrefix(line, "o="):
out.Origin = strings.TrimPrefix(line, "o=")

case strings.HasPrefix(line, "c=IN IP4 "):
rest := strings.TrimPrefix(line, "c=IN IP4 ")
host := strings.Split(rest, "/")[0]
if net.ParseIP(host) == nil {
return out, fmt.Errorf("invalid multicast host in c=: %q", host)
}
out.MulticastGroup = host

case strings.HasPrefix(line, "m=audio "):
fields := strings.Fields(line)
if len(fields) < 4 {
return out, fmt.Errorf("invalid m=audio line")
}
port, err := strconv.Atoi(fields[1])
if err != nil {
return out, fmt.Errorf("invalid audio port: %w", err)
}
pt, err := strconv.Atoi(fields[3])
if err != nil {
return out, fmt.Errorf("invalid payload type: %w", err)
}
out.Port = port
out.PayloadType = uint8(pt)

case strings.HasPrefix(line, "a=rtpmap:"):
rest := strings.TrimPrefix(line, "a=rtpmap:")
parts := strings.Fields(rest)
if len(parts) != 2 {
return out, fmt.Errorf("invalid rtpmap line")
}
pt, err := strconv.Atoi(parts[0])
if err != nil {
return out, fmt.Errorf("invalid rtpmap payload type: %w", err)
}
codecParts := strings.Split(parts[1], "/")
if len(codecParts) < 2 {
return out, fmt.Errorf("invalid rtpmap codec tuple")
}
sr, err := strconv.Atoi(codecParts[1])
if err != nil {
return out, fmt.Errorf("invalid rtpmap sample rate: %w", err)
}
ch := 1
if len(codecParts) >= 3 {
ch, err = strconv.Atoi(codecParts[2])
if err != nil {
return out, fmt.Errorf("invalid rtpmap channel count: %w", err)
}
}
out.PayloadType = uint8(pt)
out.Encoding = codecParts[0]
out.SampleRateHz = sr
out.Channels = ch

case strings.HasPrefix(line, "a=ptime:"):
ms, err := strconv.Atoi(strings.TrimPrefix(line, "a=ptime:"))
if err == nil {
out.PacketTimeMS = ms
}
}
}
if out.MulticastGroup == "" || out.Port == 0 || out.Encoding == "" || out.SampleRateHz == 0 {
return out, fmt.Errorf("incomplete SDP: %+v", out)
}
return out, nil
}

func ConfigFromSDP(base Config, info SDPInfo) (Config, error) {
cfg := base
cfg.MulticastGroup = info.MulticastGroup
cfg.Port = info.Port
cfg.PayloadType = info.PayloadType
cfg.SampleRateHz = info.SampleRateHz
cfg.Channels = info.Channels
cfg.Encoding = info.Encoding
if info.PacketTimeMS > 0 {
cfg.PacketTime = time.Duration(info.PacketTimeMS) * time.Millisecond
}
return cfg, cfg.Validate()
}

+ 22
- 0
aoiprxkit/sdp_test.go Переглянути файл

@@ -0,0 +1,22 @@
package aoiprxkit

import "testing"

func TestParseMinimalSDP(t *testing.T) {
sdp := `v=0
o=- 1 1 IN IP4 192.168.1.10
s=Test
c=IN IP4 239.69.0.1/32
t=0 0
m=audio 5004 RTP/AVP 97
a=rtpmap:97 L24/48000/2
a=ptime:1
`
got, err := ParseMinimalSDP(sdp)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got.MulticastGroup != "239.69.0.1" || got.Port != 5004 || got.PayloadType != 97 || got.Encoding != "L24" || got.SampleRateHz != 48000 || got.Channels != 2 || got.PacketTimeMS != 1 {
t.Fatalf("unexpected parsed SDP: %+v", got)
}
}

+ 70
- 0
aoiprxkit/srt.go Переглянути файл

@@ -0,0 +1,70 @@
package aoiprxkit

import (
"context"
"fmt"
"io"
)

type SRTConfig struct {
URL string
Mode string
SampleRateHz int
Channels int
SourceLabel string
}

func DefaultSRTConfig() SRTConfig {
return SRTConfig{
SampleRateHz: 48000,
Channels: 2,
Mode: "listener",
}
}

func (c SRTConfig) Validate() error {
if c.URL == "" {
return fmt.Errorf("SRT URL must not be empty")
}
if c.SampleRateHz <= 0 {
return fmt.Errorf("SampleRateHz must be > 0")
}
if c.Channels < 1 || c.Channels > 2 {
return fmt.Errorf("Channels must be 1 or 2")
}
return nil
}

type SRTConnOpener func(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error)

type SRTReceiver struct {
cfg SRTConfig
streamRx *StreamReceiver
}

func NewSRTReceiver(cfg SRTConfig, onFrame FrameHandler) (*SRTReceiver, error) {
return NewSRTReceiverWithOpener(cfg, defaultSRTConnOpener, onFrame)
}

func NewSRTReceiverWithOpener(cfg SRTConfig, opener SRTConnOpener, onFrame FrameHandler) (*SRTReceiver, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if opener == nil {
return nil, fmt.Errorf("SRT opener must not be nil")
}
src := cfg.SourceLabel
if src == "" {
src = cfg.URL
}
streamRx, err := NewStreamReceiver(StreamReceiverConfig{SourceLabel: src}, func(ctx context.Context) (io.ReadCloser, error) {
return opener(ctx, cfg)
}, onFrame)
if err != nil {
return nil, err
}
return &SRTReceiver{cfg: cfg, streamRx: streamRx}, nil
}

func (r *SRTReceiver) Start(ctx context.Context) error { return r.streamRx.Start(ctx) }
func (r *SRTReceiver) Stop() error { return r.streamRx.Stop() }

+ 13
- 0
aoiprxkit/srt_gosrt.go.example Переглянути файл

@@ -0,0 +1,13 @@
// Example only. Rename to srt_gosrt.go in your target repo and wire it to github.com/datarhei/gosrt once that dependency is available.
//go:build gosrt

package aoiprxkit

// This file is intentionally left as a non-compiling example placeholder in the package zip.
// Reason: the current environment cannot fetch external Go modules, and the exact gosrt API
// should be verified against the version you vendor or pin in your target repository.
//
// Expected job of the real implementation:
// - parse cfg.URL
// - open a gosrt listener/caller depending on cfg.Mode
// - return an io.ReadCloser that yields framed PCM packets defined by stream_proto.go

+ 13
- 0
aoiprxkit/srt_profile.md Переглянути файл

@@ -0,0 +1,13 @@
# SRT framed-PCM profile

This module now assumes a deliberately narrow WAN profile:

- transport: SRT
- payload framing: custom framed stream defined in `stream_proto.go`
- codec today: PCM S32LE
- codec reserved for later: Opus

Rationale:
- keep the Go stack small and deterministic
- avoid generic container/demux complexity
- make WAN ingest compatible with the same `PCMFrame` callback used by RTP/AES67-lite

+ 15
- 0
aoiprxkit/srt_stub.go Переглянути файл

@@ -0,0 +1,15 @@
//go:build !gosrt

package aoiprxkit

import (
"context"
"fmt"
"io"
)

func defaultSRTConnOpener(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error) {
_ = ctx
_ = cfg
return nil, fmt.Errorf("native SRT transport is not linked in this build: provide a custom opener or add a gosrt-backed opener in your target repo")
}

+ 58
- 0
aoiprxkit/srt_test.go Переглянути файл

@@ -0,0 +1,58 @@
package aoiprxkit

import (
"bytes"
"context"
"io"
"testing"
"time"
)

type readCloser struct{ io.Reader }

func (r readCloser) Close() error { return nil }

func TestSRTReceiverWithCustomOpener(t *testing.T) {
var stream bytes.Buffer
samples := []int32{1, 2, 3, 4}
if err := WritePCM32Packet(&stream, 2, 48000, 2, 1, 480, samples); err != nil {
t.Fatalf("unexpected write error: %v", err)
}

got := make(chan PCMFrame, 1)
rx, err := NewSRTReceiverWithOpener(SRTConfig{
URL: "srt://example:9000?mode=listener",
SampleRateHz: 48000,
Channels: 2,
}, func(ctx context.Context, cfg SRTConfig) (io.ReadCloser, error) {
_ = ctx
_ = cfg
return readCloser{Reader: bytes.NewReader(stream.Bytes())}, nil
}, func(frame PCMFrame) {
select {
case got <- frame:
default:
}
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := rx.Start(context.Background()); err != nil {
t.Fatalf("unexpected start error: %v", err)
}
defer rx.Stop()

select {
case frame := <-got:
if len(frame.Samples) != len(samples) {
t.Fatalf("unexpected sample len: %d", len(frame.Samples))
}
for i := range samples {
if frame.Samples[i] != samples[i] {
t.Fatalf("sample %d mismatch: got=%d want=%d", i, frame.Samples[i], samples[i])
}
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("timeout waiting for frame")
}
}

+ 53
- 0
aoiprxkit/stats.go Переглянути файл

@@ -0,0 +1,53 @@
package aoiprxkit

import "sync/atomic"

type Stats struct {
PacketsReceived uint64 `json:"packetsReceived"`
PacketsParsed uint64 `json:"packetsParsed"`
PacketsDelivered uint64 `json:"packetsDelivered"`
PacketsLateDrop uint64 `json:"packetsLateDrop"`
PacketsGapLoss uint64 `json:"packetsGapLoss"`
PacketsWrongPT uint64 `json:"packetsWrongPayloadType"`
PacketsShort uint64 `json:"packetsTooShort"`
JitterReorders uint64 `json:"jitterReorders"`
DecodeErrors uint64 `json:"decodeErrors"`
SamplesDelivered uint64 `json:"samplesDelivered"`
FramesDelivered uint64 `json:"framesDelivered"`
LastSequence uint32 `json:"lastSequence"`
SequenceValid uint32 `json:"sequenceValid"`
}

type statsAtomic struct {
packetsReceived atomic.Uint64
packetsParsed atomic.Uint64
packetsDelivered atomic.Uint64
packetsLateDrop atomic.Uint64
packetsGapLoss atomic.Uint64
packetsWrongPT atomic.Uint64
packetsShort atomic.Uint64
jitterReorders atomic.Uint64
decodeErrors atomic.Uint64
samplesDelivered atomic.Uint64
framesDelivered atomic.Uint64
lastSequence atomic.Uint32
sequenceValid atomic.Uint32
}

func (s *statsAtomic) snapshot() Stats {
return Stats{
PacketsReceived: s.packetsReceived.Load(),
PacketsParsed: s.packetsParsed.Load(),
PacketsDelivered: s.packetsDelivered.Load(),
PacketsLateDrop: s.packetsLateDrop.Load(),
PacketsGapLoss: s.packetsGapLoss.Load(),
PacketsWrongPT: s.packetsWrongPT.Load(),
PacketsShort: s.packetsShort.Load(),
JitterReorders: s.jitterReorders.Load(),
DecodeErrors: s.decodeErrors.Load(),
SamplesDelivered: s.samplesDelivered.Load(),
FramesDelivered: s.framesDelivered.Load(),
LastSequence: s.lastSequence.Load(),
SequenceValid: s.sequenceValid.Load(),
}
}

+ 137
- 0
aoiprxkit/stream_finder.go Переглянути файл

@@ -0,0 +1,137 @@
package aoiprxkit

import (
"context"
"fmt"
"sync"
"time"
)

// StreamFinder keeps a live in-memory view of SAP/SDP announcements
// and can wait for sessions by their SDP "s=" session name.
type StreamFinder struct {
listener *SAPListener

mu sync.Mutex
sessions map[string]SAPAnnouncement
waiters map[string][]chan SAPAnnouncement
}

func NewStreamFinder(cfg SAPListenerConfig) (*StreamFinder, error) {
sf := &StreamFinder{
sessions: make(map[string]SAPAnnouncement),
waiters: make(map[string][]chan SAPAnnouncement),
}
listener, err := NewSAPListener(cfg, sf.handleAnnouncement)
if err != nil {
return nil, err
}
sf.listener = listener
return sf, nil
}

func (s *StreamFinder) Start(ctx context.Context) error {
return s.listener.Start(ctx)
}

func (s *StreamFinder) Stop() error {
return s.listener.Stop()
}

func (s *StreamFinder) handleAnnouncement(a SAPAnnouncement) {
name := a.ParsedSDP.SessionName
if name == "" {
return
}

s.mu.Lock()
defer s.mu.Unlock()

if a.Delete {
delete(s.sessions, name)
return
}

s.sessions[name] = a
if waiters := s.waiters[name]; len(waiters) > 0 {
delete(s.waiters, name)
for _, ch := range waiters {
select {
case ch <- a:
default:
}
close(ch)
}
}
}

func (s *StreamFinder) FindByStreamName(name string) (SAPAnnouncement, bool) {
s.mu.Lock()
defer s.mu.Unlock()
a, ok := s.sessions[name]
return a, ok
}

func (s *StreamFinder) WaitForStreamName(ctx context.Context, name string) (SAPAnnouncement, error) {
if name == "" {
return SAPAnnouncement{}, fmt.Errorf("stream name must not be empty")
}

s.mu.Lock()
if a, ok := s.sessions[name]; ok {
s.mu.Unlock()
return a, nil
}
ch := make(chan SAPAnnouncement, 1)
s.waiters[name] = append(s.waiters[name], ch)
s.mu.Unlock()

select {
case <-ctx.Done():
s.mu.Lock()
waiters := s.waiters[name]
kept := waiters[:0]
for _, w := range waiters {
if w != ch {
kept = append(kept, w)
}
}
if len(kept) == 0 {
delete(s.waiters, name)
} else {
s.waiters[name] = kept
}
s.mu.Unlock()
return SAPAnnouncement{}, ctx.Err()
case a := <-ch:
return a, nil
}
}

func (s *StreamFinder) WaitConfigByStreamName(ctx context.Context, base Config, name string) (Config, SAPAnnouncement, error) {
a, err := s.WaitForStreamName(ctx, name)
if err != nil {
return Config{}, SAPAnnouncement{}, err
}
cfg, err := ConfigFromSDP(base, a.ParsedSDP)
if err != nil {
return Config{}, SAPAnnouncement{}, err
}
return cfg, a, nil
}

func (s *StreamFinder) Snapshot() []SAPAnnouncement {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]SAPAnnouncement, 0, len(s.sessions))
for _, v := range s.sessions {
out = append(out, v)
}
return out
}

func (s *StreamFinder) WaitForStreamNameTimeout(name string, timeout time.Duration) (SAPAnnouncement, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return s.WaitForStreamName(ctx, name)
}

+ 81
- 0
aoiprxkit/stream_proto.go Переглянути файл

@@ -0,0 +1,81 @@
package aoiprxkit

import (
"encoding/binary"
"fmt"
"io"
)

const (
streamMagic = "ARX1"

StreamCodecPCM_S32LE uint8 = 1
StreamCodecOpus uint8 = 2 // reserved for later phases
)

type StreamHeader struct {
Codec uint8
Channels uint8
SampleRateHz uint32
FrameSamples uint32
Sequence uint32
Timestamp uint64
PayloadBytes uint32
}

func ReadStreamHeader(r io.Reader) (StreamHeader, error) {
var raw [26]byte
if _, err := io.ReadFull(r, raw[:]); err != nil {
return StreamHeader{}, err
}
if string(raw[0:4]) != streamMagic {
return StreamHeader{}, fmt.Errorf("invalid stream magic %q", string(raw[0:4]))
}
h := StreamHeader{
Codec: raw[4],
Channels: raw[5],
SampleRateHz: binary.BigEndian.Uint32(raw[6:10]),
FrameSamples: binary.BigEndian.Uint32(raw[10:14]),
Sequence: binary.BigEndian.Uint32(raw[14:18]),
Timestamp: binary.BigEndian.Uint64(raw[18:26]),
}
var payloadLenRaw [4]byte
if _, err := io.ReadFull(r, payloadLenRaw[:]); err != nil {
return StreamHeader{}, err
}
h.PayloadBytes = binary.BigEndian.Uint32(payloadLenRaw[:])
return h, nil
}

func WritePCM32Packet(w io.Writer, channels int, sampleRateHz int, frameSamples int, sequence uint32, timestamp uint64, samples []int32) error {
if channels < 1 || channels > 2 {
return fmt.Errorf("channels must be 1 or 2")
}
if frameSamples < 0 {
return fmt.Errorf("frameSamples must be >= 0")
}
if len(samples) != frameSamples*channels {
return fmt.Errorf("sample length mismatch: got=%d want=%d", len(samples), frameSamples*channels)
}

payloadBytes := len(samples) * 4
var hdr [30]byte
copy(hdr[0:4], []byte(streamMagic))
hdr[4] = StreamCodecPCM_S32LE
hdr[5] = byte(channels)
binary.BigEndian.PutUint32(hdr[6:10], uint32(sampleRateHz))
binary.BigEndian.PutUint32(hdr[10:14], uint32(frameSamples))
binary.BigEndian.PutUint32(hdr[14:18], sequence)
binary.BigEndian.PutUint64(hdr[18:26], timestamp)
binary.BigEndian.PutUint32(hdr[26:30], uint32(payloadBytes))
if _, err := w.Write(hdr[:]); err != nil {
return err
}

payload := make([]byte, payloadBytes)
for i, s := range samples {
binary.LittleEndian.PutUint32(payload[i*4:i*4+4], uint32(s))
}
_, err := w.Write(payload)
return err
}

+ 34
- 0
aoiprxkit/stream_proto_test.go Переглянути файл

@@ -0,0 +1,34 @@
package aoiprxkit

import (
"bytes"
"testing"
)

func TestWriteAndReadPCM32Packet(t *testing.T) {
var buf bytes.Buffer
samples := []int32{1, -1, 10, -10}
if err := WritePCM32Packet(&buf, 2, 48000, 2, 7, 1234, samples); err != nil {
t.Fatalf("unexpected write error: %v", err)
}
hdr, err := ReadStreamHeader(&buf)
if err != nil {
t.Fatalf("unexpected read header error: %v", err)
}
if hdr.Codec != StreamCodecPCM_S32LE || hdr.Channels != 2 || hdr.SampleRateHz != 48000 || hdr.FrameSamples != 2 || hdr.Sequence != 7 || hdr.Timestamp != 1234 || hdr.PayloadBytes != 16 {
t.Fatalf("unexpected header: %+v", hdr)
}
payload := make([]byte, hdr.PayloadBytes)
if _, err := buf.Read(payload); err != nil {
t.Fatalf("unexpected payload read error: %v", err)
}
got, err := DecodeS32LE(payload, 2)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
for i := range samples {
if got[i] != samples[i] {
t.Fatalf("sample %d mismatch: got=%d want=%d", i, got[i], samples[i])
}
}
}

+ 114
- 0
aoiprxkit/stream_receiver.go Переглянути файл

@@ -0,0 +1,114 @@
package aoiprxkit

import (
"context"
"fmt"
"io"
"sync"
"time"
)

type StreamReceiverConfig struct {
SourceLabel string
}

type StreamReceiver struct {
cfg StreamReceiverConfig
opener func(context.Context) (io.ReadCloser, error)
onFrame FrameHandler

mu sync.Mutex
rc io.ReadCloser
cancel context.CancelFunc
done chan struct{}
}

func NewStreamReceiver(cfg StreamReceiverConfig, opener func(context.Context) (io.ReadCloser, error), onFrame FrameHandler) (*StreamReceiver, error) {
if opener == nil {
return nil, fmt.Errorf("opener must not be nil")
}
if onFrame == nil {
return nil, fmt.Errorf("onFrame must not be nil")
}
return &StreamReceiver{cfg: cfg, opener: opener, onFrame: onFrame, done: make(chan struct{})}, nil
}

func (r *StreamReceiver) Start(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.rc != nil {
return fmt.Errorf("stream receiver already started")
}
cctx, cancel := context.WithCancel(ctx)
rc, err := r.opener(cctx)
if err != nil {
cancel()
return err
}
r.rc = rc
r.cancel = cancel
r.done = make(chan struct{})
go r.loop(cctx, rc)
return nil
}

func (r *StreamReceiver) Stop() error {
r.mu.Lock()
rc := r.rc
cancel := r.cancel
done := r.done
r.rc = nil
r.cancel = nil
r.mu.Unlock()

if cancel != nil {
cancel()
}
if rc != nil {
_ = rc.Close()
}
if done != nil {
<-done
}
return nil
}

func (r *StreamReceiver) loop(ctx context.Context, rc io.ReadCloser) {
defer close(r.done)
for {
select {
case <-ctx.Done():
return
default:
}
hdr, err := ReadStreamHeader(rc)
if err != nil {
return
}
payload := make([]byte, hdr.PayloadBytes)
if _, err := io.ReadFull(rc, payload); err != nil {
return
}
switch hdr.Codec {
case StreamCodecPCM_S32LE:
samples, err := DecodeS32LE(payload, int(hdr.Channels))
if err != nil {
continue
}
r.onFrame(PCMFrame{
SequenceNumber: uint16(hdr.Sequence & 0xffff),
Timestamp: uint32(hdr.Timestamp & 0xffffffff),
SampleRateHz: int(hdr.SampleRateHz),
Channels: int(hdr.Channels),
Samples: samples,
ReceivedAt: time.Now(),
Source: r.cfg.SourceLabel,
})
case StreamCodecOpus:
// Reserved for later phase. Not decoded in this module revision.
continue
default:
continue
}
}
}

+ 56
- 0
aoiprxkit/stream_receiver_test.go Переглянути файл

@@ -0,0 +1,56 @@
package aoiprxkit

import (
"bytes"
"context"
"io"
"testing"
"time"
)

type nopCloser struct{ io.Reader }

func (n nopCloser) Close() error { return nil }

func TestStreamReceiverPCM(t *testing.T) {
var buf bytes.Buffer
samples := []int32{100, -100, 200, -200}
if err := WritePCM32Packet(&buf, 2, 48000, 2, 55, 999, samples); err != nil {
t.Fatalf("unexpected write error: %v", err)
}

got := make(chan PCMFrame, 1)
rx, err := NewStreamReceiver(StreamReceiverConfig{SourceLabel: "test-source"}, func(ctx context.Context) (io.ReadCloser, error) {
_ = ctx
return nopCloser{Reader: bytes.NewReader(buf.Bytes())}, nil
}, func(frame PCMFrame) {
select {
case got <- frame:
default:
}
})
if err != nil {
t.Fatalf("unexpected constructor error: %v", err)
}
if err := rx.Start(context.Background()); err != nil {
t.Fatalf("unexpected start error: %v", err)
}
defer rx.Stop()

select {
case frame := <-got:
if frame.SampleRateHz != 48000 || frame.Channels != 2 || frame.Source != "test-source" {
t.Fatalf("unexpected frame meta: %+v", frame)
}
if len(frame.Samples) != len(samples) {
t.Fatalf("unexpected sample len: %d", len(frame.Samples))
}
for i := range samples {
if frame.Samples[i] != samples[i] {
t.Fatalf("sample %d mismatch: got=%d want=%d", i, frame.Samples[i], samples[i])
}
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("timeout waiting for frame")
}
}

+ 104
- 37
cmd/fmrtx/main.go Переглянути файл

@@ -7,6 +7,7 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"

@@ -15,6 +16,9 @@ import (
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast"
ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory"
"github.com/jan/fm-rds-tx/internal/platform"
"github.com/jan/fm-rds-tx/internal/platform/plutosdr"
"github.com/jan/fm-rds-tx/internal/platform/soapysdr"
@@ -36,7 +40,6 @@ func main() {
audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream")
flag.Parse()

// --- list-devices (SoapySDR) ---
if *listDevices {
devices, err := soapysdr.Enumerate()
if err != nil {
@@ -60,13 +63,12 @@ func main() {
log.Fatalf("load config: %v", err)
}

// --- print-config ---
if *printConfig {
preemph := "off"
if cfg.FM.PreEmphasisTauUS > 0 {
preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisTauUS)
preemph = fmt.Sprintf("%.0fus", cfg.FM.PreEmphasisTauUS)
}
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n",
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=+-%.0fHz compositeRate=%dHz deviceRate=%.0fHz listen=%s pluto=%t soapy=%t\n",
cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled,
preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz,
cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress,
@@ -74,7 +76,6 @@ func main() {
return
}

// --- dry-run ---
if *dryRun {
frame := drypkg.Generate(cfg)
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
@@ -86,7 +87,6 @@ func main() {
return
}

// --- simulate ---
if *simulate {
summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
if err != nil {
@@ -96,28 +96,25 @@ func main() {
return
}

// --- TX mode ---
if *txMode {
driver := selectDriver(cfg)
if driver == nil {
log.Fatal("no hardware driver available build with -tags pluto (or -tags soapy)")
log.Fatal("no hardware driver available - build with -tags pluto (or -tags soapy)")
}
runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP)
runTXMode(cfg, *configPath, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP)
return
}

// --- default: HTTP only ---
srv := ctrlpkg.NewServer(cfg)
configureControlPlanePersistence(srv, *configPath)
server := ctrlpkg.NewHTTPServer(cfg, srv.Handler())
log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr)
log.Fatal(server.ListenAndServe())
}

// selectDriver picks the best available driver based on config and build tags.
func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
kind := cfg.Backend.Kind

// Explicit PlutoSDR
if kind == "pluto" || kind == "plutosdr" {
if plutosdr.Available() {
return plutosdr.NewPlutoDriver()
@@ -125,7 +122,6 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError())
}

// Explicit SoapySDR
if kind == "soapy" || kind == "soapysdr" {
if soapysdr.Available() {
return soapysdr.NewNativeDriver()
@@ -133,7 +129,6 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
log.Printf("warning: backend=%s but soapy driver not available", kind)
}

// Auto-detect: prefer PlutoSDR, fall back to SoapySDR
if plutosdr.Available() {
log.Println("auto-selected: pluto-iio driver")
return plutosdr.NewPlutoDriver()
@@ -146,18 +141,15 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver {
return nil
}

func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) {
func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Configure driver
// OutputDrive controls composite signal level, NOT hardware gain.
// Hardware TX gain is always 0 dB (max power). Use external attenuator for power control.
soapyCfg := platform.SoapyConfig{
Driver: cfg.Backend.Driver,
Device: cfg.Backend.Device,
CenterFreqHz: cfg.FM.FrequencyMHz * 1e6,
GainDB: 0, // 0 dB = max TX power on PlutoSDR
GainDB: 0,
DeviceArgs: map[string]string{},
}
if cfg.Backend.URI != "" {
@@ -181,42 +173,73 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a
caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate)
}

// Engine
engine := apppkg.NewEngine(cfg, driver)
cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP)

// Live audio stream source (optional)
var streamSrc *audio.StreamSource
if audioStdin || audioHTTP {
// Buffer: 2 seconds at input rate — enough to absorb jitter
bufferFrames := audioRate * 2
var ingestRuntime *ingest.Runtime
var ingress ctrlpkg.AudioIngress
if ingestEnabled(cfg.Ingest.Kind) {
rate := ingestfactory.SampleRateForKind(cfg)
bufferFrames := rate * 2
if bufferFrames <= 0 {
bufferFrames = 1
}
streamSrc = audio.NewStreamSource(bufferFrames, audioRate)
streamSrc = audio.NewStreamSource(bufferFrames, rate)
engine.SetStreamSource(streamSrc)

if audioStdin {
go func() {
log.Printf("audio: reading S16LE stereo PCM from stdin at %d Hz", audioRate)
if err := audio.IngestReader(os.Stdin, streamSrc); err != nil {
log.Printf("audio: stdin ingest ended: %v", err)
} else {
log.Println("audio: stdin EOF")
source, sourceIngress, err := ingestfactory.BuildSource(ctx, cfg, ingestfactory.Deps{Stdin: os.Stdin})
if err != nil {
log.Fatalf("ingest source: %v", err)
}
runtimeOpts := []ingest.RuntimeOption{}
runtimeOpts = append(runtimeOpts, ingest.WithPrebufferMs(cfg.Ingest.PrebufferMs))
if cfg.Ingest.Icecast.RadioText.Enabled {
relay := icecast.NewRadioTextRelay(
icecast.RadioTextOptions{
Enabled: true,
Prefix: cfg.Ingest.Icecast.RadioText.Prefix,
MaxLen: cfg.Ingest.Icecast.RadioText.MaxLen,
OnlyOnChange: cfg.Ingest.Icecast.RadioText.OnlyOnChange,
},
cfg.RDS.RadioText,
func(rt string) error {
return engine.UpdateConfig(apppkg.LiveConfigUpdate{RadioText: &rt})
},
)
runtimeOpts = append(runtimeOpts, ingest.WithStreamTitleHandler(func(streamTitle string) {
if err := relay.HandleStreamTitle(streamTitle); err != nil {
log.Printf("ingest: failed to forward StreamTitle to RDS RadioText: %v", err)
}
}()
}))
log.Printf(
"ingest: ICY StreamTitle->RDS enabled (maxLen=%d onlyOnChange=%t prefix=%q)",
cfg.Ingest.Icecast.RadioText.MaxLen,
cfg.Ingest.Icecast.RadioText.OnlyOnChange,
cfg.Ingest.Icecast.RadioText.Prefix,
)
}
if audioHTTP {
log.Printf("audio: HTTP ingest enabled on /audio/stream (rate=%dHz, buffer=%d frames)", audioRate, streamSrc.Stats().Capacity)
ingestRuntime = ingest.NewRuntime(streamSrc, source, runtimeOpts...)
if err := ingestRuntime.Start(ctx); err != nil {
log.Fatalf("ingest start: %v", err)
}
ingress = sourceIngress
log.Printf("ingest: kind=%s rate=%dHz buffer=%d frames", cfg.Ingest.Kind, rate, streamSrc.Stats().Capacity)
}

// Control plane
srv := ctrlpkg.NewServer(cfg)
configureControlPlanePersistence(srv, configPath)
srv.SetDriver(driver)
srv.SetTXController(&txBridge{engine: engine})
if streamSrc != nil {
srv.SetStreamSource(streamSrc)
}
if ingress != nil {
srv.SetAudioIngress(ingress)
}
if ingestRuntime != nil {
srv.SetIngestRuntime(ingestRuntime)
}

if autoStart {
log.Println("TX: auto-start enabled")
@@ -225,7 +248,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a
}
log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate())
} else {
log.Println("TX ready (idle) POST /tx/start to begin")
log.Println("TX ready (idle) - POST /tx/start to begin")
}

ctrlServer := ctrlpkg.NewHTTPServer(cfg, srv.Handler())
@@ -242,10 +265,48 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a
log.Printf("received %s, shutting down...", sig)

_ = engine.Stop(ctx)
if ingestRuntime != nil {
_ = ingestRuntime.Stop()
}
_ = driver.Close(ctx)
log.Println("shutdown complete")
}

func configureControlPlanePersistence(srv *ctrlpkg.Server, configPath string) {
if strings.TrimSpace(configPath) == "" {
return
}
srv.SetConfigSaver(func(next cfgpkg.Config) error {
return cfgpkg.Save(configPath, next)
})
srv.SetHardReload(func() {
log.Printf("control: hard reload requested after config save, exiting process")
os.Exit(0)
})
}

func ingestEnabled(kind string) bool {
normalized := strings.ToLower(strings.TrimSpace(kind))
return normalized != "" && normalized != "none"
}

func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, audioHTTP bool) cfgpkg.Config {
if audioRate > 0 {
cfg.Ingest.Stdin.SampleRateHz = audioRate
cfg.Ingest.HTTPRaw.SampleRateHz = audioRate
}
if audioStdin && audioHTTP {
log.Printf("audio: both --audio-stdin and --audio-http set; using ingest kind=stdin")
}
if audioStdin {
cfg.Ingest.Kind = "stdin"
}
if audioHTTP && !audioStdin {
cfg.Ingest.Kind = "http-raw"
}
return cfg
}

type txBridge struct{ engine *apppkg.Engine }

func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) }
@@ -269,6 +330,8 @@ func (b *txBridge) TXStats() map[string]any {
"runtimeIndicator": s.RuntimeIndicator,
"runtimeAlert": s.RuntimeAlert,
"appliedFrequencyMHz": s.AppliedFrequencyMHz,
"activePS": s.ActivePS,
"activeRadioText": s.ActiveRadioText,
"degradedTransitions": s.DegradedTransitions,
"mutedTransitions": s.MutedTransitions,
"faultedTransitions": s.FaultedTransitions,
@@ -290,6 +353,10 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error {
LimiterCeiling: lp.LimiterCeiling,
PS: lp.PS,
RadioText: lp.RadioText,
ToneLeftHz: lp.ToneLeftHz,
ToneRightHz: lp.ToneRightHz,
ToneAmplitude: lp.ToneAmplitude,
AudioGain: lp.AudioGain,
})
}



+ 22
- 0
cmd/fmrtx/main_test.go Переглянути файл

@@ -9,6 +9,28 @@ import (
"github.com/jan/fm-rds-tx/internal/platform"
)

func TestIngestEnabled(t *testing.T) {
tests := []struct {
name string
kind string
want bool
}{
{name: "empty", kind: "", want: false},
{name: "none", kind: "none", want: false},
{name: "none uppercase and spaces", kind: " NONE ", want: false},
{name: "stdin", kind: "stdin", want: true},
{name: "http raw uppercase", kind: " HTTP-RAW ", want: true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := ingestEnabled(tc.kind); got != tc.want {
t.Fatalf("ingestEnabled(%q)=%v want %v", tc.kind, got, tc.want)
}
})
}
}

func TestTxBridgeExportsQueueStats(t *testing.T) {
cfg := cfgpkg.Default()
driver := platform.NewSimulatedDriver(nil)


+ 427
- 416
docs/API.md Переглянути файл

@@ -1,416 +1,427 @@
# 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. Only populated when TX is active.
**Response:**
```json
{
"engine": {
"state": "running",
"runtimeStateDurationSeconds": 12.4,
"appliedFrequencyMHz": 100.0,
"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
},
"controlAudit": {
"methodNotAllowed": 0,
"unsupportedMediaType": 0,
"bodyTooLarge": 0,
"unexpectedBody": 0
}
}
```
`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.
---
### `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. Requests larger than 512 MiB are rejected with `413 Request Entity Too Large`.
**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`)
- `413` if the upload body exceeds the 512 MiB limit
- `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.
# 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`.

**Response:**
```json
{
"engine": {
"state": "running",
"runtimeStateDurationSeconds": 12.4,
"appliedFrequencyMHz": 100.0,
"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
},
"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.


---

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

+ 1097
- 0
docs/audio-ingest-implementation-plan.md
Різницю між файлами не показано, бо вона завелика
Переглянути файл


+ 296
- 0
docs/audio-ingest-rework.md Переглянути файл

@@ -0,0 +1,296 @@
# Audio Ingest Rework

## Hinweis zum Stand (2026-04-07)
Dieses Dokument beschreibt das Zielbild. Der aktuelle Ist-Stand in Phase 1 ist:
- shared ingest runtime + unified source factory sind implementiert
- `stdin`, `http-raw`, `icecast` Adapter sind implementiert
- Icecast Decoder-Layer + ffmpeg fallback sind implementiert
- native Decoder `mp3` / `oggvorbis` / `aac` sind noch Platzhalter
- funktionaler Decode-Pfad heute: ffmpeg fallback

## Ziel
`fm-rds-tx` soll mittelfristig mehrere Audio-Ingest-Pfade sauber unterstützen, ohne den bestehenden `ffmpeg`-Pfad kaputt zu machen.

## Einordnung des Phase-1-Ergebnisses
Mit Phase 1 wurde die Audio-Zuführung erstmals als eigenständiges Subsystem vor den bestehenden TX-/DSP-Pfad gezogen.
Die bestehende Sendekette bleibt weitgehend unangetastet; neue Ingest-Quellen laufen stattdessen über eine gemeinsame Runtime-Schicht, die Lifecycle, Formatwandlung und Basistelemetrie bündelt und weiterhin in den bestehenden `audio.StreamSource` einspeist.

Konkret umfasst dieser Stand:
- gemeinsame Ingest-Runtime
- zentrale Source-Auswahl für `stdin`, `http-raw` und `icecast`
- Umstellung von `/audio/stream` auf den Ingest-Pfad
- Runtime-/Source-Stats im Control-Plane-Output
- Icecast-Adapter mit Reconnect-/Decoder-Policy
- Decoder-Layer mit explizitem Fallback-Pfad

Wichtig ist die ehrliche Abgrenzung:
Die Decoder-Architektur ist vorhanden, aber native Decoder für `mp3`, `oggvorbis` und `aac` sind aktuell noch Platzhalter. Praktisch funktionsfähig ist der Icecast-Pfad derzeit vor allem über den expliziten `ffmpeg`-Fallback. Das ist für Phase 1 akzeptabel, weil die strukturelle Trennung jetzt sauber steht und spätere native Decoder nicht mehr die Runtime-Architektur verbiegen müssen.

## Warum das ein sinnvoller Abschluss von Phase 1 ist
Phase 1 hatte nicht das Ziel, sämtliche Transport- und Codecvarianten produktionsreif abzuschliessen. Ziel war vielmehr, die bisher punktuellen Audio-Eingänge in ein gemeinsames, erweiterbares Modell zu überführen. Genau das ist erreicht:
- die TX-Schicht kennt keine Source-Familien mehr direkt
- HTTP- und CLI-Ingest hängen nicht mehr als Sonderfälle im Startcode
- Icecast ist als echter Source-Typ modelliert
- Decoder-Fallback ist explizit statt implizit
- die Control Plane kann Ingest-Zustand sichtbar machen

## Nächster sinnvoller Schritt
Der nächste Block ist nicht noch mehr Runtime-Umbau, sondern gezielte inhaltliche Vervollständigung:
1. echte native Decoder für MP3 und Ogg/Vorbis
2. danach AAC/ADTS, sofern Bibliothekslage und Streaming-Verhalten sauber genug sind
3. erst danach zusätzliche Familien wie AoIP/SRT in die gemeinsame Runtime ziehen

Die strategische Richtung ist daher **nicht** „ffmpeg sofort ersetzen“, sondern:

- bestehenden `ffmpeg`-Pfad als universellen Fallback behalten
- native Ingest-Familien daneben aufbauen
- alle Pfade auf eine gemeinsame interne PCM-/Audio-Source-Abstraktion führen
- neue native Pfade schrittweise produktionsreif machen

## Leitprinzipien
1. **Kein Big-Bang-Rewrite** – Bestehendes bleibt lauffähig.
2. **Native Pfade zuerst dort, wo sie klaren Mehrwert bringen**.
3. **Go-Libraries bevorzugen** – Decoder/Protocol-Handling einkaufen statt neu erfinden.
4. **Ein gemeinsames Ingest-Modell** – unabhängig von Quelle oder Protokoll.
5. **Control Plane / Runtime / Telemetrie von Decoder-Details trennen**.

## Zielbild: drei Ingest-Familien

### 1. FFmpeg Family
Bestehender universeller Adapter.

**Rolle:**
- Fallback
- Legacy-Kompatibilität
- exotische oder seltene Formate
- schneller pragmatischer Pfad für Quellen, die nativ noch nicht unterstützt werden

**Wichtig:**
- bleibt vorerst erhalten
- wird nicht „rausoptimiert“, sondern architektonisch nachrangig
- sollte in der Runtime als eigener Ingest-Typ sichtbar sein

### 2. AoIP Family
Für professionelle / broadcast-nahe Audioquellen.

**Ziel-Protokolle / Modi:**
- RTP multicast
- AES67-lite
- SDP
- SAP
- später: NMOS IS-04 / IS-05
- später: SRT framed PCM

**Basis:**
- `aoiprxkit`

**Rolle:**
- deterministische LAN-Audiozuführung
- Broadcast-/AoIP-Umgebungen
- spätere professionelle Discovery/Activation

### 3. Streaming Family
Für klassische Internet-/HTTP-/Radio-Streamingquellen.

**Ziel-Protokolle / Modi:**
- HTTP audio streams
- Icecast / Shoutcast
- ICY metadata
- MP3
- AAC / HE-AAC (je nach verfügbarer Lib)
- später ggf. Opus

**Rolle:**
- Webradio / Online-Streams
- Metadatenübernahme
- native Alternative zu `ffmpeg` für die häufigsten Streaming-Fälle

**Wichtig:**
Diese Familie sollte **nicht** in `aoiprxkit` gepresst werden. AoIP und Streaming sind konzeptionell verschieden genug, dass getrennte Package-Bereiche sinnvoll sind.

## Gemeinsame interne Abstraktion
Alle Ingest-Familien sollen auf dieselbe interne PCM-Einspeisung münden.

### Ziel
Unabhängig davon, ob Samples von:
- `ffmpeg`
- RTP/AES67
- Icecast/MP3
- SRT framed PCM

kommen, soll der Rest der Sende-/RDS-/Runtime-Logik immer dieselbe Audioquelle sehen.

### Grobe Zielverantwortung
Eine Quelle soll idealerweise liefern können:
- PCM-Samples
- Sample-Rate
- Kanalzahl
- Source-Label / Source-Type
- Laufzeitstatus / Health
- Basisstatistiken
- optional Metadaten (z. B. ICY title)

### Wichtige Designregel
**Decoder/Protocol-Layer** und **Sender-Runtime** nicht vermischen.

Das Ingest-System soll:
- Audio empfangen / decodieren / normieren
- Health / Stats liefern
- Audio in die bestehende Audio-Pipeline schieben

Die Sender-Runtime soll:
- Quellen starten/stoppen
- aktive Quelle verwalten
- Fehler/Fallback/Status darstellen
- UI/Control-Plane bedienen

## Einordnung von `aoiprxkit`

## Was `aoiprxkit` heute schon gut abdeckt
- RTP multicast RX
- L24-Decoding
- Jitter/Reorder
- statische SDP-Auswertung
- SAP-Listener
- Stream-Finder per SDP `s=` Name
- Basis-Stats
- Live-Metering
- NMOS-/SRT-Grundgerüst

## Was `aoiprxkit` heute noch nicht vollständig als Produkt ist
- keine voll integrierte `fm-rds-tx`-Runtime-Anbindung
- SRT-Pfad eher Scaffold als fertig produktionsreif
- NMOS eher vorbereitend als vollständig integriert
- noch kein gemeinsames Source-Management mit anderen Ingest-Familien

## Konsequenz
`aoiprxkit` ist **integrationswürdig**, aber aktuell noch eher ein Modul/Baukasten als direktes Hauptsystem.

## Empfohlene Package-/Modul-Richtung in `fm-rds-tx`
Dies ist ein Zielbild, kein harter Sofort-Umbau.

### Kandidaten
- `internal/audioingest`
- gemeinsame Interfaces / gemeinsame Typen / gemeinsame Runtime-Adapter
- `internal/audioingest/ffmpeg`
- bestehender ffmpeg-basierter Pfad
- `internal/audioingest/aoip`
- Adapter zwischen `aoiprxkit` und `fm-rds-tx`
- `internal/audioingest/streaming`
- HTTP/Icecast/Shoutcast/ICY + Decoder-Libs

Optional später:
- `internal/audioingest/shared`
- Resampling, channel mapping, sample normalization, metadata structs

## Konfigurationszielbild
Die Runtime sollte einen expliziten Ingest-Typ kennen.

Beispielhaft:

```yaml
input:
kind: ffmpeg | aoip-rtp | aoip-sap | aoip-srt | stream-http
```

Später können pro Familie Unterstrukturen folgen.

Beispielhaft:

```yaml
input:
kind: aoip-rtp
aoip:
multicastGroup: 239.69.0.1
port: 5004
payloadType: 97
sampleRateHz: 48000
channels: 2
```

oder

```yaml
input:
kind: stream-http
streaming:
url: https://example.org/live.mp3
icyMeta: true
```

## Runtime-Zielbild
Die Runtime sollte Quellen einheitlich behandeln können:
- initialisieren
- starten
- stoppen
- Status abfragen
- Health/Stats lesen
- Audio in denselben bestehenden Ringbuffer / Audio-Input-Pfad drücken

## Telemetrie / UI
Die Control Plane sollte mittelfristig ingest-bezogen sichtbar machen:
- aktiver Ingest-Typ
- Source-Label
- Transport / Codec / Sample-Rate / Channels
- Fehlerzustand
- Puffer-/Jitter-/Underrun-relevante Daten
- optional Metadata (z. B. StreamTitle)

Wichtig ist hier eine Trennung zwischen:
- **Audio ingest health**
- **TX/runtime health**

## Empfohlene Umsetzungsreihenfolge

### Phase 1 – Architektur sauberziehen
- gemeinsames Ingest-Zielbild festziehen
- bestehende Audio-Input-Andockpunkte in `fm-rds-tx` dokumentieren
- entscheiden, welche internen Interfaces nötig sind

### Phase 2 – AoIP MVP
- `aoiprxkit` nicht blind verschieben, sondern zuerst als Adapter anbinden
- erster nativer Ingest-Modus: statischer RTP/AES67-lite Pfad
- PCM-Frames in bestehende Audio-Pipeline einspeisen
- Runtime-/Health-/Status sichtbar machen

### Phase 3 – SDP / SAP Discovery
- statische SDP-Unterstützung
- optional SAP Listener + Session-Auswahl
- Discovery klar von Audio-Transport trennen

### Phase 4 – Streaming MVP
- neuer nativer HTTP/Icecast/Shoutcast-Pfad
- bewährte Go-Libs für Decoder und ICY nutzen
- erstes Ziel: häufige Webradio-Fälle ohne `ffmpeg`

### Phase 5 – Vereinheitlichung / Telemetrie
- gemeinsame Ingest-Stats
- gemeinsame Statusmodelle
- UI/Control-Plane-Integration
- Quellwechsel / Fehlermeldungen / Health States

### Phase 6 – Erweiterte Pfade
- SRT sauber produktionsfähig machen
- NMOS weiter integrieren
- später ggf. Opus / weitere Streaming-Codecs

## Was explizit vermieden werden soll
- `ffmpeg` sofort herausreissen
- AoIP und Web-Streaming in denselben unscharfen Package-Topf werfen
- Decoder / Demux / Protocol-Layer unnötig selbst neu bauen
- Discovery-Logik eng mit der PCM-Pipeline verheiraten
- UI bauen, bevor Runtime-Modelle sauber stehen

## Erste konkrete Bauschritte ab jetzt
1. bestehenden Audio-Input-Pfad in `fm-rds-tx` analysieren
2. kleinste gemeinsame Ingest-Abstraktion definieren
3. `aoiprxkit`-RTP als ersten nativen Adapter integrieren
4. danach Streaming-Familie planen und anbinden

## Kurzfazit
`ffmpeg` bleibt vorerst als nützlicher Universalpfad erhalten.
Die Zukunft liegt aber in zwei nativen Familien:
- **AoIP** für professionelle/broadcast-nahe Zuführung
- **Streaming** für HTTP/Icecast/Shoutcast/ICY + Standardcodecs

Beide sollen sauber über eine gemeinsame interne Audio-Ingest-Schicht in `fm-rds-tx` zusammenlaufen.

+ 69
- 11
docs/config.plutosdr.json Переглянути файл

@@ -1,7 +1,7 @@
{
"audio": {
"inputPath": "",
"gain": 1.0,
"gain": 1,
"toneLeftHz": 400,
"toneRightHz": 2000,
"toneAmplitude": 0.3
@@ -14,31 +14,89 @@
"pty": 0
},
"fm": {
"bs412Enabled": true,
"bs412ThresholdDBr": 0,
"frequencyMHz": 100.0,
"frequencyMHz": 102.8,
"stereoEnabled": true,
"pilotLevel": 0.09,
"rdsInjection": 0.04,
"preEmphasisTauUS": 50,
"outputDrive": 1.0,
"mpxGain": 1.0,
"outputDrive": 1,
"compositeRateHz": 228000,
"maxDeviationHz": 75000,
"limiterEnabled": true,
"limiterCeiling": 1.0,
"fmModulationEnabled": true
"limiterCeiling": 1,
"fmModulationEnabled": true,
"mpxGain": 1,
"bs412Enabled": true,
"bs412ThresholdDBr": 0
},
"backend": {
"kind": "pluto",
"device": "usb:",
"driver": "",
"uri": "",
"deviceArgs": {},
"outputPath": "",
"deviceSampleRateHz": 2280000
},
"control": {
"listenAddress": "127.0.0.1:8088"
},
"runtime": {
"frameQueueCapacity": 3
},
"ingest": {
"kind": "icecast",
"prebufferMs": 1500,
"stallTimeoutMs": 3000,
"reconnect": {
"enabled": true,
"initialBackoffMs": 1000,
"maxBackoffMs": 15000
},
"stdin": {
"sampleRateHz": 44100,
"channels": 2,
"format": "s16le"
},
"httpRaw": {
"sampleRateHz": 44100,
"channels": 2,
"format": "s16le"
},
"icecast": {
"url": "http://192.168.1.40:8000/stream",
"decoder": "native",
"radioText": {
"enabled": true,
"prefix": "",
"maxLen": 64,
"onlyOnChange": true
}
},
"srt": {
"url": "",
"mode": "listener",
"sampleRateHz": 48000,
"channels": 2
},
"aes67": {
"sdpPath": "",
"sdp": "",
"discovery": {
"enabled": false,
"streamName": "",
"timeoutMs": 3000,
"interfaceName": "",
"sapGroup": "",
"sapPort": 0
},
"multicastGroup": "",
"port": 0,
"interfaceName": "",
"payloadType": 97,
"sampleRateHz": 48000,
"channels": 2,
"encoding": "L24",
"packetTimeMs": 1,
"jitterDepthPackets": 8,
"readBufferBytes": 1048576
}
}
}

+ 27
- 0
docs/config.sample.json Переглянути файл

@@ -34,5 +34,32 @@
},
"control": {
"listenAddress": "127.0.0.1:8088"
},
"runtime": {
"frameQueueCapacity": 3
},
"ingest": {
"kind": "none",
"prebufferMs": 1500,
"stallTimeoutMs": 3000,
"reconnect": {
"enabled": true,
"initialBackoffMs": 1000,
"maxBackoffMs": 15000
},
"stdin": {
"sampleRateHz": 44100,
"channels": 2,
"format": "s16le"
},
"httpRaw": {
"sampleRateHz": 44100,
"channels": 2,
"format": "s16le"
},
"icecast": {
"url": "",
"decoder": "auto"
}
}
}

+ 9
- 0
go.mod Переглянути файл

@@ -4,4 +4,13 @@ go 1.22

require github.com/jan/fm-rds-tx/internal v0.0.0

require (
aoiprxkit v0.0.0 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
)

replace github.com/jan/fm-rds-tx/internal => ./internal

replace aoiprxkit => ./aoiprxkit

+ 8
- 0
go.sum Переглянути файл

@@ -0,0 +1,8 @@
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

+ 54
- 6
internal/app/engine.go Переглянути файл

@@ -88,6 +88,8 @@ type EngineStats struct {
RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"`
RuntimeAlert string `json:"runtimeAlert,omitempty"`
AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"`
ActivePS string `json:"activePS,omitempty"`
ActiveRadioText string `json:"activeRadioText,omitempty"`
LastFault *FaultEvent `json:"lastFault,omitempty"`
DegradedTransitions uint64 `json:"degradedTransitions"`
MutedTransitions uint64 `json:"mutedTransitions"`
@@ -113,13 +115,14 @@ type RuntimeTransition struct {
}

const (
lateBufferIndicatorWindow = 5 * time.Second
writeLateTolerance = 1 * time.Millisecond
lateBufferIndicatorWindow = 2 * time.Second
writeLateTolerance = 10 * time.Millisecond
queueCriticalStreakThreshold = 3
queueMutedStreakThreshold = queueCriticalStreakThreshold * 2
queueMutedRecoveryThreshold = queueCriticalStreakThreshold
queueFaultedStreakThreshold = queueCriticalStreakThreshold
faultRepeatWindow = 1 * time.Second
lateBufferStreakThreshold = 3 // consecutive late writes required before alerting
faultHistoryCapacity = 8
runtimeTransitionHistoryCapacity = 8
)
@@ -150,6 +153,7 @@ type Engine struct {
underruns atomic.Uint64
lateBuffers atomic.Uint64
lateBufferAlertAt atomic.Uint64
lateBufferStreak atomic.Uint64 // consecutive late writes; reset on clean write
criticalStreak atomic.Uint64
mutedRecoveryStreak atomic.Uint64
mutedFaultStreak atomic.Uint64
@@ -192,7 +196,7 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) {
}
resampler := audio.NewStreamResampler(src, compositeRate)
e.generator.SetExternalSource(resampler)
log.Printf("engine: live audio stream — %d Hz → %.0f Hz (buffer %d frames)",
log.Printf("engine: live audio stream wired initial %d Hz → %.0f Hz composite (buffer %d frames); actual decoded rate auto-corrects on first chunk",
src.SampleRate, compositeRate, src.Stats().Capacity)
}

@@ -273,6 +277,11 @@ type LiveConfigUpdate struct {
LimiterCeiling *float64
PS *string
RadioText *string
// Tone and gain: live-patchable without engine restart.
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
}

// UpdateConfig applies live parameter changes without restarting the engine.
@@ -306,6 +315,16 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error {
return fmt.Errorf("limiterCeiling out of range (0-2)")
}
}
if u.ToneAmplitude != nil {
if *u.ToneAmplitude < 0 || *u.ToneAmplitude > 1 {
return fmt.Errorf("toneAmplitude out of range (0-1)")
}
}
if u.AudioGain != nil {
if *u.AudioGain < 0 || *u.AudioGain > 4 {
return fmt.Errorf("audioGain out of range (0-4)")
}
}

// --- Frequency: store for run loop to apply via driver.Tune() ---
if u.FrequencyMHz != nil {
@@ -353,6 +372,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error {
if u.LimiterCeiling != nil {
next.LimiterCeiling = *u.LimiterCeiling
}
if u.ToneLeftHz != nil {
next.ToneLeftHz = *u.ToneLeftHz
}
if u.ToneRightHz != nil {
next.ToneRightHz = *u.ToneRightHz
}
if u.ToneAmplitude != nil {
next.ToneAmplitude = *u.ToneAmplitude
}
if u.AudioGain != nil {
next.AudioGain = *u.AudioGain
}

e.generator.UpdateLive(next)
return nil
@@ -427,6 +458,10 @@ func (e *Engine) Stats() EngineStats {
hasRecentLateBuffers := e.hasRecentLateBuffers()
ri := runtimeIndicator(queue.Health, hasRecentLateBuffers)
lastFault := e.lastFaultEvent()
activePS, activeRT := "", ""
if enc := e.generator.RDSEncoder(); enc != nil {
activePS, activeRT = enc.CurrentText()
}
return EngineStats{
State: string(e.currentRuntimeState()),
RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(),
@@ -446,6 +481,8 @@ func (e *Engine) Stats() EngineStats {
RuntimeIndicator: ri,
RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers),
AppliedFrequencyMHz: e.appliedFrequencyMHz(),
ActivePS: activePS,
ActiveRadioText: activeRT,
LastFault: lastFault,
DegradedTransitions: e.degradedTransitions.Load(),
MutedTransitions: e.mutedTransitions.Load(),
@@ -604,12 +641,23 @@ func (e *Engine) writerLoop(ctx context.Context) {

lateOver := writeDur - e.chunkDuration
if lateOver > writeLateTolerance {
streak := e.lateBufferStreak.Add(1)
late := e.lateBuffers.Add(1)
e.lateBufferAlertAt.Store(uint64(time.Now().UnixNano()))
// Only arm the alert window once the streak threshold is reached.
// Isolated OS-scheduling or USB jitter spikes (single late writes)
// are normal on a loaded system and must not trigger degraded state.
// This mirrors the queue-health streak logic.
if streak >= lateBufferStreakThreshold {
e.lateBufferAlertAt.Store(uint64(time.Now().UnixNano()))
}
if late <= 5 || late%20 == 0 {
log.Printf("TX LATE: write=%s budget=%s over=%s tolerance=%s queueResidence=%s pipeline=%s",
writeDur, e.chunkDuration, lateOver, writeLateTolerance, queueResidence, pipelineLatency)
log.Printf("TX LATE [streak=%d]: write=%s budget=%s over=%s tolerance=%s queueResidence=%s pipeline=%s",
streak, writeDur, e.chunkDuration, lateOver, writeLateTolerance, queueResidence, pipelineLatency)
}
} else {
// Clean write — reset the consecutive streak so isolated spikes
// never accumulate toward the threshold.
e.lateBufferStreak.Store(0)
}

if err != nil {


+ 88
- 38
internal/audio/stream.go Переглянути файл

@@ -12,19 +12,31 @@ import (
// goroutine reads them via NextFrame(). Returns silence on underrun.
//
// Zero allocations in steady state. No mutex in the read or write path.
//
// SampleRate is the nominal input sample rate. It may be updated at runtime
// via SetSampleRate once the actual decoded rate is known (e.g. when the first
// PCM chunk arrives from a compressed stream). Reads and writes to the sample
// rate are atomic so they are safe across goroutines.
type StreamSource struct {
ring []Frame
size int
mask int // size-1, for fast modulo (size must be power of 2)
ring []Frame
size int
mask int // size-1, for fast modulo (size must be power of 2)

// SampleRate is kept as a plain int for backward compatibility with code
// that reads it before any goroutine races are possible (construction,
// logging). All hot-path code uses the atomic below.
SampleRate int

sampleRateAtomic atomic.Int32

writePos atomic.Int64
readPos atomic.Int64

Underruns atomic.Uint64
Overflows atomic.Uint64
Written atomic.Uint64
highWatermark atomic.Int64

highWatermark atomic.Int64
underrunStreak atomic.Uint64
maxUnderrunStreak atomic.Uint64
}
@@ -37,12 +49,29 @@ func NewStreamSource(capacity, sampleRate int) *StreamSource {
for size < capacity {
size <<= 1
}
return &StreamSource{
s := &StreamSource{
ring: make([]Frame, size),
size: size,
mask: size - 1,
SampleRate: sampleRate,
}
s.sampleRateAtomic.Store(int32(sampleRate))
return s
}

// SetSampleRate updates the sample rate atomically. Safe to call from any
// goroutine, including while the DSP goroutine is consuming frames via
// StreamResampler. The change takes effect on the very next NextFrame() call.
// Also updates the public SampleRate field for non-concurrent readers.
func (s *StreamSource) SetSampleRate(hz int) {
s.SampleRate = hz
s.sampleRateAtomic.Store(int32(hz))
}

// GetSampleRate returns the current sample rate via atomic load. Use this
// in hot paths / cross-goroutine reads instead of .SampleRate directly.
func (s *StreamSource) GetSampleRate() int {
return int(s.sampleRateAtomic.Load())
}

// WriteFrame pushes a single frame into the ring buffer.
@@ -124,40 +153,41 @@ func (s *StreamSource) Stats() StreamStats {
currentStreak := int(s.underrunStreak.Load())
maxStreak := int(s.maxUnderrunStreak.Load())
return StreamStats{
Available: available,
Capacity: s.size,
Buffered: buffered,
BufferedDurationSeconds: s.bufferedDurationSeconds(available),
HighWatermark: highWatermark,
Available: available,
Capacity: s.size,
Buffered: buffered,
BufferedDurationSeconds: s.bufferedDurationSeconds(available),
HighWatermark: highWatermark,
HighWatermarkDurationSeconds: s.bufferedDurationSeconds(highWatermark),
Written: s.Written.Load(),
Underruns: s.Underruns.Load(),
Overflows: s.Overflows.Load(),
UnderrunStreak: currentStreak,
MaxUnderrunStreak: maxStreak,
Written: s.Written.Load(),
Underruns: s.Underruns.Load(),
Overflows: s.Overflows.Load(),
UnderrunStreak: currentStreak,
MaxUnderrunStreak: maxStreak,
}
}

// StreamStats exposes runtime telemetry for the stream buffer.
type StreamStats struct {
Available int `json:"available"`
Capacity int `json:"capacity"`
Buffered float64 `json:"buffered"`
BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"`
HighWatermark int `json:"highWatermark"`
Available int `json:"available"`
Capacity int `json:"capacity"`
Buffered float64 `json:"buffered"`
BufferedDurationSeconds float64 `json:"bufferedDurationSeconds"`
HighWatermark int `json:"highWatermark"`
HighWatermarkDurationSeconds float64 `json:"highWatermarkDurationSeconds"`
Written uint64 `json:"written"`
Underruns uint64 `json:"underruns"`
Overflows uint64 `json:"overflows"`
UnderrunStreak int `json:"underrunStreak"`
MaxUnderrunStreak int `json:"maxUnderrunStreak"`
Written uint64 `json:"written"`
Underruns uint64 `json:"underruns"`
Overflows uint64 `json:"overflows"`
UnderrunStreak int `json:"underrunStreak"`
MaxUnderrunStreak int `json:"maxUnderrunStreak"`
}

func (s *StreamSource) bufferedDurationSeconds(available int) float64 {
if s.SampleRate <= 0 {
rate := s.GetSampleRate()
if rate <= 0 {
return 0
}
return float64(available) / float64(s.SampleRate)
return float64(available) / float64(rate)
}

func (s *StreamSource) updateHighWatermark() {
@@ -195,33 +225,53 @@ func (s *StreamSource) resetUnderrunStreak() {
// StreamResampler wraps a StreamSource and rate-converts from the stream's
// native sample rate to the target output rate using linear interpolation.
// Consumes input frames on demand — no buffering beyond the ring buffer.
//
// The input rate is read atomically from src on every NextFrame() call so
// that a SetSampleRate() from the ingest goroutine takes effect immediately,
// without any additional synchronisation. The pos accumulator is not reset
// on a rate change: this may produce a single glitch-free transient at the
// moment the rate is corrected, which is far preferable to playing the whole
// stream at the wrong pitch.
type StreamResampler struct {
src *StreamSource
ratio float64 // inputRate / outputRate (< 1 when upsampling)
pos float64
prev Frame
curr Frame
src *StreamSource
outputRate float64 // target composite rate, fixed for the lifetime of the resampler
pos float64
prev Frame
curr Frame
}

// NewStreamResampler creates a streaming resampler.
// outputRate is the fixed DSP composite rate. The input rate is taken from
// src.GetSampleRate() dynamically, so it will automatically track any
// subsequent SetSampleRate() call.
func NewStreamResampler(src *StreamSource, outputRate float64) *StreamResampler {
if src == nil || outputRate <= 0 || src.SampleRate <= 0 {
return &StreamResampler{src: src, ratio: 1.0}
if src == nil || outputRate <= 0 {
return &StreamResampler{src: src, outputRate: outputRate}
}
return &StreamResampler{
src: src,
ratio: float64(src.SampleRate) / outputRate,
src: src,
outputRate: outputRate,
}
}

// NextFrame returns the next interpolated frame at the output rate.
// Implements the frameSource interface.
// The input/output ratio is recomputed on every call from the atomic sample
// rate so that runtime rate corrections via SetSampleRate are race-free.
func (r *StreamResampler) NextFrame() Frame {
if r.src == nil {
return NewFrame(0, 0)
}

// Consume input samples as the fractional position advances
// Compute ratio atomically so we see any SetSampleRate update immediately.
ratio := 1.0
if r.outputRate > 0 {
if inputRate := r.src.GetSampleRate(); inputRate > 0 {
ratio = float64(inputRate) / r.outputRate
}
}

// Consume input samples as the fractional position advances.
for r.pos >= 1.0 {
r.prev = r.curr
r.curr = r.src.ReadFrame()
@@ -231,7 +281,7 @@ func (r *StreamResampler) NextFrame() Frame {
frac := r.pos
l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac
ri := float64(r.prev.R)*(1-frac) + float64(r.curr.R)*frac
r.pos += r.ratio
r.pos += ratio
return NewFrame(Sample(l), Sample(ri))
}



+ 240
- 3
internal/config/config.go Переглянути файл

@@ -15,6 +15,7 @@ type Config struct {
Backend BackendConfig `json:"backend"`
Control ControlConfig `json:"control"`
Runtime RuntimeConfig `json:"runtime"`
Ingest IngestConfig `json:"ingest"`
}

type AudioConfig struct {
@@ -68,6 +69,75 @@ type RuntimeConfig struct {
FrameQueueCapacity int `json:"frameQueueCapacity"`
}

type IngestConfig struct {
Kind string `json:"kind"`
PrebufferMs int `json:"prebufferMs"`
StallTimeoutMs int `json:"stallTimeoutMs"`
Reconnect IngestReconnectConfig `json:"reconnect"`
Stdin IngestPCMConfig `json:"stdin"`
HTTPRaw IngestPCMConfig `json:"httpRaw"`
Icecast IngestIcecastConfig `json:"icecast"`
SRT IngestSRTConfig `json:"srt"`
AES67 IngestAES67Config `json:"aes67"`
}

type IngestReconnectConfig struct {
Enabled bool `json:"enabled"`
InitialBackoffMs int `json:"initialBackoffMs"`
MaxBackoffMs int `json:"maxBackoffMs"`
}

type IngestPCMConfig struct {
SampleRateHz int `json:"sampleRateHz"`
Channels int `json:"channels"`
Format string `json:"format"`
}

type IngestIcecastConfig struct {
URL string `json:"url"`
Decoder string `json:"decoder"`
RadioText IngestIcecastRadioTextConfig `json:"radioText"`
}

type IngestIcecastRadioTextConfig struct {
Enabled bool `json:"enabled"`
Prefix string `json:"prefix"`
MaxLen int `json:"maxLen"`
OnlyOnChange bool `json:"onlyOnChange"`
}

type IngestSRTConfig struct {
URL string `json:"url"`
Mode string `json:"mode"`
SampleRateHz int `json:"sampleRateHz"`
Channels int `json:"channels"`
}

type IngestAES67Config struct {
SDPPath string `json:"sdpPath"`
SDP string `json:"sdp"`
Discovery IngestAES67DiscoveryConfig `json:"discovery"`
MulticastGroup string `json:"multicastGroup"`
Port int `json:"port"`
InterfaceName string `json:"interfaceName"`
PayloadType int `json:"payloadType"`
SampleRateHz int `json:"sampleRateHz"`
Channels int `json:"channels"`
Encoding string `json:"encoding"`
PacketTimeMs int `json:"packetTimeMs"`
JitterDepthPackets int `json:"jitterDepthPackets"`
ReadBufferBytes int `json:"readBufferBytes"`
}

type IngestAES67DiscoveryConfig struct {
Enabled bool `json:"enabled"`
StreamName string `json:"streamName"`
TimeoutMs int `json:"timeoutMs"`
InterfaceName string `json:"interfaceName"`
SAPGroup string `json:"sapGroup"`
SAPPort int `json:"sapPort"`
}

func Default() Config {
return Config{
Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
@@ -89,6 +159,51 @@ func Default() Config {
Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
Runtime: RuntimeConfig{FrameQueueCapacity: 3},
Ingest: IngestConfig{
Kind: "none",
PrebufferMs: 1500,
StallTimeoutMs: 3000,
Reconnect: IngestReconnectConfig{
Enabled: true,
InitialBackoffMs: 1000,
MaxBackoffMs: 15000,
},
Stdin: IngestPCMConfig{
SampleRateHz: 44100,
Channels: 2,
Format: "s16le",
},
HTTPRaw: IngestPCMConfig{
SampleRateHz: 44100,
Channels: 2,
Format: "s16le",
},
Icecast: IngestIcecastConfig{
Decoder: "auto",
RadioText: IngestIcecastRadioTextConfig{
Enabled: false,
MaxLen: 64,
OnlyOnChange: true,
},
},
SRT: IngestSRTConfig{
Mode: "listener",
SampleRateHz: 48000,
Channels: 2,
},
AES67: IngestAES67Config{
Discovery: IngestAES67DiscoveryConfig{
TimeoutMs: 3000,
},
PayloadType: 97,
SampleRateHz: 48000,
Channels: 2,
Encoding: "L24",
PacketTimeMs: 1,
JitterDepthPackets: 8,
ReadBufferBytes: 1 << 20,
},
},
}
}

@@ -122,6 +237,21 @@ func Load(path string) (Config, error) {
return cfg, cfg.Validate()
}

func Save(path string, cfg Config) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("config path is required")
}
if err := cfg.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}

func (c Config) Validate() error {
if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
return fmt.Errorf("audio.gain out of range")
@@ -156,9 +286,6 @@ func (c Config) Validate() error {
if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
return fmt.Errorf("fm.limiterCeiling out of range")
}
if c.FM.MpxGain == 0 {
c.FM.MpxGain = 1.0
} // default if omitted from JSON
if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 {
return fmt.Errorf("fm.mpxGain out of range (0.1..5)")
}
@@ -174,6 +301,116 @@ func (c Config) Validate() error {
if c.Runtime.FrameQueueCapacity <= 0 {
return fmt.Errorf("runtime.frameQueueCapacity must be > 0")
}
if c.Ingest.Kind == "" {
c.Ingest.Kind = "none"
}
ingestKind := strings.ToLower(strings.TrimSpace(c.Ingest.Kind))
switch ingestKind {
case "none", "stdin", "stdin-pcm", "http-raw", "icecast", "srt", "aes67", "aoip", "aoip-rtp":
default:
return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind)
}
if c.Ingest.PrebufferMs < 0 {
return fmt.Errorf("ingest.prebufferMs must be >= 0")
}
if c.Ingest.StallTimeoutMs < 0 {
return fmt.Errorf("ingest.stallTimeoutMs must be >= 0")
}
if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 {
return fmt.Errorf("ingest.reconnect backoff must be >= 0")
}
if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 {
return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled")
}
if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 {
return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled")
}
if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs {
return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs")
}
if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 {
return fmt.Errorf("ingest pcm sampleRateHz must be > 0")
}
if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) {
return fmt.Errorf("ingest pcm channels must be 1 or 2")
}
if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" {
return fmt.Errorf("ingest pcm format must be s16le")
}
if ingestKind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" {
return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast")
}
if ingestKind == "srt" && strings.TrimSpace(c.Ingest.SRT.URL) == "" {
return fmt.Errorf("ingest.srt.url is required when ingest.kind=srt")
}
if ingestKind == "aes67" || ingestKind == "aoip" || ingestKind == "aoip-rtp" {
hasSDP := strings.TrimSpace(c.Ingest.AES67.SDP) != ""
hasSDPPath := strings.TrimSpace(c.Ingest.AES67.SDPPath) != ""
discoveryEnabled := c.Ingest.AES67.Discovery.Enabled || strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) != ""
if hasSDP && hasSDPPath {
return fmt.Errorf("ingest.aes67.sdp and ingest.aes67.sdpPath are mutually exclusive")
}
if !hasSDP && !hasSDPPath {
if strings.TrimSpace(c.Ingest.AES67.MulticastGroup) == "" && !discoveryEnabled {
return fmt.Errorf("ingest.aes67.multicastGroup is required when ingest.kind=%s", ingestKind)
}
if (c.Ingest.AES67.Port <= 0 || c.Ingest.AES67.Port > 65535) && !discoveryEnabled {
return fmt.Errorf("ingest.aes67.port must be 1..65535")
}
}
if c.Ingest.AES67.Discovery.TimeoutMs < 0 {
return fmt.Errorf("ingest.aes67.discovery.timeoutMs must be >= 0")
}
if c.Ingest.AES67.Discovery.SAPPort < 0 || c.Ingest.AES67.Discovery.SAPPort > 65535 {
return fmt.Errorf("ingest.aes67.discovery.sapPort must be 0..65535")
}
if discoveryEnabled && strings.TrimSpace(c.Ingest.AES67.Discovery.StreamName) == "" {
return fmt.Errorf("ingest.aes67.discovery.streamName is required when discovery is enabled")
}
if discoveryEnabled && c.Ingest.AES67.Port > 65535 {
return fmt.Errorf("ingest.aes67.port must be 1..65535")
}
if c.Ingest.AES67.PayloadType < 0 || c.Ingest.AES67.PayloadType > 127 {
return fmt.Errorf("ingest.aes67.payloadType must be 0..127")
}
if c.Ingest.AES67.SampleRateHz <= 0 {
return fmt.Errorf("ingest.aes67.sampleRateHz must be > 0")
}
if c.Ingest.AES67.Channels != 1 && c.Ingest.AES67.Channels != 2 {
return fmt.Errorf("ingest.aes67.channels must be 1 or 2")
}
if strings.ToUpper(strings.TrimSpace(c.Ingest.AES67.Encoding)) != "L24" {
return fmt.Errorf("ingest.aes67.encoding must be L24")
}
if c.Ingest.AES67.PacketTimeMs <= 0 {
return fmt.Errorf("ingest.aes67.packetTimeMs must be > 0")
}
if c.Ingest.AES67.JitterDepthPackets < 1 {
return fmt.Errorf("ingest.aes67.jitterDepthPackets must be >= 1")
}
if c.Ingest.AES67.ReadBufferBytes < 0 {
return fmt.Errorf("ingest.aes67.readBufferBytes must be >= 0")
}
}
switch strings.ToLower(strings.TrimSpace(c.Ingest.SRT.Mode)) {
case "", "listener", "caller", "rendezvous":
default:
return fmt.Errorf("ingest.srt.mode unsupported: %s", c.Ingest.SRT.Mode)
}
if c.Ingest.SRT.SampleRateHz <= 0 {
return fmt.Errorf("ingest.srt.sampleRateHz must be > 0")
}
if c.Ingest.SRT.Channels != 1 && c.Ingest.SRT.Channels != 2 {
return fmt.Errorf("ingest.srt.channels must be 1 or 2")
}
switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) {
case "", "auto", "native", "ffmpeg", "fallback":
default:
return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder)
}
if c.Ingest.Icecast.RadioText.MaxLen < 0 || c.Ingest.Icecast.RadioText.MaxLen > 64 {
return fmt.Errorf("ingest.icecast.radioText.maxLen out of range (0-64)")
}
// Fail-loud PI validation
if c.RDS.Enabled {
if _, err := ParsePI(c.RDS.PI); err != nil {


+ 175
- 0
internal/config/config_test.go Переглянути файл

@@ -123,3 +123,178 @@ func TestEffectiveDeviceRate(t *testing.T) {
t.Fatal("expected 912000")
}
}

func TestValidateRejectsUnsupportedIngestKind(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "unsupported"
if err := cfg.Validate(); err == nil {
t.Fatal("expected error")
}
}

func TestValidateRejectsInvalidSRTConfig(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = ""
if err := cfg.Validate(); err == nil {
t.Fatal("expected srt url error")
}

cfg = Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000"
cfg.Ingest.SRT.Mode = "invalid"
if err := cfg.Validate(); err == nil {
t.Fatal("expected srt mode error")
}

cfg = Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000"
cfg.Ingest.SRT.SampleRateHz = 0
if err := cfg.Validate(); err == nil {
t.Fatal("expected srt sample rate error")
}

cfg = Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000"
cfg.Ingest.SRT.Channels = 3
if err := cfg.Validate(); err == nil {
t.Fatal("expected srt channels error")
}
}

func TestValidateRejectsInvalidAES67Config(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
if err := cfg.Validate(); err == nil {
t.Fatal("expected aes67 multicast group error")
}

cfg = Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = "239.10.20.30"
cfg.Ingest.AES67.Port = 5004
cfg.Ingest.AES67.Encoding = "L16"
if err := cfg.Validate(); err == nil {
t.Fatal("expected aes67 encoding error")
}

cfg = Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = "239.10.20.30"
cfg.Ingest.AES67.Port = 5004
cfg.Ingest.AES67.SDP = "v=0"
cfg.Ingest.AES67.SDPPath = "stream.sdp"
if err := cfg.Validate(); err == nil {
t.Fatal("expected mutually exclusive sdp/sdpPath error")
}
}

func TestValidateAcceptsAES67WithSDPOnly(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.SDP = "v=0\r\ns=demo\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\n"
if err := cfg.Validate(); err != nil {
t.Fatalf("expected aes67 with SDP to validate: %v", err)
}
}

func TestValidateAcceptsAES67WithDiscoveryOnly(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.Port = 0
cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN"
if err := cfg.Validate(); err != nil {
t.Fatalf("expected aes67 discovery config to validate: %v", err)
}
}

func TestValidateRejectsAES67DiscoveryWithoutStreamName(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.Port = 0
cfg.Ingest.AES67.Discovery.Enabled = true
cfg.Ingest.AES67.Discovery.StreamName = ""
if err := cfg.Validate(); err == nil {
t.Fatal("expected discovery streamName validation error")
}
}

func TestValidateRejectsAES67DiscoverySAPPortOutOfRange(t *testing.T) {
cfg := Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.Port = 0
cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN"
cfg.Ingest.AES67.Discovery.SAPPort = 70000
if err := cfg.Validate(); err == nil {
t.Fatal("expected discovery sapPort validation error")
}
}

func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) {
cfg := Default()
cfg.Ingest.Stdin.SampleRateHz = 0
if err := cfg.Validate(); err == nil {
t.Fatal("expected sampleRate error")
}

cfg = Default()
cfg.Ingest.HTTPRaw.Channels = 6
if err := cfg.Validate(); err == nil {
t.Fatal("expected channels error")
}

cfg = Default()
cfg.Ingest.Stdin.Format = "f32le"
if err := cfg.Validate(); err == nil {
t.Fatal("expected format error")
}
}

func TestValidateRejectsUnsupportedIcecastDecoder(t *testing.T) {
cfg := Default()
cfg.Ingest.Icecast.Decoder = "mystery"
if err := cfg.Validate(); err == nil {
t.Fatal("expected decoder error")
}
}

func TestValidateAcceptsIcecastDecoderFallbackAlias(t *testing.T) {
cfg := Default()
cfg.Ingest.Icecast.Decoder = "fallback"
if err := cfg.Validate(); err != nil {
t.Fatalf("expected fallback alias to be accepted: %v", err)
}
}

func TestValidateRejectsIcecastRadioTextMaxLenOutOfRange(t *testing.T) {
cfg := Default()
cfg.Ingest.Icecast.RadioText.MaxLen = 65
if err := cfg.Validate(); err == nil {
t.Fatal("expected maxLen error")
}
}

func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) {
cfg := Default()
cfg.Ingest.Reconnect.Enabled = true
cfg.Ingest.Reconnect.InitialBackoffMs = 0
if err := cfg.Validate(); err == nil {
t.Fatal("expected reconnect backoff error")
}
}

func TestValidateRejectsZeroMpxGain(t *testing.T) {
cfg := Default()
cfg.FM.MpxGain = 0
if err := cfg.Validate(); err == nil {
t.Fatal("expected mpxGain error")
}
}

+ 220
- 47
internal/control/control.go Переглянути файл

@@ -10,10 +10,12 @@ import (
"strings"
"sync"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/audio"
"github.com/jan/fm-rds-tx/internal/config"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/platform"
)

@@ -43,15 +45,31 @@ type LivePatch struct {
LimiterCeiling *float64
PS *string
RadioText *string
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
}

type Server struct {
mu sync.RWMutex
cfg config.Config
tx TXController
drv platform.SoapyDriver // optional, for runtime stats
streamSrc *audio.StreamSource // optional, for live audio ingest
audit auditCounters
mu sync.RWMutex
cfg config.Config
tx TXController
drv platform.SoapyDriver // optional, for runtime stats
streamSrc *audio.StreamSource // optional, for live audio ring stats
audioIngress AudioIngress // optional, for /audio/stream
ingestRt IngestRuntime // optional, for /runtime ingest stats
saveConfig func(config.Config) error
hardReload func()
audit auditCounters
}

type AudioIngress interface {
WritePCM16(data []byte) (int, error)
}

type IngestRuntime interface {
Stats() ingest.Stats
}

type auditEvent string
@@ -98,20 +116,30 @@ func isJSONContentType(r *http.Request) bool {
}

type ConfigPatch struct {
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
StereoEnabled *bool `json:"stereoEnabled,omitempty"`
PilotLevel *float64 `json:"pilotLevel,omitempty"`
RDSInjection *float64 `json:"rdsInjection,omitempty"`
RDSEnabled *bool `json:"rdsEnabled,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
StereoEnabled *bool `json:"stereoEnabled,omitempty"`
PilotLevel *float64 `json:"pilotLevel,omitempty"`
RDSInjection *float64 `json:"rdsInjection,omitempty"`
RDSEnabled *bool `json:"rdsEnabled,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
AudioGain *float64 `json:"audioGain,omitempty"`
PI *string `json:"pi,omitempty"`
PTY *int `json:"pty,omitempty"`
BS412Enabled *bool `json:"bs412Enabled,omitempty"`
BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
MpxGain *float64 `json:"mpxGain,omitempty"`
}

type IngestSaveRequest struct {
Ingest config.IngestConfig `json:"ingest"`
}

func NewServer(cfg config.Config) *Server {
@@ -131,12 +159,15 @@ func hasRequestBody(r *http.Request) bool {
}

func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool {
// Returns true when the request has an unexpected body and the error response
// has already been written — callers should return immediately in that case.
// Returns false when there is no body (happy path — request should proceed).
if !hasRequestBody(r) {
return true
return false
}
s.recordAudit(auditUnexpectedBody)
http.Error(w, noBodyErrMsg, http.StatusBadRequest)
return false
return true
}

func (s *Server) recordAudit(evt auditEvent) {
@@ -196,6 +227,30 @@ func (s *Server) SetStreamSource(src *audio.StreamSource) {
s.mu.Unlock()
}

func (s *Server) SetAudioIngress(ingress AudioIngress) {
s.mu.Lock()
s.audioIngress = ingress
s.mu.Unlock()
}

func (s *Server) SetIngestRuntime(rt IngestRuntime) {
s.mu.Lock()
s.ingestRt = rt
s.mu.Unlock()
}

func (s *Server) SetConfigSaver(save func(config.Config) error) {
s.mu.Lock()
s.saveConfig = save
s.mu.Unlock()
}

func (s *Server) SetHardReload(fn func()) {
s.mu.Lock()
s.hardReload = fn
s.mu.Unlock()
}

func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleUI)
@@ -203,6 +258,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/dry-run", s.handleDryRun)
mux.HandleFunc("/config", s.handleConfig)
mux.HandleFunc("/config/ingest/save", s.handleIngestSave)
mux.HandleFunc("/runtime", s.handleRuntime)
mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
mux.HandleFunc("/tx/start", s.handleTXStart)
@@ -268,6 +324,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
drv := s.drv
tx := s.tx
stream := s.streamSrc
ingestRt := s.ingestRt
s.mu.RUnlock()

result := map[string]any{}
@@ -275,11 +332,16 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
result["driver"] = drv.Stats()
}
if tx != nil {
result["engine"] = tx.TXStats()
if stats := tx.TXStats(); stats != nil {
result["engine"] = stats
}
}
if stream != nil {
result["audioStream"] = stream.Stats()
}
if ingestRt != nil {
result["ingest"] = ingestRt.Stats()
}
result["controlAudit"] = s.auditSnapshot()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
@@ -291,7 +353,7 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.rejectBody(w, r) {
if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
return
}
s.mu.RLock()
@@ -309,10 +371,11 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

// handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes
// it into the live audio ring buffer. Use with:
// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
// handleAudioStream accepts raw S16LE PCM via HTTP POST and pushes
// it into the configured ingest http-raw source. Use with:
//
// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw
// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.recordAudit(auditMethodNotAllowed)
@@ -325,14 +388,22 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
return
}
s.mu.RLock()
stream := s.streamSrc
ingress := s.audioIngress
s.mu.RUnlock()

if stream == nil {
http.Error(w, "audio stream not configured (use --audio-stdin or --audio-http)", http.StatusServiceUnavailable)
if ingress == nil {
http.Error(w, "audio ingest not configured (use --audio-http with ingest runtime)", http.StatusServiceUnavailable)
return
}

// BUG-10 fix: /audio/stream is a long-lived streaming endpoint.
// The global HTTP server ReadTimeout (5s) and WriteTimeout (10s) would
// kill connections mid-stream. Disable them per-request via ResponseController
// (requires Go 1.20+, confirmed Go 1.22).
rc := http.NewResponseController(w)
_ = rc.SetReadDeadline(time.Time{})
_ = rc.SetWriteDeadline(time.Time{})

r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit)

// Read body in chunks and push to ring buffer
@@ -341,7 +412,12 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
for {
n, err := r.Body.Read(buf)
if n > 0 {
totalFrames += stream.WritePCM(buf[:n])
written, writeErr := ingress.WritePCM16(buf[:n])
totalFrames += written
if writeErr != nil {
http.Error(w, writeErr.Error(), http.StatusServiceUnavailable)
return
}
}
if err != nil {
if err == io.EOF {
@@ -362,7 +438,6 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"frames": totalFrames,
"stats": stream.Stats(),
})
}

@@ -372,7 +447,7 @@ func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.rejectBody(w, r) {
if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
return
}
s.mu.RLock()
@@ -396,7 +471,7 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.rejectBody(w, r) {
if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected
return
}
s.mu.RLock()
@@ -406,12 +481,11 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) {
http.Error(w, "tx controller not available", http.StatusServiceUnavailable)
return
}
if err := tx.StopTX(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() {
_ = tx.StopTX()
}()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"})
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"})
}

func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
@@ -466,12 +540,21 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if patch.ToneAmplitude != nil {
next.Audio.ToneAmplitude = *patch.ToneAmplitude
}
if patch.AudioGain != nil {
next.Audio.Gain = *patch.AudioGain
}
if patch.PS != nil {
next.RDS.PS = *patch.PS
}
if patch.RadioText != nil {
next.RDS.RadioText = *patch.RadioText
}
if patch.PI != nil {
next.RDS.PI = *patch.PI
}
if patch.PTY != nil {
next.RDS.PTY = *patch.PTY
}
if patch.PreEmphasisTauUS != nil {
next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS
}
@@ -493,6 +576,15 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
if patch.RDSInjection != nil {
next.FM.RDSInjection = *patch.RDSInjection
}
if patch.BS412Enabled != nil {
next.FM.BS412Enabled = *patch.BS412Enabled
}
if patch.BS412ThresholdDBr != nil {
next.FM.BS412ThresholdDBr = *patch.BS412ThresholdDBr
}
if patch.MpxGain != nil {
next.FM.MpxGain = *patch.MpxGain
}
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -509,21 +601,102 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
LimiterCeiling: patch.LimiterCeiling,
PS: patch.PS,
RadioText: patch.RadioText,
ToneLeftHz: patch.ToneLeftHz,
ToneRightHz: patch.ToneRightHz,
ToneAmplitude: patch.ToneAmplitude,
AudioGain: patch.AudioGain,
}
// NEU-02 fix: determine whether any live-patchable fields are present,
// then release the lock before calling UpdateConfig to avoid holding
// s.mu across a potentially blocking engine call.
tx := s.tx
if tx != nil {
hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil ||
patch.StereoEnabled != nil || patch.PilotLevel != nil ||
patch.RDSInjection != nil || patch.RDSEnabled != nil ||
patch.LimiterEnabled != nil || patch.LimiterCeiling != nil ||
patch.PS != nil || patch.RadioText != nil ||
patch.ToneLeftHz != nil || patch.ToneRightHz != nil ||
patch.ToneAmplitude != nil || patch.AudioGain != nil
s.cfg = next
s.mu.Unlock()
// Apply live fields to running engine outside the lock.
var updateErr error
if tx != nil && hasLiveFields {
if err := tx.UpdateConfig(lp); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
updateErr = err
}
}
s.cfg = next
live := tx != nil
s.mu.Unlock()
if updateErr != nil {
http.Error(w, updateErr.Error(), http.StatusBadRequest)
return
}
// NEU-03 fix: report live=true only when live-patchable fields were applied.
live := tx != nil && hasLiveFields
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

func (s *Server) handleIngestSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.recordAudit(auditMethodNotAllowed)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !isJSONContentType(r) {
s.recordAudit(auditUnsupportedMediaType)
http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxConfigBodyBytes)

var req IngestSaveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
statusCode := http.StatusBadRequest
if strings.Contains(err.Error(), "http: request body too large") {
statusCode = http.StatusRequestEntityTooLarge
s.recordAudit(auditBodyTooLarge)
}
http.Error(w, err.Error(), statusCode)
return
}

s.mu.Lock()
next := s.cfg
next.Ingest = req.Ingest
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
save := s.saveConfig
reload := s.hardReload
if save == nil {
s.mu.Unlock()
http.Error(w, "config save is not configured (start with --config <path>)", http.StatusServiceUnavailable)
return
}
if err := save(next); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.cfg = next
s.mu.Unlock()

w.Header().Set("Content-Type", "application/json")
reloadScheduled := reload != nil
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"saved": true,
"reloadScheduled": reloadScheduled,
})
if reloadScheduled {
go func(fn func()) {
time.Sleep(250 * time.Millisecond)
fn()
}(reload)
}
}

+ 282
- 16
internal/control/control_test.go Переглянути файл

@@ -6,11 +6,14 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/output"
)

@@ -168,6 +171,108 @@ func TestConfigPatchRejectsNonJSONContentType(t *testing.T) {
}
}

func TestIngestSavePersistsAndSchedulesReload(t *testing.T) {
cfg := cfgpkg.Default()
cfg.Ingest.Kind = "icecast"
cfg.Ingest.Icecast.URL = "https://example.invalid/live"
srv := NewServer(cfg)

dir := t.TempDir()
configPath := filepath.Join(dir, "saved.json")
reloadDone := make(chan struct{}, 1)
srv.SetConfigSaver(func(next cfgpkg.Config) error {
return cfgpkg.Save(configPath, next)
})
srv.SetHardReload(func() {
select {
case reloadDone <- struct{}{}:
default:
}
})

nextIngest := cfgpkg.Default().Ingest
nextIngest.Kind = "srt"
nextIngest.PrebufferMs = 1000
nextIngest.StallTimeoutMs = 2500
nextIngest.Reconnect.Enabled = true
nextIngest.Reconnect.InitialBackoffMs = 500
nextIngest.Reconnect.MaxBackoffMs = 5000
nextIngest.SRT.URL = "srt://0.0.0.0:9000?mode=listener"
body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
if rec.Code != http.StatusOK {
t.Fatalf("status: %d body=%s", rec.Code, rec.Body.String())
}
select {
case <-reloadDone:
case <-time.After(2 * time.Second):
t.Fatal("expected hard reload callback")
}
saved, err := cfgpkg.Load(configPath)
if err != nil {
t.Fatalf("load saved config: %v", err)
}
if saved.Ingest.Kind != "srt" {
t.Fatalf("expected saved ingest kind srt, got %q", saved.Ingest.Kind)
}
if saved.Ingest.SRT.URL != "srt://0.0.0.0:9000?mode=listener" {
t.Fatalf("expected saved ingest.srt.url, got %q", saved.Ingest.SRT.URL)
}
}

func TestIngestSaveRejectsWhenSaverMissing(t *testing.T) {
cfg := cfgpkg.Default()
cfg.Ingest.Kind = "icecast"
cfg.Ingest.Icecast.URL = "https://example.invalid/live"
srv := NewServer(cfg)
rec := httptest.NewRecorder()
nextIngest := cfgpkg.Default().Ingest
nextIngest.Kind = "icecast"
nextIngest.Icecast.URL = "https://example.invalid/live"
body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d body=%s", rec.Code, rec.Body.String())
}
}

func TestIngestSaveUsesValidationErrors(t *testing.T) {
cfg := cfgpkg.Default()
cfg.Ingest.Kind = "icecast"
cfg.Ingest.Icecast.URL = "https://example.invalid/live"
srv := NewServer(cfg)
dir := t.TempDir()
configPath := filepath.Join(dir, "saved.json")
srv.SetConfigSaver(func(next cfgpkg.Config) error {
return cfgpkg.Save(configPath, next)
})
rec := httptest.NewRecorder()
nextIngest := cfgpkg.Default().Ingest
nextIngest.Kind = "srt"
nextIngest.SRT.URL = ""
body, err := json.Marshal(IngestSaveRequest{Ingest: nextIngest})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
srv.Handler().ServeHTTP(rec, newIngestSavePostRequest(body))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ingest.srt.url is required") {
t.Fatalf("expected existing validation error, got %q", rec.Body.String())
}
if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected no config file to be written, stat err=%v", err)
}
}

func TestRuntimeWithoutDriver(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()
@@ -175,6 +280,143 @@ func TestRuntimeWithoutDriver(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal runtime: %v", err)
}
if _, ok := body["ingest"]; ok {
t.Fatalf("expected ingest payload to be absent when ingest runtime is not configured")
}
if _, ok := body["engine"]; ok {
t.Fatalf("expected engine payload to be absent when tx controller is not configured")
}
}

func TestRuntimeIncludesIngestStats(t *testing.T) {
srv := NewServer(cfgpkg.Default())
srv.SetIngestRuntime(&fakeIngestRuntime{
stats: ingest.Stats{
Active: ingest.SourceDescriptor{ID: "stdin-main", Kind: "stdin-pcm"},
Runtime: ingest.RuntimeStats{State: "running"},
},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal runtime: %v", err)
}
ingest, ok := body["ingest"].(map[string]any)
if !ok {
t.Fatalf("expected ingest stats, got %T", body["ingest"])
}
active, ok := ingest["active"].(map[string]any)
if !ok {
t.Fatalf("expected ingest.active map, got %T", ingest["active"])
}
if active["id"] != "stdin-main" {
t.Fatalf("unexpected ingest active id: %v", active["id"])
}
}

func TestRuntimeIncludesDetailedIngestSourceAndRuntimeStats(t *testing.T) {
srv := NewServer(cfgpkg.Default())
srv.SetIngestRuntime(&fakeIngestRuntime{
stats: ingest.Stats{
Active: ingest.SourceDescriptor{
ID: "icecast-main",
Kind: "icecast",
Origin: &ingest.SourceOrigin{
Kind: "url",
Endpoint: "http://example.org/live",
},
},
Source: ingest.SourceStats{
State: "reconnecting",
Connected: false,
Reconnects: 3,
LastError: "dial tcp timeout",
},
Runtime: ingest.RuntimeStats{
State: "degraded",
ConvertErrors: 2,
WriteBlocked: true,
},
},
})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal runtime: %v", err)
}
ingestPayload, ok := body["ingest"].(map[string]any)
if !ok {
t.Fatalf("expected ingest payload map, got %T", body["ingest"])
}
source, ok := ingestPayload["source"].(map[string]any)
if !ok {
t.Fatalf("expected ingest.source map, got %T", ingestPayload["source"])
}
if source["state"] != "reconnecting" {
t.Fatalf("source state mismatch: got %v", source["state"])
}
if source["reconnects"] != float64(3) {
t.Fatalf("source reconnects mismatch: got %v", source["reconnects"])
}
if source["lastError"] != "dial tcp timeout" {
t.Fatalf("source lastError mismatch: got %v", source["lastError"])
}
active, ok := ingestPayload["active"].(map[string]any)
if !ok {
t.Fatalf("expected ingest.active map, got %T", ingestPayload["active"])
}
origin, ok := active["origin"].(map[string]any)
if !ok {
t.Fatalf("expected ingest.active.origin map, got %T", active["origin"])
}
if origin["kind"] != "url" {
t.Fatalf("origin kind mismatch: got %v", origin["kind"])
}
if origin["endpoint"] != "http://example.org/live" {
t.Fatalf("origin endpoint mismatch: got %v", origin["endpoint"])
}
runtimePayload, ok := ingestPayload["runtime"].(map[string]any)
if !ok {
t.Fatalf("expected ingest.runtime map, got %T", ingestPayload["runtime"])
}
if runtimePayload["state"] != "degraded" {
t.Fatalf("runtime state mismatch: got %v", runtimePayload["state"])
}
if runtimePayload["convertErrors"] != float64(2) {
t.Fatalf("runtime convertErrors mismatch: got %v", runtimePayload["convertErrors"])
}
if runtimePayload["writeBlocked"] != true {
t.Fatalf("runtime writeBlocked mismatch: got %v", runtimePayload["writeBlocked"])
}
}

func TestRuntimeOmitsEngineWhenControllerReturnsNilStats(t *testing.T) {
srv := NewServer(cfgpkg.Default())
srv.SetTXController(&fakeTXController{returnNilStats: true})
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal runtime: %v", err)
}
if _, ok := body["engine"]; ok {
t.Fatalf("expected engine field to be omitted when TXStats returns nil")
}
}

func TestRuntimeReportsFaultHistory(t *testing.T) {
@@ -317,8 +559,8 @@ func TestAudioStreamRequiresSource(t *testing.T) {
func TestAudioStreamPushesPCM(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
stream := audio.NewStreamSource(256, 44100)
srv.SetStreamSource(stream)
ingress := &fakeAudioIngress{}
srv.SetAudioIngress(ingress)
pcm := []byte{0, 0, 0, 0}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm))
@@ -338,12 +580,8 @@ func TestAudioStreamPushesPCM(t *testing.T) {
if frames != 1 {
t.Fatalf("expected 1 frame, got %v", frames)
}
stats, ok := body["stats"].(map[string]any)
if !ok {
t.Fatalf("missing stats: %v", body["stats"])
}
if avail, _ := stats["available"].(float64); avail < 1 {
t.Fatalf("expected stats.available >= 1, got %v", avail)
if ingress.totalFrames != 1 {
t.Fatalf("expected ingress frames=1, got %d", ingress.totalFrames)
}
}

@@ -360,7 +598,7 @@ func TestAudioStreamRejectsNonPost(t *testing.T) {
func TestAudioStreamRejectsMissingContentType(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
srv.SetAudioIngress(&fakeAudioIngress{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
srv.Handler().ServeHTTP(rec, req)
@@ -375,7 +613,7 @@ func TestAudioStreamRejectsMissingContentType(t *testing.T) {
func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
srv.SetAudioIngress(&fakeAudioIngress{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
req.Header.Set("Content-Type", "text/plain")
@@ -397,7 +635,7 @@ func TestAudioStreamRejectsBodyTooLarge(t *testing.T) {
limit := int(audioStreamBodyLimit)
body := make([]byte, limit+1)
srv := NewServer(cfgpkg.Default())
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
srv.SetAudioIngress(&fakeAudioIngress{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/octet-stream")
@@ -524,7 +762,7 @@ func TestControlAuditTracksMethodNotAllowed(t *testing.T) {

func TestControlAuditTracksUnsupportedMediaType(t *testing.T) {
srv := NewServer(cfgpkg.Default())
srv.SetStreamSource(audio.NewStreamSource(256, 44100))
srv.SetAudioIngress(&fakeAudioIngress{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0}))
srv.Handler().ServeHTTP(rec, req)
@@ -599,15 +837,43 @@ func newConfigPostRequest(body []byte) *http.Request {
return req
}

func newIngestSavePostRequest(body []byte) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/config/ingest/save", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}

type fakeTXController struct {
updateErr error
resetErr error
stats map[string]any
updateErr error
resetErr error
stats map[string]any
returnNilStats bool
}

type fakeAudioIngress struct {
totalFrames int
}

type fakeIngestRuntime struct {
stats ingest.Stats
}

func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) {
frames := len(data) / 4
f.totalFrames += frames
return frames, nil
}

func (f *fakeIngestRuntime) Stats() ingest.Stats {
return f.stats
}

func (f *fakeTXController) StartTX() error { return nil }
func (f *fakeTXController) StopTX() error { return nil }
func (f *fakeTXController) TXStats() map[string]any {
if f.returnNilStats {
return nil
}
if f.stats != nil {
return f.stats
}


+ 13
- 10
internal/control/server.go Переглянути файл

@@ -8,20 +8,23 @@ import (
)

const (
defaultReadTimeout = 5 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultMaxHeaderBytes = 1 << 20 // 1 MiB
defaultReadHeaderTimeout = 5 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultMaxHeaderBytes = 1 << 20 // 1 MiB
)

// NewHTTPServer returns a configured HTTP server for the control plane.
//
// WriteTimeout is intentionally not set: /audio/stream accepts long-lived
// POST bodies (continuous PCM push) that would be cut off by a global write
// deadline. Individual endpoints are protected by MaxBytesReader limits.
// ReadHeaderTimeout guards against slow-header attacks.
func NewHTTPServer(cfg config.Config, handler http.Handler) *http.Server {
return &http.Server{
Addr: cfg.Control.ListenAddress,
Handler: handler,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
Addr: cfg.Control.ListenAddress,
Handler: handler,
ReadHeaderTimeout: defaultReadHeaderTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
}
}

+ 947
- 2542
internal/control/ui.html
Різницю між файлами не показано, бо вона завелика
Переглянути файл


+ 29
- 0
internal/dsp/bs412.go Переглянути файл

@@ -72,6 +72,35 @@ func NewBS412Limiter(thresholdDBr, pilotLevel, rdsInjection, chunkDurationSec fl
}
}

// UpdateChunkDuration reconfigures the limiter for a new chunk size.
// Call this from GenerateFrame when the actual chunk duration is known
// (computed as samples/sampleRate) to avoid calibration errors if the
// engine's chunk duration differs from the value passed to NewBS412Limiter.
// Safe to call on every chunk; no-ops when duration has not changed.
func (l *BS412Limiter) UpdateChunkDuration(chunkSec float64) {
if chunkSec <= 0 {
return
}
windowSec := 60.0
newBufLen := int(math.Ceil(windowSec / chunkSec))
if newBufLen < 10 {
newBufLen = 10
}
if newBufLen == len(l.powerBuf) {
return // no change
}
// Resize buffer — drop history to avoid stale power readings from the
// old window size distorting the rolling average.
l.powerBuf = make([]float64, newBufLen)
l.bufIdx = 0
l.bufFull = false
l.powerSum = 0
attackTC := 2.0 / chunkSec
releaseTC := 5.0 / chunkSec
l.attackCoeff = 1.0 - math.Exp(-1.0/attackTC)
l.releaseCoeff = 1.0 - math.Exp(-1.0/releaseTC)
}

// ProcessChunk measures the audio power of a chunk and returns the
// gain factor to apply to the audio composite for BS.412 compliance.
// Call once per chunk with the average audio power of that chunk.


+ 11
- 1
internal/go.mod Переглянути файл

@@ -1,3 +1,13 @@
module github.com/jan/fm-rds-tx/internal

go 1.21
go 1.22

require (
aoiprxkit v0.0.0
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/jfreymuth/oggvorbis v1.0.5
)

require github.com/jfreymuth/vorbis v1.0.2 // indirect

replace aoiprxkit => ../aoiprxkit

+ 8
- 0
internal/go.sum Переглянути файл

@@ -0,0 +1,8 @@
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

+ 337
- 0
internal/ingest/adapters/aoip/source.go Переглянути файл

@@ -0,0 +1,337 @@
package aoip

import (
"context"
"fmt"
"io"
"sync"
"sync/atomic"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/ingest"
)

type ReceiverClient interface {
Start(ctx context.Context) error
Stop() error
Stats() aoiprxkit.Stats
}

type ReceiverFactory func(cfg aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error)

type Option func(*Source)

func WithReceiverFactory(factory ReceiverFactory) Option {
return func(s *Source) {
if factory != nil {
s.factory = factory
}
}
}

func WithDetail(detail string) Option {
return func(s *Source) {
s.detail = detail
}
}

func WithOrigin(origin ingest.SourceOrigin) Option {
return func(s *Source) {
clone := origin
s.origin = &clone
}
}

type Source struct {
id string
cfg aoiprxkit.Config

factory ReceiverFactory
detail string
origin *ingest.SourceOrigin

chunks chan ingest.PCMChunk
errs chan error

cancel context.CancelFunc
wg sync.WaitGroup

mu sync.Mutex
rx ReceiverClient
started atomic.Bool
closeOnce sync.Once

state atomic.Value // string
connected atomic.Bool
chunksIn atomic.Uint64
samplesIn atomic.Uint64
overflows atomic.Uint64
discontinuities atomic.Uint64
transportLoss atomic.Uint64
reorders atomic.Uint64
lastChunkAtUnix atomic.Int64
lastError atomic.Value // string
nextSeq atomic.Uint64

seqMu sync.Mutex
lastFrame uint16
lastHasVal bool
}

func New(id string, cfg aoiprxkit.Config, opts ...Option) *Source {
if id == "" {
id = "aes67-main"
}
if cfg.MulticastGroup == "" {
cfg = aoiprxkit.DefaultConfig()
}
s := &Source{
id: id,
cfg: cfg,
factory: newReceiverAdapter,
chunks: make(chan ingest.PCMChunk, 64),
errs: make(chan error, 8),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
s.state.Store("idle")
s.lastError.Store("")
return s
}

func (s *Source) Descriptor() ingest.SourceDescriptor {
detail := s.detail
if detail == "" {
detail = fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port)
}
origin := s.origin
if origin == nil {
origin = &ingest.SourceOrigin{
Kind: "manual",
}
}
if origin.Endpoint == "" {
copyOrigin := *origin
copyOrigin.Endpoint = fmt.Sprintf("rtp://%s:%d", s.cfg.MulticastGroup, s.cfg.Port)
origin = &copyOrigin
}
return ingest.SourceDescriptor{
ID: s.id,
Kind: "aes67",
Family: "aoip",
Transport: "rtp",
Codec: "l24",
Channels: s.cfg.Channels,
SampleRateHz: s.cfg.SampleRateHz,
Detail: detail,
Origin: origin,
}
}

func (s *Source) Start(ctx context.Context) error {
if !s.started.CompareAndSwap(false, true) {
return nil
}

rx, err := s.factory(s.cfg, s.handleFrame)
if err != nil {
s.started.Store(false)
s.connected.Store(false)
s.state.Store("failed")
s.setError(err)
return err
}

runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.mu.Lock()
s.rx = rx
s.mu.Unlock()
s.lastError.Store("")
s.connected.Store(false)
s.state.Store("connecting")

if err := rx.Start(runCtx); err != nil {
s.started.Store(false)
s.connected.Store(false)
s.state.Store("failed")
s.setError(err)
return err
}
s.connected.Store(true)
s.state.Store("running")

s.wg.Add(1)
go func() {
defer s.wg.Done()
<-runCtx.Done()
_ = s.stopReceiver()
s.connected.Store(false)
s.closeChannels()
}()
return nil
}

func (s *Source) Stop() error {
if !s.started.CompareAndSwap(true, false) {
return nil
}
if s.cancel != nil {
s.cancel()
}
if err := s.stopReceiver(); err != nil {
s.setError(err)
s.state.Store("failed")
}
s.wg.Wait()
s.connected.Store(false)
state, _ := s.state.Load().(string)
if state != "failed" {
s.state.Store("stopped")
}
return nil
}

func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
func (s *Source) Errors() <-chan error { return s.errs }

func (s *Source) Stats() ingest.SourceStats {
state, _ := s.state.Load().(string)
last := s.lastChunkAtUnix.Load()
errStr, _ := s.lastError.Load().(string)
var lastChunkAt time.Time
if last > 0 {
lastChunkAt = time.Unix(0, last)
}
var rxStats aoiprxkit.Stats
s.mu.Lock()
rx := s.rx
s.mu.Unlock()
if rx != nil {
rxStats = rx.Stats()
}
transportLoss := s.transportLoss.Load()
if rxStats.PacketsGapLoss > transportLoss {
transportLoss = rxStats.PacketsGapLoss
}
reorders := s.reorders.Load()
if rxStats.JitterReorders > reorders {
reorders = rxStats.JitterReorders
}
return ingest.SourceStats{
State: state,
Connected: s.connected.Load(),
LastChunkAt: lastChunkAt,
ChunksIn: s.chunksIn.Load(),
SamplesIn: s.samplesIn.Load(),
Overflows: s.overflows.Load(),
Underruns: rxStats.PacketsLateDrop,
Discontinuities: s.discontinuities.Load() + rxStats.PacketsLateDrop,
TransportLoss: transportLoss,
Reorders: reorders,
JitterDepth: s.cfg.JitterDepthPackets,
LastError: errStr,
}
}

func (s *Source) handleFrame(frame aoiprxkit.PCMFrame) {
if !s.started.Load() {
return
}

discontinuity := false
s.seqMu.Lock()
if s.lastHasVal {
expected := s.lastFrame + 1
if frame.SequenceNumber != expected {
discontinuity = true
delta := int16(frame.SequenceNumber - expected)
if delta > 0 {
s.transportLoss.Add(uint64(delta))
} else {
s.reorders.Add(1)
}
}
}
s.lastFrame = frame.SequenceNumber
s.lastHasVal = true
s.seqMu.Unlock()

chunk := ingest.PCMChunk{
Samples: append([]int32(nil), frame.Samples...),
Channels: frame.Channels,
SampleRateHz: frame.SampleRateHz,
Sequence: s.nextSeq.Add(1) - 1,
Timestamp: frame.ReceivedAt,
SourceID: s.id,
Discontinuity: discontinuity,
}

s.chunksIn.Add(1)
s.samplesIn.Add(uint64(len(chunk.Samples)))
s.lastChunkAtUnix.Store(time.Now().UnixNano())
if discontinuity {
s.discontinuities.Add(1)
}

select {
case s.chunks <- chunk:
default:
s.overflows.Add(1)
s.discontinuities.Add(1)
s.setError(io.ErrShortBuffer)
s.emitError(fmt.Errorf("aes67 chunk buffer overflow"))
}
}

func (s *Source) stopReceiver() error {
s.mu.Lock()
rx := s.rx
s.rx = nil
s.mu.Unlock()
if rx == nil {
return nil
}
return rx.Stop()
}

func (s *Source) closeChannels() {
s.closeOnce.Do(func() {
close(s.chunks)
close(s.errs)
})
}

func (s *Source) setError(err error) {
if err == nil {
return
}
s.lastError.Store(err.Error())
s.emitError(err)
}

func (s *Source) emitError(err error) {
if err == nil {
return
}
select {
case s.errs <- err:
default:
}
}

type receiverAdapter struct {
*aoiprxkit.Receiver
}

func newReceiverAdapter(cfg aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error) {
rx, err := aoiprxkit.NewReceiver(cfg, onFrame)
if err != nil {
return nil, err
}
return &receiverAdapter{Receiver: rx}, nil
}

+ 154
- 0
internal/ingest/adapters/aoip/source_test.go Переглянути файл

@@ -0,0 +1,154 @@
package aoip

import (
"context"
"testing"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/ingest"
)

type stubReceiver struct {
onStart func()
onStop func()
stats aoiprxkit.Stats
}

func (r *stubReceiver) Start(context.Context) error {
if r.onStart != nil {
r.onStart()
}
return nil
}

func (r *stubReceiver) Stop() error {
if r.onStop != nil {
r.onStop()
}
return nil
}

func (r *stubReceiver) Stats() aoiprxkit.Stats {
return r.stats
}

func TestSourceEmitsChunksAndMapsStats(t *testing.T) {
var handler aoiprxkit.FrameHandler
rx := &stubReceiver{
stats: aoiprxkit.Stats{
PacketsGapLoss: 1,
PacketsLateDrop: 2,
JitterReorders: 1,
},
}
src := New("aes67-test", aoiprxkit.Config{
MulticastGroup: "239.10.20.30",
Port: 5004,
PayloadType: 97,
SampleRateHz: 48000,
Channels: 2,
Encoding: "L24",
PacketTime: time.Millisecond,
JitterDepthPackets: 6,
}, WithReceiverFactory(func(_ aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (ReceiverClient, error) {
handler = onFrame
return rx, nil
}))

if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

handler(aoiprxkit.PCMFrame{
SequenceNumber: 100,
SampleRateHz: 48000,
Channels: 2,
Samples: []int32{1, -1, 2, -2},
ReceivedAt: time.Now(),
})
handler(aoiprxkit.PCMFrame{
SequenceNumber: 102,
SampleRateHz: 48000,
Channels: 2,
Samples: []int32{3, -3, 4, -4},
ReceivedAt: time.Now(),
})

chunk1 := readChunk(t, src.Chunks())
if chunk1.Discontinuity {
t.Fatalf("first chunk should not be discontinuity")
}
chunk2 := readChunk(t, src.Chunks())
if !chunk2.Discontinuity {
t.Fatalf("second chunk should be discontinuity on sequence gap")
}

stats := src.Stats()
if stats.State != "running" {
t.Fatalf("state=%q want running", stats.State)
}
if !stats.Connected {
t.Fatalf("connected=false want true")
}
if stats.ChunksIn != 2 {
t.Fatalf("chunksIn=%d want 2", stats.ChunksIn)
}
if stats.SamplesIn != 8 {
t.Fatalf("samplesIn=%d want 8", stats.SamplesIn)
}
if stats.TransportLoss != 1 {
t.Fatalf("transportLoss=%d want 1", stats.TransportLoss)
}
if stats.Reorders != 1 {
t.Fatalf("reorders=%d want 1", stats.Reorders)
}
if stats.Underruns != 2 {
t.Fatalf("underruns=%d want 2", stats.Underruns)
}
if stats.JitterDepth != 6 {
t.Fatalf("jitterDepth=%d want 6", stats.JitterDepth)
}
}

func TestSourceDescriptorSupportsDetailOverride(t *testing.T) {
src := New("aes67-test", aoiprxkit.Config{
MulticastGroup: "239.10.20.30",
Port: 5004,
SampleRateHz: 48000,
Channels: 2,
}, WithDetail("rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)"), WithOrigin(ingest.SourceOrigin{
Kind: "sap-discovery",
StreamName: "AES67-MAIN",
Endpoint: "rtp://239.10.20.30:5004",
}))

desc := src.Descriptor()
if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" {
t.Fatalf("detail=%q", desc.Detail)
}
if desc.Origin == nil {
t.Fatalf("expected descriptor origin")
}
if desc.Origin.Kind != "sap-discovery" {
t.Fatalf("origin kind=%q", desc.Origin.Kind)
}
if desc.Origin.StreamName != "AES67-MAIN" {
t.Fatalf("origin streamName=%q", desc.Origin.StreamName)
}
}

func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk {
t.Helper()
select {
case chunk, ok := <-ch:
if !ok {
t.Fatal("chunk channel closed")
}
return chunk
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout waiting for chunk")
return ingest.PCMChunk{}
}
}

+ 133
- 0
internal/ingest/adapters/httpraw/source.go Переглянути файл

@@ -0,0 +1,133 @@
package httpraw

import (
"context"
"encoding/binary"
"fmt"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
)

type Source struct {
id string
sampleRate int
channels int

chunks chan ingest.PCMChunk
errs chan error

sequence atomic.Uint64
state atomic.Value // string
chunksIn atomic.Uint64
samplesIn atomic.Uint64
discontinuities atomic.Uint64
lastChunkAtUnix atomic.Int64
lastError atomic.Value // string
}

func New(id string, sampleRate, channels int) *Source {
if id == "" {
id = "http-raw"
}
if sampleRate <= 0 {
sampleRate = 44100
}
if channels <= 0 {
channels = 2
}
s := &Source{
id: id,
sampleRate: sampleRate,
channels: channels,
chunks: make(chan ingest.PCMChunk, 32),
errs: make(chan error, 8),
}
s.state.Store("idle")
return s
}

func (s *Source) Descriptor() ingest.SourceDescriptor {
return ingest.SourceDescriptor{
ID: s.id,
Kind: "http-raw",
Family: "raw",
Transport: "http",
Codec: "pcm_s16le",
Channels: s.channels,
SampleRateHz: s.sampleRate,
Detail: "HTTP push /audio/stream",
}
}

func (s *Source) Start(_ context.Context) error {
s.state.Store("running")
return nil
}

func (s *Source) Stop() error {
s.state.Store("stopped")
return nil
}

func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
func (s *Source) Errors() <-chan error { return s.errs }

func (s *Source) Stats() ingest.SourceStats {
state, _ := s.state.Load().(string)
last := s.lastChunkAtUnix.Load()
errStr, _ := s.lastError.Load().(string)
var lastChunkAt time.Time
if last > 0 {
lastChunkAt = time.Unix(0, last)
}
return ingest.SourceStats{
State: state,
Connected: state == "running",
LastChunkAt: lastChunkAt,
ChunksIn: s.chunksIn.Load(),
SamplesIn: s.samplesIn.Load(),
Discontinuities: s.discontinuities.Load(),
LastError: errStr,
}
}

func (s *Source) WritePCM16(data []byte) (int, error) {
if s.channels != 1 && s.channels != 2 {
return 0, fmt.Errorf("unsupported configured channels: %d", s.channels)
}
if len(data) == 0 {
return 0, nil
}
frameBytes := s.channels * 2
usable := len(data) - (len(data) % frameBytes)
if usable == 0 {
return 0, nil
}
samples := make([]int32, 0, usable/2)
for i := 0; i+1 < usable; i += 2 {
v := int16(binary.LittleEndian.Uint16(data[i : i+2]))
samples = append(samples, int32(v)<<16)
}
seq := s.sequence.Add(1) - 1
chunk := ingest.PCMChunk{
Samples: samples,
Channels: s.channels,
SampleRateHz: s.sampleRate,
Sequence: seq,
Timestamp: time.Now(),
SourceID: s.id,
}
select {
case s.chunks <- chunk:
default:
s.discontinuities.Add(1)
return 0, fmt.Errorf("http raw ingress overflow")
}
frames := usable / frameBytes
s.chunksIn.Add(1)
s.samplesIn.Add(uint64(len(samples)))
s.lastChunkAtUnix.Store(time.Now().UnixNano())
return frames, nil
}

+ 145
- 0
internal/ingest/adapters/icecast/icy.go Переглянути файл

@@ -0,0 +1,145 @@
package icecast

import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
)

type icyMetadata struct {
StreamTitle string
}

type icyReader struct {
r io.Reader
metaInt int
audioLeft int
onMetadata func(icyMetadata)
}

func newICYReader(r io.Reader, metaInt int, onMetadata func(icyMetadata)) io.Reader {
if r == nil || metaInt <= 0 {
return r
}
return &icyReader{
r: r,
metaInt: metaInt,
audioLeft: metaInt,
onMetadata: onMetadata,
}
}

func (r *icyReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
for {
if r.audioLeft == 0 {
if err := r.readMetadataBlock(); err != nil {
return 0, err
}
r.audioLeft = r.metaInt
continue
}
want := len(p)
if want > r.audioLeft {
want = r.audioLeft
}
n, err := r.r.Read(p[:want])
if n > 0 {
r.audioLeft -= n
return n, nil
}
if err != nil {
return 0, err
}
}
}

func (r *icyReader) readMetadataBlock() error {
var lenBuf [1]byte
if _, err := io.ReadFull(r.r, lenBuf[:]); err != nil {
return err
}
blockLen := int(lenBuf[0]) * 16
if blockLen == 0 {
return nil
}
block := make([]byte, blockLen)
if _, err := io.ReadFull(r.r, block); err != nil {
return err
}
if r.onMetadata != nil {
r.onMetadata(parseICYMetadata(block))
}
return nil
}

// parseICYMetadata parses the ICY inline metadata block.
//
// ICY metadata is a semicolon-delimited key=value format where values are
// single-quoted strings. A naive strings.Split(raw, ";") breaks when the
// StreamTitle itself contains semicolons (e.g. "Artist - Title; Live Edit").
// This parser is quote-aware: it only splits on semicolons that appear
// outside of single-quoted value strings.
func parseICYMetadata(block []byte) icyMetadata {
raw := strings.TrimRight(string(bytes.Trim(block, "\x00")), "\x00")
meta := icyMetadata{}

fields := splitICYFields(raw)
for _, field := range fields {
field = strings.TrimSpace(field)
if !strings.HasPrefix(field, "StreamTitle=") {
continue
}
v := strings.TrimPrefix(field, "StreamTitle=")
v = strings.TrimSpace(v)
// Strip enclosing single or double quotes.
if len(v) >= 2 {
if (v[0] == '\'' && v[len(v)-1] == '\'') ||
(v[0] == '"' && v[len(v)-1] == '"') {
v = v[1 : len(v)-1]
}
}
meta.StreamTitle = v
break
}
return meta
}

// splitICYFields splits an ICY metadata string on semicolons that appear
// outside of single-quoted value strings. Semicolons inside quotes (e.g.
// StreamTitle='Artist - Song; Live';) are preserved as part of the value.
func splitICYFields(s string) []string {
var fields []string
inQuote := false
start := 0
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\'' {
inQuote = !inQuote
}
if c == ';' && !inQuote {
fields = append(fields, s[start:i])
start = i + 1
}
}
if start < len(s) {
fields = append(fields, s[start:])
}
return fields
}

func parseICYMetaInt(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0, fmt.Errorf("invalid icy-metaint: %q", raw)
}
return n, nil
}

+ 77
- 0
internal/ingest/adapters/icecast/icy_test.go Переглянути файл

@@ -0,0 +1,77 @@
package icecast

import (
"bytes"
"io"
"testing"
)

func TestParseICYMetadataExtractsStreamTitle(t *testing.T) {
meta := parseICYMetadata([]byte("StreamTitle='Artist - Track';StreamUrl='';"))
if meta.StreamTitle != "Artist - Track" {
t.Fatalf("streamTitle=%q want %q", meta.StreamTitle, "Artist - Track")
}
}

func TestICYReaderStripsMetadataAndEmitsTitle(t *testing.T) {
block := buildICYMetadataBlock("StreamTitle='Unit Test';")
wire := append([]byte("ABCD"), byte(len(block)/16))
wire = append(wire, block...)
wire = append(wire, []byte("EFGH")...)

var got icyMetadata
r := newICYReader(bytes.NewReader(wire), 4, func(meta icyMetadata) {
got = meta
})

audio, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(audio) != "ABCDEFGH" {
t.Fatalf("audio=%q want %q", string(audio), "ABCDEFGH")
}
if got.StreamTitle != "Unit Test" {
t.Fatalf("streamTitle=%q want %q", got.StreamTitle, "Unit Test")
}
}

func TestParseICYMetaInt(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{name: "empty", in: "", want: 0},
{name: "valid", in: "16000", want: 16000},
{name: "invalid", in: "x", wantErr: true},
{name: "negative", in: "-1", wantErr: true},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := parseICYMetaInt(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q", tc.in)
}
return
}
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != tc.want {
t.Fatalf("got=%d want %d", got, tc.want)
}
})
}
}

func buildICYMetadataBlock(raw string) []byte {
b := []byte(raw)
if rem := len(b) % 16; rem != 0 {
b = append(b, bytes.Repeat([]byte{0x00}, 16-rem)...)
}
return b
}

+ 106
- 0
internal/ingest/adapters/icecast/radiotext.go Переглянути файл

@@ -0,0 +1,106 @@
package icecast

import (
"strings"
"sync"
)

type RadioTextOptions struct {
Enabled bool
Prefix string
MaxLen int
OnlyOnChange bool
}

func mapStreamTitleToRadioText(streamTitle string, opts RadioTextOptions) string {
if !opts.Enabled {
return ""
}
maxLen := opts.MaxLen
if maxLen <= 0 || maxLen > 64 {
maxLen = 64
}
title := sanitizeASCII(streamTitle)
if title == "" {
return ""
}
prefixRaw := opts.Prefix
prefixHadTrailingSpace := strings.TrimRight(prefixRaw, " \t\r\n") != prefixRaw
prefix := sanitizeASCII(opts.Prefix)
if prefix != "" && prefixHadTrailingSpace {
prefix += " "
}
rt := title
if prefix != "" {
rt = prefix + title
}
if len(rt) > maxLen {
rt = strings.TrimSpace(rt[:maxLen])
}
return rt
}

func sanitizeASCII(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
prevSpace := true
for _, r := range raw {
switch r {
case '\n', '\r', '\t':
r = ' '
}
if r < 0x20 || r == 0x7f || r > 0x7e {
continue
}
if r == ' ' {
if prevSpace {
continue
}
prevSpace = true
b.WriteByte(' ')
continue
}
prevSpace = false
b.WriteByte(byte(r))
}
return strings.TrimSpace(b.String())
}

type RadioTextRelay struct {
opts RadioTextOptions
apply func(string) error
mu sync.Mutex
lastRT string
}

func NewRadioTextRelay(opts RadioTextOptions, initialRT string, apply func(string) error) *RadioTextRelay {
return &RadioTextRelay{
opts: opts,
apply: apply,
lastRT: sanitizeASCII(initialRT),
}
}

func (r *RadioTextRelay) HandleStreamTitle(streamTitle string) error {
if r == nil || r.apply == nil {
return nil
}
next := mapStreamTitleToRadioText(streamTitle, r.opts)
if next == "" {
return nil
}
r.mu.Lock()
skip := r.opts.OnlyOnChange && next == r.lastRT
if !skip {
r.lastRT = next
}
r.mu.Unlock()
if skip {
return nil
}
return r.apply(next)
}

+ 65
- 0
internal/ingest/adapters/icecast/radiotext_test.go Переглянути файл

@@ -0,0 +1,65 @@
package icecast

import "testing"

func TestMapStreamTitleToRadioTextSanitizeAndTruncate(t *testing.T) {
got := mapStreamTitleToRadioText(" Artist\t-\nSong \u2603 ", RadioTextOptions{
Enabled: true,
Prefix: "Now: ",
MaxLen: 13,
})
if got != "Now: Artist -" {
t.Fatalf("mapped=%q want %q", got, "Now: Artist -")
}
}

func TestMapStreamTitleToRadioTextDisabledReturnsEmpty(t *testing.T) {
got := mapStreamTitleToRadioText("Artist - Song", RadioTextOptions{Enabled: false})
if got != "" {
t.Fatalf("mapped=%q want empty", got)
}
}

func TestRadioTextRelayOnlyOnChange(t *testing.T) {
calls := 0
last := ""
relay := NewRadioTextRelay(RadioTextOptions{
Enabled: true,
OnlyOnChange: true,
}, "", func(rt string) error {
calls++
last = rt
return nil
})

if err := relay.HandleStreamTitle("Artist - Song"); err != nil {
t.Fatalf("first handle: %v", err)
}
if err := relay.HandleStreamTitle("Artist - Song"); err != nil {
t.Fatalf("second handle: %v", err)
}
if calls != 1 {
t.Fatalf("calls=%d want 1", calls)
}
if last != "Artist - Song" {
t.Fatalf("last=%q want %q", last, "Artist - Song")
}
}

func TestRadioTextRelayInitialSuppressesSameUpdate(t *testing.T) {
calls := 0
relay := NewRadioTextRelay(RadioTextOptions{
Enabled: true,
OnlyOnChange: true,
}, "Station default", func(string) error {
calls++
return nil
})

if err := relay.HandleStreamTitle("Station default"); err != nil {
t.Fatalf("handle: %v", err)
}
if calls != 0 {
t.Fatalf("calls=%d want 0", calls)
}
}

+ 31
- 0
internal/ingest/adapters/icecast/reconnect.go Переглянути файл

@@ -0,0 +1,31 @@
package icecast

import "time"

type ReconnectConfig struct {
Enabled bool
InitialBackoffMs int
MaxBackoffMs int
}

func (c ReconnectConfig) nextBackoff(attempt int) time.Duration {
if !c.Enabled {
return 0
}
initial := c.InitialBackoffMs
if initial <= 0 {
initial = 1000
}
max := c.MaxBackoffMs
if max <= 0 {
max = 15000
}
d := time.Duration(initial) * time.Millisecond
for i := 1; i < attempt; i++ {
d *= 2
if d >= time.Duration(max)*time.Millisecond {
return time.Duration(max) * time.Millisecond
}
}
return d
}

+ 26
- 0
internal/ingest/adapters/icecast/reconnect_test.go Переглянути файл

@@ -0,0 +1,26 @@
package icecast

import (
"testing"
"time"
)

func TestNextBackoff(t *testing.T) {
cfg := ReconnectConfig{
Enabled: true,
InitialBackoffMs: 1000,
MaxBackoffMs: 5000,
}
if got := cfg.nextBackoff(1); got != 1*time.Second {
t.Fatalf("attempt1 got %s", got)
}
if got := cfg.nextBackoff(2); got != 2*time.Second {
t.Fatalf("attempt2 got %s", got)
}
if got := cfg.nextBackoff(3); got != 4*time.Second {
t.Fatalf("attempt3 got %s", got)
}
if got := cfg.nextBackoff(4); got != 5*time.Second {
t.Fatalf("attempt4 got %s", got)
}
}

+ 379
- 0
internal/ingest/adapters/icecast/source.go Переглянути файл

@@ -0,0 +1,379 @@
package icecast

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
"github.com/jan/fm-rds-tx/internal/ingest/decoder/aac"
"github.com/jan/fm-rds-tx/internal/ingest/decoder/fallback"
"github.com/jan/fm-rds-tx/internal/ingest/decoder/mp3"
"github.com/jan/fm-rds-tx/internal/ingest/decoder/oggvorbis"
)

type Source struct {
id string
url string

client *http.Client
decReg *decoder.Registry
reconn ReconnectConfig

decoderPreference string

chunks chan ingest.PCMChunk
errs chan error
title chan string

cancel context.CancelFunc
wg sync.WaitGroup

state atomic.Value // string
connected atomic.Bool
chunksIn atomic.Uint64
samplesIn atomic.Uint64
reconnects atomic.Uint64
discontinuities atomic.Uint64
lastChunkAtUnix atomic.Int64
lastMetaAtUnix atomic.Int64
metadataUpdates atomic.Uint64
icyMetaInt atomic.Int64
lastError atomic.Value // string
streamTitle atomic.Value // string
}

var errStreamEnded = errors.New("icecast stream ended")

type Option func(*Source)

func WithDecoderPreference(pref string) Option {
return func(s *Source) {
s.decoderPreference = normalizeDecoderPreference(pref)
}
}

func WithDecoderRegistry(reg *decoder.Registry) Option {
return func(s *Source) {
if reg != nil {
s.decReg = reg
}
}
}

func New(id, url string, client *http.Client, reconn ReconnectConfig, opts ...Option) *Source {
if id == "" {
id = "icecast-main"
}
if client == nil {
// Streaming responses are long-lived; a global client timeout would
// terminate the body read after a fixed duration.
client = &http.Client{}
}
s := &Source{
id: id,
url: strings.TrimSpace(url),
client: client,
reconn: reconn,
chunks: make(chan ingest.PCMChunk, 64),
errs: make(chan error, 8),
title: make(chan string, 16),
decReg: defaultRegistry(),
decoderPreference: "auto",
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
s.decoderPreference = normalizeDecoderPreference(s.decoderPreference)
s.state.Store("idle")
s.streamTitle.Store("")
return s
}

func defaultRegistry() *decoder.Registry {
r := decoder.NewRegistry()
r.Register("mp3", func() decoder.Decoder { return mp3.New() })
r.Register("oggvorbis", func() decoder.Decoder { return oggvorbis.New() })
r.Register("aac", func() decoder.Decoder { return aac.New() })
r.Register("ffmpeg", func() decoder.Decoder { return fallback.NewFFmpeg() })
return r
}

func (s *Source) Descriptor() ingest.SourceDescriptor {
return ingest.SourceDescriptor{
ID: s.id,
Kind: "icecast",
Family: "streaming",
Transport: "http",
Codec: s.decoderPreference,
Detail: s.url,
Origin: &ingest.SourceOrigin{
Kind: "url",
Endpoint: redactURL(s.url),
},
}
}

func (s *Source) Start(ctx context.Context) error {
if s.url == "" {
return fmt.Errorf("icecast url is required")
}
runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.lastError.Store("")
s.state.Store("connecting")
s.wg.Add(1)
go s.loop(runCtx)
return nil
}

func (s *Source) Stop() error {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.state.Store("stopped")
return nil
}

func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
func (s *Source) Errors() <-chan error { return s.errs }
func (s *Source) StreamTitleUpdates() <-chan string {
return s.title
}

func (s *Source) Stats() ingest.SourceStats {
state, _ := s.state.Load().(string)
last := s.lastChunkAtUnix.Load()
lastMeta := s.lastMetaAtUnix.Load()
errStr, _ := s.lastError.Load().(string)
streamTitle, _ := s.streamTitle.Load().(string)
var lastChunkAt time.Time
var lastMetaAt time.Time
if last > 0 {
lastChunkAt = time.Unix(0, last)
}
if lastMeta > 0 {
lastMetaAt = time.Unix(0, lastMeta)
}
return ingest.SourceStats{
State: state,
Connected: s.connected.Load(),
LastChunkAt: lastChunkAt,
LastMetaAt: lastMetaAt,
StreamTitle: streamTitle,
MetadataUpdates: s.metadataUpdates.Load(),
IcyMetaInt: int(s.icyMetaInt.Load()),
ChunksIn: s.chunksIn.Load(),
SamplesIn: s.samplesIn.Load(),
Reconnects: s.reconnects.Load(),
Discontinuities: s.discontinuities.Load(),
LastError: errStr,
}
}

func (s *Source) loop(ctx context.Context) {
defer s.wg.Done()
defer close(s.chunks)
defer close(s.errs)
defer close(s.title)
attempt := 0
for {
select {
case <-ctx.Done():
return
default:
}

s.state.Store("connecting")
err := s.connectAndRun(ctx)
if ctx.Err() != nil {
return
}
if err == nil {
err = errStreamEnded
}
s.connected.Store(false)
s.lastError.Store(err.Error())
select {
case s.errs <- err:
default:
}
s.state.Store("reconnecting")
attempt++
s.reconnects.Add(1)
backoff := s.reconn.nextBackoff(attempt)
if backoff <= 0 {
s.state.Store("failed")
return
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
}
}

func (s *Source) connectAndRun(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
if err != nil {
return err
}
req.Header.Set("Icy-MetaData", "1")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("icecast connect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("icecast status: %s", resp.Status)
}
s.connected.Store(true)
s.state.Store("buffering")
s.lastError.Store("")
icyMetaInt, _ := parseICYMetaInt(resp.Header.Get("icy-metaint"))
s.icyMetaInt.Store(int64(icyMetaInt))
stream := newICYReader(resp.Body, icyMetaInt, s.onMetadata)
s.state.Store("running")
return s.decodeWithPreference(ctx, stream, decoder.StreamMeta{
ContentType: resp.Header.Get("Content-Type"),
SourceID: s.id,
SampleRateHz: 44100,
Channels: 2,
})
}

func (s *Source) onMetadata(meta icyMetadata) {
s.streamTitle.Store(meta.StreamTitle)
s.metadataUpdates.Add(1)
s.lastMetaAtUnix.Store(time.Now().UnixNano())
select {
case s.title <- meta.StreamTitle:
default:
}
}

func (s *Source) emitChunk(chunk ingest.PCMChunk) error {
select {
case s.chunks <- chunk:
default:
s.discontinuities.Add(1)
return io.ErrShortBuffer
}
s.chunksIn.Add(1)
s.samplesIn.Add(uint64(len(chunk.Samples)))
s.lastChunkAtUnix.Store(time.Now().UnixNano())
return nil
}

func (s *Source) decodeWithPreference(ctx context.Context, stream io.Reader, meta decoder.StreamMeta) error {
mode := normalizeDecoderPreference(s.decoderPreference)
switch mode {
case "ffmpeg":
return s.decodeNamed(ctx, "ffmpeg", stream, meta)
case "native":
native, err := s.decReg.SelectByContentType(meta.ContentType)
if err != nil {
return fmt.Errorf("icecast native decoder select: %w", err)
}
return native.DecodeStream(ctx, stream, meta, s.emitChunk)
case "auto":
// Phase-1 policy: try native decoder first, then fall back to ffmpeg
// only when native selection/decode reports "unsupported".
native, err := s.decReg.SelectByContentType(meta.ContentType)
if err == nil {
captured := &capturingReader{r: stream}
if err := native.DecodeStream(ctx, captured, meta, s.emitChunk); err == nil {
return nil
} else if !errors.Is(err, decoder.ErrUnsupported) {
return err
}
// Native decode can consume stream bytes before returning "unsupported".
// Reconstruct a full reader for fallback: consumed prefix + remaining stream.
stream = io.MultiReader(bytes.NewReader(captured.Bytes()), stream)
} else if !errors.Is(err, decoder.ErrUnsupported) {
return fmt.Errorf("icecast decoder select: %w", err)
}
return s.decodeNamed(ctx, "ffmpeg", stream, meta)
default:
return fmt.Errorf("unsupported icecast decoder mode: %s", mode)
}
}

// maxCaptureBytes caps the amount of stream data buffered while the native
// decoder is deciding whether it can handle the format. Without a cap, a
// decoder that reads extensively before returning ErrUnsupported could grow
// this buffer unboundedly on a corrupt or adversarial stream.
const maxCaptureBytes = 1 << 20 // 1 MiB

// errCaptureLimitExceeded is returned by capturingReader when the buffer cap
// is hit. The caller should treat it like ErrUnsupported and fall back.
var errCaptureLimitExceeded = errors.New("capture buffer limit exceeded")

type capturingReader struct {
r io.Reader
buf bytes.Buffer
}

func (r *capturingReader) Read(p []byte) (int, error) {
if r.buf.Len() >= maxCaptureBytes {
return 0, errCaptureLimitExceeded
}
n, err := r.r.Read(p)
if n > 0 {
_, _ = r.buf.Write(p[:n])
}
return n, err
}

func (r *capturingReader) Bytes() []byte {
return r.buf.Bytes()
}

func (s *Source) decodeNamed(ctx context.Context, name string, stream io.Reader, meta decoder.StreamMeta) error {
dec, err := s.decReg.Create(name)
if err != nil {
return fmt.Errorf("icecast decoder=%s unavailable: %w", name, err)
}
return dec.DecodeStream(ctx, stream, meta, s.emitChunk)
}

func normalizeDecoderPreference(pref string) string {
switch strings.ToLower(strings.TrimSpace(pref)) {
case "", "auto":
return "auto"
case "native":
return "native"
case "ffmpeg", "fallback":
return "ffmpeg"
default:
return strings.ToLower(strings.TrimSpace(pref))
}
}

func redactURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return trimmed
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
return u.String()
}

+ 575
- 0
internal/ingest/adapters/icecast/source_test.go Переглянути файл

@@ -0,0 +1,575 @@
package icecast

import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

type testDecoder struct {
name string
err error
called int
}

func (d *testDecoder) Name() string { return d.name }

func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
d.called++
return d.err
}

type consumingUnsupportedDecoder struct {
n int
called int
}

func (d *consumingUnsupportedDecoder) Name() string { return "native-consuming-unsupported" }

func (d *consumingUnsupportedDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
d.called++
buf := make([]byte, d.n)
_, _ = io.ReadFull(r, buf)
return decoder.ErrUnsupported
}

type captureStreamDecoder struct {
name string
called int
payload []byte
}

func (d *captureStreamDecoder) Name() string { return d.name }

func (d *captureStreamDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
d.called++
data, err := io.ReadAll(r)
if err != nil {
return err
}
d.payload = data
return nil
}

func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) {
native := &testDecoder{name: "native", err: decoder.ErrUnsupported}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if native.called != 1 {
t.Fatalf("native called %d times", native.called)
}
if fallback.called != 1 {
t.Fatalf("fallback called %d times", fallback.called)
}
}

func TestDecodeWithPreferenceNativeDoesNotFallback(t *testing.T) {
nativeErr := errors.New("decode failed")
native := &testDecoder{name: "native", err: nativeErr}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("native"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "ice-test",
})
if !errors.Is(err, nativeErr) {
t.Fatalf("expected native error, got %v", err)
}
if fallback.called != 0 {
t.Fatalf("fallback should not be called, got %d", fallback.called)
}
}

func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) {
native := &testDecoder{name: "native"}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("ffmpeg"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if native.called != 0 {
t.Fatalf("native should not be called in ffmpeg mode, got %d", native.called)
}
if fallback.called != 1 {
t.Fatalf("fallback called %d times", fallback.called)
}
}

func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) {
fallback := &testDecoder{name: "ffmpeg"}
reg := decoder.NewRegistry()
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "application/octet-stream",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if fallback.called != 1 {
t.Fatalf("fallback called %d times", fallback.called)
}
}

func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) {
ogg := &testDecoder{name: "oggvorbis"}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("oggvorbis", func() decoder.Decoder { return ogg })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/ogg",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if ogg.called != 1 {
t.Fatalf("ogg decoder called %d times", ogg.called)
}
if fallback.called != 0 {
t.Fatalf("fallback should not be called, got %d", fallback.called)
}
}

func TestDecodeWithPreferenceAutoUsesMP3NativeForMPEGContentType(t *testing.T) {
mp3Native := &testDecoder{name: "mp3"}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return mp3Native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/mpeg; charset=utf-8",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if mp3Native.called != 1 {
t.Fatalf("mp3 native decoder called %d times", mp3Native.called)
}
if fallback.called != 0 {
t.Fatalf("fallback should not be called, got %d", fallback.called)
}
}

func TestDecodeWithPreferenceAutoNativeErrorDoesNotFallback(t *testing.T) {
nativeErr := errors.New("native hard failure")
mp3Native := &testDecoder{name: "mp3", err: nativeErr}
fallback := &testDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return mp3Native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "ice-test",
})
if !errors.Is(err, nativeErr) {
t.Fatalf("expected native error, got %v", err)
}
if fallback.called != 0 {
t.Fatalf("fallback should not be called on native hard error, got %d", fallback.called)
}
}

func TestDecodeWithPreferenceAutoFallbackSeesFullStreamAfterNativeConsumesPrefix(t *testing.T) {
const consumed = 4
input := []byte("0123456789abcdef")

native := &consumingUnsupportedDecoder{n: consumed}
fallback := &captureStreamDecoder{name: "ffmpeg"}

reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return native })
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(input), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "ice-test",
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if native.called != 1 {
t.Fatalf("native called %d times", native.called)
}
if fallback.called != 1 {
t.Fatalf("fallback called %d times", fallback.called)
}
if !bytes.Equal(fallback.payload, input) {
t.Fatalf("fallback payload mismatch: got %q want %q", string(fallback.payload), string(input))
}
}

func TestDecodeWithPreferenceNativeUnsupportedContentTypeFailsWithoutFallback(t *testing.T) {
fallback := &testDecoder{name: "ffmpeg"}
reg := decoder.NewRegistry()
reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

src := New("ice-test", "http://example", nil, ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("native"),
)

err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
ContentType: "application/octet-stream",
SourceID: "ice-test",
})
if err == nil {
t.Fatal("expected native-mode select error for unsupported content-type")
}
if fallback.called != 0 {
t.Fatalf("fallback should not be called in native mode, got %d", fallback.called)
}
}

func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) {
src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback"))
if got := src.Descriptor().Codec; got != "ffmpeg" {
t.Fatalf("codec=%s want ffmpeg", got)
}
}

func TestDescriptorOriginRedactsCredentialsAndQuery(t *testing.T) {
src := New("ice-test", "http://user:secret@example.org:8000/live.mp3?token=abc", nil, ReconnectConfig{})
desc := src.Descriptor()
if desc.Origin == nil {
t.Fatalf("expected descriptor origin")
}
if desc.Origin.Kind != "url" {
t.Fatalf("origin kind=%q want url", desc.Origin.Kind)
}
if desc.Origin.Endpoint != "http://example.org:8000/live.mp3" {
t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint)
}
}

func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) {
const (
audioPrefix = "ABCD"
audioSuffix = "EFGH"
title = "Artist - Track"
)
var reqIcyHeader atomic.Value
reqIcyHeader.Store("")

metadata := buildICYMetadataBlock("StreamTitle='" + title + "';")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqIcyHeader.Store(r.Header.Get("Icy-Metadata"))
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("icy-metaint", "4")
_, _ = w.Write([]byte(audioPrefix))
_, _ = w.Write([]byte{byte(len(metadata) / 16)})
_, _ = w.Write(metadata)
_, _ = w.Write([]byte(audioSuffix))
}))
defer srv.Close()

native := &captureStreamDecoder{name: "mp3"}
reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return native })
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{},
WithDecoderRegistry(reg),
WithDecoderPreference("auto"),
)

if err := src.connectAndRun(context.Background()); err != nil {
t.Fatalf("connectAndRun: %v", err)
}
if got := reqIcyHeader.Load().(string); got != "1" {
t.Fatalf("Icy-Metadata header=%q want 1", got)
}
if got := string(native.payload); got != audioPrefix+audioSuffix {
t.Fatalf("decoded payload=%q want %q", got, audioPrefix+audioSuffix)
}
stats := src.Stats()
if stats.StreamTitle != title {
t.Fatalf("streamTitle=%q want %q", stats.StreamTitle, title)
}
if stats.MetadataUpdates < 1 {
t.Fatalf("metadataUpdates=%d want >=1", stats.MetadataUpdates)
}
if stats.IcyMetaInt != 4 {
t.Fatalf("icyMetaInt=%d want 4", stats.IcyMetaInt)
}
}

type scriptedLoopDecoder struct {
mu sync.Mutex
actions []decodeAction
calls int
totalBytesRead int
}

type decodeAction struct {
err error
blockUntilStop bool
}

func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" }

func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
data, err := io.ReadAll(r)
if err != nil {
return err
}

d.mu.Lock()
d.calls++
d.totalBytesRead += len(data)
callIdx := d.calls - 1
action := decodeAction{}
if callIdx < len(d.actions) {
action = d.actions[callIdx]
}
d.mu.Unlock()

if action.blockUntilStop {
<-ctx.Done()
return nil
}
return action.err
}

func (d *scriptedLoopDecoder) callCount() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.calls
}

func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) {
var requests atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("test-stream"))
}))
defer srv.Close()

dec := &scriptedLoopDecoder{
actions: []decodeAction{
{}, // first connection ends cleanly (EOS-like)
{blockUntilStop: true},
},
}
reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return dec })
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
Enabled: true,
InitialBackoffMs: 1,
MaxBackoffMs: 1,
}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS")

stats := src.Stats()
if stats.Reconnects < 1 {
t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
}
if got := requests.Load(); got < 2 {
t.Fatalf("requests=%d want >=2", got)
}
}

func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) {
const boom = "decoder boom"
var requests atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("test-stream"))
}))
defer srv.Close()

dec := &scriptedLoopDecoder{
actions: []decodeAction{
{err: errors.New(boom)}, // first attempt fails
{blockUntilStop: true}, // second attempt recovers and stays running
},
}
reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return dec })
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
Enabled: true,
InitialBackoffMs: 1,
MaxBackoffMs: 1,
}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

select {
case err := <-src.Errors():
if err == nil || !strings.Contains(err.Error(), boom) {
t.Fatalf("error=%v want contains %q", err, boom)
}
case <-time.After(1 * time.Second):
t.Fatal("timed out waiting for source error reporting")
}

waitForCondition(t, func() bool {
st := src.Stats()
return dec.callCount() >= 2 && st.LastError == ""
}, "lastError cleared after successful reconnect")

if got := requests.Load(); got < 2 {
t.Fatalf("requests=%d want >=2", got)
}
}

func TestNewWithoutClientUsesStreamingSafeHTTPClient(t *testing.T) {
src := New("ice-test", "http://example", nil, ReconnectConfig{})
if src.client == nil {
t.Fatal("expected default http client")
}
if src.client.Timeout != 0 {
t.Fatalf("client timeout=%v want 0 for streaming", src.client.Timeout)
}
}

func TestSourceReconnectsAfterDeadlineExceededError(t *testing.T) {
var requests atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write([]byte("test-stream"))
}))
defer srv.Close()

dec := &scriptedLoopDecoder{
actions: []decodeAction{
{err: context.DeadlineExceeded}, // first attempt fails transiently
{blockUntilStop: true}, // second attempt recovers and stays running
},
}
reg := decoder.NewRegistry()
reg.Register("mp3", func() decoder.Decoder { return dec })
reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
Enabled: true,
InitialBackoffMs: 1,
MaxBackoffMs: 1,
}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after deadline exceeded")

stats := src.Stats()
if stats.Reconnects < 1 {
t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
}
if got := requests.Load(); got < 2 {
t.Fatalf("requests=%d want >=2", got)
}
}

func waitForCondition(t *testing.T, cond func() bool, label string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timeout waiting for condition: %s", label)
}

+ 305
- 0
internal/ingest/adapters/srt/source.go Переглянути файл

@@ -0,0 +1,305 @@
package srt

import (
"context"
"fmt"
"io"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/ingest"
)

type Option func(*Source)

func WithConnOpener(opener aoiprxkit.SRTConnOpener) Option {
return func(s *Source) {
if opener != nil {
s.opener = opener
}
}
}

type Source struct {
id string
cfg aoiprxkit.SRTConfig

opener aoiprxkit.SRTConnOpener

chunks chan ingest.PCMChunk
errs chan error

cancel context.CancelFunc
wg sync.WaitGroup

mu sync.Mutex
rx *aoiprxkit.SRTReceiver
started atomic.Bool
closeOnce sync.Once

state atomic.Value // string
connected atomic.Bool
chunksIn atomic.Uint64
samplesIn atomic.Uint64
overflows atomic.Uint64
discontinuities atomic.Uint64
transportLoss atomic.Uint64
reorders atomic.Uint64
lastChunkAtUnix atomic.Int64
lastError atomic.Value // string
nextSeq atomic.Uint64

seqMu sync.Mutex
lastFrame uint16
lastHasVal bool
}

func New(id string, cfg aoiprxkit.SRTConfig, opts ...Option) *Source {
if id == "" {
id = "srt-main"
}
if cfg.Mode == "" {
cfg.Mode = "listener"
}
if cfg.SampleRateHz <= 0 {
cfg.SampleRateHz = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 2
}

s := &Source{
id: id,
cfg: cfg,
chunks: make(chan ingest.PCMChunk, 64),
errs: make(chan error, 8),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
s.state.Store("idle")
s.lastError.Store("")
return s
}

func (s *Source) Descriptor() ingest.SourceDescriptor {
return ingest.SourceDescriptor{
ID: s.id,
Kind: "srt",
Family: "aoip",
Transport: "srt",
Codec: "pcm_s32le",
Channels: s.cfg.Channels,
SampleRateHz: s.cfg.SampleRateHz,
Detail: s.cfg.URL,
Origin: &ingest.SourceOrigin{
Kind: "url",
Endpoint: redactURL(s.cfg.URL),
Mode: strings.TrimSpace(s.cfg.Mode),
},
}
}

func (s *Source) Start(ctx context.Context) error {
if !s.started.CompareAndSwap(false, true) {
return nil
}

var (
rx *aoiprxkit.SRTReceiver
err error
)
if s.opener != nil {
rx, err = aoiprxkit.NewSRTReceiverWithOpener(s.cfg, s.opener, s.handleFrame)
} else {
rx, err = aoiprxkit.NewSRTReceiver(s.cfg, s.handleFrame)
}
if err != nil {
s.started.Store(false)
s.connected.Store(false)
s.state.Store("failed")
s.setError(err)
return err
}

runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.mu.Lock()
s.rx = rx
s.mu.Unlock()
s.lastError.Store("")
s.connected.Store(false)
s.state.Store("connecting")

if err := rx.Start(runCtx); err != nil {
s.started.Store(false)
s.connected.Store(false)
s.state.Store("failed")
s.setError(err)
return err
}
s.connected.Store(true)
s.state.Store("running")

s.wg.Add(1)
go func() {
defer s.wg.Done()
<-runCtx.Done()
_ = s.stopReceiver()
s.connected.Store(false)
s.closeChannels()
}()
return nil
}

func (s *Source) Stop() error {
if !s.started.CompareAndSwap(true, false) {
return nil
}
if s.cancel != nil {
s.cancel()
}
if err := s.stopReceiver(); err != nil {
s.setError(err)
s.state.Store("failed")
}
s.wg.Wait()
s.connected.Store(false)
state, _ := s.state.Load().(string)
if state != "failed" {
s.state.Store("stopped")
}
return nil
}

func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
func (s *Source) Errors() <-chan error { return s.errs }

func (s *Source) Stats() ingest.SourceStats {
state, _ := s.state.Load().(string)
last := s.lastChunkAtUnix.Load()
errStr, _ := s.lastError.Load().(string)
var lastChunkAt time.Time
if last > 0 {
lastChunkAt = time.Unix(0, last)
}
return ingest.SourceStats{
State: state,
Connected: s.connected.Load(),
LastChunkAt: lastChunkAt,
ChunksIn: s.chunksIn.Load(),
SamplesIn: s.samplesIn.Load(),
Overflows: s.overflows.Load(),
Discontinuities: s.discontinuities.Load(),
TransportLoss: s.transportLoss.Load(),
Reorders: s.reorders.Load(),
LastError: errStr,
}
}

func (s *Source) handleFrame(frame aoiprxkit.PCMFrame) {
if !s.started.Load() {
return
}

discontinuity := false
s.seqMu.Lock()
if s.lastHasVal {
expected := s.lastFrame + 1
if frame.SequenceNumber != expected {
discontinuity = true
delta := int16(frame.SequenceNumber - expected)
if delta > 0 {
s.transportLoss.Add(uint64(delta))
} else {
s.reorders.Add(1)
}
}
}
s.lastFrame = frame.SequenceNumber
s.lastHasVal = true
s.seqMu.Unlock()

chunk := ingest.PCMChunk{
Samples: append([]int32(nil), frame.Samples...),
Channels: frame.Channels,
SampleRateHz: frame.SampleRateHz,
Sequence: s.nextSeq.Add(1) - 1,
Timestamp: frame.ReceivedAt,
SourceID: s.id,
Discontinuity: discontinuity,
}

s.chunksIn.Add(1)
s.samplesIn.Add(uint64(len(chunk.Samples)))
s.lastChunkAtUnix.Store(time.Now().UnixNano())
if discontinuity {
s.discontinuities.Add(1)
}

select {
case s.chunks <- chunk:
default:
s.overflows.Add(1)
s.discontinuities.Add(1)
s.setError(io.ErrShortBuffer)
s.emitError(fmt.Errorf("srt chunk buffer overflow"))
}
}

func (s *Source) stopReceiver() error {
s.mu.Lock()
rx := s.rx
s.rx = nil
s.mu.Unlock()
if rx == nil {
return nil
}
return rx.Stop()
}

func (s *Source) closeChannels() {
s.closeOnce.Do(func() {
close(s.chunks)
close(s.errs)
})
}

func (s *Source) setError(err error) {
if err == nil {
return
}
s.lastError.Store(err.Error())
s.emitError(err)
}

func (s *Source) emitError(err error) {
if err == nil {
return
}
select {
case s.errs <- err:
default:
}
}

func redactURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return trimmed
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
return u.String()
}

+ 123
- 0
internal/ingest/adapters/srt/source_test.go Переглянути файл

@@ -0,0 +1,123 @@
package srt

import (
"bytes"
"context"
"io"
"testing"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/ingest"
)

type readCloser struct{ io.Reader }

func (r readCloser) Close() error { return nil }

func TestSourceEmitsChunksFromSRTFrames(t *testing.T) {
var stream bytes.Buffer
if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 10, 100, []int32{1, 2, 3, 4}); err != nil {
t.Fatalf("write packet 1: %v", err)
}
if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 12, 200, []int32{5, 6, 7, 8}); err != nil {
t.Fatalf("write packet 2: %v", err)
}

src := New("srt-test", aoiprxkit.SRTConfig{
URL: "srt://127.0.0.1:9000?mode=listener",
Mode: "listener",
SampleRateHz: 48000,
Channels: 2,
}, WithConnOpener(func(ctx context.Context, cfg aoiprxkit.SRTConfig) (io.ReadCloser, error) {
_ = ctx
_ = cfg
return readCloser{Reader: bytes.NewReader(stream.Bytes())}, nil
}))

desc := src.Descriptor()
if desc.Origin == nil {
t.Fatalf("expected descriptor origin")
}
if desc.Origin.Kind != "url" {
t.Fatalf("origin kind=%q want url", desc.Origin.Kind)
}
if desc.Origin.Endpoint != "srt://127.0.0.1:9000" {
t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint)
}
if desc.Origin.Mode != "listener" {
t.Fatalf("origin mode=%q want listener", desc.Origin.Mode)
}

if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

chunk1 := readChunk(t, src.Chunks())
if chunk1.SourceID != "srt-test" {
t.Fatalf("source id=%q want srt-test", chunk1.SourceID)
}
if chunk1.Channels != 2 || chunk1.SampleRateHz != 48000 {
t.Fatalf("shape=%d/%d", chunk1.Channels, chunk1.SampleRateHz)
}
if chunk1.Discontinuity {
t.Fatalf("first chunk should not be discontinuity")
}
assertSamples(t, chunk1.Samples, []int32{1, 2, 3, 4})

chunk2 := readChunk(t, src.Chunks())
if !chunk2.Discontinuity {
t.Fatalf("second chunk should be marked discontinuity on seq gap")
}
assertSamples(t, chunk2.Samples, []int32{5, 6, 7, 8})

stats := src.Stats()
if stats.State != "running" {
t.Fatalf("state=%q want running", stats.State)
}
if !stats.Connected {
t.Fatalf("connected=false want true")
}
if stats.ChunksIn != 2 {
t.Fatalf("chunksIn=%d want 2", stats.ChunksIn)
}
if stats.SamplesIn != 8 {
t.Fatalf("samplesIn=%d want 8", stats.SamplesIn)
}
if stats.TransportLoss != 1 {
t.Fatalf("transportLoss=%d want 1", stats.TransportLoss)
}
if stats.Discontinuities < 1 {
t.Fatalf("discontinuities=%d want >=1", stats.Discontinuities)
}
if stats.LastChunkAt.IsZero() {
t.Fatalf("lastChunkAt should be set")
}
}

func readChunk(t *testing.T, ch <-chan ingest.PCMChunk) ingest.PCMChunk {
t.Helper()
select {
case chunk, ok := <-ch:
if !ok {
t.Fatal("chunk channel closed")
}
return chunk
case <-time.After(500 * time.Millisecond):
t.Fatal("timeout waiting for chunk")
return ingest.PCMChunk{}
}
}

func assertSamples(t *testing.T, got, want []int32) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("sample len=%d want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sample[%d]=%d want %d", i, got[i], want[i])
}
}
}

+ 181
- 0
internal/ingest/adapters/stdinpcm/source.go Переглянути файл

@@ -0,0 +1,181 @@
package stdinpcm

import (
"context"
"encoding/binary"
"fmt"
"io"
"sync"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
)

type Source struct {
id string
reader io.Reader
sampleRate int
channels int
chunkFrames int

chunks chan ingest.PCMChunk
errs chan error

cancel context.CancelFunc
wg sync.WaitGroup

state atomic.Value // string
chunksIn atomic.Uint64
samplesIn atomic.Uint64
discontinuities atomic.Uint64
lastChunkAtUnix atomic.Int64
lastError atomic.Value // string
}

func New(id string, reader io.Reader, sampleRate, channels, chunkFrames int) *Source {
if id == "" {
id = "stdin"
}
if sampleRate <= 0 {
sampleRate = 44100
}
if channels <= 0 {
channels = 2
}
if chunkFrames <= 0 {
chunkFrames = 1024
}

s := &Source{
id: id,
reader: reader,
sampleRate: sampleRate,
channels: channels,
chunkFrames: chunkFrames,
chunks: make(chan ingest.PCMChunk, 8),
errs: make(chan error, 4),
}
s.state.Store("idle")
return s
}

func (s *Source) Descriptor() ingest.SourceDescriptor {
return ingest.SourceDescriptor{
ID: s.id,
Kind: "stdin-pcm",
Family: "raw",
Transport: "stdin",
Codec: "pcm_s16le",
Channels: s.channels,
SampleRateHz: s.sampleRate,
Detail: "S16LE interleaved PCM via stdin",
}
}

func (s *Source) Start(ctx context.Context) error {
if s.reader == nil {
return fmt.Errorf("stdin source reader is nil")
}
runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.state.Store("running")

s.wg.Add(1)
go s.readLoop(runCtx)
return nil
}

func (s *Source) Stop() error {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.state.Store("stopped")
return nil
}

func (s *Source) Chunks() <-chan ingest.PCMChunk { return s.chunks }
func (s *Source) Errors() <-chan error { return s.errs }

func (s *Source) Stats() ingest.SourceStats {
state, _ := s.state.Load().(string)
last := s.lastChunkAtUnix.Load()
errStr, _ := s.lastError.Load().(string)
var lastChunkAt time.Time
if last > 0 {
lastChunkAt = time.Unix(0, last)
}
return ingest.SourceStats{
State: state,
Connected: state == "running",
LastChunkAt: lastChunkAt,
ChunksIn: s.chunksIn.Load(),
SamplesIn: s.samplesIn.Load(),
Discontinuities: s.discontinuities.Load(),
LastError: errStr,
}
}

func (s *Source) readLoop(ctx context.Context) {
defer s.wg.Done()
defer close(s.errs)
defer close(s.chunks)

frameBytes := s.channels * 2
buf := make([]byte, s.chunkFrames*frameBytes)
seq := uint64(0)

for {
select {
case <-ctx.Done():
return
default:
}

n, err := io.ReadAtLeast(s.reader, buf, frameBytes)
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
if n > 0 {
s.emitChunk(buf[:n], seq)
}
s.state.Store("stopped")
return
}
wrapped := fmt.Errorf("stdin read: %w", err)
s.lastError.Store(wrapped.Error())
s.state.Store("failed")
select {
case s.errs <- wrapped:
default:
}
return
}
s.emitChunk(buf[:n], seq)
seq++
}
}

func (s *Source) emitChunk(data []byte, seq uint64) {
samples := make([]int32, 0, len(data)/2)
for i := 0; i+1 < len(data); i += 2 {
v := int16(binary.LittleEndian.Uint16(data[i : i+2]))
samples = append(samples, int32(v)<<16)
}
chunk := ingest.PCMChunk{
Samples: samples,
Channels: s.channels,
SampleRateHz: s.sampleRate,
Sequence: seq,
Timestamp: time.Now(),
SourceID: s.id,
}
s.chunksIn.Add(1)
s.samplesIn.Add(uint64(len(samples)))
s.lastChunkAtUnix.Store(time.Now().UnixNano())
select {
case s.chunks <- chunk:
default:
s.discontinuities.Add(1)
}
}

+ 33
- 0
internal/ingest/adapters/stdinpcm/source_test.go Переглянути файл

@@ -0,0 +1,33 @@
package stdinpcm

import (
"bytes"
"context"
"testing"
"time"
)

func TestSourceReadsPCMChunks(t *testing.T) {
// Two stereo frames (S16LE): [0,0] and [32767,-32768]
raw := []byte{
0x00, 0x00, 0x00, 0x00,
0xff, 0x7f, 0x00, 0x80,
}
src := New("stdin-test", bytes.NewReader(raw), 44100, 2, 2)
if err := src.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer src.Stop()

select {
case chunk := <-src.Chunks():
if chunk.Channels != 2 {
t.Fatalf("channels=%d", chunk.Channels)
}
if len(chunk.Samples) != 4 {
t.Fatalf("samples=%d want 4", len(chunk.Samples))
}
case <-time.After(1 * time.Second):
t.Fatal("timed out waiting for chunk")
}
}

+ 45
- 0
internal/ingest/convert.go Переглянути файл

@@ -0,0 +1,45 @@
package ingest

import (
"fmt"
"math"

"github.com/jan/fm-rds-tx/internal/audio"
)

const int32AbsMax = 2147483648.0

func ChunkToFrames(chunk PCMChunk) ([]audio.Frame, error) {
if chunk.Channels != 1 && chunk.Channels != 2 {
return nil, fmt.Errorf("unsupported channel count: %d", chunk.Channels)
}
if chunk.Channels <= 0 {
return nil, fmt.Errorf("invalid channel count: %d", chunk.Channels)
}
if len(chunk.Samples)%chunk.Channels != 0 {
return nil, fmt.Errorf("invalid interleaved sample count: %d for channels=%d", len(chunk.Samples), chunk.Channels)
}

frames := make([]audio.Frame, len(chunk.Samples)/chunk.Channels)
switch chunk.Channels {
case 1:
for i := range frames {
s := normalizePCM(chunk.Samples[i])
frames[i] = audio.NewFrame(s, s)
}
case 2:
for i := range frames {
off := i * 2
l := normalizePCM(chunk.Samples[off])
r := normalizePCM(chunk.Samples[off+1])
frames[i] = audio.NewFrame(l, r)
}
}
return frames, nil
}

func normalizePCM(v int32) audio.Sample {
norm := float64(v) / int32AbsMax
norm = math.Max(float64(audio.SampleMin), math.Min(float64(audio.SampleMax), norm))
return audio.Sample(norm)
}

+ 55
- 0
internal/ingest/convert_test.go Переглянути файл

@@ -0,0 +1,55 @@
package ingest

import "testing"

func TestChunkToFramesMonoDuplicate(t *testing.T) {
frames, err := ChunkToFrames(PCMChunk{
Channels: 1,
Samples: []int32{2147483647, -2147483648},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(frames) != 2 {
t.Fatalf("expected 2 frames, got %d", len(frames))
}
if frames[0].L != frames[0].R {
t.Fatalf("expected mono duplication, got L=%v R=%v", frames[0].L, frames[0].R)
}
if frames[1].L != frames[1].R {
t.Fatalf("expected mono duplication, got L=%v R=%v", frames[1].L, frames[1].R)
}
}

func TestChunkToFramesStereoPassThrough(t *testing.T) {
frames, err := ChunkToFrames(PCMChunk{
Channels: 2,
Samples: []int32{100, 200, -300, -400},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(frames) != 2 {
t.Fatalf("expected 2 frames, got %d", len(frames))
}
if !(frames[0].L < frames[0].R) {
t.Fatalf("expected left < right for first frame, got %v >= %v", frames[0].L, frames[0].R)
}
if !(frames[1].L > frames[1].R) {
t.Fatalf("expected left > right for second frame, got %v <= %v", frames[1].L, frames[1].R)
}
}

func TestChunkToFramesRejectsUnsupportedChannels(t *testing.T) {
_, err := ChunkToFrames(PCMChunk{Channels: 3, Samples: []int32{1, 2, 3}})
if err == nil {
t.Fatal("expected error for unsupported channels")
}
}

func TestChunkToFramesRejectsInvalidInterleaving(t *testing.T) {
_, err := ChunkToFrames(PCMChunk{Channels: 2, Samples: []int32{1, 2, 3}})
if err == nil {
t.Fatal("expected error for invalid interleaving")
}
}

+ 20
- 0
internal/ingest/decoder/aac/decoder.go Переглянути файл

@@ -0,0 +1,20 @@
package aac

import (
"context"
"fmt"
"io"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

type Decoder struct{}

func New() *Decoder { return &Decoder{} }

func (d *Decoder) Name() string { return "aac-native" }

func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
return fmt.Errorf("%w: aac native decoder not wired yet", decoder.ErrUnsupported)
}

+ 66
- 0
internal/ingest/decoder/decoder.go Переглянути файл

@@ -0,0 +1,66 @@
package decoder

import (
"context"
"fmt"
"io"
"strings"

"github.com/jan/fm-rds-tx/internal/ingest"
)

var ErrUnsupported = fmt.Errorf("decoder unsupported")

type StreamMeta struct {
ContentType string
SampleRateHz int
Channels int
SourceID string
}

type Decoder interface {
Name() string
DecodeStream(ctx context.Context, r io.Reader, meta StreamMeta, emit func(ingest.PCMChunk) error) error
}

type Builder func() Decoder

type Registry struct {
byName map[string]Builder
}

func NewRegistry() *Registry {
return &Registry{byName: map[string]Builder{}}
}

func (r *Registry) Register(name string, builder Builder) {
if r == nil || builder == nil {
return
}
r.byName[strings.ToLower(strings.TrimSpace(name))] = builder
}

func (r *Registry) Create(name string) (Decoder, error) {
if r == nil {
return nil, fmt.Errorf("%w: registry nil", ErrUnsupported)
}
builder, ok := r.byName[strings.ToLower(strings.TrimSpace(name))]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnsupported, name)
}
return builder(), nil
}

func (r *Registry) SelectByContentType(contentType string) (Decoder, error) {
ct := strings.ToLower(strings.TrimSpace(contentType))
switch {
case strings.Contains(ct, "mpeg"), strings.Contains(ct, "mp3"):
return r.Create("mp3")
case strings.Contains(ct, "ogg"), strings.Contains(ct, "vorbis"):
return r.Create("oggvorbis")
case strings.Contains(ct, "aac"), strings.Contains(ct, "adts"):
return r.Create("aac")
default:
return nil, fmt.Errorf("%w: content-type=%s", ErrUnsupported, contentType)
}
}

+ 54
- 0
internal/ingest/decoder/decoder_test.go Переглянути файл

@@ -0,0 +1,54 @@
package decoder

import (
"context"
"errors"
"io"
"testing"

"github.com/jan/fm-rds-tx/internal/ingest"
)

type fakeDecoder struct{ name string }

func (d *fakeDecoder) Name() string { return d.name }

func (d *fakeDecoder) DecodeStream(_ context.Context, _ io.Reader, _ StreamMeta, _ func(ingest.PCMChunk) error) error {
return nil
}

func TestRegistrySelectByContentType(t *testing.T) {
r := NewRegistry()
r.Register("mp3", func() Decoder { return &fakeDecoder{name: "mp3"} })
r.Register("oggvorbis", func() Decoder { return &fakeDecoder{name: "ogg"} })
r.Register("aac", func() Decoder { return &fakeDecoder{name: "aac"} })

tests := []struct {
ct string
want string
}{
{"audio/mpeg", "mp3"},
{"audio/mpeg; charset=utf-8", "mp3"},
{"application/ogg", "ogg"},
{"audio/ogg;codecs=vorbis", "ogg"},
{"audio/aac", "aac"},
{"audio/aacp", "aac"},
}
for _, tt := range tests {
dec, err := r.SelectByContentType(tt.ct)
if err != nil {
t.Fatalf("content-type %s: %v", tt.ct, err)
}
if dec.Name() != tt.want {
t.Fatalf("content-type %s: got %s want %s", tt.ct, dec.Name(), tt.want)
}
}
}

func TestRegistrySelectByContentTypeUnsupported(t *testing.T) {
r := NewRegistry()
_, err := r.SelectByContentType("application/octet-stream")
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}

+ 157
- 0
internal/ingest/decoder/fallback/ffmpeg.go Переглянути файл

@@ -0,0 +1,157 @@
package fallback

import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

type FFmpegDecoder struct{}

func NewFFmpeg() *FFmpegDecoder { return &FFmpegDecoder{} }

func (d *FFmpegDecoder) Name() string { return "ffmpeg-fallback" }

func (d *FFmpegDecoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error {
if r == nil {
return fmt.Errorf("%w: ffmpeg decoder stream reader is nil", decoder.ErrUnsupported)
}
if emit == nil {
return fmt.Errorf("%w: ffmpeg decoder emit callback is nil", decoder.ErrUnsupported)
}

sampleRate := meta.SampleRateHz
if sampleRate <= 0 {
sampleRate = 44100
}
channels := meta.Channels
if channels <= 0 {
channels = 2
}

cmd := exec.CommandContext(ctx,
"ffmpeg",
"-hide_banner", "-loglevel", "error",
"-i", "pipe:0",
"-f", "s16le",
"-acodec", "pcm_s16le",
"-ac", fmt.Sprintf("%d", channels),
"-ar", fmt.Sprintf("%d", sampleRate),
"pipe:1",
)

stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("ffmpeg stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("ffmpeg stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("ffmpeg stderr pipe: %w", err)
}

if err := cmd.Start(); err != nil {
if errorsIsNotFound(err) {
return fmt.Errorf("%w: ffmpeg executable not found in PATH", decoder.ErrUnsupported)
}
return fmt.Errorf("ffmpeg start: %w", err)
}

errCh := make(chan error, 2)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_, copyErr := io.Copy(stdin, r)
_ = stdin.Close()
if copyErr != nil && ctx.Err() == nil {
errCh <- fmt.Errorf("ffmpeg stdin copy: %w", copyErr)
}
}()

stderrData, _ := io.ReadAll(stderr)
readErr := d.readPCM(ctx, stdout, sampleRate, channels, meta.SourceID, emit)
waitErr := cmd.Wait()
wg.Wait()
close(errCh)

for e := range errCh {
if e != nil {
return e
}
}
if readErr != nil {
return readErr
}
if waitErr != nil && ctx.Err() == nil {
msg := strings.TrimSpace(string(stderrData))
if msg != "" {
return fmt.Errorf("ffmpeg decode: %w (%s)", waitErr, msg)
}
return fmt.Errorf("ffmpeg decode: %w", waitErr)
}
return nil
}

func (d *FFmpegDecoder) readPCM(ctx context.Context, r io.Reader, sampleRate, channels int, sourceID string, emit func(ingest.PCMChunk) error) error {
const chunkFrames = 1024
frameBytes := channels * 2
buf := make([]byte, chunkFrames*frameBytes)
seq := uint64(0)
for {
select {
case <-ctx.Done():
return nil
default:
}
n, err := io.ReadAtLeast(r, buf, frameBytes)
if err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
if n > 0 {
if emitErr := emitPCM(buf[:n], seq, sampleRate, channels, sourceID, emit); emitErr != nil {
return emitErr
}
}
return nil
}
return fmt.Errorf("ffmpeg read pcm: %w", err)
}
if emitErr := emitPCM(buf[:n], seq, sampleRate, channels, sourceID, emit); emitErr != nil {
return emitErr
}
seq++
}
}

func emitPCM(data []byte, seq uint64, sampleRate, channels int, sourceID string, emit func(ingest.PCMChunk) error) error {
samples := make([]int32, 0, len(data)/2)
for i := 0; i+1 < len(data); i += 2 {
v := int16(binary.LittleEndian.Uint16(data[i : i+2]))
samples = append(samples, int32(v)<<16)
}
return emit(ingest.PCMChunk{
Samples: samples,
Channels: channels,
SampleRateHz: sampleRate,
Sequence: seq,
Timestamp: time.Now(),
SourceID: sourceID,
})
}

func errorsIsNotFound(err error) bool {
var execErr *exec.Error
return err != nil && (errors.As(err, &execErr) || strings.Contains(strings.ToLower(err.Error()), "executable file not found"))
}

+ 58
- 0
internal/ingest/decoder/helpers.go Переглянути файл

@@ -0,0 +1,58 @@
package decoder

import (
"encoding/binary"
"math"
"time"

"github.com/jan/fm-rds-tx/internal/ingest"
)

const defaultSampleRateHz = 44100

func ResolveSampleRate(decodedSampleRateHz int, meta StreamMeta) int {
if decodedSampleRateHz > 0 {
return decodedSampleRateHz
}
if meta.SampleRateHz > 0 {
return meta.SampleRateHz
}
return defaultSampleRateHz
}

func BuildChunk(samples []int32, channels, sampleRateHz int, seq uint64, sourceID string) ingest.PCMChunk {
return ingest.PCMChunk{
Samples: samples,
Channels: channels,
SampleRateHz: sampleRateHz,
Sequence: seq,
Timestamp: time.Now(),
SourceID: sourceID,
}
}

func PCM16LEToPCM32(in []byte) []int32 {
out := make([]int32, 0, len(in)/2)
for i := 0; i+1 < len(in); i += 2 {
v := int16(binary.LittleEndian.Uint16(in[i : i+2]))
out = append(out, int32(v)<<16)
}
return out
}

func Float32ToPCM32(in []float32) []int32 {
out := make([]int32, len(in))
for i, sample := range in {
if sample > 1 {
sample = 1
} else if sample < -1 {
sample = -1
}
if sample == -1 {
out[i] = math.MinInt32
continue
}
out[i] = int32(sample * math.MaxInt32)
}
return out
}

+ 75
- 0
internal/ingest/decoder/mp3/decoder.go Переглянути файл

@@ -0,0 +1,75 @@
package mp3

import (
"context"
"fmt"
"io"

gomp3 "github.com/hajimehoshi/go-mp3"
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

type Decoder struct{}

func New() *Decoder { return &Decoder{} }

func (d *Decoder) Name() string { return "mp3-native" }

func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error {
if r == nil {
return fmt.Errorf("%w: mp3 decoder stream reader is nil", decoder.ErrUnsupported)
}
if emit == nil {
return fmt.Errorf("%w: mp3 decoder emit callback is nil", decoder.ErrUnsupported)
}

dec, err := gomp3.NewDecoder(r)
if err != nil {
return fmt.Errorf("%w: mp3 decoder init: %v", decoder.ErrUnsupported, err)
}

const channels = 2 // go-mp3 always decodes to stereo s16le
sampleRate := decoder.ResolveSampleRate(dec.SampleRate(), meta)

const chunkFrames = 1024
const frameBytes = channels * 2
buf := make([]byte, chunkFrames*frameBytes)
seq := uint64(0)

for {
select {
case <-ctx.Done():
return nil
default:
}

n, readErr := io.ReadAtLeast(dec, buf, frameBytes)
if readErr != nil {
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
if n > 0 {
if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("mp3 decoder read pcm: %w", readErr)
}

if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil {
return err
}
seq++
}
}

func emitChunk(data []byte, seq uint64, sampleRate int, sourceID string, emit func(ingest.PCMChunk) error) error {
return emit(decoder.BuildChunk(
decoder.PCM16LEToPCM32(data),
2,
sampleRate,
seq,
sourceID,
))
}

+ 60
- 0
internal/ingest/decoder/mp3/decoder_test.go Переглянути файл

@@ -0,0 +1,60 @@
package mp3

import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

func TestDecodeStream(t *testing.T) {
tonePath := filepath.Join("testdata", "tone_44k_stereo.mp3")
data, err := os.ReadFile(tonePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}

var chunks []ingest.PCMChunk
d := New()
err = d.DecodeStream(context.Background(), bytes.NewReader(data), decoder.StreamMeta{
ContentType: "audio/mpeg",
SourceID: "mp3-test",
}, func(c ingest.PCMChunk) error {
chunks = append(chunks, c)
return nil
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected chunks")
}
if chunks[0].Channels != 2 {
t.Fatalf("channels=%d want 2", chunks[0].Channels)
}
if chunks[0].SampleRateHz != 44100 {
t.Fatalf("sampleRate=%d want 44100", chunks[0].SampleRateHz)
}
if len(chunks[0].Samples) == 0 {
t.Fatal("expected samples in first chunk")
}
}

func TestDecodeStreamNilReader(t *testing.T) {
err := New().DecodeStream(context.Background(), nil, decoder.StreamMeta{}, func(ingest.PCMChunk) error { return nil })
if !errors.Is(err, decoder.ErrUnsupported) {
t.Fatalf("expected unsupported, got %v", err)
}
}

func TestDecodeStreamNilEmit(t *testing.T) {
err := New().DecodeStream(context.Background(), bytes.NewReader([]byte("not-mp3")), decoder.StreamMeta{}, nil)
if !errors.Is(err, decoder.ErrUnsupported) {
t.Fatalf("expected unsupported, got %v", err)
}
}

BIN
internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3 Переглянути файл


+ 76
- 0
internal/ingest/decoder/oggvorbis/decoder.go Переглянути файл

@@ -0,0 +1,76 @@
package oggvorbis

import (
"context"
"fmt"
"io"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
libvorbis "github.com/jfreymuth/oggvorbis"
)

type Decoder struct{}

func New() *Decoder { return &Decoder{} }

func (d *Decoder) Name() string { return "oggvorbis-native" }

func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error {
if r == nil {
return fmt.Errorf("%w: ogg/vorbis decoder stream reader is nil", decoder.ErrUnsupported)
}
if emit == nil {
return fmt.Errorf("%w: ogg/vorbis decoder emit callback is nil", decoder.ErrUnsupported)
}

dec, err := libvorbis.NewReader(r)
if err != nil {
return fmt.Errorf("%w: ogg/vorbis decoder init: %v", decoder.ErrUnsupported, err)
}

channels := dec.Channels()
if channels <= 0 {
if meta.Channels > 0 {
channels = meta.Channels
} else {
return fmt.Errorf("%w: ogg/vorbis decoder invalid channel count", decoder.ErrUnsupported)
}
}

sampleRate := decoder.ResolveSampleRate(dec.SampleRate(), meta)

const chunkFrames = 1024
buf := make([]float32, chunkFrames*channels)
seq := uint64(0)

for {
select {
case <-ctx.Done():
return nil
default:
}

n, readErr := dec.Read(buf)
if n > 0 {
chunk := decoder.BuildChunk(
decoder.Float32ToPCM32(buf[:n]),
channels,
sampleRate,
seq,
meta.SourceID,
)
if err := emit(chunk); err != nil {
return err
}
seq++
}

if readErr != nil {
if readErr == io.EOF {
return nil
}
return fmt.Errorf("ogg/vorbis decoder read pcm: %w", readErr)
}
}
}

+ 60
- 0
internal/ingest/decoder/oggvorbis/decoder_test.go Переглянути файл

@@ -0,0 +1,60 @@
package oggvorbis

import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"

"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/decoder"
)

func TestDecodeStream(t *testing.T) {
tonePath := filepath.Join("testdata", "tone_44k_stereo.ogg")
data, err := os.ReadFile(tonePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}

var chunks []ingest.PCMChunk
d := New()
err = d.DecodeStream(context.Background(), bytes.NewReader(data), decoder.StreamMeta{
ContentType: "audio/ogg",
SourceID: "ogg-test",
}, func(c ingest.PCMChunk) error {
chunks = append(chunks, c)
return nil
})
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(chunks) == 0 {
t.Fatal("expected chunks")
}
if chunks[0].Channels != 2 {
t.Fatalf("channels=%d want 2", chunks[0].Channels)
}
if chunks[0].SampleRateHz != 44100 {
t.Fatalf("sampleRate=%d want 44100", chunks[0].SampleRateHz)
}
if len(chunks[0].Samples) == 0 {
t.Fatal("expected samples in first chunk")
}
}

func TestDecodeStreamNilReader(t *testing.T) {
err := New().DecodeStream(context.Background(), nil, decoder.StreamMeta{}, func(ingest.PCMChunk) error { return nil })
if !errors.Is(err, decoder.ErrUnsupported) {
t.Fatalf("expected unsupported, got %v", err)
}
}

func TestDecodeStreamNilEmit(t *testing.T) {
err := New().DecodeStream(context.Background(), bytes.NewReader([]byte("not-ogg")), decoder.StreamMeta{}, nil)
if !errors.Is(err, decoder.ErrUnsupported) {
t.Fatalf("expected unsupported, got %v", err)
}
}

BIN
internal/ingest/decoder/oggvorbis/testdata/tone_44k_stereo.ogg Переглянути файл


+ 294
- 0
internal/ingest/factory/factory.go Переглянути файл

@@ -0,0 +1,294 @@
package factory

import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/aoip"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/srt"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm"
)

type Deps struct {
Stdin io.Reader
HTTP *http.Client
SRTOpener aoiprxkit.SRTConnOpener
AES67ReceiverFactory aoip.ReceiverFactory
AES67Discover AES67DiscoverFunc
}

type AudioIngress interface {
WritePCM16(data []byte) (int, error)
}

type AES67DiscoverRequest struct {
StreamName string
Timeout time.Duration
InterfaceName string
SAPGroup string
SAPPort int
}

type AES67DiscoverFunc func(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error)

func BuildSource(ctx context.Context, cfg config.Config, deps Deps) (ingest.Source, AudioIngress, error) {
switch normalizeIngestKind(cfg.Ingest.Kind) {
case "", "none":
return nil, nil, nil
case "stdin", "stdin-pcm":
reader := deps.Stdin
if reader == nil {
reader = os.Stdin
}
src := stdinpcm.New("stdin-main", reader, cfg.Ingest.Stdin.SampleRateHz, cfg.Ingest.Stdin.Channels, 1024)
return src, nil, nil
case "http-raw":
src := httpraw.New("http-raw-main", cfg.Ingest.HTTPRaw.SampleRateHz, cfg.Ingest.HTTPRaw.Channels)
return src, src, nil
case "icecast":
src := icecast.New(
"icecast-main",
cfg.Ingest.Icecast.URL,
deps.HTTP,
icecast.ReconnectConfig{
Enabled: cfg.Ingest.Reconnect.Enabled,
InitialBackoffMs: cfg.Ingest.Reconnect.InitialBackoffMs,
MaxBackoffMs: cfg.Ingest.Reconnect.MaxBackoffMs,
},
icecast.WithDecoderPreference(cfg.Ingest.Icecast.Decoder),
)
return src, nil, nil
case "srt":
srtCfg := aoiprxkit.SRTConfig{
URL: cfg.Ingest.SRT.URL,
Mode: cfg.Ingest.SRT.Mode,
SampleRateHz: cfg.Ingest.SRT.SampleRateHz,
Channels: cfg.Ingest.SRT.Channels,
}
opts := []srt.Option{}
if deps.SRTOpener != nil {
opts = append(opts, srt.WithConnOpener(deps.SRTOpener))
}
src := srt.New("srt-main", srtCfg, opts...)
return src, nil, nil
case "aes67", "aoip", "aoip-rtp":
aoipCfg, detail, origin, err := buildAES67Config(ctx, cfg, deps)
if err != nil {
return nil, nil, err
}
opts := []aoip.Option{}
if deps.AES67ReceiverFactory != nil {
opts = append(opts, aoip.WithReceiverFactory(deps.AES67ReceiverFactory))
}
if detail != "" {
opts = append(opts, aoip.WithDetail(detail))
}
if origin != nil {
opts = append(opts, aoip.WithOrigin(*origin))
}
src := aoip.New("aes67-main", aoipCfg, opts...)
return src, nil, nil
default:
return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind)
}
}

func SampleRateForKind(cfg config.Config) int {
switch normalizeIngestKind(cfg.Ingest.Kind) {
case "stdin", "stdin-pcm":
if cfg.Ingest.Stdin.SampleRateHz > 0 {
return cfg.Ingest.Stdin.SampleRateHz
}
case "http-raw":
if cfg.Ingest.HTTPRaw.SampleRateHz > 0 {
return cfg.Ingest.HTTPRaw.SampleRateHz
}
case "icecast":
// 48000 Hz is the most common rate for modern Icecast streams.
// The ingest runtime will auto-correct to the actual decoded rate
// after the first PCM chunk arrives (see runtime.go handleChunk).
return 48000
case "srt":
if cfg.Ingest.SRT.SampleRateHz > 0 {
return cfg.Ingest.SRT.SampleRateHz
}
case "aes67", "aoip", "aoip-rtp":
if cfg.Ingest.AES67.SampleRateHz > 0 {
return cfg.Ingest.AES67.SampleRateHz
}
}
// Default to 48000 Hz: the correct rate for professional sources
// (SRT, AES67) and modern streams. The ingest runtime corrects this
// dynamically from the first decoded chunk for compressed sources.
return 48000
}

func normalizeIngestKind(kind string) string {
return strings.ToLower(strings.TrimSpace(kind))
}

func buildAES67Config(ctx context.Context, cfg config.Config, deps Deps) (aoiprxkit.Config, string, *ingest.SourceOrigin, error) {
base := aoiprxkit.DefaultConfig()
ing := cfg.Ingest.AES67
if strings.TrimSpace(ing.InterfaceName) != "" {
base.InterfaceName = strings.TrimSpace(ing.InterfaceName)
}
if ing.PayloadType >= 0 {
base.PayloadType = uint8(ing.PayloadType)
}
if ing.SampleRateHz > 0 {
base.SampleRateHz = ing.SampleRateHz
}
if ing.Channels > 0 {
base.Channels = ing.Channels
}
if strings.TrimSpace(ing.Encoding) != "" {
base.Encoding = strings.ToUpper(strings.TrimSpace(ing.Encoding))
}
if ing.PacketTimeMs > 0 {
base.PacketTime = time.Duration(ing.PacketTimeMs) * time.Millisecond
}
if ing.JitterDepthPackets > 0 {
base.JitterDepthPackets = ing.JitterDepthPackets
}
if ing.ReadBufferBytes > 0 {
base.ReadBufferBytes = ing.ReadBufferBytes
}

sdpText, discoveredStreamName, origin, err := resolveAES67SDP(ctx, ing, deps)
if err != nil {
return aoiprxkit.Config{}, "", nil, err
}

if sdpText != "" {
info, err := aoiprxkit.ParseMinimalSDP(sdpText)
if err != nil {
return aoiprxkit.Config{}, "", nil, fmt.Errorf("parse ingest.aes67 SDP: %w", err)
}
parsed, err := aoiprxkit.ConfigFromSDP(base, info)
if err != nil {
return aoiprxkit.Config{}, "", nil, fmt.Errorf("map ingest.aes67 SDP: %w", err)
}
detail := ""
endpoint := fmt.Sprintf("rtp://%s:%d", parsed.MulticastGroup, parsed.Port)
if discoveredStreamName != "" {
detail = fmt.Sprintf("rtp://%s:%d (SAP s=%s)", parsed.MulticastGroup, parsed.Port, discoveredStreamName)
}
if origin == nil {
origin = &ingest.SourceOrigin{}
}
if origin.Endpoint == "" {
origin.Endpoint = endpoint
}
return parsed, detail, origin, nil
}
if strings.TrimSpace(ing.MulticastGroup) != "" {
base.MulticastGroup = strings.TrimSpace(ing.MulticastGroup)
}
if ing.Port > 0 {
base.Port = ing.Port
}
if err := base.Validate(); err != nil {
return aoiprxkit.Config{}, "", nil, err
}
if origin == nil {
origin = &ingest.SourceOrigin{Kind: "manual"}
}
if origin.Endpoint == "" {
origin.Endpoint = fmt.Sprintf("rtp://%s:%d", base.MulticastGroup, base.Port)
}
return base, "", origin, nil
}

func resolveAES67SDP(ctx context.Context, ing config.IngestAES67Config, deps Deps) (string, string, *ingest.SourceOrigin, error) {
sdpText := strings.TrimSpace(ing.SDP)
if sdpText == "" && strings.TrimSpace(ing.SDPPath) != "" {
sdpPath := filepath.Clean(ing.SDPPath)
data, err := os.ReadFile(sdpPath)
if err != nil {
return "", "", nil, fmt.Errorf("read ingest.aes67.sdpPath: %w", err)
}
sdpText = string(data)
return sdpText, "", &ingest.SourceOrigin{
Kind: "sdp-file",
SDPPath: sdpPath,
}, nil
}
if sdpText != "" {
return sdpText, "", &ingest.SourceOrigin{
Kind: "sdp-inline",
}, nil
}

discoveryEnabled := ing.Discovery.Enabled || strings.TrimSpace(ing.Discovery.StreamName) != ""
if !discoveryEnabled {
return "", "", &ingest.SourceOrigin{
Kind: "manual",
}, nil
}
timeout := time.Duration(ing.Discovery.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 3 * time.Second
}
req := AES67DiscoverRequest{
StreamName: strings.TrimSpace(ing.Discovery.StreamName),
Timeout: timeout,
InterfaceName: strings.TrimSpace(ing.Discovery.InterfaceName),
SAPGroup: strings.TrimSpace(ing.Discovery.SAPGroup),
SAPPort: ing.Discovery.SAPPort,
}
discover := deps.AES67Discover
if discover == nil {
discover = discoverAES67ViaSAP
}
announcement, err := discover(ctx, req)
if err != nil {
return "", "", nil, fmt.Errorf("discover ingest.aes67 stream %q via SAP: %w", req.StreamName, err)
}
if strings.TrimSpace(announcement.SDP) == "" {
return "", "", nil, fmt.Errorf("discover ingest.aes67 stream %q via SAP: empty SDP payload", req.StreamName)
}
return announcement.SDP, req.StreamName, &ingest.SourceOrigin{
Kind: "sap-discovery",
StreamName: req.StreamName,
}, nil
}

func discoverAES67ViaSAP(ctx context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) {
if req.StreamName == "" {
return aoiprxkit.SAPAnnouncement{}, fmt.Errorf("stream name must not be empty")
}
listenerCfg := aoiprxkit.DefaultSAPListenerConfig()
if req.InterfaceName != "" {
listenerCfg.InterfaceName = req.InterfaceName
}
if req.SAPGroup != "" {
listenerCfg.Group = req.SAPGroup
}
if req.SAPPort > 0 {
listenerCfg.Port = req.SAPPort
}
sf, err := aoiprxkit.NewStreamFinder(listenerCfg)
if err != nil {
return aoiprxkit.SAPAnnouncement{}, err
}
if err := sf.Start(ctx); err != nil {
return aoiprxkit.SAPAnnouncement{}, err
}
defer sf.Stop()

waitCtx, cancel := context.WithTimeout(ctx, req.Timeout)
defer cancel()
return sf.WaitForStreamName(waitCtx, req.StreamName)
}

+ 271
- 0
internal/ingest/factory/factory_test.go Переглянути файл

@@ -0,0 +1,271 @@
package factory

import (
"bytes"
"context"
"errors"
"testing"
"time"

"aoiprxkit"

"github.com/jan/fm-rds-tx/internal/config"
)

func TestBuildSourceNone(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "none"
src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src != nil || ingress != nil {
t.Fatalf("expected nil source and ingress for kind=none")
}
}

func TestBuildSourceHTTPRawProvidesIngress(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "http-raw"
src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source")
}
if ingress == nil {
t.Fatalf("expected ingress for http-raw")
}
}

func TestBuildSourceKindIsNormalized(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = " HTTP-RAW "
src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil || ingress == nil {
t.Fatalf("expected source and ingress for normalized http-raw kind")
}
if got := src.Descriptor().Kind; got != "http-raw" {
t.Fatalf("source kind=%q want http-raw", got)
}
}

func TestBuildSourceStdin(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "stdin"
src, ingress, err := BuildSource(cfg, Deps{Stdin: bytes.NewReader(nil)})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source")
}
if ingress != nil {
t.Fatalf("expected no ingress for stdin")
}
if got := src.Descriptor().Kind; got != "stdin-pcm" {
t.Fatalf("source kind=%s", got)
}
}

func TestBuildSourceIcecastUsesDecoderPreference(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "icecast"
cfg.Ingest.Icecast.URL = "http://localhost:8000/stream"
cfg.Ingest.Icecast.Decoder = "ffmpeg"
src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source")
}
if ingress != nil {
t.Fatalf("expected no ingress for icecast")
}
if got := src.Descriptor().Codec; got != "ffmpeg" {
t.Fatalf("codec=%s want ffmpeg", got)
}
if got := src.Descriptor().Origin; got == nil || got.Kind != "url" {
t.Fatalf("expected icecast origin kind url, got %+v", got)
}
}

func TestBuildSourceSRT(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000?mode=listener"
cfg.Ingest.SRT.Mode = "listener"
cfg.Ingest.SRT.SampleRateHz = 48000
cfg.Ingest.SRT.Channels = 2

src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source")
}
if ingress != nil {
t.Fatalf("expected no ingress for srt")
}
if got := src.Descriptor().Kind; got != "srt" {
t.Fatalf("source kind=%s", got)
}
if got := src.Descriptor().Origin; got == nil || got.Kind != "url" || got.Mode != "listener" {
t.Fatalf("expected srt origin url/listener, got %+v", got)
}
}

func TestBuildSourceAES67(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = "239.69.10.20"
cfg.Ingest.AES67.Port = 5008
cfg.Ingest.AES67.PayloadType = 98
cfg.Ingest.AES67.SampleRateHz = 48000
cfg.Ingest.AES67.Channels = 2
cfg.Ingest.AES67.Encoding = "L24"
cfg.Ingest.AES67.PacketTimeMs = 1
cfg.Ingest.AES67.JitterDepthPackets = 6

src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source")
}
if ingress != nil {
t.Fatalf("expected no ingress for aes67")
}
if got := src.Descriptor().Kind; got != "aes67" {
t.Fatalf("source kind=%s", got)
}
}

func TestBuildSourceAES67FromInlineSDP(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.SDP = "v=0\r\ns=demo\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\na=ptime:1\r\n"

src, _, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
desc := src.Descriptor()
if desc.Transport != "rtp" {
t.Fatalf("transport=%q want rtp", desc.Transport)
}
if desc.SampleRateHz != 48000 || desc.Channels != 2 {
t.Fatalf("shape=%d/%d", desc.SampleRateHz, desc.Channels)
}
if desc.Origin == nil || desc.Origin.Kind != "sdp-inline" {
t.Fatalf("origin=%+v want sdp-inline", desc.Origin)
}
if desc.Origin.Endpoint != "rtp://239.10.20.30:5004" {
t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint)
}
}

func TestBuildSourceAES67WithDiscovery(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.Port = 0
cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN"
cfg.Ingest.AES67.Discovery.TimeoutMs = 1500

var gotReq AES67DiscoverRequest
src, _, err := BuildSource(cfg, Deps{
AES67Discover: func(_ context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) {
gotReq = req
return aoiprxkit.SAPAnnouncement{
SDP: "v=0\r\ns=AES67-MAIN\r\nc=IN IP4 239.10.20.30\r\nm=audio 5004 RTP/AVP 97\r\na=rtpmap:97 L24/48000/2\r\na=ptime:1\r\n",
}, nil
},
})
if err != nil {
t.Fatalf("build source: %v", err)
}
if gotReq.StreamName != "AES67-MAIN" {
t.Fatalf("discovery streamName=%q want AES67-MAIN", gotReq.StreamName)
}
if gotReq.Timeout != 1500*time.Millisecond {
t.Fatalf("discovery timeout=%s want 1500ms", gotReq.Timeout)
}
desc := src.Descriptor()
if desc.Detail != "rtp://239.10.20.30:5004 (SAP s=AES67-MAIN)" {
t.Fatalf("descriptor detail=%q", desc.Detail)
}
if desc.Origin == nil || desc.Origin.Kind != "sap-discovery" {
t.Fatalf("origin=%+v want sap-discovery", desc.Origin)
}
if desc.Origin.StreamName != "AES67-MAIN" {
t.Fatalf("origin streamName=%q", desc.Origin.StreamName)
}
}

func TestBuildSourceAES67DiscoveryError(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = ""
cfg.Ingest.AES67.Port = 0
cfg.Ingest.AES67.Discovery.StreamName = "AES67-MAIN"

_, _, err := BuildSource(cfg, Deps{
AES67Discover: func(_ context.Context, req AES67DiscoverRequest) (aoiprxkit.SAPAnnouncement, error) {
_ = req
return aoiprxkit.SAPAnnouncement{}, errors.New("timeout")
},
})
if err == nil {
t.Fatalf("expected discovery error")
}
}

func TestBuildSourceUnsupportedKind(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "nope"
_, _, err := BuildSource(cfg, Deps{})
if err == nil {
t.Fatalf("expected error")
}
}

func TestSampleRateForKind(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "stdin"
cfg.Ingest.Stdin.SampleRateHz = 48000
if got := SampleRateForKind(cfg); got != 48000 {
t.Fatalf("stdin sample rate=%d", got)
}

cfg.Ingest.Kind = "http-raw"
cfg.Ingest.HTTPRaw.SampleRateHz = 32000
if got := SampleRateForKind(cfg); got != 32000 {
t.Fatalf("http-raw sample rate=%d", got)
}

cfg.Ingest.Kind = "icecast"
if got := SampleRateForKind(cfg); got != 44100 {
t.Fatalf("icecast sample rate=%d", got)
}

cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.SampleRateHz = 48000
if got := SampleRateForKind(cfg); got != 48000 {
t.Fatalf("srt sample rate=%d", got)
}

cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.SampleRateHz = 32000
if got := SampleRateForKind(cfg); got != 32000 {
t.Fatalf("aes67 sample rate=%d", got)
}
}

+ 208
- 0
internal/ingest/factory/ingest_smoke_test.go Переглянути файл

@@ -0,0 +1,208 @@
package factory

import (
"bytes"
"context"
"io"
"testing"
"time"

"aoiprxkit"
"github.com/jan/fm-rds-tx/internal/audio"
"github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/ingest"
aoipad "github.com/jan/fm-rds-tx/internal/ingest/adapters/aoip"
)

type streamReadCloser struct{ io.Reader }

func (r streamReadCloser) Close() error { return nil }

type stubAES67Receiver struct {
onStart func()
}

func (r *stubAES67Receiver) Start(context.Context) error {
if r.onStart != nil {
r.onStart()
}
return nil
}

func (r *stubAES67Receiver) Stop() error { return nil }
func (r *stubAES67Receiver) Stats() aoiprxkit.Stats {
return aoiprxkit.Stats{}
}

func TestHTTPRawFactoryToRuntimeSmoke(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "http-raw"
cfg.Ingest.HTTPRaw.SampleRateHz = 44100
cfg.Ingest.HTTPRaw.Channels = 2

src, ingress, err := BuildSource(cfg, Deps{})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil || ingress == nil {
t.Fatalf("expected source and ingress for kind=http-raw")
}

sink := audio.NewStreamSource(128, cfg.Ingest.HTTPRaw.SampleRateHz)
rt := ingest.NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("runtime start: %v", err)
}
defer rt.Stop()

// Two stereo frames: L1,R1,L2,R2 (S16LE).
frames, err := ingress.WritePCM16([]byte{
0xE8, 0x03, 0x18, 0xFC,
0xD0, 0x07, 0x30, 0xF8,
})
if err != nil {
t.Fatalf("write pcm16: %v", err)
}
if frames != 2 {
t.Fatalf("frames=%d want 2", frames)
}

waitForSinkFrames(t, sink, 2)

stats := rt.Stats()
if stats.Active.Kind != "http-raw" {
t.Fatalf("active kind=%q want http-raw", stats.Active.Kind)
}
if stats.Source.ChunksIn != 1 {
t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn)
}
if stats.Source.SamplesIn != 4 {
t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn)
}
if stats.Runtime.State != "running" {
t.Fatalf("runtime state=%q want running", stats.Runtime.State)
}
if stats.Runtime.LastChunkAt.IsZero() {
t.Fatalf("runtime lastChunkAt should be set")
}
}

func TestSRTFactoryToRuntimeSmoke(t *testing.T) {
var stream bytes.Buffer
if err := aoiprxkit.WritePCM32Packet(&stream, 2, 48000, 2, 1, 480, []int32{11, -11, 22, -22}); err != nil {
t.Fatalf("write packet: %v", err)
}

cfg := config.Default()
cfg.Ingest.Kind = "srt"
cfg.Ingest.SRT.URL = "srt://127.0.0.1:9000?mode=listener"
cfg.Ingest.SRT.SampleRateHz = 48000
cfg.Ingest.SRT.Channels = 2

src, ingress, err := BuildSource(cfg, Deps{
SRTOpener: func(ctx context.Context, srtCfg aoiprxkit.SRTConfig) (io.ReadCloser, error) {
_ = ctx
_ = srtCfg
return streamReadCloser{Reader: bytes.NewReader(stream.Bytes())}, nil
},
})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source for kind=srt")
}
if ingress != nil {
t.Fatalf("expected no ingress for kind=srt")
}

sink := audio.NewStreamSource(128, cfg.Ingest.SRT.SampleRateHz)
rt := ingest.NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("runtime start: %v", err)
}
defer rt.Stop()

waitForSinkFrames(t, sink, 2)

stats := rt.Stats()
if stats.Active.Kind != "srt" {
t.Fatalf("active kind=%q want srt", stats.Active.Kind)
}
if stats.Source.ChunksIn != 1 {
t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn)
}
if stats.Source.SamplesIn != 4 {
t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn)
}
}

func TestAES67FactoryToRuntimeSmoke(t *testing.T) {
cfg := config.Default()
cfg.Ingest.Kind = "aes67"
cfg.Ingest.AES67.MulticastGroup = "239.10.20.30"
cfg.Ingest.AES67.Port = 5004
cfg.Ingest.AES67.SampleRateHz = 48000
cfg.Ingest.AES67.Channels = 2
cfg.Ingest.AES67.Encoding = "L24"
cfg.Ingest.AES67.PacketTimeMs = 1

var frameHandler aoiprxkit.FrameHandler
src, ingress, err := BuildSource(cfg, Deps{
AES67ReceiverFactory: func(_ aoiprxkit.Config, onFrame aoiprxkit.FrameHandler) (aoipad.ReceiverClient, error) {
frameHandler = onFrame
return &stubAES67Receiver{
onStart: func() {
frameHandler(aoiprxkit.PCMFrame{
SequenceNumber: 1,
SampleRateHz: 48000,
Channels: 2,
Samples: []int32{7, -7, 9, -9},
ReceivedAt: time.Now(),
})
},
}, nil
},
})
if err != nil {
t.Fatalf("build source: %v", err)
}
if src == nil {
t.Fatalf("expected source for kind=aes67")
}
if ingress != nil {
t.Fatalf("expected no ingress for kind=aes67")
}

sink := audio.NewStreamSource(128, cfg.Ingest.AES67.SampleRateHz)
rt := ingest.NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("runtime start: %v", err)
}
defer rt.Stop()

waitForSinkFrames(t, sink, 2)

stats := rt.Stats()
if stats.Active.Kind != "aes67" {
t.Fatalf("active kind=%q want aes67", stats.Active.Kind)
}
if stats.Source.ChunksIn != 1 {
t.Fatalf("source chunksIn=%d want 1", stats.Source.ChunksIn)
}
if stats.Source.SamplesIn != 4 {
t.Fatalf("source samplesIn=%d want 4", stats.Source.SamplesIn)
}
}

func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) {
t.Helper()
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) {
if sink.Available() >= minFrames {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames)
}

+ 488
- 0
internal/ingest/runtime.go Переглянути файл

@@ -0,0 +1,488 @@
package ingest

import (
"context"
"log"
"sync"
"sync/atomic"
"time"

"github.com/jan/fm-rds-tx/internal/audio"
)

type Runtime struct {
sink *audio.StreamSource
source Source
started atomic.Bool
onTitle func(string)
prebuffer time.Duration

ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup

work *frameBuffer
workSampleRate int
prebufferFrames int
gateOpen bool
seenChunk bool
lastDrainAt time.Time
drainAllowance float64

mu sync.RWMutex
active SourceDescriptor
stats RuntimeStats
}

type RuntimeOption func(*Runtime)

func WithStreamTitleHandler(handler func(string)) RuntimeOption {
return func(r *Runtime) {
r.onTitle = handler
}
}

func WithPrebuffer(d time.Duration) RuntimeOption {
return func(r *Runtime) {
if d < 0 {
d = 0
}
r.prebuffer = d
}
}

func WithPrebufferMs(ms int) RuntimeOption {
return func(r *Runtime) {
if ms < 0 {
ms = 0
}
r.prebuffer = time.Duration(ms) * time.Millisecond
}
}

func NewRuntime(sink *audio.StreamSource, src Source, opts ...RuntimeOption) *Runtime {
sampleRate := 44100
capacity := 1024
if sink != nil {
if sink.SampleRate > 0 {
sampleRate = sink.SampleRate
}
if sinkCap := sink.Stats().Capacity; sinkCap > 0 {
capacity = sinkCap * 2
}
}
r := &Runtime{
sink: sink,
source: src,
work: newFrameBuffer(capacity),
workSampleRate: sampleRate,
stats: RuntimeStats{
State: "idle",
},
}
for _, opt := range opts {
if opt != nil {
opt(r)
}
}
if r.workSampleRate > 0 && r.prebuffer > 0 {
r.prebufferFrames = int(r.prebuffer.Seconds() * float64(r.workSampleRate))
}
minCapacity := 256
if r.prebufferFrames > 0 && minCapacity < r.prebufferFrames*2 {
minCapacity = r.prebufferFrames * 2
}
if r.work == nil || r.work.capacity() < minCapacity {
r.work = newFrameBuffer(minCapacity)
}
r.updateBufferedStatsLocked()
return r
}

func (r *Runtime) Start(ctx context.Context) error {
if r.sink == nil {
r.mu.Lock()
r.stats.State = "failed"
r.mu.Unlock()
return nil
}
if r.source == nil {
r.mu.Lock()
r.stats.State = "idle"
r.mu.Unlock()
return nil
}
if !r.started.CompareAndSwap(false, true) {
return nil
}

r.ctx, r.cancel = context.WithCancel(ctx)
r.mu.Lock()
r.active = r.source.Descriptor()
r.stats.State = "starting"
r.stats.Prebuffering = false
r.stats.WriteBlocked = false
r.gateOpen = false
r.seenChunk = false
r.lastDrainAt = time.Now()
r.drainAllowance = 0
r.work.reset()
r.updateBufferedStatsLocked()
r.mu.Unlock()
if err := r.source.Start(r.ctx); err != nil {
r.started.Store(false)
r.mu.Lock()
r.stats.State = "failed"
r.mu.Unlock()
return err
}

r.wg.Add(1)
go r.run()
return nil
}

func (r *Runtime) Stop() error {
if !r.started.CompareAndSwap(true, false) {
return nil
}
if r.cancel != nil {
r.cancel()
}
if r.source != nil {
_ = r.source.Stop()
}
r.wg.Wait()
r.mu.Lock()
r.stats.State = "stopped"
r.mu.Unlock()
return nil
}

func (r *Runtime) run() {
defer r.wg.Done()

ch := r.source.Chunks()
errCh := r.source.Errors()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
var titleCh <-chan string
if src, ok := r.source.(StreamTitleSource); ok && r.onTitle != nil {
titleCh = src.StreamTitleUpdates()
}
for {
select {
case <-r.ctx.Done():
return
case err, ok := <-errCh:
if !ok {
errCh = nil
continue
}
if err == nil {
continue
}
r.mu.Lock()
r.stats.State = "degraded"
r.stats.Prebuffering = false
r.mu.Unlock()
case chunk, ok := <-ch:
if !ok {
r.mu.Lock()
r.stats.State = "stopped"
r.stats.Prebuffering = false
r.mu.Unlock()
return
}
r.handleChunk(chunk)
case <-ticker.C:
r.drainWorkingBuffer()
case title, ok := <-titleCh:
if !ok {
titleCh = nil
continue
}
r.onTitle(title)
}
}
}

func (r *Runtime) handleChunk(chunk PCMChunk) {
r.mu.Lock()
r.seenChunk = true

// Propagate the actual decoded sample rate to the sink and pacer the
// first time (or whenever) it differs from our working rate. This fixes
// the two-part rate-mismatch bug that appears when a native decoder
// (e.g. go-mp3) decodes a 48000 Hz stream while the StreamSource and
// StreamResampler were initialised assuming 44100 Hz:
//
// 1. The pacer (pacedDrainLimitLocked) was draining at the wrong rate,
// causing the work buffer to overflow → glitches.
// 2. The StreamResampler ratio (inputRate/outputRate) was computed from
// the stale sink.SampleRate, so every frame was played at the wrong
// pitch → audio too slow (44100/48000 ≈ 91.9 % speed).
//
// SetSampleRate writes atomically, so the StreamResampler's NextFrame()
// picks up the corrected ratio without any additional locking.
if chunk.SampleRateHz > 0 && chunk.SampleRateHz != r.workSampleRate {
prev := r.workSampleRate
r.workSampleRate = chunk.SampleRateHz
if r.sink != nil {
r.sink.SetSampleRate(chunk.SampleRateHz)
}
log.Printf("ingest: actual decoded sample rate %d Hz (was %d Hz) — resampler and pacer updated", chunk.SampleRateHz, prev)
}

r.mu.Unlock()

frames, err := ChunkToFrames(chunk)
if err != nil {
r.mu.Lock()
r.stats.ConvertErrors++
r.stats.State = "degraded"
r.mu.Unlock()
return
}
dropped := uint64(0)
for _, frame := range frames {
if !r.work.push(frame) {
dropped++
}
}
r.mu.Lock()
if chunk.SampleRateHz > 0 {
r.active.SampleRateHz = chunk.SampleRateHz
}
if chunk.Channels > 0 {
r.active.Channels = chunk.Channels
}
r.stats.LastChunkAt = time.Now()
r.stats.DroppedFrames += dropped
if dropped > 0 {
r.stats.State = "degraded"
}
r.updateBufferedStatsLocked()
r.mu.Unlock()
r.drainWorkingBuffer()
}

func (r *Runtime) drainWorkingBuffer() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if r.sink == nil {
r.resetDrainPacerLocked(now)
r.updateBufferedStatsLocked()
return
}
bufferedFrames := r.work.available()
if !r.gateOpen {
switch {
case bufferedFrames == 0:
if r.stats.State == "degraded" {
// Keep degraded visible until fresh audio recovers runtime.
} else if !r.seenChunk {
r.stats.State = "starting"
} else if r.stats.State != "degraded" {
r.stats.State = "running"
}
r.stats.Prebuffering = false
r.stats.WriteBlocked = false
r.resetDrainPacerLocked(now)
r.updateBufferedStatsLocked()
return
case r.prebufferFrames > 0 && bufferedFrames < r.prebufferFrames:
r.stats.State = "prebuffering"
r.stats.Prebuffering = true
r.stats.WriteBlocked = false
r.resetDrainPacerLocked(now)
r.updateBufferedStatsLocked()
return
default:
r.gateOpen = true
r.resetDrainPacerLocked(now)
}
}
writeBlocked := false
limit := r.pacedDrainLimitLocked(now, bufferedFrames)
written := 0
for written < limit && r.work.available() > 0 {
frame, ok := r.work.peek()
if !ok {
break
}
if !r.sink.WriteFrame(frame) {
writeBlocked = true
break
}
r.work.pop()
written++
}
if written > 0 {
r.drainAllowance -= float64(written)
if r.drainAllowance < 0 {
r.drainAllowance = 0
}
}
if r.work.available() == 0 && r.prebufferFrames > 0 {
// Re-arm the gate after dry-out to rebuild margin before resuming.
r.gateOpen = false
r.resetDrainPacerLocked(now)
}
r.stats.Prebuffering = false
r.stats.WriteBlocked = writeBlocked
if writeBlocked {
r.stats.State = "degraded"
} else {
r.stats.State = "running"
}
r.updateBufferedStatsLocked()
}

func (r *Runtime) pacedDrainLimitLocked(now time.Time, bufferedFrames int) int {
if bufferedFrames <= 0 {
return 0
}
// Use workSampleRate which is kept in sync with sink.SampleRate via
// handleChunk. This ensures the pacer drains at the actual decoded rate
// rather than the initial (potentially wrong) configured rate.
rate := r.workSampleRate
if r.sink != nil && r.sink.GetSampleRate() > 0 {
rate = r.sink.GetSampleRate()
}
if rate <= 0 {
return bufferedFrames
}
if !r.lastDrainAt.IsZero() {
elapsed := now.Sub(r.lastDrainAt)
if elapsed > 0 {
r.drainAllowance += elapsed.Seconds() * float64(rate)
}
}
r.lastDrainAt = now
maxAllowance := maxInt(1, rate/5) // cap accumulated credit at 200 ms
if r.drainAllowance > float64(maxAllowance) {
r.drainAllowance = float64(maxAllowance)
}
limit := int(r.drainAllowance)
if limit <= 0 {
return 0
}
maxBurst := maxInt(1, rate/50) // max 20 ms worth of frames per drain call
if limit > maxBurst {
limit = maxBurst
}
sinkStats := r.sink.Stats()
headroom := sinkStats.Capacity - sinkStats.Available
if headroom < 0 {
headroom = 0
}
if limit > headroom {
limit = headroom
}
if limit > bufferedFrames {
limit = bufferedFrames
}
return limit
}

func (r *Runtime) resetDrainPacerLocked(now time.Time) {
r.lastDrainAt = now
r.drainAllowance = 0
}

func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

func (r *Runtime) updateBufferedStatsLocked() {
available := r.work.available()
capacity := r.work.capacity()
buffered := 0.0
if capacity > 0 {
buffered = float64(available) / float64(capacity)
}
bufferedSeconds := 0.0
if r.workSampleRate > 0 {
bufferedSeconds = float64(available) / float64(r.workSampleRate)
}
r.stats.Buffered = buffered
r.stats.BufferedSeconds = bufferedSeconds
}

func (r *Runtime) Stats() Stats {
r.mu.RLock()
runtimeStats := r.stats
active := r.active
r.mu.RUnlock()

sourceStats := SourceStats{}
if r.source != nil {
sourceStats = r.source.Stats()
}
if sourceStats.BufferedSeconds < runtimeStats.BufferedSeconds {
sourceStats.BufferedSeconds = runtimeStats.BufferedSeconds
}
return Stats{
Active: active,
Source: sourceStats,
Runtime: runtimeStats,
}
}

type frameBuffer struct {
frames []audio.Frame
head int
len int
}

func newFrameBuffer(capacity int) *frameBuffer {
if capacity < 1 {
capacity = 1
}
return &frameBuffer{frames: make([]audio.Frame, capacity)}
}

func (b *frameBuffer) capacity() int {
return len(b.frames)
}

func (b *frameBuffer) available() int {
return b.len
}

func (b *frameBuffer) reset() {
b.head = 0
b.len = 0
}

func (b *frameBuffer) push(frame audio.Frame) bool {
if b.len >= len(b.frames) {
return false
}
idx := (b.head + b.len) % len(b.frames)
b.frames[idx] = frame
b.len++
return true
}

func (b *frameBuffer) peek() (audio.Frame, bool) {
if b.len == 0 {
return audio.Frame{}, false
}
return b.frames[b.head], true
}

func (b *frameBuffer) pop() (audio.Frame, bool) {
if b.len == 0 {
return audio.Frame{}, false
}
frame := b.frames[b.head]
b.head = (b.head + 1) % len(b.frames)
b.len--
return frame, true
}

+ 401
- 0
internal/ingest/runtime_test.go Переглянути файл

@@ -0,0 +1,401 @@
package ingest

import (
"context"
"errors"
"sync"
"testing"
"time"

"github.com/jan/fm-rds-tx/internal/audio"
)

type fakeSource struct {
desc SourceDescriptor
chunks chan PCMChunk
errs chan error
title chan string
stats SourceStats
once sync.Once
}

func newFakeSource() *fakeSource {
return &fakeSource{
desc: SourceDescriptor{ID: "fake", Kind: "stdin-pcm"},
chunks: make(chan PCMChunk, 4),
errs: make(chan error, 1),
title: make(chan string, 4),
stats: SourceStats{State: "running", Connected: true},
}
}

func (s *fakeSource) Descriptor() SourceDescriptor { return s.desc }
func (s *fakeSource) Start(context.Context) error { return nil }
func (s *fakeSource) Stop() error { s.once.Do(func() { close(s.chunks) }); return nil }
func (s *fakeSource) Chunks() <-chan PCMChunk { return s.chunks }
func (s *fakeSource) Errors() <-chan error { return s.errs }
func (s *fakeSource) StreamTitleUpdates() <-chan string {
return s.title
}
func (s *fakeSource) Stats() SourceStats { return s.stats }

func TestRuntimeWritesFramesToStreamSink(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
rt := NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 44100,
Samples: []int32{1000 << 16, -1000 << 16},
}

deadline := time.Now().Add(1 * time.Second)
for sink.Available() < 1 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if sink.Available() < 1 {
t.Fatal("expected at least one frame in sink")
}
}

func TestRuntimeRecoversToRunningAfterSourceError(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
rt := NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.errs <- errors.New("decode transient failure")
waitForRuntimeState(t, rt, "degraded")

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 44100,
Samples: []int32{500 << 16, -500 << 16},
}
waitForRuntimeState(t, rt, "running")
}

func TestRuntimeRecoversToRunningAfterConvertError(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
rt := NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

// Invalid stereo chunk: odd sample count causes conversion error.
src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 44100,
Samples: []int32{100 << 16},
}
waitForRuntimeState(t, rt, "degraded")

if got := rt.Stats().Runtime.ConvertErrors; got != 1 {
t.Fatalf("convertErrors=%d want 1", got)
}

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 44100,
Samples: []int32{300 << 16, -300 << 16},
}
waitForRuntimeState(t, rt, "running")
}

func TestRuntimeWithMissingSourceStaysIdleAndReturnsZeroSourceStats(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
rt := NewRuntime(sink, nil)

if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
stats := rt.Stats()
if stats.Runtime.State != "idle" {
t.Fatalf("runtime state=%q want idle", stats.Runtime.State)
}
if stats.Active.ID != "" || stats.Active.Kind != "" {
t.Fatalf("expected empty active descriptor, got %+v", stats.Active)
}
if stats.Source.State != "" {
t.Fatalf("expected zero-value source stats, got state=%q", stats.Source.State)
}
}

func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
src.desc = SourceDescriptor{ID: "icecast-primary", Kind: "icecast"}
src.stats = SourceStats{
State: "reconnecting",
Connected: false,
Reconnects: 4,
LastError: "stream ended",
}
rt := NewRuntime(sink, src)

if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

stats := rt.Stats()
if stats.Active.ID != "icecast-primary" {
t.Fatalf("active id=%q want icecast-primary", stats.Active.ID)
}
if stats.Active.Kind != "icecast" {
t.Fatalf("active kind=%q want icecast", stats.Active.Kind)
}
if stats.Source.Reconnects != 4 {
t.Fatalf("source reconnects=%d want 4", stats.Source.Reconnects)
}
if stats.Source.LastError != "stream ended" {
t.Fatalf("source lastError=%q want stream ended", stats.Source.LastError)
}
}

func TestRuntimePrebufferGateAppliesBeforeSinkWrites(t *testing.T) {
sink := audio.NewStreamSource(512, 1000)
src := newFakeSource()
rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond))
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 1000,
Samples: stereoSamples(80, 100),
}

time.Sleep(30 * time.Millisecond)
if sink.Available() != 0 {
t.Fatalf("sink available=%d want 0 while prebuffering", sink.Available())
}
stats := rt.Stats()
if stats.Runtime.State != "prebuffering" || !stats.Runtime.Prebuffering {
t.Fatalf("runtime state=%q prebuffering=%t", stats.Runtime.State, stats.Runtime.Prebuffering)
}
if stats.Runtime.BufferedSeconds <= 0 {
t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds)
}

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 1000,
Samples: stereoSamples(40, 120),
}
waitForSinkFrames(t, sink, 1)
waitForRuntimeState(t, rt, "running")
if got := rt.Stats().Runtime.Prebuffering; got {
t.Fatalf("runtime prebuffering=%t want false", got)
}
}

func TestRuntimeWriteBlockedRetainsWorkingBuffer(t *testing.T) {
sink := audio.NewStreamSource(1, 1000)
src := newFakeSource()
rt := NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 1000,
Samples: stereoSamples(4, 200),
}
waitForSinkFrames(t, sink, 1)
waitForRuntimeState(t, rt, "running")
stats := rt.Stats()
if stats.Runtime.WriteBlocked {
t.Fatalf("runtime writeBlocked=%t want false", stats.Runtime.WriteBlocked)
}
if stats.Runtime.BufferedSeconds <= 0 {
t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds)
}
if stats.Runtime.DroppedFrames != 0 {
t.Fatalf("runtime droppedFrames=%d want 0", stats.Runtime.DroppedFrames)
}
if got := sink.Stats().Overflows; got != 0 {
t.Fatalf("sink overflows=%d want 0", got)
}
}

func TestRuntimeDrainWorkingBufferIsBurstBounded(t *testing.T) {
sink := audio.NewStreamSource(64, 1000)
rt := NewRuntime(sink, nil)

rt.gateOpen = true
for i := 0; i < 40; i++ {
if !rt.work.push(audio.NewFrame(0.1, -0.1)) {
t.Fatalf("failed to seed work frame %d", i)
}
}
rt.lastDrainAt = time.Now().Add(-time.Second)

rt.drainWorkingBuffer()

if got := sink.Available(); got != 20 {
t.Fatalf("sink available=%d want 20 (20ms burst at 1kHz)", got)
}
if got := rt.work.available(); got != 20 {
t.Fatalf("work available=%d want 20", got)
}
if got := rt.Stats().Runtime.WriteBlocked; got {
t.Fatalf("runtime writeBlocked=%t want false", got)
}
}

func TestRuntimeDrainWorkingBufferHonorsSinkHeadroom(t *testing.T) {
sink := audio.NewStreamSource(64, 1000)
rt := NewRuntime(sink, nil)

for i := 0; i < 63; i++ {
if !sink.WriteFrame(audio.NewFrame(0.2, -0.2)) {
t.Fatalf("failed to seed sink frame %d", i)
}
}
rt.gateOpen = true
for i := 0; i < 8; i++ {
if !rt.work.push(audio.NewFrame(0.3, -0.3)) {
t.Fatalf("failed to seed work frame %d", i)
}
}
rt.lastDrainAt = time.Now().Add(-time.Second)

rt.drainWorkingBuffer()

if got := sink.Available(); got != 64 {
t.Fatalf("sink available=%d want 64", got)
}
if got := rt.work.available(); got != 7 {
t.Fatalf("work available=%d want 7", got)
}
if got := sink.Stats().Overflows; got != 0 {
t.Fatalf("sink overflows=%d want 0", got)
}
if got := rt.Stats().Runtime.WriteBlocked; got {
t.Fatalf("runtime writeBlocked=%t want false", got)
}
}

func TestRuntimeStatsSourceBufferedSecondsIncludesWorkingBuffer(t *testing.T) {
sink := audio.NewStreamSource(32, 1000)
src := newFakeSource()
src.stats = SourceStats{State: "running", Connected: true, BufferedSeconds: 0}
rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond))
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 1000,
Samples: stereoSamples(50, 300),
}
time.Sleep(20 * time.Millisecond)
stats := rt.Stats()
if stats.Source.BufferedSeconds <= 0 {
t.Fatalf("source bufferedSeconds=%f want > 0", stats.Source.BufferedSeconds)
}
}

func TestRuntimeUpdatesActiveDescriptorFromChunkMetadata(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
src.desc = SourceDescriptor{
ID: "icecast-primary",
Kind: "icecast",
Channels: 0,
SampleRateHz: 0,
}
rt := NewRuntime(sink, src)
if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.chunks <- PCMChunk{
Channels: 2,
SampleRateHz: 48000,
Samples: []int32{100 << 16, -100 << 16},
}

waitForRuntimeState(t, rt, "running")
stats := rt.Stats()
if stats.Active.SampleRateHz != 48000 {
t.Fatalf("active sampleRateHz=%d want 48000", stats.Active.SampleRateHz)
}
if stats.Active.Channels != 2 {
t.Fatalf("active channels=%d want 2", stats.Active.Channels)
}
}

func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) {
sink := audio.NewStreamSource(128, 44100)
src := newFakeSource()
got := make(chan string, 1)
rt := NewRuntime(sink, src, WithStreamTitleHandler(func(title string) {
got <- title
}))

if err := rt.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
defer rt.Stop()

src.title <- "Artist - Song"
select {
case title := <-got:
if title != "Artist - Song" {
t.Fatalf("title=%q want %q", title, "Artist - Song")
}
case <-time.After(1 * time.Second):
t.Fatal("timed out waiting for forwarded title")
}
}

func waitForRuntimeState(t *testing.T, rt *Runtime, want string) {
t.Helper()
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) {
if got := rt.Stats().Runtime.State; got == want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timeout waiting for runtime state %q; last=%q", want, rt.Stats().Runtime.State)
}

func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) {
t.Helper()
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) {
if sink.Available() >= minFrames {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames)
}

func stereoSamples(frames int, v int32) []int32 {
out := make([]int32, 0, frames*2)
for i := 0; i < frames; i++ {
out = append(out, v<<16, -v<<16)
}
return out
}

+ 18
- 0
internal/ingest/source.go Переглянути файл

@@ -0,0 +1,18 @@
package ingest

import "context"

type Source interface {
Descriptor() SourceDescriptor
Start(ctx context.Context) error
Stop() error
Chunks() <-chan PCMChunk
Errors() <-chan error
Stats() SourceStats
}

// StreamTitleSource is an optional extension for sources that expose
// title/metadata updates (for example ICY StreamTitle).
type StreamTitleSource interface {
StreamTitleUpdates() <-chan string
}

+ 41
- 0
internal/ingest/stats.go Переглянути файл

@@ -0,0 +1,41 @@
package ingest

import "time"

type SourceStats struct {
State string `json:"state"`
Connected bool `json:"connected"`
LastChunkAt time.Time `json:"lastChunkAt,omitempty"`
LastMetaAt time.Time `json:"lastMetaAt,omitempty"`
StreamTitle string `json:"streamTitle,omitempty"`
MetadataUpdates uint64 `json:"metadataUpdates,omitempty"`
IcyMetaInt int `json:"icyMetaInt,omitempty"`
ChunksIn uint64 `json:"chunksIn"`
SamplesIn uint64 `json:"samplesIn"`
BufferedSeconds float64 `json:"bufferedSeconds"`
Overflows uint64 `json:"overflows"`
Underruns uint64 `json:"underruns"`
Reconnects uint64 `json:"reconnects"`
Discontinuities uint64 `json:"discontinuities"`
TransportLoss uint64 `json:"transportLoss"`
Reorders uint64 `json:"reorders"`
JitterDepth int `json:"jitterDepth"`
LastError string `json:"lastError,omitempty"`
}

type RuntimeStats struct {
State string `json:"state"`
Prebuffering bool `json:"prebuffering"`
Buffered float64 `json:"buffered"`
BufferedSeconds float64 `json:"bufferedSeconds"`
LastChunkAt time.Time `json:"lastChunkAt,omitempty"`
DroppedFrames uint64 `json:"droppedFrames"`
ConvertErrors uint64 `json:"convertErrors"`
WriteBlocked bool `json:"writeBlocked"`
}

type Stats struct {
Active SourceDescriptor `json:"active"`
Source SourceStats `json:"source"`
Runtime RuntimeStats `json:"runtime"`
}

+ 37
- 0
internal/ingest/types.go Переглянути файл

@@ -0,0 +1,37 @@
package ingest

import "time"

// PCMChunk is the ingest-internal normalized PCM unit before TX conversion.
// Samples are interleaved per channel.
type PCMChunk struct {
Samples []int32
Channels int
SampleRateHz int
Sequence uint64
Timestamp time.Time
SourceID string
Discontinuity bool
}

type SourceDescriptor struct {
ID string `json:"id"`
Kind string `json:"kind"`
Family string `json:"family"`
Transport string `json:"transport"`
Codec string `json:"codec"`
Channels int `json:"channels"`
SampleRateHz int `json:"sampleRateHz"`
Detail string `json:"detail,omitempty"`
Origin *SourceOrigin `json:"origin,omitempty"`
}

// SourceOrigin describes where an ingest source definition came from and
// which endpoint it resolved to, so control/runtime can show provenance.
type SourceOrigin struct {
Kind string `json:"kind,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Mode string `json:"mode,omitempty"`
StreamName string `json:"streamName,omitempty"`
SDPPath string `json:"sdpPath,omitempty"`
}

+ 49
- 6
internal/offline/generator.go Переглянути файл

@@ -32,6 +32,11 @@ type LiveParams struct {
LimiterEnabled bool
LimiterCeiling float64
MpxGain float64 // hardware calibration factor for composite output
// Tone + gain: live-patchable without DSP chain reinit.
ToneLeftHz float64
ToneRightHz float64
ToneAmplitude float64
AudioGain float64
}

// PreEmphasizedSource wraps an audio source and applies pre-emphasis.
@@ -112,6 +117,10 @@ type Generator struct {
// Optional external audio source (e.g. StreamResampler for live audio).
// When set, takes priority over WAV/tones in sourceFor().
externalSource frameSource

// Tone source reference — non-nil when a ToneSource is the active audio input.
// Allows live-updating tone parameters via LiveParams each chunk.
toneSource *audio.ToneSource
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
@@ -120,8 +129,15 @@ func NewGenerator(cfg cfgpkg.Config) *Generator {

// SetExternalSource sets a live audio source (e.g. StreamResampler) that
// takes priority over WAV/tone sources. Must be called before the first
// GenerateFrame() call (i.e. before init).
// GenerateFrame() call; calling it after init() has no effect because
// g.source is already wired to the old source.
func (g *Generator) SetExternalSource(src frameSource) {
if g.initialized {
// init() already called sourceFor() and wired g.source. Updating
// g.externalSource here would have no effect on the live DSP chain.
// This is a programming error — log loudly rather than silently break.
panic("generator: SetExternalSource called after GenerateFrame; call it before the engine starts")
}
g.externalSource = src
}

@@ -189,12 +205,14 @@ func (g *Generator) init() {
g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate)
// BS.412 MPX power limiter (EU/CH requirement for licensed FM)
if g.cfg.FM.BS412Enabled {
chunkSec := 0.05 // 50ms chunks (matches engine default)
// chunkSec is not known at init time (Engine.chunkDuration may differ).
// Pass 0 here; GenerateFrame computes the actual chunk duration from
// the real sample count and updates BS.412 accordingly.
g.bs412 = dsp.NewBS412Limiter(
g.cfg.FM.BS412ThresholdDBr,
g.cfg.FM.PilotLevel,
g.cfg.FM.RDSInjection,
chunkSec,
0,
)
}
if g.cfg.FM.FMModulationEnabled {
@@ -218,6 +236,10 @@ func (g *Generator) init() {
LimiterEnabled: g.cfg.FM.LimiterEnabled,
LimiterCeiling: ceiling,
MpxGain: g.cfg.FM.MpxGain,
ToneLeftHz: g.cfg.Audio.ToneLeftHz,
ToneRightHz: g.cfg.Audio.ToneRightHz,
ToneAmplitude: g.cfg.Audio.ToneAmplitude,
AudioGain: g.cfg.Audio.Gain,
})

g.initialized = true
@@ -231,9 +253,13 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) {
if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil {
return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
g.toneSource = ts
return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath}
}
return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude)
g.toneSource = ts
return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"}
}

func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
@@ -263,6 +289,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0}
}

// Apply live tone and gain updates each chunk. GenerateFrame runs on a
// single goroutine so these field writes are safe without additional locking.
if g.toneSource != nil {
g.toneSource.LeftFreq = lp.ToneLeftHz
g.toneSource.RightFreq = lp.ToneRightHz
g.toneSource.Amplitude = lp.ToneAmplitude
}
if g.source != nil {
g.source.gain = lp.AudioGain
}

// Broadcast clip-filter-clip FM MPX signal chain:
//
// Audio L/R → PreEmphasis
@@ -360,8 +397,14 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
}
}

// BS.412: feed this chunk's average audio power for next chunk's gain calculation
// BS.412: feed this chunk's actual duration and average audio power for
// the next chunk's gain calculation. Using the real sample count avoids
// the error that occurred when chunkSec was hardcoded to 0.05 — any
// SetChunkDuration() call from the engine would silently miscalibrate
// the ITU-R BS.412 power measurement window.
if g.bs412 != nil && samples > 0 {
chunkSec := float64(samples) / g.sampleRate
g.bs412.UpdateChunkDuration(chunkSec)
g.bs412.ProcessChunk(bs412PowerAccum / float64(samples))
}



+ 14
- 11
internal/output/frame_queue.go Переглянути файл

@@ -80,22 +80,19 @@ func (q *FrameQueue) Capacity() int {
}

// FillLevel reports the current occupancy as a fraction of capacity.
// Uses len(ch) directly for accuracy: updateDepth() is called after the
// channel operation, so q.depth can lag by one frame transiently.
func (q *FrameQueue) FillLevel() float64 {
q.mu.Lock()
depth := q.depth
q.mu.Unlock()
if q.capacity == 0 {
return 0
}
return float64(depth) / float64(q.capacity)
return float64(len(q.ch)) / float64(q.capacity)
}

// Depth returns the current number of frames in the queue.
// Uses len(ch) directly for accuracy (see FillLevel).
func (q *FrameQueue) Depth() int {
q.mu.Lock()
depth := q.depth
q.mu.Unlock()
return depth
return len(q.ch)
}

// Stats returns a snapshot of the queue metrics.
@@ -104,7 +101,7 @@ func (q *FrameQueue) Stats() QueueStats {
fill := q.fillLevelLocked()
stats := QueueStats{
Capacity: q.capacity,
Depth: q.depth,
Depth: len(q.ch),
FillLevel: fill,
Health: queueHealthFromFill(fill),
HighWaterMark: q.highWaterMark,
@@ -128,11 +125,15 @@ func (q *FrameQueue) Push(ctx context.Context, frame *CompositeFrame) error {
return ErrFrameQueueClosed
}

// BUG-05 fix: increment depth BEFORE the channel send so that Stats()
// never reports fill=0 while a frame is in the channel awaiting receive.
// On context cancellation, undo the increment.
q.updateDepth(+1)
select {
case q.ch <- frame:
q.updateDepth(+1)
return nil
case <-ctx.Done():
q.updateDepth(-1)
q.recordPushTimeout()
return ctx.Err()
}
@@ -211,7 +212,9 @@ func (q *FrameQueue) fillLevelLocked() float64 {
if q.capacity == 0 {
return 0
}
return float64(q.depth) / float64(q.capacity)
// Use len(ch) rather than q.depth: depth is updated after the channel
// operation, so it can be off by one during the Push/Pop window.
return float64(len(q.ch)) / float64(q.capacity)
}

func (q *FrameQueue) recordPushTimeout() {


+ 59
- 16
internal/rds/encoder.go Переглянути файл

@@ -94,8 +94,17 @@ type Encoder struct {

// Live-updatable text — written by control API, read at group boundaries.
// Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz).
livePS atomic.Value // string
liveRT atomic.Value // string
// pendingText.set distinguishes "no pending update" from "update to empty string"
// so that PS/RT can be explicitly cleared via UpdateText.
livePS atomic.Value // pendingText
liveRT atomic.Value // pendingText
}

// pendingText carries a pending text update for PS or RT.
// set=false means no update is pending; set=true means apply val (even if empty).
type pendingText struct {
val string
set bool
}

func NewEncoder(cfg RDSConfig) (*Encoder, error) {
@@ -163,16 +172,35 @@ func (e *Encoder) Reset() {

// UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers,
// applied at the next RDS group boundary by the DSP goroutine.
// Pass empty string to leave a field unchanged.
//
// Pass empty string to leave a field unchanged. To explicitly clear a field
// (set PS to 8 spaces, or RT to empty), use ClearPS/ClearRT instead.
func (e *Encoder) UpdateText(ps, rt string) {
if ps != "" {
e.livePS.Store(normalizePS(ps))
e.livePS.Store(pendingText{val: normalizePS(ps), set: true})
}
if rt != "" {
e.liveRT.Store(normalizeRT(rt))
e.liveRT.Store(pendingText{val: normalizeRT(rt), set: true})
}
}

// ClearPS resets the Program Service name to 8 spaces at the next group boundary.
func (e *Encoder) ClearPS() {
e.livePS.Store(pendingText{val: normalizePS(""), set: true})
}

// ClearRT resets RadioText to an empty string at the next group boundary.
// Per RDS spec, an empty RT causes receivers to clear their display.
func (e *Encoder) ClearRT() {
e.liveRT.Store(pendingText{val: "", set: true})
}

// CurrentText returns the currently active PS and RT from the encoder scheduler.
// It reflects the last text applied at an RDS group boundary.
func (e *Encoder) CurrentText() (ps, rt string) {
return e.scheduler.cfg.PS, e.scheduler.cfg.RT
}

// NextSample returns the next RDS subcarrier sample at the configured rate.
// Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier
// for phase-locked operation in a stereo MPX chain.
@@ -192,15 +220,15 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 {
// Apply live text updates at group boundaries (~88ms at 228kHz).
// Atomics are consumed (cleared) after reading to prevent
// re-applying the same text every group and toggling A/B flag.
if ps, ok := e.livePS.Load().(string); ok && ps != "" {
e.scheduler.cfg.PS = ps
e.livePS.Store("") // consumed
if pt, ok := e.livePS.Load().(pendingText); ok && pt.set {
e.scheduler.cfg.PS = pt.val
e.livePS.Store(pendingText{}) // consumed
}
if rt, ok := e.liveRT.Load().(string); ok && rt != "" {
e.scheduler.cfg.RT = rt
if pt, ok := e.liveRT.Load().(pendingText); ok && pt.set {
e.scheduler.cfg.RT = pt.val
e.scheduler.rtIdx = 0 // restart RT transmission for new text
e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec
e.liveRT.Store("") // consumed
e.liveRT.Store(pendingText{}) // consumed
}
e.getRDSGroup()
e.bitPos = 0
@@ -240,12 +268,27 @@ func (e *Encoder) Generate(n int) []float64 {
out := make([]float64, n); for i := range out { out[i] = e.NextSample() }; return out
}
func (e *Encoder) Symbol() float64 {
if e.bitPos >= bitsPerGroup { return -1 }
sym := 1.0; if e.bitBuffer[e.bitPos] == 0 { sym = -1.0 }
// Populate the bit buffer on first call (bitPos starts at bitsPerGroup
// after NewEncoder/Reset, so the guard below would return -1 immediately
// without this bootstrap step).
if e.bitPos >= bitsPerGroup {
e.getRDSGroup()
e.bitPos = 0
}
sym := 1.0
if e.bitBuffer[e.bitPos] == 0 {
sym = -1.0
}
e.sampleCount++
if e.sampleCount >= e.spb { e.sampleCount = 0; e.bitPos++
if e.bitPos >= bitsPerGroup { e.getRDSGroup(); e.bitPos = 0 }
}; return sym
if e.sampleCount >= e.spb {
e.sampleCount = 0
e.bitPos++
if e.bitPos >= bitsPerGroup {
e.getRDSGroup()
e.bitPos = 0
}
}
return sym
}

func (e *Encoder) getRDSGroup() {


Завантаження…
Відмінити
Зберегти