🎉 live server seems to be working now
This commit is contained in:
9
third_party/sendspin-go/pkg/audio/decode/decoder.go
vendored
Normal file
9
third_party/sendspin-go/pkg/audio/decode/decoder.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// ABOUTME: Decoder interface definition
|
||||
// ABOUTME: Common interface for all audio decoders
|
||||
package decode
|
||||
|
||||
// Decoder decodes audio in various formats to PCM int32 samples
|
||||
type Decoder interface {
|
||||
Decode(data []byte) ([]int32, error)
|
||||
Close() error
|
||||
}
|
||||
14
third_party/sendspin-go/pkg/audio/decode/doc.go
vendored
Normal file
14
third_party/sendspin-go/pkg/audio/decode/doc.go
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// ABOUTME: Audio decoder package for multiple codec support
|
||||
// ABOUTME: Provides Decoder interface and implementations for PCM, Opus, FLAC
|
||||
// Package decode provides audio decoders for various codecs.
|
||||
//
|
||||
// Supports: PCM (16-bit and 24-bit), Opus, FLAC (stub)
|
||||
//
|
||||
// All decoders implement the Decoder interface and output int32 samples
|
||||
// in 24-bit range for consistent hi-res audio processing.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// decoder, err := decode.NewPCM(format)
|
||||
// samples, err := decoder.Decode(audioData)
|
||||
package decode
|
||||
185
third_party/sendspin-go/pkg/audio/decode/flac.go
vendored
Normal file
185
third_party/sendspin-go/pkg/audio/decode/flac.go
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
// ABOUTME: FLAC streaming decoder using io.Pipe + mewkiz/flac
|
||||
// ABOUTME: Decodes FLAC frames to int32 samples for the playback pipeline
|
||||
package decode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// FLACDecoder decodes streaming FLAC frames to int32 PCM samples. It
|
||||
// bridges chunk-by-chunk network delivery with mewkiz/flac's io.Reader
|
||||
// API using an io.Pipe. A background goroutine reads FLAC frames from
|
||||
// the pipe and pushes decoded samples to an internal channel.
|
||||
type FLACDecoder struct {
|
||||
format audio.Format
|
||||
pipeWriter *io.PipeWriter
|
||||
sampleCh chan []int32
|
||||
errCh chan error
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFLAC(format audio.Format) (Decoder, error) {
|
||||
if format.Codec != "flac" {
|
||||
return nil, fmt.Errorf("invalid codec for FLAC decoder: %s", format.Codec)
|
||||
}
|
||||
if len(format.CodecHeader) == 0 {
|
||||
return nil, fmt.Errorf("FLAC decoder requires CodecHeader (STREAMINFO)")
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
d := &FLACDecoder{
|
||||
format: format,
|
||||
pipeWriter: pw,
|
||||
sampleCh: make(chan []int32, 16),
|
||||
errCh: make(chan error, 1),
|
||||
}
|
||||
|
||||
go d.runDecoder(pr, format.CodecHeader)
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// runDecoder writes the codec header, initializes the FLAC stream, and
|
||||
// loops calling ParseNext to decode frames. Runs in a background
|
||||
// goroutine for the lifetime of the decoder.
|
||||
func (d *FLACDecoder) runDecoder(pr *io.PipeReader, codecHeader []byte) {
|
||||
defer close(d.sampleCh)
|
||||
defer pr.Close()
|
||||
|
||||
// Create a reader that starts with the codec_header, then reads
|
||||
// from the pipe for the frame data.
|
||||
combined := io.MultiReader(bytes.NewReader(codecHeader), pr)
|
||||
|
||||
stream, err := flac.New(combined)
|
||||
if err != nil {
|
||||
select {
|
||||
case d.errCh <- fmt.Errorf("flac.New: %w", err):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
channels := int(stream.Info.NChannels)
|
||||
bitDepth := int(stream.Info.BitsPerSample)
|
||||
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return
|
||||
}
|
||||
// Pipe closed = normal shutdown
|
||||
if err.Error() == "io: read/write on closed pipe" {
|
||||
return
|
||||
}
|
||||
log.Printf("FLAC ParseNext error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
blockSize := int(frame.BlockSize)
|
||||
samples := make([]int32, blockSize*channels)
|
||||
idx := 0
|
||||
|
||||
for i := 0; i < blockSize; i++ {
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample := frame.Subframes[ch].Samples[i]
|
||||
|
||||
// Convert to 24-bit int32 range (same logic as FLACSource
|
||||
// in internal/server/audio_source.go)
|
||||
var converted int32
|
||||
if bitDepth == 16 {
|
||||
converted = sample << 8
|
||||
} else if bitDepth == 24 {
|
||||
converted = sample
|
||||
} else {
|
||||
shift := bitDepth - 24
|
||||
if shift > 0 {
|
||||
converted = sample >> shift
|
||||
} else {
|
||||
converted = sample << -shift
|
||||
}
|
||||
}
|
||||
samples[idx] = converted
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
d.sampleCh <- samples
|
||||
}
|
||||
}
|
||||
|
||||
func (d *FLACDecoder) Decode(data []byte) ([]int32, error) {
|
||||
d.mu.Lock()
|
||||
if d.closed {
|
||||
d.mu.Unlock()
|
||||
return nil, fmt.Errorf("decoder closed")
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Check for initialization errors from the background goroutine.
|
||||
select {
|
||||
case err := <-d.errCh:
|
||||
return nil, err
|
||||
default:
|
||||
}
|
||||
|
||||
// Write the chunk data to the pipe. This feeds the background
|
||||
// goroutine's ParseNext loop.
|
||||
_, err := d.pipeWriter.Write(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write to FLAC pipe: %w", err)
|
||||
}
|
||||
|
||||
// Return at most one frame per call. The server guarantees 1 chunk
|
||||
// = 1 FLAC frame (encoder block size matches ChunkDurationMs), so
|
||||
// each Decode call should yield exactly one frame's samples.
|
||||
//
|
||||
// Draining all queued frames into one return value would tag every
|
||||
// frame with the current chunk's timestamp — collapsing per-frame
|
||||
// timing into a single PlayAt, which both breaks multi-room sync
|
||||
// and hands the playback ring buffer multiples of its capacity in
|
||||
// one Write (see issue: "ring buffer full, dropped N samples").
|
||||
//
|
||||
// If the parsing goroutine has raced ahead and queued more than
|
||||
// one frame, the extras stay buffered on sampleCh and surface on
|
||||
// subsequent Decode calls (one per call). The frame may also span
|
||||
// multiple chunks; in that case the goroutine has not produced
|
||||
// anything yet and we return (nil, nil) — the receiver skips the
|
||||
// chunk and the frame surfaces on a later Decode.
|
||||
select {
|
||||
case samples, ok := <-d.sampleCh:
|
||||
if !ok {
|
||||
return nil, io.EOF
|
||||
}
|
||||
return samples, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *FLACDecoder) Close() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.closed {
|
||||
return nil
|
||||
}
|
||||
d.closed = true
|
||||
|
||||
d.pipeWriter.Close()
|
||||
|
||||
// Drain remaining samples so the goroutine can exit.
|
||||
for range d.sampleCh {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
157
third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go
vendored
Normal file
157
third_party/sendspin-go/pkg/audio/decode/flac_integration_test.go
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
// ABOUTME: Integration test for FLAC decoder using real FLAC files
|
||||
// ABOUTME: Skipped when no FLAC fixture is available
|
||||
package decode
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// findFLACFixture looks for a FLAC test file in known locations.
|
||||
func findFLACFixture() string {
|
||||
candidates := []string{
|
||||
// conformance repo fixture (relative to pkg/audio/decode/)
|
||||
"../../../conformance/repos/sendspin-cli/tests/fixtures/almost_silent.flac",
|
||||
"../../../../conformance/repos/sendspin-cli/tests/fixtures/almost_silent.flac",
|
||||
"../../../conformance/fixtures/almost-silent-5s-48000-2-24.flac",
|
||||
"../../../../conformance/fixtures/almost-silent-5s-48000-2-24.flac",
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitFLACFile reads a FLAC file and returns the codec_header (fLaC +
|
||||
// all metadata blocks) and the raw frame data (everything after metadata).
|
||||
func splitFLACFile(path string) (codecHeader []byte, frameData []byte, sampleRate, channels, bitDepth int, totalSamples uint64, err error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, 0, 0, err
|
||||
}
|
||||
if len(raw) < 4 || string(raw[:4]) != "fLaC" {
|
||||
return nil, nil, 0, 0, 0, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
// Parse metadata blocks to find where frame data starts.
|
||||
offset := 4
|
||||
for {
|
||||
if offset+4 > len(raw) {
|
||||
return nil, nil, 0, 0, 0, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
header := raw[offset : offset+4]
|
||||
lastBlock := header[0]&0x80 != 0
|
||||
blockLength := int(header[1])<<16 | int(header[2])<<8 | int(header[3])
|
||||
offset += 4 + blockLength
|
||||
if lastBlock {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
codecHeader = make([]byte, offset)
|
||||
copy(codecHeader, raw[:offset])
|
||||
|
||||
// Get stream info for verification
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
stream, err := flac.New(f)
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0, 0, 0, err
|
||||
}
|
||||
|
||||
return codecHeader, raw[offset:], int(stream.Info.SampleRate), int(stream.Info.NChannels), int(stream.Info.BitsPerSample), stream.Info.NSamples, nil
|
||||
}
|
||||
|
||||
func TestFLACDecoder_RealFile(t *testing.T) {
|
||||
fixturePath := findFLACFixture()
|
||||
if fixturePath == "" {
|
||||
t.Skip("no FLAC fixture found — skipping integration test")
|
||||
}
|
||||
|
||||
codecHeader, frameData, sampleRate, channels, bitDepth, totalSamples, err := splitFLACFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("splitFLACFile: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fixture: %s", filepath.Base(fixturePath))
|
||||
t.Logf("Format: %dHz %dch %dbit, %d total samples", sampleRate, channels, bitDepth, totalSamples)
|
||||
t.Logf("Codec header: %d bytes, frame data: %d bytes", len(codecHeader), len(frameData))
|
||||
|
||||
format := audio.Format{
|
||||
Codec: "flac",
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitDepth: bitDepth,
|
||||
CodecHeader: codecHeader,
|
||||
}
|
||||
|
||||
dec, err := NewFLAC(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFLAC: %v", err)
|
||||
}
|
||||
|
||||
// The FLACDecoder uses an io.Pipe internally: Decode() writes to the
|
||||
// pipe then drains decoded samples from a channel. With a full file's
|
||||
// worth of frame data, the internal sample channel (buffer 16) fills
|
||||
// before the pipe write completes, causing deadlock in a single
|
||||
// goroutine. To test the full pipeline we access the unexported fields
|
||||
// directly: write frame data to the pipe from a goroutine, then
|
||||
// collect decoded samples from the channel until it closes.
|
||||
flacDec := dec.(*FLACDecoder)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, writeErr := flacDec.pipeWriter.Write(frameData)
|
||||
if writeErr != nil {
|
||||
t.Errorf("pipe write: %v", writeErr)
|
||||
}
|
||||
// Close the writer so the decoder sees EOF and stops.
|
||||
flacDec.pipeWriter.Close()
|
||||
}()
|
||||
|
||||
var allSamples []int32
|
||||
for samples := range flacDec.sampleCh {
|
||||
allSamples = append(allSamples, samples...)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(allSamples) == 0 {
|
||||
t.Fatal("expected decoded samples, got empty")
|
||||
}
|
||||
|
||||
expectedSamples := int(totalSamples) * channels
|
||||
t.Logf("Decoded %d samples (expected ~%d)", len(allSamples), expectedSamples)
|
||||
|
||||
// Verify sample count is in the right ballpark.
|
||||
if len(allSamples) < expectedSamples/2 {
|
||||
t.Errorf("decoded far fewer samples than expected: %d vs %d", len(allSamples), expectedSamples)
|
||||
}
|
||||
|
||||
// Verify samples aren't all zero (sanity check — the fixture is
|
||||
// "almost silent" but should have some non-zero values).
|
||||
nonZero := 0
|
||||
for _, s := range allSamples {
|
||||
if s != 0 {
|
||||
nonZero++
|
||||
}
|
||||
}
|
||||
t.Logf("Non-zero samples: %d / %d", nonZero, len(allSamples))
|
||||
}
|
||||
128
third_party/sendspin-go/pkg/audio/decode/flac_test.go
vendored
Normal file
128
third_party/sendspin-go/pkg/audio/decode/flac_test.go
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
// ABOUTME: Tests for the FLAC streaming decoder
|
||||
// ABOUTME: Lifecycle tests — create with header, error without, close cleanly
|
||||
package decode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
// buildMinimalFLACHeader creates a syntactically valid FLAC codec_header
|
||||
// (fLaC marker + STREAMINFO metadata block) for testing decoder lifecycle.
|
||||
// The header is valid enough for mewkiz/flac to parse STREAMINFO, though
|
||||
// no frames follow it.
|
||||
func buildMinimalFLACHeader(sampleRate, channels, bitDepth, blockSize int) []byte {
|
||||
header := make([]byte, 0, 42)
|
||||
|
||||
// fLaC marker
|
||||
header = append(header, 'f', 'L', 'a', 'C')
|
||||
|
||||
// Metadata block header: last=1 (0x80), type=0 (STREAMINFO), length=34
|
||||
header = append(header, 0x80, 0x00, 0x00, 34)
|
||||
|
||||
// STREAMINFO (34 bytes)
|
||||
streamInfo := make([]byte, 34)
|
||||
// min block size (bytes 0-1)
|
||||
streamInfo[0] = byte(blockSize >> 8)
|
||||
streamInfo[1] = byte(blockSize)
|
||||
// max block size (bytes 2-3)
|
||||
streamInfo[2] = byte(blockSize >> 8)
|
||||
streamInfo[3] = byte(blockSize)
|
||||
// min/max frame size (bytes 4-9): 0 = unknown
|
||||
// sample rate (20 bits) | channels-1 (3 bits) | bps-1 (5 bits) | total samples (36 bits)
|
||||
// packed into bytes 10-17
|
||||
packed := uint64(sampleRate)<<44 | uint64(channels-1)<<41 | uint64(bitDepth-1)<<36
|
||||
for i := 0; i < 8; i++ {
|
||||
streamInfo[10+i] = byte(packed >> (56 - 8*i))
|
||||
}
|
||||
// MD5 (bytes 18-33): zeros
|
||||
|
||||
header = append(header, streamInfo...)
|
||||
return header
|
||||
}
|
||||
|
||||
func TestNewFLAC_RequiresCodecHeader(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "flac",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
}
|
||||
_, err := NewFLAC(format)
|
||||
if err == nil {
|
||||
t.Error("expected error when CodecHeader is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFLAC_InvalidCodec(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
}
|
||||
_, err := NewFLAC(format)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid codec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFLAC_ValidHeader(t *testing.T) {
|
||||
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
|
||||
format := audio.Format{
|
||||
Codec: "flac",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
CodecHeader: codecHeader,
|
||||
}
|
||||
dec, err := NewFLAC(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFLAC: %v", err)
|
||||
}
|
||||
defer dec.Close()
|
||||
}
|
||||
|
||||
func TestFLACDecoder_CloseWithoutDecode(t *testing.T) {
|
||||
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
|
||||
format := audio.Format{
|
||||
Codec: "flac",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
CodecHeader: codecHeader,
|
||||
}
|
||||
dec, err := NewFLAC(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFLAC: %v", err)
|
||||
}
|
||||
if err := dec.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
// Double close should not panic
|
||||
if err := dec.Close(); err != nil {
|
||||
t.Errorf("double Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFLACDecoder_DecodeAfterClose(t *testing.T) {
|
||||
codecHeader := buildMinimalFLACHeader(48000, 2, 24, 4096)
|
||||
format := audio.Format{
|
||||
Codec: "flac",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
CodecHeader: codecHeader,
|
||||
}
|
||||
dec, err := NewFLAC(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFLAC: %v", err)
|
||||
}
|
||||
dec.Close()
|
||||
|
||||
_, err = dec.Decode([]byte{0x00})
|
||||
if err == nil {
|
||||
t.Error("expected error after Close")
|
||||
}
|
||||
}
|
||||
52
third_party/sendspin-go/pkg/audio/decode/opus.go
vendored
Normal file
52
third_party/sendspin-go/pkg/audio/decode/opus.go
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// ABOUTME: Opus audio decoder
|
||||
// ABOUTME: Decodes Opus audio to int32 samples
|
||||
package decode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"gopkg.in/hraban/opus.v2"
|
||||
)
|
||||
|
||||
type OpusDecoder struct {
|
||||
decoder *opus.Decoder
|
||||
format audio.Format
|
||||
pcm16Buf []int16 // reusable decode buffer to avoid per-frame allocation
|
||||
}
|
||||
|
||||
func NewOpus(format audio.Format) (Decoder, error) {
|
||||
if format.Codec != "opus" {
|
||||
return nil, fmt.Errorf("invalid codec for Opus decoder: %s", format.Codec)
|
||||
}
|
||||
|
||||
dec, err := opus.NewDecoder(format.SampleRate, format.Channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create opus decoder: %w", err)
|
||||
}
|
||||
|
||||
return &OpusDecoder{
|
||||
decoder: dec,
|
||||
format: format,
|
||||
pcm16Buf: make([]int16, 5760*format.Channels),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *OpusDecoder) Decode(data []byte) ([]int32, error) {
|
||||
// Reuse pre-allocated int16 buffer for decode (avoids 23KB alloc per frame)
|
||||
n, err := d.decoder.Decode(data, d.pcm16Buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opus decode failed: %w", err)
|
||||
}
|
||||
|
||||
actualSamples := n * d.format.Channels
|
||||
pcm32 := make([]int32, actualSamples)
|
||||
for i := 0; i < actualSamples; i++ {
|
||||
pcm32[i] = audio.SampleFromInt16(d.pcm16Buf[i])
|
||||
}
|
||||
return pcm32, nil
|
||||
}
|
||||
|
||||
func (d *OpusDecoder) Close() error {
|
||||
return nil
|
||||
}
|
||||
110
third_party/sendspin-go/pkg/audio/decode/opus_test.go
vendored
Normal file
110
third_party/sendspin-go/pkg/audio/decode/opus_test.go
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
// ABOUTME: Tests for Opus decoder
|
||||
// ABOUTME: Tests Opus decoder creation and validation
|
||||
package decode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestNewOpus(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
if decoder == nil {
|
||||
t.Fatal("expected decoder to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpus_InvalidCodec(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewOpus(format)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid codec, got nil")
|
||||
}
|
||||
|
||||
if decoder != nil {
|
||||
t.Fatal("expected decoder to be nil for invalid codec")
|
||||
}
|
||||
|
||||
expectedError := "invalid codec for Opus decoder: pcm"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("expected error %q, got %q", expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpus_MonoChannel(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 1,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create mono decoder: %v", err)
|
||||
}
|
||||
|
||||
if decoder == nil {
|
||||
t.Fatal("expected decoder to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpus_InvalidSampleRate(t *testing.T) {
|
||||
// Opus library may reject invalid sample rates
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 44100, // Opus typically uses 48000
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
// We expect this might fail at the opus library level
|
||||
// This test documents the behavior
|
||||
decoder, err := NewOpus(format)
|
||||
|
||||
// Either it succeeds (opus lib is flexible) or fails (opus lib is strict)
|
||||
// Both are valid outcomes, we just verify proper error handling
|
||||
if err != nil && decoder != nil {
|
||||
t.Fatal("if error is returned, decoder must be nil")
|
||||
}
|
||||
if err == nil && decoder == nil {
|
||||
t.Fatal("if no error, decoder must not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpusClose(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
err = decoder.Close()
|
||||
if err != nil {
|
||||
t.Errorf("expected Close to succeed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
52
third_party/sendspin-go/pkg/audio/decode/pcm.go
vendored
Normal file
52
third_party/sendspin-go/pkg/audio/decode/pcm.go
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// ABOUTME: PCM audio decoder
|
||||
// ABOUTME: Decodes 16-bit and 24-bit PCM audio to int32 samples
|
||||
package decode
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
type PCMDecoder struct {
|
||||
bitDepth int
|
||||
}
|
||||
|
||||
func NewPCM(format audio.Format) (Decoder, error) {
|
||||
if format.Codec != "pcm" {
|
||||
return nil, fmt.Errorf("invalid codec for PCM decoder: %s", format.Codec)
|
||||
}
|
||||
|
||||
if format.BitDepth != 16 && format.BitDepth != 24 {
|
||||
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
|
||||
}
|
||||
|
||||
return &PCMDecoder{
|
||||
bitDepth: format.BitDepth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *PCMDecoder) Decode(data []byte) ([]int32, error) {
|
||||
if d.bitDepth == 24 {
|
||||
numSamples := len(data) / 3
|
||||
samples := make([]int32, numSamples)
|
||||
for i := 0; i < numSamples; i++ {
|
||||
b := [3]byte{data[i*3], data[i*3+1], data[i*3+2]}
|
||||
samples[i] = audio.SampleFrom24Bit(b)
|
||||
}
|
||||
return samples, nil
|
||||
} else {
|
||||
numSamples := len(data) / 2
|
||||
samples := make([]int32, numSamples)
|
||||
for i := 0; i < numSamples; i++ {
|
||||
sample16 := int16(binary.LittleEndian.Uint16(data[i*2:]))
|
||||
samples[i] = audio.SampleFromInt16(sample16)
|
||||
}
|
||||
return samples, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *PCMDecoder) Close() error {
|
||||
return nil
|
||||
}
|
||||
171
third_party/sendspin-go/pkg/audio/decode/pcm_test.go
vendored
Normal file
171
third_party/sendspin-go/pkg/audio/decode/pcm_test.go
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
// ABOUTME: Tests for PCM decoder
|
||||
// ABOUTME: Tests 16-bit and 24-bit PCM decoding
|
||||
package decode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestNewPCM(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
if decoder == nil {
|
||||
t.Fatal("expected decoder to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMDecode16Bit(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
input := []byte{0x00, 0x01, 0x02, 0x03}
|
||||
output, err := decoder.Decode(input)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSamples := len(input) / 2
|
||||
if len(output) != expectedSamples {
|
||||
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
|
||||
}
|
||||
|
||||
// Verify little-endian conversion with 24-bit scaling
|
||||
// 0x00, 0x01 -> 0x0100 = 256 (16-bit) -> 256<<8 = 65536 (24-bit)
|
||||
// 0x02, 0x03 -> 0x0302 = 770 (16-bit) -> 770<<8 = 197120 (24-bit)
|
||||
expected0 := int32(256 << 8)
|
||||
if output[0] != expected0 {
|
||||
t.Errorf("expected first sample %d, got %d", expected0, output[0])
|
||||
}
|
||||
expected1 := int32(770 << 8)
|
||||
if output[1] != expected1 {
|
||||
t.Errorf("expected second sample %d, got %d", expected1, output[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMDecode24Bit(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 192000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
input := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
output, err := decoder.Decode(input)
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSamples := len(input) / 3
|
||||
if len(output) != expectedSamples {
|
||||
t.Errorf("expected %d samples, got %d", expectedSamples, len(output))
|
||||
}
|
||||
|
||||
// Verify 24-bit little-endian conversion
|
||||
// 0x00, 0x01, 0x02 -> 0x020100 = 131328
|
||||
expected0 := int32(0x020100)
|
||||
if output[0] != expected0 {
|
||||
t.Errorf("expected first sample %d, got %d", expected0, output[0])
|
||||
}
|
||||
|
||||
// 0x03, 0x04, 0x05 -> 0x050403 = 328707
|
||||
expected1 := int32(0x050403)
|
||||
if output[1] != expected1 {
|
||||
t.Errorf("expected second sample %d, got %d", expected1, output[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPCM_InvalidCodec(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid codec, got nil")
|
||||
}
|
||||
|
||||
if decoder != nil {
|
||||
t.Fatal("expected decoder to be nil for invalid codec")
|
||||
}
|
||||
|
||||
expectedError := "invalid codec for PCM decoder: opus"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("expected error %q, got %q", expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPCM_UnsupportedBitDepth(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 32,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported bit depth, got nil")
|
||||
}
|
||||
|
||||
if decoder != nil {
|
||||
t.Fatal("expected decoder to be nil for unsupported bit depth")
|
||||
}
|
||||
|
||||
expectedError := "unsupported bit depth: 32 (supported: 16, 24)"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("expected error %q, got %q", expectedError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMDecode_EmptyInput(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
decoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create decoder: %v", err)
|
||||
}
|
||||
|
||||
output, err := decoder.Decode([]byte{})
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed with empty input: %v", err)
|
||||
}
|
||||
|
||||
if len(output) != 0 {
|
||||
t.Errorf("expected 0 samples from empty input, got %d", len(output))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user