| @@ -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 | |||
| @@ -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 | |||
| @@ -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` | |||
| @@ -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())) | |||
| } | |||
| @@ -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`. | |||
| @@ -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", | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||