diff --git a/go.mod b/go.mod index 342444f..6159ab6 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index bd7c4fb..b405b7f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/audio/audio.go b/internal/audio/audio.go index d773bbd..a3f0972 100644 --- a/internal/audio/audio.go +++ b/internal/audio/audio.go @@ -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). diff --git a/internal/audio/doc.go b/internal/audio/doc.go index e52ab7c..215de41 100644 --- a/internal/audio/doc.go +++ b/internal/audio/doc.go @@ -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 diff --git a/internal/audio/mp3.go b/internal/audio/mp3.go new file mode 100644 index 0000000..94a8be1 --- /dev/null +++ b/internal/audio/mp3.go @@ -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) +} diff --git a/internal/audio/mp3_test.go b/internal/audio/mp3_test.go new file mode 100644 index 0000000..83a27be --- /dev/null +++ b/internal/audio/mp3_test.go @@ -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 = "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" + +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) + } +} diff --git a/internal/audio/oto.go b/internal/audio/oto.go index e0248ca..09b40a6 100644 --- a/internal/audio/oto.go +++ b/internal/audio/oto.go @@ -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 }