| @@ -10,6 +10,13 @@ | |||||
| - `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` | ||||
| - `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` | - `go run ./cmd/offline -duration 500ms -output build/offline/composite.iqf32` | ||||
| ### Tone configuration | |||||
| The current no-hardware source can be parameterized via config: | |||||
| - `audio.toneLeftHz` | |||||
| - `audio.toneRightHz` | |||||
| - `audio.toneAmplitude` | |||||
| ### Internal DSP module | ### Internal DSP module | ||||
| - `cd internal` | - `cd internal` | ||||
| - `go test ./...` | - `go test ./...` | ||||
| @@ -2,7 +2,10 @@ | |||||
| "audio": { | "audio": { | ||||
| "inputPath": "", | "inputPath": "", | ||||
| "sampleRate": 48000, | "sampleRate": 48000, | ||||
| "gain": 1.0 | |||||
| "gain": 1.0, | |||||
| "toneLeftHz": 1000, | |||||
| "toneRightHz": 1600, | |||||
| "toneAmplitude": 0.4 | |||||
| }, | }, | ||||
| "rds": { | "rds": { | ||||
| "enabled": true, | "enabled": true, | ||||
| @@ -22,6 +22,14 @@ func NewToneSource(sampleRate float64) *ToneSource { | |||||
| } | } | ||||
| } | } | ||||
| func NewConfiguredToneSource(sampleRate, leftHz, rightHz, amplitude float64) *ToneSource { | |||||
| src := NewToneSource(sampleRate) | |||||
| src.LeftFreq = leftHz | |||||
| src.RightFreq = rightHz | |||||
| src.Amplitude = amplitude | |||||
| return src | |||||
| } | |||||
| func (s *ToneSource) NextFrame() Frame { | func (s *ToneSource) NextFrame() Frame { | ||||
| left := s.Amplitude * math.Sin(2*math.Pi*s.LeftPhase) | left := s.Amplitude * math.Sin(2*math.Pi*s.LeftPhase) | ||||
| right := s.Amplitude * math.Sin(2*math.Pi*s.RightPhase) | right := s.Amplitude * math.Sin(2*math.Pi*s.RightPhase) | ||||
| @@ -15,9 +15,12 @@ type Config struct { | |||||
| } | } | ||||
| type AudioConfig struct { | type AudioConfig struct { | ||||
| InputPath string `json:"inputPath"` | |||||
| SampleRate int `json:"sampleRate"` | |||||
| Gain float64 `json:"gain"` | |||||
| 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 { | ||||
| @@ -50,7 +53,7 @@ type ControlConfig struct { | |||||
| func Default() Config { | func Default() Config { | ||||
| return Config{ | return Config{ | ||||
| Audio: AudioConfig{SampleRate: 48000, Gain: 1.0}, | |||||
| 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}, | 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}, | 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"}, | Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, | ||||
| @@ -80,6 +83,12 @@ func (c Config) Validate() error { | |||||
| if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | if c.Audio.Gain < 0 || c.Audio.Gain > 4 { | ||||
| return fmt.Errorf("audio.gain out of range") | 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 { | if c.FM.FrequencyMHz < 65 || c.FM.FrequencyMHz > 110 { | ||||
| return fmt.Errorf("fm.frequencyMHz out of range") | return fmt.Errorf("fm.frequencyMHz out of range") | ||||
| } | } | ||||
| @@ -16,15 +16,15 @@ func TestDefaultValidate(t *testing.T) { | |||||
| func TestLoadAndValidate(t *testing.T) { | func TestLoadAndValidate(t *testing.T) { | ||||
| dir := t.TempDir() | dir := t.TempDir() | ||||
| path := filepath.Join(dir, "config.json") | path := filepath.Join(dir, "config.json") | ||||
| if err := os.WriteFile(path, []byte(`{"fm":{"frequencyMHz":99.9},"backend":{"kind":"file","outputPath":"out.f32"},"control":{"listenAddress":"127.0.0.1:8088"}}`), 0o644); err != nil { | |||||
| 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) | t.Fatalf("write config: %v", err) | ||||
| } | } | ||||
| cfg, err := Load(path) | cfg, err := Load(path) | ||||
| if err != nil { | if err != nil { | ||||
| t.Fatalf("load config: %v", err) | t.Fatalf("load config: %v", err) | ||||
| } | } | ||||
| if cfg.FM.FrequencyMHz != 99.9 { | |||||
| t.Fatalf("unexpected frequency: %v", cfg.FM.FrequencyMHz) | |||||
| if cfg.Audio.ToneLeftHz != 900 { | |||||
| t.Fatalf("unexpected left tone: %v", cfg.Audio.ToneLeftHz) | |||||
| } | } | ||||
| } | } | ||||
| @@ -55,7 +55,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| }) | }) | ||||
| rdsSamples := rdsEnc.Generate(samples) | rdsSamples := rdsEnc.Generate(samples) | ||||
| source := audio.NewToneSource(sampleRate) | |||||
| source := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) | |||||
| for i := 0; i < samples; i++ { | for i := 0; i < samples; i++ { | ||||
| t := float64(i) / sampleRate | t := float64(i) / sampleRate | ||||
| @@ -108,5 +108,5 @@ func (g *Generator) WriteFile(path string, duration time.Duration) error { | |||||
| } | } | ||||
| func (g *Generator) Summary(duration time.Duration) string { | func (g *Generator) Summary(duration time.Duration) string { | ||||
| return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled) | |||||
| return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t toneL=%.1f toneR=%.1f", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz) | |||||
| } | } | ||||