Selaa lähdekoodia

feat: tighten config semantics for HW readiness and release prep

tags/v0.5.0-pre
Jan Svabenik 1 kuukausi sitten
vanhempi
commit
8a8805ce0f
7 muutettua tiedostoa jossa 215 lisäystä ja 169 poistoa
  1. +52
    -44
      CHANGELOG.md
  2. +24
    -14
      RELEASE.md
  3. +4
    -3
      docs/config.sample.json
  4. +64
    -26
      internal/config/config.go
  5. +43
    -31
      internal/config/config_test.go
  6. +20
    -21
      internal/dryrun/dryrun.go
  7. +8
    -30
      internal/dryrun/dryrun_test.go

+ 52
- 44
CHANGELOG.md Näytä tiedosto

@@ -1,47 +1,55 @@
# Changelog

## v0.4.0-pre
## v0.5.0-pre

Full review implementation: HW-integration readiness, unity signal path, TX engine, spectral verification.

### Architecture changes
- All signal sources (pilot, RDS, stereo) now output unity-normalized signals (peak ±1.0)
- Combiner gains are direct config values — no magic-number normalization (`/0.1`, `/0.05` eliminated)
- Pre-emphasis moved from composite rate to audio input rate (correct signal path, efficient on SBC)
- RDS encoder: `NextSample()` zero-allocation hot path, fixed-size `[104]uint8` bit buffer
- PI validation moved to `config.Validate()` — fail-loud, no silent 0x1234 fallback

### New: TX Engine (`internal/app/engine.go`)
- Continuous chunk-based generation loop with configurable chunk duration
- Explicit `Start()`/`Stop()` — TX default is OFF
- Atomic underrun/overrun counters, chunk/sample stats
- Context-based cancellation with clean drain

### New: Extended SoapyDriver interface
- `Start(ctx)`, `Stop(ctx)`, `Capabilities(ctx)`, `Stats()` added to driver contract
- `SimulatedDriver` implements full interface with atomic runtime counters
- `DeviceCaps` struct for hardware capability reporting
- `RuntimeStats` struct for live telemetry

### New: Rate adaptation
- `backend.deviceSampleRateHz` separates SDR device rate from internal composite rate
- `Config.EffectiveDeviceRate()` resolves to device rate or falls back to composite rate

### New: Control plane upgrade
- `POST /tx/start` — explicit TX start (requires `TXController`)
- `POST /tx/stop` — explicit TX stop
- `GET /runtime` — live engine + driver telemetry
- `TXController` interface for decoupled engine control

### New: Spectral verification
- Goertzel algorithm (`dsp.GoertzelEnergy`, `dsp.BandEnergy`)
- Blackbox tests verify 19 kHz pilot, 38 kHz stereo, 57 kHz RDS energy presence
- Blackbox tests verify suppression when stereo/RDS disabled

### New: Operator truth tests
- `rds.enabled=false` → verified no 57 kHz energy
- `fmModulationEnabled=false` → verified Q=0
- `limiterEnabled=false` → verified higher peaks than with limiter
- `stereoEnabled=false` → verified no pilot/stereo energy

### Config changes
- `preEmphasisUS` → `preEmphasisTauUS` (unambiguous: microseconds, not region)
- `audio.sampleRate` → `audio.inputSampleRate` (clear: input source rate)
- `backend.deviceSampleRateHz` added (0 = same as compositeRateHz)
- `rds.pi` validated at load time; empty/invalid = hard error
- `rds.pty` range-checked (0-31)

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

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

### Included
- Windows-first Go project scaffold
- validated JSON config schema
- HTTP status/control surface with dry-run endpoint
- deterministic dry-run output
- offline composite/IQ-style file generation
- simulated transmit path in main CLI via Soapy-oriented backend abstraction
- automated no-hardware tests and smoke scripts

### Not yet included
- real SoapySDR hardware driver integration
- full RDS group framing/CRC/timing correctness
- real audio ingestion pipeline
- release CI / packaged distribution
## v0.4.0-pre
[previous changelog entries]

+ 24
- 14
RELEASE.md Näytä tiedosto

@@ -1,21 +1,31 @@
# Release Notes

## Project status
## v0.5.0-pre — HW-integration readiness

This repository is currently at a **pre-v1 no-hardware milestone** with a complete DSP chain.
### Go/No-Go status

What is true today:
- the repo builds and all tests pass
- dry-run, offline generation, and simulated transmit paths run without hardware
- 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
**Gate: First real HW smoke test** — ARCHITECTURALLY READY
- [x] Extended SoapyDriver interface (Start/Stop/Caps/Stats)
- [x] Continuous TX engine with chunk-based generation
- [x] Device rate separated from composite rate
- [x] TX safety: default off, explicit start required
- [x] Runtime telemetry available via /runtime
- [ ] Actual CGO SoapySDR binding (next step)

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
**Gate: Serious HW bring-up** — P1 ITEMS DONE
- [x] Blackbox spectral checks (19/38/57 kHz)
- [x] Operator truth tests for all risky config switches
- [x] Fail-loud PI validation
- [x] Level/injection ownership: unity sources, direct combiner gains

## Recommended tag
### What works
- Complete DSP chain: pre-emphasis → stereo → RDS → MPX → limiter → FM mod
- 79 passing tests including spectral verification
- All signal sources unity-normalized, combiner controls final levels
- Continuous TX engine with Start/Stop/Stats
- Control plane with /tx/start, /tx/stop, /runtime

- `v0.4.0-pre`
### What's next
- CGO SoapySDR driver binding (the actual hardware call)
- Live audio ingest (streaming/network sources)
- End-to-end decode verification with external RDS decoder

+ 4
- 3
docs/config.sample.json Näytä tiedosto

@@ -1,7 +1,7 @@
{
"audio": {
"inputPath": "",
"sampleRate": 48000,
"inputSampleRate": 48000,
"gain": 1.0,
"toneLeftHz": 1000,
"toneRightHz": 1600,
@@ -19,7 +19,7 @@
"stereoEnabled": true,
"pilotLevel": 0.1,
"rdsInjection": 0.05,
"preEmphasisUS": 50,
"preEmphasisTauUS": 50,
"outputDrive": 0.5,
"compositeRateHz": 228000,
"maxDeviationHz": 75000,
@@ -30,7 +30,8 @@
"backend": {
"kind": "file",
"device": "",
"outputPath": "build/out/composite.f32"
"outputPath": "build/out/composite.f32",
"deviceSampleRateHz": 0
},
"control": {
"listenAddress": "127.0.0.1:8088"


+ 64
- 26
internal/config/config.go Näytä tiedosto

@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)

type Config struct {
@@ -15,12 +17,12 @@ type Config 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"`
InputSampleRate int `json:"inputSampleRate"` // sample rate for WAV/tone source
Gain float64 `json:"gain"`
ToneLeftHz float64 `json:"toneLeftHz"`
ToneRightHz float64 `json:"toneRightHz"`
ToneAmplitude float64 `json:"toneAmplitude"`
}

type RDSConfig struct {
@@ -32,23 +34,24 @@ type RDSConfig struct {
}

type FMConfig struct {
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
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
PilotLevel float64 `json:"pilotLevel"` // linear injection level in composite (e.g. 0.1 = 10%)
RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%)
PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
OutputDrive float64 `json:"outputDrive"`
CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate
MaxDeviationHz float64 `json:"maxDeviationHz"`
LimiterEnabled bool `json:"limiterEnabled"`
LimiterCeiling float64 `json:"limiterCeiling"`
FMModulationEnabled bool `json:"fmModulationEnabled"`
}

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"`
DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz
}

type ControlConfig struct {
@@ -57,14 +60,14 @@ type ControlConfig struct {

func Default() Config {
return Config{
Audio: AudioConfig{SampleRate: 48000, Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0.4},
Audio: AudioConfig{InputSampleRate: 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
PreEmphasisTauUS: 50,
OutputDrive: 0.5,
CompositeRateHz: 228000,
MaxDeviationHz: 75000,
@@ -77,6 +80,21 @@ func Default() Config {
}
}

// ParsePI parses a hex PI code string. Returns an error for invalid input.
func ParsePI(pi string) (uint16, error) {
trimmed := strings.TrimSpace(pi)
if trimmed == "" {
return 0, fmt.Errorf("rds.pi is required")
}
trimmed = strings.TrimPrefix(trimmed, "0x")
trimmed = strings.TrimPrefix(trimmed, "0X")
v, err := strconv.ParseUint(trimmed, 16, 16)
if err != nil {
return 0, fmt.Errorf("invalid rds.pi: %q", pi)
}
return uint16(v), nil
}

func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
@@ -93,8 +111,8 @@ func Load(path string) (Config, 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.InputSampleRate < 8000 || c.Audio.InputSampleRate > 384000 {
return fmt.Errorf("audio.inputSampleRate out of range")
}
if c.Audio.Gain < 0 || c.Audio.Gain > 4 {
return fmt.Errorf("audio.gain out of range")
@@ -120,8 +138,8 @@ func (c Config) Validate() error {
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.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
return fmt.Errorf("fm.preEmphasisTauUS 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")
@@ -132,8 +150,28 @@ func (c Config) Validate() error {
if c.Backend.Kind == "" {
return fmt.Errorf("backend.kind is required")
}
if c.Backend.DeviceSampleRateHz < 0 {
return fmt.Errorf("backend.deviceSampleRateHz must be >= 0")
}
if c.Control.ListenAddress == "" {
return fmt.Errorf("control.listenAddress is required")
}
// Fail-loud PI validation
if c.RDS.Enabled {
if _, err := ParsePI(c.RDS.PI); err != nil {
return fmt.Errorf("rds config: %w", err)
}
}
if c.RDS.PTY < 0 || c.RDS.PTY > 31 {
return fmt.Errorf("rds.pty out of range (0-31)")
}
return nil
}

// EffectiveDeviceRate returns the device sample rate, falling back to composite rate.
func (c Config) EffectiveDeviceRate() float64 {
if c.Backend.DeviceSampleRateHz > 0 {
return c.Backend.DeviceSampleRateHz
}
return float64(c.FM.CompositeRateHz)
}

+ 43
- 31
internal/config/config_test.go Näytä tiedosto

@@ -7,56 +7,68 @@ import (
)

func TestDefaultValidate(t *testing.T) {
cfg := Default()
if err := cfg.Validate(); err != nil {
t.Fatalf("default config invalid: %v", err)
}
if err := Default().Validate(); err != nil { t.Fatalf("default 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)
}
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)
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)
}
if err != nil { t.Fatalf("load: %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 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")
}
cfg := Default(); cfg.FM.PreEmphasisTauUS = 150
if err := cfg.Validate(); err == nil { t.Fatal("expected error") }
}

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

func TestDefaultFMModulation(t *testing.T) {
cfg := Default()
if !cfg.FM.FMModulationEnabled {
t.Fatal("expected FM modulation enabled by default")
if !cfg.FM.FMModulationEnabled { t.Fatal("expected true") }
if cfg.FM.MaxDeviationHz != 75000 { t.Fatal("expected 75000") }
}

func TestParsePI(t *testing.T) {
tests := []struct{ in string; want uint16; ok bool }{
{"1234", 0x1234, true},
{"0xBEEF", 0xBEEF, true},
{"0XCAFE", 0xCAFE, true},
{" 0x2345 ", 0x2345, true},
{"", 0, false},
{"nope", 0, false},
}
if cfg.FM.MaxDeviationHz != 75000 {
t.Fatalf("expected default maxDeviationHz=75000, got %.0f", cfg.FM.MaxDeviationHz)
for _, tt := range tests {
got, err := ParsePI(tt.in)
if tt.ok && err != nil { t.Fatalf("ParsePI(%q): %v", tt.in, err) }
if !tt.ok && err == nil { t.Fatalf("ParsePI(%q): expected error", tt.in) }
if tt.ok && got != tt.want { t.Fatalf("ParsePI(%q): got %x want %x", tt.in, got, tt.want) }
}
}

func TestValidateRejectsInvalidPI(t *testing.T) {
cfg := Default(); cfg.RDS.PI = "nope"
if err := cfg.Validate(); err == nil { t.Fatal("expected error for invalid PI") }
}

func TestValidateRejectsEmptyPI(t *testing.T) {
cfg := Default(); cfg.RDS.PI = ""
if err := cfg.Validate(); err == nil { t.Fatal("expected error for empty PI") }
}

func TestEffectiveDeviceRate(t *testing.T) {
cfg := Default()
if cfg.EffectiveDeviceRate() != float64(cfg.FM.CompositeRateHz) { t.Fatal("expected composite rate when device rate is 0") }
cfg.Backend.DeviceSampleRateHz = 912000
if cfg.EffectiveDeviceRate() != 912000 { t.Fatal("expected 912000") }
}

+ 20
- 21
internal/dryrun/dryrun.go Näytä tiedosto

@@ -14,12 +14,13 @@ type FrameSummary struct {
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
RDSEnabled bool `json:"rdsEnabled"`
SampleRateHz int `json:"sampleRateHz"`
InputSampleRateHz int `json:"inputSampleRateHz"`
CompositeRate int `json:"compositeRateHz"`
DeviceRate float64 `json:"deviceSampleRateHz"`
PilotLevel float64 `json:"pilotLevel"`
RDSInjection float64 `json:"rdsInjection"`
OutputDrive float64 `json:"outputDrive"`
PreEmphasisUS float64 `json:"preEmphasisUS"`
PreEmphasisTauUS float64 `json:"preEmphasisTauUS"`
MaxDeviationHz float64 `json:"maxDeviationHz"`
LimiterEnabled bool `json:"limiterEnabled"`
FMModulation bool `json:"fmModulationEnabled"`
@@ -42,21 +43,22 @@ func Generate(cfg cfgpkg.Config) FrameSummary {
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,
Mode: "dry-run",
FrequencyMHz: cfg.FM.FrequencyMHz,
StereoEnabled: cfg.FM.StereoEnabled,
RDSEnabled: cfg.RDS.Enabled,
InputSampleRateHz: cfg.Audio.InputSampleRate,
CompositeRate: cfg.FM.CompositeRateHz,
DeviceRate: cfg.EffectiveDeviceRate(),
PilotLevel: cfg.FM.PilotLevel,
RDSInjection: cfg.FM.RDSInjection,
OutputDrive: cfg.FM.OutputDrive,
PreEmphasisTauUS: cfg.FM.PreEmphasisTauUS,
MaxDeviationHz: cfg.FM.MaxDeviationHz,
LimiterEnabled: cfg.FM.LimiterEnabled,
FMModulation: cfg.FM.FMModulationEnabled,
Source: source,
PreviewSamples: preview,
}
}

@@ -72,8 +74,5 @@ func WriteJSON(path string, frame FrameSummary) error {
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
return os.WriteFile(path, append(data, '\n'), 0o644)
}

+ 8
- 30
internal/dryrun/dryrun_test.go Näytä tiedosto

@@ -11,39 +11,17 @@ import (
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)
}
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)
}
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.PreEmphasisTauUS != 50 { t.Fatalf("unexpected preEmphasisTauUS: %.0f", frame.PreEmphasisTauUS) }
if !frame.FMModulation { t.Fatal("expected fmModulationEnabled=true") }
if frame.DeviceRate != float64(cfg.FM.CompositeRateHz) { t.Fatalf("expected deviceRate=%d, got %.0f", cfg.FM.CompositeRateHz, frame.DeviceRate) }
}

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)
}
if err := WriteJSON(out, Generate(cfgpkg.Default())); err != nil { t.Fatalf("WriteJSON: %v", err) }
if _, err := os.Stat(out); err != nil { t.Fatalf("expected output: %v", err) }
}

Loading…
Peruuta
Tallenna