Files
majordomo/provider/llamaswap/music.go
T
steveandClaude Fable 5 fd8d56c3c1
Gadfly review (reusable) / review (pull_request) Successful in 15s
Adversarial Review (Gadfly) / review (pull_request) Successful in 15s
CI / Tidy (pull_request) Successful in 9m26s
CI / Build & Test (pull_request) Successful in 10m0s
fix: live-API corrections — music result shapes, mesh format honesty, mesh conversion
Round 2 from live smokes on netherstorm (2026-07-14):

- ACE-Step result blob is an ARRAY of objects and carries RAW control
  characters inside string values (literal newlines) — strict JSON
  rejected it. parseMusicResult sanitizes control chars (only legal
  inside string values in the double-encoded blob) and accepts array or
  object shapes. Regression test uses the live payload shape.
- Hunyuan3D GenerationRequest has NO output-format field (the documented
  type param is fiction) — it always returns GLB. Results are now
  labelled by sniffed magic bytes, never by the requested format.
- NEW meshgen.Converter/ConverterProvider optional surface + llamaswap
  impl over the mediautils shim POST /v1/convert_mesh — the STL hop for
  the printer pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-14 12:22:06 -04:00

294 lines
10 KiB
Go

// music.go implements musicgen.Provider against an ACE-Step-1.5-style API
// server reached through llama-swap's /upstream passthrough (ADR-0021):
//
// POST /upstream/<id>/release_task {prompt, lyrics, ...} -> {task_id}
// POST /upstream/<id>/query_result {task_id_list: [...]} -> status+result
// GET /upstream/<id>/<result file URL> -> audio bytes
//
// The backend is an async job queue; Generate wraps it into the blocking
// one-call contract by polling, so a context deadline is the caller's
// budget for the whole job (mort's tool timeout sits well under its
// agent-runtime ceiling for exactly this reason).
package llamaswap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/musicgen"
)
// musicPollInterval is the delay between query_result polls. Long enough to
// be polite to the queue, short enough that a ~10s xl-turbo song isn't
// dominated by poll latency. A var so tests can shrink it.
var musicPollInterval = 2 * time.Second
// musicPollMaxConsecutiveFailures bounds how many consecutive BAD polls
// (transport error, unparseable payload, task momentarily absent) are
// tolerated before aborting. A multi-minute GPU job must not die to one
// blip; a genuinely broken upstream still fails within ~5 intervals.
const musicPollMaxConsecutiveFailures = 5
// MusicModel implements musicgen.Provider. The id selects which upstream
// llama-swap loads.
func (p *Provider) MusicModel(id string, opts ...musicgen.ModelOption) (musicgen.Model, error) {
if err := p.requireBaseURL(); err != nil {
return nil, err
}
_ = musicgen.ApplyModelOptions(opts)
return &musicModel{p: p, id: id}, nil
}
type musicModel struct {
p *Provider
id string
}
// releaseTaskRequest is the ACE-Step POST /release_task shape. audio_duration
// is the v1 param name; verify against the upstream ACE-Step-1.5 repo's
// docs/en/API.md at smoke time — an
// unknown field is ignored upstream, degrading to the default clip length,
// never an error.
type releaseTaskRequest struct {
Prompt string `json:"prompt"`
Lyrics string `json:"lyrics,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
TaskType string `json:"task_type"`
AudioDuration int `json:"audio_duration,omitempty"`
InferenceSteps *int `json:"inference_steps,omitempty"`
Seed *int64 `json:"seed,omitempty"`
}
// queryItem is one task's poll state. `result` arrives as a JSON-encoded
// STRING (the ACE-Step API double-encodes it).
type queryItem struct {
TaskID string `json:"task_id"`
Status int `json:"status"` // 0 queued/running, 1 succeeded, 2 failed
Result string `json:"result"`
}
// musicResult is the useful subset of ACE-Step's double-encoded result
// blob.
type musicResult struct {
File string `json:"file"`
}
// parseMusicResult decodes the `result` string, tolerating two live-API
// quirks (observed 2026-07-14): the payload is an ARRAY of result objects
// (not a bare object), and string values can contain RAW control
// characters (a literal newline in timing fields) that strict JSON
// rejects. Control chars can only legally sit inside string values in the
// double-encoded blob, so replacing them with spaces preserves structure.
func parseMusicResult(blob string) (musicResult, bool) {
sanitized := strings.Map(func(r rune) rune {
if r < 0x20 {
return ' '
}
return r
}, blob)
var arr []musicResult
if err := json.Unmarshal([]byte(sanitized), &arr); err == nil && len(arr) > 0 {
return arr[0], true
}
var one musicResult
if err := json.Unmarshal([]byte(sanitized), &one); err == nil {
return one, true
}
return musicResult{}, false
}
// musicFormatMIME resolves the clip MIME from the response Content-Type
// or the requested format, reusing speechMIME's table (one format->MIME
// map for the whole provider). wav32 is ACE-Step-specific: normalize it
// to wav before the shared lookup.
func musicFormatMIME(contentType, format string) string {
format = strings.ToLower(strings.TrimSpace(format))
if format == "wav32" {
format = "wav"
}
return speechMIME(contentType, format)
}
// Generate implements musicgen.Model.
func (m *musicModel) 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: music generation requires a prompt", 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)
}
taskID, err := m.releaseTask(ctx, req)
if err != nil {
return nil, err
}
item, err := m.pollResult(ctx, taskID)
if err != nil {
return nil, err
}
return m.fetchResult(ctx, req.Format, item)
}
// releaseTask submits the job and returns its task id.
func (m *musicModel) releaseTask(ctx context.Context, req musicgen.Request) (string, error) {
path, err := upstreamPath(m.id, "/release_task")
if err != nil {
return "", err
}
wire := releaseTaskRequest{
Prompt: req.Prompt,
Lyrics: req.Lyrics,
AudioFormat: req.Format,
TaskType: "text2music",
AudioDuration: req.DurationSeconds,
InferenceSteps: req.Steps,
Seed: req.Seed,
}
// Tolerant envelope: {"data": {"task_id": ...}} per the docs, with a
// top-level fallback in case the wrapper changes.
var resp struct {
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
TaskID string `json:"task_id"`
}
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, &wire, &resp); err != nil {
return "", err
}
taskID := resp.Data.TaskID
if taskID == "" {
taskID = resp.TaskID
}
if taskID == "" {
return "", &llm.APIError{Provider: m.p.name, Model: m.id, Message: "release_task returned no task_id"}
}
return taskID, nil
}
// pollResult polls query_result until the task succeeds, fails, or ctx
// expires. Transient trouble — a transport blip, a momentarily
// unparseable payload, the task briefly absent from the response — is
// tolerated up to musicPollMaxConsecutiveFailures in a row: a
// multi-minute exclusive-GPU job must not die to one flaky poll. Only an
// explicit status=2, a run of consecutive failures, or the ctx deadline
// aborts.
func (m *musicModel) pollResult(ctx context.Context, taskID string) (*queryItem, error) {
path, err := upstreamPath(m.id, "/query_result")
if err != nil {
return nil, err
}
body := map[string]any{"task_id_list": []string{taskID}}
ticker := time.NewTicker(musicPollInterval)
defer ticker.Stop()
failures := 0
var lastErr error
for {
item, pollErr := m.pollOnce(ctx, path, body, taskID)
switch {
case pollErr != nil:
// Ctx expiry is never transient — bail with the deadline error.
if ctx.Err() != nil {
return nil, fmt.Errorf("llama-swap: music generation: %w", ctx.Err())
}
failures++
lastErr = pollErr
if failures >= musicPollMaxConsecutiveFailures {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: fmt.Sprintf("music poll failed %d times in a row: %v", failures, lastErr)}
}
case item.Status == 1:
return item, nil
case item.Status == 2:
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music generation failed upstream: " + truncateForError([]byte(item.Result))}
default:
failures = 0 // healthy queued/running poll
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("llama-swap: music generation: %w", ctx.Err())
case <-ticker.C:
}
}
}
// pollOnce performs one query_result round trip and locates the task.
func (m *musicModel) pollOnce(ctx context.Context, path string, body any, taskID string) (*queryItem, error) {
// Tolerant envelope: items under "data" or a bare array.
var raw json.RawMessage
if err := m.p.doJSON(ctx, http.MethodPost, path, m.id, body, &raw); err != nil {
return nil, err
}
return findQueryItem(raw, taskID)
}
// findQueryItem digs the task's entry out of the query_result payload,
// tolerating {"data": [...]}, {"data": {...}}, and bare-array envelopes.
func findQueryItem(raw json.RawMessage, taskID string) (*queryItem, error) {
var env struct {
Data json.RawMessage `json:"data"`
}
candidates := raw
if json.Unmarshal(raw, &env) == nil && len(env.Data) > 0 {
candidates = env.Data
}
var items []queryItem
if err := json.Unmarshal(candidates, &items); err != nil {
var one queryItem
if err := json.Unmarshal(candidates, &one); err != nil {
return nil, fmt.Errorf("unrecognized query_result payload shape")
}
items = []queryItem{one}
}
for i := range items {
// Single-item responses without a task_id echo are assumed to be
// ours — we only ever poll one task; a mismatch surfaces as a
// transient miss and is retried by the caller.
if items[i].TaskID == taskID || (items[i].TaskID == "" && len(items) == 1) {
return &items[i], nil
}
}
return nil, fmt.Errorf("query_result did not include task %s", taskID)
}
// fetchResult downloads the finished clip named by the job's result blob.
func (m *musicModel) fetchResult(ctx context.Context, format string, item *queryItem) (*musicgen.Result, error) {
result, ok := parseMusicResult(item.Result)
if !ok || result.File == "" {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music result blob missing file URL: " + truncateForError([]byte(item.Result))}
}
// The file URL is server-relative (e.g. "/v1/audio?path=..."); route it
// back through the same upstream. upstreamPath additionally refuses
// dot-dot/scheme smuggling in this SERVER-SUPPLIED value.
if !strings.HasPrefix(result.File, "/") {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id,
Message: "music result file URL is not server-relative: " + truncateForError([]byte(result.File))}
}
path, err := upstreamPath(m.id, result.File)
if err != nil {
return nil, err
}
raw, contentType, err := m.p.doRaw(ctx, http.MethodGet, path, m.id, "", nil, maxResponseBytes)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "music response contained no audio"}
}
mimeType := musicFormatMIME(contentType, format)
return &musicgen.Result{
Audio: musicgen.Audio{Data: raw, MIME: mimeType},
Raw: json.RawMessage(item.Result),
}, nil
}