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:
m3ta-chiron committed 2026-10-18 16:33:46 +02:00
1 parent ae9f3b680e
commit 7a843ae441
7 files changed
+211 -6

No files matched your search

+2
View File
@@ -5,6 +5,8 @@ go 1.26.8
require (
github.com/BurntSushi/toml v1.6.0
github.com/ebitengine/oto/v3 v3.5.0
github.com/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4
)
require (
+6
View File
@@ -4,7 +4,13 @@ github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GP
github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+2 -1
View File
@@ -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).
+3 -3
View File
@@ -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
+68
View File
@@ -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)
}
+127
View File
@@ -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)
}
}
+3 -2
View File
@@ -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
}