| import os |
| import json |
| import re |
| import argparse |
| import numpy as np |
| import soundfile as sf |
| import librosa |
| import torch |
| from tqdm import tqdm |
| import random |
| from qwen_tts import Qwen3TTSModel |
|
|
| def remove_brackets_content_zh(text): |
| |
| |
| cleaned_text = re.sub(r'【.*?】', '', text) |
| return cleaned_text |
|
|
| def remove_brackets_content_en(text): |
| |
| cleaned_text = re.sub(r'【.*?】', ' ', text) |
| |
| |
| |
| cleaned_text = re.sub(r'\s+', ' ', cleaned_text).strip() |
| |
| return cleaned_text |
|
|
| def get_target_control(item, target_key): |
| """ |
| 获取目标的 control 字典。 |
| 完美兼容“无后缀基础版”与“带数字后缀进化版”同时存在的情况。 |
| """ |
| max_idx = -1 |
| best_key = None |
| |
| |
| if target_key in item: |
| best_key = target_key |
| |
| |
| pattern = re.compile(rf"{re.escape(target_key)}_(\d+)") |
| for key in item.keys(): |
| match = pattern.fullmatch(key) |
| if match: |
| idx = int(match.group(1)) |
| if idx > max_idx: |
| max_idx = idx |
| best_key = key |
| |
| if best_key: |
| return item.get(best_key), best_key |
| return None, None |
|
|
| def read_jsonl(file_path): |
| data = [] |
| try: |
| with open(file_path, 'r', encoding='utf-8') as f: |
| for line_number, line in enumerate(f, start=1): |
| line = line.strip() |
| if not line: continue |
| try: |
| item = json.loads(line) |
| |
| item["line_idx"] = int(item.get("line_idx", line_number)) |
| data.append(item) |
| except json.JSONDecodeError as e: |
| print(f"[Warning] 第 {line_number} 行解析失败: {e}") |
| except Exception as e: |
| print(f"[Error] 读取文件异常: {e}") |
| return data |
|
|
| def trim_silence(audio, top_db=45): |
| if len(audio) == 0: |
| return audio |
| trimmed_audio, _ = librosa.effects.trim(audio, top_db=top_db) |
| return trimmed_audio |
|
|
| def parse_requested_speakers(speakers_arg): |
| return [x.strip() for x in speakers_arg.split(",") if x.strip()] |
|
|
| def get_nonempty_segment_indexes(control): |
| return [ |
| idx for idx, part in enumerate(control["Control"]) |
| if part.get("sample_text", "").strip() |
| ] |
|
|
| def write_item_metadata(item, control, out_sub_dir, line_idx): |
| os.makedirs(out_sub_dir, exist_ok=True) |
| control_json_path = os.path.join(out_sub_dir, "control.json") |
| with open(control_json_path, "w", encoding="utf-8") as f: |
| json.dump(control, f, ensure_ascii=False, indent=4) |
|
|
| keys_to_keep = ["audio_content", "ability", "file_name", "instruct_id", "language"] |
| instruct_data = {k: item[k] for k in keys_to_keep if k in item} |
| instruct_id = item.get("instruct_id", line_idx) |
| instruct_json_path = os.path.join(out_sub_dir, f"{instruct_id}_instruct.json") |
| with open(instruct_json_path, "w", encoding="utf-8") as f: |
| json.dump(instruct_data, f, ensure_ascii=False, indent=4) |
|
|
| def item_needs_generation(item, args): |
| control = item["_parsed_control"] |
| line_idx = item.get("line_idx") |
| out_sub_dir = os.path.join(args.output_dir, str(line_idx)) |
| requested_speakers = parse_requested_speakers(args.speakers) |
|
|
| voice_instruct_zh = control["Global"].get("instruct_zh", "") |
| voice_instruct_en = control["Global"].get("instruct_en", "") |
| if voice_instruct_zh and not os.path.exists(os.path.join(out_sub_dir, f"{line_idx}_vd_zh.wav")): |
| return True |
| if not args.zh_only and voice_instruct_en and not os.path.exists(os.path.join(out_sub_dir, f"{line_idx}_vd_en.wav")): |
| return True |
|
|
| |
| |
| if not requested_speakers: |
| return True |
|
|
| segment_indexes = get_nonempty_segment_indexes(control) |
| for speaker in requested_speakers: |
| final_zh_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_zh.wav") |
| if not os.path.exists(final_zh_path): |
| return True |
| for seg_idx in segment_indexes: |
| seg_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_zh_{seg_idx}.wav") |
| if not os.path.exists(seg_path): |
| return True |
|
|
| if not args.zh_only: |
| final_en_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_en.wav") |
| if not os.path.exists(final_en_path): |
| return True |
| for seg_idx in segment_indexes: |
| seg_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_en_{seg_idx}.wav") |
| if not os.path.exists(seg_path): |
| return True |
|
|
| write_item_metadata(item, control, out_sub_dir, line_idx) |
| return False |
|
|
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--input_jsonl", required=True, type=str) |
| parser.add_argument("--output_dir", required=True, type=str) |
| parser.add_argument("--custom_voice_path", required=True, type=str) |
| parser.add_argument("--voice_design_path", required=True, type=str) |
| parser.add_argument("--num_gpus", default=4, type=int, help="总分块数") |
| parser.add_argument("--gpu_id", required=True, type=int, help="当前处理的块编号 (0 到 num_gpus-1)") |
| parser.add_argument("--control_key", type=str, default=None, help="强制指定读取的 control 字段,如 final_generated_control") |
| parser.add_argument("--speakers", type=str, default="", help="Comma-separated CustomVoice speaker subset. Default keeps all speakers.") |
| parser.add_argument("--zh_only", action="store_true", help="Only generate zh VoiceDesign/CustomVoice prompt wavs.") |
| parser.add_argument("--en_only", action="store_true", help="Only generate en VoiceDesign/CustomVoice prompt wavs.") |
| args = parser.parse_args() |
|
|
| os.makedirs(args.output_dir, exist_ok=True) |
| |
| |
| all_data = read_jsonl(args.input_jsonl) |
| if not all_data: |
| print("无有效数据,退出。") |
| return |
|
|
| |
| valid_data = [] |
| for item in all_data: |
| control, used_key = get_target_control(item, args.control_key) |
| |
| |
| if not control or "Global" not in control or "Control" not in control: |
| continue |
| |
| |
| texts = [c.get("sample_text", "") for c in control["Control"]] |
| full_text = "".join(texts) |
| if not full_text.strip(): |
| continue |
| |
| |
| item["_parsed_control"] = control |
| valid_data.append(item) |
|
|
| if not valid_data: |
| print("未找到包含有效 control_key 的数据,退出。") |
| return |
|
|
| |
| chunk_size = (len(valid_data) + args.num_gpus - 1) // args.num_gpus |
| chunks = [valid_data[i:i + chunk_size] for i in range(0, len(valid_data), chunk_size)] |
| |
| if args.gpu_id >= len(chunks): |
| print(f"[Worker {args.gpu_id}] 没有分配到数据块,任务结束。") |
| return |
| |
| my_chunk = chunks[args.gpu_id] |
| my_chunk = [item for item in my_chunk if item_needs_generation(item, args)] |
| if not my_chunk: |
| print(f"[Worker {args.gpu_id}] 没有待生成音频,跳过模型加载。") |
| return |
| |
| device = "cuda:0" |
| print(f"[Worker {args.gpu_id}] 启动,共需处理 {len(my_chunk)} 条有效数据 (总有效数据: {len(valid_data)})。") |
|
|
| |
| print(f"[Worker {args.gpu_id}] 正在加载 VoiceDesign 模型...") |
| vd_model = Qwen3TTSModel.from_pretrained( |
| args.voice_design_path, device_map=device, dtype=torch.bfloat16, attn_implementation="flash_attention_2" |
| ) |
|
|
| print(f"[Worker {args.gpu_id}] 正在加载 CustomVoice 模型...") |
| cv_model = Qwen3TTSModel.from_pretrained( |
| args.custom_voice_path, device_map=device, dtype=torch.bfloat16, attn_implementation="flash_attention_2" |
| ) |
| |
| supported_speakers = cv_model.get_supported_speakers() |
| if args.speakers.strip(): |
| requested = [x.strip() for x in args.speakers.split(",") if x.strip()] |
| supported_speakers = [x for x in requested if x in supported_speakers] |
| if not supported_speakers: |
| raise ValueError(f"No requested speakers are supported: {requested}") |
| print(f"[Worker {args.gpu_id}] 支持的说话人: {supported_speakers}") |
| random.seed(args.gpu_id) |
|
|
| |
| for item in tqdm(my_chunk, desc=f"Worker {args.gpu_id} Progress"): |
|
|
| current_seed = random.randint(0, 200) |
| random.seed(current_seed) |
| np.random.seed(current_seed) |
| torch.manual_seed(current_seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(current_seed) |
|
|
| line_idx = item.get("line_idx") |
| control = item["_parsed_control"] |
| |
| out_sub_dir = os.path.join(args.output_dir, str(line_idx)) |
| os.makedirs(out_sub_dir, exist_ok=True) |
|
|
| voice_instruct_zh = control["Global"].get("instruct_zh", "") |
| voice_instruct_en = control["Global"].get("instruct_en", "") |
|
|
| expressive_instructs_zh = [c.get("instruct_zh", "") for c in control["Control"]] |
| expressive_instructs_en = [c.get("instruct_en", "") for c in control["Control"]] |
| texts = [c.get("sample_text", "") for c in control["Control"]] |
| full_text = "".join(texts) |
|
|
| |
| try: |
| vd_zh_path = os.path.join(out_sub_dir, f"{line_idx}_vd_zh.wav") |
| if not args.en_only and voice_instruct_zh and not os.path.exists(vd_zh_path): |
| wavs, sr = vd_model.generate_voice_design(text=full_text, language="Auto", instruct=remove_brackets_content_zh(voice_instruct_zh)) |
| sf.write(vd_zh_path, wavs[0], sr) |
| |
| if not args.zh_only: |
| vd_en_path = os.path.join(out_sub_dir, f"{line_idx}_vd_en.wav") |
| if voice_instruct_en and not os.path.exists(vd_en_path): |
| wavs, sr = vd_model.generate_voice_design(text=full_text, language="Auto", instruct=remove_brackets_content_en(voice_instruct_en)) |
| sf.write(vd_en_path, wavs[0], sr) |
| except Exception as e: |
| print(f"[Worker {args.gpu_id}] 行号 {line_idx} VoiceDesign 生成失败: {e}") |
|
|
| |
| needs_trimming = len(texts) >= 2 |
| for speaker in supported_speakers: |
| try: |
| |
| cv_zh_segments = [] |
| final_sr_zh = 24000 |
| final_zh_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_zh.wav") |
| |
| if not args.en_only: |
| for seg_idx, (text_seg, inst_seg) in enumerate(zip(texts, expressive_instructs_zh)): |
| if not text_seg.strip(): continue |
| seg_wav_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_zh_{seg_idx}.wav") |
| |
| |
| if os.path.exists(seg_wav_path): |
| audio_data, sr = sf.read(seg_wav_path) |
| else: |
| wavs, sr = cv_model.generate_custom_voice(text=text_seg, language="Auto", speaker=speaker, instruct=remove_brackets_content_zh(inst_seg)) |
| audio_data = trim_silence(wavs[0], top_db=45) if needs_trimming else wavs[0] |
| sf.write(seg_wav_path, audio_data, sr) |
| |
| cv_zh_segments.append(audio_data) |
| final_sr_zh = sr |
| |
| |
| if cv_zh_segments and not os.path.exists(final_zh_path): |
| final_zh_audio = np.concatenate(cv_zh_segments) |
| sf.write(final_zh_path, final_zh_audio, final_sr_zh) |
|
|
| if not args.zh_only: |
| |
| cv_en_segments = [] |
| final_sr_en = 24000 |
| final_en_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_en.wav") |
| |
| for seg_idx, (text_seg, inst_seg) in enumerate(zip(texts, expressive_instructs_en)): |
| if not text_seg.strip(): continue |
| seg_wav_path = os.path.join(out_sub_dir, f"{line_idx}_cv_{speaker}_en_{seg_idx}.wav") |
| |
| |
| if os.path.exists(seg_wav_path): |
| audio_data, sr = sf.read(seg_wav_path) |
| else: |
| wavs, sr = cv_model.generate_custom_voice(text=text_seg, language="Auto", speaker=speaker, instruct=remove_brackets_content_en(inst_seg)) |
| audio_data = trim_silence(wavs[0], top_db=45) if needs_trimming else wavs[0] |
| sf.write(seg_wav_path, audio_data, sr) |
|
|
| cv_en_segments.append(audio_data) |
| final_sr_en = sr |
| |
| |
| if cv_en_segments and not os.path.exists(final_en_path): |
| final_en_audio = np.concatenate(cv_en_segments) |
| sf.write(final_en_path, final_en_audio, final_sr_en) |
|
|
| except Exception as e: |
| print(f"[Worker {args.gpu_id}] 行号 {line_idx} Speaker {speaker} CustomVoice 生成失败: {e}") |
| |
| write_item_metadata(item, control, out_sub_dir, line_idx) |
|
|
| print(f"[Worker {args.gpu_id}] 任务完成!") |
|
|
| if __name__ == "__main__": |
| main() |
|
|