Преглед изворни кода

feat: expose new DSP controls across config, control API, dry-run and docs

tags/v0.4.0-pre^0
Jan Svabenik пре 1 месец
родитељ
комит
6558d7bc09
12 измењених фајлова са 587 додато и 424 уклоњено
  1. +28
    -1
      CHANGELOG.md
  2. +24
    -17
      README.md
  3. +7
    -5
      RELEASE.md
  4. +60
    -53
      cmd/fmrtx/main.go
  5. +43
    -9
      docs/README.md
  6. +7
    -3
      docs/config.sample.json
  7. +108
    -83
      internal/config/config.go
  8. +49
    -24
      internal/config/config_test.go
  9. +103
    -88
      internal/control/control.go
  10. +58
    -55
      internal/control/control_test.go
  11. +65
    -57
      internal/dryrun/dryrun.go
  12. +35
    -29
      internal/dryrun/dryrun_test.go

+ 28
- 1
CHANGELOG.md Прегледај датотеку

@@ -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


+ 24
- 17
README.md Прегледај датотеку

@@ -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

+ 7
- 5
RELEASE.md Прегледај датотеку

@@ -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`

+ 60
- 53
cmd/fmrtx/main.go Прегледај датотеку

@@ -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()))
} }

+ 43
- 9
docs/README.md Прегледај датотеку

@@ -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`.

+ 7
- 3
docs/config.sample.json Прегледај датотеку

@@ -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",


+ 108
- 83
internal/config/config.go Прегледај датотеку

@@ -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
} }

+ 49
- 24
internal/config/config_test.go Прегледај датотеку

@@ -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)
}
} }

+ 103
- 88
internal/control/control.go Прегледај датотеку

@@ -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)
}
} }

+ 58
- 55
internal/control/control_test.go Прегледај датотеку

@@ -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)
}
} }

+ 65
- 57
internal/dryrun/dryrun.go Прегледај датотеку

@@ -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
} }

+ 35
- 29
internal/dryrun/dryrun_test.go Прегледај датотеку

@@ -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)
}
} }

Loading…
Откажи
Сачувај