// 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() }