Sfoglia il codice sorgente

feat: add wav file source for offline pipeline

tags/v0.4.0-pre
Jan Svabenik 1 mese fa
parent
commit
d810e2a6c7
4 ha cambiato i file con 131 aggiunte e 2 eliminazioni
  1. +6
    -0
      docs/README.md
  2. +74
    -0
      internal/audio/wav.go
  3. +32
    -0
      internal/audio/wav_test.go
  4. +19
    -2
      internal/offline/generator.go

+ 6
- 0
docs/README.md Vedi File

@@ -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:


+ 74
- 0
internal/audio/wav.go Vedi File

@@ -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()
}

+ 32
- 0
internal/audio/wav_test.go Vedi File

@@ -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()
}

+ 19
- 2
internal/offline/generator.go Vedi File

@@ -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)
}

Loading…
Annulla
Salva