Files
talk/internal/audio/mp3.go
T
m3ta-chiron 7a843ae441 feat(audio): mp3 dekodieren für Cloud-Provider (reiner Go-Dekoder)
oto-Sink bekommt eine Format-Weiche: RIFF/WAVE-PCM (osbuiltin) und MP3
(ElevenLabs/OpenAI/Edge ab Task 03). Dekoder ist github.com/hajimehoshi/
go-mp3 v0.3.4 — reines Go ohne CGO, verifiziert per GOOS/GOARCH-Matrix mit
CGO_ENABLED=0 (alle 6 Targets bauen). Der Dekoder liefert immer 16-Bit-LE
stereo (Mono-Quellen wie Edge 24 kHz werden gespiegelt); die Sample-Rate
kommt aus dem ersten Frame. Fixtures sind echte, einmalig erzeugte MP3s
(mono 24 kHz 48 kbit/s = Edge-Format, stereo 44,1 kHz = Cloud-typisch),
als Base64 eingebettet — Tests bleiben werkzeugfrei.
2026-10-18 16:33:46 +02:00

69 lines
2.4 KiB
Go

package audio
import (
"bytes"
"fmt"
"io"
mpg "github.com/hajimehoshi/go-mp3"
)
// decodeMP3 dekodiert MP3-Daten — das Lieferformat der Cloud-Provider ab
// Task 03 (ElevenLabs, OpenAI TTS, Edge-TTS) — in rohe PCM-Samples für
// oto.
//
// Entscheidung mp3-Dekodierung (Task 03): eingebauter, reiner Go-Dekoder
// github.com/hajimehoshi/go-mp3 v0.3.4 statt WAV/PCM-Anforderung bei den
// APIs. Begründung: (1) Alle drei Cloud-Provider liefern mp3 als ihr
// natives Format; nur OpenAI kennt response_format "wav"/"pcm", ElevenLabs
// und Edge-TTS aber nicht — eine einheitliche mp3-Pipeline hält die Kette
// symmetrisch. (2) Der Dekoder ist nachweislich frei von CGO (kein
// import "C" im Modul), damit bleibt die Cross-Compile-Verpflichtung
// (CGO_ENABLED=0 für alle 6 Targets) unangetastet — verifiziert per
// GOOS/GOARCH-Matrix-Build, siehe Task-03-Report.
//
// Der Dekoder liefert immer 16-Bit-PCM little-endian mit 2 Kanälen — auch
// für Mono-Quellen (etwa Edge-TTS: 24 kHz mono) wird auf Stereo
// gespiegelt; die Sample-Rate stammt aus dem ersten MP3-Frame.
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 {
return pcmFormat{}, nil, fmt.Errorf("mp3: dekodieren: %w", err)
}
if len(pcm) == 0 {
return pcmFormat{}, nil, fmt.Errorf("mp3: Dekodierung lieferte keine Samples")
}
return pcmFormat{
SampleRate: dec.SampleRate(),
Channels: 2, // go-mp3 dekodiert grundsätzlich 2 Kanäle (s. doc)
BitsPerCoded: 16,
}, pcm, nil
}
// looksLikeMP3 meldet, ob data nach MP3 aussieht: ID3v2-Tag (führt mit
// "ID3") oder ein roher Frame-Sync (erstes Byte 0xFF, oberste 3 Bits des
// zweiten Bytes gesetzt).
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
}
// decodeAudio ist die Format-Weiche der Sinks: RIFF/WAVE (PCM) nach
// decodeWAV, MP3 (ID3-Tag oder Frame-Sync) nach decodeMP3. Alles andere
// fällt durch zu decodeWAV, das den präzisen „kein RIFF"-Fehler liefert.
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)
}