| @@ -1,47 +1,55 @@ | |||
| # 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 | |||
| ## 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": { | |||
| "inputPath": "", | |||
| "sampleRate": 48000, | |||
| "inputSampleRate": 48000, | |||
| "gain": 1.0, | |||
| "toneLeftHz": 1000, | |||
| "toneRightHz": 1600, | |||
| @@ -19,7 +19,7 @@ | |||
| "stereoEnabled": true, | |||
| "pilotLevel": 0.1, | |||
| "rdsInjection": 0.05, | |||
| "preEmphasisUS": 50, | |||
| "preEmphasisTauUS": 50, | |||
| "outputDrive": 0.5, | |||
| "compositeRateHz": 228000, | |||
| "maxDeviationHz": 75000, | |||
| @@ -30,7 +30,8 @@ | |||
| "backend": { | |||
| "kind": "file", | |||
| "device": "", | |||
| "outputPath": "build/out/composite.f32" | |||
| "outputPath": "build/out/composite.f32", | |||
| "deviceSampleRateHz": 0 | |||
| }, | |||
| "control": { | |||
| "listenAddress": "127.0.0.1:8088" | |||
| @@ -4,6 +4,8 @@ import ( | |||
| "encoding/json" | |||
| "fmt" | |||
| "os" | |||
| "strconv" | |||
| "strings" | |||
| ) | |||
| type Config struct { | |||
| @@ -15,12 +17,12 @@ type Config 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 { | |||
| @@ -32,23 +34,24 @@ type RDSConfig 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 { | |||
| 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 { | |||
| @@ -57,14 +60,14 @@ type ControlConfig struct { | |||
| func Default() 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}, | |||
| FM: FMConfig{ | |||
| FrequencyMHz: 100.0, | |||
| StereoEnabled: true, | |||
| PilotLevel: 0.1, | |||
| RDSInjection: 0.05, | |||
| PreEmphasisUS: 50, // European default | |||
| PreEmphasisTauUS: 50, | |||
| OutputDrive: 0.5, | |||
| CompositeRateHz: 228000, | |||
| 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) { | |||
| cfg := Default() | |||
| if path == "" { | |||
| @@ -93,8 +111,8 @@ func Load(path string) (Config, 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 { | |||
| 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 { | |||
| 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 { | |||
| return fmt.Errorf("fm.maxDeviationHz out of range") | |||
| @@ -132,8 +150,28 @@ func (c Config) Validate() error { | |||
| if c.Backend.Kind == "" { | |||
| return fmt.Errorf("backend.kind is required") | |||
| } | |||
| if c.Backend.DeviceSampleRateHz < 0 { | |||
| return fmt.Errorf("backend.deviceSampleRateHz must be >= 0") | |||
| } | |||
| if c.Control.ListenAddress == "" { | |||
| 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 | |||
| } | |||
| // 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) { | |||
| 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) { | |||
| dir := t.TempDir() | |||
| 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) | |||
| 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) { | |||
| 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) { | |||
| 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) { | |||
| 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) { | |||
| 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"` | |||
| StereoEnabled bool `json:"stereoEnabled"` | |||
| RDSEnabled bool `json:"rdsEnabled"` | |||
| SampleRateHz int `json:"sampleRateHz"` | |||
| InputSampleRateHz int `json:"inputSampleRateHz"` | |||
| CompositeRate int `json:"compositeRateHz"` | |||
| DeviceRate float64 `json:"deviceSampleRateHz"` | |||
| PilotLevel float64 `json:"pilotLevel"` | |||
| RDSInjection float64 `json:"rdsInjection"` | |||
| OutputDrive float64 `json:"outputDrive"` | |||
| PreEmphasisUS float64 `json:"preEmphasisUS"` | |||
| PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` | |||
| MaxDeviationHz float64 `json:"maxDeviationHz"` | |||
| LimiterEnabled bool `json:"limiterEnabled"` | |||
| FMModulation bool `json:"fmModulationEnabled"` | |||
| @@ -42,21 +43,22 @@ func Generate(cfg cfgpkg.Config) FrameSummary { | |||
| source = cfg.Audio.InputPath | |||
| } | |||
| 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 { | |||
| 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) { | |||
| cfg := cfgpkg.Default() | |||
| 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) { | |||
| dir := t.TempDir() | |||
| 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) } | |||
| } | |||