浏览代码

feat: add explicit HTTP audio ingest mode

tags/v0.9.0
Jan Svabenik 1 个月前
父节点
当前提交
44ff130d23
共有 5 个文件被更改,包括 78 次插入17 次删除
  1. +10
    -1
      README.md
  2. +22
    -13
      cmd/fmrtx/main.go
  3. +2
    -2
      docs/API.md
  4. +1
    -1
      docs/pro-runtime-hardening-workboard.md
  5. +43
    -0
      internal/control/control_test.go

+ 10
- 1
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 <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:


+ 22
- 13
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


+ 2
- 2
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


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

---



+ 43
- 0
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()


正在加载...
取消
保存