feat: Introduce Audio Smart Chunker for optimized long audio splitting and CosyVoice integration
This commit is contained in:
@@ -64,6 +64,9 @@ _load_nodes_from_module(".aiia_audio_merger", "aiia_audio_merger")
|
||||
# 10. 处理 aiia_cosyvoice_nodes.py
|
||||
_load_nodes_from_module(".aiia_cosyvoice_nodes", "aiia_cosyvoice_nodes")
|
||||
|
||||
# 11. 处理 aiia_audio_processor.py
|
||||
_load_nodes_from_module(".aiia_audio_processor", "aiia_audio_processor")
|
||||
|
||||
# 告诉 ComfyUI 这个节点包有一个包含网页资源的 'js' 目录
|
||||
WEB_DIRECTORY = "js"
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
class AIIA_Audio_Silence_Splitter:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"max_duration": ("FLOAT", {"default": 27.0, "min": 5.0, "max": 29.0, "step": 0.1, "tooltip": "每个片段的最大长度,建议设置为27秒以留出余量"}),
|
||||
"silence_threshold": ("FLOAT", {"default": 0.001, "min": 0.0001, "max": 0.1, "step": 0.0001, "tooltip": "判定为静音的能量阈值"}),
|
||||
"min_silence_duration": ("FLOAT", {"default": 0.3, "min": 0.05, "max": 2.0, "step": 0.05, "tooltip": "判定为有效停顿的最短静音时长"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WHISPER_CHUNKS", "INT")
|
||||
RETURN_NAMES = ("whisper_chunks", "chunk_count")
|
||||
FUNCTION = "split_audio"
|
||||
CATEGORY = "AIIA/audio"
|
||||
|
||||
def split_audio(self, audio, max_duration, silence_threshold, min_silence_duration):
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
# 计算单通道能量
|
||||
energy = torch.abs(waveform).mean(dim=1).squeeze()
|
||||
if energy.ndim > 1: energy = energy.mean(dim=0) # 处理 batch
|
||||
|
||||
# 1. 识别静音区间
|
||||
is_silence = energy < silence_threshold
|
||||
# 转换为 numpy 处理更方便
|
||||
sil_np = is_silence.cpu().numpy()
|
||||
|
||||
# 寻找静音区间的起始和结束索引
|
||||
diff = np.diff(sil_np.astype(int))
|
||||
starts = np.where(diff == 1)[0]
|
||||
ends = np.where(diff == -1)[0]
|
||||
|
||||
if sil_np[0]: starts = np.insert(starts, 0, 0)
|
||||
if sil_np[-1]: ends = np.append(ends, len(sil_np) - 1)
|
||||
|
||||
# 过滤过短的静音
|
||||
min_sil_samples = int(min_sil_duration * sample_rate)
|
||||
valid_silence_gaps = []
|
||||
for s, e in zip(starts, ends):
|
||||
if e - s >= min_sil_samples:
|
||||
valid_silence_gaps.append((s / sample_rate, e / sample_rate))
|
||||
|
||||
# 2. 贪心算法构建片段
|
||||
total_duration = waveform.shape[-1] / sample_rate
|
||||
chunks = []
|
||||
current_start = 0.0
|
||||
|
||||
while current_start < total_duration:
|
||||
target_end = current_start + max_duration
|
||||
|
||||
if target_end >= total_duration:
|
||||
chunks.append([current_start, total_duration])
|
||||
break
|
||||
|
||||
# 在 [current_start, target_end] 范围内寻找最后一个有效的静音点
|
||||
best_gap_mid = -1
|
||||
for g_start, g_end in valid_silence_gaps:
|
||||
g_mid = (g_start + g_end) / 2
|
||||
if current_start < g_mid <= target_end:
|
||||
best_gap_mid = g_mid
|
||||
elif g_mid > target_end:
|
||||
break
|
||||
|
||||
if best_gap_mid != -1:
|
||||
chunks.append([current_start, best_gap_mid])
|
||||
current_start = best_gap_mid
|
||||
else:
|
||||
# 如果没找到静音点,强行截断并打印警告
|
||||
print(f"警告: 在 {current_start:.1f}s 后的 {max_duration}s 内未找到静音点,强行截断。")
|
||||
chunks.append([current_start, target_end])
|
||||
current_start = target_end
|
||||
|
||||
# 3. 构造输出
|
||||
whisper_chunks_data = {
|
||||
"text": "",
|
||||
"chunks": [
|
||||
{"timestamp": [round(c[0], 3), round(c[1], 3)], "text": f"Chunk {i}", "speaker": "AIIA_SMART_CHUNK"}
|
||||
for i, c in enumerate(chunks)
|
||||
],
|
||||
"language": ""
|
||||
}
|
||||
|
||||
print(f"--- [AIIA Smart Splitter] 已将 {total_duration:.1f}s 音频划分为 {len(chunks)} 个片段 ---")
|
||||
return (whisper_chunks_data, len(chunks))
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AIIA_Audio_Silence_Splitter": AIIA_Audio_Silence_Splitter}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AIIA_Audio_Silence_Splitter": "Audio Smart Chunker (Silence-based)"}
|
||||
+40
-63
@@ -48,12 +48,10 @@ class AIIA_CosyVoice_VoiceConversion:
|
||||
cosyvoice_model = model["model"]
|
||||
sample_rate = cosyvoice_model.sample_rate
|
||||
|
||||
# 1. 准备参考音频
|
||||
target_waveform = target_audio["waveform"]
|
||||
if target_audio["sample_rate"] != sample_rate:
|
||||
import torchaudio
|
||||
resampler = torchaudio.transforms.Resample(target_audio["sample_rate"], sample_rate)
|
||||
target_waveform = resampler(target_waveform)
|
||||
target_waveform = torchaudio.transforms.Resample(target_audio["sample_rate"], sample_rate)(target_waveform)
|
||||
|
||||
max_target_samples = 30 * sample_rate
|
||||
if target_waveform.shape[-1] > max_target_samples:
|
||||
@@ -65,20 +63,16 @@ class AIIA_CosyVoice_VoiceConversion:
|
||||
if target_np.ndim == 2: target_np = target_np.T
|
||||
sf.write(target_path, target_np, sample_rate)
|
||||
|
||||
# 2. 准备源音频
|
||||
source_waveform = source_audio["waveform"]
|
||||
if source_audio["sample_rate"] != sample_rate:
|
||||
import torchaudio
|
||||
resampler = torchaudio.transforms.Resample(source_audio["sample_rate"], sample_rate)
|
||||
source_waveform = resampler(source_waveform)
|
||||
source_waveform = torchaudio.transforms.Resample(source_audio["sample_rate"], sample_rate)(source_waveform)
|
||||
|
||||
source_waveform = source_waveform.squeeze()
|
||||
if source_waveform.ndim == 1: source_waveform = source_waveform.unsqueeze(0)
|
||||
|
||||
total_samples = source_waveform.shape[-1]
|
||||
|
||||
# 硬限制计算:核心片段 + 重叠部分 < 30秒
|
||||
MAX_TOTAL_SEC = 29.8 # 留一点点 Buffer
|
||||
MAX_TOTAL_SEC = 29.8
|
||||
chunk_samples = chunk_size * sample_rate
|
||||
overlap_samples = overlap_size * sample_rate
|
||||
max_total_samples = int(MAX_TOTAL_SEC * sample_rate)
|
||||
@@ -88,79 +82,62 @@ class AIIA_CosyVoice_VoiceConversion:
|
||||
if os.path.exists(target_path): os.unlink(target_path)
|
||||
return ({"waveform": result_waveform.unsqueeze(0), "sample_rate": sample_rate},)
|
||||
|
||||
print(f"[AIIA CosyVoice] Long audio detected. Max allowed total chunk len: {MAX_TOTAL_SEC}s")
|
||||
|
||||
# 3. 智能分块逻辑
|
||||
chunks_to_process = []
|
||||
current_start = 0
|
||||
search_range = 2 * sample_rate
|
||||
is_pre_chunked = whisper_chunks and any(c.get("speaker") == "AIIA_SMART_CHUNK" for c in whisper_chunks.get("chunks", []))
|
||||
|
||||
while current_start < total_samples:
|
||||
# 核心目标终点
|
||||
target_end_time = (current_start + chunk_samples) / sample_rate
|
||||
best_split_time = target_end_time
|
||||
|
||||
# 语义优先:寻找最接近的缝隙
|
||||
if whisper_chunks and "chunks" in whisper_chunks:
|
||||
closest_gap_dist = float('inf')
|
||||
for i in range(len(whisper_chunks["chunks"]) - 1):
|
||||
gap_start = whisper_chunks["chunks"][i]["timestamp"][1]
|
||||
gap_end = whisper_chunks["chunks"][i+1]["timestamp"][0]
|
||||
gap_mid = (gap_start + gap_end) / 2
|
||||
|
||||
# 关键修复:确保 (split_point + overlap) 不超过 30s 限制
|
||||
if (gap_mid * sample_rate + overlap_samples) - current_start > max_total_samples:
|
||||
continue
|
||||
|
||||
dist = abs(gap_mid - target_end_time)
|
||||
if dist < 5: # 5秒内
|
||||
if dist < closest_gap_dist:
|
||||
if is_pre_chunked:
|
||||
print(f"[AIIA CosyVoice] Detected Smart Chunker input. Following pre-defined segments strictly.")
|
||||
for c in whisper_chunks["chunks"]:
|
||||
s_time, e_time = c["timestamp"]
|
||||
s_idx = int(s_time * sample_rate)
|
||||
e_idx = int(e_time * sample_rate)
|
||||
e_idx_with_overlap = min(e_idx + overlap_samples, total_samples)
|
||||
chunks_to_process.append(source_waveform[:, s_idx:e_idx_with_overlap])
|
||||
else:
|
||||
current_start = 0
|
||||
search_range = 2 * sample_rate
|
||||
while current_start < total_samples:
|
||||
target_end_time = (current_start + chunk_samples) / sample_rate
|
||||
best_split_time = target_end_time
|
||||
if whisper_chunks and "chunks" in whisper_chunks:
|
||||
closest_gap_dist = float('inf')
|
||||
for i in range(len(whisper_chunks["chunks"]) - 1):
|
||||
gap_mid = (whisper_chunks["chunks"][i]["timestamp"][1] + whisper_chunks["chunks"][i+1]["timestamp"][0]) / 2
|
||||
if (gap_mid * sample_rate + overlap_samples) - current_start > max_total_samples: continue
|
||||
dist = abs(gap_mid - target_end_time)
|
||||
if dist < 5 and dist < closest_gap_dist:
|
||||
closest_gap_dist = dist
|
||||
best_split_time = gap_mid
|
||||
|
||||
split_point = int(best_split_time * sample_rate)
|
||||
# 物理微调:静音探测
|
||||
split_point = self._find_best_split_point(source_waveform, split_point, search_range)
|
||||
|
||||
# 严格限制:确保不越界且总长度(含重叠)不超过 30s
|
||||
split_point = min(split_point, total_samples)
|
||||
if (split_point + overlap_samples) - current_start > max_total_samples:
|
||||
split_point = current_start + max_total_samples - overlap_samples - 100
|
||||
|
||||
actual_end = min(split_point + overlap_samples, total_samples)
|
||||
|
||||
# 检查是否是最后一块
|
||||
if actual_end >= total_samples:
|
||||
chunks_to_process.append(source_waveform[:, current_start:])
|
||||
break
|
||||
else:
|
||||
chunks_to_process.append(source_waveform[:, current_start:actual_end])
|
||||
|
||||
current_start = split_point
|
||||
|
||||
# 如果剩余长度太短(小于1秒),合并到最后一块
|
||||
if total_samples - current_start < sample_rate:
|
||||
chunks_to_process[-1] = source_waveform[:, total_samples - chunks_to_process[-1].shape[-1] - (total_samples - current_start): ]
|
||||
break
|
||||
split_point = int(best_split_time * sample_rate)
|
||||
split_point = self._find_best_split_point(source_waveform, split_point, search_range)
|
||||
split_point = min(split_point, total_samples)
|
||||
if (split_point + overlap_samples) - current_start > max_total_samples:
|
||||
split_point = current_start + max_total_samples - overlap_samples - 100
|
||||
actual_end = min(split_point + overlap_samples, total_samples)
|
||||
if actual_end >= total_samples:
|
||||
chunks_to_process.append(source_waveform[:, current_start:])
|
||||
break
|
||||
else:
|
||||
chunks_to_process.append(source_waveform[:, current_start:actual_end])
|
||||
current_start = split_point
|
||||
if total_samples - current_start < sample_rate:
|
||||
break
|
||||
|
||||
final_segments = []
|
||||
for i, chunk in enumerate(chunks_to_process):
|
||||
print(f"[AIIA CosyVoice] Processing chunk {i+1}/{len(chunks_to_process)}, actual input len: {chunk.shape[-1]/sample_rate:.2f}s")
|
||||
converted_chunk = self._inference_single_chunk(cosyvoice_model, chunk, target_path, speed, sample_rate, seed)
|
||||
|
||||
if not final_segments:
|
||||
final_segments.append(converted_chunk)
|
||||
else:
|
||||
prev_chunk = final_segments[-1]
|
||||
chunk_overlap_samples = int(overlap_samples / speed)
|
||||
|
||||
if chunk_overlap_samples > 0 and prev_chunk.shape[-1] > chunk_overlap_samples:
|
||||
t = torch.linspace(0, np.pi, chunk_overlap_samples, device=converted_chunk.device)
|
||||
fade_out = 0.5 * (1.0 + torch.cos(t))
|
||||
fade_in = 1.0 - fade_out
|
||||
|
||||
overlap_part = prev_chunk[:, -chunk_overlap_samples:] * fade_out + converted_chunk[:, :chunk_overlap_samples] * fade_in
|
||||
|
||||
final_segments[-1] = prev_chunk[:, :-chunk_overlap_samples]
|
||||
final_segments.append(overlap_part)
|
||||
final_segments.append(converted_chunk[:, chunk_overlap_samples:])
|
||||
@@ -189,4 +166,4 @@ class AIIA_CosyVoice_VoiceConversion:
|
||||
if os.path.exists(source_path): os.unlink(source_path)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AIIA_CosyVoice_VoiceConversion": AIIA_CosyVoice_VoiceConversion}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AIIA_CosyVoice_VoiceConversion": "Voice Conversion (AIIA Unlimited)"}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AIIA_CosyVoice_VoiceConversion": "Voice Conversion (AIIA Unlimited)"}
|
||||
|
||||
Reference in New Issue
Block a user