| @@ -10,6 +10,12 @@ | |||||
| - `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` | ||||
| ### Audio source modes | |||||
| Current no-hardware sources: | |||||
| - generated stereo tones via config | |||||
| - 16-bit PCM WAV file input via `audio.inputPath` | |||||
| ### Tone configuration | ### Tone configuration | ||||
| The current no-hardware source can be parameterized via config: | 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" | "github.com/jan/fm-rds-tx/internal/stereo" | ||||
| ) | ) | ||||
| type frameSource interface { | |||||
| NextFrame() audio.Frame | |||||
| } | |||||
| type Generator struct { | type Generator struct { | ||||
| cfg cfgpkg.Config | cfg cfgpkg.Config | ||||
| } | } | ||||
| @@ -24,6 +28,15 @@ func NewGenerator(cfg cfgpkg.Config) *Generator { | |||||
| return &Generator{cfg: cfg} | 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 { | func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | ||||
| sampleRate := float64(g.cfg.FM.CompositeRateHz) | sampleRate := float64(g.cfg.FM.CompositeRateHz) | ||||
| if sampleRate <= 0 { | if sampleRate <= 0 { | ||||
| @@ -55,7 +68,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| }) | }) | ||||
| rdsSamples := rdsEnc.Generate(samples) | 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++ { | for i := 0; i < samples; i++ { | ||||
| t := float64(i) / sampleRate | 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 { | 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) | |||||
| } | } | ||||