feat: 兼容 proplus-hc-dev 训练数据 + 新增「情绪/括注」字段

兼容 proplus-hc-dev 分支基于 6 列 .list 训练的模型,并按主分支自身流程
增加 emotion 字段(标注/推理 UI 单输入框「情绪/括注」,存 emotion 列,
保留 remark 列以兼容 proplus 6 列结构)。emotion/remark 不参与训练。

核心兼容性修复(消除 3 处硬编码拒绝 proplus 的 6 列 .list):
- 新增 tools/list_metadata.py: 6 列 .list 解析/格式化器 (parse/format_list_line)
- 1-get-text.py:129  line.split("|") 严格 4 列解包 -> parse_list_line
                   (proplus 同款修复,只取训练所需前 4 列,emotion/remark 静默忽略)
- subfix_webui b_load_list: 仅接受 len==4 -> 改用 parse_list_line (>=4 列)
- subfix_webui b_save_list: 写 4 列 -> 写 6 列 (保留 emotion/remark,向前向后兼容)

标注 UI (tools/subfix_webui.py):
- 每条音频新增「情绪/括注」Text 输入框 (g_emotion_list)
- b_change_index/reload_data/b_submit_change 贯通 emotion 读写
- 8 个分页事件输出列表统一追加 *g_emotion_list

推理 WebUI (inference_webui.py):
- _read_emotion_map_for_webui: 读 output/asr_opt/<exp>/*.list 的 emotion 列
- 训练样本下拉标签附加【emotion】(有则显示)
- 选中样本时同步刷新「情绪/括注」只读文本框; 应用样本一并填入
- 无 emotion 数据则文本框留空 (符合「无数据不显示」)

API (api_v2.py):
- GET /models/{name}/samples 响应增加 emotion 字段 (来自同源 .list)

向后兼容: 主分支原生 4 列 .list 全程正常; 训练 pipeline 不感知 emotion。
list_metadata/parse/emotion/round-trip 单元自测通过; py_compile 全通过。
This commit is contained in:
XucroYuri 2026-07-07 11:44:02 +08:00
parent 7be8a59e52
commit e1720e7e5a
5 changed files with 232 additions and 42 deletions

View File

@ -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/<model_name>/ 下所有 *.list4/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,

View File

@ -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 列 .listparse_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)])

View File

@ -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/<model_name>/ 下所有 *.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/<model_name>/<parts...> 路径并做目录穿越校验。
@ -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)}

61
tools/list_metadata.py Normal file
View File

@ -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_fieldsemotion / 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 返回 Noneemotion/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 ""),
]
)

View File

@ -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/remarkproplus-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(