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.
69 lines
2.4 KiB
Go
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)
|
|
}
|