2026-10-18 16:33:46 +02:00
|
|
|
package audio
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
|
|
|
|
|
mpg "github.com/hajimehoshi/go-mp3"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// decodeMP3 decodes MP3 data, the delivery format of the cloud providers
|
|
|
|
|
// (ElevenLabs, OpenAI TTS, Edge TTS), into raw PCM samples for oto.
|
2026-10-18 16:33:46 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Decision: a built-in pure Go decoder (github.com/hajimehoshi/go-mp3
|
|
|
|
|
// v0.3.4) instead of requesting WAV/PCM from the APIs. Reasons: (1) all
|
|
|
|
|
// three cloud providers deliver mp3 as their native format; only OpenAI
|
|
|
|
|
// knows response_format "wav"/"pcm", ElevenLabs and Edge TTS do not, so a
|
|
|
|
|
// single mp3 pipeline keeps the chain symmetric. (2) The decoder is free of
|
|
|
|
|
// CGO (no import "C" in the module), which keeps the cross-compile
|
|
|
|
|
// commitment (CGO_ENABLED=0 for all 6 targets) intact, verified by a
|
|
|
|
|
// GOOS/GOARCH matrix build.
|
2026-10-18 16:33:46 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// The decoder always yields 16-bit little-endian PCM with 2 channels; mono
|
|
|
|
|
// sources (such as Edge TTS: 24 kHz mono) are mirrored to stereo. The
|
|
|
|
|
// sample rate comes from the first MP3 frame.
|
2026-10-18 16:33:46 +02:00
|
|
|
func decodeMP3(data []byte) (pcmFormat, []byte, error) {
|
|
|
|
|
dec, err := mpg.NewDecoder(bytes.NewReader(data))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: %w", err)
|
|
|
|
|
}
|
|
|
|
|
pcm, err := io.ReadAll(dec)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding: %w", err)
|
2026-10-18 16:33:46 +02:00
|
|
|
}
|
|
|
|
|
if len(pcm) == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding produced no samples")
|
2026-10-18 16:33:46 +02:00
|
|
|
}
|
|
|
|
|
return pcmFormat{
|
|
|
|
|
SampleRate: dec.SampleRate(),
|
2026-10-24 11:15:35 +02:00
|
|
|
Channels: 2, // go-mp3 always decodes to 2 channels (see its docs)
|
2026-10-18 16:33:46 +02:00
|
|
|
BitsPerCoded: 16,
|
|
|
|
|
}, pcm, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// looksLikeMP3 reports whether data looks like MP3: an ID3v2 tag (leading
|
|
|
|
|
// "ID3") or a raw frame sync (first byte 0xFF, top 3 bits of the second
|
|
|
|
|
// byte set).
|
2026-10-18 16:33:46 +02:00
|
|
|
func looksLikeMP3(data []byte) bool {
|
|
|
|
|
if len(data) >= 3 && string(data[0:3]) == "ID3" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// decodeAudio is the format switch of the sinks: RIFF/WAVE (PCM) goes to
|
|
|
|
|
// decodeWAV, MP3 (ID3 tag or frame sync) to decodeMP3. Everything else
|
|
|
|
|
// falls through to decodeWAV, which yields the precise "no RIFF" error.
|
2026-10-18 16:33:46 +02:00
|
|
|
func decodeAudio(data []byte) (pcmFormat, []byte, error) {
|
|
|
|
|
if len(data) >= 4 && string(data[0:4]) == "RIFF" {
|
|
|
|
|
return decodeWAV(data)
|
|
|
|
|
}
|
|
|
|
|
if looksLikeMP3(data) {
|
|
|
|
|
return decodeMP3(data)
|
|
|
|
|
}
|
|
|
|
|
return decodeWAV(data)
|
|
|
|
|
}
|