- 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
101 lines
3.6 KiB
Go
101 lines
3.6 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), maxAudioResponseBytes)
|
|
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
|
|
}
|