- 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]>
119 lines
4.2 KiB
Go
119 lines
4.2 KiB
Go
// sfx.go implements a second musicgen.Model factory against a Stable Audio
|
|
// Open shim (sfxgen) reached through llama-swap's /upstream passthrough
|
|
// (ADR-0024):
|
|
//
|
|
// POST /upstream/<id>/v1/sfx JSON {prompt, seconds?, steps?, cfg_scale?, seed?}
|
|
//
|
|
// Unlike the ACE-Step music path (music.go), /v1/sfx is SYNCHRONOUS: the
|
|
// response body is the finished WAV clip — no job queue, no polling. The
|
|
// surface reuses the musicgen types (a sound effect is a short audio clip
|
|
// from a text prompt); only the provider method differs.
|
|
package llamaswap
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/musicgen"
|
|
)
|
|
|
|
// SFXModel returns a musicgen.Model bound to a sync sound-effect backend.
|
|
// The id selects which upstream llama-swap loads.
|
|
func (p *Provider) SFXModel(id string, opts ...musicgen.ModelOption) (musicgen.Model, error) {
|
|
if err := p.requireBaseURL(); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = musicgen.ApplyModelOptions(opts)
|
|
return &sfxModel{p: p, id: id}, nil
|
|
}
|
|
|
|
type sfxModel struct {
|
|
p *Provider
|
|
id string
|
|
}
|
|
|
|
// sfxRequest is the sfxgen shim's /v1/sfx shape. Optional fields stay off
|
|
// the wire so the model's own defaults apply.
|
|
type sfxRequest struct {
|
|
Prompt string `json:"prompt"`
|
|
Seconds int `json:"seconds,omitempty"`
|
|
Steps *int `json:"steps,omitempty"`
|
|
CFGScale *float64 `json:"cfg_scale,omitempty"`
|
|
Seed *int64 `json:"seed,omitempty"`
|
|
}
|
|
|
|
// Generate implements musicgen.Model. The clip-length ceiling (~11s for
|
|
// Stable Audio Open Small) is the backend's to enforce, not this client's.
|
|
func (m *sfxModel) Generate(ctx context.Context, req musicgen.Request, opts ...musicgen.Option) (*musicgen.Result, error) {
|
|
req = req.Apply(opts...)
|
|
if strings.TrimSpace(req.Prompt) == "" {
|
|
return nil, fmt.Errorf("%w: sfx generation requires a prompt", llm.ErrUnsupported)
|
|
}
|
|
if req.Lyrics != "" {
|
|
return nil, fmt.Errorf("%w: sfx generation does not support lyrics", llm.ErrUnsupported)
|
|
}
|
|
if req.DurationSeconds < 0 {
|
|
return nil, fmt.Errorf("%w: duration must be >= 0, got %d", llm.ErrUnsupported, req.DurationSeconds)
|
|
}
|
|
if req.Steps != nil && *req.Steps <= 0 {
|
|
return nil, fmt.Errorf("%w: inference steps must be > 0, got %d", llm.ErrUnsupported, *req.Steps)
|
|
}
|
|
// The endpoint emits WAV only; a caller asking for another container
|
|
// would silently get mislabelled bytes — reject instead.
|
|
if f := strings.ToLower(strings.TrimSpace(req.Format)); f != "" && f != "wav" {
|
|
return nil, fmt.Errorf("%w: sfx output is wav only, got format %q", llm.ErrUnsupported, req.Format)
|
|
}
|
|
|
|
path, err := upstreamPath(m.id, "/v1/sfx")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wire := sfxRequest{
|
|
Prompt: req.Prompt,
|
|
Seconds: req.DurationSeconds,
|
|
Steps: req.Steps,
|
|
CFGScale: req.CFGScale,
|
|
Seed: req.Seed,
|
|
}
|
|
encoded, err := json.Marshal(wire)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("llama-swap: encode sfx request: %w", err)
|
|
}
|
|
raw, contentType, err := m.p.doRaw(ctx, http.MethodPost, path, m.id, "application/json", bytes.NewReader(encoded), maxResponseBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "sfx response contained no audio"}
|
|
}
|
|
mimeType := audioResultMIME(contentType, raw)
|
|
if mimeType == "" {
|
|
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
|
|
Message: fmt.Sprintf("sfx response is not audio (Content-Type %q): %s", contentType, truncateForError(raw))}
|
|
}
|
|
return &musicgen.Result{Audio: musicgen.Audio{Data: raw, MIME: mimeType}}, nil
|
|
}
|
|
|
|
// audioResultMIME resolves an audio body's MIME type: the response
|
|
// Content-Type when it is a concrete audio type, else content sniffing
|
|
// (RIFF/WAVE and friends), else "" — the caller treats undetectable as an
|
|
// upstream error, mirroring videoMIME. Sniffed "audio/wave" is normalized
|
|
// to the conventional "audio/wav".
|
|
func audioResultMIME(contentType string, data []byte) string {
|
|
if mt := mimeFromContentType(contentType, "audio/"); mt != "" {
|
|
return mt
|
|
}
|
|
if mt := http.DetectContentType(data); strings.HasPrefix(mt, "audio/") {
|
|
if mt == "audio/wave" {
|
|
return "audio/wav"
|
|
}
|
|
return mt
|
|
}
|
|
return ""
|
|
}
|