| @@ -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. | |||||
| @@ -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` | |||||
| @@ -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()) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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. | |||||
| @@ -0,0 +1,3 @@ | |||||
| module aoiprxkit | |||||
| go 1.22 | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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>` | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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"` | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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, | |||||
| }) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() } | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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") | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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(), | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,7 @@ import ( | |||||
| "log" | "log" | ||||
| "os" | "os" | ||||
| "os/signal" | "os/signal" | ||||
| "strings" | |||||
| "syscall" | "syscall" | ||||
| "time" | "time" | ||||
| @@ -15,6 +16,9 @@ import ( | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | 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" | ||||
| "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | ||||
| "github.com/jan/fm-rds-tx/internal/platform/soapysdr" | "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") | audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream") | ||||
| flag.Parse() | flag.Parse() | ||||
| // --- list-devices (SoapySDR) --- | |||||
| if *listDevices { | if *listDevices { | ||||
| devices, err := soapysdr.Enumerate() | devices, err := soapysdr.Enumerate() | ||||
| if err != nil { | if err != nil { | ||||
| @@ -60,13 +63,12 @@ func main() { | |||||
| log.Fatalf("load config: %v", err) | log.Fatalf("load config: %v", err) | ||||
| } | } | ||||
| // --- print-config --- | |||||
| if *printConfig { | if *printConfig { | ||||
| preemph := "off" | preemph := "off" | ||||
| if cfg.FM.PreEmphasisTauUS > 0 { | 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, | cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, | ||||
| preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, | preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, | ||||
| cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress, | cfg.FM.CompositeRateHz, cfg.EffectiveDeviceRate(), cfg.Control.ListenAddress, | ||||
| @@ -74,7 +76,6 @@ func main() { | |||||
| return | return | ||||
| } | } | ||||
| // --- dry-run --- | |||||
| if *dryRun { | if *dryRun { | ||||
| frame := drypkg.Generate(cfg) | frame := drypkg.Generate(cfg) | ||||
| if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { | if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { | ||||
| @@ -86,7 +87,6 @@ func main() { | |||||
| return | return | ||||
| } | } | ||||
| // --- simulate --- | |||||
| if *simulate { | if *simulate { | ||||
| summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) | summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -96,28 +96,25 @@ func main() { | |||||
| return | return | ||||
| } | } | ||||
| // --- TX mode --- | |||||
| if *txMode { | if *txMode { | ||||
| driver := selectDriver(cfg) | driver := selectDriver(cfg) | ||||
| if driver == nil { | 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 | return | ||||
| } | } | ||||
| // --- default: HTTP only --- | |||||
| srv := ctrlpkg.NewServer(cfg) | srv := ctrlpkg.NewServer(cfg) | ||||
| configureControlPlanePersistence(srv, *configPath) | |||||
| server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) | server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) | ||||
| log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) | log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) | ||||
| log.Fatal(server.ListenAndServe()) | log.Fatal(server.ListenAndServe()) | ||||
| } | } | ||||
| // selectDriver picks the best available driver based on config and build tags. | |||||
| func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { | func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { | ||||
| kind := cfg.Backend.Kind | kind := cfg.Backend.Kind | ||||
| // Explicit PlutoSDR | |||||
| if kind == "pluto" || kind == "plutosdr" { | if kind == "pluto" || kind == "plutosdr" { | ||||
| if plutosdr.Available() { | if plutosdr.Available() { | ||||
| return plutosdr.NewPlutoDriver() | 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()) | log.Printf("warning: backend=%s but pluto driver not available (%s)", kind, plutosdr.AvailableError()) | ||||
| } | } | ||||
| // Explicit SoapySDR | |||||
| if kind == "soapy" || kind == "soapysdr" { | if kind == "soapy" || kind == "soapysdr" { | ||||
| if soapysdr.Available() { | if soapysdr.Available() { | ||||
| return soapysdr.NewNativeDriver() | 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) | log.Printf("warning: backend=%s but soapy driver not available", kind) | ||||
| } | } | ||||
| // Auto-detect: prefer PlutoSDR, fall back to SoapySDR | |||||
| if plutosdr.Available() { | if plutosdr.Available() { | ||||
| log.Println("auto-selected: pluto-iio driver") | log.Println("auto-selected: pluto-iio driver") | ||||
| return plutosdr.NewPlutoDriver() | return plutosdr.NewPlutoDriver() | ||||
| @@ -146,18 +141,15 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { | |||||
| return nil | 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()) | ctx, cancel := context.WithCancel(context.Background()) | ||||
| defer cancel() | 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{ | soapyCfg := platform.SoapyConfig{ | ||||
| Driver: cfg.Backend.Driver, | Driver: cfg.Backend.Driver, | ||||
| Device: cfg.Backend.Device, | Device: cfg.Backend.Device, | ||||
| CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, | CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, | ||||
| GainDB: 0, // 0 dB = max TX power on PlutoSDR | |||||
| GainDB: 0, | |||||
| DeviceArgs: map[string]string{}, | DeviceArgs: map[string]string{}, | ||||
| } | } | ||||
| if cfg.Backend.URI != "" { | 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) | caps.GainMinDB, caps.GainMaxDB, caps.MinSampleRate, caps.MaxSampleRate) | ||||
| } | } | ||||
| // Engine | |||||
| engine := apppkg.NewEngine(cfg, driver) | engine := apppkg.NewEngine(cfg, driver) | ||||
| cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) | |||||
| // Live audio stream source (optional) | |||||
| var streamSrc *audio.StreamSource | 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 { | if bufferFrames <= 0 { | ||||
| bufferFrames = 1 | bufferFrames = 1 | ||||
| } | } | ||||
| streamSrc = audio.NewStreamSource(bufferFrames, audioRate) | |||||
| streamSrc = audio.NewStreamSource(bufferFrames, rate) | |||||
| engine.SetStreamSource(streamSrc) | 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) | srv := ctrlpkg.NewServer(cfg) | ||||
| configureControlPlanePersistence(srv, configPath) | |||||
| srv.SetDriver(driver) | srv.SetDriver(driver) | ||||
| srv.SetTXController(&txBridge{engine: engine}) | srv.SetTXController(&txBridge{engine: engine}) | ||||
| if streamSrc != nil { | if streamSrc != nil { | ||||
| srv.SetStreamSource(streamSrc) | srv.SetStreamSource(streamSrc) | ||||
| } | } | ||||
| if ingress != nil { | |||||
| srv.SetAudioIngress(ingress) | |||||
| } | |||||
| if ingestRuntime != nil { | |||||
| srv.SetIngestRuntime(ingestRuntime) | |||||
| } | |||||
| if autoStart { | if autoStart { | ||||
| log.Println("TX: auto-start enabled") | 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()) | log.Printf("TX ACTIVE: freq=%.3fMHz rate=%.0fHz", cfg.FM.FrequencyMHz, cfg.EffectiveDeviceRate()) | ||||
| } else { | } 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()) | 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) | log.Printf("received %s, shutting down...", sig) | ||||
| _ = engine.Stop(ctx) | _ = engine.Stop(ctx) | ||||
| if ingestRuntime != nil { | |||||
| _ = ingestRuntime.Stop() | |||||
| } | |||||
| _ = driver.Close(ctx) | _ = driver.Close(ctx) | ||||
| log.Println("shutdown complete") | 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 } | type txBridge struct{ engine *apppkg.Engine } | ||||
| func (b *txBridge) StartTX() error { return b.engine.Start(context.Background()) } | 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, | "runtimeIndicator": s.RuntimeIndicator, | ||||
| "runtimeAlert": s.RuntimeAlert, | "runtimeAlert": s.RuntimeAlert, | ||||
| "appliedFrequencyMHz": s.AppliedFrequencyMHz, | "appliedFrequencyMHz": s.AppliedFrequencyMHz, | ||||
| "activePS": s.ActivePS, | |||||
| "activeRadioText": s.ActiveRadioText, | |||||
| "degradedTransitions": s.DegradedTransitions, | "degradedTransitions": s.DegradedTransitions, | ||||
| "mutedTransitions": s.MutedTransitions, | "mutedTransitions": s.MutedTransitions, | ||||
| "faultedTransitions": s.FaultedTransitions, | "faultedTransitions": s.FaultedTransitions, | ||||
| @@ -290,6 +353,10 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { | |||||
| LimiterCeiling: lp.LimiterCeiling, | LimiterCeiling: lp.LimiterCeiling, | ||||
| PS: lp.PS, | PS: lp.PS, | ||||
| RadioText: lp.RadioText, | RadioText: lp.RadioText, | ||||
| ToneLeftHz: lp.ToneLeftHz, | |||||
| ToneRightHz: lp.ToneRightHz, | |||||
| ToneAmplitude: lp.ToneAmplitude, | |||||
| AudioGain: lp.AudioGain, | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -9,6 +9,28 @@ import ( | |||||
| "github.com/jan/fm-rds-tx/internal/platform" | "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) { | func TestTxBridgeExportsQueueStats(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| driver := platform.NewSimulatedDriver(nil) | driver := platform.NewSimulatedDriver(nil) | ||||
| @@ -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. | |||||
| @@ -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. | |||||
| @@ -1,7 +1,7 @@ | |||||
| { | { | ||||
| "audio": { | "audio": { | ||||
| "inputPath": "", | "inputPath": "", | ||||
| "gain": 1.0, | |||||
| "gain": 1, | |||||
| "toneLeftHz": 400, | "toneLeftHz": 400, | ||||
| "toneRightHz": 2000, | "toneRightHz": 2000, | ||||
| "toneAmplitude": 0.3 | "toneAmplitude": 0.3 | ||||
| @@ -14,31 +14,89 @@ | |||||
| "pty": 0 | "pty": 0 | ||||
| }, | }, | ||||
| "fm": { | "fm": { | ||||
| "bs412Enabled": true, | |||||
| "bs412ThresholdDBr": 0, | |||||
| "frequencyMHz": 100.0, | |||||
| "frequencyMHz": 102.8, | |||||
| "stereoEnabled": true, | "stereoEnabled": true, | ||||
| "pilotLevel": 0.09, | "pilotLevel": 0.09, | ||||
| "rdsInjection": 0.04, | "rdsInjection": 0.04, | ||||
| "preEmphasisTauUS": 50, | "preEmphasisTauUS": 50, | ||||
| "outputDrive": 1.0, | |||||
| "mpxGain": 1.0, | |||||
| "outputDrive": 1, | |||||
| "compositeRateHz": 228000, | "compositeRateHz": 228000, | ||||
| "maxDeviationHz": 75000, | "maxDeviationHz": 75000, | ||||
| "limiterEnabled": true, | "limiterEnabled": true, | ||||
| "limiterCeiling": 1.0, | |||||
| "fmModulationEnabled": true | |||||
| "limiterCeiling": 1, | |||||
| "fmModulationEnabled": true, | |||||
| "mpxGain": 1, | |||||
| "bs412Enabled": true, | |||||
| "bs412ThresholdDBr": 0 | |||||
| }, | }, | ||||
| "backend": { | "backend": { | ||||
| "kind": "pluto", | "kind": "pluto", | ||||
| "device": "usb:", | "device": "usb:", | ||||
| "driver": "", | |||||
| "uri": "", | |||||
| "deviceArgs": {}, | |||||
| "outputPath": "", | "outputPath": "", | ||||
| "deviceSampleRateHz": 2280000 | "deviceSampleRateHz": 2280000 | ||||
| }, | }, | ||||
| "control": { | "control": { | ||||
| "listenAddress": "127.0.0.1:8088" | "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 | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -34,5 +34,32 @@ | |||||
| }, | }, | ||||
| "control": { | "control": { | ||||
| "listenAddress": "127.0.0.1:8088" | "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" | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -4,4 +4,13 @@ go 1.22 | |||||
| require github.com/jan/fm-rds-tx/internal v0.0.0 | 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 github.com/jan/fm-rds-tx/internal => ./internal | ||||
| replace aoiprxkit => ./aoiprxkit | |||||
| @@ -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= | |||||
| @@ -88,6 +88,8 @@ type EngineStats struct { | |||||
| RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` | RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` | ||||
| RuntimeAlert string `json:"runtimeAlert,omitempty"` | RuntimeAlert string `json:"runtimeAlert,omitempty"` | ||||
| AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` | AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` | ||||
| ActivePS string `json:"activePS,omitempty"` | |||||
| ActiveRadioText string `json:"activeRadioText,omitempty"` | |||||
| LastFault *FaultEvent `json:"lastFault,omitempty"` | LastFault *FaultEvent `json:"lastFault,omitempty"` | ||||
| DegradedTransitions uint64 `json:"degradedTransitions"` | DegradedTransitions uint64 `json:"degradedTransitions"` | ||||
| MutedTransitions uint64 `json:"mutedTransitions"` | MutedTransitions uint64 `json:"mutedTransitions"` | ||||
| @@ -113,13 +115,14 @@ type RuntimeTransition struct { | |||||
| } | } | ||||
| const ( | const ( | ||||
| lateBufferIndicatorWindow = 5 * time.Second | |||||
| writeLateTolerance = 1 * time.Millisecond | |||||
| lateBufferIndicatorWindow = 2 * time.Second | |||||
| writeLateTolerance = 10 * time.Millisecond | |||||
| queueCriticalStreakThreshold = 3 | queueCriticalStreakThreshold = 3 | ||||
| queueMutedStreakThreshold = queueCriticalStreakThreshold * 2 | queueMutedStreakThreshold = queueCriticalStreakThreshold * 2 | ||||
| queueMutedRecoveryThreshold = queueCriticalStreakThreshold | queueMutedRecoveryThreshold = queueCriticalStreakThreshold | ||||
| queueFaultedStreakThreshold = queueCriticalStreakThreshold | queueFaultedStreakThreshold = queueCriticalStreakThreshold | ||||
| faultRepeatWindow = 1 * time.Second | faultRepeatWindow = 1 * time.Second | ||||
| lateBufferStreakThreshold = 3 // consecutive late writes required before alerting | |||||
| faultHistoryCapacity = 8 | faultHistoryCapacity = 8 | ||||
| runtimeTransitionHistoryCapacity = 8 | runtimeTransitionHistoryCapacity = 8 | ||||
| ) | ) | ||||
| @@ -150,6 +153,7 @@ type Engine struct { | |||||
| underruns atomic.Uint64 | underruns atomic.Uint64 | ||||
| lateBuffers atomic.Uint64 | lateBuffers atomic.Uint64 | ||||
| lateBufferAlertAt atomic.Uint64 | lateBufferAlertAt atomic.Uint64 | ||||
| lateBufferStreak atomic.Uint64 // consecutive late writes; reset on clean write | |||||
| criticalStreak atomic.Uint64 | criticalStreak atomic.Uint64 | ||||
| mutedRecoveryStreak atomic.Uint64 | mutedRecoveryStreak atomic.Uint64 | ||||
| mutedFaultStreak atomic.Uint64 | mutedFaultStreak atomic.Uint64 | ||||
| @@ -192,7 +196,7 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) { | |||||
| } | } | ||||
| resampler := audio.NewStreamResampler(src, compositeRate) | resampler := audio.NewStreamResampler(src, compositeRate) | ||||
| e.generator.SetExternalSource(resampler) | 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) | src.SampleRate, compositeRate, src.Stats().Capacity) | ||||
| } | } | ||||
| @@ -273,6 +277,11 @@ type LiveConfigUpdate struct { | |||||
| LimiterCeiling *float64 | LimiterCeiling *float64 | ||||
| PS *string | PS *string | ||||
| RadioText *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. | // 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)") | 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() --- | // --- Frequency: store for run loop to apply via driver.Tune() --- | ||||
| if u.FrequencyMHz != nil { | if u.FrequencyMHz != nil { | ||||
| @@ -353,6 +372,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { | |||||
| if u.LimiterCeiling != nil { | if u.LimiterCeiling != nil { | ||||
| next.LimiterCeiling = *u.LimiterCeiling | 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) | e.generator.UpdateLive(next) | ||||
| return nil | return nil | ||||
| @@ -427,6 +458,10 @@ func (e *Engine) Stats() EngineStats { | |||||
| hasRecentLateBuffers := e.hasRecentLateBuffers() | hasRecentLateBuffers := e.hasRecentLateBuffers() | ||||
| ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) | ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) | ||||
| lastFault := e.lastFaultEvent() | lastFault := e.lastFaultEvent() | ||||
| activePS, activeRT := "", "" | |||||
| if enc := e.generator.RDSEncoder(); enc != nil { | |||||
| activePS, activeRT = enc.CurrentText() | |||||
| } | |||||
| return EngineStats{ | return EngineStats{ | ||||
| State: string(e.currentRuntimeState()), | State: string(e.currentRuntimeState()), | ||||
| RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(), | RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(), | ||||
| @@ -446,6 +481,8 @@ func (e *Engine) Stats() EngineStats { | |||||
| RuntimeIndicator: ri, | RuntimeIndicator: ri, | ||||
| RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), | RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), | ||||
| AppliedFrequencyMHz: e.appliedFrequencyMHz(), | AppliedFrequencyMHz: e.appliedFrequencyMHz(), | ||||
| ActivePS: activePS, | |||||
| ActiveRadioText: activeRT, | |||||
| LastFault: lastFault, | LastFault: lastFault, | ||||
| DegradedTransitions: e.degradedTransitions.Load(), | DegradedTransitions: e.degradedTransitions.Load(), | ||||
| MutedTransitions: e.mutedTransitions.Load(), | MutedTransitions: e.mutedTransitions.Load(), | ||||
| @@ -604,12 +641,23 @@ func (e *Engine) writerLoop(ctx context.Context) { | |||||
| lateOver := writeDur - e.chunkDuration | lateOver := writeDur - e.chunkDuration | ||||
| if lateOver > writeLateTolerance { | if lateOver > writeLateTolerance { | ||||
| streak := e.lateBufferStreak.Add(1) | |||||
| late := e.lateBuffers.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 { | 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 { | if err != nil { | ||||
| @@ -12,19 +12,31 @@ import ( | |||||
| // goroutine reads them via NextFrame(). Returns silence on underrun. | // goroutine reads them via NextFrame(). Returns silence on underrun. | ||||
| // | // | ||||
| // Zero allocations in steady state. No mutex in the read or write path. | // 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 { | 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 | SampleRate int | ||||
| sampleRateAtomic atomic.Int32 | |||||
| writePos atomic.Int64 | writePos atomic.Int64 | ||||
| readPos atomic.Int64 | readPos atomic.Int64 | ||||
| Underruns atomic.Uint64 | Underruns atomic.Uint64 | ||||
| Overflows atomic.Uint64 | Overflows atomic.Uint64 | ||||
| Written atomic.Uint64 | Written atomic.Uint64 | ||||
| highWatermark atomic.Int64 | |||||
| highWatermark atomic.Int64 | |||||
| underrunStreak atomic.Uint64 | underrunStreak atomic.Uint64 | ||||
| maxUnderrunStreak atomic.Uint64 | maxUnderrunStreak atomic.Uint64 | ||||
| } | } | ||||
| @@ -37,12 +49,29 @@ func NewStreamSource(capacity, sampleRate int) *StreamSource { | |||||
| for size < capacity { | for size < capacity { | ||||
| size <<= 1 | size <<= 1 | ||||
| } | } | ||||
| return &StreamSource{ | |||||
| s := &StreamSource{ | |||||
| ring: make([]Frame, size), | ring: make([]Frame, size), | ||||
| size: size, | size: size, | ||||
| mask: size - 1, | mask: size - 1, | ||||
| SampleRate: sampleRate, | 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. | // WriteFrame pushes a single frame into the ring buffer. | ||||
| @@ -124,40 +153,41 @@ func (s *StreamSource) Stats() StreamStats { | |||||
| currentStreak := int(s.underrunStreak.Load()) | currentStreak := int(s.underrunStreak.Load()) | ||||
| maxStreak := int(s.maxUnderrunStreak.Load()) | maxStreak := int(s.maxUnderrunStreak.Load()) | ||||
| return StreamStats{ | 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), | 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. | // StreamStats exposes runtime telemetry for the stream buffer. | ||||
| type StreamStats struct { | 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"` | 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 { | func (s *StreamSource) bufferedDurationSeconds(available int) float64 { | ||||
| if s.SampleRate <= 0 { | |||||
| rate := s.GetSampleRate() | |||||
| if rate <= 0 { | |||||
| return 0 | return 0 | ||||
| } | } | ||||
| return float64(available) / float64(s.SampleRate) | |||||
| return float64(available) / float64(rate) | |||||
| } | } | ||||
| func (s *StreamSource) updateHighWatermark() { | func (s *StreamSource) updateHighWatermark() { | ||||
| @@ -195,33 +225,53 @@ func (s *StreamSource) resetUnderrunStreak() { | |||||
| // StreamResampler wraps a StreamSource and rate-converts from the stream's | // StreamResampler wraps a StreamSource and rate-converts from the stream's | ||||
| // native sample rate to the target output rate using linear interpolation. | // native sample rate to the target output rate using linear interpolation. | ||||
| // Consumes input frames on demand — no buffering beyond the ring buffer. | // 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 { | 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. | // 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 { | 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{ | return &StreamResampler{ | ||||
| src: src, | |||||
| ratio: float64(src.SampleRate) / outputRate, | |||||
| src: src, | |||||
| outputRate: outputRate, | |||||
| } | } | ||||
| } | } | ||||
| // NextFrame returns the next interpolated frame at the output rate. | // NextFrame returns the next interpolated frame at the output rate. | ||||
| // Implements the frameSource interface. | // 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 { | func (r *StreamResampler) NextFrame() Frame { | ||||
| if r.src == nil { | if r.src == nil { | ||||
| return NewFrame(0, 0) | 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 { | for r.pos >= 1.0 { | ||||
| r.prev = r.curr | r.prev = r.curr | ||||
| r.curr = r.src.ReadFrame() | r.curr = r.src.ReadFrame() | ||||
| @@ -231,7 +281,7 @@ func (r *StreamResampler) NextFrame() Frame { | |||||
| frac := r.pos | frac := r.pos | ||||
| l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac | l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac | ||||
| ri := float64(r.prev.R)*(1-frac) + float64(r.curr.R)*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)) | return NewFrame(Sample(l), Sample(ri)) | ||||
| } | } | ||||
| @@ -15,6 +15,7 @@ type Config struct { | |||||
| Backend BackendConfig `json:"backend"` | Backend BackendConfig `json:"backend"` | ||||
| Control ControlConfig `json:"control"` | Control ControlConfig `json:"control"` | ||||
| Runtime RuntimeConfig `json:"runtime"` | Runtime RuntimeConfig `json:"runtime"` | ||||
| Ingest IngestConfig `json:"ingest"` | |||||
| } | } | ||||
| type AudioConfig struct { | type AudioConfig struct { | ||||
| @@ -68,6 +69,75 @@ type RuntimeConfig struct { | |||||
| FrameQueueCapacity int `json:"frameQueueCapacity"` | 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 { | func Default() Config { | ||||
| return Config{ | return Config{ | ||||
| Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, | 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"}, | Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, | ||||
| Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, | Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, | ||||
| Runtime: RuntimeConfig{FrameQueueCapacity: 3}, | 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() | 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 { | func (c Config) Validate() error { | ||||
| if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | ||||
| return fmt.Errorf("audio.gain out of range") | 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 { | if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { | ||||
| return fmt.Errorf("fm.limiterCeiling out of range") | 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 { | if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { | ||||
| return fmt.Errorf("fm.mpxGain out of range (0.1..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 { | if c.Runtime.FrameQueueCapacity <= 0 { | ||||
| return fmt.Errorf("runtime.frameQueueCapacity must be > 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 | // Fail-loud PI validation | ||||
| if c.RDS.Enabled { | if c.RDS.Enabled { | ||||
| if _, err := ParsePI(c.RDS.PI); err != nil { | if _, err := ParsePI(c.RDS.PI); err != nil { | ||||
| @@ -123,3 +123,178 @@ func TestEffectiveDeviceRate(t *testing.T) { | |||||
| t.Fatal("expected 912000") | 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") | |||||
| } | |||||
| } | |||||
| @@ -10,10 +10,12 @@ import ( | |||||
| "strings" | "strings" | ||||
| "sync" | "sync" | ||||
| "sync/atomic" | "sync/atomic" | ||||
| "time" | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| "github.com/jan/fm-rds-tx/internal/config" | "github.com/jan/fm-rds-tx/internal/config" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | 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" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| ) | ) | ||||
| @@ -43,15 +45,31 @@ type LivePatch struct { | |||||
| LimiterCeiling *float64 | LimiterCeiling *float64 | ||||
| PS *string | PS *string | ||||
| RadioText *string | RadioText *string | ||||
| ToneLeftHz *float64 | |||||
| ToneRightHz *float64 | |||||
| ToneAmplitude *float64 | |||||
| AudioGain *float64 | |||||
| } | } | ||||
| type Server struct { | 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 | type auditEvent string | ||||
| @@ -98,20 +116,30 @@ func isJSONContentType(r *http.Request) bool { | |||||
| } | } | ||||
| type ConfigPatch struct { | 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 { | 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 { | 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) { | if !hasRequestBody(r) { | ||||
| return true | |||||
| return false | |||||
| } | } | ||||
| s.recordAudit(auditUnexpectedBody) | s.recordAudit(auditUnexpectedBody) | ||||
| http.Error(w, noBodyErrMsg, http.StatusBadRequest) | http.Error(w, noBodyErrMsg, http.StatusBadRequest) | ||||
| return false | |||||
| return true | |||||
| } | } | ||||
| func (s *Server) recordAudit(evt auditEvent) { | func (s *Server) recordAudit(evt auditEvent) { | ||||
| @@ -196,6 +227,30 @@ func (s *Server) SetStreamSource(src *audio.StreamSource) { | |||||
| s.mu.Unlock() | 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 { | func (s *Server) Handler() http.Handler { | ||||
| mux := http.NewServeMux() | mux := http.NewServeMux() | ||||
| mux.HandleFunc("/", s.handleUI) | mux.HandleFunc("/", s.handleUI) | ||||
| @@ -203,6 +258,7 @@ func (s *Server) Handler() http.Handler { | |||||
| mux.HandleFunc("/status", s.handleStatus) | mux.HandleFunc("/status", s.handleStatus) | ||||
| mux.HandleFunc("/dry-run", s.handleDryRun) | mux.HandleFunc("/dry-run", s.handleDryRun) | ||||
| mux.HandleFunc("/config", s.handleConfig) | mux.HandleFunc("/config", s.handleConfig) | ||||
| mux.HandleFunc("/config/ingest/save", s.handleIngestSave) | |||||
| mux.HandleFunc("/runtime", s.handleRuntime) | mux.HandleFunc("/runtime", s.handleRuntime) | ||||
| mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) | mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) | ||||
| mux.HandleFunc("/tx/start", s.handleTXStart) | mux.HandleFunc("/tx/start", s.handleTXStart) | ||||
| @@ -268,6 +324,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { | |||||
| drv := s.drv | drv := s.drv | ||||
| tx := s.tx | tx := s.tx | ||||
| stream := s.streamSrc | stream := s.streamSrc | ||||
| ingestRt := s.ingestRt | |||||
| s.mu.RUnlock() | s.mu.RUnlock() | ||||
| result := map[string]any{} | result := map[string]any{} | ||||
| @@ -275,11 +332,16 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { | |||||
| result["driver"] = drv.Stats() | result["driver"] = drv.Stats() | ||||
| } | } | ||||
| if tx != nil { | if tx != nil { | ||||
| result["engine"] = tx.TXStats() | |||||
| if stats := tx.TXStats(); stats != nil { | |||||
| result["engine"] = stats | |||||
| } | |||||
| } | } | ||||
| if stream != nil { | if stream != nil { | ||||
| result["audioStream"] = stream.Stats() | result["audioStream"] = stream.Stats() | ||||
| } | } | ||||
| if ingestRt != nil { | |||||
| result["ingest"] = ingestRt.Stats() | |||||
| } | |||||
| result["controlAudit"] = s.auditSnapshot() | result["controlAudit"] = s.auditSnapshot() | ||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(result) | _ = 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) | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||||
| return | return | ||||
| } | } | ||||
| if !s.rejectBody(w, r) { | |||||
| if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected | |||||
| return | return | ||||
| } | } | ||||
| s.mu.RLock() | 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}) | _ = 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) { | func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { | ||||
| if r.Method != http.MethodPost { | if r.Method != http.MethodPost { | ||||
| s.recordAudit(auditMethodNotAllowed) | s.recordAudit(auditMethodNotAllowed) | ||||
| @@ -325,14 +388,22 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { | |||||
| return | return | ||||
| } | } | ||||
| s.mu.RLock() | s.mu.RLock() | ||||
| stream := s.streamSrc | |||||
| ingress := s.audioIngress | |||||
| s.mu.RUnlock() | 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 | 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) | r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit) | ||||
| // Read body in chunks and push to ring buffer | // Read body in chunks and push to ring buffer | ||||
| @@ -341,7 +412,12 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { | |||||
| for { | for { | ||||
| n, err := r.Body.Read(buf) | n, err := r.Body.Read(buf) | ||||
| if n > 0 { | 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 != nil { | ||||
| if err == io.EOF { | 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{ | _ = json.NewEncoder(w).Encode(map[string]any{ | ||||
| "ok": true, | "ok": true, | ||||
| "frames": totalFrames, | "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) | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||||
| return | return | ||||
| } | } | ||||
| if !s.rejectBody(w, r) { | |||||
| if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected | |||||
| return | return | ||||
| } | } | ||||
| s.mu.RLock() | 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) | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||||
| return | return | ||||
| } | } | ||||
| if !s.rejectBody(w, r) { | |||||
| if s.rejectBody(w, r) { // BUG-01 fix: rejectBody returns true when rejected | |||||
| return | return | ||||
| } | } | ||||
| s.mu.RLock() | 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) | http.Error(w, "tx controller not available", http.StatusServiceUnavailable) | ||||
| return | 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") | 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) { | 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 { | if patch.ToneAmplitude != nil { | ||||
| next.Audio.ToneAmplitude = *patch.ToneAmplitude | next.Audio.ToneAmplitude = *patch.ToneAmplitude | ||||
| } | } | ||||
| if patch.AudioGain != nil { | |||||
| next.Audio.Gain = *patch.AudioGain | |||||
| } | |||||
| if patch.PS != nil { | if patch.PS != nil { | ||||
| next.RDS.PS = *patch.PS | next.RDS.PS = *patch.PS | ||||
| } | } | ||||
| if patch.RadioText != nil { | if patch.RadioText != nil { | ||||
| next.RDS.RadioText = *patch.RadioText | 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 { | if patch.PreEmphasisTauUS != nil { | ||||
| next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS | next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS | ||||
| } | } | ||||
| @@ -493,6 +576,15 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| if patch.RDSInjection != nil { | if patch.RDSInjection != nil { | ||||
| next.FM.RDSInjection = *patch.RDSInjection | 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 { | if err := next.Validate(); err != nil { | ||||
| s.mu.Unlock() | s.mu.Unlock() | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| @@ -509,21 +601,102 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| LimiterCeiling: patch.LimiterCeiling, | LimiterCeiling: patch.LimiterCeiling, | ||||
| PS: patch.PS, | PS: patch.PS, | ||||
| RadioText: patch.RadioText, | 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 | 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 { | 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") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) | _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) | ||||
| default: | default: | ||||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | 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) | |||||
| } | |||||
| } | |||||
| @@ -6,11 +6,14 @@ import ( | |||||
| "errors" | "errors" | ||||
| "net/http" | "net/http" | ||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "os" | |||||
| "path/filepath" | |||||
| "strings" | "strings" | ||||
| "testing" | "testing" | ||||
| "time" | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | 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" | "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) { | func TestRuntimeWithoutDriver(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| @@ -175,6 +280,143 @@ func TestRuntimeWithoutDriver(t *testing.T) { | |||||
| if rec.Code != 200 { | if rec.Code != 200 { | ||||
| t.Fatalf("status: %d", rec.Code) | 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) { | func TestRuntimeReportsFaultHistory(t *testing.T) { | ||||
| @@ -317,8 +559,8 @@ func TestAudioStreamRequiresSource(t *testing.T) { | |||||
| func TestAudioStreamPushesPCM(t *testing.T) { | func TestAudioStreamPushesPCM(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| srv := NewServer(cfg) | srv := NewServer(cfg) | ||||
| stream := audio.NewStreamSource(256, 44100) | |||||
| srv.SetStreamSource(stream) | |||||
| ingress := &fakeAudioIngress{} | |||||
| srv.SetAudioIngress(ingress) | |||||
| pcm := []byte{0, 0, 0, 0} | pcm := []byte{0, 0, 0, 0} | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) | req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) | ||||
| @@ -338,12 +580,8 @@ func TestAudioStreamPushesPCM(t *testing.T) { | |||||
| if frames != 1 { | if frames != 1 { | ||||
| t.Fatalf("expected 1 frame, got %v", frames) | 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) { | func TestAudioStreamRejectsMissingContentType(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| srv := NewServer(cfg) | srv := NewServer(cfg) | ||||
| srv.SetStreamSource(audio.NewStreamSource(256, 44100)) | |||||
| srv.SetAudioIngress(&fakeAudioIngress{}) | |||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | ||||
| srv.Handler().ServeHTTP(rec, req) | srv.Handler().ServeHTTP(rec, req) | ||||
| @@ -375,7 +613,7 @@ func TestAudioStreamRejectsMissingContentType(t *testing.T) { | |||||
| func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { | func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| srv := NewServer(cfg) | srv := NewServer(cfg) | ||||
| srv.SetStreamSource(audio.NewStreamSource(256, 44100)) | |||||
| srv.SetAudioIngress(&fakeAudioIngress{}) | |||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | ||||
| req.Header.Set("Content-Type", "text/plain") | req.Header.Set("Content-Type", "text/plain") | ||||
| @@ -397,7 +635,7 @@ func TestAudioStreamRejectsBodyTooLarge(t *testing.T) { | |||||
| limit := int(audioStreamBodyLimit) | limit := int(audioStreamBodyLimit) | ||||
| body := make([]byte, limit+1) | body := make([]byte, limit+1) | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| srv.SetStreamSource(audio.NewStreamSource(256, 44100)) | |||||
| srv.SetAudioIngress(&fakeAudioIngress{}) | |||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) | req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) | ||||
| req.Header.Set("Content-Type", "application/octet-stream") | req.Header.Set("Content-Type", "application/octet-stream") | ||||
| @@ -524,7 +762,7 @@ func TestControlAuditTracksMethodNotAllowed(t *testing.T) { | |||||
| func TestControlAuditTracksUnsupportedMediaType(t *testing.T) { | func TestControlAuditTracksUnsupportedMediaType(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| srv.SetStreamSource(audio.NewStreamSource(256, 44100)) | |||||
| srv.SetAudioIngress(&fakeAudioIngress{}) | |||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) | ||||
| srv.Handler().ServeHTTP(rec, req) | srv.Handler().ServeHTTP(rec, req) | ||||
| @@ -599,15 +837,43 @@ func newConfigPostRequest(body []byte) *http.Request { | |||||
| return req | 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 { | 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) StartTX() error { return nil } | ||||
| func (f *fakeTXController) StopTX() error { return nil } | func (f *fakeTXController) StopTX() error { return nil } | ||||
| func (f *fakeTXController) TXStats() map[string]any { | func (f *fakeTXController) TXStats() map[string]any { | ||||
| if f.returnNilStats { | |||||
| return nil | |||||
| } | |||||
| if f.stats != nil { | if f.stats != nil { | ||||
| return f.stats | return f.stats | ||||
| } | } | ||||
| @@ -8,20 +8,23 @@ import ( | |||||
| ) | ) | ||||
| const ( | 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. | // 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 { | func NewHTTPServer(cfg config.Config, handler http.Handler) *http.Server { | ||||
| return &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, | |||||
| } | } | ||||
| } | } | ||||
| @@ -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 | // ProcessChunk measures the audio power of a chunk and returns the | ||||
| // gain factor to apply to the audio composite for BS.412 compliance. | // gain factor to apply to the audio composite for BS.412 compliance. | ||||
| // Call once per chunk with the average audio power of that chunk. | // Call once per chunk with the average audio power of that chunk. | ||||
| @@ -1,3 +1,13 @@ | |||||
| module github.com/jan/fm-rds-tx/internal | 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 | |||||
| @@ -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= | |||||
| @@ -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 = ©Origin | |||||
| } | |||||
| 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 | |||||
| } | |||||
| @@ -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{} | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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")) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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, | |||||
| )) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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) | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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"` | |||||
| } | |||||
| @@ -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"` | |||||
| } | |||||
| @@ -32,6 +32,11 @@ type LiveParams struct { | |||||
| LimiterEnabled bool | LimiterEnabled bool | ||||
| LimiterCeiling float64 | LimiterCeiling float64 | ||||
| MpxGain float64 // hardware calibration factor for composite output | 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. | // 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). | // Optional external audio source (e.g. StreamResampler for live audio). | ||||
| // When set, takes priority over WAV/tones in sourceFor(). | // When set, takes priority over WAV/tones in sourceFor(). | ||||
| externalSource frameSource | 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 { | 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 | // SetExternalSource sets a live audio source (e.g. StreamResampler) that | ||||
| // takes priority over WAV/tone sources. Must be called before the first | // 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) { | 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 | g.externalSource = src | ||||
| } | } | ||||
| @@ -189,12 +205,14 @@ func (g *Generator) init() { | |||||
| g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) | g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) | ||||
| // BS.412 MPX power limiter (EU/CH requirement for licensed FM) | // BS.412 MPX power limiter (EU/CH requirement for licensed FM) | ||||
| if g.cfg.FM.BS412Enabled { | 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.bs412 = dsp.NewBS412Limiter( | ||||
| g.cfg.FM.BS412ThresholdDBr, | g.cfg.FM.BS412ThresholdDBr, | ||||
| g.cfg.FM.PilotLevel, | g.cfg.FM.PilotLevel, | ||||
| g.cfg.FM.RDSInjection, | g.cfg.FM.RDSInjection, | ||||
| chunkSec, | |||||
| 0, | |||||
| ) | ) | ||||
| } | } | ||||
| if g.cfg.FM.FMModulationEnabled { | if g.cfg.FM.FMModulationEnabled { | ||||
| @@ -218,6 +236,10 @@ func (g *Generator) init() { | |||||
| LimiterEnabled: g.cfg.FM.LimiterEnabled, | LimiterEnabled: g.cfg.FM.LimiterEnabled, | ||||
| LimiterCeiling: ceiling, | LimiterCeiling: ceiling, | ||||
| MpxGain: g.cfg.FM.MpxGain, | 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 | 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 { | 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.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 { | 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} | 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: | // Broadcast clip-filter-clip FM MPX signal chain: | ||||
| // | // | ||||
| // Audio L/R → PreEmphasis | // 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 { | if g.bs412 != nil && samples > 0 { | ||||
| chunkSec := float64(samples) / g.sampleRate | |||||
| g.bs412.UpdateChunkDuration(chunkSec) | |||||
| g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) | g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) | ||||
| } | } | ||||
| @@ -80,22 +80,19 @@ func (q *FrameQueue) Capacity() int { | |||||
| } | } | ||||
| // FillLevel reports the current occupancy as a fraction of capacity. | // 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 { | func (q *FrameQueue) FillLevel() float64 { | ||||
| q.mu.Lock() | |||||
| depth := q.depth | |||||
| q.mu.Unlock() | |||||
| if q.capacity == 0 { | if q.capacity == 0 { | ||||
| return 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. | // Depth returns the current number of frames in the queue. | ||||
| // Uses len(ch) directly for accuracy (see FillLevel). | |||||
| func (q *FrameQueue) Depth() int { | 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. | // Stats returns a snapshot of the queue metrics. | ||||
| @@ -104,7 +101,7 @@ func (q *FrameQueue) Stats() QueueStats { | |||||
| fill := q.fillLevelLocked() | fill := q.fillLevelLocked() | ||||
| stats := QueueStats{ | stats := QueueStats{ | ||||
| Capacity: q.capacity, | Capacity: q.capacity, | ||||
| Depth: q.depth, | |||||
| Depth: len(q.ch), | |||||
| FillLevel: fill, | FillLevel: fill, | ||||
| Health: queueHealthFromFill(fill), | Health: queueHealthFromFill(fill), | ||||
| HighWaterMark: q.highWaterMark, | HighWaterMark: q.highWaterMark, | ||||
| @@ -128,11 +125,15 @@ func (q *FrameQueue) Push(ctx context.Context, frame *CompositeFrame) error { | |||||
| return ErrFrameQueueClosed | 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 { | select { | ||||
| case q.ch <- frame: | case q.ch <- frame: | ||||
| q.updateDepth(+1) | |||||
| return nil | return nil | ||||
| case <-ctx.Done(): | case <-ctx.Done(): | ||||
| q.updateDepth(-1) | |||||
| q.recordPushTimeout() | q.recordPushTimeout() | ||||
| return ctx.Err() | return ctx.Err() | ||||
| } | } | ||||
| @@ -211,7 +212,9 @@ func (q *FrameQueue) fillLevelLocked() float64 { | |||||
| if q.capacity == 0 { | if q.capacity == 0 { | ||||
| return 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() { | func (q *FrameQueue) recordPushTimeout() { | ||||
| @@ -94,8 +94,17 @@ type Encoder struct { | |||||
| // Live-updatable text — written by control API, read at group boundaries. | // Live-updatable text — written by control API, read at group boundaries. | ||||
| // Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz). | // 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) { | 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, | // UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers, | ||||
| // applied at the next RDS group boundary by the DSP goroutine. | // 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) { | func (e *Encoder) UpdateText(ps, rt string) { | ||||
| if ps != "" { | if ps != "" { | ||||
| e.livePS.Store(normalizePS(ps)) | |||||
| e.livePS.Store(pendingText{val: normalizePS(ps), set: true}) | |||||
| } | } | ||||
| if rt != "" { | 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. | // NextSample returns the next RDS subcarrier sample at the configured rate. | ||||
| // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | ||||
| // for phase-locked operation in a stereo MPX chain. | // 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). | // Apply live text updates at group boundaries (~88ms at 228kHz). | ||||
| // Atomics are consumed (cleared) after reading to prevent | // Atomics are consumed (cleared) after reading to prevent | ||||
| // re-applying the same text every group and toggling A/B flag. | // 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.rtIdx = 0 // restart RT transmission for new text | ||||
| e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | ||||
| e.liveRT.Store("") // consumed | |||||
| e.liveRT.Store(pendingText{}) // consumed | |||||
| } | } | ||||
| e.getRDSGroup() | e.getRDSGroup() | ||||
| e.bitPos = 0 | 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 | out := make([]float64, n); for i := range out { out[i] = e.NextSample() }; return out | ||||
| } | } | ||||
| func (e *Encoder) Symbol() float64 { | 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++ | 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() { | func (e *Encoder) getRDSGroup() { | ||||