diff --git a/docs/README.md b/docs/README.md index 86a22b0..807b9f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..4cf28d0 --- /dev/null +++ b/internal/audio/wav.go @@ -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() +} diff --git a/internal/audio/wav_test.go b/internal/audio/wav_test.go new file mode 100644 index 0000000..84750b8 --- /dev/null +++ b/internal/audio/wav_test.go @@ -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() +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index d150bd3..2498043 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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) }