Files
talk/internal/audio/mp3_test.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

128 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}