| @@ -1,47 +1,55 @@ | |||||
| # Changelog | # Changelog | ||||
| ## v0.4.0-pre | |||||
| ## v0.5.0-pre | |||||
| Full review implementation: HW-integration readiness, unity signal path, TX engine, spectral verification. | |||||
| ### Architecture changes | |||||
| - All signal sources (pilot, RDS, stereo) now output unity-normalized signals (peak ±1.0) | |||||
| - Combiner gains are direct config values — no magic-number normalization (`/0.1`, `/0.05` eliminated) | |||||
| - Pre-emphasis moved from composite rate to audio input rate (correct signal path, efficient on SBC) | |||||
| - RDS encoder: `NextSample()` zero-allocation hot path, fixed-size `[104]uint8` bit buffer | |||||
| - PI validation moved to `config.Validate()` — fail-loud, no silent 0x1234 fallback | |||||
| ### New: TX Engine (`internal/app/engine.go`) | |||||
| - Continuous chunk-based generation loop with configurable chunk duration | |||||
| - Explicit `Start()`/`Stop()` — TX default is OFF | |||||
| - Atomic underrun/overrun counters, chunk/sample stats | |||||
| - Context-based cancellation with clean drain | |||||
| ### New: Extended SoapyDriver interface | |||||
| - `Start(ctx)`, `Stop(ctx)`, `Capabilities(ctx)`, `Stats()` added to driver contract | |||||
| - `SimulatedDriver` implements full interface with atomic runtime counters | |||||
| - `DeviceCaps` struct for hardware capability reporting | |||||
| - `RuntimeStats` struct for live telemetry | |||||
| ### New: Rate adaptation | |||||
| - `backend.deviceSampleRateHz` separates SDR device rate from internal composite rate | |||||
| - `Config.EffectiveDeviceRate()` resolves to device rate or falls back to composite rate | |||||
| ### New: Control plane upgrade | |||||
| - `POST /tx/start` — explicit TX start (requires `TXController`) | |||||
| - `POST /tx/stop` — explicit TX stop | |||||
| - `GET /runtime` — live engine + driver telemetry | |||||
| - `TXController` interface for decoupled engine control | |||||
| ### New: Spectral verification | |||||
| - Goertzel algorithm (`dsp.GoertzelEnergy`, `dsp.BandEnergy`) | |||||
| - Blackbox tests verify 19 kHz pilot, 38 kHz stereo, 57 kHz RDS energy presence | |||||
| - Blackbox tests verify suppression when stereo/RDS disabled | |||||
| ### New: Operator truth tests | |||||
| - `rds.enabled=false` → verified no 57 kHz energy | |||||
| - `fmModulationEnabled=false` → verified Q=0 | |||||
| - `limiterEnabled=false` → verified higher peaks than with limiter | |||||
| - `stereoEnabled=false` → verified no pilot/stereo energy | |||||
| ### Config changes | |||||
| - `preEmphasisUS` → `preEmphasisTauUS` (unambiguous: microseconds, not region) | |||||
| - `audio.sampleRate` → `audio.inputSampleRate` (clear: input source rate) | |||||
| - `backend.deviceSampleRateHz` added (0 = same as compositeRateHz) | |||||
| - `rds.pi` validated at load time; empty/invalid = hard error | |||||
| - `rds.pty` range-checked (0-31) | |||||
| Full DSP chain implementation, pre-hardware milestone. | |||||
| ### Added | |||||
| - RDS encoder rewrite: standards-grade group framing (0A for PS, 2A for RadioText), CRC-10 per IEC 62106 with offset words, differential encoding, group scheduler with PS/RT cycling and A/B flag toggle | |||||
| - Stereo encoder: stateful 38 kHz DSB-SC subcarrier oscillator (phase-coherent across block boundaries), replaces fragile sample-indexed sin() in generator | |||||
| - Pre-emphasis filter: first-order IIR high-shelf, configurable τ (50 µs EU, 75 µs US, 0=off), with complementary de-emphasis for verification | |||||
| - FM modulator: composite-to-IQ via phase integration, configurable ±75 kHz deviation, unit-magnitude output guaranteed | |||||
| - MPX limiter: smooth attack/release peak limiter with hard-clip safety net | |||||
| - Linear-interpolation audio resampler (replaces nearest-neighbor) | |||||
| - Robust WAV loader with RIFF chunk scanning (handles LIST, INFO, bext, etc.) | |||||
| - New config fields: preEmphasisUS, maxDeviationHz, limiterEnabled, limiterCeiling, fmModulationEnabled | |||||
| - New patchable runtime fields via HTTP: preEmphasisUS, limiterEnabled, limiterCeiling | |||||
| - Comprehensive tests for all new DSP blocks | |||||
| ### Changed | |||||
| - Config defaults: pre-emphasis 50µs, limiter on, FM modulation on, deviation ±75kHz, RDS injection 0.05 | |||||
| - Offline generator wires full DSP chain: pre-emphasis → stereo encode → RDS → MPX combine → limiter → FM modulate | |||||
| - Dry-run output includes all new config fields | |||||
| - Status endpoint reports pre-emphasis, limiter, and FM modulation state | |||||
| ### Fixed | |||||
| - Stereo subcarrier phase discontinuity at block boundaries | |||||
| - WAV loader breaking on files with extra metadata chunks before data | |||||
| - Resampler aliasing artifacts from zero-order hold | |||||
| ## v0.3.0-pre | |||||
| Initial pre-v1 milestone for the licensed short-term FM/RDS transmitter project. | |||||
| ### Included | |||||
| - Windows-first Go project scaffold | |||||
| - validated JSON config schema | |||||
| - HTTP status/control surface with dry-run endpoint | |||||
| - deterministic dry-run output | |||||
| - offline composite/IQ-style file generation | |||||
| - simulated transmit path in main CLI via Soapy-oriented backend abstraction | |||||
| - automated no-hardware tests and smoke scripts | |||||
| ### Not yet included | |||||
| - real SoapySDR hardware driver integration | |||||
| - full RDS group framing/CRC/timing correctness | |||||
| - real audio ingestion pipeline | |||||
| - release CI / packaged distribution | |||||
| ## v0.4.0-pre | |||||
| [previous changelog entries] | |||||
| @@ -1,21 +1,31 @@ | |||||
| # Release Notes | # Release Notes | ||||
| ## Project status | |||||
| ## v0.5.0-pre — HW-integration readiness | |||||
| This repository is currently at a **pre-v1 no-hardware milestone** with a complete DSP chain. | |||||
| ### Go/No-Go status | |||||
| What is true today: | |||||
| - the repo builds and all tests pass | |||||
| - dry-run, offline generation, and simulated transmit paths run without hardware | |||||
| - full broadcast DSP chain: pre-emphasis, stereo encoding, RDS (IEC 62106), limiter, FM modulation | |||||
| - offline IQ output has verified unit-magnitude FM modulation | |||||
| - core architecture for config/control/output is in place and extensible | |||||
| **Gate: First real HW smoke test** — ARCHITECTURALLY READY | |||||
| - [x] Extended SoapyDriver interface (Start/Stop/Caps/Stats) | |||||
| - [x] Continuous TX engine with chunk-based generation | |||||
| - [x] Device rate separated from composite rate | |||||
| - [x] TX safety: default off, explicit start required | |||||
| - [x] Runtime telemetry available via /runtime | |||||
| - [ ] Actual CGO SoapySDR binding (next step) | |||||
| What is not yet true: | |||||
| - real SDR hardware transmission is not integrated | |||||
| - live audio ingest (streaming, network) is not implemented | |||||
| - this should not yet be presented as a final v1.0 release | |||||
| **Gate: Serious HW bring-up** — P1 ITEMS DONE | |||||
| - [x] Blackbox spectral checks (19/38/57 kHz) | |||||
| - [x] Operator truth tests for all risky config switches | |||||
| - [x] Fail-loud PI validation | |||||
| - [x] Level/injection ownership: unity sources, direct combiner gains | |||||
| ## Recommended tag | |||||
| ### What works | |||||
| - Complete DSP chain: pre-emphasis → stereo → RDS → MPX → limiter → FM mod | |||||
| - 79 passing tests including spectral verification | |||||
| - All signal sources unity-normalized, combiner controls final levels | |||||
| - Continuous TX engine with Start/Stop/Stats | |||||
| - Control plane with /tx/start, /tx/stop, /runtime | |||||
| - `v0.4.0-pre` | |||||
| ### What's next | |||||
| - CGO SoapySDR driver binding (the actual hardware call) | |||||
| - Live audio ingest (streaming/network sources) | |||||
| - End-to-end decode verification with external RDS decoder | |||||
| @@ -1,7 +1,7 @@ | |||||
| { | { | ||||
| "audio": { | "audio": { | ||||
| "inputPath": "", | "inputPath": "", | ||||
| "sampleRate": 48000, | |||||
| "inputSampleRate": 48000, | |||||
| "gain": 1.0, | "gain": 1.0, | ||||
| "toneLeftHz": 1000, | "toneLeftHz": 1000, | ||||
| "toneRightHz": 1600, | "toneRightHz": 1600, | ||||
| @@ -19,7 +19,7 @@ | |||||
| "stereoEnabled": true, | "stereoEnabled": true, | ||||
| "pilotLevel": 0.1, | "pilotLevel": 0.1, | ||||
| "rdsInjection": 0.05, | "rdsInjection": 0.05, | ||||
| "preEmphasisUS": 50, | |||||
| "preEmphasisTauUS": 50, | |||||
| "outputDrive": 0.5, | "outputDrive": 0.5, | ||||
| "compositeRateHz": 228000, | "compositeRateHz": 228000, | ||||
| "maxDeviationHz": 75000, | "maxDeviationHz": 75000, | ||||
| @@ -30,7 +30,8 @@ | |||||
| "backend": { | "backend": { | ||||
| "kind": "file", | "kind": "file", | ||||
| "device": "", | "device": "", | ||||
| "outputPath": "build/out/composite.f32" | |||||
| "outputPath": "build/out/composite.f32", | |||||
| "deviceSampleRateHz": 0 | |||||
| }, | }, | ||||
| "control": { | "control": { | ||||
| "listenAddress": "127.0.0.1:8088" | "listenAddress": "127.0.0.1:8088" | ||||
| @@ -4,6 +4,8 @@ import ( | |||||
| "encoding/json" | "encoding/json" | ||||
| "fmt" | "fmt" | ||||
| "os" | "os" | ||||
| "strconv" | |||||
| "strings" | |||||
| ) | ) | ||||
| type Config struct { | type Config struct { | ||||
| @@ -15,12 +17,12 @@ type Config struct { | |||||
| } | } | ||||
| type AudioConfig struct { | type AudioConfig struct { | ||||
| InputPath string `json:"inputPath"` | |||||
| SampleRate int `json:"sampleRate"` | |||||
| Gain float64 `json:"gain"` | |||||
| ToneLeftHz float64 `json:"toneLeftHz"` | |||||
| ToneRightHz float64 `json:"toneRightHz"` | |||||
| ToneAmplitude float64 `json:"toneAmplitude"` | |||||
| InputPath string `json:"inputPath"` | |||||
| InputSampleRate int `json:"inputSampleRate"` // sample rate for WAV/tone source | |||||
| Gain float64 `json:"gain"` | |||||
| ToneLeftHz float64 `json:"toneLeftHz"` | |||||
| ToneRightHz float64 `json:"toneRightHz"` | |||||
| ToneAmplitude float64 `json:"toneAmplitude"` | |||||
| } | } | ||||
| type RDSConfig struct { | type RDSConfig struct { | ||||
| @@ -32,23 +34,24 @@ type RDSConfig struct { | |||||
| } | } | ||||
| type FMConfig struct { | type FMConfig struct { | ||||
| FrequencyMHz float64 `json:"frequencyMHz"` | |||||
| StereoEnabled bool `json:"stereoEnabled"` | |||||
| PilotLevel float64 `json:"pilotLevel"` | |||||
| RDSInjection float64 `json:"rdsInjection"` | |||||
| PreEmphasisUS float64 `json:"preEmphasisUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off | |||||
| OutputDrive float64 `json:"outputDrive"` | |||||
| CompositeRateHz int `json:"compositeRateHz"` | |||||
| MaxDeviationHz float64 `json:"maxDeviationHz"` // FM deviation in Hz, default 75000 | |||||
| LimiterEnabled bool `json:"limiterEnabled"` | |||||
| LimiterCeiling float64 `json:"limiterCeiling"` // composite ceiling, default 1.0 | |||||
| FMModulationEnabled bool `json:"fmModulationEnabled"` // true = output FM IQ, false = raw composite | |||||
| FrequencyMHz float64 `json:"frequencyMHz"` | |||||
| StereoEnabled bool `json:"stereoEnabled"` | |||||
| PilotLevel float64 `json:"pilotLevel"` // linear injection level in composite (e.g. 0.1 = 10%) | |||||
| RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%) | |||||
| PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off | |||||
| OutputDrive float64 `json:"outputDrive"` | |||||
| CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate | |||||
| MaxDeviationHz float64 `json:"maxDeviationHz"` | |||||
| LimiterEnabled bool `json:"limiterEnabled"` | |||||
| LimiterCeiling float64 `json:"limiterCeiling"` | |||||
| FMModulationEnabled bool `json:"fmModulationEnabled"` | |||||
| } | } | ||||
| type BackendConfig struct { | type BackendConfig struct { | ||||
| Kind string `json:"kind"` | |||||
| Device string `json:"device"` | |||||
| OutputPath string `json:"outputPath"` | |||||
| Kind string `json:"kind"` | |||||
| Device string `json:"device"` | |||||
| OutputPath string `json:"outputPath"` | |||||
| DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz | |||||
| } | } | ||||
| type ControlConfig struct { | type ControlConfig struct { | ||||
| @@ -57,14 +60,14 @@ type ControlConfig struct { | |||||
| func Default() Config { | func Default() Config { | ||||
| return Config{ | return Config{ | ||||
| Audio: AudioConfig{SampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, | |||||
| Audio: AudioConfig{InputSampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4}, | |||||
| RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0}, | RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0}, | ||||
| FM: FMConfig{ | FM: FMConfig{ | ||||
| FrequencyMHz: 100.0, | FrequencyMHz: 100.0, | ||||
| StereoEnabled: true, | StereoEnabled: true, | ||||
| PilotLevel: 0.1, | PilotLevel: 0.1, | ||||
| RDSInjection: 0.05, | RDSInjection: 0.05, | ||||
| PreEmphasisUS: 50, // European default | |||||
| PreEmphasisTauUS: 50, | |||||
| OutputDrive: 0.5, | OutputDrive: 0.5, | ||||
| CompositeRateHz: 228000, | CompositeRateHz: 228000, | ||||
| MaxDeviationHz: 75000, | MaxDeviationHz: 75000, | ||||
| @@ -77,6 +80,21 @@ func Default() Config { | |||||
| } | } | ||||
| } | } | ||||
| // ParsePI parses a hex PI code string. Returns an error for invalid input. | |||||
| func ParsePI(pi string) (uint16, error) { | |||||
| trimmed := strings.TrimSpace(pi) | |||||
| if trimmed == "" { | |||||
| return 0, fmt.Errorf("rds.pi is required") | |||||
| } | |||||
| trimmed = strings.TrimPrefix(trimmed, "0x") | |||||
| trimmed = strings.TrimPrefix(trimmed, "0X") | |||||
| v, err := strconv.ParseUint(trimmed, 16, 16) | |||||
| if err != nil { | |||||
| return 0, fmt.Errorf("invalid rds.pi: %q", pi) | |||||
| } | |||||
| return uint16(v), nil | |||||
| } | |||||
| func Load(path string) (Config, error) { | func Load(path string) (Config, error) { | ||||
| cfg := Default() | cfg := Default() | ||||
| if path == "" { | if path == "" { | ||||
| @@ -93,8 +111,8 @@ func Load(path string) (Config, error) { | |||||
| } | } | ||||
| func (c Config) Validate() error { | func (c Config) Validate() error { | ||||
| if c.Audio.SampleRate < 8000 || c.Audio.SampleRate > 384000 { | |||||
| return fmt.Errorf("audio.sampleRate out of range") | |||||
| if c.Audio.InputSampleRate < 8000 || c.Audio.InputSampleRate > 384000 { | |||||
| return fmt.Errorf("audio.inputSampleRate out of range") | |||||
| } | } | ||||
| if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | ||||
| return fmt.Errorf("audio.gain out of range") | return fmt.Errorf("audio.gain out of range") | ||||
| @@ -120,8 +138,8 @@ func (c Config) Validate() error { | |||||
| if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { | if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { | ||||
| return fmt.Errorf("fm.compositeRateHz out of range") | return fmt.Errorf("fm.compositeRateHz out of range") | ||||
| } | } | ||||
| if c.FM.PreEmphasisUS < 0 || c.FM.PreEmphasisUS > 100 { | |||||
| return fmt.Errorf("fm.preEmphasisUS out of range (0=off, 50=EU, 75=US)") | |||||
| if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 { | |||||
| return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)") | |||||
| } | } | ||||
| if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { | if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { | ||||
| return fmt.Errorf("fm.maxDeviationHz out of range") | return fmt.Errorf("fm.maxDeviationHz out of range") | ||||
| @@ -132,8 +150,28 @@ func (c Config) Validate() error { | |||||
| if c.Backend.Kind == "" { | if c.Backend.Kind == "" { | ||||
| return fmt.Errorf("backend.kind is required") | return fmt.Errorf("backend.kind is required") | ||||
| } | } | ||||
| if c.Backend.DeviceSampleRateHz < 0 { | |||||
| return fmt.Errorf("backend.deviceSampleRateHz must be >= 0") | |||||
| } | |||||
| if c.Control.ListenAddress == "" { | if c.Control.ListenAddress == "" { | ||||
| return fmt.Errorf("control.listenAddress is required") | return fmt.Errorf("control.listenAddress is required") | ||||
| } | } | ||||
| // Fail-loud PI validation | |||||
| if c.RDS.Enabled { | |||||
| if _, err := ParsePI(c.RDS.PI); err != nil { | |||||
| return fmt.Errorf("rds config: %w", err) | |||||
| } | |||||
| } | |||||
| if c.RDS.PTY < 0 || c.RDS.PTY > 31 { | |||||
| return fmt.Errorf("rds.pty out of range (0-31)") | |||||
| } | |||||
| return nil | return nil | ||||
| } | } | ||||
| // EffectiveDeviceRate returns the device sample rate, falling back to composite rate. | |||||
| func (c Config) EffectiveDeviceRate() float64 { | |||||
| if c.Backend.DeviceSampleRateHz > 0 { | |||||
| return c.Backend.DeviceSampleRateHz | |||||
| } | |||||
| return float64(c.FM.CompositeRateHz) | |||||
| } | |||||
| @@ -7,56 +7,68 @@ import ( | |||||
| ) | ) | ||||
| func TestDefaultValidate(t *testing.T) { | func TestDefaultValidate(t *testing.T) { | ||||
| cfg := Default() | |||||
| if err := cfg.Validate(); err != nil { | |||||
| t.Fatalf("default config invalid: %v", err) | |||||
| } | |||||
| if err := Default().Validate(); err != nil { t.Fatalf("default invalid: %v", err) } | |||||
| } | } | ||||
| func TestLoadAndValidate(t *testing.T) { | func TestLoadAndValidate(t *testing.T) { | ||||
| dir := t.TempDir() | dir := t.TempDir() | ||||
| path := filepath.Join(dir, "config.json") | path := filepath.Join(dir, "config.json") | ||||
| if err := os.WriteFile(path, []byte(`{"audio":{"toneLeftHz":900,"toneRightHz":1700,"toneAmplitude":0.3},"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil { | |||||
| t.Fatalf("write config: %v", err) | |||||
| } | |||||
| os.WriteFile(path, []byte(`{"audio":{"toneLeftHz":900,"toneRightHz":1700,"toneAmplitude":0.3},"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644) | |||||
| cfg, err := Load(path) | cfg, err := Load(path) | ||||
| if err != nil { | |||||
| t.Fatalf("load config: %v", err) | |||||
| } | |||||
| if cfg.Audio.ToneLeftHz != 900 { | |||||
| t.Fatalf("unexpected left tone: %v", cfg.Audio.ToneLeftHz) | |||||
| } | |||||
| if err != nil { t.Fatalf("load: %v", err) } | |||||
| if cfg.Audio.ToneLeftHz != 900 { t.Fatalf("unexpected left tone: %v", cfg.Audio.ToneLeftHz) } | |||||
| } | } | ||||
| func TestValidateRejectsBadFrequency(t *testing.T) { | func TestValidateRejectsBadFrequency(t *testing.T) { | ||||
| cfg := Default() | |||||
| cfg.FM.FrequencyMHz = 200 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected validation error") | |||||
| } | |||||
| cfg := Default(); cfg.FM.FrequencyMHz = 200 | |||||
| if err := cfg.Validate(); err == nil { t.Fatal("expected error") } | |||||
| } | } | ||||
| func TestValidateRejectsBadPreEmphasis(t *testing.T) { | func TestValidateRejectsBadPreEmphasis(t *testing.T) { | ||||
| cfg := Default() | |||||
| cfg.FM.PreEmphasisUS = 150 | |||||
| if err := cfg.Validate(); err == nil { | |||||
| t.Fatal("expected validation error for preEmphasisUS > 100") | |||||
| } | |||||
| cfg := Default(); cfg.FM.PreEmphasisTauUS = 150 | |||||
| if err := cfg.Validate(); err == nil { t.Fatal("expected error") } | |||||
| } | } | ||||
| func TestDefaultPreEmphasis(t *testing.T) { | func TestDefaultPreEmphasis(t *testing.T) { | ||||
| cfg := Default() | |||||
| if cfg.FM.PreEmphasisUS != 50 { | |||||
| t.Fatalf("expected default preEmphasisUS=50, got %.0f", cfg.FM.PreEmphasisUS) | |||||
| } | |||||
| if Default().FM.PreEmphasisTauUS != 50 { t.Fatal("expected 50") } | |||||
| } | } | ||||
| func TestDefaultFMModulation(t *testing.T) { | func TestDefaultFMModulation(t *testing.T) { | ||||
| cfg := Default() | cfg := Default() | ||||
| if !cfg.FM.FMModulationEnabled { | |||||
| t.Fatal("expected FM modulation enabled by default") | |||||
| if !cfg.FM.FMModulationEnabled { t.Fatal("expected true") } | |||||
| if cfg.FM.MaxDeviationHz != 75000 { t.Fatal("expected 75000") } | |||||
| } | |||||
| func TestParsePI(t *testing.T) { | |||||
| tests := []struct{ in string; want uint16; ok bool }{ | |||||
| {"1234", 0x1234, true}, | |||||
| {"0xBEEF", 0xBEEF, true}, | |||||
| {"0XCAFE", 0xCAFE, true}, | |||||
| {" 0x2345 ", 0x2345, true}, | |||||
| {"", 0, false}, | |||||
| {"nope", 0, false}, | |||||
| } | } | ||||
| if cfg.FM.MaxDeviationHz != 75000 { | |||||
| t.Fatalf("expected default maxDeviationHz=75000, got %.0f", cfg.FM.MaxDeviationHz) | |||||
| for _, tt := range tests { | |||||
| got, err := ParsePI(tt.in) | |||||
| if tt.ok && err != nil { t.Fatalf("ParsePI(%q): %v", tt.in, err) } | |||||
| if !tt.ok && err == nil { t.Fatalf("ParsePI(%q): expected error", tt.in) } | |||||
| if tt.ok && got != tt.want { t.Fatalf("ParsePI(%q): got %x want %x", tt.in, got, tt.want) } | |||||
| } | } | ||||
| } | } | ||||
| func TestValidateRejectsInvalidPI(t *testing.T) { | |||||
| cfg := Default(); cfg.RDS.PI = "nope" | |||||
| if err := cfg.Validate(); err == nil { t.Fatal("expected error for invalid PI") } | |||||
| } | |||||
| func TestValidateRejectsEmptyPI(t *testing.T) { | |||||
| cfg := Default(); cfg.RDS.PI = "" | |||||
| if err := cfg.Validate(); err == nil { t.Fatal("expected error for empty PI") } | |||||
| } | |||||
| func TestEffectiveDeviceRate(t *testing.T) { | |||||
| cfg := Default() | |||||
| if cfg.EffectiveDeviceRate() != float64(cfg.FM.CompositeRateHz) { t.Fatal("expected composite rate when device rate is 0") } | |||||
| cfg.Backend.DeviceSampleRateHz = 912000 | |||||
| if cfg.EffectiveDeviceRate() != 912000 { t.Fatal("expected 912000") } | |||||
| } | |||||
| @@ -14,12 +14,13 @@ type FrameSummary struct { | |||||
| FrequencyMHz float64 `json:"frequencyMHz"` | FrequencyMHz float64 `json:"frequencyMHz"` | ||||
| StereoEnabled bool `json:"stereoEnabled"` | StereoEnabled bool `json:"stereoEnabled"` | ||||
| RDSEnabled bool `json:"rdsEnabled"` | RDSEnabled bool `json:"rdsEnabled"` | ||||
| SampleRateHz int `json:"sampleRateHz"` | |||||
| InputSampleRateHz int `json:"inputSampleRateHz"` | |||||
| CompositeRate int `json:"compositeRateHz"` | CompositeRate int `json:"compositeRateHz"` | ||||
| DeviceRate float64 `json:"deviceSampleRateHz"` | |||||
| PilotLevel float64 `json:"pilotLevel"` | PilotLevel float64 `json:"pilotLevel"` | ||||
| RDSInjection float64 `json:"rdsInjection"` | RDSInjection float64 `json:"rdsInjection"` | ||||
| OutputDrive float64 `json:"outputDrive"` | OutputDrive float64 `json:"outputDrive"` | ||||
| PreEmphasisUS float64 `json:"preEmphasisUS"` | |||||
| PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` | |||||
| MaxDeviationHz float64 `json:"maxDeviationHz"` | MaxDeviationHz float64 `json:"maxDeviationHz"` | ||||
| LimiterEnabled bool `json:"limiterEnabled"` | LimiterEnabled bool `json:"limiterEnabled"` | ||||
| FMModulation bool `json:"fmModulationEnabled"` | FMModulation bool `json:"fmModulationEnabled"` | ||||
| @@ -42,21 +43,22 @@ func Generate(cfg cfgpkg.Config) FrameSummary { | |||||
| source = cfg.Audio.InputPath | source = cfg.Audio.InputPath | ||||
| } | } | ||||
| return FrameSummary{ | return FrameSummary{ | ||||
| Mode: "dry-run", | |||||
| FrequencyMHz: cfg.FM.FrequencyMHz, | |||||
| StereoEnabled: cfg.FM.StereoEnabled, | |||||
| RDSEnabled: cfg.RDS.Enabled, | |||||
| SampleRateHz: cfg.Audio.SampleRate, | |||||
| CompositeRate: cfg.FM.CompositeRateHz, | |||||
| PilotLevel: cfg.FM.PilotLevel, | |||||
| RDSInjection: cfg.FM.RDSInjection, | |||||
| OutputDrive: cfg.FM.OutputDrive, | |||||
| PreEmphasisUS: cfg.FM.PreEmphasisUS, | |||||
| MaxDeviationHz: cfg.FM.MaxDeviationHz, | |||||
| LimiterEnabled: cfg.FM.LimiterEnabled, | |||||
| FMModulation: cfg.FM.FMModulationEnabled, | |||||
| Source: source, | |||||
| PreviewSamples: preview, | |||||
| Mode: "dry-run", | |||||
| FrequencyMHz: cfg.FM.FrequencyMHz, | |||||
| StereoEnabled: cfg.FM.StereoEnabled, | |||||
| RDSEnabled: cfg.RDS.Enabled, | |||||
| InputSampleRateHz: cfg.Audio.InputSampleRate, | |||||
| CompositeRate: cfg.FM.CompositeRateHz, | |||||
| DeviceRate: cfg.EffectiveDeviceRate(), | |||||
| PilotLevel: cfg.FM.PilotLevel, | |||||
| RDSInjection: cfg.FM.RDSInjection, | |||||
| OutputDrive: cfg.FM.OutputDrive, | |||||
| PreEmphasisTauUS: cfg.FM.PreEmphasisTauUS, | |||||
| MaxDeviationHz: cfg.FM.MaxDeviationHz, | |||||
| LimiterEnabled: cfg.FM.LimiterEnabled, | |||||
| FMModulation: cfg.FM.FMModulationEnabled, | |||||
| Source: source, | |||||
| PreviewSamples: preview, | |||||
| } | } | ||||
| } | } | ||||
| @@ -72,8 +74,5 @@ func WriteJSON(path string, frame FrameSummary) error { | |||||
| if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | ||||
| return fmt.Errorf("create output dir: %w", err) | return fmt.Errorf("create output dir: %w", err) | ||||
| } | } | ||||
| if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { | |||||
| return fmt.Errorf("write dry-run frame: %w", err) | |||||
| } | |||||
| return nil | |||||
| return os.WriteFile(path, append(data, '\n'), 0o644) | |||||
| } | } | ||||
| @@ -11,39 +11,17 @@ import ( | |||||
| func TestGenerate(t *testing.T) { | func TestGenerate(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| frame := Generate(cfg) | frame := Generate(cfg) | ||||
| if frame.Mode != "dry-run" { | |||||
| t.Fatalf("unexpected mode: %s", frame.Mode) | |||||
| } | |||||
| if len(frame.PreviewSamples) != 16 { | |||||
| t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples)) | |||||
| } | |||||
| if frame.Source != "tones" { | |||||
| t.Fatalf("unexpected source: %s", frame.Source) | |||||
| } | |||||
| if frame.PreEmphasisUS != 50 { | |||||
| t.Fatalf("unexpected preEmphasisUS: %.0f", frame.PreEmphasisUS) | |||||
| } | |||||
| if !frame.FMModulation { | |||||
| t.Fatal("expected fmModulationEnabled=true") | |||||
| } | |||||
| } | |||||
| func TestGenerateUsesInputPathAsSource(t *testing.T) { | |||||
| cfg := cfgpkg.Default() | |||||
| cfg.Audio.InputPath = "demo.wav" | |||||
| frame := Generate(cfg) | |||||
| if frame.Source != "demo.wav" { | |||||
| t.Fatalf("unexpected source: %s", frame.Source) | |||||
| } | |||||
| if frame.Mode != "dry-run" { t.Fatalf("unexpected mode: %s", frame.Mode) } | |||||
| if len(frame.PreviewSamples) != 16 { t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples)) } | |||||
| if frame.Source != "tones" { t.Fatalf("unexpected source: %s", frame.Source) } | |||||
| if frame.PreEmphasisTauUS != 50 { t.Fatalf("unexpected preEmphasisTauUS: %.0f", frame.PreEmphasisTauUS) } | |||||
| if !frame.FMModulation { t.Fatal("expected fmModulationEnabled=true") } | |||||
| if frame.DeviceRate != float64(cfg.FM.CompositeRateHz) { t.Fatalf("expected deviceRate=%d, got %.0f", cfg.FM.CompositeRateHz, frame.DeviceRate) } | |||||
| } | } | ||||
| func TestWriteJSONFile(t *testing.T) { | func TestWriteJSONFile(t *testing.T) { | ||||
| dir := t.TempDir() | dir := t.TempDir() | ||||
| out := filepath.Join(dir, "frame.json") | out := filepath.Join(dir, "frame.json") | ||||
| if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil { | |||||
| t.Fatalf("WriteJSON failed: %v", err) | |||||
| } | |||||
| if _, err := os.Stat(out); err != nil { | |||||
| t.Fatalf("expected output file: %v", err) | |||||
| } | |||||
| if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil { t.Fatalf("WriteJSON: %v", err) } | |||||
| if _, err := os.Stat(out); err != nil { t.Fatalf("expected output: %v", err) } | |||||
| } | } | ||||