| @@ -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 <hz>` | |||
| - `--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: | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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` | | |||
| --- | |||
| @@ -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() | |||