// 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//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 "" }