diff --git a/GPT_SoVITS/inference_webui.py b/GPT_SoVITS/inference_webui.py index b588852f..fecbc57f 100644 --- a/GPT_SoVITS/inference_webui.py +++ b/GPT_SoVITS/inference_webui.py @@ -1245,6 +1245,34 @@ def _read_name2text_for_webui(logs_dir: Path) -> dict[str, dict[str, str]]: return output +def _read_emotion_map_for_webui(model_name: str) -> dict[str, str]: + """读取 ASR 标注 .list 中的 emotion 列,返回 {audio_name|stem: emotion}。 + + 扫描 output/asr_opt// 下所有 *.list(4/5/6 列均兼容)。 + 无 emotion 列或文件不存在时返回空 dict。 + """ + from tools.list_metadata import parse_list_line + + emotion_map: dict[str, str] = {} + list_dir = Path("output") / "asr_opt" / model_name + if not list_dir.exists(): + return emotion_map + for list_path in sorted(list_dir.glob("*.list")): + try: + for line in list_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + item = parse_list_line(line) + if item is None or not item.emotion: + continue + name = os.path.basename(item.wav_path.strip("\"'")) + if not name: + continue + emotion_map[name] = item.emotion + emotion_map[Path(name).stem] = item.emotion + except Exception: + continue + return emotion_map + + def on_model_name_change(model_name: str): """模型名变化时,加载该模型的训练样本列表到下拉框与预览播放器。""" if not model_name: @@ -1256,10 +1284,16 @@ def on_model_name_change(model_name: str): samples: list[tuple[str, str]] = [] if wav_dir.exists(): name2text = _read_name2text_for_webui(logs_dir) + emotion_map = _read_emotion_map_for_webui(model_name) for f in sorted(wav_dir.iterdir()): if f.is_file() and f.suffix.lower() in (".wav", ".mp3", ".flac"): text = name2text.get(f.name, {}).get("text", "") or name2text.get(f.stem, {}).get("text", "") - label = f"{f.name}" + (f" | {text[:30]}" if text else "") + emotion = emotion_map.get(f.name, "") or emotion_map.get(f.stem, "") + label = f"{f.name}" + if text: + label += f" | {text[:30]}" + if emotion: + label += f" | 【{emotion}】" samples.append((label, str(f))) choices = [s[0] for s in samples] value = samples[0][0] if samples else "" @@ -1268,28 +1302,33 @@ def on_model_name_change(model_name: str): def on_ref_sample_change(sample_label: str, model_name: str): - """训练样本选择变化时,更新预览播放器。""" + """训练样本选择变化时,更新预览播放器与情绪/括注文本框。""" if not sample_label or not model_name: - return gr.Audio(value=None) + return gr.Audio(value=None), "" from config import exp_root wav_name = sample_label.split(" | ")[0] audio_path = Path(exp_root) / model_name / "5-wav32k" / wav_name - return gr.Audio(value=str(audio_path) if audio_path.exists() else None) + emotion_map = _read_emotion_map_for_webui(model_name) + emotion = emotion_map.get(wav_name, "") or emotion_map.get(Path(wav_name).stem, "") + return gr.Audio(value=str(audio_path) if audio_path.exists() else None), emotion def on_apply_sample(sample_label: str, model_name: str): - """应用选中样本:写入参考音频路径和参考文本。""" + """应用选中样本:写入参考音频路径、参考文本、情绪/括注。""" if not sample_label or not model_name: - return None, "", gr.Dropdown() + return None, "", gr.Dropdown(), "" from config import exp_root wav_name = sample_label.split(" | ")[0] audio_path = Path(exp_root) / model_name / "5-wav32k" / wav_name logs_dir = Path(exp_root) / model_name name2text = _read_name2text_for_webui(logs_dir) + emotion_map = _read_emotion_map_for_webui(model_name) text = name2text.get(wav_name, {}).get("text", "") or name2text.get(Path(wav_name).stem, {}).get("text", "") - return (str(audio_path) if audio_path.exists() else None), text, gr.Dropdown() + emotion = emotion_map.get(wav_name, "") or emotion_map.get(Path(wav_name).stem, "") + audio_out = str(audio_path) if audio_path.exists() else None + return audio_out, text, gr.Dropdown(), emotion def _scan_model_weights_for_webui() -> dict[str, dict[str, list[str]]]: @@ -1412,6 +1451,12 @@ with gr.Blocks(title="GPT-SoVITS WebUI", analytics_enabled=False, js=js, css=css ) ) prompt_text = gr.Textbox(label=i18n("参考音频的文本"), value="", lines=5, max_lines=5, scale=1) + ref_emotion_text = gr.Textbox( + label=i18n("情绪/括注(来自训练样本,无数据留空)"), + value="", + interactive=False, + scale=1, + ) with gr.Column(scale=14): prompt_language = gr.Dropdown( label=i18n("参考音频的语种"), @@ -1584,12 +1629,12 @@ with gr.Blocks(title="GPT-SoVITS WebUI", analytics_enabled=False, js=js, css=css ref_sample_dropdown.change( fn=on_ref_sample_change, inputs=[ref_sample_dropdown, model_name_dropdown], - outputs=[ref_sample_player], + outputs=[ref_sample_player, ref_emotion_text], ) apply_sample_btn.click( fn=on_apply_sample, inputs=[ref_sample_dropdown, model_name_dropdown], - outputs=[inp_ref, prompt_text, ref_sample_dropdown], + outputs=[inp_ref, prompt_text, ref_sample_dropdown, ref_emotion_text], ) auto_select_weights_btn.click( fn=on_auto_select_weights, diff --git a/GPT_SoVITS/prepare_datasets/1-get-text.py b/GPT_SoVITS/prepare_datasets/1-get-text.py index 8d83e79a..85db31c5 100644 --- a/GPT_SoVITS/prepare_datasets/1-get-text.py +++ b/GPT_SoVITS/prepare_datasets/1-get-text.py @@ -20,6 +20,7 @@ import os.path from text.cleaner import clean_text from transformers import AutoModelForMaskedLM, AutoTokenizer from tools.my_utils import clean_path +from tools.list_metadata import parse_list_line # inp_text=sys.argv[1] # inp_wav_dir=sys.argv[2] @@ -126,7 +127,12 @@ if os.path.exists(txt_path) == False: } for line in lines[int(i_part) :: int(all_parts)]: try: - wav_name, spk_name, language, text = line.split("|") + # 兼容 4/5/6 列 .list:parse_list_line 只取训练所需前 4 列, + # 额外的 emotion/remark 列(proplus-hc-dev 分支)被静默忽略,不影响训练。 + item = parse_list_line(line) + if item is None: + raise ValueError("list 行列数不足,需至少 4 列:wav_path|speaker_name|language|text") + wav_name, language, text = item.wav_path, item.language, item.text # todo.append([name,text,"zh"]) if language in language_v1_to_language_v2.keys(): todo.append([wav_name, text, language_v1_to_language_v2.get(language, language)]) diff --git a/api_v2.py b/api_v2.py index b08c08b9..5ead07d4 100644 --- a/api_v2.py +++ b/api_v2.py @@ -637,6 +637,36 @@ def _read_name2text(logs_dir: Path) -> dict[str, dict[str, str]]: return output +def _read_emotion_map(model_name: str) -> dict[str, str]: + """读取 ASR 标注 .list 中的 emotion 列,返回 {audio_name|stem: emotion}。 + + 扫描 output/asr_opt// 下所有 *.list(兼容 4/5/6 列)。 + 兼容 proplus-hc-dev 分支训练数据:emotion 为第 5 列,缺省为空串。 + 无 .list 或无 emotion 列时返回空 dict。 + """ + from tools.list_metadata import parse_list_line + + emotion_map: dict[str, str] = {} + list_dir = Path("output") / "asr_opt" / model_name + if not list_dir.exists(): + return emotion_map + for list_path in sorted(list_dir.glob("*.list")): + try: + for line in list_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + item = parse_list_line(line) + if item is None or not item.emotion: + continue + name = os.path.basename(item.wav_path.strip("\"'")) + if not name: + continue + emotion_map[name] = item.emotion + emotion_map[Path(name).stem] = item.emotion + except Exception: + continue + return emotion_map + + + def _safe_logs_subpath(model_name: str, *parts: str) -> Path | None: """解析 logs// 路径并做目录穿越校验。 @@ -706,6 +736,7 @@ async def list_model_samples(model_name: str): if logs_abs is None or not logs_abs.exists(): return JSONResponse(status_code=404, content={"message": f"model '{model_name}' not found"}) name2text = _read_name2text(logs_abs) + emotion_map = _read_emotion_map(model_name) wav_dir = logs_abs / "5-wav32k" samples: list[dict] = [] if wav_dir.exists(): @@ -718,11 +749,13 @@ async def list_model_samples(model_name: str): if entry is None: continue # 与 name2text 取交集,只返回有标注的样本 rel = os.path.relpath(f, now_dir) + emotion = emotion_map.get(f.name, "") or emotion_map.get(f.stem, "") samples.append({ "audio_name": f.name, "audio_path": rel.replace(os.sep, "/"), "text": entry["text"], "lang": entry["lang"], + "emotion": emotion, }) return {"model_name": model_name, "samples": samples, "total": len(samples)} diff --git a/tools/list_metadata.py b/tools/list_metadata.py new file mode 100644 index 00000000..eb232945 --- /dev/null +++ b/tools/list_metadata.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ListLine: + """一条 `.list` 标注行(兼容 4/5/6 列)。 + + 列定义: wav_path|speaker_name|language|text[|emotion|remark] + 训练只用前 4 列(training_fields);emotion / remark 为元数据, + 不参与模型训练,用于标注与参考音频展示。 + """ + + wav_path: str + speaker_name: str + language: str + text: str + emotion: str = "" + remark: str = "" + + @property + def training_fields(self): + return [self.wav_path, self.speaker_name, self.language, self.text] + + +def _clean(value): + return str(value or "").strip() + + +def parse_list_line(line): + """解析一行 `.list`。列数 < 4 返回 None;emotion/remark 缺省为空串。""" + parts = str(line or "").rstrip("\r\n").split("|") + if len(parts) < 4: + return None + return ListLine( + wav_path=_clean(parts[0]), + speaker_name=_clean(parts[1]), + language=_clean(parts[2]), + text=_clean(parts[3]), + emotion=_clean(parts[4]) if len(parts) >= 5 else "", + remark=_clean(parts[5]) if len(parts) >= 6 else "", + ) + + +def format_list_line(item): + """把 ListLine 格式化为 6 列 `.list` 行(emotion/remark 为空也保留分隔符)。 + + 注意:写出 6 列以保证与 proplus-hc-dev 分支互操作,主分支训练侧 + (1-get-text.py)已用 parse_list_line 只消费前 4 列,多余列不影响训练。 + """ + return "|".join( + [ + str(item.wav_path or ""), + str(item.speaker_name or ""), + str(item.language or ""), + str(item.text or ""), + str(item.emotion or ""), + str(item.remark or ""), + ] + ) diff --git a/tools/subfix_webui.py b/tools/subfix_webui.py index 51a7dfad..3ac0094e 100644 --- a/tools/subfix_webui.py +++ b/tools/subfix_webui.py @@ -32,6 +32,7 @@ g_batch = 10 g_text_list = [] g_audio_list = [] g_checkbox_list = [] +g_emotion_list = [] g_data_json = [] @@ -43,7 +44,13 @@ def reload_data(index, batch): datas = g_data_json[index : index + batch] output = [] for d in datas: - output.append({g_json_key_text: d[g_json_key_text], g_json_key_path: d[g_json_key_path]}) + output.append( + { + g_json_key_text: d[g_json_key_text], + g_json_key_path: d[g_json_key_path], + "emotion": d.get("emotion", ""), + } + ) return output @@ -52,26 +59,26 @@ def b_change_index(index, batch): g_index, g_batch = index, batch datas = reload_data(index, batch) output = [] + # 文本框组 for i, _ in enumerate(datas): output.append( - # gr.Textbox( - # label=f"Text {i+index}", - # value=_[g_json_key_text]#text - # ) {"__type__": "update", "label": f"Text {i + index}", "value": _[g_json_key_text]} ) for _ in range(g_batch - len(datas)): + output.append({"__type__": "update", "label": "Text", "value": ""}) + # 情绪/括注框组(与文本框一一对应) + for i, _ in enumerate(datas): output.append( - # gr.Textbox( - # label=f"Text", - # value="" - # ) - {"__type__": "update", "label": "Text", "value": ""} + {"__type__": "update", "label": f"情绪/括注 {i + index}", "value": _.get("emotion", "")} ) + for _ in range(g_batch - len(datas)): + output.append({"__type__": "update", "label": "情绪/括注", "value": ""}) + # 音频组 for _ in datas: output.append(_[g_json_key_path]) for _ in range(g_batch - len(datas)): output.append(None) + # 复选框组 for _ in range(g_batch): output.append(False) return output @@ -93,8 +100,12 @@ def b_previous_index(index, batch): return 0, *b_change_index(0, batch) -def b_submit_change(*text_list): +def b_submit_change(*args): + # 输入约定:[text×batch] + [emotion×batch](由事件绑定顺序拼成) global g_data_json + half = g_batch + text_list = args[:half] + emotion_list = args[half:] change = False for i, new_text in enumerate(text_list): if g_index + i <= g_max_json_index: @@ -102,6 +113,12 @@ def b_submit_change(*text_list): if g_data_json[g_index + i][g_json_key_text] != new_text: g_data_json[g_index + i][g_json_key_text] = new_text change = True + for i, new_emotion in enumerate(emotion_list): + if g_index + i <= g_max_json_index: + new_emotion = (new_emotion or "").strip() + if g_data_json[g_index + i].get("emotion", "") != new_emotion: + g_data_json[g_index + i]["emotion"] = new_emotion + change = True if change: b_save_file() return g_index, *b_change_index(g_index, g_batch) @@ -226,13 +243,25 @@ def b_save_json(): def b_save_list(): + from tools.list_metadata import format_list_line, ListLine + with open(g_load_file, "w", encoding="utf-8") as file: for data in g_data_json: - wav_path = data["wav_path"] - speaker_name = data["speaker_name"] - language = data["language"] - text = data["text"] - file.write(f"{wav_path}|{speaker_name}|{language}|{text}".strip() + "\n") + # 写出 6 列以保留 emotion/remark(proplus-hc-dev 互操作); + # 主分支训练侧(1-get-text.py)只消费前 4 列,多余列不影响训练。 + file.write( + format_list_line( + ListLine( + wav_path=data.get("wav_path", ""), + speaker_name=data.get("speaker_name", ""), + language=data.get("language", ""), + text=data.get("text", ""), + emotion=data.get("emotion", ""), + remark=data.get("remark", ""), + ) + ).strip() + + "\n" + ) def b_load_json(): @@ -245,17 +274,25 @@ def b_load_json(): def b_load_list(): global g_data_json, g_max_json_index + from tools.list_metadata import parse_list_line + with open(g_load_file, "r", encoding="utf-8") as source: data_list = source.readlines() for _ in data_list: - data = _.split("|") - if len(data) == 4: - wav_path, speaker_name, language, text = data - g_data_json.append( - {"wav_path": wav_path, "speaker_name": speaker_name, "language": language, "text": text.strip()} - ) - else: - print("error line:", data) + item = parse_list_line(_) + if item is None: + print("error line:", _) + continue + g_data_json.append( + { + "wav_path": item.wav_path, + "speaker_name": item.speaker_name, + "language": item.language, + "text": item.text, + "emotion": item.emotion, + "remark": item.remark, + } + ) g_max_json_index = len(g_data_json) - 1 @@ -338,9 +375,16 @@ if __name__ == "__main__": text = gr.Textbox(label="Text", visible=True, scale=5) audio_output = gr.Audio(label="Output Audio", visible=True, scale=5) audio_check = gr.Checkbox(label="Yes", show_label=True, info="Choose Audio", scale=1) + emotion = gr.Textbox( + label="情绪/括注", + visible=True, + scale=3, + placeholder="可选:台词对应的表演情绪或括注", + ) g_text_list.append(text) g_audio_list.append(audio_output) g_checkbox_list.append(audio_check) + g_emotion_list.append(emotion) with gr.Row(): batchsize_slider = gr.Slider( @@ -356,15 +400,16 @@ if __name__ == "__main__": index_slider, batchsize_slider, ], - outputs=[*g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[*g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_submit_change.click( b_submit_change, inputs=[ *g_text_list, + *g_emotion_list, ], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_previous_index.click( @@ -373,7 +418,7 @@ if __name__ == "__main__": index_slider, batchsize_slider, ], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_next_index.click( @@ -382,25 +427,25 @@ if __name__ == "__main__": index_slider, batchsize_slider, ], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_delete_audio.click( b_delete_audio, inputs=[*g_checkbox_list], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_merge_audio.click( b_merge_audio, inputs=[interval_slider, *g_checkbox_list], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_audio_split.click( b_audio_split, inputs=[splitpoint_slider, *g_checkbox_list], - outputs=[index_slider, *g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[index_slider, *g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) btn_invert_selection.click(b_invert_selection, inputs=[*g_checkbox_list], outputs=[*g_checkbox_list]) @@ -413,7 +458,7 @@ if __name__ == "__main__": index_slider, batchsize_slider, ], - outputs=[*g_text_list, *g_audio_list, *g_checkbox_list], + outputs=[*g_text_list, *g_emotion_list, *g_audio_list, *g_checkbox_list], ) demo.launch(