From 44ff130d230779ffd8d4359f16e9473e534a89b7 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 5 Apr 2026 18:26:27 +0200 Subject: [PATCH] feat: add explicit HTTP audio ingest mode --- README.md | 11 ++++++- cmd/fmrtx/main.go | 35 ++++++++++++-------- docs/API.md | 4 +-- docs/pro-runtime-hardening-workboard.md | 2 +- internal/control/control_test.go | 43 +++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ca0a03b..ad73b7c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,14 @@ ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | .\fmrtx.exe ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | .\fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 48000 --config docs/config.plutosdr.json ``` +### 8) HTTP audio ingest + +Start the control plane with `--audio-http` to accept raw PCM pushes on `/audio/stream` and feed them into the live encoder: + +```powershell +ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://localhost:8088/audio/stream +``` + ## CLI overview ## `fmrtx` @@ -156,6 +164,7 @@ Important runtime modes and flags include: - `--list-devices` - `--audio-stdin` - `--audio-rate ` +- `--audio-http` ## `offline` Useful flags include: @@ -196,7 +205,7 @@ POST /audio/stream push raw S16LE stereo PCM into live stream buffer - live patching of selected parameters - dry-run inspection - browser-accessible control UI -- optional HTTP audio ingest +- optional HTTP audio ingest (enable with `--audio-http`) ### Live config notes `POST /config` supports live updates for selected fields such as: diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index d839d65..a7abed9 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -34,6 +34,7 @@ func main() { listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)") + audioHTTP := flag.Bool("audio-http", false, "enable HTTP audio ingest via /audio/stream") flag.Parse() // --- list-devices (SoapySDR) --- @@ -102,7 +103,7 @@ func main() { if driver == nil { log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") } - runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate) + runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) return } @@ -145,7 +146,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { return nil } -func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int) { +func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int, audioHTTP bool) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -185,20 +186,28 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a // Live audio stream source (optional) var streamSrc *audio.StreamSource - if audioStdin { + if audioStdin || audioHTTP { // Buffer: 2 seconds at input rate — enough to absorb jitter - streamSrc = audio.NewStreamSource(audioRate*2, audioRate) + bufferFrames := audioRate * 2 + if bufferFrames <= 0 { + bufferFrames = 1 + } + streamSrc = audio.NewStreamSource(bufferFrames, audioRate) engine.SetStreamSource(streamSrc) - // Stdin ingest goroutine - 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") - } - }() + 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") + } + }() + } + if audioHTTP { + log.Printf("audio: HTTP ingest enabled on /audio/stream (rate=%dHz, buffer=%d frames)", audioRate, streamSrc.Stats().Capacity) + } } // Control plane diff --git a/docs/API.md b/docs/API.md index 97742f3..57301bb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -237,7 +237,7 @@ These cannot be hot-reloaded (they affect DSP pipeline structure): Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at the configured `--audio-rate` (default 44100 Hz). -Requires `--audio-stdin` or a configured stream source. +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. @@ -300,7 +300,7 @@ ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \ ### HTTP audio push -Push audio from a remote machine via the HTTP API: +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 diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index e2cd6af..694a7cf 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -63,7 +63,7 @@ Kein „ist im Kopf klar“. Der Stand kommt hier rein. | ID | Status | Beschreibung | Ort | |---|---|---|---| | CFG-SEM-001 | CONFIRMED | `fm.outputDrive` wird in Validation und Runtime nicht konsistent behandelt | `internal/config/config.go`, `internal/app/engine.go` | -| CTL-UX-001 | CONFIRMED | `handleAudioStream()` referenziert `--audio-http`, was CLI-seitig überprüft werden sollte | `internal/control/control.go` | +| CTL-UX-001 | RESOLVED | `handleAudioStream()` beschreibt `--audio-http`; der CLI-Schalter ist nun vorhanden und setzt den Stream-Puffer für `/audio/stream` direkt. | `internal/control/control.go`, `cmd/fmrtx/main.go` | --- diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 93fc508..9c07f76 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -9,6 +9,7 @@ import ( "testing" cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/output" ) @@ -124,6 +125,48 @@ func TestRuntimeWithoutDriver(t *testing.T) { } } +func TestAudioStreamRequiresSource(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(nil)) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when audio stream missing, got %d", rec.Code) + } +} + +func TestAudioStreamPushesPCM(t *testing.T) { + cfg := cfgpkg.Default() + srv := NewServer(cfg) + stream := audio.NewStreamSource(256, 44100) + srv.SetStreamSource(stream) + pcm := []byte{0, 0, 0, 0} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(pcm)) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("expected 200, got %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if ok, _ := body["ok"].(bool); !ok { + t.Fatalf("expected ok true, got %v", body["ok"]) + } + frames, _ := body["frames"].(float64) + if frames != 1 { + t.Fatalf("expected 1 frame, got %v", frames) + } + stats, ok := body["stats"].(map[string]any) + if !ok { + t.Fatalf("missing stats: %v", body["stats"]) + } + if avail, _ := stats["available"].(float64); avail < 1 { + t.Fatalf("expected stats.available >= 1, got %v", avail) + } +} + func TestTXStartWithoutController(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder()