Files
billwuhao-ComfyUI_ACE-Step/ace_step_nodes.py
T
2025-05-08 12:58:23 +08:00

501 lines
19 KiB
Python

import torchaudio
import tempfile
from typing import Optional, List
import torch
import os
import ast
import sys
import librosa
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.append(current_dir)
from pipeline_ace_step import ACEStepPipeline as AP
import folder_paths
cache_dir = folder_paths.get_temp_directory()
models_dir = folder_paths.models_dir
model_path = os.path.join(models_dir, "TTS", "ACE-Step-v1-3.5B")
class AudioCacher:
"""
一个用于缓存音频张量到临时文件,并在之后清理这些文件的类。
支持作为上下文管理器使用,以便自动清理。
"""
def __init__(self, cache_dir: Optional[str] = None, default_format: str = "wav"):
"""
初始化 AudioCacher。
Args:
cache_dir (Optional[str]): 缓存文件存放的目录。
如果为 None,则使用系统默认的临时目录。
default_format (str): 默认的音频文件格式后缀 (例如 "wav", "mp3", "flac")。
"""
if cache_dir is None:
self.cache_dir = tempfile.gettempdir()
else:
self.cache_dir = cache_dir
# 确保缓存目录存在
if not os.path.exists(self.cache_dir):
try:
os.makedirs(self.cache_dir, exist_ok=True)
except OSError as e:
raise # 重新抛出异常,因为这是一个关键的初始化步骤
self.default_format = default_format.lstrip('.') # 确保没有前导点
self._files_to_cleanup_in_context: List[str] = [] # 用于上下文管理器
def cache_audio_tensor(
self,
audio_tensor: torch.Tensor,
sample_rate: int,
filename_prefix: str = "cached_audio_",
audio_format: Optional[str] = None
) -> str:
"""
将音频张量保存到缓存文件,并返回文件路径。
Args:
audio_tensor (torch.Tensor): 要保存的音频张量。
sample_rate (int): 音频的采样率。
filename_prefix (str): 缓存文件名的前缀。
audio_format (Optional[str]): 要使用的音频格式 (例如 "wav", "mp3")。
如果为 None,则使用初始化时设置的 default_format。
Returns:
str: 保存的缓存文件的绝对路径。
Raises:
RuntimeError: 如果保存音频失败。
"""
current_format = (audio_format or self.default_format).lstrip('.')
# 创建一个带特定后缀的临时文件,但不立即删除
# NamedTemporaryFile 会在创建时打开文件,我们需要先关闭它才能让 torchaudio.save 使用
try:
with tempfile.NamedTemporaryFile(
prefix=filename_prefix,
suffix=f".{current_format}",
dir=self.cache_dir,
delete=False # 这是关键,我们手动管理删除
) as tmp_file:
temp_filepath = tmp_file.name
# 此时 tmp_file 已经关闭,但文件因 delete=False 而保留
torchaudio.save(temp_filepath, audio_tensor, sample_rate)
# 如果在上下文管理器中使用,则记录此文件以备自动清理
self._files_to_cleanup_in_context.append(temp_filepath)
return temp_filepath
except Exception as e:
# 如果 temp_filepath 已定义且文件存在,尝试删除,因为它可能不完整或损坏
if 'temp_filepath' in locals() and os.path.exists(temp_filepath):
try:
os.remove(temp_filepath)
except OSError as e_clean:
logger.warning(f"Error cleaning temporary file {temp_filepath}: {e_clean}")
raise RuntimeError(f"Failed to save audio: {e}") from e
def cleanup_file(self, filepath: str) -> bool:
"""
清理指定的缓存文件。
Args:
filepath (str): 要删除的文件的路径。
Returns:
bool: 如果文件成功删除或文件不存在,则返回 True;如果删除失败,则返回 False。
"""
if not filepath:
return True # 没有文件可以删除,所以认为是“成功”
if os.path.exists(filepath):
try:
os.remove(filepath)
# 如果文件在上下文中被跟踪,也从中移除
if filepath in self._files_to_cleanup_in_context:
self._files_to_cleanup_in_context.remove(filepath)
return True
except OSError as e:
return False
else:
# 如果文件在上下文中被跟踪,也从中移除
if filepath in self._files_to_cleanup_in_context:
self._files_to_cleanup_in_context.remove(filepath)
return True # 文件不存在,也视为清理“成功”
def cleanup_all_tracked_files(self) -> None:
"""
清理所有由当前上下文管理器实例跟踪的缓存文件。
"""
# 迭代列表的副本,因为 cleanup_file 可能会修改列表
for f_path in list(self._files_to_cleanup_in_context):
self.cleanup_file(f_path)
self._files_to_cleanup_in_context.clear() # 确保列表被清空
def __enter__(self):
"""进入上下文管理器时调用。"""
# 重置跟踪文件列表,以防同一个实例被多次用于 'with' 语句
self._files_to_cleanup_in_context = []
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""退出上下文管理器时调用,负责清理。"""
self.cleanup_all_tracked_files()
# 返回 False 以便在发生异常时重新抛出异常
return False
from data_sampler import DataSampler
def sample_data(json_data):
return (
json_data["audio_duration"],
json_data["prompt"],
json_data["lyrics"],
json_data["infer_step"],
json_data["guidance_scale"],
json_data["scheduler_type"],
json_data["cfg_type"],
json_data["omega_scale"],
json_data["actual_seeds"][0],
json_data["guidance_interval"],
json_data["guidance_interval_decay"],
json_data["min_guidance_scale"],
json_data["use_erg_tag"],
json_data["use_erg_lyric"],
json_data["use_erg_diffusion"],
", ".join(map(str, json_data["oss_steps"])),
json_data["guidance_scale_text"] if "guidance_scale_text" in json_data else 0.0,
json_data["guidance_scale_lyric"] if "guidance_scale_lyric" in json_data else 0.0,
)
data_sampler = DataSampler()
json_data = data_sampler.sample()
json_data = sample_data(json_data)
audio_duration,\
prompt, \
lyrics,\
infer_step, \
guidance_scale,\
scheduler_type, \
cfg_type, \
omega_scale, \
manual_seeds, \
guidance_interval, \
guidance_interval_decay, \
min_guidance_scale, \
use_erg_tag, \
use_erg_lyric, \
use_erg_diffusion, \
oss_steps, \
guidance_scale_text, \
guidance_scale_lyric = json_data
class GenerationParameters:
@classmethod
def INPUT_TYPES(s):
return {"required":
{ "audio_duration": ("FLOAT", {"default": audio_duration, "min": 0.0, "max": 240.0, "step": 1.0, "tooltip": "0 is a random length"}),
"infer_step": ("INT", {"default": infer_step, "min": 1, "max": 60, "step": 1}),
"guidance_scale": ("FLOAT", {"default": guidance_scale, "min": 0.0, "max": 200.0, "step": 0.1, "tooltip": "When guidance_scale_lyric > 1 and guidance_scale_text > 1, the guidance scale will not be applied."}),
"scheduler_type": (["euler", "heun"], {"default": scheduler_type, "tooltip": "euler is recommended. heun will take more time."}),
"cfg_type": (["cfg", "apg", "cfg_star"], {"default": cfg_type, "tooltip": "apg is recommended. cfg and cfg_star are almost the same."}),
"omega_scale": ("FLOAT", {"default": omega_scale, "min": -100.0, "max": 100.0, "step": 0.1, "tooltip": "Higher values can reduce artifacts"}),
"seed": ("INT", {"default":manual_seeds, "min": 0, "max": 4294967295, "step": 1}),
"guidance_interval": ("FLOAT", {"default": guidance_interval, "min": 0, "max": 1, "step": 0.01, "tooltip": "0.5 means only apply guidance in the middle steps"}),
"guidance_interval_decay": ("FLOAT", {"default": guidance_interval_decay, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Guidance scale will decay from guidance_scale to min_guidance_scale in the interval. 0.0 means no decay."}),
"min_guidance_scale": ("INT", {"default": min_guidance_scale, "min": 0, "max": 200, "step": 1}),
"use_erg_tag": ("BOOLEAN", {"default": use_erg_tag}),
"use_erg_lyric": ("BOOLEAN", {"default": use_erg_lyric}),
"use_erg_diffusion": ("BOOLEAN", {"default": use_erg_diffusion}),
"oss_steps": ("STRING", {"default": oss_steps}),
"guidance_scale_text": ("FLOAT", {"default": guidance_scale_text, "min": 0.0, "max": 10.0, "step": 0.1}),
"guidance_scale_lyric": ("FLOAT", {"default": guidance_scale_lyric, "min": 0.0, "max": 10.0, "step": 0.1}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("parameters",)
FUNCTION = "generate"
CATEGORY = "🎤MW/MW-ACE-Step"
def generate(self, **kwargs):
kwargs["manual_seeds"] = kwargs.pop("seed")
return (str(kwargs),)
class MultiLinePromptACES:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"multi_line_prompt": ("STRING", {
"multiline": True,
"default": prompt}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "promptgen"
def promptgen(self, multi_line_prompt: str):
return (multi_line_prompt.strip(),)
class MultiLineLyrics:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"multi_line_prompt": ("STRING", {
"multiline": True,
"default": lyrics}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("lyrics",)
FUNCTION = "lyricsgen"
def lyricsgen(self, multi_line_prompt: str):
return (multi_line_prompt.strip(),)
ap = None
class ACEStepGen:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"forceInput": True}),
"lyrics": ("STRING", {"forceInput": True}),
"parameters": ("STRING", {"forceInput": True}),
"unload_model": ("BOOLEAN", {"default": True}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("music",)
FUNCTION = "acestepgen"
def acestepgen(self, prompt: str, lyrics: str, parameters: str, unload_model=True):
parameters = ast.literal_eval(parameters)
global ap
if ap is None:
ap = AP(model_path)
audio_output = ap(prompt=prompt, lyrics=lyrics, task="text2music", **parameters)
audio, sr = audio_output[0][0].unsqueeze(0), audio_output[0][1]
if unload_model:
ap.cleanup()
ap = None
return ({"waveform": audio, "sample_rate": sr},)
class ACEStepRepainting:
def __init__(self):
ap = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"src_audio": ("AUDIO",),
"prompt": ("STRING", {"forceInput": True}),
"lyrics": ("STRING", {"forceInput": True}),
"parameters": ("STRING", {"forceInput": True}),
"repaint_start": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"repaint_end": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"repaint_variance": ("FLOAT", {"default": 0.01, "min": 0.01, "max": 1.0, "step": 0.01}),
"seed": ("INT", {"default":0, "min": 0, "max": 4294967295, "step": 1}),
"unload_model": ("BOOLEAN", {"default": True}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("music",)
FUNCTION = "acesteprepainting"
def acesteprepainting(self, src_audio, prompt: str, lyrics: str, parameters: str, repaint_start, repaint_end, repaint_variance, seed, unload_model=True):
retake_seeds = [str(seed)]
ac = AudioCacher(cache_dir=cache_dir)
src_audio_path = ac.cache_audio_tensor(src_audio["waveform"].squeeze(0), src_audio["sample_rate"], filename_prefix="src_audio_")
audio_duration = librosa.get_duration(filename=src_audio_path)
if repaint_end > audio_duration:
repaint_end = audio_duration
parameters = ast.literal_eval(parameters)
parameters["audio_duration"] = audio_duration
global ap
if ap is None:
ap = AP(model_path)
audio_output = ap(
prompt=prompt,
lyrics=lyrics,
task="repaint",
retake_seeds=retake_seeds,
src_audio_path=src_audio_path,
repaint_start=repaint_start,
repaint_end=repaint_end,
retake_variance=repaint_variance,
**parameters)
audio, sr = audio_output[0][0].unsqueeze(0), audio_output[0][1]
ac.cleanup_file(src_audio_path)
if unload_model:
ap.cleanup()
ap = None
return ({"waveform": audio, "sample_rate": sr},)
class ACEStepEdit:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"src_audio": ("AUDIO",),
"prompt": ("STRING", {"forceInput": True}),
"lyrics": ("STRING", {"forceInput": True}),
"parameters": ("STRING", {"forceInput": True}),
"edit_prompt": ("STRING", {"forceInput": True}),
"edit_lyrics": ("STRING", {"forceInput": True}),
"edit_n_min": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 1.0, "step": 0.01}),
"edit_n_max": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"seed": ("INT", {"default":0, "min": 0, "max": 4294967295, "step": 1}),
"unload_model": ("BOOLEAN", {"default": True}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("music",)
FUNCTION = "acestepedit"
def acestepedit(self, src_audio, prompt: str, lyrics: str, parameters: str, edit_prompt, edit_lyrics, edit_n_min, edit_n_max, seed, unload_model=True):
retake_seeds = [str(seed)]
ac = AudioCacher(cache_dir=cache_dir)
src_audio_path = ac.cache_audio_tensor(src_audio["waveform"].squeeze(0), src_audio["sample_rate"], filename_prefix="src_audio_")
audio_duration = librosa.get_duration(filename=src_audio_path)
parameters = ast.literal_eval(parameters)
parameters["audio_duration"] = audio_duration
global ap
if ap is None:
ap = AP(model_path)
audio_output = ap(
prompt=prompt,
lyrics=lyrics,
task="edit",
retake_seeds=retake_seeds,
src_audio_path=src_audio_path,
edit_target_prompt = edit_prompt,
edit_target_lyrics = edit_lyrics,
edit_n_min = edit_n_min,
edit_n_max = edit_n_max,
**parameters)
audio, sr = audio_output[0][0].unsqueeze(0), audio_output[0][1]
ac.cleanup_file(src_audio_path)
if unload_model:
ap.cleanup()
ap = None
return ({"waveform": audio, "sample_rate": sr},)
class ACEStepExtend:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"src_audio": ("AUDIO",),
"prompt": ("STRING", {"forceInput": True}),
"lyrics": ("STRING", {"forceInput": True}),
"parameters": ("STRING", {"forceInput": True}),
"left_extend_length": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
"right_extend_length": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}),
# "repaint_variance": ("FLOAT", {"default": 0.01, "min": 0.01, "max": 1.0, "step": 0.01}),
"seed": ("INT", {"default":0, "min": 0, "max": 4294967295, "step": 1}),
"unload_model": ("BOOLEAN", {"default": True}),
},
}
CATEGORY = "🎤MW/MW-ACE-Step"
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("music",)
FUNCTION = "acestepextend"
def acestepextend(self, src_audio, prompt: str, lyrics: str, parameters: str, left_extend_length, right_extend_length, seed, unload_model=True):
retake_seeds = [str(seed)]
ac = AudioCacher(cache_dir=cache_dir)
src_audio_path = ac.cache_audio_tensor(src_audio["waveform"].squeeze(0), src_audio["sample_rate"], filename_prefix="src_audio_")
audio_duration = librosa.get_duration(filename=src_audio_path)
repaint_start = -left_extend_length
repaint_end = audio_duration + right_extend_length
parameters = ast.literal_eval(parameters)
parameters["audio_duration"] = audio_duration
global ap
if ap is None:
ap = AP(model_path)
audio_output = ap(
prompt=prompt,
lyrics=lyrics,
task="extend",
retake_seeds=retake_seeds,
src_audio_path=src_audio_path,
repaint_start=repaint_start,
repaint_end=repaint_end,
retake_variance=1.0,
**parameters)
audio, sr = audio_output[0][0].unsqueeze(0), audio_output[0][1]
ac.cleanup_file(src_audio_path)
if unload_model:
ap.cleanup()
ap = None
return ({"waveform": audio, "sample_rate": sr},)
NODE_CLASS_MAPPINGS = {
"ACEStepGen": ACEStepGen,
"GenerationParameters": GenerationParameters,
"MultiLinePromptACES": MultiLinePromptACES,
"MultiLineLyrics": MultiLineLyrics,
"ACEStepRepainting": ACEStepRepainting,
"ACEStepEdit": ACEStepEdit,
"ACEStepExtend": ACEStepExtend,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ACEStepGen": "ACE-Step",
"GenerationParameters": "ACE-Step Parameters",
"MultiLinePromptACES": "ACE-Step Prompt",
"MultiLineLyrics": "ACE-Step Lyrics",
"ACEStepRepainting": "ACE-Step Repainting",
"ACEStepEdit": "ACE-Step Edit",
"ACEStepExtend": "ACE-Step Extend",
}