From 6558d7bc09586aff853cd1ae85c1c9bd938c2db7 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 09:13:36 +0200 Subject: [PATCH] feat: expose new DSP controls across config, control API, dry-run and docs --- CHANGELOG.md | 29 ++++- README.md | 41 ++++--- RELEASE.md | 12 +- cmd/fmrtx/main.go | 113 +++++++++--------- docs/README.md | 52 +++++++-- docs/config.sample.json | 10 +- internal/config/config.go | 191 +++++++++++++++++-------------- internal/config/config_test.go | 73 ++++++++---- internal/control/control.go | 191 +++++++++++++++++-------------- internal/control/control_test.go | 113 +++++++++--------- internal/dryrun/dryrun.go | 122 +++++++++++--------- internal/dryrun/dryrun_test.go | 64 ++++++----- 12 files changed, 587 insertions(+), 424 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37de23b..7f34c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,35 @@ # Changelog +## v0.4.0-pre + +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 -Current pre-v1 milestone for the licensed short-term FM/RDS transmitter project. +Initial pre-v1 milestone for the licensed short-term FM/RDS transmitter project. ### Included - Windows-first Go project scaffold diff --git a/README.md b/README.md index 9739339..12f19d9 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,25 @@ This repository is currently at a **pre-v1, no-hardware-tested milestone**. What works today: - JSON configuration loading and validation -- small HTTP control/status surface +- small HTTP control/status surface with runtime config patching - dry-run generation for no-hardware inspection -- offline IQ/composite-style file generation +- offline IQ/composite file generation with full DSP chain - simulated transmit path through a Soapy-oriented backend abstraction - automated no-hardware tests and smoke checks +DSP chain (fully implemented): +- stereo encoding with phase-coherent 19 kHz pilot and 38 kHz DSB-SC subcarrier +- RDS encoding with standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK +- pre-emphasis filter (50 µs EU / 75 µs US / configurable) +- MPX limiter with smooth attack/release and hard-clip safety net +- FM modulator producing baseband IQ output (±75 kHz deviation, unit magnitude) +- linear-interpolation audio resampler +- robust WAV loader with RIFF chunk scanning + What does **not** work yet: - real SDR hardware transmission -- full RDS group framing / CRC / standards-grade correctness -- real live audio ingest pipeline -- production-ready broadcast chain processing +- real live audio ingest pipeline (streaming/network) +- production-ready broadcast chain processing (multiband, look-ahead limiting) ## Project goal @@ -57,7 +65,7 @@ go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms ``` -### Offline generator +### Offline generator (full DSP chain) ```powershell go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 @@ -77,17 +85,17 @@ cmd/ offline/ offline no-hardware generator internal/ app/ simulated transmit path wiring - audio/ sample/frame helpers + audio/ sample/frame helpers, WAV loader, resampler config/ config schema + validation control/ HTTP control/status handlers dryrun/ JSON no-hardware summaries - dsp/ DSP helpers + dsp/ oscillator, pre-emphasis, FM modulator, limiter mpx/ MPX combiner primitives - offline/ deterministic offline composite generation + offline/ deterministic offline composite generation (full DSP chain) output/ backend abstractions + file/dummy sinks platform/ Soapy-oriented backend abstraction - rds/ basic RDS encoder scaffolding - stereo/ stereo encoder primitives + rds/ RDS encoder (IEC 62106 group framing, CRC, diff encoding) + stereo/ stereo encoder (pilot + 38 kHz subcarrier) examples/ soapy_simulated/ docs/ @@ -97,7 +105,7 @@ scripts/ ## Current release posture Recommended current milestone tag: -- `v0.3.0-pre` +- `v0.4.0-pre` See also: - `docs/README.md` @@ -107,8 +115,7 @@ See also: ## Next priorities -1. real audio ingest path -2. stronger RDS implementation -3. tighter end-to-end regression coverage -4. real SoapySDR backend integration -5. first true v1.0 criteria review after those pieces exist +1. real audio ingest path (live PCM, network audio) +2. real SoapySDR backend integration +3. tighter end-to-end regression coverage (decode verification) +4. first true v1.0 criteria review after those pieces exist diff --git a/RELEASE.md b/RELEASE.md index 3f07d80..4984f65 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,18 +2,20 @@ ## Project status -This repository is currently at a **pre-v1 no-hardware milestone**. +This repository is currently at a **pre-v1 no-hardware milestone** with a complete DSP chain. What is true today: -- the repo builds -- automated tests pass +- the repo builds and all tests pass - dry-run, offline generation, and simulated transmit paths run without hardware -- core architecture for config/control/output is in place +- 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 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 ## Recommended tag -- `v0.3.0-pre` +- `v0.4.0-pre` diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 52419a9..08d699b 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -1,60 +1,67 @@ package main import ( - "flag" - "fmt" - "log" - "net/http" - "os" - "time" - - apppkg "github.com/jan/fm-rds-tx/internal/app" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" - ctrlpkg "github.com/jan/fm-rds-tx/internal/control" - drypkg "github.com/jan/fm-rds-tx/internal/dryrun" + "flag" + "fmt" + "log" + "net/http" + "os" + "time" + + apppkg "github.com/jan/fm-rds-tx/internal/app" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" + ctrlpkg "github.com/jan/fm-rds-tx/internal/control" + drypkg "github.com/jan/fm-rds-tx/internal/dryrun" ) func main() { - configPath := flag.String("config", "", "path to JSON config") - printConfig := flag.Bool("print-config", false, "print effective config and exit") - dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output") - dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout") - simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") - simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") - simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") - flag.Parse() - - cfg, err := cfgpkg.Load(*configPath) - if err != nil { - log.Fatalf("load config: %v", err) - } - - if *printConfig { - fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t listen=%s\n", cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, cfg.Control.ListenAddress) - return - } - - if *dryRun { - frame := drypkg.Generate(cfg) - if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { - log.Fatalf("dry-run failed: %v", err) - } - if *dryOutput != "" && *dryOutput != "-" { - fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) - } - return - } - - if *simulate { - summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) - if err != nil { - log.Fatalf("simulate-tx failed: %v", err) - } - fmt.Println(summary) - return - } - - srv := ctrlpkg.NewServer(cfg) - log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) - log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) + configPath := flag.String("config", "", "path to JSON config") + printConfig := flag.Bool("print-config", false, "print effective config and exit") + dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output") + dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout") + simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path") + simulateOutput := flag.String("simulate-output", "", "simulated transmit output file") + simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") + flag.Parse() + + cfg, err := cfgpkg.Load(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + if *printConfig { + preemph := "off" + if cfg.FM.PreEmphasisUS > 0 { + preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisUS) + } + fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz listen=%s\n", + cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, + preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz, + cfg.Control.ListenAddress) + return + } + + if *dryRun { + frame := drypkg.Generate(cfg) + if err := drypkg.WriteJSON(*dryOutput, frame); err != nil { + log.Fatalf("dry-run failed: %v", err) + } + if *dryOutput != "" && *dryOutput != "-" { + fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput) + } + return + } + + if *simulate { + summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration) + if err != nil { + log.Fatalf("simulate-tx failed: %v", err) + } + fmt.Println(summary) + return + } + + srv := ctrlpkg.NewServer(cfg) + log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress) + log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) } diff --git a/docs/README.md b/docs/README.md index b286f9d..2032ba9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,8 +14,8 @@ Current no-hardware sources: - generated stereo tones via config -- 16-bit PCM WAV file input via `audio.inputPath` -- basic sample-rate adaptation for WAV sources into the composite generation path +- 16-bit PCM WAV file input via `audio.inputPath` (robust chunk-scanning loader) +- linear-interpolation sample-rate conversion for WAV sources - transparent tone fallback if the configured WAV source cannot be loaded ### Tone configuration @@ -25,6 +25,40 @@ The current no-hardware source can be parameterized via config: - `audio.toneRightHz` - `audio.toneAmplitude` +### DSP chain + +The full signal chain from audio input to IQ output: + +1. **Audio ingest** — tone generator or WAV file with linear-interpolation resampler +2. **Gain staging** — configurable audio gain +3. **Pre-emphasis** — first-order IIR high-shelf filter, configurable τ (50 µs EU / 75 µs US / 0 = off) +4. **Stereo encoder** — L+R mono, L-R on stateful 38 kHz DSB-SC subcarrier, phase-coherent 19 kHz pilot +5. **RDS encoder** — standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK subcarrier, group scheduler cycling PS and RadioText +6. **MPX combiner** — configurable gains for mono, stereo, pilot, and RDS components +7. **Output drive** — configurable output level scaling +8. **MPX limiter** — smooth attack/release peak limiter with hard-clip safety net +9. **FM modulator** — composite-to-IQ via phase integration, configurable ±75 kHz deviation, unit-magnitude IQ output + +### Pre-emphasis + +FM broadcast requires pre-emphasis to boost high frequencies before transmission. The receiver applies complementary de-emphasis to restore flat response while reducing noise. + +- Europe/World: τ = 50 µs (`preEmphasisUS: 50`) +- North America/South Korea: τ = 75 µs (`preEmphasisUS: 75`) +- Disabled: `preEmphasisUS: 0` + +### FM modulation modes + +- `fmModulationEnabled: true` — output is baseband FM-modulated IQ (I² + Q² = 1). This is what SDR transmitters expect. +- `fmModulationEnabled: false` — output is raw composite MPX (I = composite, Q = 0). Useful for analysis or composite exciters. + +### Limiter + +The MPX limiter prevents overmodulation by applying smooth gain reduction when the composite signal exceeds the configured ceiling. A hard clipper acts as a safety net after the limiter. + +- `limiterEnabled: true/false` +- `limiterCeiling: 1.0` (max composite level before FM modulation) + ### HTTP control surface Available endpoints: @@ -42,6 +76,9 @@ Current patchable runtime fields via `POST /config`: - `toneAmplitude` - `ps` - `radioText` +- `preEmphasisUS` +- `limiterEnabled` +- `limiterCeiling` ### Internal DSP module - `cd internal` @@ -55,23 +92,20 @@ Current patchable runtime fields via `POST /config`: ## Dry run The dry-run mode generates a synthetic, hardware-free frame summary based on the current config. -It now reports the active source label as well, so dry-run output is less disconnected from the offline/sim paths. +It reports the active source label, pre-emphasis setting, limiter state, and FM modulation mode. -The HTTP control plane also exposes `GET /dry-run` for quick inspection of the currently effective no-hardware summary. +The HTTP control plane also exposes `GET /dry-run` for quick inspection. ## Simulated transmit `--simulate-tx` runs the offline generator through the Soapy-oriented simulated backend path and writes an IQ-style artifact to disk. -The current summary includes backend, input source, frequency, and configured output sample rate. ## Offline generation -`cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path. -This is still an MVP path, but it is a more realistic offline artifact than the JSON-only dry-run. -The generator summary now reports whether the active source is tones, wav, or tone-fallback. +`cmd/offline` generates a deterministic no-hardware IQ/composite file using the full DSP chain (pre-emphasis, stereo encoding, RDS, limiter, FM modulation). ## Release posture Current honest release posture: **pre-v1**. -Recommended milestone tag: `v0.3.0-pre`. +Recommended milestone tag: `v0.4.0-pre`. See `CHANGELOG.md` and `RELEASE.md`. diff --git a/docs/config.sample.json b/docs/config.sample.json index 23ecaf4..cb07a2b 100644 --- a/docs/config.sample.json +++ b/docs/config.sample.json @@ -18,10 +18,14 @@ "frequencyMHz": 100.0, "stereoEnabled": true, "pilotLevel": 0.1, - "rdsInjection": 0.03, - "preEmphasisUS": false, + "rdsInjection": 0.05, + "preEmphasisUS": 50, "outputDrive": 0.5, - "compositeRateHz": 228000 + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "fmModulationEnabled": true }, "backend": { "kind": "file", diff --git a/internal/config/config.go b/internal/config/config.go index a300a12..49bbc47 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,114 +1,139 @@ package config import ( - "encoding/json" - "fmt" - "os" + "encoding/json" + "fmt" + "os" ) type Config struct { - Audio AudioConfig `json:"audio"` - RDS RDSConfig `json:"rds"` - FM FMConfig `json:"fm"` - Backend BackendConfig `json:"backend"` - Control ControlConfig `json:"control"` + Audio AudioConfig `json:"audio"` + RDS RDSConfig `json:"rds"` + FM FMConfig `json:"fm"` + Backend BackendConfig `json:"backend"` + Control ControlConfig `json:"control"` } 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"` + SampleRate int `json:"sampleRate"` + Gain float64 `json:"gain"` + ToneLeftHz float64 `json:"toneLeftHz"` + ToneRightHz float64 `json:"toneRightHz"` + ToneAmplitude float64 `json:"toneAmplitude"` } type RDSConfig struct { - Enabled bool `json:"enabled"` - PI string `json:"pi"` - PS string `json:"ps"` - RadioText string `json:"radioText"` - PTY int `json:"pty"` + Enabled bool `json:"enabled"` + PI string `json:"pi"` + PS string `json:"ps"` + RadioText string `json:"radioText"` + PTY int `json:"pty"` } type FMConfig struct { - FrequencyMHz float64 `json:"frequencyMHz"` - StereoEnabled bool `json:"stereoEnabled"` - PilotLevel float64 `json:"pilotLevel"` - RDSInjection float64 `json:"rdsInjection"` - PreEmphasisUS bool `json:"preEmphasisUS"` - OutputDrive float64 `json:"outputDrive"` - CompositeRateHz int `json:"compositeRateHz"` + 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 } 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"` } type ControlConfig struct { - ListenAddress string `json:"listenAddress"` + ListenAddress string `json:"listenAddress"` } func Default() Config { - return Config{ - Audio: AudioConfig{SampleRate: 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.03, OutputDrive: 0.5, CompositeRateHz: 228000}, - Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, - Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, - } + return Config{ + Audio: AudioConfig{SampleRate: 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 + OutputDrive: 0.5, + CompositeRateHz: 228000, + MaxDeviationHz: 75000, + LimiterEnabled: true, + LimiterCeiling: 1.0, + FMModulationEnabled: true, + }, + Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, + Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, + } } func Load(path string) (Config, error) { - cfg := Default() - if path == "" { - return cfg, cfg.Validate() - } - data, err := os.ReadFile(path) - if err != nil { - return Config{}, err - } - if err := json.Unmarshal(data, &cfg); err != nil { - return Config{}, err - } - return cfg, cfg.Validate() + cfg := Default() + if path == "" { + return cfg, cfg.Validate() + } + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + return cfg, cfg.Validate() } 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.Gain < 0 || c.Audio.Gain > 4 { - return fmt.Errorf("audio.gain out of range") - } - if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 { - return fmt.Errorf("audio tone frequencies must be positive") - } - if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 { - return fmt.Errorf("audio.toneAmplitude out of range") - } - if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 { - return fmt.Errorf("fm.frequencyMHz out of range") - } - if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 { - return fmt.Errorf("fm.pilotLevel out of range") - } - if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.1 { - return fmt.Errorf("fm.rdsInjection out of range") - } - if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 { - return fmt.Errorf("fm.outputDrive out of range") - } - if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { - return fmt.Errorf("fm.compositeRateHz out of range") - } - if c.Backend.Kind == "" { - return fmt.Errorf("backend.kind is required") - } - if c.Control.ListenAddress == "" { - return fmt.Errorf("control.listenAddress is required") - } - return nil + if c.Audio.SampleRate < 8000 || c.Audio.SampleRate > 384000 { + return fmt.Errorf("audio.sampleRate out of range") + } + if c.Audio.Gain < 0 || c.Audio.Gain > 4 { + return fmt.Errorf("audio.gain out of range") + } + if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 { + return fmt.Errorf("audio tone frequencies must be positive") + } + if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 { + return fmt.Errorf("audio.toneAmplitude out of range") + } + if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 { + return fmt.Errorf("fm.frequencyMHz out of range") + } + if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 { + return fmt.Errorf("fm.pilotLevel out of range") + } + if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 { + return fmt.Errorf("fm.rdsInjection out of range") + } + if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 { + return fmt.Errorf("fm.outputDrive out of range") + } + 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.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { + return fmt.Errorf("fm.maxDeviationHz out of range") + } + if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { + return fmt.Errorf("fm.limiterCeiling out of range") + } + if c.Backend.Kind == "" { + return fmt.Errorf("backend.kind is required") + } + if c.Control.ListenAddress == "" { + return fmt.Errorf("control.listenAddress is required") + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f2d6fd2..b439f2b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,37 +1,62 @@ package config import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" ) func TestDefaultValidate(t *testing.T) { - cfg := Default() - if err := cfg.Validate(); err != nil { - t.Fatalf("default config invalid: %v", err) - } + cfg := Default() + if err := cfg.Validate(); err != nil { + t.Fatalf("default config 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) - } - 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) - } + 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) + } + 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) + } } 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 validation 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") + } +} + +func TestDefaultPreEmphasis(t *testing.T) { + cfg := Default() + if cfg.FM.PreEmphasisUS != 50 { + t.Fatalf("expected default preEmphasisUS=50, got %.0f", cfg.FM.PreEmphasisUS) + } +} + +func TestDefaultFMModulation(t *testing.T) { + cfg := Default() + if !cfg.FM.FMModulationEnabled { + t.Fatal("expected FM modulation enabled by default") + } + if cfg.FM.MaxDeviationHz != 75000 { + t.Fatalf("expected default maxDeviationHz=75000, got %.0f", cfg.FM.MaxDeviationHz) + } } diff --git a/internal/control/control.go b/internal/control/control.go index 68afedc..5d410ee 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -1,120 +1,135 @@ package control import ( - "encoding/json" - "net/http" - "sync" + "encoding/json" + "net/http" + "sync" - "github.com/jan/fm-rds-tx/internal/config" - drypkg "github.com/jan/fm-rds-tx/internal/dryrun" + "github.com/jan/fm-rds-tx/internal/config" + drypkg "github.com/jan/fm-rds-tx/internal/dryrun" ) type Server struct { - mu sync.RWMutex - cfg config.Config + mu sync.RWMutex + cfg config.Config } type ConfigPatch struct { - FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` - OutputDrive *float64 `json:"outputDrive,omitempty"` - ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` - ToneRightHz *float64 `json:"toneRightHz,omitempty"` - ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` - PS *string `json:"ps,omitempty"` - RadioText *string `json:"radioText,omitempty"` + FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` + OutputDrive *float64 `json:"outputDrive,omitempty"` + ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` + ToneRightHz *float64 `json:"toneRightHz,omitempty"` + ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` + PS *string `json:"ps,omitempty"` + RadioText *string `json:"radioText,omitempty"` + PreEmphasisUS *float64 `json:"preEmphasisUS,omitempty"` + LimiterEnabled *bool `json:"limiterEnabled,omitempty"` + LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` } func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } func (s *Server) Handler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/healthz", s.handleHealth) - mux.HandleFunc("/status", s.handleStatus) - mux.HandleFunc("/dry-run", s.handleDryRun) - mux.HandleFunc("/config", s.handleConfig) - return mux + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealth) + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/dry-run", s.handleDryRun) + mux.HandleFunc("/config", s.handleConfig) + return mux } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { - s.mu.RLock() - cfg := s.cfg - s.mu.RUnlock() + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "service": "fm-rds-tx", - "backend": cfg.Backend.Kind, - "frequencyMHz": cfg.FM.FrequencyMHz, - "stereoEnabled": cfg.FM.StereoEnabled, - "rdsEnabled": cfg.RDS.Enabled, - "toneLeftHz": cfg.Audio.ToneLeftHz, - "toneRightHz": cfg.Audio.ToneRightHz, - }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "service": "fm-rds-tx", + "backend": cfg.Backend.Kind, + "frequencyMHz": cfg.FM.FrequencyMHz, + "stereoEnabled": cfg.FM.StereoEnabled, + "rdsEnabled": cfg.RDS.Enabled, + "toneLeftHz": cfg.Audio.ToneLeftHz, + "toneRightHz": cfg.Audio.ToneRightHz, + "preEmphasisUS": cfg.FM.PreEmphasisUS, + "limiterEnabled": cfg.FM.LimiterEnabled, + "fmModulationEnabled": cfg.FM.FMModulationEnabled, + }) } func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) { - s.mu.RLock() - cfg := s.cfg - s.mu.RUnlock() + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(drypkg.Generate(cfg)) } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.mu.RLock() - cfg := s.cfg - s.mu.RUnlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(cfg) - case http.MethodPost: - var patch ConfigPatch - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } + switch r.Method { + case http.MethodGet: + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(cfg) + case http.MethodPost: + var patch ConfigPatch + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } - s.mu.Lock() - next := s.cfg - if patch.FrequencyMHz != nil { - next.FM.FrequencyMHz = *patch.FrequencyMHz - } - if patch.OutputDrive != nil { - next.FM.OutputDrive = *patch.OutputDrive - } - if patch.ToneLeftHz != nil { - next.Audio.ToneLeftHz = *patch.ToneLeftHz - } - if patch.ToneRightHz != nil { - next.Audio.ToneRightHz = *patch.ToneRightHz - } - if patch.ToneAmplitude != nil { - next.Audio.ToneAmplitude = *patch.ToneAmplitude - } - if patch.PS != nil { - next.RDS.PS = *patch.PS - } - if patch.RadioText != nil { - next.RDS.RadioText = *patch.RadioText - } - if err := next.Validate(); err != nil { - s.mu.Unlock() - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - s.cfg = next - s.mu.Unlock() + s.mu.Lock() + next := s.cfg + if patch.FrequencyMHz != nil { + next.FM.FrequencyMHz = *patch.FrequencyMHz + } + if patch.OutputDrive != nil { + next.FM.OutputDrive = *patch.OutputDrive + } + if patch.ToneLeftHz != nil { + next.Audio.ToneLeftHz = *patch.ToneLeftHz + } + if patch.ToneRightHz != nil { + next.Audio.ToneRightHz = *patch.ToneRightHz + } + if patch.ToneAmplitude != nil { + next.Audio.ToneAmplitude = *patch.ToneAmplitude + } + if patch.PS != nil { + next.RDS.PS = *patch.PS + } + if patch.RadioText != nil { + next.RDS.RadioText = *patch.RadioText + } + if patch.PreEmphasisUS != nil { + next.FM.PreEmphasisUS = *patch.PreEmphasisUS + } + if patch.LimiterEnabled != nil { + next.FM.LimiterEnabled = *patch.LimiterEnabled + } + if patch.LimiterCeiling != nil { + next.FM.LimiterCeiling = *patch.LimiterCeiling + } + if err := next.Validate(); err != nil { + s.mu.Unlock() + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.cfg = next + s.mu.Unlock() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } } diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 6eb33ee..5f7049e 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -1,73 +1,76 @@ package control import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" ) func TestHealthz(t *testing.T) { - srv := NewServer(cfgpkg.Default()) - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d", rec.Code) - } + srv := NewServer(cfgpkg.Default()) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } } func TestStatus(t *testing.T) { - srv := NewServer(cfgpkg.Default()) - req := httptest.NewRequest(http.MethodGet, "/status", nil) - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d", rec.Code) - } - var body map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body["service"] != "fm-rds-tx" { - t.Fatalf("unexpected service: %v", body["service"]) - } + srv := NewServer(cfgpkg.Default()) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["service"] != "fm-rds-tx" { + t.Fatalf("unexpected service: %v", body["service"]) + } + if _, ok := body["preEmphasisUS"]; !ok { + t.Fatal("expected preEmphasisUS in status") + } } func TestDryRunEndpoint(t *testing.T) { - srv := NewServer(cfgpkg.Default()) - req := httptest.NewRequest(http.MethodGet, "/dry-run", nil) - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d", rec.Code) - } - var body map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body["mode"] != "dry-run" { - t.Fatalf("unexpected mode: %v", body["mode"]) - } + srv := NewServer(cfgpkg.Default()) + req := httptest.NewRequest(http.MethodGet, "/dry-run", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["mode"] != "dry-run" { + t.Fatalf("unexpected mode: %v", body["mode"]) + } } func TestConfigPatch(t *testing.T) { - srv := NewServer(cfgpkg.Default()) - body := []byte(`{"toneLeftHz":900,"radioText":"hello world"}`) - req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) - rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) - } + srv := NewServer(cfgpkg.Default()) + body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisUS":75}`) + req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } - getReq := httptest.NewRequest(http.MethodGet, "/config", nil) - getRec := httptest.NewRecorder() - srv.Handler().ServeHTTP(getRec, getReq) - if getRec.Code != http.StatusOK { - t.Fatalf("unexpected status: %d", getRec.Code) - } + getReq := httptest.NewRequest(http.MethodGet, "/config", nil) + getRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", getRec.Code) + } } diff --git a/internal/dryrun/dryrun.go b/internal/dryrun/dryrun.go index ed5303d..add35d1 100644 --- a/internal/dryrun/dryrun.go +++ b/internal/dryrun/dryrun.go @@ -1,71 +1,79 @@ package dryrun import ( - "encoding/json" - "fmt" - "os" - "path/filepath" + "encoding/json" + "fmt" + "os" + "path/filepath" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" ) type FrameSummary struct { - Mode string `json:"mode"` - FrequencyMHz float64 `json:"frequencyMHz"` - StereoEnabled bool `json:"stereoEnabled"` - RDSEnabled bool `json:"rdsEnabled"` - SampleRateHz int `json:"sampleRateHz"` - CompositeRate int `json:"compositeRateHz"` - PilotLevel float64 `json:"pilotLevel"` - RDSInjection float64 `json:"rdsInjection"` - OutputDrive float64 `json:"outputDrive"` - Source string `json:"source"` - PreviewSamples []float64 `json:"previewSamples"` + Mode string `json:"mode"` + FrequencyMHz float64 `json:"frequencyMHz"` + StereoEnabled bool `json:"stereoEnabled"` + RDSEnabled bool `json:"rdsEnabled"` + SampleRateHz int `json:"sampleRateHz"` + CompositeRate int `json:"compositeRateHz"` + PilotLevel float64 `json:"pilotLevel"` + RDSInjection float64 `json:"rdsInjection"` + OutputDrive float64 `json:"outputDrive"` + PreEmphasisUS float64 `json:"preEmphasisUS"` + MaxDeviationHz float64 `json:"maxDeviationHz"` + LimiterEnabled bool `json:"limiterEnabled"` + FMModulation bool `json:"fmModulationEnabled"` + Source string `json:"source"` + PreviewSamples []float64 `json:"previewSamples"` } func Generate(cfg cfgpkg.Config) FrameSummary { - preview := make([]float64, 16) - base := cfg.Audio.Gain * cfg.FM.OutputDrive - for i := range preview { - sign := 1.0 - if i%2 == 1 { - sign = -1.0 - } - preview[i] = sign * base * float64(i+1) / 16.0 - } - source := "tones" - if cfg.Audio.InputPath != "" { - 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, - Source: source, - PreviewSamples: preview, - } + preview := make([]float64, 16) + base := cfg.Audio.Gain * cfg.FM.OutputDrive + for i := range preview { + sign := 1.0 + if i%2 == 1 { + sign = -1.0 + } + preview[i] = sign * base * float64(i+1) / 16.0 + } + source := "tones" + if cfg.Audio.InputPath != "" { + 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, + } } func WriteJSON(path string, frame FrameSummary) error { - data, err := json.MarshalIndent(frame, "", " ") - if err != nil { - return fmt.Errorf("marshal dry-run frame: %w", err) - } - if path == "" || path == "-" { - _, err = os.Stdout.Write(append(data, '\n')) - return err - } - 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 + data, err := json.MarshalIndent(frame, "", " ") + if err != nil { + return fmt.Errorf("marshal dry-run frame: %w", err) + } + if path == "" || path == "-" { + _, err = os.Stdout.Write(append(data, '\n')) + return err + } + 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 } diff --git a/internal/dryrun/dryrun_test.go b/internal/dryrun/dryrun_test.go index 407c03d..b37e84c 100644 --- a/internal/dryrun/dryrun_test.go +++ b/internal/dryrun/dryrun_test.go @@ -1,43 +1,49 @@ package dryrun import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" - cfgpkg "github.com/jan/fm-rds-tx/internal/config" + cfgpkg "github.com/jan/fm-rds-tx/internal/config" ) 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) - } + 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) - } + cfg := cfgpkg.Default() + cfg.Audio.InputPath = "demo.wav" + frame := Generate(cfg) + if frame.Source != "demo.wav" { + t.Fatalf("unexpected source: %s", frame.Source) + } } 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) - } + 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) + } }