From 8a8805ce0f13f70d7bd441f425c59131884a97a6 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 10:06:39 +0200 Subject: [PATCH] feat: tighten config semantics for HW readiness and release prep --- CHANGELOG.md | 96 ++++++++++++++++++---------------- RELEASE.md | 38 +++++++++----- docs/config.sample.json | 7 +-- internal/config/config.go | 90 ++++++++++++++++++++++--------- internal/config/config_test.go | 74 +++++++++++++++----------- internal/dryrun/dryrun.go | 41 +++++++-------- internal/dryrun/dryrun_test.go | 38 +++----------- 7 files changed, 215 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f34c1c..6dd5ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/RELEASE.md b/RELEASE.md index 4984f65..d0bea96 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/docs/config.sample.json b/docs/config.sample.json index cb07a2b..19da1fa 100644 --- a/docs/config.sample.json +++ b/docs/config.sample.json @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go index 49bbc47..e0f1860 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b439f2b..871f710 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") } +} diff --git a/internal/dryrun/dryrun.go b/internal/dryrun/dryrun.go index add35d1..094322f 100644 --- a/internal/dryrun/dryrun.go +++ b/internal/dryrun/dryrun.go @@ -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) } diff --git a/internal/dryrun/dryrun_test.go b/internal/dryrun/dryrun_test.go index b37e84c..92b2176 100644 --- a/internal/dryrun/dryrun_test.go +++ b/internal/dryrun/dryrun_test.go @@ -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) } }