| @@ -16,9 +16,7 @@ import ( | |||||
| 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" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/httpraw" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/stdinpcm" | |||||
| ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" | |||||
| "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" | ||||
| @@ -179,7 +177,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a | |||||
| var ingestRuntime *ingest.Runtime | var ingestRuntime *ingest.Runtime | ||||
| var ingress ctrlpkg.AudioIngress | var ingress ctrlpkg.AudioIngress | ||||
| if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { | if cfg.Ingest.Kind != "" && cfg.Ingest.Kind != "none" { | ||||
| rate := ingestSampleRate(cfg) | |||||
| rate := ingestfactory.SampleRateForKind(cfg) | |||||
| bufferFrames := rate * 2 | bufferFrames := rate * 2 | ||||
| if bufferFrames <= 0 { | if bufferFrames <= 0 { | ||||
| bufferFrames = 1 | bufferFrames = 1 | ||||
| @@ -187,7 +185,7 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a | |||||
| streamSrc = audio.NewStreamSource(bufferFrames, rate) | streamSrc = audio.NewStreamSource(bufferFrames, rate) | ||||
| engine.SetStreamSource(streamSrc) | engine.SetStreamSource(streamSrc) | ||||
| source, sourceIngress, err := buildPhase1Source(cfg) | |||||
| source, sourceIngress, err := ingestfactory.BuildSource(cfg, ingestfactory.Deps{Stdin: os.Stdin}) | |||||
| if err != nil { | if err != nil { | ||||
| log.Fatalf("ingest source: %v", err) | log.Fatalf("ingest source: %v", err) | ||||
| } | } | ||||
| @@ -260,41 +258,6 @@ func applyLegacyAudioFlags(cfg cfgpkg.Config, audioStdin bool, audioRate int, au | |||||
| return cfg | 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 | |||||
| case "icecast": | |||||
| return 44100 | |||||
| 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 "icecast": | |||||
| src := icecast.New("icecast-main", cfg.Ingest.Icecast.URL, nil, icecast.ReconnectConfig{ | |||||
| Enabled: cfg.Ingest.Reconnect.Enabled, | |||||
| InitialBackoffMs: cfg.Ingest.Reconnect.InitialBackoffMs, | |||||
| MaxBackoffMs: cfg.Ingest.Reconnect.MaxBackoffMs, | |||||
| }) | |||||
| return src, nil, 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()) } | ||||
| @@ -137,7 +137,7 @@ func Default() Config { | |||||
| Format: "s16le", | Format: "s16le", | ||||
| }, | }, | ||||
| Icecast: IngestIcecastConfig{ | Icecast: IngestIcecastConfig{ | ||||
| Decoder: "native", | |||||
| Decoder: "auto", | |||||
| }, | }, | ||||
| }, | }, | ||||
| } | } | ||||
| @@ -207,9 +207,6 @@ func (c Config) Validate() error { | |||||
| if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { | if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { | ||||
| return fmt.Errorf("fm.limiterCeiling out of range") | return fmt.Errorf("fm.limiterCeiling out of range") | ||||
| } | } | ||||
| if c.FM.MpxGain == 0 { | |||||
| c.FM.MpxGain = 1.0 | |||||
| } // default if omitted from JSON | |||||
| if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { | if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { | ||||
| return fmt.Errorf("fm.mpxGain out of range (0.1..5)") | return fmt.Errorf("fm.mpxGain out of range (0.1..5)") | ||||
| } | } | ||||
| @@ -228,6 +225,11 @@ func (c Config) Validate() error { | |||||
| if c.Ingest.Kind == "" { | if c.Ingest.Kind == "" { | ||||
| c.Ingest.Kind = "none" | c.Ingest.Kind = "none" | ||||
| } | } | ||||
| switch strings.ToLower(strings.TrimSpace(c.Ingest.Kind)) { | |||||
| case "none", "stdin", "stdin-pcm", "http-raw", "icecast": | |||||
| default: | |||||
| return fmt.Errorf("ingest.kind unsupported: %s", c.Ingest.Kind) | |||||
| } | |||||
| if c.Ingest.PrebufferMs < 0 { | if c.Ingest.PrebufferMs < 0 { | ||||
| return fmt.Errorf("ingest.prebufferMs must be >= 0") | return fmt.Errorf("ingest.prebufferMs must be >= 0") | ||||
| } | } | ||||
| @@ -237,18 +239,32 @@ func (c Config) Validate() error { | |||||
| if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 { | if c.Ingest.Reconnect.InitialBackoffMs < 0 || c.Ingest.Reconnect.MaxBackoffMs < 0 { | ||||
| return fmt.Errorf("ingest.reconnect backoff must be >= 0") | return fmt.Errorf("ingest.reconnect backoff must be >= 0") | ||||
| } | } | ||||
| if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.InitialBackoffMs <= 0 { | |||||
| return fmt.Errorf("ingest.reconnect.initialBackoffMs must be > 0 when reconnect is enabled") | |||||
| } | |||||
| if c.Ingest.Reconnect.Enabled && c.Ingest.Reconnect.MaxBackoffMs <= 0 { | |||||
| return fmt.Errorf("ingest.reconnect.maxBackoffMs must be > 0 when reconnect is enabled") | |||||
| } | |||||
| if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs { | if c.Ingest.Reconnect.MaxBackoffMs > 0 && c.Ingest.Reconnect.InitialBackoffMs > c.Ingest.Reconnect.MaxBackoffMs { | ||||
| return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs") | return fmt.Errorf("ingest.reconnect.initialBackoffMs must be <= maxBackoffMs") | ||||
| } | } | ||||
| if c.Ingest.Stdin.SampleRateHz < 0 || c.Ingest.HTTPRaw.SampleRateHz < 0 { | |||||
| return fmt.Errorf("ingest pcm sampleRateHz must be >= 0") | |||||
| if c.Ingest.Stdin.SampleRateHz <= 0 || c.Ingest.HTTPRaw.SampleRateHz <= 0 { | |||||
| return fmt.Errorf("ingest pcm sampleRateHz must be > 0") | |||||
| } | } | ||||
| if c.Ingest.Stdin.Channels < 0 || c.Ingest.HTTPRaw.Channels < 0 { | |||||
| return fmt.Errorf("ingest pcm channels must be >= 0") | |||||
| if (c.Ingest.Stdin.Channels != 1 && c.Ingest.Stdin.Channels != 2) || (c.Ingest.HTTPRaw.Channels != 1 && c.Ingest.HTTPRaw.Channels != 2) { | |||||
| return fmt.Errorf("ingest pcm channels must be 1 or 2") | |||||
| } | |||||
| if strings.ToLower(strings.TrimSpace(c.Ingest.Stdin.Format)) != "s16le" || strings.ToLower(strings.TrimSpace(c.Ingest.HTTPRaw.Format)) != "s16le" { | |||||
| return fmt.Errorf("ingest pcm format must be s16le") | |||||
| } | } | ||||
| if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { | if c.Ingest.Kind == "icecast" && strings.TrimSpace(c.Ingest.Icecast.URL) == "" { | ||||
| return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") | return fmt.Errorf("ingest.icecast.url is required when ingest.kind=icecast") | ||||
| } | } | ||||
| switch strings.ToLower(strings.TrimSpace(c.Ingest.Icecast.Decoder)) { | |||||
| case "", "auto", "native", "ffmpeg", "fallback": | |||||
| default: | |||||
| return fmt.Errorf("ingest.icecast.decoder unsupported: %s", c.Ingest.Icecast.Decoder) | |||||
| } | |||||
| // Fail-loud PI validation | // Fail-loud PI validation | ||||
| if c.RDS.Enabled { | if c.RDS.Enabled { | ||||
| if _, err := ParsePI(c.RDS.PI); err != nil { | if _, err := ParsePI(c.RDS.PI); err != nil { | ||||
| @@ -123,3 +123,56 @@ func TestEffectiveDeviceRate(t *testing.T) { | |||||
| t.Fatal("expected 912000") | t.Fatal("expected 912000") | ||||
| } | } | ||||
| } | } | ||||
| func TestValidateRejectsUnsupportedIngestKind(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.Ingest.Kind = "unsupported" | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected error") | |||||
| } | |||||
| } | |||||
| func TestValidateRejectsUnsupportedIngestPCMShape(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.Ingest.Stdin.SampleRateHz = 0 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected sampleRate error") | |||||
| } | |||||
| cfg = Default() | |||||
| cfg.Ingest.HTTPRaw.Channels = 6 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected channels error") | |||||
| } | |||||
| cfg = Default() | |||||
| cfg.Ingest.Stdin.Format = "f32le" | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected format error") | |||||
| } | |||||
| } | |||||
| func TestValidateRejectsUnsupportedIcecastDecoder(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.Ingest.Icecast.Decoder = "mystery" | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected decoder error") | |||||
| } | |||||
| } | |||||
| func TestValidateRejectsReconnectWithMissingBackoff(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.Ingest.Reconnect.Enabled = true | |||||
| cfg.Ingest.Reconnect.InitialBackoffMs = 0 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected reconnect backoff error") | |||||
| } | |||||
| } | |||||
| func TestValidateRejectsZeroMpxGain(t *testing.T) { | |||||
| cfg := Default() | |||||
| cfg.FM.MpxGain = 0 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected mpxGain error") | |||||
| } | |||||
| } | |||||