| @@ -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 | 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 | ## CLI overview | ||||
| ## `fmrtx` | ## `fmrtx` | ||||
| @@ -156,6 +164,7 @@ Important runtime modes and flags include: | |||||
| - `--list-devices` | - `--list-devices` | ||||
| - `--audio-stdin` | - `--audio-stdin` | ||||
| - `--audio-rate <hz>` | - `--audio-rate <hz>` | ||||
| - `--audio-http` | |||||
| ## `offline` | ## `offline` | ||||
| Useful flags include: | Useful flags include: | ||||
| @@ -196,7 +205,7 @@ POST /audio/stream push raw S16LE stereo PCM into live stream buffer | |||||
| - live patching of selected parameters | - live patching of selected parameters | ||||
| - dry-run inspection | - dry-run inspection | ||||
| - browser-accessible control UI | - browser-accessible control UI | ||||
| - optional HTTP audio ingest | |||||
| - optional HTTP audio ingest (enable with `--audio-http`) | |||||
| ### Live config notes | ### Live config notes | ||||
| `POST /config` supports live updates for selected fields such as: | `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") | listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") | ||||
| audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") | 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)") | 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() | flag.Parse() | ||||
| // --- list-devices (SoapySDR) --- | // --- list-devices (SoapySDR) --- | ||||
| @@ -102,7 +103,7 @@ func main() { | |||||
| 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) | |||||
| runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) | |||||
| return | return | ||||
| } | } | ||||
| @@ -145,7 +146,7 @@ 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) { | |||||
| func runTXMode(cfg cfgpkg.Config, 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() | ||||
| @@ -185,20 +186,28 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a | |||||
| // Live audio stream source (optional) | // Live audio stream source (optional) | ||||
| var streamSrc *audio.StreamSource | var streamSrc *audio.StreamSource | ||||
| if audioStdin { | |||||
| if audioStdin || audioHTTP { | |||||
| // Buffer: 2 seconds at input rate — enough to absorb jitter | // 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) | 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 | // 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). | 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. | **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 | ### 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 | ```bash | ||||
| # From another machine on the network | # From another machine on the network | ||||
| @@ -63,7 +63,7 @@ Kein „ist im Kopf klar“. Der Stand kommt hier rein. | |||||
| | ID | Status | Beschreibung | Ort | | | 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` | | | 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" | "testing" | ||||
| 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/audio" | |||||
| "github.com/jan/fm-rds-tx/internal/output" | "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) { | func TestTXStartWithoutController(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||