549 lines
14 KiB
Go
549 lines
14 KiB
Go
// ABOUTME: Audio source abstraction for streaming from files or generating test tones
|
|
// ABOUTME: Supports MP3, FLAC, WAV files with automatic decoding
|
|
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/go-mp3"
|
|
"github.com/mewkiz/flac"
|
|
)
|
|
|
|
// AudioSource provides PCM audio samples
|
|
type AudioSource interface {
|
|
// Read reads PCM samples into the buffer (int32 for 24-bit support). Returns number of samples read or error.
|
|
Read(samples []int32) (int, error)
|
|
// SampleRate returns the sample rate of the audio
|
|
SampleRate() int
|
|
// Channels returns the number of channels
|
|
Channels() int
|
|
// Metadata returns title, artist, album
|
|
Metadata() (title, artist, album string)
|
|
// Close closes the audio source
|
|
Close() error
|
|
}
|
|
|
|
// NewAudioSource creates an audio source from a file path or HTTP URL
|
|
// If path is empty, returns a test tone generator
|
|
// Automatically resamples to 48kHz if needed for Opus compatibility
|
|
func NewAudioSource(pathOrURL string) (AudioSource, error) {
|
|
if pathOrURL == "" {
|
|
return NewTestToneSource(), nil
|
|
}
|
|
|
|
var source AudioSource
|
|
var err error
|
|
|
|
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
|
if strings.Contains(pathOrURL, ".m3u8") {
|
|
log.Printf("Streaming from HLS URL: %s", pathOrURL)
|
|
source, err = NewFFmpegSource(pathOrURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
log.Printf("Streaming from HTTP URL: %s", pathOrURL)
|
|
source, err = NewHTTPMP3Source(pathOrURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
} else {
|
|
if _, err := os.Stat(pathOrURL); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("audio file not found: %s", pathOrURL)
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(pathOrURL))
|
|
|
|
switch ext {
|
|
case ".mp3":
|
|
source, err = NewMP3Source(pathOrURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case ".flac":
|
|
source, err = NewFLACSource(pathOrURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported audio format: %s (supported: .mp3, .flac)", ext)
|
|
}
|
|
}
|
|
|
|
// Note: We no longer auto-resample here. Sources are kept at native sample rate.
|
|
// If Opus encoding is needed and source isn't 48kHz, resampling happens per-client in audio engine.
|
|
// This allows PCM clients to receive hi-res audio at native rates!
|
|
|
|
return source, nil
|
|
}
|
|
|
|
// MP3Source reads from an MP3 file
|
|
type MP3Source struct {
|
|
file *os.File
|
|
decoder *mp3.Decoder
|
|
sampleRate int
|
|
channels int
|
|
title string
|
|
artist string
|
|
album string
|
|
}
|
|
|
|
// NewMP3Source creates a new MP3 audio source
|
|
func NewMP3Source(filePath string) (*MP3Source, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
|
|
}
|
|
|
|
decoder, err := mp3.NewDecoder(f)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("failed to decode MP3: %w", err)
|
|
}
|
|
|
|
filename := filepath.Base(filePath)
|
|
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
|
|
|
log.Printf("Loaded MP3: %s (sample rate: %d Hz)", title, decoder.SampleRate())
|
|
|
|
return &MP3Source{
|
|
file: f,
|
|
decoder: decoder,
|
|
sampleRate: decoder.SampleRate(),
|
|
channels: 2, // MP3 decoder outputs stereo
|
|
title: title,
|
|
artist: "Unknown Artist",
|
|
album: "Unknown Album",
|
|
}, nil
|
|
}
|
|
|
|
func (s *MP3Source) Read(samples []int32) (int, error) {
|
|
numBytes := len(samples) * 2 // int16 = 2 bytes per sample
|
|
buf := make([]byte, numBytes)
|
|
|
|
n, err := s.decoder.Read(buf)
|
|
if err != nil && err != io.EOF {
|
|
return 0, err
|
|
}
|
|
|
|
// Convert bytes to int16, then scale to 24-bit range
|
|
numSamples := n / 2
|
|
for i := 0; i < numSamples; i++ {
|
|
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
|
|
// Left-shift by 8 to convert 16-bit range to 24-bit range
|
|
// Example: 32767 (max 16-bit) << 8 = 8388352 (near max 24-bit 8388607)
|
|
samples[i] = int32(sample16) << 8
|
|
}
|
|
|
|
if err == io.EOF {
|
|
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
|
|
return numSamples, fmt.Errorf("failed to seek to start: %w", seekErr)
|
|
}
|
|
newDecoder, decErr := mp3.NewDecoder(s.file)
|
|
if decErr != nil {
|
|
return numSamples, fmt.Errorf("failed to create new decoder: %w", decErr)
|
|
}
|
|
s.decoder = newDecoder
|
|
}
|
|
|
|
return numSamples, nil
|
|
}
|
|
|
|
func (s *MP3Source) SampleRate() int { return s.sampleRate }
|
|
func (s *MP3Source) Channels() int { return s.channels }
|
|
func (s *MP3Source) Metadata() (string, string, string) {
|
|
return s.title, s.artist, s.album
|
|
}
|
|
func (s *MP3Source) Close() error {
|
|
return s.file.Close()
|
|
}
|
|
|
|
// FLACSource reads from a FLAC file
|
|
type FLACSource struct {
|
|
file *os.File
|
|
stream *flac.Stream
|
|
sampleRate int
|
|
channels int
|
|
bitDepth int
|
|
title string
|
|
artist string
|
|
album string
|
|
|
|
// Buffer for partial frames (FLAC frames may not align with chunk boundaries)
|
|
frameBuffer []int32
|
|
frameBufferPos int
|
|
}
|
|
|
|
// NewFLACSource creates a new FLAC audio source
|
|
func NewFLACSource(filePath string) (*FLACSource, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open FLAC file: %w", err)
|
|
}
|
|
|
|
stream, err := flac.New(f)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("failed to decode FLAC: %w", err)
|
|
}
|
|
|
|
info := stream.Info
|
|
sampleRate := int(info.SampleRate)
|
|
channels := int(info.NChannels)
|
|
bitDepth := int(info.BitsPerSample)
|
|
|
|
filename := filepath.Base(filePath)
|
|
title := strings.TrimSuffix(filename, filepath.Ext(filename))
|
|
|
|
log.Printf("Loaded FLAC: %s (sample rate: %d Hz, channels: %d, bit depth: %d)",
|
|
title, sampleRate, channels, bitDepth)
|
|
|
|
return &FLACSource{
|
|
file: f,
|
|
stream: stream,
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
bitDepth: bitDepth,
|
|
title: title,
|
|
artist: "Unknown Artist",
|
|
album: "Unknown Album",
|
|
}, nil
|
|
}
|
|
|
|
func (s *FLACSource) Read(samples []int32) (int, error) {
|
|
samplesRead := 0
|
|
|
|
// Drain any buffered samples from a previous partial frame before reading more
|
|
if s.frameBuffer != nil && s.frameBufferPos < len(s.frameBuffer) {
|
|
available := len(s.frameBuffer) - s.frameBufferPos
|
|
toCopy := len(samples)
|
|
if toCopy > available {
|
|
toCopy = available
|
|
}
|
|
|
|
copy(samples[samplesRead:], s.frameBuffer[s.frameBufferPos:s.frameBufferPos+toCopy])
|
|
samplesRead += toCopy
|
|
s.frameBufferPos += toCopy
|
|
|
|
if samplesRead >= len(samples) {
|
|
return samplesRead, nil
|
|
}
|
|
|
|
if s.frameBufferPos >= len(s.frameBuffer) {
|
|
s.frameBuffer = nil
|
|
s.frameBufferPos = 0
|
|
}
|
|
}
|
|
|
|
for samplesRead < len(samples) {
|
|
frame, err := s.stream.ParseNext()
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
if _, seekErr := s.file.Seek(0, 0); seekErr != nil {
|
|
return samplesRead, fmt.Errorf("failed to seek to start: %w", seekErr)
|
|
}
|
|
newStream, decErr := flac.New(s.file)
|
|
if decErr != nil {
|
|
return samplesRead, fmt.Errorf("failed to create new stream: %w", decErr)
|
|
}
|
|
s.stream = newStream
|
|
s.frameBuffer = nil
|
|
s.frameBufferPos = 0
|
|
continue
|
|
}
|
|
return samplesRead, err
|
|
}
|
|
|
|
frameSize := int(frame.BlockSize) * s.channels
|
|
frameSamples := make([]int32, frameSize)
|
|
frameIdx := 0
|
|
|
|
for i := 0; i < int(frame.BlockSize); i++ {
|
|
for ch := 0; ch < s.channels; ch++ {
|
|
sample := frame.Subframes[ch].Samples[i]
|
|
|
|
// Convert to int32 24-bit range
|
|
var converted int32
|
|
if s.bitDepth == 16 {
|
|
// Convert 16-bit to 24-bit range
|
|
converted = sample << 8
|
|
} else if s.bitDepth == 24 {
|
|
// Already 24-bit, use directly
|
|
converted = sample
|
|
} else {
|
|
// For other bit depths, scale to 24-bit range
|
|
shift := s.bitDepth - 24
|
|
if shift > 0 {
|
|
converted = sample >> shift
|
|
} else {
|
|
converted = sample << -shift
|
|
}
|
|
}
|
|
|
|
frameSamples[frameIdx] = converted
|
|
frameIdx++
|
|
}
|
|
}
|
|
|
|
remaining := len(samples) - samplesRead
|
|
toCopy := frameSize
|
|
if toCopy > remaining {
|
|
toCopy = remaining
|
|
}
|
|
|
|
copy(samples[samplesRead:], frameSamples[:toCopy])
|
|
samplesRead += toCopy
|
|
|
|
// Buffer leftover samples — FLAC frames don't align with chunk boundaries
|
|
if toCopy < frameSize {
|
|
s.frameBuffer = frameSamples
|
|
s.frameBufferPos = toCopy
|
|
break
|
|
}
|
|
}
|
|
|
|
return samplesRead, nil
|
|
}
|
|
|
|
func (s *FLACSource) SampleRate() int { return s.sampleRate }
|
|
func (s *FLACSource) Channels() int { return s.channels }
|
|
func (s *FLACSource) Metadata() (string, string, string) {
|
|
return s.title, s.artist, s.album
|
|
}
|
|
func (s *FLACSource) Close() error {
|
|
return s.file.Close()
|
|
}
|
|
|
|
// HTTPMP3Source streams MP3 from an HTTP URL
|
|
type HTTPMP3Source struct {
|
|
url string
|
|
response *http.Response
|
|
decoder *mp3.Decoder
|
|
sampleRate int
|
|
channels int
|
|
title string
|
|
}
|
|
|
|
func NewHTTPMP3Source(url string) (*HTTPMP3Source, error) {
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch HTTP stream: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("HTTP error: %s", resp.Status)
|
|
}
|
|
|
|
decoder, err := mp3.NewDecoder(resp.Body)
|
|
if err != nil {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("failed to decode MP3 stream: %w", err)
|
|
}
|
|
|
|
log.Printf("Streaming MP3 from HTTP: %s (sample rate: %d Hz)", url, decoder.SampleRate())
|
|
|
|
return &HTTPMP3Source{
|
|
url: url,
|
|
response: resp,
|
|
decoder: decoder,
|
|
sampleRate: decoder.SampleRate(),
|
|
channels: 2, // MP3 decoder outputs stereo
|
|
title: "HTTP Stream",
|
|
}, nil
|
|
}
|
|
|
|
func (s *HTTPMP3Source) Read(samples []int32) (int, error) {
|
|
numBytes := len(samples) * 2 // int16 = 2 bytes
|
|
buf := make([]byte, numBytes)
|
|
|
|
n, err := s.decoder.Read(buf)
|
|
if err != nil {
|
|
return 0, err // Don't loop HTTP streams, just end on EOF
|
|
}
|
|
|
|
// Convert bytes to int16, then scale to 24-bit range
|
|
numSamples := n / 2
|
|
for i := 0; i < numSamples; i++ {
|
|
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
|
|
// Left-shift by 8 to convert 16-bit range to 24-bit range
|
|
samples[i] = int32(sample16) << 8
|
|
}
|
|
|
|
return numSamples, nil
|
|
}
|
|
|
|
func (s *HTTPMP3Source) SampleRate() int { return s.sampleRate }
|
|
func (s *HTTPMP3Source) Channels() int { return s.channels }
|
|
func (s *HTTPMP3Source) Metadata() (string, string, string) {
|
|
return s.title, "HTTP Stream", ""
|
|
}
|
|
func (s *HTTPMP3Source) Close() error {
|
|
if s.response != nil {
|
|
return s.response.Body.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FFmpegSource streams audio from any URL/format using ffmpeg
|
|
// Supports HLS (.m3u8), DASH, and other streaming protocols
|
|
type FFmpegSource struct {
|
|
url string
|
|
cmd *exec.Cmd
|
|
stdout io.ReadCloser
|
|
reader *bufio.Reader
|
|
sampleRate int
|
|
channels int
|
|
title string
|
|
}
|
|
|
|
// NewFFmpegSource creates an ffmpeg-backed source for HLS and other streaming URLs.
|
|
func NewFFmpegSource(url string) (*FFmpegSource, error) {
|
|
// Validate URL scheme to prevent command injection
|
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
|
return nil, fmt.Errorf("unsupported URL scheme: only http and https are allowed")
|
|
}
|
|
|
|
// Check if ffmpeg is available
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
return nil, fmt.Errorf("ffmpeg not found in PATH: %w (install with: brew install ffmpeg)", err)
|
|
}
|
|
|
|
sampleRate := 48000
|
|
channels := 2
|
|
|
|
cmd := exec.Command("ffmpeg",
|
|
"-loglevel", "error", // Only show errors
|
|
"-i", url,
|
|
"-f", "s16le",
|
|
"-ar", fmt.Sprintf("%d", sampleRate),
|
|
"-ac", fmt.Sprintf("%d", channels),
|
|
"-")
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ffmpeg stdout: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
|
}
|
|
|
|
log.Printf("Streaming via ffmpeg: %s (sample rate: %d Hz, channels: %d)", url, sampleRate, channels)
|
|
|
|
return &FFmpegSource{
|
|
url: url,
|
|
cmd: cmd,
|
|
stdout: stdout,
|
|
reader: bufio.NewReader(stdout),
|
|
sampleRate: sampleRate,
|
|
channels: channels,
|
|
title: "Live Stream",
|
|
}, nil
|
|
}
|
|
|
|
func (s *FFmpegSource) Read(samples []int32) (int, error) {
|
|
numBytes := len(samples) * 2 // int16 = 2 bytes
|
|
buf := make([]byte, numBytes)
|
|
|
|
n, err := io.ReadFull(s.reader, buf)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Convert bytes to int16, then scale to 24-bit range
|
|
numSamples := n / 2
|
|
for i := 0; i < numSamples; i++ {
|
|
sample16 := int16(binary.LittleEndian.Uint16(buf[i*2 : i*2+2]))
|
|
// Left-shift by 8 to convert 16-bit range to 24-bit range
|
|
samples[i] = int32(sample16) << 8
|
|
}
|
|
|
|
return numSamples, nil
|
|
}
|
|
|
|
func (s *FFmpegSource) SampleRate() int { return s.sampleRate }
|
|
func (s *FFmpegSource) Channels() int { return s.channels }
|
|
func (s *FFmpegSource) Metadata() (string, string, string) {
|
|
return s.title, "Live Stream", ""
|
|
}
|
|
func (s *FFmpegSource) Close() error {
|
|
if s.stdout != nil {
|
|
s.stdout.Close()
|
|
}
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
s.cmd.Process.Kill()
|
|
s.cmd.Wait()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResampledSource wraps an AudioSource and resamples to a target sample rate
|
|
type ResampledSource struct {
|
|
source AudioSource
|
|
resampler *Resampler
|
|
targetRate int
|
|
inputBuffer []int32
|
|
outputBuffer []int32
|
|
}
|
|
|
|
func NewResampledSource(source AudioSource, targetRate int) *ResampledSource {
|
|
inputRate := source.SampleRate()
|
|
channels := source.Channels()
|
|
|
|
inputSamples := (inputRate * channels * 100) / 1000
|
|
outputSamples := (targetRate * channels * 100) / 1000
|
|
|
|
return &ResampledSource{
|
|
source: source,
|
|
resampler: NewResampler(inputRate, targetRate, channels),
|
|
targetRate: targetRate,
|
|
inputBuffer: make([]int32, inputSamples),
|
|
outputBuffer: make([]int32, outputSamples*2), // Extra space for safety
|
|
}
|
|
}
|
|
|
|
func (r *ResampledSource) Read(samples []int32) (int, error) {
|
|
neededInput := r.resampler.InputSamplesNeeded(len(samples))
|
|
if neededInput > len(r.inputBuffer) {
|
|
neededInput = len(r.inputBuffer)
|
|
}
|
|
|
|
n, err := r.source.Read(r.inputBuffer[:neededInput])
|
|
if err != nil && err != io.EOF {
|
|
return 0, err
|
|
}
|
|
|
|
outputSamples := r.resampler.Resample(r.inputBuffer[:n], samples)
|
|
|
|
return outputSamples, nil
|
|
}
|
|
|
|
func (r *ResampledSource) SampleRate() int {
|
|
return r.targetRate
|
|
}
|
|
|
|
func (r *ResampledSource) Channels() int {
|
|
return r.source.Channels()
|
|
}
|
|
|
|
func (r *ResampledSource) Metadata() (string, string, string) {
|
|
return r.source.Metadata()
|
|
}
|
|
|
|
func (r *ResampledSource) Close() error {
|
|
return r.source.Close()
|
|
}
|