🎉 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))
|
||||
}
|
||||
}
|
||||
24
third_party/sendspin-go/pkg/audio/doc.go
vendored
Normal file
24
third_party/sendspin-go/pkg/audio/doc.go
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// ABOUTME: Audio fundamentals package providing core types and utilities
|
||||
// ABOUTME: Defines Format, Buffer types and sample conversion functions
|
||||
// Package audio provides fundamental audio types and utilities for hi-res audio processing.
|
||||
//
|
||||
// This package defines core types used throughout the sendspin library:
|
||||
// - Format: Describes audio stream format (codec, sample rate, channels, bit depth)
|
||||
// - Buffer: Represents decoded PCM audio with timestamp information
|
||||
//
|
||||
// It also provides utilities for converting between different sample formats:
|
||||
// - 16-bit ↔ 24-bit conversions
|
||||
// - int32 ↔ packed byte conversions
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// format := audio.Format{
|
||||
// Codec: "pcm",
|
||||
// SampleRate: 192000,
|
||||
// Channels: 2,
|
||||
// BitDepth: 24,
|
||||
// }
|
||||
//
|
||||
// // Convert 16-bit sample to 24-bit range
|
||||
// sample24 := audio.SampleFromInt16(sample16)
|
||||
package audio
|
||||
14
third_party/sendspin-go/pkg/audio/encode/doc.go
vendored
Normal file
14
third_party/sendspin-go/pkg/audio/encode/doc.go
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// ABOUTME: Audio encoder package for encoding PCM to various formats
|
||||
// ABOUTME: Provides Encoder interface and implementations for PCM, Opus
|
||||
// Package encode provides audio encoders for various codecs.
|
||||
//
|
||||
// Supports: PCM (16-bit and 24-bit), Opus
|
||||
//
|
||||
// All encoders accept int32 samples in 24-bit range and encode
|
||||
// to wire format.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// encoder, err := encode.NewPCM(format)
|
||||
// data, err := encoder.Encode(samples)
|
||||
package encode
|
||||
9
third_party/sendspin-go/pkg/audio/encode/encoder.go
vendored
Normal file
9
third_party/sendspin-go/pkg/audio/encode/encoder.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// ABOUTME: Encoder interface definition
|
||||
// ABOUTME: Common interface for all audio encoders
|
||||
package encode
|
||||
|
||||
// Encoder encodes PCM int32 samples to various formats
|
||||
type Encoder interface {
|
||||
Encode(samples []int32) ([]byte, error)
|
||||
Close() error
|
||||
}
|
||||
64
third_party/sendspin-go/pkg/audio/encode/opus.go
vendored
Normal file
64
third_party/sendspin-go/pkg/audio/encode/opus.go
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
// ABOUTME: Opus audio encoder
|
||||
// ABOUTME: Encodes int32 samples to Opus bytes
|
||||
package encode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"gopkg.in/hraban/opus.v2"
|
||||
)
|
||||
|
||||
type OpusEncoder struct {
|
||||
encoder *opus.Encoder
|
||||
sampleRate int
|
||||
channels int
|
||||
frameSize int
|
||||
pcmBuf []int16 // reusable conversion buffer
|
||||
outBuf []byte // reusable encode output buffer
|
||||
}
|
||||
|
||||
func NewOpus(format audio.Format) (Encoder, error) {
|
||||
if format.Codec != "opus" {
|
||||
return nil, fmt.Errorf("invalid codec for Opus encoder: %s", format.Codec)
|
||||
}
|
||||
|
||||
encoder, err := opus.NewEncoder(format.SampleRate, format.Channels, opus.AppAudio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create opus encoder: %w", err)
|
||||
}
|
||||
|
||||
// Opus frame size depends on sample rate
|
||||
frameSize := format.SampleRate / 50 // 20ms frame
|
||||
|
||||
return &OpusEncoder{
|
||||
encoder: encoder,
|
||||
sampleRate: format.SampleRate,
|
||||
channels: format.Channels,
|
||||
frameSize: frameSize,
|
||||
pcmBuf: make([]int16, frameSize*format.Channels),
|
||||
outBuf: make([]byte, 4000),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *OpusEncoder) Encode(samples []int32) ([]byte, error) {
|
||||
if len(samples) > len(e.pcmBuf) {
|
||||
e.pcmBuf = make([]int16, len(samples))
|
||||
}
|
||||
|
||||
pcm := e.pcmBuf[:len(samples)]
|
||||
for i, sample := range samples {
|
||||
pcm[i] = audio.SampleToInt16(sample)
|
||||
}
|
||||
|
||||
n, err := e.encoder.Encode(pcm, e.outBuf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opus encode error: %w", err)
|
||||
}
|
||||
|
||||
return append([]byte(nil), e.outBuf[:n]...), nil
|
||||
}
|
||||
|
||||
func (e *OpusEncoder) Close() error {
|
||||
return nil
|
||||
}
|
||||
159
third_party/sendspin-go/pkg/audio/encode/opus_test.go
vendored
Normal file
159
third_party/sendspin-go/pkg/audio/encode/opus_test.go
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
// ABOUTME: Unit tests for Opus encoder
|
||||
// ABOUTME: Tests Opus encoding functionality
|
||||
package encode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestNewOpus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format audio.Format
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid Opus 48kHz stereo",
|
||||
format: audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid Opus 48kHz mono",
|
||||
format: audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 1,
|
||||
BitDepth: 16,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid codec",
|
||||
format: audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid codec",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encoder, err := NewOpus(tt.format)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("NewOpus() expected error, got nil")
|
||||
} else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("NewOpus() error = %v, want error containing %v", err, tt.errContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("NewOpus() unexpected error = %v", err)
|
||||
}
|
||||
if encoder == nil {
|
||||
t.Errorf("NewOpus() returned nil encoder")
|
||||
}
|
||||
if encoder != nil {
|
||||
encoder.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpusEncoder_Encode(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
encoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpus() failed: %v", err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
|
||||
// 20ms frame at 48kHz = 960 samples per channel
|
||||
frameSize := 48000 / 50 // 20ms
|
||||
samples := make([]int32, frameSize*2) // stereo
|
||||
|
||||
for i := 0; i < len(samples); i++ {
|
||||
samples[i] = int32((i % 1000) * 8388) // Simple pattern
|
||||
}
|
||||
|
||||
output, err := encoder.Encode(samples)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() failed: %v", err)
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
t.Errorf("Encode() returned empty output")
|
||||
}
|
||||
if len(output) > 4000 {
|
||||
t.Errorf("Encode() output size %d exceeds max Opus packet size 4000", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpusEncoder_EncodeSilence(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
encoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpus() failed: %v", err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
|
||||
// 20ms frame at 48kHz = 960 samples per channel
|
||||
frameSize := 48000 / 50 // 20ms
|
||||
samples := make([]int32, frameSize*2) // stereo, all zeros
|
||||
|
||||
output, err := encoder.Encode(samples)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() failed: %v", err)
|
||||
}
|
||||
|
||||
// Even silence should produce valid Opus packets
|
||||
if len(output) == 0 {
|
||||
t.Errorf("Encode() returned empty output for silence")
|
||||
}
|
||||
if len(output) > 4000 {
|
||||
t.Errorf("Encode() output size %d exceeds max Opus packet size 4000", len(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpusEncoder_Close(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
encoder, err := NewOpus(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpus() failed: %v", err)
|
||||
}
|
||||
|
||||
err = encoder.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
52
third_party/sendspin-go/pkg/audio/encode/pcm.go
vendored
Normal file
52
third_party/sendspin-go/pkg/audio/encode/pcm.go
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
// ABOUTME: PCM audio encoder
|
||||
// ABOUTME: Encodes int32 samples to 16-bit or 24-bit PCM bytes
|
||||
package encode
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
type PCMEncoder struct {
|
||||
bitDepth int
|
||||
}
|
||||
|
||||
func NewPCM(format audio.Format) (Encoder, error) {
|
||||
if format.Codec != "pcm" {
|
||||
return nil, fmt.Errorf("invalid codec for PCM encoder: %s", format.Codec)
|
||||
}
|
||||
|
||||
if format.BitDepth != 16 && format.BitDepth != 24 {
|
||||
return nil, fmt.Errorf("unsupported bit depth: %d (supported: 16, 24)", format.BitDepth)
|
||||
}
|
||||
|
||||
return &PCMEncoder{
|
||||
bitDepth: format.BitDepth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *PCMEncoder) Encode(samples []int32) ([]byte, error) {
|
||||
if e.bitDepth == 24 {
|
||||
output := make([]byte, len(samples)*3)
|
||||
for i, sample := range samples {
|
||||
bytes := audio.SampleTo24Bit(sample)
|
||||
output[i*3] = bytes[0]
|
||||
output[i*3+1] = bytes[1]
|
||||
output[i*3+2] = bytes[2]
|
||||
}
|
||||
return output, nil
|
||||
} else {
|
||||
output := make([]byte, len(samples)*2)
|
||||
for i, sample := range samples {
|
||||
sample16 := audio.SampleToInt16(sample)
|
||||
binary.LittleEndian.PutUint16(output[i*2:], uint16(sample16))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *PCMEncoder) Close() error {
|
||||
return nil
|
||||
}
|
||||
201
third_party/sendspin-go/pkg/audio/encode/pcm_test.go
vendored
Normal file
201
third_party/sendspin-go/pkg/audio/encode/pcm_test.go
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
// ABOUTME: Unit tests for PCM encoder
|
||||
// ABOUTME: Tests 16-bit and 24-bit PCM encoding
|
||||
package encode
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestNewPCM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format audio.Format
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid 16-bit PCM",
|
||||
format: audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid 24-bit PCM",
|
||||
format: audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid codec",
|
||||
format: audio.Format{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid codec",
|
||||
},
|
||||
{
|
||||
name: "unsupported bit depth",
|
||||
format: audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 32,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "unsupported bit depth",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encoder, err := NewPCM(tt.format)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("NewPCM() expected error, got nil")
|
||||
} else if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("NewPCM() error = %v, want error containing %v", err, tt.errContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("NewPCM() unexpected error = %v", err)
|
||||
}
|
||||
if encoder == nil {
|
||||
t.Errorf("NewPCM() returned nil encoder")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMEncoder_Encode16Bit(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
encoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPCM() failed: %v", err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
|
||||
samples := []int32{
|
||||
0, // silence
|
||||
0x7FFF00, // max positive 16-bit (left-justified in 24-bit)
|
||||
-0x800000, // max negative 16-bit (left-justified in 24-bit)
|
||||
0x123400, // arbitrary positive value
|
||||
-0x567800, // arbitrary negative value
|
||||
}
|
||||
|
||||
output, err := encoder.Encode(samples)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSize := len(samples) * 2
|
||||
if len(output) != expectedSize {
|
||||
t.Errorf("Encode() output size = %d, want %d", len(output), expectedSize)
|
||||
}
|
||||
|
||||
for i, sample := range samples {
|
||||
expected := audio.SampleToInt16(sample)
|
||||
actual := int16(binary.LittleEndian.Uint16(output[i*2:]))
|
||||
if actual != expected {
|
||||
t.Errorf("Sample %d: got %d, want %d", i, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMEncoder_Encode24Bit(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 24,
|
||||
}
|
||||
|
||||
encoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPCM() failed: %v", err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
|
||||
samples := []int32{
|
||||
0, // silence
|
||||
0x7FFFFF, // max positive 24-bit
|
||||
-0x800000, // max negative 24-bit
|
||||
0x123456, // arbitrary positive value
|
||||
-0x567890, // arbitrary negative value
|
||||
}
|
||||
|
||||
output, err := encoder.Encode(samples)
|
||||
if err != nil {
|
||||
t.Fatalf("Encode() failed: %v", err)
|
||||
}
|
||||
|
||||
expectedSize := len(samples) * 3
|
||||
if len(output) != expectedSize {
|
||||
t.Errorf("Encode() output size = %d, want %d", len(output), expectedSize)
|
||||
}
|
||||
|
||||
for i, sample := range samples {
|
||||
expected := audio.SampleTo24Bit(sample)
|
||||
actual := [3]byte{
|
||||
output[i*3],
|
||||
output[i*3+1],
|
||||
output[i*3+2],
|
||||
}
|
||||
if actual != expected {
|
||||
t.Errorf("Sample %d: got %v, want %v", i, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMEncoder_Close(t *testing.T) {
|
||||
format := audio.Format{
|
||||
Codec: "pcm",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
encoder, err := NewPCM(format)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPCM() failed: %v", err)
|
||||
}
|
||||
|
||||
err = encoder.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0))
|
||||
}
|
||||
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
13
third_party/sendspin-go/pkg/audio/output/doc.go
vendored
Normal file
13
third_party/sendspin-go/pkg/audio/output/doc.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// ABOUTME: Audio output package for playing audio
|
||||
// ABOUTME: Provides Output interface with malgo implementation
|
||||
// Package output provides audio playback interfaces.
|
||||
//
|
||||
// Currently supports:
|
||||
// - malgo (miniaudio): 16/24/32-bit output, format re-initialization supported
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// out := output.NewMalgo()
|
||||
// err := out.Open(192000, 2, 24) // 192kHz, stereo, 24-bit
|
||||
// err = out.Write(samples)
|
||||
package output
|
||||
510
third_party/sendspin-go/pkg/audio/output/malgo.go
vendored
Normal file
510
third_party/sendspin-go/pkg/audio/output/malgo.go
vendored
Normal file
@@ -0,0 +1,510 @@
|
||||
// ABOUTME: Malgo-based audio output implementation with 24-bit support
|
||||
// ABOUTME: Uses miniaudio library via malgo for true hi-res audio playback
|
||||
package output
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
"github.com/gen2brain/malgo"
|
||||
)
|
||||
|
||||
// PlaybackDevice describes a playback endpoint discoverable via miniaudio.
|
||||
// Returned by ListPlaybackDevices and used to select a specific device when
|
||||
// constructing a Malgo output.
|
||||
type PlaybackDevice struct {
|
||||
Name string
|
||||
IsDefault bool
|
||||
ID malgo.DeviceID
|
||||
}
|
||||
|
||||
type Malgo struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
malgoCtx *malgo.AllocatedContext
|
||||
device *malgo.Device
|
||||
deviceName string // empty = use default
|
||||
sampleRate int
|
||||
channels int
|
||||
bitDepth int
|
||||
volume int
|
||||
muted bool
|
||||
ready bool
|
||||
|
||||
// Ring buffer for callback-based playback
|
||||
ringBuffer *RingBuffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// RingBuffer provides thread-safe circular buffer for audio samples
|
||||
type RingBuffer struct {
|
||||
buffer []int32
|
||||
readPos int
|
||||
writePos int
|
||||
size int
|
||||
count int // Number of samples currently in buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRingBuffer creates a ring buffer with given capacity (in samples)
|
||||
func NewRingBuffer(capacity int) *RingBuffer {
|
||||
return &RingBuffer{
|
||||
buffer: make([]int32, capacity),
|
||||
size: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// Write adds samples to the ring buffer
|
||||
func (rb *RingBuffer) Write(samples []int32) int {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
|
||||
written := 0
|
||||
for i := 0; i < len(samples) && rb.count < rb.size; i++ {
|
||||
rb.buffer[rb.writePos] = samples[i]
|
||||
rb.writePos = (rb.writePos + 1) % rb.size
|
||||
rb.count++
|
||||
written++
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// Read retrieves samples from the ring buffer
|
||||
func (rb *RingBuffer) Read(samples []int32) int {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
|
||||
read := 0
|
||||
for i := 0; i < len(samples) && rb.count > 0; i++ {
|
||||
samples[i] = rb.buffer[rb.readPos]
|
||||
rb.readPos = (rb.readPos + 1) % rb.size
|
||||
rb.count--
|
||||
read++
|
||||
}
|
||||
|
||||
// Zero-fill remaining if underrun
|
||||
for i := read; i < len(samples); i++ {
|
||||
samples[i] = 0
|
||||
}
|
||||
|
||||
return read
|
||||
}
|
||||
|
||||
// Available returns the number of samples available to read
|
||||
func (rb *RingBuffer) Available() int {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
return rb.count
|
||||
}
|
||||
|
||||
// Free returns the number of free slots in the buffer
|
||||
func (rb *RingBuffer) Free() int {
|
||||
rb.mu.Lock()
|
||||
defer rb.mu.Unlock()
|
||||
return rb.size - rb.count
|
||||
}
|
||||
|
||||
// NewMalgo constructs a new malgo-backed audio output. deviceName selects a
|
||||
// specific playback device by name (as reported by ListPlaybackDevices). An
|
||||
// empty deviceName lets miniaudio pick the platform default.
|
||||
func NewMalgo(deviceName string) Output {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Malgo{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
deviceName: deviceName,
|
||||
volume: 100,
|
||||
muted: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ListPlaybackDevices enumerates every playback device miniaudio can see.
|
||||
// It creates a fresh context and tears it down before returning, so it is
|
||||
// safe to call before any player/device has been initialized.
|
||||
func ListPlaybackDevices() ([]PlaybackDevice, error) {
|
||||
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init malgo context: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = ctx.Uninit()
|
||||
ctx.Free()
|
||||
}()
|
||||
|
||||
infos, err := ctx.Devices(malgo.Playback)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate playback devices: %w", err)
|
||||
}
|
||||
|
||||
out := make([]PlaybackDevice, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
out = append(out, PlaybackDevice{
|
||||
Name: info.Name(),
|
||||
IsDefault: info.IsDefault != 0,
|
||||
ID: info.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchDevice picks a PlaybackDevice from a list based on a requested name.
|
||||
//
|
||||
// Empty requested name -> the device with IsDefault set, else the first in
|
||||
// the list, else nil if the list is empty (caller falls back to whatever
|
||||
// miniaudio's default-config path does).
|
||||
//
|
||||
// Non-empty requested name -> exact Name match first, then short-name match
|
||||
// (the text before the first ", "). Miniaudio's Linux/ALSA backend builds
|
||||
// device names from snd_device_name_hint's DESC field, which follows a
|
||||
// "<card-short>, <stream-description>" convention, so users naturally try
|
||||
// just the short part. If the short-name match is ambiguous, we error out
|
||||
// instead of picking one silently.
|
||||
//
|
||||
// Fail-loud on no-match: the error lists every available device name, each
|
||||
// quoted with %q so embedded commas are distinguishable from the list
|
||||
// separator. Silent fallback to default is the behavior this feature
|
||||
// exists to correct.
|
||||
func matchDevice(devices []PlaybackDevice, requested string) (*PlaybackDevice, error) {
|
||||
if requested == "" {
|
||||
if len(devices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for i := range devices {
|
||||
if devices[i].IsDefault {
|
||||
return &devices[i], nil
|
||||
}
|
||||
}
|
||||
return &devices[0], nil
|
||||
}
|
||||
for i := range devices {
|
||||
if devices[i].Name == requested {
|
||||
return &devices[i], nil
|
||||
}
|
||||
}
|
||||
var shortMatches []int
|
||||
for i, d := range devices {
|
||||
if idx := strings.Index(d.Name, ", "); idx > 0 && d.Name[:idx] == requested {
|
||||
shortMatches = append(shortMatches, i)
|
||||
}
|
||||
}
|
||||
if len(shortMatches) == 1 {
|
||||
return &devices[shortMatches[0]], nil
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
return nil, fmt.Errorf("audio device %q not found (no playback devices available)", requested)
|
||||
}
|
||||
quoted := make([]string, len(devices))
|
||||
for i, d := range devices {
|
||||
quoted[i] = fmt.Sprintf("%q", d.Name)
|
||||
}
|
||||
sort.Strings(quoted)
|
||||
if len(shortMatches) > 1 {
|
||||
return nil, fmt.Errorf("audio device %q is ambiguous (matches %d devices by short name); use the full quoted name. Available: %s", requested, len(shortMatches), strings.Join(quoted, ", "))
|
||||
}
|
||||
return nil, fmt.Errorf("audio device %q not found; available: %s", requested, strings.Join(quoted, ", "))
|
||||
}
|
||||
|
||||
func (m *Malgo) Open(sampleRate, channels, bitDepth int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// If already initialized with same format, reuse
|
||||
if m.device != nil && m.sampleRate == sampleRate && m.channels == channels && m.bitDepth == bitDepth {
|
||||
log.Printf("Audio output already initialized with same format, reusing device")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If format changed, reinitialize
|
||||
if m.device != nil {
|
||||
log.Printf("Format change detected (%dHz/%dch/%dbit -> %dHz/%dch/%dbit), reinitializing device",
|
||||
m.sampleRate, m.channels, m.bitDepth, sampleRate, channels, bitDepth)
|
||||
if err := m.closeDevice(); err != nil {
|
||||
return fmt.Errorf("failed to close old device: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.malgoCtx == nil {
|
||||
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize malgo context: %w", err)
|
||||
}
|
||||
m.malgoCtx = ctx
|
||||
}
|
||||
|
||||
var format malgo.FormatType
|
||||
switch bitDepth {
|
||||
case 16:
|
||||
format = malgo.FormatS16
|
||||
case 24:
|
||||
format = malgo.FormatS24
|
||||
case 32:
|
||||
format = malgo.FormatS32
|
||||
default:
|
||||
return fmt.Errorf("unsupported bit depth: %d (supported: 16, 24, 32)", bitDepth)
|
||||
}
|
||||
|
||||
// Create ring buffer (80ms capacity - tuned for Music Assistant)
|
||||
bufferSamples := (sampleRate * channels * 80) / 1000
|
||||
m.ringBuffer = NewRingBuffer(bufferSamples)
|
||||
|
||||
deviceConfig := malgo.DefaultDeviceConfig(malgo.Playback)
|
||||
deviceConfig.Playback.Format = format
|
||||
deviceConfig.Playback.Channels = uint32(channels)
|
||||
deviceConfig.SampleRate = uint32(sampleRate)
|
||||
deviceConfig.Alsa.NoMMap = 1
|
||||
// Pin the period to 20 ms instead of miniaudio's default low-latency
|
||||
// 10 ms. Several backends (bcm2835 ALSA on Pi, PulseAudio/PipeWire on
|
||||
// some Intel Smart Sound paths — see mackron/miniaudio#877) silently
|
||||
// round the requested 10 ms period to a different internal value and
|
||||
// then stall the audio callback after a few invocations. Asking for
|
||||
// 20 ms lands inside the safer range used by miniaudio's own backend
|
||||
// defaults (see CHANGES.md: PulseAudio default raised to 25 ms in
|
||||
// v0.11.8 to work around PipeWire glitches) and gives the driver
|
||||
// enough headroom that the negotiated period matches what we asked
|
||||
// for. ~20 ms of added pipeline latency is invisible inside Sendspin's
|
||||
// 200+ ms scheduler budget.
|
||||
deviceConfig.PeriodSizeInMilliseconds = 20
|
||||
|
||||
// Resolve the playback device. When m.deviceName is empty, miniaudio's
|
||||
// enumerated default is picked (and logged so the operator knows what
|
||||
// they're getting). When non-empty, the device must exist or Open fails
|
||||
// loudly — silent fallback defeats the point of the knob.
|
||||
infos, err := m.malgoCtx.Devices(malgo.Playback)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enumerate playback devices: %w", err)
|
||||
}
|
||||
catalog := make([]PlaybackDevice, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
catalog = append(catalog, PlaybackDevice{
|
||||
Name: info.Name(),
|
||||
IsDefault: info.IsDefault != 0,
|
||||
ID: info.ID,
|
||||
})
|
||||
}
|
||||
chosen, err := matchDevice(catalog, m.deviceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Hand miniaudio a pointer to the selected device ID, pinned across the
|
||||
// cgo call so Go 1.21+'s pointer check accepts it.
|
||||
//
|
||||
// Pinning &chosen.ID[0] directly would fail: chosen is an element inside
|
||||
// a []PlaybackDevice, and the containing heap object also holds the Go
|
||||
// string Name — whose backing bytes are another Go pointer that cgo's
|
||||
// recursive scan would find unpinned and reject. Copying the ID bytes
|
||||
// into a standalone []byte isolates the pointer target: a byte slice's
|
||||
// backing array contains only bytes (no further Go pointers), so the
|
||||
// scan finds nothing to complain about.
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
chosenLabel := "(miniaudio default)"
|
||||
if chosen != nil {
|
||||
idBuf := append([]byte(nil), chosen.ID[:]...)
|
||||
pinner.Pin(&idBuf[0])
|
||||
deviceConfig.Playback.DeviceID = unsafe.Pointer(&idBuf[0])
|
||||
if chosen.IsDefault {
|
||||
chosenLabel = fmt.Sprintf("%q (default)", chosen.Name)
|
||||
} else {
|
||||
chosenLabel = fmt.Sprintf("%q", chosen.Name)
|
||||
}
|
||||
}
|
||||
|
||||
onSamples := func(pOutputSample, pInputSamples []byte, frameCount uint32) {
|
||||
m.dataCallback(pOutputSample, frameCount)
|
||||
}
|
||||
|
||||
deviceCallbacks := malgo.DeviceCallbacks{
|
||||
Data: onSamples,
|
||||
}
|
||||
|
||||
device, err := malgo.InitDevice(m.malgoCtx.Context, deviceConfig, deviceCallbacks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize playback device: %w", err)
|
||||
}
|
||||
|
||||
if err := device.Start(); err != nil {
|
||||
device.Uninit()
|
||||
return fmt.Errorf("failed to start device: %w", err)
|
||||
}
|
||||
|
||||
m.device = device
|
||||
m.sampleRate = sampleRate
|
||||
m.channels = channels
|
||||
m.bitDepth = bitDepth
|
||||
m.ready = true
|
||||
|
||||
log.Printf("Audio output initialized: device=%s %dHz/%dch/%d-bit period=%dms (malgo/%s)",
|
||||
chosenLabel, sampleRate, channels, bitDepth, deviceConfig.PeriodSizeInMilliseconds, formatName(format))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write queues audio samples for playback.
|
||||
// Writes in passes if the ring is too small to absorb the whole buffer
|
||||
// at once, waiting for the audio callback to drain space between passes.
|
||||
// Buffers larger than the ring (e.g. Music Assistant's ~85 ms PCM chunks
|
||||
// against a 80 ms ring) succeed as long as the callback keeps draining.
|
||||
// Returns an error only if no drain progress occurs for maxStallTime,
|
||||
// which indicates the audio callback itself has stalled.
|
||||
func (m *Malgo) Write(samples []int32) error {
|
||||
if !m.ready {
|
||||
return fmt.Errorf("output not initialized")
|
||||
}
|
||||
|
||||
const (
|
||||
retryInterval = 1 * time.Millisecond
|
||||
maxStallTime = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
volumedSamples := applyVolume(samples, m.volume, m.muted)
|
||||
|
||||
written := 0
|
||||
lastProgress := time.Now()
|
||||
for written < len(volumedSamples) {
|
||||
n := m.ringBuffer.Write(volumedSamples[written:])
|
||||
if n > 0 {
|
||||
written += n
|
||||
lastProgress = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Ring is full this pass. Wait for the audio callback to
|
||||
// drain. If we go too long with zero progress, the callback
|
||||
// has likely stalled — drop the remainder rather than block
|
||||
// the producer indefinitely.
|
||||
if time.Since(lastProgress) > maxStallTime {
|
||||
dropped := len(volumedSamples) - written
|
||||
return fmt.Errorf("ring buffer stalled, dropped %d of %d samples after %v with no drain progress",
|
||||
dropped, len(volumedSamples), maxStallTime)
|
||||
}
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dataCallback is called by malgo to fill the audio output buffer
|
||||
func (m *Malgo) dataCallback(pOutput []byte, frameCount uint32) {
|
||||
totalSamples := int(frameCount) * m.channels
|
||||
samples := make([]int32, totalSamples)
|
||||
|
||||
m.ringBuffer.Read(samples)
|
||||
|
||||
switch m.bitDepth {
|
||||
case 16:
|
||||
m.write16Bit(pOutput, samples)
|
||||
case 24:
|
||||
m.write24Bit(pOutput, samples)
|
||||
case 32:
|
||||
m.write32Bit(pOutput, samples)
|
||||
}
|
||||
}
|
||||
|
||||
// write16Bit converts int32 samples to 16-bit output
|
||||
func (m *Malgo) write16Bit(output []byte, samples []int32) {
|
||||
for i, sample := range samples {
|
||||
sample16 := audio.SampleToInt16(sample)
|
||||
output[i*2] = byte(sample16)
|
||||
output[i*2+1] = byte(sample16 >> 8)
|
||||
}
|
||||
}
|
||||
|
||||
// write24Bit converts int32 samples to 24-bit output (3 bytes per sample)
|
||||
func (m *Malgo) write24Bit(output []byte, samples []int32) {
|
||||
for i, sample := range samples {
|
||||
output[i*3] = byte(sample)
|
||||
output[i*3+1] = byte(sample >> 8)
|
||||
output[i*3+2] = byte(sample >> 16)
|
||||
}
|
||||
}
|
||||
|
||||
// write32Bit converts int32 samples to 32-bit output
|
||||
func (m *Malgo) write32Bit(output []byte, samples []int32) {
|
||||
for i, sample := range samples {
|
||||
// Left-shift 24-bit value to fill the upper bits of the 32-bit container
|
||||
sample32 := sample << 8
|
||||
output[i*4] = byte(sample32)
|
||||
output[i*4+1] = byte(sample32 >> 8)
|
||||
output[i*4+2] = byte(sample32 >> 16)
|
||||
output[i*4+3] = byte(sample32 >> 24)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Malgo) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if err := m.closeDevice(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m.malgoCtx != nil {
|
||||
if err := m.malgoCtx.Uninit(); err != nil {
|
||||
log.Printf("Warning: malgo context uninit error: %v", err)
|
||||
}
|
||||
m.malgoCtx.Free()
|
||||
m.malgoCtx = nil
|
||||
}
|
||||
|
||||
m.cancel()
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeDevice stops and uninitializes the device; caller must hold m.mu.
|
||||
func (m *Malgo) closeDevice() error {
|
||||
if m.device != nil {
|
||||
if err := m.device.Stop(); err != nil {
|
||||
log.Printf("Warning: device stop error: %v", err)
|
||||
}
|
||||
m.device.Uninit()
|
||||
m.device = nil
|
||||
m.ready = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Malgo) SetVolume(volume int) {
|
||||
if volume < 0 {
|
||||
volume = 0
|
||||
}
|
||||
if volume > 100 {
|
||||
volume = 100
|
||||
}
|
||||
m.volume = volume
|
||||
log.Printf("Volume set to %d", volume)
|
||||
}
|
||||
|
||||
func (m *Malgo) SetMuted(muted bool) {
|
||||
m.muted = muted
|
||||
log.Printf("Muted: %v", muted)
|
||||
}
|
||||
|
||||
func (m *Malgo) GetVolume() int {
|
||||
return m.volume
|
||||
}
|
||||
|
||||
func (m *Malgo) IsMuted() bool {
|
||||
return m.muted
|
||||
}
|
||||
|
||||
func formatName(format malgo.FormatType) string {
|
||||
switch format {
|
||||
case malgo.FormatS16:
|
||||
return "S16"
|
||||
case malgo.FormatS24:
|
||||
return "S24"
|
||||
case malgo.FormatS32:
|
||||
return "S32"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown(%d)", format)
|
||||
}
|
||||
}
|
||||
232
third_party/sendspin-go/pkg/audio/output/malgo_test.go
vendored
Normal file
232
third_party/sendspin-go/pkg/audio/output/malgo_test.go
vendored
Normal file
@@ -0,0 +1,232 @@
|
||||
// ABOUTME: Tests for the pure matchDevice selection logic used by Open
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gen2brain/malgo"
|
||||
)
|
||||
|
||||
// newDevice builds a PlaybackDevice with a unique sentinel ID so tests can
|
||||
// assert the correct entry was returned. The actual ID bytes are opaque to
|
||||
// miniaudio at this layer; we only check that matchDevice returns the right
|
||||
// slice element.
|
||||
func newDevice(name string, isDefault bool, marker byte) PlaybackDevice {
|
||||
var id malgo.DeviceID
|
||||
id[0] = marker
|
||||
return PlaybackDevice{Name: name, IsDefault: isDefault, ID: id}
|
||||
}
|
||||
|
||||
func TestMatchDevice_EmptyRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
devices []PlaybackDevice
|
||||
wantNil bool
|
||||
wantName string
|
||||
wantMarker byte
|
||||
}{
|
||||
{
|
||||
name: "empty catalog returns nil",
|
||||
devices: nil,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "prefers the device flagged IsDefault",
|
||||
devices: []PlaybackDevice{
|
||||
newDevice("First", false, 0x01),
|
||||
newDevice("DefaultSink", true, 0x02),
|
||||
newDevice("Third", false, 0x03),
|
||||
},
|
||||
wantName: "DefaultSink",
|
||||
wantMarker: 0x02,
|
||||
},
|
||||
{
|
||||
name: "falls back to first device when none flagged default",
|
||||
devices: []PlaybackDevice{
|
||||
newDevice("Alpha", false, 0x0A),
|
||||
newDevice("Beta", false, 0x0B),
|
||||
},
|
||||
wantName: "Alpha",
|
||||
wantMarker: 0x0A,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := matchDevice(tt.devices, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected a device, got nil")
|
||||
}
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("name = %q, want %q", got.Name, tt.wantName)
|
||||
}
|
||||
if got.ID[0] != tt.wantMarker {
|
||||
t.Errorf("id[0] = 0x%x, want 0x%x (wrong slice element returned)", got.ID[0], tt.wantMarker)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDevice_ExactNameMatch(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("HDA Intel PCH: ALC257 Analog", true, 0x10),
|
||||
newDevice("HDMI 0", false, 0x11),
|
||||
newDevice("USB Audio Device", false, 0x12),
|
||||
}
|
||||
|
||||
got, err := matchDevice(devices, "USB Audio Device")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.Name != "USB Audio Device" {
|
||||
t.Errorf("got %+v, want USB Audio Device", got)
|
||||
}
|
||||
if got.ID[0] != 0x12 {
|
||||
t.Errorf("wrong device matched: id[0] = 0x%x, want 0x12", got.ID[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDevice_NoMatchListsAvailable(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("Charlie", false, 0x01),
|
||||
newDevice("Alpha", true, 0x02),
|
||||
newDevice("Bravo", false, 0x03),
|
||||
}
|
||||
|
||||
got, err := matchDevice(devices, "DoesNotExist")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil device, got %+v", got)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `"DoesNotExist"`) {
|
||||
t.Errorf("error should name the missing device: %q", msg)
|
||||
}
|
||||
// Available names must be listed, sorted, so users can copy/paste the right one.
|
||||
for _, want := range []string{"Alpha", "Bravo", "Charlie"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error should list %q; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
alphaIdx := strings.Index(msg, "Alpha")
|
||||
bravoIdx := strings.Index(msg, "Bravo")
|
||||
charlieIdx := strings.Index(msg, "Charlie")
|
||||
if !(alphaIdx < bravoIdx && bravoIdx < charlieIdx) {
|
||||
t.Errorf("available names should be sorted alphabetically; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDevice_NoMatchEmptyCatalogGivesDistinctError(t *testing.T) {
|
||||
got, err := matchDevice(nil, "Anything")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil device, got %+v", got)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "no playback devices available") {
|
||||
t.Errorf("error should distinguish empty-catalog case: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchDevice_ShortNameMatch covers miniaudio's Linux/ALSA naming where
|
||||
// device.name is "<card-short>, <stream-description>" — users typing just
|
||||
// the short prefix should match unambiguously when only one device has that
|
||||
// prefix. Reproduces the HiFiBerry case from the field bug.
|
||||
func TestMatchDevice_ShortNameMatch(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("Default Audio Device", true, 0x01),
|
||||
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x02),
|
||||
newDevice("vc4-hdmi-1, MAI PCM i2s-hifi-0", false, 0x03),
|
||||
newDevice("PDP Audio Device, USB Audio", false, 0x04),
|
||||
}
|
||||
|
||||
got, err := matchDevice(devices, "vc4-hdmi-0")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.ID[0] != 0x02 {
|
||||
t.Errorf("short-name %q should resolve to id 0x02; got %+v", "vc4-hdmi-0", got)
|
||||
}
|
||||
|
||||
got, err = matchDevice(devices, "PDP Audio Device")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.ID[0] != 0x04 {
|
||||
t.Errorf("short-name %q should resolve to id 0x04; got %+v", "PDP Audio Device", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchDevice_ExactNameWinsOverShortName guards the precedence: if a
|
||||
// device's full name happens to equal someone else's short prefix, the
|
||||
// exact match takes priority over the short-name search.
|
||||
func TestMatchDevice_ExactNameWinsOverShortName(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("Foo, long description", false, 0x01),
|
||||
newDevice("Foo", false, 0x02),
|
||||
}
|
||||
|
||||
got, err := matchDevice(devices, "Foo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || got.ID[0] != 0x02 {
|
||||
t.Errorf("exact match should win: expected id 0x02; got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchDevice_ShortNameAmbiguousReturnsError covers the two-HiFiBerry
|
||||
// case: the same short prefix matches multiple devices. We must not silently
|
||||
// pick one.
|
||||
func TestMatchDevice_ShortNameAmbiguousReturnsError(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("HiFiBerry, card 0", false, 0x01),
|
||||
newDevice("HiFiBerry, card 1", false, 0x02),
|
||||
}
|
||||
|
||||
got, err := matchDevice(devices, "HiFiBerry")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil on ambiguous short-name match, got %+v", got)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error on ambiguous short-name match")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "ambiguous") {
|
||||
t.Errorf("error should mention ambiguity: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `"HiFiBerry, card 0"`) || !strings.Contains(msg, `"HiFiBerry, card 1"`) {
|
||||
t.Errorf("ambiguity error should list both candidates quoted with %%q: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchDevice_NoMatchQuotesNames ensures names with embedded commas are
|
||||
// distinguishable from the list separator in the error output.
|
||||
func TestMatchDevice_NoMatchQuotesNames(t *testing.T) {
|
||||
devices := []PlaybackDevice{
|
||||
newDevice("vc4-hdmi-0, MAI PCM i2s-hifi-0", false, 0x01),
|
||||
}
|
||||
|
||||
_, err := matchDevice(devices, "nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `"vc4-hdmi-0, MAI PCM i2s-hifi-0"`) {
|
||||
t.Errorf("name should appear quoted in error so embedded comma is unambiguous: %q", msg)
|
||||
}
|
||||
}
|
||||
12
third_party/sendspin-go/pkg/audio/output/output.go
vendored
Normal file
12
third_party/sendspin-go/pkg/audio/output/output.go
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// ABOUTME: Audio output interface definition
|
||||
// ABOUTME: Common interface for audio playback backends
|
||||
package output
|
||||
|
||||
// Output represents an audio output device
|
||||
type Output interface {
|
||||
Open(sampleRate, channels, bitDepth int) error
|
||||
Write(samples []int32) error
|
||||
Close() error
|
||||
SetVolume(volume int)
|
||||
SetMuted(muted bool)
|
||||
}
|
||||
9
third_party/sendspin-go/pkg/audio/output/output_test.go
vendored
Normal file
9
third_party/sendspin-go/pkg/audio/output/output_test.go
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// ABOUTME: Audio output interface tests
|
||||
// ABOUTME: Verifies Output interface implementation
|
||||
package output
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMalgoImplementsOutput(t *testing.T) {
|
||||
var _ Output = (*Malgo)(nil)
|
||||
}
|
||||
110
third_party/sendspin-go/pkg/audio/output/query.go
vendored
Normal file
110
third_party/sendspin-go/pkg/audio/output/query.go
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
// ABOUTME: Capability probe for malgo playback devices (rate/bit-depth ceilings)
|
||||
// ABOUTME: Used by Player to filter advertised SupportedFormats before handshake
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gen2brain/malgo"
|
||||
)
|
||||
|
||||
// QueryDeviceCapabilities returns the highest sample rate and bit depth the
|
||||
// named playback device's malgo (miniaudio) backend reports as natively
|
||||
// supported. deviceName matches the same way ListPlaybackDevices and Open
|
||||
// accept it; an empty string selects the platform default.
|
||||
//
|
||||
// Best-effort. When the backend reports zero native formats — some devices
|
||||
// don't, especially on cold-start Windows / Pulse — this returns (0, 0, nil)
|
||||
// so the caller can fall back to "no cap". On Linux/ALSA the answer can also
|
||||
// be optimistic, because miniaudio reports what the driver claims to accept,
|
||||
// and ALSA layers software resampling under formats the underlying hardware
|
||||
// (e.g. bcm2835 onboard headphones) can't actually sustain. The user-facing
|
||||
// override knob exists exactly for that case.
|
||||
//
|
||||
// Does NOT InitDevice. Cheaper than opening the device, but the trade-off is
|
||||
// that the answer is a best-guess from miniaudio rather than ground truth.
|
||||
func QueryDeviceCapabilities(deviceName string) (maxSampleRate, maxBitDepth int, err error) {
|
||||
ctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("init malgo context: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = ctx.Uninit()
|
||||
ctx.Free()
|
||||
}()
|
||||
|
||||
infos, err := ctx.Devices(malgo.Playback)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("enumerate playback devices: %w", err)
|
||||
}
|
||||
|
||||
catalog := make([]PlaybackDevice, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
catalog = append(catalog, PlaybackDevice{
|
||||
Name: info.Name(),
|
||||
IsDefault: info.IsDefault != 0,
|
||||
ID: info.ID,
|
||||
})
|
||||
}
|
||||
|
||||
chosen, err := matchDevice(catalog, deviceName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if chosen == nil {
|
||||
// No devices at all. Treat as "no cap" — no audio output is going to
|
||||
// happen anyway, so the caller's handshake will fail for unrelated
|
||||
// reasons.
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
detail, err := ctx.DeviceInfo(malgo.Playback, chosen.ID, malgo.Shared)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("query device info for %q: %w", chosen.Name, err)
|
||||
}
|
||||
|
||||
maxRate, maxDepth := capsFromFormats(detail.Formats)
|
||||
return maxRate, maxDepth, nil
|
||||
}
|
||||
|
||||
// capsFromFormats walks a DeviceInfo's native-format list and returns the
|
||||
// highest sample rate and bit depth observed. Formats with unknown bit
|
||||
// representations (FormatU8, FormatUnknown) are ignored — we'd rather report
|
||||
// a lower cap than advertise rates only achievable in unsupported formats.
|
||||
//
|
||||
// Pure helper so the cgo-bound QueryDeviceCapabilities doesn't need test
|
||||
// coverage of its own — capsFromFormats covers the interesting logic.
|
||||
func capsFromFormats(formats []malgo.DataFormat) (maxSampleRate, maxBitDepth int) {
|
||||
for _, f := range formats {
|
||||
bits := formatBits(f.Format)
|
||||
if bits == 0 {
|
||||
continue
|
||||
}
|
||||
if int(f.SampleRate) > maxSampleRate {
|
||||
maxSampleRate = int(f.SampleRate)
|
||||
}
|
||||
if bits > maxBitDepth {
|
||||
maxBitDepth = bits
|
||||
}
|
||||
}
|
||||
return maxSampleRate, maxBitDepth
|
||||
}
|
||||
|
||||
// formatBits returns the linear bit count for a malgo FormatType.
|
||||
// 0 means unknown/unsupported and the caller should ignore the entry.
|
||||
func formatBits(f malgo.FormatType) int {
|
||||
switch f {
|
||||
case malgo.FormatS16:
|
||||
return 16
|
||||
case malgo.FormatS24:
|
||||
return 24
|
||||
case malgo.FormatS32:
|
||||
return 32
|
||||
case malgo.FormatF32:
|
||||
// 32-bit float carries the same dynamic range as S32 for our
|
||||
// purposes — both clear our 24-bit advertised ceiling.
|
||||
return 32
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
90
third_party/sendspin-go/pkg/audio/output/query_test.go
vendored
Normal file
90
third_party/sendspin-go/pkg/audio/output/query_test.go
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
// ABOUTME: Tests for the pure capsFromFormats / formatBits helpers
|
||||
// ABOUTME: cgo-bound QueryDeviceCapabilities is not exercised here
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gen2brain/malgo"
|
||||
)
|
||||
|
||||
func TestFormatBits(t *testing.T) {
|
||||
tests := []struct {
|
||||
format malgo.FormatType
|
||||
want int
|
||||
}{
|
||||
{malgo.FormatS16, 16},
|
||||
{malgo.FormatS24, 24},
|
||||
{malgo.FormatS32, 32},
|
||||
{malgo.FormatF32, 32},
|
||||
{malgo.FormatUnknown, 0},
|
||||
{malgo.FormatU8, 0}, // we don't currently advertise 8-bit formats
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := formatBits(tt.format); got != tt.want {
|
||||
t.Errorf("formatBits(%v) = %d, want %d", tt.format, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsFromFormats_Empty(t *testing.T) {
|
||||
rate, depth := capsFromFormats(nil)
|
||||
if rate != 0 || depth != 0 {
|
||||
t.Errorf("expected (0, 0) for empty input, got (%d, %d)", rate, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsFromFormats_TakesMaxAcrossEntries(t *testing.T) {
|
||||
// Mixed-rate / mixed-depth list. Rate and depth caps come from
|
||||
// different entries — neither field's max needs to come from the same row.
|
||||
formats := []malgo.DataFormat{
|
||||
{Format: malgo.FormatS16, Channels: 2, SampleRate: 192000},
|
||||
{Format: malgo.FormatS24, Channels: 2, SampleRate: 48000},
|
||||
}
|
||||
rate, depth := capsFromFormats(formats)
|
||||
if rate != 192000 {
|
||||
t.Errorf("expected max rate 192000, got %d", rate)
|
||||
}
|
||||
if depth != 24 {
|
||||
t.Errorf("expected max depth 24, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsFromFormats_IgnoresUnknownFormat(t *testing.T) {
|
||||
// An entry with an unknown FormatType must not contribute to either cap,
|
||||
// even if its SampleRate is huge — we'd be advertising a rate we couldn't
|
||||
// actually drive in any of our supported formats.
|
||||
formats := []malgo.DataFormat{
|
||||
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 384000},
|
||||
{Format: malgo.FormatS16, Channels: 2, SampleRate: 48000},
|
||||
}
|
||||
rate, depth := capsFromFormats(formats)
|
||||
if rate != 48000 {
|
||||
t.Errorf("expected unknown-format entry to be ignored; got rate %d", rate)
|
||||
}
|
||||
if depth != 16 {
|
||||
t.Errorf("expected depth 16, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsFromFormats_AllUnknownReturnsZero(t *testing.T) {
|
||||
formats := []malgo.DataFormat{
|
||||
{Format: malgo.FormatUnknown, Channels: 2, SampleRate: 192000},
|
||||
{Format: malgo.FormatU8, Channels: 2, SampleRate: 48000},
|
||||
}
|
||||
rate, depth := capsFromFormats(formats)
|
||||
if rate != 0 || depth != 0 {
|
||||
t.Errorf("expected (0, 0) when no entry has a known bit depth, got (%d, %d)", rate, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsFromFormats_F32MapsToThirtyTwo(t *testing.T) {
|
||||
formats := []malgo.DataFormat{
|
||||
{Format: malgo.FormatF32, Channels: 2, SampleRate: 96000},
|
||||
}
|
||||
_, depth := capsFromFormats(formats)
|
||||
if depth != 32 {
|
||||
t.Errorf("FormatF32 should map to 32 bits; got %d", depth)
|
||||
}
|
||||
}
|
||||
34
third_party/sendspin-go/pkg/audio/output/volume.go
vendored
Normal file
34
third_party/sendspin-go/pkg/audio/output/volume.go
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
// ABOUTME: Volume and mute helpers shared by audio output backends
|
||||
// ABOUTME: Extracted from oto.go during the oto removal cleanup
|
||||
package output
|
||||
|
||||
import "github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
|
||||
// applyVolume applies volume and mute to samples with clipping protection.
|
||||
// Samples are expected to be in the int32 24-bit range.
|
||||
func applyVolume(samples []int32, volume int, muted bool) []int32 {
|
||||
multiplier := getVolumeMultiplier(volume, muted)
|
||||
|
||||
result := make([]int32, len(samples))
|
||||
for i, sample := range samples {
|
||||
scaled := int64(float64(sample) * multiplier)
|
||||
|
||||
if scaled > audio.Max24Bit {
|
||||
scaled = audio.Max24Bit
|
||||
} else if scaled < audio.Min24Bit {
|
||||
scaled = audio.Min24Bit
|
||||
}
|
||||
|
||||
result[i] = int32(scaled)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getVolumeMultiplier returns the float multiplier for a given volume/mute state.
|
||||
func getVolumeMultiplier(volume int, muted bool) float64 {
|
||||
if muted {
|
||||
return 0.0
|
||||
}
|
||||
return float64(volume) / 100.0
|
||||
}
|
||||
70
third_party/sendspin-go/pkg/audio/output/volume_test.go
vendored
Normal file
70
third_party/sendspin-go/pkg/audio/output/volume_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
// ABOUTME: Tests for shared volume/mute helpers in pkg/audio/output
|
||||
// ABOUTME: Covers multiplier table, half-scale, mute, and 24-bit clamping
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio"
|
||||
)
|
||||
|
||||
func TestVolumeMultiplier(t *testing.T) {
|
||||
tests := []struct {
|
||||
volume int
|
||||
muted bool
|
||||
expected float64
|
||||
}{
|
||||
{100, false, 1.0},
|
||||
{50, false, 0.5},
|
||||
{0, false, 0.0},
|
||||
{80, true, 0.0}, // muted overrides volume
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := getVolumeMultiplier(tt.volume, tt.muted)
|
||||
if result != tt.expected {
|
||||
t.Errorf("volume=%d muted=%v: expected %f, got %f",
|
||||
tt.volume, tt.muted, tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_HalfScale(t *testing.T) {
|
||||
samples := []int32{1000 << 8, -1000 << 8}
|
||||
|
||||
result := applyVolume(samples, 50, false)
|
||||
|
||||
if result[0] != int32(500<<8) {
|
||||
t.Errorf("sample 0: expected %d, got %d", 500<<8, result[0])
|
||||
}
|
||||
if result[1] != int32(-500<<8) {
|
||||
t.Errorf("sample 1: expected %d, got %d", -500<<8, result[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_Muted(t *testing.T) {
|
||||
samples := []int32{audio.Max24Bit, audio.Min24Bit, 1 << 20}
|
||||
|
||||
result := applyVolume(samples, 100, true)
|
||||
|
||||
for i, got := range result {
|
||||
if got != 0 {
|
||||
t.Errorf("sample %d: expected 0 when muted, got %d", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVolume_Clamps24Bit(t *testing.T) {
|
||||
// Inputs outside the 24-bit range must clamp, not overflow.
|
||||
overMax := int32(audio.Max24Bit + 1)
|
||||
underMin := int32(audio.Min24Bit - 1)
|
||||
|
||||
result := applyVolume([]int32{overMax, underMin}, 100, false)
|
||||
|
||||
if result[0] != audio.Max24Bit {
|
||||
t.Errorf("max clamp: expected %d, got %d", audio.Max24Bit, result[0])
|
||||
}
|
||||
if result[1] != audio.Min24Bit {
|
||||
t.Errorf("min clamp: expected %d, got %d", audio.Min24Bit, result[1])
|
||||
}
|
||||
}
|
||||
83
third_party/sendspin-go/pkg/audio/resample.go
vendored
Normal file
83
third_party/sendspin-go/pkg/audio/resample.go
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
// ABOUTME: Linear resampler for converting between audio sample rates
|
||||
// ABOUTME: Uses linear interpolation to convert interleaved audio samples
|
||||
package audio
|
||||
|
||||
// Resampler performs linear interpolation to convert between sample rates
|
||||
type Resampler struct {
|
||||
inputRate int
|
||||
outputRate int
|
||||
channels int
|
||||
ratio float64
|
||||
position float64
|
||||
lastSample []int32 // one sample per channel
|
||||
}
|
||||
|
||||
func NewResampler(inputRate, outputRate, channels int) *Resampler {
|
||||
return &Resampler{
|
||||
inputRate: inputRate,
|
||||
outputRate: outputRate,
|
||||
channels: channels,
|
||||
ratio: float64(inputRate) / float64(outputRate),
|
||||
position: 0.0,
|
||||
lastSample: make([]int32, channels),
|
||||
}
|
||||
}
|
||||
|
||||
// Resample converts input samples to output sample rate using linear interpolation.
|
||||
// input and output are interleaved; returns the number of output samples written.
|
||||
func (r *Resampler) Resample(input []int32, output []int32) int {
|
||||
if len(input) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
inputFrames := len(input) / r.channels
|
||||
outputFrames := len(output) / r.channels
|
||||
|
||||
outIdx := 0
|
||||
|
||||
for outIdx < outputFrames {
|
||||
inputPos := r.position
|
||||
inputIdx := int(inputPos)
|
||||
|
||||
if inputIdx >= inputFrames-1 {
|
||||
break
|
||||
}
|
||||
|
||||
frac := inputPos - float64(inputIdx)
|
||||
|
||||
for ch := 0; ch < r.channels; ch++ {
|
||||
sample1 := input[inputIdx*r.channels+ch]
|
||||
sample2 := input[(inputIdx+1)*r.channels+ch]
|
||||
|
||||
interpolated := float64(sample1)*(1.0-frac) + float64(sample2)*frac
|
||||
output[outIdx*r.channels+ch] = int32(interpolated)
|
||||
}
|
||||
|
||||
outIdx++
|
||||
r.position += r.ratio
|
||||
}
|
||||
|
||||
// Reset position for next chunk, keeping fractional part
|
||||
r.position -= float64(int(r.position))
|
||||
|
||||
return outIdx * r.channels
|
||||
}
|
||||
|
||||
func (r *Resampler) Reset() {
|
||||
r.position = 0.0
|
||||
for i := range r.lastSample {
|
||||
r.lastSample[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resampler) OutputSamplesNeeded(inputSamples int) int {
|
||||
inputFrames := inputSamples / r.channels
|
||||
outputFrames := int(float64(inputFrames) / r.ratio)
|
||||
return outputFrames * r.channels
|
||||
}
|
||||
|
||||
func (r *Resampler) InputSamplesNeeded(outputSamples int) int {
|
||||
outputFrames := outputSamples / r.channels
|
||||
inputFrames := int(float64(outputFrames) * r.ratio)
|
||||
return inputFrames * r.channels
|
||||
}
|
||||
57
third_party/sendspin-go/pkg/audio/types.go
vendored
Normal file
57
third_party/sendspin-go/pkg/audio/types.go
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
// ABOUTME: Audio type definitions
|
||||
// ABOUTME: Defines audio formats and decoded buffers
|
||||
package audio
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// 24-bit audio range constants
|
||||
Max24Bit = 8388607 // 2^23 - 1
|
||||
Min24Bit = -8388608 // -2^23
|
||||
)
|
||||
|
||||
// Format describes audio stream format
|
||||
type Format struct {
|
||||
Codec string
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitDepth int
|
||||
CodecHeader []byte // For FLAC, Opus, etc.
|
||||
}
|
||||
|
||||
// Buffer represents decoded PCM audio
|
||||
type Buffer struct {
|
||||
Timestamp int64 // Server timestamp (microseconds)
|
||||
PlayAt time.Time // Local play time
|
||||
Samples []int32 // PCM samples (int32 to support both 16-bit and 24-bit)
|
||||
Format Format
|
||||
}
|
||||
|
||||
// SampleToInt16 converts int32 sample to int16 (for 16-bit playback)
|
||||
func SampleToInt16(sample int32) int16 {
|
||||
return int16(sample >> 8)
|
||||
}
|
||||
|
||||
// SampleFromInt16 converts int16 sample to int32 (left-justified in 24-bit)
|
||||
func SampleFromInt16(sample int16) int32 {
|
||||
return int32(sample) << 8
|
||||
}
|
||||
|
||||
// SampleTo24Bit converts int32 to 24-bit packed bytes (little-endian)
|
||||
func SampleTo24Bit(sample int32) [3]byte {
|
||||
return [3]byte{
|
||||
byte(sample),
|
||||
byte(sample >> 8),
|
||||
byte(sample >> 16),
|
||||
}
|
||||
}
|
||||
|
||||
// SampleFrom24Bit converts 24-bit packed bytes to int32 (little-endian)
|
||||
func SampleFrom24Bit(b [3]byte) int32 {
|
||||
val := int32(b[0]) | int32(b[1])<<8 | int32(b[2])<<16
|
||||
// Sign-extend from 24-bit to 32-bit
|
||||
if val&0x800000 != 0 {
|
||||
val |= ^0xFFFFFF
|
||||
}
|
||||
return val
|
||||
}
|
||||
126
third_party/sendspin-go/pkg/audio/types_test.go
vendored
Normal file
126
third_party/sendspin-go/pkg/audio/types_test.go
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
// ABOUTME: Tests for audio types
|
||||
// ABOUTME: Tests sample conversion functions
|
||||
package audio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSampleFromInt16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int16
|
||||
expected int32
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"positive", 100, 100 << 8},
|
||||
{"negative", -100, -100 << 8},
|
||||
{"max", 32767, 32767 << 8},
|
||||
{"min", -32768, -32768 << 8},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SampleFromInt16(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleToInt16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int32
|
||||
expected int16
|
||||
}{
|
||||
{"zero", 0, 0},
|
||||
{"positive", 100 << 8, 100},
|
||||
{"negative", -100 << 8, -100},
|
||||
{"24bit positive", 1000000, 3906}, // 1000000 >> 8 = 3906
|
||||
{"24bit negative", -1000000, -3907},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SampleToInt16(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleTo24Bit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int32
|
||||
expected [3]byte
|
||||
}{
|
||||
{"zero", 0, [3]byte{0, 0, 0}},
|
||||
{"positive", 0x123456, [3]byte{0x56, 0x34, 0x12}},
|
||||
{"negative", -256, [3]byte{0x00, 0xFF, 0xFF}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SampleTo24Bit(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleFrom24Bit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input [3]byte
|
||||
expected int32
|
||||
}{
|
||||
{"zero", [3]byte{0, 0, 0}, 0},
|
||||
{"positive", [3]byte{0x56, 0x34, 0x12}, 0x123456},
|
||||
{"negative", [3]byte{0x00, 0xFF, 0xFF}, -256},
|
||||
{"max positive", [3]byte{0xFF, 0xFF, 0x7F}, Max24Bit},
|
||||
{"max negative", [3]byte{0x00, 0x00, 0x80}, Min24Bit},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SampleFrom24Bit(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip16Bit(t *testing.T) {
|
||||
// Test that 16-bit samples survive round-trip conversion
|
||||
samples := []int16{0, 100, -100, 1000, -1000, 32767, -32768}
|
||||
|
||||
for _, original := range samples {
|
||||
sample32 := SampleFromInt16(original)
|
||||
result := SampleToInt16(sample32)
|
||||
if result != original {
|
||||
t.Errorf("round-trip failed: %d -> %d -> %d", original, sample32, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip24Bit(t *testing.T) {
|
||||
// Test that 24-bit samples survive round-trip conversion
|
||||
samples := []int32{0, 100000, -100000, Max24Bit, Min24Bit}
|
||||
|
||||
for _, original := range samples {
|
||||
bytes := SampleTo24Bit(original)
|
||||
result := SampleFrom24Bit(bytes)
|
||||
// Mask to 24-bit for comparison
|
||||
expected := original & 0xFFFFFF
|
||||
if expected&0x800000 != 0 {
|
||||
expected |= ^0xFFFFFF
|
||||
}
|
||||
if result != expected {
|
||||
t.Errorf("round-trip failed: %d -> %v -> %d (expected %d)", original, bytes, result, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user