Parcourir la source

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

tags/v0.4.0-pre^0
Jan Svabenik il y a 1 mois
Parent
révision
6558d7bc09
12 fichiers modifiés avec 587 ajouts et 424 suppressions
  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 Voir le fichier

@@ -1,8 +1,35 @@
# Changelog

## v0.4.0-pre

Full DSP chain implementation, pre-hardware milestone.

### Added
- RDS encoder rewrite: standards-grade group framing (0A for PS, 2A for RadioText), CRC-10 per IEC 62106 with offset words, differential encoding, group scheduler with PS/RT cycling and A/B flag toggle
- Stereo encoder: stateful 38 kHz DSB-SC subcarrier oscillator (phase-coherent across block boundaries), replaces fragile sample-indexed sin() in generator
- Pre-emphasis filter: first-order IIR high-shelf, configurable τ (50 µs EU, 75 µs US, 0=off), with complementary de-emphasis for verification
- FM modulator: composite-to-IQ via phase integration, configurable ±75 kHz deviation, unit-magnitude output guaranteed
- MPX limiter: smooth attack/release peak limiter with hard-clip safety net
- Linear-interpolation audio resampler (replaces nearest-neighbor)
- Robust WAV loader with RIFF chunk scanning (handles LIST, INFO, bext, etc.)
- New config fields: preEmphasisUS, maxDeviationHz, limiterEnabled, limiterCeiling, fmModulationEnabled
- New patchable runtime fields via HTTP: preEmphasisUS, limiterEnabled, limiterCeiling
- Comprehensive tests for all new DSP blocks

### Changed
- Config defaults: pre-emphasis 50µs, limiter on, FM modulation on, deviation ±75kHz, RDS injection 0.05
- Offline generator wires full DSP chain: pre-emphasis → stereo encode → RDS → MPX combine → limiter → FM modulate
- Dry-run output includes all new config fields
- Status endpoint reports pre-emphasis, limiter, and FM modulation state

### Fixed
- Stereo subcarrier phase discontinuity at block boundaries
- WAV loader breaking on files with extra metadata chunks before data
- Resampler aliasing artifacts from zero-order hold

## v0.3.0-pre

Current pre-v1 milestone for the licensed short-term FM/RDS transmitter project.
Initial pre-v1 milestone for the licensed short-term FM/RDS transmitter project.

### Included
- Windows-first Go project scaffold


+ 24
- 17
README.md Voir le fichier

@@ -8,17 +8,25 @@ This repository is currently at a **pre-v1, no-hardware-tested milestone**.

What works today:
- JSON configuration loading and validation
- small HTTP control/status surface
- small HTTP control/status surface with runtime config patching
- dry-run generation for no-hardware inspection
- offline IQ/composite-style file generation
- offline IQ/composite file generation with full DSP chain
- simulated transmit path through a Soapy-oriented backend abstraction
- automated no-hardware tests and smoke checks

DSP chain (fully implemented):
- stereo encoding with phase-coherent 19 kHz pilot and 38 kHz DSB-SC subcarrier
- RDS encoding with standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK
- pre-emphasis filter (50 µs EU / 75 µs US / configurable)
- MPX limiter with smooth attack/release and hard-clip safety net
- FM modulator producing baseband IQ output (±75 kHz deviation, unit magnitude)
- linear-interpolation audio resampler
- robust WAV loader with RIFF chunk scanning

What does **not** work yet:
- real SDR hardware transmission
- full RDS group framing / CRC / standards-grade correctness
- real live audio ingest pipeline
- production-ready broadcast chain processing
- real live audio ingest pipeline (streaming/network)
- production-ready broadcast chain processing (multiband, look-ahead limiting)

## Project goal

@@ -57,7 +65,7 @@ go run ./cmd/fmrtx --dry-run --dry-output build/dryrun/frame.json
go run ./cmd/fmrtx --simulate-tx --simulate-output build/sim/simulated-soapy.iqf32 --simulate-duration 250ms
```

### Offline generator
### Offline generator (full DSP chain)

```powershell
go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32
@@ -77,17 +85,17 @@ cmd/
offline/ offline no-hardware generator
internal/
app/ simulated transmit path wiring
audio/ sample/frame helpers
audio/ sample/frame helpers, WAV loader, resampler
config/ config schema + validation
control/ HTTP control/status handlers
dryrun/ JSON no-hardware summaries
dsp/ DSP helpers
dsp/ oscillator, pre-emphasis, FM modulator, limiter
mpx/ MPX combiner primitives
offline/ deterministic offline composite generation
offline/ deterministic offline composite generation (full DSP chain)
output/ backend abstractions + file/dummy sinks
platform/ Soapy-oriented backend abstraction
rds/ basic RDS encoder scaffolding
stereo/ stereo encoder primitives
rds/ RDS encoder (IEC 62106 group framing, CRC, diff encoding)
stereo/ stereo encoder (pilot + 38 kHz subcarrier)
examples/
soapy_simulated/
docs/
@@ -97,7 +105,7 @@ scripts/
## Current release posture

Recommended current milestone tag:
- `v0.3.0-pre`
- `v0.4.0-pre`

See also:
- `docs/README.md`
@@ -107,8 +115,7 @@ See also:

## Next priorities

1. real audio ingest path
2. stronger RDS implementation
3. tighter end-to-end regression coverage
4. real SoapySDR backend integration
5. first true v1.0 criteria review after those pieces exist
1. real audio ingest path (live PCM, network audio)
2. real SoapySDR backend integration
3. tighter end-to-end regression coverage (decode verification)
4. first true v1.0 criteria review after those pieces exist

+ 7
- 5
RELEASE.md Voir le fichier

@@ -2,18 +2,20 @@

## Project status

This repository is currently at a **pre-v1 no-hardware milestone**.
This repository is currently at a **pre-v1 no-hardware milestone** with a complete DSP chain.

What is true today:
- the repo builds
- automated tests pass
- the repo builds and all tests pass
- dry-run, offline generation, and simulated transmit paths run without hardware
- core architecture for config/control/output is in place
- full broadcast DSP chain: pre-emphasis, stereo encoding, RDS (IEC 62106), limiter, FM modulation
- offline IQ output has verified unit-magnitude FM modulation
- core architecture for config/control/output is in place and extensible

What is not yet true:
- real SDR hardware transmission is not integrated
- live audio ingest (streaming, network) is not implemented
- this should not yet be presented as a final v1.0 release

## Recommended tag

- `v0.3.0-pre`
- `v0.4.0-pre`

+ 60
- 53
cmd/fmrtx/main.go Voir le fichier

@@ -1,60 +1,67 @@
package main

import (
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
apppkg "github.com/jan/fm-rds-tx/internal/app"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
apppkg "github.com/jan/fm-rds-tx/internal/app"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
)

func main() {
configPath := flag.String("config", "", "path to JSON config")
printConfig := flag.Bool("print-config", false, "print effective config and exit")
dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
flag.Parse()

cfg, err := cfgpkg.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}

if *printConfig {
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t listen=%s\n", cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled, cfg.Control.ListenAddress)
return
}

if *dryRun {
frame := drypkg.Generate(cfg)
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
log.Fatalf("dry-run failed: %v", err)
}
if *dryOutput != "" && *dryOutput != "-" {
fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
}
return
}

if *simulate {
summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
if err != nil {
log.Fatalf("simulate-tx failed: %v", err)
}
fmt.Println(summary)
return
}

srv := ctrlpkg.NewServer(cfg)
log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress)
log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))
configPath := flag.String("config", "", "path to JSON config")
printConfig := flag.Bool("print-config", false, "print effective config and exit")
dryRun := flag.Bool("dry-run", false, "run no-hardware dry-run output")
dryOutput := flag.String("dry-output", "-", "dry-run output path or - for stdout")
simulate := flag.Bool("simulate-tx", false, "run simulated Soapy/backend transmit path")
simulateOutput := flag.String("simulate-output", "", "simulated transmit output file")
simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
flag.Parse()

cfg, err := cfgpkg.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}

if *printConfig {
preemph := "off"
if cfg.FM.PreEmphasisUS > 0 {
preemph = fmt.Sprintf("%.0fµs", cfg.FM.PreEmphasisUS)
}
fmt.Printf("backend=%s freq=%.1fMHz stereo=%t rds=%t preemph=%s limiter=%t fmmod=%t deviation=±%.0fHz listen=%s\n",
cfg.Backend.Kind, cfg.FM.FrequencyMHz, cfg.FM.StereoEnabled, cfg.RDS.Enabled,
preemph, cfg.FM.LimiterEnabled, cfg.FM.FMModulationEnabled, cfg.FM.MaxDeviationHz,
cfg.Control.ListenAddress)
return
}

if *dryRun {
frame := drypkg.Generate(cfg)
if err := drypkg.WriteJSON(*dryOutput, frame); err != nil {
log.Fatalf("dry-run failed: %v", err)
}
if *dryOutput != "" && *dryOutput != "-" {
fmt.Fprintf(os.Stderr, "dry run frame written to %s\n", *dryOutput)
}
return
}

if *simulate {
summary, err := apppkg.RunSimulatedTransmit(cfg, *simulateOutput, *simulateDuration)
if err != nil {
log.Fatalf("simulate-tx failed: %v", err)
}
fmt.Println(summary)
return
}

srv := ctrlpkg.NewServer(cfg)
log.Printf("fm-rds-tx listening on %s", cfg.Control.ListenAddress)
log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()))
}

+ 43
- 9
docs/README.md Voir le fichier

@@ -14,8 +14,8 @@

Current no-hardware sources:
- generated stereo tones via config
- 16-bit PCM WAV file input via `audio.inputPath`
- basic sample-rate adaptation for WAV sources into the composite generation path
- 16-bit PCM WAV file input via `audio.inputPath` (robust chunk-scanning loader)
- linear-interpolation sample-rate conversion for WAV sources
- transparent tone fallback if the configured WAV source cannot be loaded

### Tone configuration
@@ -25,6 +25,40 @@ The current no-hardware source can be parameterized via config:
- `audio.toneRightHz`
- `audio.toneAmplitude`

### DSP chain

The full signal chain from audio input to IQ output:

1. **Audio ingest** — tone generator or WAV file with linear-interpolation resampler
2. **Gain staging** — configurable audio gain
3. **Pre-emphasis** — first-order IIR high-shelf filter, configurable τ (50 µs EU / 75 µs US / 0 = off)
4. **Stereo encoder** — L+R mono, L-R on stateful 38 kHz DSB-SC subcarrier, phase-coherent 19 kHz pilot
5. **RDS encoder** — standards-grade group framing (0A/2A), CRC-10 per IEC 62106, differential encoding, 57 kHz BPSK subcarrier, group scheduler cycling PS and RadioText
6. **MPX combiner** — configurable gains for mono, stereo, pilot, and RDS components
7. **Output drive** — configurable output level scaling
8. **MPX limiter** — smooth attack/release peak limiter with hard-clip safety net
9. **FM modulator** — composite-to-IQ via phase integration, configurable ±75 kHz deviation, unit-magnitude IQ output

### Pre-emphasis

FM broadcast requires pre-emphasis to boost high frequencies before transmission. The receiver applies complementary de-emphasis to restore flat response while reducing noise.

- Europe/World: τ = 50 µs (`preEmphasisUS: 50`)
- North America/South Korea: τ = 75 µs (`preEmphasisUS: 75`)
- Disabled: `preEmphasisUS: 0`

### FM modulation modes

- `fmModulationEnabled: true` — output is baseband FM-modulated IQ (I² + Q² = 1). This is what SDR transmitters expect.
- `fmModulationEnabled: false` — output is raw composite MPX (I = composite, Q = 0). Useful for analysis or composite exciters.

### Limiter

The MPX limiter prevents overmodulation by applying smooth gain reduction when the composite signal exceeds the configured ceiling. A hard clipper acts as a safety net after the limiter.

- `limiterEnabled: true/false`
- `limiterCeiling: 1.0` (max composite level before FM modulation)

### HTTP control surface

Available endpoints:
@@ -42,6 +76,9 @@ Current patchable runtime fields via `POST /config`:
- `toneAmplitude`
- `ps`
- `radioText`
- `preEmphasisUS`
- `limiterEnabled`
- `limiterCeiling`

### Internal DSP module
- `cd internal`
@@ -55,23 +92,20 @@ Current patchable runtime fields via `POST /config`:
## Dry run

The dry-run mode generates a synthetic, hardware-free frame summary based on the current config.
It now reports the active source label as well, so dry-run output is less disconnected from the offline/sim paths.
It reports the active source label, pre-emphasis setting, limiter state, and FM modulation mode.

The HTTP control plane also exposes `GET /dry-run` for quick inspection of the currently effective no-hardware summary.
The HTTP control plane also exposes `GET /dry-run` for quick inspection.

## Simulated transmit

`--simulate-tx` runs the offline generator through the Soapy-oriented simulated backend path and writes an IQ-style artifact to disk.
The current summary includes backend, input source, frequency, and configured output sample rate.

## Offline generation

`cmd/offline` generates a deterministic no-hardware IQ/composite-style file using the repository's output backend path.
This is still an MVP path, but it is a more realistic offline artifact than the JSON-only dry-run.
The generator summary now reports whether the active source is tones, wav, or tone-fallback.
`cmd/offline` generates a deterministic no-hardware IQ/composite file using the full DSP chain (pre-emphasis, stereo encoding, RDS, limiter, FM modulation).

## Release posture

Current honest release posture: **pre-v1**.
Recommended milestone tag: `v0.3.0-pre`.
Recommended milestone tag: `v0.4.0-pre`.
See `CHANGELOG.md` and `RELEASE.md`.

+ 7
- 3
docs/config.sample.json Voir le fichier

@@ -18,10 +18,14 @@
"frequencyMHz": 100.0,
"stereoEnabled": true,
"pilotLevel": 0.1,
"rdsInjection": 0.03,
"preEmphasisUS": false,
"rdsInjection": 0.05,
"preEmphasisUS": 50,
"outputDrive": 0.5,
"compositeRateHz": 228000
"compositeRateHz": 228000,
"maxDeviationHz": 75000,
"limiterEnabled": true,
"limiterCeiling": 1.0,
"fmModulationEnabled": true
},
"backend": {
"kind": "file",


+ 108
- 83
internal/config/config.go Voir le fichier

@@ -1,114 +1,139 @@
package config

import (
"encoding/json"
"fmt"
"os"
"encoding/json"
"fmt"
"os"
)

type Config struct {
Audio AudioConfig `json:"audio"`
RDS RDSConfig `json:"rds"`
FM FMConfig `json:"fm"`
Backend BackendConfig `json:"backend"`
Control ControlConfig `json:"control"`
Audio AudioConfig `json:"audio"`
RDS RDSConfig `json:"rds"`
FM FMConfig `json:"fm"`
Backend BackendConfig `json:"backend"`
Control ControlConfig `json:"control"`
}

type AudioConfig struct {
InputPath string `json:"inputPath"`
SampleRate int `json:"sampleRate"`
Gain float64 `json:"gain"`
ToneLeftHz float64 `json:"toneLeftHz"`
ToneRightHz float64 `json:"toneRightHz"`
ToneAmplitude float64 `json:"toneAmplitude"`
InputPath string `json:"inputPath"`
SampleRate int `json:"sampleRate"`
Gain float64 `json:"gain"`
ToneLeftHz float64 `json:"toneLeftHz"`
ToneRightHz float64 `json:"toneRightHz"`
ToneAmplitude float64 `json:"toneAmplitude"`
}

type RDSConfig struct {
Enabled bool `json:"enabled"`
PI string `json:"pi"`
PS string `json:"ps"`
RadioText string `json:"radioText"`
PTY int `json:"pty"`
Enabled bool `json:"enabled"`
PI string `json:"pi"`
PS string `json:"ps"`
RadioText string `json:"radioText"`
PTY int `json:"pty"`
}

type FMConfig struct {
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
PreEmphasisUS bool `json:"preEmphasisUS"`
OutputDrive float64 `json:"outputDrive"`
CompositeRateHz int `json:"compositeRateHz"`
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
PreEmphasisUS float64 `json:"preEmphasisUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
OutputDrive float64 `json:"outputDrive"`
CompositeRateHz int `json:"compositeRateHz"`
MaxDeviationHz float64 `json:"maxDeviationHz"` // FM deviation in Hz, default 75000
LimiterEnabled bool `json:"limiterEnabled"`
LimiterCeiling float64 `json:"limiterCeiling"` // composite ceiling, default 1.0
FMModulationEnabled bool `json:"fmModulationEnabled"` // true = output FM IQ, false = raw composite
}

type BackendConfig struct {
Kind string `json:"kind"`
Device string `json:"device"`
OutputPath string `json:"outputPath"`
Kind string `json:"kind"`
Device string `json:"device"`
OutputPath string `json:"outputPath"`
}

type ControlConfig struct {
ListenAddress string `json:"listenAddress"`
ListenAddress string `json:"listenAddress"`
}

func Default() Config {
return Config{
Audio: AudioConfig{SampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
FM: FMConfig{FrequencyMHz: 100.0, StereoEnabled: true, PilotLevel: 0.1, RDSInjection: 0.03, OutputDrive: 0.5, CompositeRateHz: 228000},
Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
}
return Config{
Audio: AudioConfig{SampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0},
FM: FMConfig{
FrequencyMHz: 100.0,
StereoEnabled: true,
PilotLevel: 0.1,
RDSInjection: 0.05,
PreEmphasisUS: 50, // European default
OutputDrive: 0.5,
CompositeRateHz: 228000,
MaxDeviationHz: 75000,
LimiterEnabled: true,
LimiterCeiling: 1.0,
FMModulationEnabled: true,
},
Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"},
Control: ControlConfig{ListenAddress: "127.0.0.1:8088"},
}
}

func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, cfg.Validate()
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, cfg.Validate()
cfg := Default()
if path == "" {
return cfg, cfg.Validate()
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
return cfg, cfg.Validate()
}

func (c Config) Validate() error {
if c.Audio.SampleRate < 8000 || c.Audio.SampleRate > 384000 {
return fmt.Errorf("audio.sampleRate out of range")
}
if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
return fmt.Errorf("audio.gain out of range")
}
if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 {
return fmt.Errorf("audio tone frequencies must be positive")
}
if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
return fmt.Errorf("audio.toneAmplitude out of range")
}
if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
return fmt.Errorf("fm.frequencyMHz out of range")
}
if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
return fmt.Errorf("fm.pilotLevel out of range")
}
if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.1 {
return fmt.Errorf("fm.rdsInjection out of range")
}
if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 {
return fmt.Errorf("fm.outputDrive out of range")
}
if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
return fmt.Errorf("fm.compositeRateHz out of range")
}
if c.Backend.Kind == "" {
return fmt.Errorf("backend.kind is required")
}
if c.Control.ListenAddress == "" {
return fmt.Errorf("control.listenAddress is required")
}
return nil
if c.Audio.SampleRate < 8000 || c.Audio.SampleRate > 384000 {
return fmt.Errorf("audio.sampleRate out of range")
}
if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
return fmt.Errorf("audio.gain out of range")
}
if c.Audio.ToneLeftHz <= 0 || c.Audio.ToneRightHz <= 0 {
return fmt.Errorf("audio tone frequencies must be positive")
}
if c.Audio.ToneAmplitude < 0 || c.Audio.ToneAmplitude > 1 {
return fmt.Errorf("audio.toneAmplitude out of range")
}
if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 {
return fmt.Errorf("fm.frequencyMHz out of range")
}
if c.FM.PilotLevel < 0 || c.FM.PilotLevel > 0.2 {
return fmt.Errorf("fm.pilotLevel out of range")
}
if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 {
return fmt.Errorf("fm.rdsInjection out of range")
}
if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 1 {
return fmt.Errorf("fm.outputDrive out of range")
}
if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 {
return fmt.Errorf("fm.compositeRateHz out of range")
}
if c.FM.PreEmphasisUS < 0 || c.FM.PreEmphasisUS > 100 {
return fmt.Errorf("fm.preEmphasisUS out of range (0=off, 50=EU, 75=US)")
}
if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
return fmt.Errorf("fm.maxDeviationHz out of range")
}
if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 {
return fmt.Errorf("fm.limiterCeiling out of range")
}
if c.Backend.Kind == "" {
return fmt.Errorf("backend.kind is required")
}
if c.Control.ListenAddress == "" {
return fmt.Errorf("control.listenAddress is required")
}
return nil
}

+ 49
- 24
internal/config/config_test.go Voir le fichier

@@ -1,37 +1,62 @@
package config

import (
"os"
"path/filepath"
"testing"
"os"
"path/filepath"
"testing"
)

func TestDefaultValidate(t *testing.T) {
cfg := Default()
if err := cfg.Validate(); err != nil {
t.Fatalf("default config invalid: %v", err)
}
cfg := Default()
if err := cfg.Validate(); err != nil {
t.Fatalf("default config invalid: %v", err)
}
}

func TestLoadAndValidate(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"audio":{"toneLeftHz":900,"toneRightHz":1700,"toneAmplitude":0.3},"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Audio.ToneLeftHz != 900 {
t.Fatalf("unexpected left tone: %v", cfg.Audio.ToneLeftHz)
}
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"audio":{"toneLeftHz":900,"toneRightHz":1700,"toneAmplitude":0.3},"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Audio.ToneLeftHz != 900 {
t.Fatalf("unexpected left tone: %v", cfg.Audio.ToneLeftHz)
}
}

func TestValidateRejectsBadFrequency(t *testing.T) {
cfg := Default()
cfg.FM.FrequencyMHz = 200
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error")
}
cfg := Default()
cfg.FM.FrequencyMHz = 200
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error")
}
}

func TestValidateRejectsBadPreEmphasis(t *testing.T) {
cfg := Default()
cfg.FM.PreEmphasisUS = 150
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for preEmphasisUS > 100")
}
}

func TestDefaultPreEmphasis(t *testing.T) {
cfg := Default()
if cfg.FM.PreEmphasisUS != 50 {
t.Fatalf("expected default preEmphasisUS=50, got %.0f", cfg.FM.PreEmphasisUS)
}
}

func TestDefaultFMModulation(t *testing.T) {
cfg := Default()
if !cfg.FM.FMModulationEnabled {
t.Fatal("expected FM modulation enabled by default")
}
if cfg.FM.MaxDeviationHz != 75000 {
t.Fatalf("expected default maxDeviationHz=75000, got %.0f", cfg.FM.MaxDeviationHz)
}
}

+ 103
- 88
internal/control/control.go Voir le fichier

@@ -1,120 +1,135 @@
package control

import (
"encoding/json"
"net/http"
"sync"
"encoding/json"
"net/http"
"sync"

"github.com/jan/fm-rds-tx/internal/config"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
"github.com/jan/fm-rds-tx/internal/config"
drypkg "github.com/jan/fm-rds-tx/internal/dryrun"
)

type Server struct {
mu sync.RWMutex
cfg config.Config
mu sync.RWMutex
cfg config.Config
}

type ConfigPatch struct {
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
PreEmphasisUS *float64 `json:"preEmphasisUS,omitempty"`
LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
}

func NewServer(cfg config.Config) *Server { return &Server{cfg: cfg} }

func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/dry-run", s.handleDryRun)
mux.HandleFunc("/config", s.handleConfig)
return mux
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealth)
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/dry-run", s.handleDryRun)
mux.HandleFunc("/config", s.handleConfig)
return mux
}

func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}

func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"service": "fm-rds-tx",
"backend": cfg.Backend.Kind,
"frequencyMHz": cfg.FM.FrequencyMHz,
"stereoEnabled": cfg.FM.StereoEnabled,
"rdsEnabled": cfg.RDS.Enabled,
"toneLeftHz": cfg.Audio.ToneLeftHz,
"toneRightHz": cfg.Audio.ToneRightHz,
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"service": "fm-rds-tx",
"backend": cfg.Backend.Kind,
"frequencyMHz": cfg.FM.FrequencyMHz,
"stereoEnabled": cfg.FM.StereoEnabled,
"rdsEnabled": cfg.RDS.Enabled,
"toneLeftHz": cfg.Audio.ToneLeftHz,
"toneRightHz": cfg.Audio.ToneRightHz,
"preEmphasisUS": cfg.FM.PreEmphasisUS,
"limiterEnabled": cfg.FM.LimiterEnabled,
"fmModulationEnabled": cfg.FM.FMModulationEnabled,
})
}

func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(drypkg.Generate(cfg))
}

func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cfg)
case http.MethodPost:
var patch ConfigPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cfg)
case http.MethodPost:
var patch ConfigPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

s.mu.Lock()
next := s.cfg
if patch.FrequencyMHz != nil {
next.FM.FrequencyMHz = *patch.FrequencyMHz
}
if patch.OutputDrive != nil {
next.FM.OutputDrive = *patch.OutputDrive
}
if patch.ToneLeftHz != nil {
next.Audio.ToneLeftHz = *patch.ToneLeftHz
}
if patch.ToneRightHz != nil {
next.Audio.ToneRightHz = *patch.ToneRightHz
}
if patch.ToneAmplitude != nil {
next.Audio.ToneAmplitude = *patch.ToneAmplitude
}
if patch.PS != nil {
next.RDS.PS = *patch.PS
}
if patch.RadioText != nil {
next.RDS.RadioText = *patch.RadioText
}
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.cfg = next
s.mu.Unlock()
s.mu.Lock()
next := s.cfg
if patch.FrequencyMHz != nil {
next.FM.FrequencyMHz = *patch.FrequencyMHz
}
if patch.OutputDrive != nil {
next.FM.OutputDrive = *patch.OutputDrive
}
if patch.ToneLeftHz != nil {
next.Audio.ToneLeftHz = *patch.ToneLeftHz
}
if patch.ToneRightHz != nil {
next.Audio.ToneRightHz = *patch.ToneRightHz
}
if patch.ToneAmplitude != nil {
next.Audio.ToneAmplitude = *patch.ToneAmplitude
}
if patch.PS != nil {
next.RDS.PS = *patch.PS
}
if patch.RadioText != nil {
next.RDS.RadioText = *patch.RadioText
}
if patch.PreEmphasisUS != nil {
next.FM.PreEmphasisUS = *patch.PreEmphasisUS
}
if patch.LimiterEnabled != nil {
next.FM.LimiterEnabled = *patch.LimiterEnabled
}
if patch.LimiterCeiling != nil {
next.FM.LimiterCeiling = *patch.LimiterCeiling
}
if err := next.Validate(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.cfg = next
s.mu.Unlock()

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

+ 58
- 55
internal/control/control_test.go Voir le fichier

@@ -1,73 +1,76 @@
package control

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

func TestHealthz(t *testing.T) {
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
}

func TestStatus(t *testing.T) {
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["service"] != "fm-rds-tx" {
t.Fatalf("unexpected service: %v", body["service"])
}
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["service"] != "fm-rds-tx" {
t.Fatalf("unexpected service: %v", body["service"])
}
if _, ok := body["preEmphasisUS"]; !ok {
t.Fatal("expected preEmphasisUS in status")
}
}

func TestDryRunEndpoint(t *testing.T) {
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/dry-run", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["mode"] != "dry-run" {
t.Fatalf("unexpected mode: %v", body["mode"])
}
srv := NewServer(cfgpkg.Default())
req := httptest.NewRequest(http.MethodGet, "/dry-run", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["mode"] != "dry-run" {
t.Fatalf("unexpected mode: %v", body["mode"])
}
}

func TestConfigPatch(t *testing.T) {
srv := NewServer(cfgpkg.Default())
body := []byte(`{"toneLeftHz":900,"radioText":"hello world"}`)
req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
srv := NewServer(cfgpkg.Default())
body := []byte(`{"toneLeftHz":900,"radioText":"hello world","preEmphasisUS":75}`)
req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}

getReq := httptest.NewRequest(http.MethodGet, "/config", nil)
getRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", getRec.Code)
}
getReq := httptest.NewRequest(http.MethodGet, "/config", nil)
getRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", getRec.Code)
}
}

+ 65
- 57
internal/dryrun/dryrun.go Voir le fichier

@@ -1,71 +1,79 @@
package dryrun

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"encoding/json"
"fmt"
"os"
"path/filepath"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

type FrameSummary struct {
Mode string `json:"mode"`
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
RDSEnabled bool `json:"rdsEnabled"`
SampleRateHz int `json:"sampleRateHz"`
CompositeRate int `json:"compositeRateHz"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
OutputDrive float64 `json:"outputDrive"`
Source string `json:"source"`
PreviewSamples []float64 `json:"previewSamples"`
Mode string `json:"mode"`
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
RDSEnabled bool `json:"rdsEnabled"`
SampleRateHz int `json:"sampleRateHz"`
CompositeRate int `json:"compositeRateHz"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
OutputDrive float64 `json:"outputDrive"`
PreEmphasisUS float64 `json:"preEmphasisUS"`
MaxDeviationHz float64 `json:"maxDeviationHz"`
LimiterEnabled bool `json:"limiterEnabled"`
FMModulation bool `json:"fmModulationEnabled"`
Source string `json:"source"`
PreviewSamples []float64 `json:"previewSamples"`
}

func Generate(cfg cfgpkg.Config) FrameSummary {
preview := make([]float64, 16)
base := cfg.Audio.Gain * cfg.FM.OutputDrive
for i := range preview {
sign := 1.0
if i%2 == 1 {
sign = -1.0
}
preview[i] = sign * base * float64(i+1) / 16.0
}
source := "tones"
if cfg.Audio.InputPath != "" {
source = cfg.Audio.InputPath
}
return FrameSummary{
Mode: "dry-run",
FrequencyMHz: cfg.FM.FrequencyMHz,
StereoEnabled: cfg.FM.StereoEnabled,
RDSEnabled: cfg.RDS.Enabled,
SampleRateHz: cfg.Audio.SampleRate,
CompositeRate: cfg.FM.CompositeRateHz,
PilotLevel: cfg.FM.PilotLevel,
RDSInjection: cfg.FM.RDSInjection,
OutputDrive: cfg.FM.OutputDrive,
Source: source,
PreviewSamples: preview,
}
preview := make([]float64, 16)
base := cfg.Audio.Gain * cfg.FM.OutputDrive
for i := range preview {
sign := 1.0
if i%2 == 1 {
sign = -1.0
}
preview[i] = sign * base * float64(i+1) / 16.0
}
source := "tones"
if cfg.Audio.InputPath != "" {
source = cfg.Audio.InputPath
}
return FrameSummary{
Mode: "dry-run",
FrequencyMHz: cfg.FM.FrequencyMHz,
StereoEnabled: cfg.FM.StereoEnabled,
RDSEnabled: cfg.RDS.Enabled,
SampleRateHz: cfg.Audio.SampleRate,
CompositeRate: cfg.FM.CompositeRateHz,
PilotLevel: cfg.FM.PilotLevel,
RDSInjection: cfg.FM.RDSInjection,
OutputDrive: cfg.FM.OutputDrive,
PreEmphasisUS: cfg.FM.PreEmphasisUS,
MaxDeviationHz: cfg.FM.MaxDeviationHz,
LimiterEnabled: cfg.FM.LimiterEnabled,
FMModulation: cfg.FM.FMModulationEnabled,
Source: source,
PreviewSamples: preview,
}
}

func WriteJSON(path string, frame FrameSummary) error {
data, err := json.MarshalIndent(frame, "", " ")
if err != nil {
return fmt.Errorf("marshal dry-run frame: %w", err)
}
if path == "" || path == "-" {
_, err = os.Stdout.Write(append(data, '\n'))
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write dry-run frame: %w", err)
}
return nil
data, err := json.MarshalIndent(frame, "", " ")
if err != nil {
return fmt.Errorf("marshal dry-run frame: %w", err)
}
if path == "" || path == "-" {
_, err = os.Stdout.Write(append(data, '\n'))
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write dry-run frame: %w", err)
}
return nil
}

+ 35
- 29
internal/dryrun/dryrun_test.go Voir le fichier

@@ -1,43 +1,49 @@
package dryrun

import (
"os"
"path/filepath"
"testing"
"os"
"path/filepath"
"testing"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
)

func TestGenerate(t *testing.T) {
cfg := cfgpkg.Default()
frame := Generate(cfg)
if frame.Mode != "dry-run" {
t.Fatalf("unexpected mode: %s", frame.Mode)
}
if len(frame.PreviewSamples) != 16 {
t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples))
}
if frame.Source != "tones" {
t.Fatalf("unexpected source: %s", frame.Source)
}
cfg := cfgpkg.Default()
frame := Generate(cfg)
if frame.Mode != "dry-run" {
t.Fatalf("unexpected mode: %s", frame.Mode)
}
if len(frame.PreviewSamples) != 16 {
t.Fatalf("unexpected preview length: %d", len(frame.PreviewSamples))
}
if frame.Source != "tones" {
t.Fatalf("unexpected source: %s", frame.Source)
}
if frame.PreEmphasisUS != 50 {
t.Fatalf("unexpected preEmphasisUS: %.0f", frame.PreEmphasisUS)
}
if !frame.FMModulation {
t.Fatal("expected fmModulationEnabled=true")
}
}

func TestGenerateUsesInputPathAsSource(t *testing.T) {
cfg := cfgpkg.Default()
cfg.Audio.InputPath = "demo.wav"
frame := Generate(cfg)
if frame.Source != "demo.wav" {
t.Fatalf("unexpected source: %s", frame.Source)
}
cfg := cfgpkg.Default()
cfg.Audio.InputPath = "demo.wav"
frame := Generate(cfg)
if frame.Source != "demo.wav" {
t.Fatalf("unexpected source: %s", frame.Source)
}
}

func TestWriteJSONFile(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "frame.json")
if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil {
t.Fatalf("WriteJSON failed: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("expected output file: %v", err)
}
dir := t.TempDir()
out := filepath.Join(dir, "frame.json")
if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil {
t.Fatalf("WriteJSON failed: %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("expected output file: %v", err)
}
}

Chargement…
Annuler
Enregistrer