- speakWithReference now validates the response IS audio via the same audioResultMIME sniff the sfx/enhance surfaces use — a 2xx JSON soft error or HTML proxy page was previously wrapped up as audio/wav bytes. - audioResultMIME moves to audio.go (next to speechMIME; it was defined in sfx.go but shared by enhance/clone) and learns the Ogg container normalization (application/ogg -> audio/ogg). - Stems zip unpack gains entry-count (16) and total-decompressed (1GB) caps on top of the existing per-entry cap — the per-entry bound alone still let a many-entry bomb multiply up. - sanitizeFilename drops NUL and both path separators too, so upload metadata can never smuggle directory structure to a file-writing shim. - New maxAudioResponseBytes (256MB) for bodies that ARE one audio clip (clone, enhance, sfx): a long WAV legitimately passes the 64MB JSON cap. - speakWithReference local renamed path -> upPath (naming parity with stems/enhance). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WWCQcYStWXBYUy5sZWnbLT
216 lines
7.3 KiB
Go
216 lines
7.3 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/audio"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
func TestSpeakWithReferenceUsesCloneRoute(t *testing.T) {
|
|
var gotPath, gotInput, gotVoice, gotFilename string
|
|
var gotRef []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
gotInput = r.FormValue("input")
|
|
gotVoice = r.FormValue("voice")
|
|
f, hdr, err := r.FormFile("voice_file")
|
|
if err != nil {
|
|
t.Fatalf("voice_file part: %v", err)
|
|
}
|
|
defer f.Close()
|
|
gotRef, _ = io.ReadAll(f)
|
|
gotFilename = hdr.Filename
|
|
w.Header().Set("Content-Type", "audio/wav")
|
|
_, _ = w.Write(wavFixture())
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("chatterbox")
|
|
res, err := sm.Speak(context.Background(),
|
|
audio.SpeechRequest{Input: "hello in my voice", Voice: "narrator"},
|
|
audio.WithReferenceAudio([]byte("REFWAV"), "audio/wav"))
|
|
if err != nil {
|
|
t.Fatalf("Speak: %v", err)
|
|
}
|
|
if gotPath != "/upstream/chatterbox/v1/audio/speech/upload" {
|
|
t.Errorf("path = %q, want clone route", gotPath)
|
|
}
|
|
if gotInput != "hello in my voice" || gotVoice != "narrator" {
|
|
t.Errorf("input/voice = %q/%q", gotInput, gotVoice)
|
|
}
|
|
if string(gotRef) != "REFWAV" || gotFilename != "audio.wav" {
|
|
t.Errorf("ref = %q name = %q", gotRef, gotFilename)
|
|
}
|
|
if res.MIME != "audio/wav" || len(res.Audio) == 0 {
|
|
t.Errorf("result = %q/%d bytes", res.MIME, len(res.Audio))
|
|
}
|
|
}
|
|
|
|
func TestSpeakWithReferenceSniffsHeaderlessWav(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
for _, k := range []string{"voice", "response_format", "speed"} {
|
|
if v, ok := r.MultipartForm.Value[k]; ok {
|
|
t.Errorf("unset request sent %q = %v, want omitted", k, v)
|
|
}
|
|
}
|
|
w.Header()["Content-Type"] = nil // no declared type at all
|
|
_, _ = w.Write(wavFixture())
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("chatterbox")
|
|
res, err := sm.Speak(context.Background(),
|
|
audio.SpeechRequest{Input: "hi"},
|
|
audio.WithReferenceAudio([]byte("REF"), ""))
|
|
if err != nil {
|
|
t.Fatalf("Speak: %v", err)
|
|
}
|
|
// Headerless RIFF sniffs audio/wave, normalized to the conventional wav.
|
|
if res.MIME != "audio/wav" {
|
|
t.Errorf("MIME = %q, want sniffed audio/wav", res.MIME)
|
|
}
|
|
}
|
|
|
|
func TestSpeakWithReferenceRejectsNonAudioResponse(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// A FastAPI-style 2xx soft error must not be wrapped up as audio.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"detail":"reference audio too short"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("chatterbox")
|
|
_, err := sm.Speak(context.Background(),
|
|
audio.SpeechRequest{Input: "hi"},
|
|
audio.WithReferenceAudio([]byte("REF"), "audio/wav"))
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %v, want APIError for non-audio clone body", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeFilenameStripsPathAndControlBytes(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"song.mp3": "song.mp3",
|
|
"../../etc/passwd": "....etcpasswd",
|
|
"a\r\nContent-Type: evil": "aContent-Type: evil",
|
|
"..\\..\\boot.ini": "....boot.ini",
|
|
"nul\x00byte.wav": "nulbyte.wav",
|
|
" / ": "",
|
|
} {
|
|
if got := sanitizeFilename(in); got != want {
|
|
t.Errorf("sanitizeFilename(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSpeakWithoutReferenceKeepsJSONRoute(t *testing.T) {
|
|
var gotPath, gotContentType string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
_, _ = w.Write([]byte("MP3"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("chatterbox")
|
|
if _, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "hi"}); err != nil {
|
|
t.Fatalf("Speak: %v", err)
|
|
}
|
|
if gotPath != "/v1/audio/speech" || gotContentType != "application/json" {
|
|
t.Errorf("path/content-type = %q/%q, want JSON route", gotPath, gotContentType)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeTranslateForcesAutoLanguage(t *testing.T) {
|
|
var gotTranslate, gotLanguage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
gotTranslate = r.FormValue("translate")
|
|
gotLanguage = r.FormValue("language")
|
|
_, _ = w.Write([]byte(`{"text":"hello"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
tm, _ := p.TranscriptionModel("whisper-large-v3-turbo")
|
|
res, err := tm.Transcribe(context.Background(),
|
|
audio.TranscriptionRequest{Audio: []byte("AUDIO")},
|
|
audio.WithTranslate())
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
// whisper.cpp's server default language is "en", which would skip
|
|
// translation — translate must force auto-detection when no explicit
|
|
// language hint was given.
|
|
if gotTranslate != "true" || gotLanguage != "auto" {
|
|
t.Errorf("translate/language = %q/%q, want true/auto", gotTranslate, gotLanguage)
|
|
}
|
|
if res.Text != "hello" {
|
|
t.Errorf("text = %q", res.Text)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeTranslateKeepsExplicitLanguage(t *testing.T) {
|
|
var gotTranslate, gotLanguage string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
gotTranslate = r.FormValue("translate")
|
|
gotLanguage = r.FormValue("language")
|
|
_, _ = w.Write([]byte(`{"text":"hello"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
tm, _ := p.TranscriptionModel("whisper-large-v3-turbo")
|
|
if _, err := tm.Transcribe(context.Background(),
|
|
audio.TranscriptionRequest{Audio: []byte("AUDIO"), Language: "de", Translate: true}); err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if gotTranslate != "true" || gotLanguage != "de" {
|
|
t.Errorf("translate/language = %q/%q, want true/de", gotTranslate, gotLanguage)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeWithoutTranslateOmitsFields(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
for _, k := range []string{"translate", "language"} {
|
|
if v, ok := r.MultipartForm.Value[k]; ok {
|
|
t.Errorf("unset request sent %q = %v, want omitted", k, v)
|
|
}
|
|
}
|
|
_, _ = w.Write([]byte(`{"text":"hi"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
tm, _ := p.TranscriptionModel("whisper-large-v3-turbo")
|
|
if _, err := tm.Transcribe(context.Background(),
|
|
audio.TranscriptionRequest{Audio: []byte("AUDIO")}); err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
}
|