| @@ -15,6 +15,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/httpraw" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" | |||||
| "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 +39,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 +62,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 +75,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 +86,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 +95,24 @@ 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, driver, *txAutoStart, *audioStdin, *audioRate, *audioHTTP) | ||||
| return | return | ||||
| } | } | ||||
| // --- default: HTTP only --- | |||||
| srv := ctrlpkg.NewServer(cfg) | srv := ctrlpkg.NewServer(cfg) | ||||
| 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 +120,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 +127,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() | ||||
| @@ -150,14 +143,11 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a | |||||
| 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 +171,45 @@ 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 cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { | |||||
| rate := ingestSampleRate(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 := buildPhase1Source(cfg) | |||||
| if err != nil { | |||||
| log.Fatalf("ingest source: %v", err) | |||||
| } | } | ||||
| 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) | |||||
| 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) | ||||
| 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 +218,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 +235,56 @@ 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 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 | |||||
| } | |||||
| func ingestSampleRate(cfg cfgpkg.Config) int { | |||||
| switch cfg.Ingest.Kind { | |||||
| case "stdin", "stdin-pcm": | |||||
| return cfg.Ingest.Stdin.SampleRateHz | |||||
| case "http-raw": | |||||
| return cfg.Ingest.HTTPRaw.SampleRateHz | |||||
| default: | |||||
| return 44100 | |||||
| } | |||||
| } | |||||
| func buildPhase1Source(cfg cfgpkg.Config) (ingest.Source, ctrlpkg.AudioIngress, error) { | |||||
| switch cfg.Ingest.Kind { | |||||
| case "stdin", "stdin-pcm": | |||||
| src := stdinpcm.New("stdin-main", os.Stdin, 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 "", "none": | |||||
| return nil, nil, nil | |||||
| default: | |||||
| return nil, nil, fmt.Errorf("unsupported ingest kind: %s", cfg.Ingest.Kind) | |||||
| } | |||||
| } | |||||
| 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()) } | ||||
| @@ -14,6 +14,7 @@ import ( | |||||
| "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" | ||||
| ) | ) | ||||
| @@ -46,12 +47,22 @@ type LivePatch struct { | |||||
| } | } | ||||
| 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 | |||||
| audit auditCounters | |||||
| } | |||||
| type AudioIngress interface { | |||||
| WritePCM16(data []byte) (int, error) | |||||
| } | |||||
| type IngestRuntime interface { | |||||
| Stats() ingest.Stats | |||||
| } | } | ||||
| type auditEvent string | type auditEvent string | ||||
| @@ -196,6 +207,18 @@ 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) Handler() http.Handler { | func (s *Server) Handler() http.Handler { | ||||
| mux := http.NewServeMux() | mux := http.NewServeMux() | ||||
| mux.HandleFunc("/", s.handleUI) | mux.HandleFunc("/", s.handleUI) | ||||
| @@ -268,6 +291,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{} | ||||
| @@ -280,6 +304,9 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { | |||||
| 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) | ||||
| @@ -311,8 +338,9 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) | |||||
| // handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes | // handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes | ||||
| // it into the live audio ring buffer. Use with: | // 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 | |||||
| // | |||||
| // 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,11 +353,11 @@ 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 | ||||
| } | } | ||||
| @@ -341,7 +369,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 +395,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(), | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -9,7 +9,6 @@ import ( | |||||
| "strings" | "strings" | ||||
| "testing" | "testing" | ||||
| "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/output" | "github.com/jan/fm-rds-tx/internal/output" | ||||
| ) | ) | ||||
| @@ -317,8 +316,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 +337,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 +355,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 +370,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 +392,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 +519,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) | ||||
| @@ -605,6 +600,16 @@ type fakeTXController struct { | |||||
| stats map[string]any | stats map[string]any | ||||
| } | } | ||||
| type fakeAudioIngress struct { | |||||
| totalFrames int | |||||
| } | |||||
| func (f *fakeAudioIngress) WritePCM16(data []byte) (int, error) { | |||||
| frames := len(data) / 4 | |||||
| f.totalFrames += frames | |||||
| return frames, nil | |||||
| } | |||||
| 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 { | ||||
| @@ -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 | |||||
| } | |||||
| @@ -34,6 +34,12 @@ func NewRuntime(sink *audio.StreamSource, src Source) *Runtime { | |||||
| } | } | ||||
| func (r *Runtime) Start(ctx context.Context) error { | 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 { | if r.source == nil { | ||||
| r.mu.Lock() | r.mu.Lock() | ||||
| r.stats.State = "idle" | r.stats.State = "idle" | ||||
| @@ -91,7 +97,11 @@ func (r *Runtime) run() { | |||||
| select { | select { | ||||
| case <-r.ctx.Done(): | case <-r.ctx.Done(): | ||||
| return | return | ||||
| case err := <-errCh: | |||||
| case err, ok := <-errCh: | |||||
| if !ok { | |||||
| errCh = nil | |||||
| continue | |||||
| } | |||||
| if err == nil { | if err == nil { | ||||
| continue | continue | ||||
| } | } | ||||
| @@ -100,6 +110,9 @@ func (r *Runtime) run() { | |||||
| r.mu.Unlock() | r.mu.Unlock() | ||||
| case chunk, ok := <-ch: | case chunk, ok := <-ch: | ||||
| if !ok { | if !ok { | ||||
| r.mu.Lock() | |||||
| r.stats.State = "stopped" | |||||
| r.mu.Unlock() | |||||
| return | return | ||||
| } | } | ||||
| r.handleChunk(chunk) | r.handleChunk(chunk) | ||||