434d721b99
- New leaf package `audio` (ADR-0017): SpeechModel/SpeechProvider and TranscriptionModel/TranscriptionProvider with imagegen conventions (zero value = backend default, functional options + Apply, bytes in/out, never URLs). Root re-exports added. - imagegen.Editor (ADR-0018): optional image-to-image interface — EditRequest carries the generation knobs plus Init image and denoising Strength; separate interface so existing Models keep compiling. - provider/llamaswap implements all of it: POST /v1/audio/speech (JSON, raw-audio response, MIME from Content-Type with format fallback), POST /v1/audio/transcriptions (multipart, response_format=json), ListVoices (GET /v1/audio/voices?model=, tolerant of string-list and object-list shapes), POST /sdapi/v1/img2img (txt2img wire + init_images/denoising_strength, shared image decode), and Health(ctx) (GET /health) — a cheap liveness probe for often-offline hosts. - Hermetic httptest coverage for every new wire shape and validation path; README sections + support-matrix footnote updated in the same commit (also corrects the stale /v1/images/generations claim — the image path has been SDAPI since the seed fix). First consumer: mort's llamaswap media tool cluster (status / image / TTS / STT agent tools against the netherstorm host). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
248 lines
7.5 KiB
Go
248 lines
7.5 KiB
Go
package llamaswap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/audio"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
)
|
|
|
|
func TestSpeak(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/audio/speech" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer tok" {
|
|
t.Errorf("auth = %q", r.Header.Get("Authorization"))
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
w.Header().Set("Content-Type", "audio/mpeg")
|
|
_, _ = w.Write([]byte("MP3BYTES"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithToken("tok"), WithHTTPClient(srv.Client()))
|
|
sm, err := p.SpeechModel("kokoro")
|
|
if err != nil {
|
|
t.Fatalf("SpeechModel: %v", err)
|
|
}
|
|
res, err := sm.Speak(context.Background(),
|
|
audio.SpeechRequest{Input: "hello world"},
|
|
audio.WithVoice("af_heart"), audio.WithFormat("mp3"), audio.WithSpeed(1.2),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Speak: %v", err)
|
|
}
|
|
if string(res.Audio) != "MP3BYTES" || res.MIME != "audio/mpeg" {
|
|
t.Errorf("result = %q %q", res.Audio, res.MIME)
|
|
}
|
|
want := map[string]any{"model": "kokoro", "input": "hello world", "voice": "af_heart", "response_format": "mp3", "speed": 1.2}
|
|
for k, w := range want {
|
|
if gotBody[k] != w {
|
|
t.Errorf("%s = %v, want %v", k, gotBody[k], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSpeakDefaultsOmitted(t *testing.T) {
|
|
var gotBody map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
|
_, _ = w.Write([]byte("x"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("kokoro")
|
|
res, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "hi"})
|
|
if err != nil {
|
|
t.Fatalf("Speak: %v", err)
|
|
}
|
|
for _, k := range []string{"voice", "response_format", "speed"} {
|
|
if v, ok := gotBody[k]; ok {
|
|
t.Errorf("unset request sent %q = %v, want omitted", k, v)
|
|
}
|
|
}
|
|
// No usable Content-Type and no requested format → mp3 default.
|
|
if res.MIME != "audio/mpeg" {
|
|
t.Errorf("MIME = %q, want audio/mpeg fallback", res.MIME)
|
|
}
|
|
}
|
|
|
|
func TestSpeakEmptyInput(t *testing.T) {
|
|
p := New(WithBaseURL("http://example.invalid"))
|
|
sm, _ := p.SpeechModel("kokoro")
|
|
if _, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: " "}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("err = %v, want ErrUnsupported", err)
|
|
}
|
|
}
|
|
|
|
func TestSpeechMIME(t *testing.T) {
|
|
cases := []struct {
|
|
contentType, format, want string
|
|
}{
|
|
{"audio/ogg; codecs=opus", "mp3", "audio/ogg"}, // concrete header wins
|
|
{"application/octet-stream", "wav", "audio/wav"},
|
|
{"", "", "audio/mpeg"},
|
|
{"", "opus", "audio/ogg"},
|
|
{"text/plain", "flac", "audio/flac"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := speechMIME(tc.contentType, tc.format); got != tc.want {
|
|
t.Errorf("speechMIME(%q, %q) = %q, want %q", tc.contentType, tc.format, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscribe(t *testing.T) {
|
|
var gotFields map[string]string
|
|
var gotFile []byte
|
|
var gotFilename string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/audio/transcriptions" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
t.Fatalf("parse multipart: %v", err)
|
|
}
|
|
gotFields = map[string]string{}
|
|
for k, v := range r.MultipartForm.Value {
|
|
gotFields[k] = v[0]
|
|
}
|
|
f, hdr, err := r.FormFile("file")
|
|
if err != nil {
|
|
t.Fatalf("form file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
gotFile, _ = io.ReadAll(f)
|
|
gotFilename = hdr.Filename
|
|
_, _ = w.Write([]byte(`{"text":"hello there"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
tm, err := p.TranscriptionModel("whisper")
|
|
if err != nil {
|
|
t.Fatalf("TranscriptionModel: %v", err)
|
|
}
|
|
res, err := tm.Transcribe(context.Background(),
|
|
audio.TranscriptionRequest{Audio: []byte("AUDIO"), MIME: "audio/mpeg"},
|
|
audio.WithLanguage("en"), audio.WithPrompt("robot names"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Transcribe: %v", err)
|
|
}
|
|
if res.Text != "hello there" {
|
|
t.Errorf("text = %q", res.Text)
|
|
}
|
|
if string(gotFile) != "AUDIO" || gotFilename != "audio.mp3" {
|
|
t.Errorf("file = %q name = %q", gotFile, gotFilename)
|
|
}
|
|
want := map[string]string{"model": "whisper", "language": "en", "prompt": "robot names", "response_format": "json"}
|
|
for k, w := range want {
|
|
if gotFields[k] != w {
|
|
t.Errorf("%s = %q, want %q", k, gotFields[k], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTranscribeEmptyAudio(t *testing.T) {
|
|
p := New(WithBaseURL("http://example.invalid"))
|
|
tm, _ := p.TranscriptionModel("whisper")
|
|
if _, err := tm.Transcribe(context.Background(), audio.TranscriptionRequest{}); !errors.Is(err, llm.ErrUnsupported) {
|
|
t.Errorf("err = %v, want ErrUnsupported", err)
|
|
}
|
|
}
|
|
|
|
func TestListVoices(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/audio/voices" {
|
|
t.Errorf("path = %q", r.URL.Path)
|
|
}
|
|
if got := r.URL.Query().Get("model"); got != "kokoro" {
|
|
t.Errorf("model = %q", got)
|
|
}
|
|
_, _ = w.Write([]byte(`{"voices":["af_heart","af_bella"]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
voices, err := p.ListVoices(context.Background(), "kokoro")
|
|
if err != nil {
|
|
t.Fatalf("ListVoices: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(voices, []string{"af_heart", "af_bella"}) {
|
|
t.Errorf("voices = %v", voices)
|
|
}
|
|
|
|
if _, err := p.ListVoices(context.Background(), ""); err == nil {
|
|
t.Error("empty model: want error")
|
|
}
|
|
}
|
|
|
|
func TestParseVoicesShapes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want []string
|
|
}{
|
|
{"envelope strings", `{"voices":["a","b"]}`, []string{"a", "b"}},
|
|
{"bare array", `["a","b"]`, []string{"a", "b"}},
|
|
{"objects by id", `{"voices":[{"id":"a"},{"id":"b"}]}`, []string{"a", "b"}},
|
|
{"objects by name", `{"data":[{"name":"a"},{"voice_id":"b"}]}`, []string{"a", "b"}},
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := parseVoices([]byte(tc.raw))
|
|
if err != nil {
|
|
t.Errorf("%s: %v", tc.name, err)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
if _, err := parseVoices([]byte(`"just a string"`)); err == nil {
|
|
t.Error("unparseable shape: want error")
|
|
}
|
|
}
|
|
|
|
func TestAudioAPIError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"model is loading"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
|
|
sm, _ := p.SpeechModel("kokoro")
|
|
_, err := sm.Speak(context.Background(), audio.SpeechRequest{Input: "x"})
|
|
var apiErr *llm.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("err = %T %v, want *llm.APIError", err, err)
|
|
}
|
|
if apiErr.Status != http.StatusServiceUnavailable || apiErr.Message != "model is loading" || apiErr.Model != "kokoro" {
|
|
t.Errorf("apiErr = %+v", apiErr)
|
|
}
|
|
}
|
|
|
|
func TestAudioNoBaseURL(t *testing.T) {
|
|
p := New()
|
|
if _, err := p.SpeechModel("kokoro"); err == nil {
|
|
t.Error("SpeechModel: want error without base URL")
|
|
}
|
|
if _, err := p.TranscriptionModel("whisper"); err == nil {
|
|
t.Error("TranscriptionModel: want error without base URL")
|
|
}
|
|
if _, err := p.ListVoices(context.Background(), "kokoro"); err == nil {
|
|
t.Error("ListVoices: want error without base URL")
|
|
}
|
|
}
|