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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user