| @@ -10,6 +10,12 @@ | |||
| - `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` | |||
| ### Audio source modes | |||
| Current no-hardware sources: | |||
| - generated stereo tones via config | |||
| - 16-bit PCM WAV file input via `audio.inputPath` | |||
| ### Tone configuration | |||
| The current no-hardware source can be parameterized via config: | |||
| @@ -0,0 +1,74 @@ | |||
| package audio | |||
| import ( | |||
| "encoding/binary" | |||
| "fmt" | |||
| "io" | |||
| "os" | |||
| ) | |||
| type WAVSource struct { | |||
| frames []Frame | |||
| index int | |||
| } | |||
| func LoadWAVSource(path string) (*WAVSource, error) { | |||
| f, err := os.Open(path) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| defer f.Close() | |||
| header := make([]byte, 44) | |||
| if _, err := io.ReadFull(f, header); err != nil { | |||
| return nil, fmt.Errorf("read wav header: %w", err) | |||
| } | |||
| if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { | |||
| return nil, fmt.Errorf("unsupported wav header") | |||
| } | |||
| channels := binary.LittleEndian.Uint16(header[22:24]) | |||
| bitsPerSample := binary.LittleEndian.Uint16(header[34:36]) | |||
| dataSize := binary.LittleEndian.Uint32(header[40:44]) | |||
| if bitsPerSample != 16 { | |||
| return nil, fmt.Errorf("only 16-bit PCM wav supported") | |||
| } | |||
| if channels != 1 && channels != 2 { | |||
| return nil, fmt.Errorf("only mono/stereo wav supported") | |||
| } | |||
| raw := make([]byte, dataSize) | |||
| if _, err := io.ReadFull(f, raw); err != nil { | |||
| return nil, fmt.Errorf("read wav data: %w", err) | |||
| } | |||
| step := int(channels) * 2 | |||
| frames := make([]Frame, 0, len(raw)/step) | |||
| for i := 0; i+step <= len(raw); i += step { | |||
| l := pcm16ToSample(int16(binary.LittleEndian.Uint16(raw[i : i+2]))) | |||
| r := l | |||
| if channels == 2 { | |||
| r = pcm16ToSample(int16(binary.LittleEndian.Uint16(raw[i+2 : i+4]))) | |||
| } | |||
| frames = append(frames, NewFrame(l, r)) | |||
| } | |||
| return &WAVSource{frames: frames}, nil | |||
| } | |||
| func (s *WAVSource) NextFrame() Frame { | |||
| if len(s.frames) == 0 { | |||
| return NewFrame(0, 0) | |||
| } | |||
| frame := s.frames[s.index] | |||
| s.index++ | |||
| if s.index >= len(s.frames) { | |||
| s.index = 0 | |||
| } | |||
| return frame | |||
| } | |||
| func pcm16ToSample(v int16) Sample { | |||
| return Sample(float64(v) / 32768.0).Clamp() | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| package audio | |||
| import ( | |||
| "os" | |||
| "path/filepath" | |||
| "testing" | |||
| ) | |||
| func TestPCM16ToSample(t *testing.T) { | |||
| if pcm16ToSample(32767) <= 0 { | |||
| t.Fatal("expected positive sample") | |||
| } | |||
| } | |||
| func TestLoadWAVSource(t *testing.T) { | |||
| dir := t.TempDir() | |||
| path := filepath.Join(dir, "test.wav") | |||
| wav := []byte{ | |||
| 'R','I','F','F', 52,0,0,0, 'W','A','V','E', | |||
| 'f','m','t',' ', 16,0,0,0, 1,0, 1,0, 0x80,0xbb,0x00,0x00, 0x00,0x77,0x01,0x00, 2,0, 16,0, | |||
| 'd','a','t','a', 8,0,0,0, | |||
| 0,0, 255,127, 0,128, 0,0, | |||
| } | |||
| if err := os.WriteFile(path, wav, 0o644); err != nil { | |||
| t.Fatalf("write wav: %v", err) | |||
| } | |||
| src, err := LoadWAVSource(path) | |||
| if err != nil { | |||
| t.Fatalf("LoadWAVSource failed: %v", err) | |||
| } | |||
| _ = src.NextFrame() | |||
| } | |||
| @@ -16,6 +16,10 @@ import ( | |||
| "github.com/jan/fm-rds-tx/internal/stereo" | |||
| ) | |||
| type frameSource interface { | |||
| NextFrame() audio.Frame | |||
| } | |||
| type Generator struct { | |||
| cfg cfgpkg.Config | |||
| } | |||
| @@ -24,6 +28,15 @@ func NewGenerator(cfg cfgpkg.Config) *Generator { | |||
| return &Generator{cfg: cfg} | |||
| } | |||
| func (g *Generator) sourceFor(sampleRate float64) frameSource { | |||
| if g.cfg.Audio.InputPath != "" { | |||
| if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { | |||
| return src | |||
| } | |||
| } | |||
| return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) | |||
| } | |||
| func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | |||
| sampleRate := float64(g.cfg.FM.CompositeRateHz) | |||
| if sampleRate <= 0 { | |||
| @@ -55,7 +68,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| }) | |||
| rdsSamples := rdsEnc.Generate(samples) | |||
| source := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) | |||
| source := g.sourceFor(sampleRate) | |||
| for i := 0; i < samples; i++ { | |||
| t := float64(i) / sampleRate | |||
| @@ -108,5 +121,9 @@ func (g *Generator) WriteFile(path string, duration time.Duration) error { | |||
| } | |||
| 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 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) | |||
| source := "tones" | |||
| if g.cfg.Audio.InputPath != "" { | |||
| source = g.cfg.Audio.InputPath | |||
| } | |||
| return fmt.Sprintf("offline frame: freq=%.1fMHz sampleRate=%d duration=%s outputDrive=%.2f stereo=%t rds=%t source=%s", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, source) | |||
| } | |||