diff --git a/backend/internal/protocol/autodl_indextts2_test.go b/backend/internal/protocol/autodl_indextts2_test.go new file mode 100644 index 000000000..8a92092c6 --- /dev/null +++ b/backend/internal/protocol/autodl_indextts2_test.go @@ -0,0 +1,95 @@ +package protocol + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// IndexTTS2 的音色完全来自 prompt_simple 参考音频,缺失时上游返回“缺少必填参数”, +// 因此插件必须把画布连线的参考音频映射过去,并在本地拦截未连接音频的请求。 +func TestAutoDLIndexTTS2Workflow(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "autodl-comfyui.yingce-plugin")) + if err != nil { + t.Fatal(err) + } + pkg, err := ParsePluginPackage(data) + if err != nil { + t.Fatal(err) + } + adapters, err := LoadInstalledProviders(pkg.ManifestRaw, nil) + if err != nil { + t.Fatal(err) + } + var adapter Adapter + for _, a := range adapters { + if a.Metadata().ID == "autodl-comfyui-audio" { + adapter = a + } + } + if adapter == nil { + t.Fatal("autodl-comfyui-audio provider adapter not found") + } + if !adapter.Metadata().RequiresPublicMediaURLs { + t.Fatal("IndexTTS2 reference audio must be sent as public URLs") + } + + if _, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ + Capability: CapabilityAudio, Model: "indextts2-v1", Prompt: "你好,这是一段测试文本", + }}); err == nil || !strings.Contains(err.Error(), "音色参考音频") { + t.Fatalf("missing prompt_simple must be rejected locally, err = %v", err) + } + + create, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ + Capability: CapabilityAudio, Model: "indextts2-v1", Prompt: "你好,这是一段测试文本", + Audios: []MediaReference{{URL: "https://cdn.example/voice.mp3", Order: 0}}, + Extra: map[string]any{"audioVoice": "alloy"}, + }}) + if err != nil { + t.Fatal(err) + } + if create.Method != "POST" || create.Path != "/api/v1/comfyui/comfyui_workflow/indextts2-v1" { + t.Fatalf("create spec = %#v", create) + } + body := create.Body.(map[string]any) + if body["prompt_text"] != "你好,这是一段测试文本" || body["prompt_simple"] != "https://cdn.example/voice.mp3" || body["emo_control_method"] != "与音色参考音频相同" || body["emo_random"] != false { + t.Fatalf("single reference body = %#v", body) + } + for _, name := range []string{"emo_happy", "emo_angry", "emo_sad", "emo_afraid", "emo_disgusted", "emo_melancholic", "emo_surprised", "emo_calm"} { + if value, ok := body[name]; !ok || manifestFloat(value) != 0 { + t.Fatalf("%s = %#v, body = %#v", name, value, body) + } + } + for _, name := range []string{"emo_ref_audio", "voice", "prompt"} { + if _, exists := body[name]; exists { + t.Fatalf("single reference body must not send %s: %#v", name, body) + } + } + + withEmotion, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ + Capability: CapabilityAudio, Model: "indextts2-v1", Prompt: "测试", + Audios: []MediaReference{{URL: "https://cdn.example/emotion.mp3", Order: 2}, {URL: "https://cdn.example/voice.mp3", Order: 1}}, + ProviderOptions: map[string]map[string]any{"autodl-comfyui-audio": {"emo_happy": 0.5, "emo_calm": 0.3}}, + }}) + if err != nil { + t.Fatal(err) + } + emotionBody := withEmotion.Body.(map[string]any) + if emotionBody["prompt_simple"] != "https://cdn.example/voice.mp3" || emotionBody["emo_ref_audio"] != "https://cdn.example/emotion.mp3" || emotionBody["emo_control_method"] != "使用情感参考音频" || manifestFloat(emotionBody["emo_happy"]) != 0.5 || manifestFloat(emotionBody["emo_calm"]) != 0.3 { + t.Fatalf("emotion reference body = %#v", emotionBody) + } + + if _, err := adapter.BuildCreate(context.Background(), RequestContext{BaseURL: "https://autodl.art", Request: GenerationRequest{ + Capability: CapabilityAudio, Model: "indextts2-v1", Prompt: "测试", + Audios: []MediaReference{{URL: "https://cdn.example/a.mp3"}, {URL: "https://cdn.example/b.mp3", Order: 1}, {URL: "https://cdn.example/c.mp3", Order: 2}}, + }}); err == nil { + t.Fatal("more than two reference audios must be rejected") + } + + completed, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "671ce5ca"}, []byte(`{"msg":"","code":"Success","data":{"status":"completed","results":[{"url":"https://cos.example/ComfyUI_00010_.wav","type":"audio","node_id":"9","file_type":"wav","output_type":"output"}],"task_id":"671ce5ca"}}`)) + if err != nil || completed.Status != StatusSucceeded || completed.Result == nil || len(completed.Result.Audios) != 1 || completed.Result.Audios[0].URL != "https://cos.example/ComfyUI_00010_.wav" { + t.Fatalf("completed = %#v, err = %v", completed, err) + } +} diff --git a/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin index 8af8ce60d..a163beb68 100644 Binary files a/plugin-packages/autodl-comfyui.yingce-plugin and b/plugin-packages/autodl-comfyui.yingce-plugin differ diff --git a/web/src/components/audio-settings-panel.tsx b/web/src/components/audio-settings-panel.tsx index 22ee09864..7262614c7 100644 --- a/web/src/components/audio-settings-panel.tsx +++ b/web/src/components/audio-settings-panel.tsx @@ -7,7 +7,7 @@ import type { AiConfig } from "@/stores/use-config-store"; const speedOptions = ["0.75", "1", "1.25", "1.5"]; -type AudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions"; +type AudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions" | "audioEmotionControlMethod" | "audioEmotionRandom" | "audioEmotionHappy" | "audioEmotionAngry" | "audioEmotionSad" | "audioEmotionAfraid" | "audioEmotionDisgusted" | "audioEmotionMelancholic" | "audioEmotionSurprised" | "audioEmotionCalm"; type AudioSettingsPanelProps = { config: AiConfig; @@ -75,11 +75,46 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = onMouseDown={(event) => event.stopPropagation()} /> + + + +
+ {emotionFields.map(([key, label]) => ( + + ))} +
+
); } +const emotionFields: Array<[Extract, string]> = [ + ["audioEmotionHappy", "快乐"], ["audioEmotionAngry", "愤怒"], ["audioEmotionSad", "悲伤"], ["audioEmotionAfraid", "害怕"], + ["audioEmotionDisgusted", "厌恶"], ["audioEmotionMelancholic", "忧郁"], ["audioEmotionSurprised", "惊讶"], ["audioEmotionCalm", "平静"], +]; + +function normalizeEmotionWeight(value: string) { + const number = Number(value); + if (!Number.isFinite(number)) return "0"; + return String(Math.max(0, Math.min(1, number))); +} + function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { return (