| @@ -1,8 +1,35 @@ | |||||
| # Changelog | # 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 | ## 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 | ### Included | ||||
| - Windows-first Go project scaffold | - Windows-first Go project scaffold | ||||
| @@ -8,17 +8,25 @@ This repository is currently at a **pre-v1, no-hardware-tested milestone**. | |||||
| What works today: | What works today: | ||||
| - JSON configuration loading and validation | - 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 | - 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 | - simulated transmit path through a Soapy-oriented backend abstraction | ||||
| - automated no-hardware tests and smoke checks | - 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: | What does **not** work yet: | ||||
| - real SDR hardware transmission | - 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 | ## 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 | go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms | ||||
| ``` | ``` | ||||
| ### Offline generator | |||||
| ### Offline generator (full DSP chain) | |||||
| ```powershell | ```powershell | ||||
| go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 | go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32 | ||||
| @@ -77,17 +85,17 @@ cmd/ | |||||
| offline/ offline no-hardware generator | offline/ offline no-hardware generator | ||||
| internal/ | internal/ | ||||
| app/ simulated transmit path wiring | app/ simulated transmit path wiring | ||||
| audio/ sample/frame helpers | |||||
| audio/ sample/frame helpers, WAV loader, resampler | |||||
| config/ config schema + validation | config/ config schema + validation | ||||
| control/ HTTP control/status handlers | control/ HTTP control/status handlers | ||||
| dryrun/ JSON no-hardware summaries | dryrun/ JSON no-hardware summaries | ||||
| dsp/ DSP helpers | |||||
| dsp/ oscillator, pre-emphasis, FM modulator, limiter | |||||
| mpx/ MPX combiner primitives | mpx/ MPX combiner primitives | ||||
| offline/ deterministic offline composite generation | |||||
| offline/ deterministic offline composite generation (full DSP chain) | |||||
| output/ backend abstractions + file/dummy sinks | output/ backend abstractions + file/dummy sinks | ||||
| platform/ Soapy-oriented backend abstraction | 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/ | examples/ | ||||
| soapy_simulated/ | soapy_simulated/ | ||||
| docs/ | docs/ | ||||
| @@ -97,7 +105,7 @@ scripts/ | |||||
| ## Current release posture | ## Current release posture | ||||
| Recommended current milestone tag: | Recommended current milestone tag: | ||||
| - `v0.3.0-pre` | |||||
| - `v0.4.0-pre` | |||||
| See also: | See also: | ||||
| - `docs/README.md` | - `docs/README.md` | ||||
| @@ -107,8 +115,7 @@ See also: | |||||
| ## Next priorities | ## 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 | ## 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: | 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 | - 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: | What is not yet true: | ||||
| - real SDR hardware transmission is not integrated | - 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 | - this should not yet be presented as a final v1.0 release | ||||
| ## Recommended tag | ## Recommended tag | ||||
| - `v0.3.0-pre` | |||||
| - `v0.4.0-pre` | |||||
| @@ -1,60 +1,67 @@ | |||||
| package main | package main | ||||
| import ( | 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() { | 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: | Current no-hardware sources: | ||||
| - generated stereo tones via config | - 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 | - transparent tone fallback if the configured WAV source cannot be loaded | ||||
| ### Tone configuration | ### Tone configuration | ||||
| @@ -25,6 +25,40 @@ The current no-hardware source can be parameterized via config: | |||||
| - `audio.toneRightHz` | - `audio.toneRightHz` | ||||
| - `audio.toneAmplitude` | - `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 | ### HTTP control surface | ||||
| Available endpoints: | Available endpoints: | ||||
| @@ -42,6 +76,9 @@ Current patchable runtime fields via `POST /config`: | |||||
| - `toneAmplitude` | - `toneAmplitude` | ||||
| - `ps` | - `ps` | ||||
| - `radioText` | - `radioText` | ||||
| - `preEmphasisUS` | |||||
| - `limiterEnabled` | |||||
| - `limiterCeiling` | |||||
| ### Internal DSP module | ### Internal DSP module | ||||
| - `cd internal` | - `cd internal` | ||||
| @@ -55,23 +92,20 @@ Current patchable runtime fields via `POST /config`: | |||||
| ## Dry run | ## Dry run | ||||
| The dry-run mode generates a synthetic, hardware-free frame summary based on the current config. | 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 | ## Simulated transmit | ||||
| `--simulate-tx` runs the offline generator through the Soapy-oriented simulated backend path and writes an IQ-style artifact to disk. | `--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 | ## 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 | ## Release posture | ||||
| Current honest release posture: **pre-v1**. | 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`. | See `CHANGELOG.md` and `RELEASE.md`. | ||||
| @@ -18,10 +18,14 @@ | |||||
| "frequencyMHz": 100.0, | "frequencyMHz": 100.0, | ||||
| "stereoEnabled": true, | "stereoEnabled": true, | ||||
| "pilotLevel": 0.1, | "pilotLevel": 0.1, | ||||
| "rdsInjection": 0.03, | |||||
| "preEmphasisUS": false, | |||||
| "rdsInjection": 0.05, | |||||
| "preEmphasisUS": 50, | |||||
| "outputDrive": 0.5, | "outputDrive": 0.5, | ||||
| "compositeRateHz": 228000 | |||||
| "compositeRateHz": 228000, | |||||
| "maxDeviationHz": 75000, | |||||
| "limiterEnabled": true, | |||||
| "limiterCeiling": 1.0, | |||||
| "fmModulationEnabled": true | |||||
| }, | }, | ||||
| "backend": { | "backend": { | ||||
| "kind": "file", | "kind": "file", | ||||
| @@ -1,114 +1,139 @@ | |||||
| package config | package config | ||||
| import ( | import ( | ||||
| "encoding/json" | |||||
| "fmt" | |||||
| "os" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "os" | |||||
| ) | ) | ||||
| type Config struct { | 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 { | 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 { | 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 { | 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 { | 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 { | type ControlConfig struct { | ||||
| ListenAddress string `json:"listenAddress"` | |||||
| ListenAddress string `json:"listenAddress"` | |||||
| } | } | ||||
| func Default() Config { | 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) { | 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 { | 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 | package config | ||||
| import ( | import ( | ||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| ) | ) | ||||
| func TestDefaultValidate(t *testing.T) { | func TestDefaultValidate(t *testing.T) { | ||||
| cfg := Default() | |||||
| if err := cfg.Validate(); err != nil { | |||||
| t.Fatalf("default config invalid: %v", err) | |||||
| } | |||||
| cfg := Default() | |||||
| if err := cfg.Validate(); err != nil { | |||||
| t.Fatalf("default config invalid: %v", err) | |||||
| } | |||||
| } | } | ||||
| func TestLoadAndValidate(t *testing.T) { | 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) { | 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 | package control | ||||
| import ( | 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 { | type Server struct { | ||||
| mu sync.RWMutex | |||||
| cfg config.Config | |||||
| mu sync.RWMutex | |||||
| cfg config.Config | |||||
| } | } | ||||
| type ConfigPatch struct { | 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 NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} } | ||||
| func (s *Server) Handler() http.Handler { | 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) { | 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) { | 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) { | 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) { | 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 | package control | ||||
| import ( | 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) { | 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) { | 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) { | 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) { | 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 | package dryrun | ||||
| import ( | 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 { | 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 { | 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 { | 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 | package dryrun | ||||
| import ( | 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) { | 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) { | 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) { | 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) | |||||
| } | |||||
| } | } | ||||