Просмотр исходного кода

ingest: add native mp3 decoder

main
Jan 1 месяц назад
Родитель
Сommit
70dd4ab8b8
7 измененных файлов: 144 добавлений и 2 удалений
  1. +2
    -0
      go.mod
  2. +4
    -0
      go.sum
  3. +2
    -0
      internal/go.mod
  4. +4
    -0
      internal/go.sum
  5. +72
    -2
      internal/ingest/decoder/mp3/decoder.go
  6. +60
    -0
      internal/ingest/decoder/mp3/decoder_test.go
  7. Двоичные данные
      internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3

+ 2
- 0
go.mod Просмотреть файл

@@ -4,4 +4,6 @@ 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

replace github.com/jan/fm-rds-tx/internal => ./internal replace github.com/jan/fm-rds-tx/internal => ./internal

+ 4
- 0
go.sum Просмотреть файл

@@ -0,0 +1,4 @@
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/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

+ 2
- 0
internal/go.mod Просмотреть файл

@@ -1,3 +1,5 @@
module github.com/jan/fm-rds-tx/internal module github.com/jan/fm-rds-tx/internal


go 1.21 go 1.21

require github.com/hajimehoshi/go-mp3 v0.3.4

+ 4
- 0
internal/go.sum Просмотреть файл

@@ -0,0 +1,4 @@
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/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

+ 72
- 2
internal/ingest/decoder/mp3/decoder.go Просмотреть файл

@@ -2,9 +2,12 @@ package mp3


import ( import (
"context" "context"
"encoding/binary"
"fmt" "fmt"
"io" "io"
"time"


gomp3 "github.com/hajimehoshi/go-mp3"
"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"
) )
@@ -15,6 +18,73 @@ func New() *Decoder { return &Decoder{} }


func (d *Decoder) Name() string { return "mp3-native" } func (d *Decoder) Name() string { return "mp3-native" }


func (d *Decoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
return fmt.Errorf("%w: mp3 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: mp3 decoder stream reader is nil", decoder.ErrUnsupported)
}
if emit == nil {
return fmt.Errorf("%w: mp3 decoder emit callback is nil", decoder.ErrUnsupported)
}

dec, err := gomp3.NewDecoder(r)
if err != nil {
return fmt.Errorf("%w: mp3 decoder init: %v", decoder.ErrUnsupported, err)
}

const channels = 2 // go-mp3 always decodes to stereo s16le
sampleRate := dec.SampleRate()
if sampleRate <= 0 {
if meta.SampleRateHz > 0 {
sampleRate = meta.SampleRateHz
} else {
sampleRate = 44100
}
}

const chunkFrames = 1024
const frameBytes = channels * 2
buf := make([]byte, chunkFrames*frameBytes)
seq := uint64(0)

for {
select {
case <-ctx.Done():
return nil
default:
}

n, readErr := io.ReadAtLeast(dec, buf, frameBytes)
if readErr != nil {
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
if n > 0 {
if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("mp3 decoder read pcm: %w", readErr)
}

if err := emitChunk(buf[:n], seq, sampleRate, meta.SourceID, emit); err != nil {
return err
}
seq++
}
}

func emitChunk(data []byte, seq uint64, sampleRate int, sourceID string, emit func(ingest.PCMChunk) error) error {
samples := make([]int32, 0, len(data)/2)
for i := 0; i+1 < len(data); i += 2 {
v := int16(binary.LittleEndian.Uint16(data[i : i+2]))
samples = append(samples, int32(v)<<16)
}
return emit(ingest.PCMChunk{
Samples: samples,
Channels: 2,
SampleRateHz: sampleRate,
Sequence: seq,
Timestamp: time.Now(),
SourceID: sourceID,
})
} }

+ 60
- 0
internal/ingest/decoder/mp3/decoder_test.go Просмотреть файл

@@ -0,0 +1,60 @@
package mp3

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.mp3")
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/mpeg",
SourceID: "mp3-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-mp3")), decoder.StreamMeta{}, nil)
if !errors.Is(err, decoder.ErrUnsupported) {
t.Fatalf("expected unsupported, got %v", err)
}
}

Двоичные данные
internal/ingest/decoder/mp3/testdata/tone_44k_stereo.mp3 Просмотреть файл


Загрузка…
Отмена
Сохранить