| @@ -4,6 +4,10 @@ go 1.22 | |||||
| require github.com/jan/fm-rds-tx/internal v0.0.0 | require github.com/jan/fm-rds-tx/internal v0.0.0 | ||||
| require github.com/hajimehoshi/go-mp3 v0.3.4 // indirect | |||||
| require ( | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 // indirect | |||||
| github.com/jfreymuth/oggvorbis v1.0.5 // indirect | |||||
| github.com/jfreymuth/vorbis v1.0.2 // indirect | |||||
| ) | |||||
| replace github.com/jan/fm-rds-tx/internal => ./internal | replace github.com/jan/fm-rds-tx/internal => ./internal | ||||
| @@ -1,4 +1,8 @@ | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= | github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= | ||||
| github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= | github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= | ||||
| github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= | ||||
| github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= | |||||
| github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= | |||||
| github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= | |||||
| github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= | |||||
| golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| @@ -2,4 +2,9 @@ module github.com/jan/fm-rds-tx/internal | |||||
| go 1.21 | go 1.21 | ||||
| require github.com/hajimehoshi/go-mp3 v0.3.4 | |||||
| require ( | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 | |||||
| github.com/jfreymuth/oggvorbis v1.0.5 | |||||
| ) | |||||
| require github.com/jfreymuth/vorbis v1.0.2 // indirect | |||||
| @@ -1,4 +1,8 @@ | |||||
| github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= | github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= | ||||
| github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= | github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= | ||||
| github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= | ||||
| github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= | |||||
| github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= | |||||
| github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= | |||||
| github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= | |||||
| golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| @@ -128,6 +128,34 @@ func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) { | |||||
| ogg := &testDecoder{name: "oggvorbis"} | |||||
| fallback := &testDecoder{name: "ffmpeg"} | |||||
| reg := decoder.NewRegistry() | |||||
| reg.Register("oggvorbis", func() decoder.Decoder { return ogg }) | |||||
| reg.Register("ffmpeg", func() decoder.Decoder { return fallback }) | |||||
| src := New("ice-test", "http://example", nil, ReconnectConfig{}, | |||||
| WithDecoderRegistry(reg), | |||||
| WithDecoderPreference("auto"), | |||||
| ) | |||||
| err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{ | |||||
| ContentType: "audio/ogg", | |||||
| SourceID: "ice-test", | |||||
| }) | |||||
| if err != nil { | |||||
| t.Fatalf("decode: %v", err) | |||||
| } | |||||
| if ogg.called != 1 { | |||||
| t.Fatalf("ogg decoder called %d times", ogg.called) | |||||
| } | |||||
| if fallback.called != 0 { | |||||
| t.Fatalf("fallback should not be called, got %d", fallback.called) | |||||
| } | |||||
| } | |||||
| func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { | func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) { | ||||
| src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback")) | src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback")) | ||||
| if got := src.Descriptor().Codec; got != "ffmpeg" { | if got := src.Descriptor().Codec; got != "ffmpeg" { | ||||
| @@ -4,9 +4,12 @@ import ( | |||||
| "context" | "context" | ||||
| "fmt" | "fmt" | ||||
| "io" | "io" | ||||
| "math" | |||||
| "time" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest" | "github.com/jan/fm-rds-tx/internal/ingest" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest/decoder" | "github.com/jan/fm-rds-tx/internal/ingest/decoder" | ||||
| libvorbis "github.com/jfreymuth/oggvorbis" | |||||
| ) | ) | ||||
| type Decoder struct{} | type Decoder struct{} | ||||
| @@ -15,6 +18,86 @@ func New() *Decoder { return &Decoder{} } | |||||
| func (d *Decoder) Name() string { return "oggvorbis-native" } | func (d *Decoder) Name() string { return "oggvorbis-native" } | ||||
| func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error { | |||||
| return fmt.Errorf("%w: ogg/vorbis native decoder not wired yet", decoder.ErrUnsupported) | |||||
| func (d *Decoder) DecodeStream(ctx context.Context, r io.Reader, meta decoder.StreamMeta, emit func(ingest.PCMChunk) error) error { | |||||
| if r == nil { | |||||
| return fmt.Errorf("%w: ogg/vorbis decoder stream reader is nil", decoder.ErrUnsupported) | |||||
| } | |||||
| if emit == nil { | |||||
| return fmt.Errorf("%w: ogg/vorbis decoder emit callback is nil", decoder.ErrUnsupported) | |||||
| } | |||||
| dec, err := libvorbis.NewReader(r) | |||||
| if err != nil { | |||||
| return fmt.Errorf("%w: ogg/vorbis decoder init: %v", decoder.ErrUnsupported, err) | |||||
| } | |||||
| channels := dec.Channels() | |||||
| if channels <= 0 { | |||||
| if meta.Channels > 0 { | |||||
| channels = meta.Channels | |||||
| } else { | |||||
| return fmt.Errorf("%w: ogg/vorbis decoder invalid channel count", decoder.ErrUnsupported) | |||||
| } | |||||
| } | |||||
| sampleRate := dec.SampleRate() | |||||
| if sampleRate <= 0 { | |||||
| if meta.SampleRateHz > 0 { | |||||
| sampleRate = meta.SampleRateHz | |||||
| } else { | |||||
| sampleRate = 44100 | |||||
| } | |||||
| } | |||||
| const chunkFrames = 1024 | |||||
| buf := make([]float32, chunkFrames*channels) | |||||
| seq := uint64(0) | |||||
| for { | |||||
| select { | |||||
| case <-ctx.Done(): | |||||
| return nil | |||||
| default: | |||||
| } | |||||
| n, readErr := dec.Read(buf) | |||||
| if n > 0 { | |||||
| chunk := ingest.PCMChunk{ | |||||
| Samples: float32ToPCM32(buf[:n]), | |||||
| Channels: channels, | |||||
| SampleRateHz: sampleRate, | |||||
| Sequence: seq, | |||||
| Timestamp: time.Now(), | |||||
| SourceID: meta.SourceID, | |||||
| } | |||||
| if err := emit(chunk); err != nil { | |||||
| return err | |||||
| } | |||||
| seq++ | |||||
| } | |||||
| if readErr != nil { | |||||
| if readErr == io.EOF { | |||||
| return nil | |||||
| } | |||||
| return fmt.Errorf("ogg/vorbis decoder read pcm: %w", readErr) | |||||
| } | |||||
| } | |||||
| } | |||||
| func float32ToPCM32(in []float32) []int32 { | |||||
| out := make([]int32, len(in)) | |||||
| for i, sample := range in { | |||||
| if sample > 1 { | |||||
| sample = 1 | |||||
| } else if sample < -1 { | |||||
| sample = -1 | |||||
| } | |||||
| if sample == -1 { | |||||
| out[i] = math.MinInt32 | |||||
| continue | |||||
| } | |||||
| out[i] = int32(sample * math.MaxInt32) | |||||
| } | |||||
| return out | |||||
| } | } | ||||
| @@ -0,0 +1,60 @@ | |||||
| package oggvorbis | |||||
| import ( | |||||
| "bytes" | |||||
| "context" | |||||
| "errors" | |||||
| "os" | |||||
| "path/filepath" | |||||
| "testing" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest" | |||||
| "github.com/jan/fm-rds-tx/internal/ingest/decoder" | |||||
| ) | |||||
| func TestDecodeStream(t *testing.T) { | |||||
| tonePath := filepath.Join("testdata", "tone_44k_stereo.ogg") | |||||
| data, err := os.ReadFile(tonePath) | |||||
| if err != nil { | |||||
| t.Fatalf("read fixture: %v", err) | |||||
| } | |||||
| var chunks []ingest.PCMChunk | |||||
| d := New() | |||||
| err = d.DecodeStream(context.Background(), bytes.NewReader(data), decoder.StreamMeta{ | |||||
| ContentType: "audio/ogg", | |||||
| SourceID: "ogg-test", | |||||
| }, func(c ingest.PCMChunk) error { | |||||
| chunks = append(chunks, c) | |||||
| return nil | |||||
| }) | |||||
| if err != nil { | |||||
| t.Fatalf("decode: %v", err) | |||||
| } | |||||
| if len(chunks) == 0 { | |||||
| t.Fatal("expected chunks") | |||||
| } | |||||
| if chunks[0].Channels != 2 { | |||||
| t.Fatalf("channels=%d want 2", chunks[0].Channels) | |||||
| } | |||||
| if chunks[0].SampleRateHz != 44100 { | |||||
| t.Fatalf("sampleRate=%d want 44100", chunks[0].SampleRateHz) | |||||
| } | |||||
| if len(chunks[0].Samples) == 0 { | |||||
| t.Fatal("expected samples in first chunk") | |||||
| } | |||||
| } | |||||
| func TestDecodeStreamNilReader(t *testing.T) { | |||||
| err := New().DecodeStream(context.Background(), nil, decoder.StreamMeta{}, func(ingest.PCMChunk) error { return nil }) | |||||
| if !errors.Is(err, decoder.ErrUnsupported) { | |||||
| t.Fatalf("expected unsupported, got %v", err) | |||||
| } | |||||
| } | |||||
| func TestDecodeStreamNilEmit(t *testing.T) { | |||||
| err := New().DecodeStream(context.Background(), bytes.NewReader([]byte("not-ogg")), decoder.StreamMeta{}, nil) | |||||
| if !errors.Is(err, decoder.ErrUnsupported) { | |||||
| t.Fatalf("expected unsupported, got %v", err) | |||||
| } | |||||
| } | |||||