- audio.StemSeparator/StemSeparationProvider: Demucs zip transport via POST /upstream/<id>/v1/stems (Mode two -> two_stems=vocals; model + format fields); bounded zip unpack, entry name -> stem, ext -> MIME. - SFXModel reuses musicgen against the sync /upstream/<id>/v1/sfx route (JSON prompt/seconds/steps/cfg_scale/seed -> WAV); musicgen.Request gains CFGScale. - audio.SpeechEnhancer/SpeechEnhancementProvider: POST /upstream/<id>/v1/enhance -> WAV (result reuses SpeechResult). - SpeechRequest.ReferenceAudio/ReferenceMIME (+WithReferenceAudio): llamaswap switches to the chatterbox clone route POST /upstream/<id>/v1/audio/speech/upload (input + voice_file), wav MIME fallback. - TranscriptionRequest.Translate (+WithTranslate): translate=true form field, language=auto forced when no explicit hint (whisper.cpp default en would skip translation). - httptest contract tests (zip unpack, clone-route switch, translate + auto-language injection); ADR-0024 (index row deferred — MJ-A backfills the ADR index table and parallel edits would conflict). Co-Authored-By: Claude Fable 5 <[email protected]>
180 lines
6.0 KiB
Go
180 lines
6.0 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/audio"
|
|
)
|
|
|
|
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 TestSpeakWithReferenceDefaultsToWavMIME(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([]byte("RAWAUDIO"))
|
|
}))
|
|
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)
|
|
}
|
|
// The clone route's default container is wav, not the JSON route's mp3.
|
|
if res.MIME != "audio/wav" {
|
|
t.Errorf("MIME = %q, want audio/wav fallback", res.MIME)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|