Add Comfy Cloud Audio POC nodes

Amp-Thread-ID: https://ampcode.com/threads/T-019fd081-33af-7539-8c76-fd13e47ec584
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Hunter Senft-Grupp
2026-08-05 06:23:54 +00:00
parent f4da7a827b
commit b4405c501a
3 changed files with 313 additions and 0 deletions

View File

@@ -20,6 +20,12 @@ ComfyCloudWorkflow = Literal[
"video.ltx-2-3-first-last-frame.v1",
"video.wan-2-2-14b-first-last-frame.v1",
"video.scail-2-character-replacement.v1",
"audio.ace-step-1-5-xl-turbo.v1",
"audio.stable-audio-3-medium.v1",
"audio.chatterbox-multilingual-voice-clone.v1",
"audio.chatterbox-dialogue.v1",
"audio.chatterbox-voice-conversion.v1",
"audio.melbandroformer-stem-separation.v1",
]
@@ -45,6 +51,19 @@ class ComfyCloudWorkflowInputs(BaseModel):
scene_prompt: str | None = Field(None)
driving_subject: str | None = Field(None)
reference_subject: str | None = Field(None)
style_prompt: str | None = Field(None)
lyrics: str | None = Field(None)
bpm: int | None = Field(None)
time_signature: str | None = Field(None)
language: str | None = Field(None)
key: str | None = Field(None)
expand_prompt: bool | None = Field(None)
category: str | None = Field(None)
text: str | None = Field(None)
exaggeration: float | None = Field(None)
cfg_weight: float | None = Field(None)
temperature: float | None = Field(None)
script: str | None = Field(None)
class ComfyCloudAssetInput(BaseModel):
@@ -69,4 +88,5 @@ class ComfyCloudStatusResponse(BaseModel):
status: str = Field(...)
progress: float | None = Field(None)
output_url: str | None = Field(None)
output_urls: dict[str, str] | None = Field(None)
error: str | None = Field(None)

View File

@@ -13,6 +13,7 @@ from comfy_api_nodes.apis.comfy_cloud import (
)
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_audio_input,
download_url_to_image_tensor,
download_url_to_video_output,
get_number_of_images,
@@ -566,6 +567,194 @@ class ComfyCloudSCAIL2CharacterReplacementNode(IO.ComfyNode):
return await _run_video_workflow(cls, "video.scail-2-character-replacement.v1", ComfyCloudWorkflowInputs(scene_prompt=scene_prompt, driving_subject=driving_subject, reference_subject=reference_subject, reference_character_url=image_url, driving_video_url=video_url, seed=seed))
_UINT32_MAX = 0xFFFFFFFF
_ACE_LANGUAGES = ["ar", "az", "bg", "bn", "ca", "cs", "da", "de", "el", "en", "es", "fa", "fi", "fr", "he", "hi", "hr", "ht", "hu", "id", "is", "it", "ja", "ko", "la", "lt", "ms", "ne", "nl", "no", "pa", "pl", "pt", "ro", "ru", "sa", "sk", "sr", "sv", "sw", "ta", "te", "th", "tl", "tr", "uk", "ur", "vi", "yue", "zh", "unknown"]
_ACE_KEYS = [f"{root} {mode}" for mode in ("major", "minor") for root in ("C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B")]
_CHATTERBOX_LANGUAGES = ["Arabic (ar)", "Danish (da)", "German (de)", "Greek (el)", "English (en)", "Spanish (es)", "Finnish (fi)", "French (fr)", "Hebrew (he)", "Hindi (hi)", "Italian (it)", "Japanese (ja)", "Korean (ko)", "Malay (ms)", "Dutch (nl)", "Norwegian (no)", "Polish (pl)", "Portuguese (pt)", "Russian (ru)", "Swedish (sv)", "Swahili (sw)", "Turkish (tr)", "Chinese (zh)"]
def _audio_schema(node_id: str, display_name: str, inputs: list[IO.Input], outputs: list[IO.Output] | None = None) -> IO.Schema:
return IO.Schema(
node_id=node_id,
display_name=display_name,
category="partner/audio/Comfy Cloud",
inputs=inputs,
outputs=outputs or [IO.Audio.Output()],
hidden=[IO.Hidden.auth_token_comfy_org, IO.Hidden.api_key_comfy_org, IO.Hidden.unique_id],
is_api_node=True,
)
def _audio_duration(audio: Input.Audio) -> float:
return audio["waveform"].shape[-1] / audio["sample_rate"]
def _validate_audio_duration(name: str, audio: Input.Audio, minimum: float, maximum: float) -> None:
duration = _audio_duration(audio)
if duration < minimum or duration > maximum:
raise ValueError(f"{name} duration must be between {minimum:g} and {maximum:g} seconds.")
async def _audio_asset(cls: type[IO.ComfyNode], name: str, audio: Input.Audio) -> dict[str, ComfyCloudAssetInput]:
return {name: ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, audio))}
async def _run_audio_workflow(cls: type[IO.ComfyNode], workflow: ComfyCloudWorkflow, inputs: ComfyCloudWorkflowInputs, output_names: tuple[str, ...] = ()) -> IO.NodeOutput:
task = await sync_op(cls, _GENERATE_ENDPOINT, response_model=ComfyCloudGenerateResponse, data=ComfyCloudGenerateRequest(workflow=workflow, inputs=inputs))
result = await poll_op(
cls,
ApiEndpoint(path=task.polling_url),
response_model=ComfyCloudStatusResponse,
status_extractor=lambda response: response.status,
progress_extractor=lambda response: response.progress,
cancel_endpoint=ApiEndpoint(path=task.cancel_url, method="POST"),
)
if output_names:
if not result.output_urls or any(not result.output_urls.get(name) for name in output_names):
detail = f": {result.error}" if result.error else ""
raise RuntimeError(f"Comfy Cloud task {result.task_id} completed without all named output URLs{detail}")
outputs = [await download_url_to_audio_input(result.output_urls[name], cls=cls) for name in output_names]
return IO.NodeOutput(*outputs)
if not result.output_url:
detail = f": {result.error}" if result.error else ""
raise RuntimeError(f"Comfy Cloud task {result.task_id} completed without an output URL{detail}")
return IO.NodeOutput(await download_url_to_audio_input(result.output_url, cls=cls))
class ComfyCloudACEStep15XLTurboNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudACEStep15XLTurboNode",
"ACE-Step 1.5 XL Turbo",
[
_prompt_input("style_prompt"),
IO.String.Input("lyrics", multiline=True, default=""),
IO.Float.Input("duration_seconds", default=120, min=10, max=300, step=0.1),
_seed_input(),
IO.Int.Input("bpm", default=120, min=10, max=300),
IO.Combo.Input("time_signature", options=["2", "3", "4", "6"], default="4"),
IO.Combo.Input("language", options=_ACE_LANGUAGES, default="en"),
IO.Combo.Input("key", options=_ACE_KEYS, default="E minor"),
],
)
@classmethod
async def execute(cls, style_prompt: str, lyrics: str, duration_seconds: float, seed: int, bpm: int, time_signature: str, language: str, key: str) -> IO.NodeOutput:
validate_string(style_prompt, min_length=1, max_length=4096, field_name="style_prompt")
validate_string(lyrics, min_length=0, max_length=20000, field_name="lyrics")
return await _run_audio_workflow(cls, "audio.ace-step-1-5-xl-turbo.v1", ComfyCloudWorkflowInputs(style_prompt=style_prompt, lyrics=lyrics, duration_seconds=duration_seconds, seed=seed, bpm=bpm, time_signature=time_signature, language=language, key=key))
class ComfyCloudStableAudio3MediumNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudStableAudio3MediumNode",
"Stable Audio 3 Medium",
[
_prompt_input(),
IO.Float.Input("duration_seconds", default=30, min=1, max=300, step=0.1),
_seed_input(),
IO.Boolean.Input("expand_prompt", default=True),
IO.Combo.Input("category", options=["Music", "Instrument", "SFX", "One-shot"], default="Music"),
],
)
@classmethod
async def execute(cls, prompt: str, duration_seconds: float, seed: int, expand_prompt: bool, category: str) -> IO.NodeOutput:
validate_string(prompt, min_length=1, max_length=4096)
return await _run_audio_workflow(cls, "audio.stable-audio-3-medium.v1", ComfyCloudWorkflowInputs(prompt=prompt, duration_seconds=duration_seconds, seed=seed, expand_prompt=expand_prompt, category=category))
class ComfyCloudChatterboxMultilingualVoiceCloneNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudChatterboxMultilingualVoiceCloneNode",
"Chatterbox Multilingual Voice Clone",
[
_prompt_input("text"), IO.Audio.Input("voice_reference"),
IO.Combo.Input("language", options=_CHATTERBOX_LANGUAGES, default="English (en)"),
IO.Float.Input("exaggeration", default=0.5, min=0, max=2, step=0.05),
IO.Float.Input("cfg_weight", default=0.5, min=0, max=1, step=0.05),
IO.Float.Input("temperature", default=0.8, min=0.05, max=2, step=0.05),
IO.Int.Input("seed", default=0, min=0, max=_UINT32_MAX, control_after_generate=True),
],
)
@classmethod
async def execute(cls, text: str, voice_reference: Input.Audio, language: str, exaggeration: float, cfg_weight: float, temperature: float, seed: int) -> IO.NodeOutput:
validate_string(text, min_length=1, max_length=5000, field_name="text")
_validate_audio_duration("Voice reference", voice_reference, 1, 30)
return await _run_audio_workflow(cls, "audio.chatterbox-multilingual-voice-clone.v1", ComfyCloudWorkflowInputs(text=text, assets=await _audio_asset(cls, "voice_reference", voice_reference), language=language, exaggeration=exaggeration, cfg_weight=cfg_weight, temperature=temperature, seed=seed))
class ComfyCloudChatterboxDialogueNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudChatterboxDialogueNode",
"Chatterbox Dialogue",
[
_prompt_input("script"), IO.Audio.Input("speaker_a_reference"), IO.Audio.Input("speaker_b_reference"),
IO.Float.Input("exaggeration", default=0.5, min=0.25, max=2, step=0.05),
IO.Float.Input("cfg_weight", default=0.5, min=0.2, max=1, step=0.05),
IO.Float.Input("temperature", default=0.8, min=0.05, max=5, step=0.05),
IO.Int.Input("seed", default=0, min=0, max=_UINT32_MAX, control_after_generate=True),
],
)
@classmethod
async def execute(cls, script: str, speaker_a_reference: Input.Audio, speaker_b_reference: Input.Audio, exaggeration: float, cfg_weight: float, temperature: float, seed: int) -> IO.NodeOutput:
validate_string(script, min_length=1, max_length=10000, field_name="script")
if any(line.strip() and not line.strip().startswith(("SPEAKER A:", "SPEAKER B:", "SPEAKER C:", "SPEAKER D:")) for line in script.splitlines()):
raise ValueError("Every nonblank utterance must start with SPEAKER A: through SPEAKER D:.")
_validate_audio_duration("Speaker A reference", speaker_a_reference, 1, 30)
_validate_audio_duration("Speaker B reference", speaker_b_reference, 1, 30)
assets = {
"speaker_a_reference": ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, speaker_a_reference)),
"speaker_b_reference": ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, speaker_b_reference)),
}
return await _run_audio_workflow(cls, "audio.chatterbox-dialogue.v1", ComfyCloudWorkflowInputs(script=script, assets=assets, exaggeration=exaggeration, cfg_weight=cfg_weight, temperature=temperature, seed=seed))
class ComfyCloudChatterboxVoiceConversionNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudChatterboxVoiceConversionNode",
"Chatterbox Voice Conversion",
[IO.Audio.Input("source_audio"), IO.Audio.Input("target_voice_reference"), IO.Int.Input("seed", default=0, min=0, max=_UINT32_MAX, control_after_generate=True)],
)
@classmethod
async def execute(cls, source_audio: Input.Audio, target_voice_reference: Input.Audio, seed: int) -> IO.NodeOutput:
_validate_audio_duration("Source audio", source_audio, 0.5, 300)
_validate_audio_duration("Target voice reference", target_voice_reference, 1, 30)
assets = {
"source_audio": ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, source_audio)),
"target_voice_reference": ComfyCloudAssetInput(type="AUDIO", url=await upload_audio_to_comfyapi(cls, target_voice_reference)),
}
return await _run_audio_workflow(cls, "audio.chatterbox-voice-conversion.v1", ComfyCloudWorkflowInputs(assets=assets, seed=seed))
class ComfyCloudMelBandRoFormerStemSeparationNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return _audio_schema(
"ComfyCloudMelBandRoFormerStemSeparationNode",
"MelBandRoFormer Stem Separation",
[IO.Audio.Input("audio")],
[IO.Audio.Output("vocals"), IO.Audio.Output("instruments")],
)
@classmethod
async def execute(cls, audio: Input.Audio) -> IO.NodeOutput:
_validate_audio_duration("Audio", audio, 0.5, 600)
return await _run_audio_workflow(cls, "audio.melbandroformer-stem-separation.v1", ComfyCloudWorkflowInputs(assets=await _audio_asset(cls, "audio", audio)), ("vocals", "instruments"))
class ComfyCloudExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
@@ -586,6 +775,12 @@ class ComfyCloudExtension(ComfyExtension):
ComfyCloudLTX23FirstLastFrameNode,
ComfyCloudWan22FirstLastFrameNode,
ComfyCloudSCAIL2CharacterReplacementNode,
ComfyCloudACEStep15XLTurboNode,
ComfyCloudStableAudio3MediumNode,
ComfyCloudChatterboxMultilingualVoiceCloneNode,
ComfyCloudChatterboxDialogueNode,
ComfyCloudChatterboxVoiceConversionNode,
ComfyCloudMelBandRoFormerStemSeparationNode,
]

View File

@@ -336,6 +336,104 @@ def test_download_cloud_audio_url_to_audio_input(monkeypatch):
audio_decode.assert_called_once_with(downloaded)
AUDIO_POC_NODES = [
(nodes_comfy_cloud.ComfyCloudACEStep15XLTurboNode, "audio.ace-step-1-5-xl-turbo.v1", ["style_prompt", "lyrics", "duration_seconds", "seed", "bpm", "time_signature", "language", "key"]),
(nodes_comfy_cloud.ComfyCloudStableAudio3MediumNode, "audio.stable-audio-3-medium.v1", ["prompt", "duration_seconds", "seed", "expand_prompt", "category"]),
(nodes_comfy_cloud.ComfyCloudChatterboxMultilingualVoiceCloneNode, "audio.chatterbox-multilingual-voice-clone.v1", ["text", "voice_reference", "language", "exaggeration", "cfg_weight", "temperature", "seed"]),
(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode, "audio.chatterbox-dialogue.v1", ["script", "speaker_a_reference", "speaker_b_reference", "exaggeration", "cfg_weight", "temperature", "seed"]),
(nodes_comfy_cloud.ComfyCloudChatterboxVoiceConversionNode, "audio.chatterbox-voice-conversion.v1", ["source_audio", "target_voice_reference", "seed"]),
(nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode, "audio.melbandroformer-stem-separation.v1", ["audio"]),
]
@pytest.mark.parametrize(("node", "workflow", "input_names"), AUDIO_POC_NODES)
def test_audio_poc_schemas_and_registration(node, workflow, input_names):
schema = node.define_schema()
registered = asyncio.run(nodes_comfy_cloud.ComfyCloudExtension().get_node_list())
assert schema.is_api_node
assert schema.category == "partner/audio/Comfy Cloud"
assert [input.id for input in schema.inputs] == input_names
assert all(output.get_io_type() == "AUDIO" for output in schema.outputs)
assert workflow in get_args(ComfyCloudWorkflow)
assert node in registered
def test_audio_poc_schema_defaults_ranges_and_enums():
schemas = {workflow: {input.id: input for input in node.define_schema().inputs} for node, workflow, _ in AUDIO_POC_NODES}
ace = schemas["audio.ace-step-1-5-xl-turbo.v1"]
assert (ace["duration_seconds"].default, ace["duration_seconds"].min, ace["duration_seconds"].max, ace["duration_seconds"].step) == (120, 10, 300, 0.1)
assert (ace["bpm"].default, ace["bpm"].min, ace["bpm"].max) == (120, 10, 300)
assert ace["time_signature"].options == ["2", "3", "4", "6"]
assert ace["language"].default == "en"
assert ace["key"].default == "E minor"
stable = schemas["audio.stable-audio-3-medium.v1"]
assert stable["category"].options == ["Music", "Instrument", "SFX", "One-shot"]
assert stable["expand_prompt"].default is True
for workflow in [
"audio.chatterbox-multilingual-voice-clone.v1",
"audio.chatterbox-dialogue.v1",
"audio.chatterbox-voice-conversion.v1",
]:
assert schemas[workflow]["seed"].max == 0xFFFFFFFF
mel_schema = nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode.define_schema()
assert [output.id for output in mel_schema.outputs] == ["vocals", "instruments"]
def test_audio_poc_request_mapping_and_named_result_decoding(monkeypatch):
sync = AsyncMock(return_value=ComfyCloudGenerateResponse(task_id="task-audio", status="queued", polling_url="/tasks/task-audio", cancel_url="/tasks/task-audio/cancel"))
poll = AsyncMock(return_value=ComfyCloudStatusResponse(task_id="task-audio", status="completed", output_urls={"vocals": "/vocals.mp3", "instruments": "/instruments.mp3"}))
upload = AsyncMock(return_value="/uploads/song.m4a")
download = AsyncMock(side_effect=["vocals-audio", "instruments-audio"])
monkeypatch.setattr(nodes_comfy_cloud, "sync_op", sync)
monkeypatch.setattr(nodes_comfy_cloud, "poll_op", poll)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
monkeypatch.setattr(nodes_comfy_cloud, "download_url_to_audio_input", download)
audio = {"waveform": torch.zeros(1, 2, 48000), "sample_rate": 48000}
output = asyncio.run(nodes_comfy_cloud.ComfyCloudMelBandRoFormerStemSeparationNode.execute(audio))
request = sync.call_args.kwargs["data"]
assert request.workflow == "audio.melbandroformer-stem-separation.v1"
assert request.inputs.model_dump(exclude_none=True) == {"assets": {"audio": {"type": "AUDIO", "url": "/uploads/song.m4a"}}}
assert [call.args[0] for call in download.await_args_list] == ["/vocals.mp3", "/instruments.mp3"]
assert poll.call_args.kwargs["cancel_endpoint"].path == "/tasks/task-audio/cancel"
assert tuple(output) == ("vocals-audio", "instruments-audio")
def test_chatterbox_audio_inputs_use_named_staged_assets(monkeypatch):
run = AsyncMock(return_value=("audio-output",))
upload = AsyncMock(side_effect=["/uploads/source.m4a", "/uploads/target.m4a"])
monkeypatch.setattr(nodes_comfy_cloud, "_run_audio_workflow", run)
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
source = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
target = {"waveform": torch.zeros(1, 1, 96000), "sample_rate": 48000}
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxVoiceConversionNode.execute(source, target, 7))
inputs = run.call_args.args[2]
assert inputs.model_dump(exclude_none=True) == {
"assets": {
"source_audio": {"type": "AUDIO", "url": "/uploads/source.m4a"},
"target_voice_reference": {"type": "AUDIO", "url": "/uploads/target.m4a"},
},
"seed": 7,
}
assert "audio_url" not in inputs.model_dump(exclude_none=True)
def test_chatterbox_dialogue_rejects_invalid_speaker_labels(monkeypatch):
upload = AsyncMock()
monkeypatch.setattr(nodes_comfy_cloud, "upload_audio_to_comfyapi", upload)
audio = {"waveform": torch.zeros(1, 1, 48000), "sample_rate": 48000}
with pytest.raises(ValueError, match="Every nonblank utterance"):
asyncio.run(nodes_comfy_cloud.ComfyCloudChatterboxDialogueNode.execute("NARRATOR: Hello", audio, audio, 0.5, 0.5, 0.8, 0))
upload.assert_not_awaited()
@pytest.mark.parametrize(("file_format", "expected_format"), [(".GLB", "glb"), ("SPZ", "spz")])
def test_download_cloud_3d_url_to_file_3d(monkeypatch, file_format, expected_format):
node = nodes_comfy_cloud.ComfyCloudTextToImageNode