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.
This commit is contained in:
1 parent
ae9f3b680e
commit
7a843ae441
7 files changed
+211
-6
No files matched your search
@@ -13,7 +13,8 @@ const (
|
||||
)
|
||||
|
||||
// Sink gibt Audio-Bytes wieder. Die Bytes stammen aus Provider.Synthesize;
|
||||
// oto versteht RIFF/WAVE (PCM) — das Lieferformat von osbuiltin.
|
||||
// oto versteht RIFF/WAVE (PCM — das Lieferformat von osbuiltin) und MP3
|
||||
// (das Lieferformat der Cloud-Provider ElevenLabs/OpenAI/Edge).
|
||||
type Sink interface {
|
||||
// Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx
|
||||
// abgebrochen wird).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package audio implementiert die Audio-Ausgabe von talk über das
|
||||
// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM) und ein
|
||||
// null-Sink, das verwirft (CI, Server, Tests). Auswahl per Config
|
||||
// audio.sink = "oto" | "null" (Default oto).
|
||||
// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM und MP3, per
|
||||
// reinem Go-Dekodierer) und ein null-Sink, das verwirft (CI, Server,
|
||||
// Tests). Auswahl per Config audio.sink = "oto" | "null" (Default oto).
|
||||
package audio
|
||||
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Zwei einmalig erzeugte, echte MP3-Fixtures (ffmpeg am Dev-Host, in den
|
||||
// Test eingebettet — Tests bleiben zur Laufzeit frei von Werkzeug-Abhängig-
|
||||
// keiten). Beide entsprechen dem Lieferformat der Cloud-Provider aus
|
||||
// Task 03:
|
||||
//
|
||||
// fixtureMP3Mono: 0,15 s Stille, 24 kHz mono 48 kbit/s — exakt das
|
||||
// Edge-TTS-Ausgabeformat (audio-24khz-48kbitrate-mono-
|
||||
// mp3); übt den Mono→Stereo-Upsample-Pfad des Dekoders.
|
||||
// fixtureMP3Stereo: 0,15 s 440-Hz-Sinus, 44,1 kHz stereo 64 kbit/s — das
|
||||
// typische ElevenLabs/OpenAI-Format.
|
||||
const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
|
||||
|
||||
const fixtureMP3StereoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAHAAAGhgA/Pz8/Pz8/Pz8/Pz8/P19fX19fX19fX19fX19ff39/f39/f39/f39/f3+fn5+fn5+fn5+fn5+fn5+/v7+/v7+/v7+/v7+/v9/f39/f39/f39/f39/f//////////////////8AAAAATGF2YzYwLjMxAAAAAAAAAAAAAAAAJANpAAAAAAAABoYxZN5VAAAAAAAAAAAAAAAAAAAAAP/7UGQAAAGPB1PVMCAMAAANIKAAAQtkrUe5tQAAAAA0gwAAAAAAWLlbSAEsSzOM4EAEAaEx+7b9wfPggCAIawf1AgCByXB8P4gBCDju/o8/ynn+U9/QAAB54AwKBwMBwMBQAAFQYGzUQALpOGBjRoAS+wRiXRL4oTPxwxscc7rrbANAE+BRFYRX4CwgQbQbf8QQFYCkKoNv/iJIh8KxCPf/xVIh8PiEej3+oGhKEga/6AAAApR2UEOC4IEAFJQRgMbD4SaWFOYlBOYKASCQCLL/+1JkD4jxxgdL53QADAAADSDgAAEGsCEUDftiQAAANIAAAARAoAmvdKzM8GvTr7RI79DR2VcAU20f7f//oSHMQEjMz03SSMSce03/PSzfLHvMSwKQ4NnM9RTNDwzV1MZFVqS/AHAT+e8a7q9+L/6U/+z6FQAAwGRkDDijFFTKrjabzCXGENGzGA0TxejCIBdNEYs6apxxagJ1trIcXd1/JeZ/X/b/1+i936xuCQSSghxU5gAd/mCwEoZaKkBlfBKGC2BKeoGIwBGAMJiS/APLqv/7UmQuiPG2B8bLXsiQAAANIAAAAQZcHyVMewJQAAA0gAAABHKvo6//9u//q/7Nv1ogDEBcxUkMiRzSZwwMIINMWLQbzFZAgEwK8CEMwXMKCNMZNvIM+GXrLrPfy/0dBBK8xQOM5NTeoQxNRxzhW4hOCMckxOgmjhWUztHMwPjL30xUZXRK8Ac+5n3dP8U+jp/993R/1QAAABhG0CAAXeBgIBQM7kwRQ5jJeegMk8OAwPALy4SxQUY1PL0xYN871ZmKO7X/s/1f///oT4MeHNco//tSZFAN8XoIxYt/0JAAAA0gAAABBrQhFA37YkAAADSAAAAEPXrMRAXg3c72DdEF8MRUH8+VI2Do0rU0WoxhJl9JmCf/1f//////SgAAcCBADChEw0eMWPzNYwwlh3TRv77NE4dMwhQeDIZBxRxGnjoAsl+zwbtQl9P2Vywc//29RAABYoMCCKFka6wsBjBQuObW04mcTGIbCA2hPQBoB2V2wIYzS/1q///6k////+0AAD2/T7fb/b0YCAAACXCJpXpktZdKRqMMiYljiZYRljn/+1JkdA3xqAdI6z7AmAAADSAAAAEFtCEWDXtCQAAANIAAAAS1qIWFQOGBu1BDXyALuvctJN0DKUQSBydaGrcUrx5+AghClCpXselWdBCLUOQJbMQULhJKl5UtMt63+FjfdbQTuGrApeyZav7/e/////+LP9DMeiUThj/Ihg0EmkMQAAHEiVkSKOHEQCE4SASTkRCVR6IniVx4jOxjwVce/EU7vK1MQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7UmSZAPGrB8ZLfsiQAAANIAAAAQXUHTWVwAAwAAA0goAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//tSZL2AA+cuVO5nIAQAAA0gwAAABJgm3HxggAAAADSDgAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU="
|
||||
|
||||
func fixture(t *testing.T, b64 string) []byte {
|
||||
t.Helper()
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("Fixture dekodieren: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDecodeMP3Mono: das Edge-Format (24 kHz mono) dekodiert zu 16-Bit-PCM
|
||||
// stereo — der Dekoder spiegelt Mono auf zwei Kanäle, Samples sind 4 Bytes
|
||||
// (2 Kanäle × 2 Bytes) aligned.
|
||||
func TestDecodeMP3Mono(t *testing.T) {
|
||||
f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeMP3 (mono): %v", err)
|
||||
}
|
||||
if f.SampleRate != 24000 {
|
||||
t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
|
||||
}
|
||||
if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
|
||||
t.Errorf("Format = %+v, want 2 Kanäle/16 Bit/int", f)
|
||||
}
|
||||
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
||||
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar (16-Bit-Samples stereo)", len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMP3Stereo: ElevenLabs/OpenAI-typisches Format (44,1 kHz stereo).
|
||||
func TestDecodeMP3Stereo(t *testing.T) {
|
||||
f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeMP3 (stereo): %v", err)
|
||||
}
|
||||
if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
|
||||
t.Errorf("Format = %+v, want 44100 Hz stereo 16 Bit", f)
|
||||
}
|
||||
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
||||
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar", len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMP3Fehler: Daten, die nach MP3 aussehen, aber keine Frames
|
||||
// enthalten, sind ein klarer mp3-Fehler (nicht der WAV-Fehlerpfad).
|
||||
func TestDecodeMP3Fehler(t *testing.T) {
|
||||
cases := []struct{ name, want string }{
|
||||
{"Müll mit Frame-Sync", "mp3:"},
|
||||
{"ID3-Tag ohne Frames", "mp3:"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var data []byte
|
||||
switch tc.name {
|
||||
case "Müll mit Frame-Sync":
|
||||
data = []byte{0xFF, 0xFB, 'k', 'e', 'i', 'n', ' ', 'm', 'p', '3'}
|
||||
case "ID3-Tag ohne Frames":
|
||||
data = []byte("ID3\x04\x00\x00\x00\x00\x00\x00")
|
||||
}
|
||||
_, _, err := decodeMP3(data)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("decodeMP3-Fehler = %v, will Meldung mit %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLooksLikeMP3 pinnt die Format-Erkennung.
|
||||
func TestLooksLikeMP3(t *testing.T) {
|
||||
yes := [][]byte{
|
||||
fixture(t, fixtureMP3StereoB64), // ID3-Tag
|
||||
{0xFF, 0xFB, 0x00, 0x00}, // roher Frame-Sync
|
||||
}
|
||||
no := [][]byte{
|
||||
[]byte("RIFF....WAVE"),
|
||||
[]byte("kein audio"),
|
||||
{0xFF},
|
||||
{},
|
||||
}
|
||||
for _, d := range yes {
|
||||
if !looksLikeMP3(d) {
|
||||
t.Errorf("looksLikeMP3(%x) = false, want true", d[:min(4, len(d))])
|
||||
}
|
||||
}
|
||||
for _, d := range no {
|
||||
if looksLikeMP3(d) {
|
||||
t.Errorf("looksLikeMP3(%q) = true, want false", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeAudioWeiche: RIFF geht an den WAV-Parser, MP3 an den MP3-
|
||||
// Dekoder, alles andere fällt mit dem WAV-Fehler durch.
|
||||
func TestDecodeAudioWeiche(t *testing.T) {
|
||||
f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
|
||||
if err != nil || f.SampleRate != 22050 {
|
||||
t.Errorf("decodeAudio(WAV) = %+v, %v — soll den WAV-Pfad nehmen", f, err)
|
||||
}
|
||||
f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
|
||||
if err != nil || f.SampleRate != 24000 {
|
||||
t.Errorf("decodeAudio(MP3) = %+v, %v — soll den MP3-Pfad nehmen", f, err)
|
||||
}
|
||||
if _, _, err = decodeAudio([]byte("kein audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
|
||||
t.Errorf("decodeAudio(Müll) = %v, will WAV-Fehler", err)
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,12 @@ func contextFor(f pcmFormat) (*otoContext, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Play implementiert Sink: dekodiert WAV (RIFF/PCM) und spielt die Samples
|
||||
// Play implementiert Sink: dekodiert die Daten (RIFF/WAVE-PCM von osbuiltin
|
||||
// oder MP3 von den Cloud-Providern, siehe mp3.go) und spielt die Samples
|
||||
// blockierend ab. Fehler des Audio-Geräts oder des Formats werden klar
|
||||
// gemeldet.
|
||||
func (OtoSink) Play(ctx context.Context, data []byte) error {
|
||||
f, pcm, err := decodeWAV(data)
|
||||
f, pcm, err := decodeAudio(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user