diff --git "a/.cursor/plans/\351\207\215\346\236\204_webui_\346\250\241\345\235\227\345\214\226_32503828.plan.md" "b/.cursor/plans/\351\207\215\346\236\204_webui_\346\250\241\345\235\227\345\214\226_32503828.plan.md" new file mode 100644 index 0000000..ebe788a --- /dev/null +++ "b/.cursor/plans/\351\207\215\346\236\204_webui_\346\250\241\345\235\227\345\214\226_32503828.plan.md" @@ -0,0 +1,160 @@ +--- +name: 重构 webui 模块化 +overview: 将 webui.py(2134行)拆分为 webui/ 包,按职责划分为多个模块,保持原入口文件兼容。 +todos: [] +--- + +# 重构 webui.py 为模块化包 + +## 目标结构 + +``` +webui/ +├── __init__.py # 导出核心接口 +├── constants.py # 常量、示例数据、方言配置 +├── i18n.py # 国际化字典和函数 +├── utils.py # 工具函数(类型转换、文本验证) +├── synthesis.py # 合成核心逻辑 +├── file_manager.py # 文件保存、ZIP 创建 +├── config_manager.py # 配置导入/导出 +├── components.py # UI 组件创建 +├── callbacks.py # UI 回调函数(说话人管理、语言切换等) +└── interface.py # render_interface 主函数 + +webui.py # 入口文件(保持兼容) +``` + +## 模块划分详情 + +### 1. `webui/constants.py` + +- `BASE_DIR`, `CONFIG_DIR`, `MAX_SPEAKERS`, `MAX_TEXT_INPUTS` +- `S1_PROMPT_WAV`, `S2_PROMPT_WAV` +- `EXAMPLES_LIST`(第130-178行) +- `load_dialect_prompt_data()` 及 `DIALECT_PROMPT_DATA`, `DIALECT_CHOICES` + +### 2. `webui/i18n.py` + +- `_i18n_key2lang_dict`(约240行的翻译字典) +- `global_lang` 变量 +- `i18n()` 函数 +- `get_select_speaker_label()` 函数 + +### 3. `webui/utils.py` + +- `_ensure_config_dir()`, `_list_config_files()` +- `_read_json_file()`, `_write_json_file()` +- `_coerce_gradio_file_to_path()`, `_coerce_audio_value_to_path()` +- `check_monologue_text()`, `check_dialect_prompt_text()`, `check_dialogue_text()` + +### 4. `webui/synthesis.py` + +- 全局变量 `model`, `dataset` +- `initiate_model()` 函数 +- `process_single()` 函数 +- `dialogue_synthesis_function()` 函数(核心合成逻辑,约280行) + +### 5. `webui/file_manager.py` + +- `create_zip_file()` 函数 +- `create_all_zip()` 函数 + +### 6. `webui/config_manager.py` + +- `_build_current_config_dict()` 函数 +- `_export_current_config()` 函数 +- `_refresh_config_dropdown()` 函数 +- `_apply_loaded_config()` 函数 +- `_load_uploaded_and_apply()` 函数 +- `_load_selected_and_apply()` 函数 + +### 7. `webui/components.py` + +- `create_speaker_group()` 函数 +- `update_example_choices()`, `update_prompt_text()` 函数 + +### 8. `webui/callbacks.py` + +- `update_speakers_visibility()` 函数 +- `add_speaker()`, `quick_add_speakers()` 函数 +- `batch_delete_speakers()` 函数 +- `select_all_checkboxes()`, `select_none_checkboxes()` 函数 +- `update_text_inputs_visibility()` 函数 +- `process_single_synthesis()` 函数 +- `collect_and_synthesize_queue()` 函数 +- `_change_component_language()` 函数 + +### 9. `webui/interface.py` + +- `render_interface()` 函数:仅保留 UI 布局定义,回调逻辑引用 `callbacks` 模块 + +### 10. `webui/__init__.py` + +```python +from .interface import render_interface +from .synthesis import initiate_model +from .constants import BASE_DIR, CONFIG_DIR +``` + +### 11. `webui.py`(入口文件) + +```python +from webui import render_interface, initiate_model +from webui.constants import ... +# 保持原有 get_args() 和 main 逻辑 +``` + +## 模块依赖关系 + +```mermaid +flowchart TD + subgraph entry [Entry Point] + webui_py[webui.py] + end + + subgraph pkg [webui package] + init[__init__.py] + interface[interface.py] + callbacks[callbacks.py] + components[components.py] + config_mgr[config_manager.py] + synthesis[synthesis.py] + file_mgr[file_manager.py] + utils[utils.py] + i18n[i18n.py] + constants[constants.py] + end + + webui_py --> init + init --> interface + init --> synthesis + + interface --> callbacks + interface --> components + interface --> config_mgr + + callbacks --> synthesis + callbacks --> file_mgr + callbacks --> i18n + + config_mgr --> utils + config_mgr --> constants + + synthesis --> constants + synthesis --> i18n + + components --> i18n + + file_mgr --> i18n + + utils --> constants +``` + +## 实施步骤 + +1. 创建 `webui/` 目录结构 +2. 按依赖顺序创建模块(constants -> i18n -> utils -> 其他) +3. 迁移代码到各模块,调整 import 语句 +4. 重构 `render_interface()`,将回调函数移至 `callbacks.py` +5. 更新 `webui.py` 为简洁入口 +6. 验证功能正常 \ No newline at end of file diff --git a/.cursor/rules/python.mdc b/.cursor/rules/python.mdc new file mode 100644 index 0000000..a586601 --- /dev/null +++ b/.cursor/rules/python.mdc @@ -0,0 +1,15 @@ +--- +alwaysApply: true +--- +- 终端执行命令,优先使用bash。 +- 运行 python 命令前,需要 先 激活python环境: +```python +# 初始化 conda 环境 +source /root/anaconda3/etc/profile.d/conda.sh + +# 激活环境 +conda activate soulxpodcast + +# 进入项目目录 +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast +``` \ No newline at end of file diff --git a/config/Unit3-2.json b/config/Unit3-2.json new file mode 100644 index 0000000..f016785 --- /dev/null +++ b/config/Unit3-2.json @@ -0,0 +1,59 @@ +{ + "version": "1.0", + "export_time": "2025-12-14 18:12:31", + "language": "zh", + "num_speakers": 7, + "num_text_inputs": 4, + "seed": 1988, + "diff_spk_pause_ms": 0, + "speakers": [ + { + "prompt_audio": "/tmp/gradio/9465bec06a6985197897d551638386080106fec488a16ce293cb3645fe3aa8b3/佩奇1 - 如果要骑到南瓜那儿那我们一定要非常小心千万不可以撞上去.mp3", + "prompt_text": "如果要骑到南瓜那儿,那我们一定要非常小心,千万不可以撞上去!", + "dialect_prompt_text": "", + "remark": "佩奇" + }, + { + "prompt_audio": "/tmp/gradio/f2b287e0f747cdb9ea8ca421132c3518baba630ab72febb6f12cf25d43dfb66f/米小圈1.mp3", + "prompt_text": "这把钥匙一定很重要,失主到现在一定很着急。我按照字条上的地址来到一座山上,失主叔叔看到我把小盒子给他送回来高兴的都快哭了。", + "dialect_prompt_text": "", + "remark": "米小圈" + }, + { + "prompt_audio": "/tmp/gradio/bf2dfc2b4940b4a9a565e980f6313e424a9f189cbda7905a26b1be0e1bd3858f/敖丙1.mp3", + "prompt_text": "五年多了,再次登台肯定还是有点儿,你不紧张吗?", + "dialect_prompt_text": "", + "remark": "敖丙" + }, + { + "prompt_audio": "/tmp/gradio/a3065ccdb99a5cdefc540d8d2799478c75b684820dc8e37a7baeb8a79f7fee61/Amy1.mp3", + "prompt_text": "We're from the UK.\nWe're twins.", + "dialect_prompt_text": "", + "remark": "Amy" + }, + { + "prompt_audio": "/tmp/gradio/02f81d423e5c2be7fafe5585692cd7407cdf7880fd8506556604c31e6806c17b/sam1.mp3", + "prompt_text": "Hi, everyone. I'm Sam.\nI'm nine.\nShe's Amy.\nShe's nine, too.", + "dialect_prompt_text": "", + "remark": "Sam" + }, + { + "prompt_audio": "/tmp/gradio/435d41f664ea91565eb2a4531f168b99cd0e2123d9783051ac110f6176a0fdbe/daming1.mp3", + "prompt_text": "Sam, this is my dad.\nThis is my grandpa.\nThat is my grandma.\nBut who is that?\nOh, this is my grandma.", + "dialect_prompt_text": "", + "remark": "Ming" + }, + { + "prompt_audio": "/tmp/gradio/34aebf28bbf889b39dc10b44b63640fdf43542dc08195dd3a9fd1b8ca42a1f05/Lingling1.mp3", + "prompt_text": "Hi, Sam.\nHi, Amy.\nI'm Ling Ling.\nHe's Da Ming.", + "dialect_prompt_text": "", + "remark": "Ling" + } + ], + "text_inputs": [ + "[S1] 一 <|pause:600|> 听力测试(共四节,满分60分)<|pause:2000|>\n[S2] 第一节:<|pause:600|>判断正误。(共10小题;每小题2分,满分20分)听录音,判断录音内容与下列图片是否一致?一致的请填“T”,不一致的请填“F”。每道题读三遍。<|pause:2000|>\n[S6] one, <|pause:1200|> N <|pause:1800|> N <|pause:1800|> N <|pause:2000|>\n[S4] two, <|pause:1200|> Let's see. <|pause:1800|> Let's see. <|pause:1800|> Let's see.<|pause:2000|>\n[S4] three, <|pause:1200|> Look at my umbrella. <|pause:1800|> Look at my umbrella. <|pause:1800|> Look at my umbrella.<|pause:2000|>\n[S4] four, <|pause:1200|> I want balloons. <|pause:1800|> I want balloons. <|pause:1800|> I want balloons.<|pause:2000|>\n[S4] five, <|pause:1200|> It's an orange orange. <|pause:1800|> It's an orange orange. <|pause:1800|> It's an orange orange.<|pause:2000|>\n[S4] six, <|pause:1200|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful!<|pause:2000|>\n[S4] seven, <|pause:1200|> Today is National Day. <|pause:1800|> Today is National Day. <|pause:1800|> Today is National Day.<|pause:2000|>\n[S4] eight, <|pause:1200|> So many colours! <|pause:1800|> So many colours! <|pause:1800|> So many colours!<|pause:2000|>\n[S4] nine, <|pause:1200|> What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:2000|>\n[S4] ten, <|pause:1200|> Look! It's red now. <|pause:1800|> Look! It's red now. <|pause:1800|> Look! It's red now.<|pause:2000|>", + "[S3] 第二节:<|pause:600|>听音选图. (共10小题;每小题2分,满分20分)听录音,根据录音内容选择正确的图片。每道题读三遍。<|pause:2000|>\n[S6] eleven, <|pause:1200|> K <|pause:1800|> K <|pause:1800|> K <|pause:2000|>\n[S5] twelve, <|pause:1200|> What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:2000|>\n[S5] thirteen, <|pause:1200|> Let's come and paint. <|pause:1800|> Let's come and paint. <|pause:1800|> Let's come and paint.<|pause:2000|>\n[S5] fourteen, <|pause:1200|> It's green and red. <|pause:1800|> It's green and red. <|pause:1800|> It's green and red.<|pause:2000|>\n[S5] fifteen, <|pause:1200|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day!<|pause:2000|>\n[S5] sixteen, <|pause:1200|> It's a picture. <|pause:1800|> It's a picture. <|pause:1800|> It's a picture.<|pause:2000|>\n[S5] seventeen, <|pause:1200|> Bobo is the first. <|pause:1800|> Bobo is the first. <|pause:1800|> Bobo is the first.<|pause:2000|>\n[S5] eighteen, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>\n[S5] nineteen, <|pause:1200|> What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:2000|>\n[S6] twenty, <|pause:1200|> L <|pause:1800|> L <|pause:1800|> L <|pause:2000|>", + "[S2] 第三节:<|pause:600|>听音选词. (共10小题;每小题1分,满分10分)听录音,根据录音内容选择正确的单词。每道题读三遍。<|pause:2000|>\n[S6] twenty one, <|pause:1200|> KMH <|pause:1800|> KMH <|pause:1800|> KMH <|pause:2000|>\n[S6] twenty two, <|pause:1200|> want <|pause:1800|> want <|pause:1800|> want <|pause:2000|>\n[S6] twenty three, <|pause:1200|> blue <|pause:1800|> blue <|pause:1800|> blue <|pause:2000|>\n[S6] twenty four, <|pause:1200|> purple <|pause:1800|> purple <|pause:1800|> purple <|pause:2000|>\n[S6] twenty five, <|pause:1200|> can <|pause:1800|> can <|pause:1800|> can <|pause:2000|>\n[S6] twenty six, <|pause:1200|> red <|pause:1800|> red <|pause:1800|> red <|pause:2000|>\n[S6] twenty seven, <|pause:1200|> world <|pause:1800|> world <|pause:1800|> world <|pause:2000|>\n[S6] twenty eight, <|pause:1200|> magical <|pause:1800|> magical <|pause:1800|> magical <|pause:2000|>\n[S6] twenty nine, <|pause:1200|> today <|pause:1800|> today <|pause:1800|> today <|pause:2000|>\n[S6] thirty, <|pause:1200|> fun <|pause:1800|> fun <|pause:1800|> fun <|pause:2000|>", + "[S3] 第四节:<|pause:600|> 听音辩句。(共10小题;每小题1分,满分10分)听录音,根据录音内容选出相应的句子。每道题读三遍。<|pause:2000|>\n[S7] thirty one, <|pause:1200|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green.<|pause:2000|>\n[S7] thirty two, <|pause:1200|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black.<|pause:2000|>\n[S7] thirty three, <|pause:1200|> Look at our clothes. <|pause:1800|> Look at our clothes. <|pause:1800|> Look at our clothes.<|pause:2000|>\n[S7] thirty four, <|pause:1200|> I'm yellow. <|pause:1800|> I'm yellow. <|pause:1800|> I'm yellow.<|pause:2000|>\n[S7] thirty five, <|pause:1200|> Come back. <|pause:1800|> Come back. <|pause:1800|> Come back.<|pause:2000|>\n[S7] thirty six, <|pause:1200|> What colour is it? <|pause:1800|> What colour is it? <|pause:1800|> What colour is it?<|pause:2000|>\n[S7] thirty seven, <|pause:1200|> It's a colourful world. <|pause:1800|> It's a colourful world. <|pause:1800|> It's a colourful world.<|pause:2000|>\n[S7] thirty eight, <|pause:1200|> This is fun <|pause:1800|> This is fun <|pause:1800|> This is fun <|pause:2000|>\n[S7] thirty nine, <|pause:1200|> I want a blue bag. <|pause:1800|> I want a blue bag. <|pause:1800|> I want a blue bag.<|pause:2000|>\n[S7] forty, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>" + ] +} \ No newline at end of file diff --git a/config/Unit3-All-Start.json b/config/Unit3-All-Start.json new file mode 100644 index 0000000..a7d776d --- /dev/null +++ b/config/Unit3-All-Start.json @@ -0,0 +1,66 @@ +{ + "version": "1.0", + "export_time": "2025-12-15 02:43:45", + "language": "zh", + "num_speakers": 8, + "num_text_inputs": 4, + "seed": 1988, + "diff_spk_pause_ms": 0, + "task_pause_ms": 500, + "speakers": [ + { + "prompt_audio": "/tmp/gradio/9465bec06a6985197897d551638386080106fec488a16ce293cb3645fe3aa8b3/佩奇1 - 如果要骑到南瓜那儿那我们一定要非常小心千万不可以撞上去.mp3", + "prompt_text": "如果要骑到南瓜那儿,那我们一定要非常小心,千万不可以撞上去!", + "dialect_prompt_text": "", + "remark": "佩奇" + }, + { + "prompt_audio": "/tmp/gradio/f2b287e0f747cdb9ea8ca421132c3518baba630ab72febb6f12cf25d43dfb66f/米小圈1.mp3", + "prompt_text": "这把钥匙一定很重要,失主到现在一定很着急。我按照字条上的地址来到一座山上,失主叔叔看到我把小盒子给他送回来高兴的都快哭了。", + "dialect_prompt_text": "", + "remark": "米小圈" + }, + { + "prompt_audio": "/tmp/gradio/bf2dfc2b4940b4a9a565e980f6313e424a9f189cbda7905a26b1be0e1bd3858f/敖丙1.mp3", + "prompt_text": "五年多了,再次登台肯定还是有点儿,你不紧张吗?", + "dialect_prompt_text": "", + "remark": "敖丙" + }, + { + "prompt_audio": "/tmp/gradio/cfa8200a4c305f043176b3d32a728d5944e8a91285f42fa90a4f1cbc1ecc3a09/Amy2.mp3", + "prompt_text": "It's my school bag.\nIt's a book.\nIt's a pen.\nNo, it isn't.\nIt's a pencil.\nIt's a ruler.\nWhat's that, Tom?", + "dialect_prompt_text": "", + "remark": "Amy" + }, + { + "prompt_audio": "/tmp/gradio/02f81d423e5c2be7fafe5585692cd7407cdf7880fd8506556604c31e6806c17b/sam1.mp3", + "prompt_text": "Hi, everyone. I'm Sam.\nI'm nine.\nShe's Amy.\nShe's nine, too.", + "dialect_prompt_text": "", + "remark": "Sam" + }, + { + "prompt_audio": "/tmp/gradio/435d41f664ea91565eb2a4531f168b99cd0e2123d9783051ac110f6176a0fdbe/daming1.mp3", + "prompt_text": "Sam, this is my dad.\nThis is my grandpa.\nThat is my grandma.\nBut who is that?\nOh, this is my grandma.", + "dialect_prompt_text": "", + "remark": "Ming" + }, + { + "prompt_audio": "/tmp/gradio/34aebf28bbf889b39dc10b44b63640fdf43542dc08195dd3a9fd1b8ca42a1f05/Lingling1.mp3", + "prompt_text": "Hi, Sam.\nHi, Amy.\nI'm Ling Ling.\nHe's Da Ming.", + "dialect_prompt_text": "", + "remark": "Ling" + }, + { + "prompt_audio": "/tmp/gradio/ac1f22f4ca0a9d3474eff5fe1fb14b0a0428c9bf281b92268d57ac0951dc12f2/Bobo1.mp3", + "prompt_text": "I want a balloon too.\nIt's red.\nIt's yellow.\nIt's pink.\nIt's purple.\nIt's orange.", + "dialect_prompt_text": "", + "remark": "bobo" + } + ], + "text_inputs": [ + "[S1] 一 <|pause:600|> 听力测试(共四节,满分60分)<|pause:2000|>\n[S2] 第一节:<|pause:600|>判断正误。(共10小题;每小题2分,满分20分)听录音,判断录音内容与下列图片是否一致?一致的请填“T”,不一致的请填“F”。每道题读三遍。<|pause:2000|>\n[S6] one, <|pause:1200|> N <|pause:1800|> N <|pause:1800|> N <|pause:2000|>\n[S4] two, <|pause:1200|> Let's see. <|pause:1800|> Let's see. <|pause:1800|> Let's see.<|pause:2000|>\n[S4] three, <|pause:1200|> Look at my umbrella. <|pause:1800|> Look at my umbrella. <|pause:1800|> Look at my umbrella.<|pause:2000|>\n[S4] four, <|pause:1200|> I want balloons. <|pause:1800|> I want balloons. <|pause:1800|> I want balloons.<|pause:2000|>\n[S4] five, <|pause:1200|> It's an orange orange. <|pause:1800|> It's an orange orange. <|pause:1800|> It's an orange orange.<|pause:2000|>\n[S4] six, <|pause:1200|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful!<|pause:2000|>\n[S4] seven, <|pause:1200|> Today is National Day. <|pause:1800|> Today is National Day. <|pause:1800|> Today is National Day.<|pause:2000|>\n[S4] eight, <|pause:1200|> So many colours! <|pause:1800|> So many colours! <|pause:1800|> So many colours!<|pause:2000|>\n[S4] nine, <|pause:1200|> What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:2000|>\n[S4] ten, <|pause:1200|> Look! It's red now. <|pause:1800|> Look! It's red now. <|pause:1800|> Look! It's red now.<|pause:2000|>", + "[S3] 第二节:<|pause:600|>听音选图. (共10小题;每小题2分,满分20分)听录音,根据录音内容选择正确的图片。每道题读三遍。<|pause:2000|>\n[S6] eleven, <|pause:1200|> K <|pause:1800|> K <|pause:1800|> K <|pause:2000|>\n[S5] twelve, <|pause:1200|> What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:2000|>\n[S5] thirteen, <|pause:1200|> Let's come and paint. <|pause:1800|> Let's come and paint. <|pause:1800|> Let's come and paint.<|pause:2000|>\n[S5] fourteen, <|pause:1200|> It's green and red. <|pause:1800|> It's green and red. <|pause:1800|> It's green and red.<|pause:2000|>\n[S5] fifteen, <|pause:1200|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day!<|pause:2000|>\n[S5] sixteen, <|pause:1200|> It's a picture. <|pause:1800|> It's a picture. <|pause:1800|> It's a picture.<|pause:2000|>\n[S5] seventeen, <|pause:1200|> Bobo is the first. <|pause:1800|> Bobo is the first. <|pause:1800|> Bobo is the first.<|pause:2000|>\n[S5] eighteen, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>\n[S5] nineteen, <|pause:1200|> What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:2000|>\n[S6] twenty, <|pause:1200|> L <|pause:1800|> L <|pause:1800|> L <|pause:2000|>", + "[S2] 第三节:<|pause:600|>听音选词. (共10小题;每小题1分,满分10分)听录音,根据录音内容选择正确的单词。每道题读三遍。<|pause:2000|>\n[S6] twenty one, <|pause:1200|> KMH <|pause:1800|> KMH <|pause:1800|> KMH <|pause:2000|>\n[S6] twenty two, <|pause:1200|> want <|pause:1800|> want <|pause:1800|> want <|pause:2000|>\n[S6] twenty three, <|pause:1200|> blue <|pause:1800|> blue <|pause:1800|> blue <|pause:2000|>\n[S6] twenty four, <|pause:1200|> purple <|pause:1800|> purple <|pause:1800|> purple <|pause:2000|>\n[S6] twenty five, <|pause:1200|> can <|pause:1800|> can <|pause:1800|> can <|pause:2000|>\n[S6] twenty six, <|pause:1200|> red <|pause:1800|> red <|pause:1800|> red <|pause:2000|>\n[S6] twenty seven, <|pause:1200|> world <|pause:1800|> world <|pause:1800|> world <|pause:2000|>\n[S6] twenty eight, <|pause:1200|> magical <|pause:1800|> magical <|pause:1800|> magical <|pause:2000|>\n[S6] twenty nine, <|pause:1200|> today <|pause:1800|> today <|pause:1800|> today <|pause:2000|>\n[S6] thirty, <|pause:1200|> fun <|pause:1800|> fun <|pause:1800|> fun <|pause:2000|>", + "[S3] 第四节:<|pause:600|> 听音辩句。(共10小题;每小题1分,满分10分)听录音,根据录音内容选出相应的句子。每道题读三遍。<|pause:2000|>\n[S7] thirty one, <|pause:1200|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green.<|pause:2000|>\n[S7] thirty two, <|pause:1200|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black.<|pause:2000|>\n[S7] thirty three, <|pause:1200|> Look at our clothes. <|pause:1800|> Look at our clothes. <|pause:1800|> Look at our clothes.<|pause:2000|>\n[S7] thirty four, <|pause:1200|> I'm yellow. <|pause:1800|> I'm yellow. <|pause:1800|> I'm yellow.<|pause:2000|>\n[S7] thirty five, <|pause:1200|> Come back. <|pause:1800|> Come back. <|pause:1800|> Come back.<|pause:2000|>\n[S7] thirty six, <|pause:1200|> What colour is it? <|pause:1800|> What colour is it? <|pause:1800|> What colour is it?<|pause:2000|>\n[S7] thirty seven, <|pause:1200|> It's a colourful world. <|pause:1800|> It's a colourful world. <|pause:1800|> It's a colourful world.<|pause:2000|>\n[S7] thirty eight, <|pause:1200|> This is fun <|pause:1800|> This is fun <|pause:1800|> This is fun <|pause:2000|>\n[S7] thirty nine, <|pause:1200|> I want a blue bag. <|pause:1800|> I want a blue bag. <|pause:1800|> I want a blue bag.<|pause:2000|>\n[S7] forty, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>" + ] +} \ No newline at end of file diff --git a/config/Unit3.json b/config/Unit3.json new file mode 100644 index 0000000..548cd0c --- /dev/null +++ b/config/Unit3.json @@ -0,0 +1,59 @@ +{ + "version": "1.0", + "export_time": "2025-12-14 16:57:54", + "language": "zh", + "num_speakers": 7, + "num_text_inputs": 4, + "seed": 1988, + "diff_spk_pause_ms": 0, + "speakers": [ + { + "prompt_audio": "/tmp/gradio/f2b287e0f747cdb9ea8ca421132c3518baba630ab72febb6f12cf25d43dfb66f/米小圈1.mp3", + "prompt_text": "这把钥匙一定很重要,失主到现在一定很着急。我按照字条上的地址来到一座山上,失主叔叔看到我把小盒子给他送回来高兴的都快哭了", + "dialect_prompt_text": "", + "remark": "米小圈" + }, + { + "prompt_audio": "/tmp/gradio/9465bec06a6985197897d551638386080106fec488a16ce293cb3645fe3aa8b3/佩奇1 - 如果要骑到南瓜那儿那我们一定要非常小心千万不可以撞上去.mp3", + "prompt_text": "如果要骑到南瓜那儿,那我们一定要非常小心,千万不可以撞上去!", + "dialect_prompt_text": "", + "remark": "佩奇" + }, + { + "prompt_audio": "/tmp/gradio/bf2dfc2b4940b4a9a565e980f6313e424a9f189cbda7905a26b1be0e1bd3858f/敖丙1.mp3", + "prompt_text": "五年多了,再次登台肯定还是有点儿,你不紧张吗?", + "dialect_prompt_text": "", + "remark": "敖丙" + }, + { + "prompt_audio": "/tmp/gradio/a3065ccdb99a5cdefc540d8d2799478c75b684820dc8e37a7baeb8a79f7fee61/Amy1.mp3", + "prompt_text": "We're from the UK.\nWe're twins.", + "dialect_prompt_text": "", + "remark": "Amy" + }, + { + "prompt_audio": "/tmp/gradio/02f81d423e5c2be7fafe5585692cd7407cdf7880fd8506556604c31e6806c17b/sam1.mp3", + "prompt_text": "Hi, everyone. I'm Sam.\nI'm nine.\nShe's Amy.\nShe's nine, too.", + "dialect_prompt_text": "", + "remark": "Sam" + }, + { + "prompt_audio": "/tmp/gradio/435d41f664ea91565eb2a4531f168b99cd0e2123d9783051ac110f6176a0fdbe/daming1.mp3", + "prompt_text": "Sam, this is my dad.\nThis is my grandpa.\nThat is my grandma.\nBut who is that?\nOh, this is my grandma.", + "dialect_prompt_text": "", + "remark": "Ming" + }, + { + "prompt_audio": "/tmp/gradio/34aebf28bbf889b39dc10b44b63640fdf43542dc08195dd3a9fd1b8ca42a1f05/Lingling1.mp3", + "prompt_text": "Hi, Sam.\nHi, Amy.\nI'm Ling Ling.\nHe's Da Ming.", + "dialect_prompt_text": "", + "remark": "Ling" + } + ], + "text_inputs": [ + "[S1] 一 <|pause:600|> 听力测试(共四节,满分60分)<|pause:2000|>\n[S2] 第一节:<|pause:600|>判断正误。(共10小题;每小题2分,满分20分)听录音,判断录音内容与下列图片是否一致?一致的请填“T”,不一致的请填“F”。每道题读三遍。<|pause:2000|>\n[S6] one, <|pause:1200|> N <|pause:1800|> N <|pause:1800|> N <|pause:2000|>\n[S4] two, <|pause:1200|> Let's see. <|pause:1800|> Let's see. <|pause:1800|> Let's see.<|pause:2000|>\n[S4] three, <|pause:1200|> Look at my umbrella. <|pause:1800|> Look at my umbrella. <|pause:1800|> Look at my umbrella.<|pause:2000|>\n[S4] four, <|pause:1200|> I want balloons. <|pause:1800|> I want balloons. <|pause:1800|> I want balloons.<|pause:2000|>\n[S4] five, <|pause:1200|> It's an orange orange. <|pause:1800|> It's an orange orange. <|pause:1800|> It's an orange orange.<|pause:2000|>\n[S4] six, <|pause:1200|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful! <|pause:1800|> The rainbow is colourful!<|pause:2000|>\n[S4] seven, <|pause:1200|> Today is National Day. <|pause:1800|> Today is National Day. <|pause:1800|> Today is National Day.<|pause:2000|>\n[S4] eight, <|pause:1200|> So many colours! <|pause:1800|> So many colours! <|pause:1800|> So many colours!<|pause:2000|>\n[S4] nine, <|pause:1200|> What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:1800|>[S4] What colours can you see? <|pause:1200|> [S5] I can see black and white.<|pause:2000|>\n[S4] ten, <|pause:1200|> Look! It's red now. <|pause:1800|> Look! It's red now. <|pause:1800|> Look! It's red now.<|pause:2000|>", + "[S3] 第二节:<|pause:600|>听音选图. (共10小题;每小题2分,满分20分)听录音,根据录音内容选择正确的图片。每道题读三遍。<|pause:2000|>\n[S6] eleven, <|pause:1200|> K <|pause:1800|> K <|pause:1800|> K <|pause:2000|>\n[S5] twelve, <|pause:1200|> What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:1800|>[S5] What colour is it? <|pause:1200|> [S6] It's purple.<|pause:2000|>\n[S5] thirteen, <|pause:1200|> Let's come and paint. <|pause:1800|> Let's come and paint. <|pause:1800|> Let's come and paint.<|pause:2000|>\n[S5] fourteen, <|pause:1200|> It's green and red. <|pause:1800|> It's green and red. <|pause:1800|> It's green and red.<|pause:2000|>\n[S5] fifteen, <|pause:1200|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day! <|pause:1800|> This is a colourful picture for the happy national day!<|pause:2000|>\n[S5] sixteen, <|pause:1200|> It's a picture. <|pause:1800|> It's a picture. <|pause:1800|> It's a picture.<|pause:2000|>\n[S5] seventeen, <|pause:1200|> Bobo is the first. <|pause:1800|> Bobo is the first. <|pause:1800|> Bobo is the first.<|pause:2000|>\n[S5] eighteen, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>\n[S5] nineteen, <|pause:1200|> What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:1800|>[S5] What colours can you see? <|pause:1200|> [S7] I can see pink, red and green.<|pause:2000|>\n[S6] twenty, <|pause:1200|> L <|pause:1800|> L <|pause:1800|> L <|pause:2000|>", + "[S2] 第三节:<|pause:600|>听音选词. (共10小题;每小题1分,满分10分)听录音,根据录音内容选择正确的单词。每道题读三遍。<|pause:2000|>\n[S6] twenty one, <|pause:1200|> KMH <|pause:1800|> KMH <|pause:1800|> KMH <|pause:2000|>\n[S6] twenty two, <|pause:1200|> want <|pause:1800|> want <|pause:1800|> want <|pause:2000|>\n[S6] twenty three, <|pause:1200|> blue <|pause:1800|> blue <|pause:1800|> blue <|pause:2000|>\n[S6] twenty four, <|pause:1200|> purple <|pause:1800|> purple <|pause:1800|> purple <|pause:2000|>\n[S6] twenty five, <|pause:1200|> can <|pause:1800|> can <|pause:1800|> can <|pause:2000|>\n[S6] twenty six, <|pause:1200|> red <|pause:1800|> red <|pause:1800|> red <|pause:2000|>\n[S6] twenty seven, <|pause:1200|> world <|pause:1800|> world <|pause:1800|> world <|pause:2000|>\n[S6] twenty eight, <|pause:1200|> magical <|pause:1800|> magical <|pause:1800|> magical <|pause:2000|>\n[S6] twenty nine, <|pause:1200|> today <|pause:1800|> today <|pause:1800|> today <|pause:2000|>\n[S6] thirty, <|pause:1200|> fun <|pause:1800|> fun <|pause:1800|> fun <|pause:2000|>", + "[S3] 第四节:<|pause:600|> 听音辩句。(共10小题;每小题1分,满分10分)听录音,根据录音内容选出相应的句子。每道题读三遍。<|pause:2000|>\n[S7] thirty one, <|pause:1200|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green. <|pause:1800|> Yellow and blue make green.<|pause:2000|>\n[S7] thirty two, <|pause:1200|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black. <|pause:1800|> Red, yellow and blue make black.<|pause:2000|>\n[S7] thirty three, <|pause:1200|> Look at our clothes. <|pause:1800|> Look at our clothes. <|pause:1800|> Look at our clothes.<|pause:2000|>\n[S7] thirty four, <|pause:1200|> I'm yellow. <|pause:1800|> I'm yellow. <|pause:1800|> I'm yellow.<|pause:2000|>\n[S7] thirty five, <|pause:1200|> Come back. <|pause:1800|> Come back. <|pause:1800|> Come back.<|pause:2000|>\n[S7] thirty six, <|pause:1200|> What colour is it? <|pause:1800|> What colour is it? <|pause:1800|> What colour is it?<|pause:2000|>\n[S7] thirty seven, <|pause:1200|> It's a colourful world. <|pause:1800|> It's a colourful world. <|pause:1800|> It's a colourful world.<|pause:2000|>\n[S7] thirty eight, <|pause:1200|> This is fun <|pause:1800|> This is fun <|pause:1800|> This is fun <|pause:2000|>\n[S7] thirty nine, <|pause:1200|> I want a blue bag. <|pause:1800|> I want a blue bag. <|pause:1800|> I want a blue bag.<|pause:2000|>\n[S7] forty, <|pause:1200|> You are right. <|pause:1800|> You are right. <|pause:1800|> You are right.<|pause:2000|>" + ] +} \ No newline at end of file diff --git a/config/test_jindu.json b/config/test_jindu.json new file mode 100644 index 0000000..2cfabe0 --- /dev/null +++ b/config/test_jindu.json @@ -0,0 +1,57 @@ +{ + "version": "1.0", + "export_time": "2025-12-14 17:15:44", + "language": "zh", + "num_speakers": 7, + "num_text_inputs": 2, + "seed": 1988, + "diff_spk_pause_ms": 0, + "speakers": [ + { + "prompt_audio": "/tmp/gradio/f2b287e0f747cdb9ea8ca421132c3518baba630ab72febb6f12cf25d43dfb66f/米小圈1.mp3", + "prompt_text": "这把钥匙一定很重要,失主到现在一定很着急。我按照字条上的地址来到一座山上,失主叔叔看到我把小盒子给他送回来高兴的都快哭了", + "dialect_prompt_text": "", + "remark": "米小圈" + }, + { + "prompt_audio": "/tmp/gradio/9465bec06a6985197897d551638386080106fec488a16ce293cb3645fe3aa8b3/佩奇1 - 如果要骑到南瓜那儿那我们一定要非常小心千万不可以撞上去.mp3", + "prompt_text": "如果要骑到南瓜那儿,那我们一定要非常小心,千万不可以撞上去!", + "dialect_prompt_text": "", + "remark": "佩奇" + }, + { + "prompt_audio": "/tmp/gradio/bf2dfc2b4940b4a9a565e980f6313e424a9f189cbda7905a26b1be0e1bd3858f/敖丙1.mp3", + "prompt_text": "五年多了,再次登台肯定还是有点儿,你不紧张吗?", + "dialect_prompt_text": "", + "remark": "敖丙" + }, + { + "prompt_audio": "/tmp/gradio/a3065ccdb99a5cdefc540d8d2799478c75b684820dc8e37a7baeb8a79f7fee61/Amy1.mp3", + "prompt_text": "We're from the UK.\nWe're twins.", + "dialect_prompt_text": "", + "remark": "Amy" + }, + { + "prompt_audio": "/tmp/gradio/02f81d423e5c2be7fafe5585692cd7407cdf7880fd8506556604c31e6806c17b/sam1.mp3", + "prompt_text": "Hi, everyone. I'm Sam.\nI'm nine.\nShe's Amy.\nShe's nine, too.", + "dialect_prompt_text": "", + "remark": "Sam" + }, + { + "prompt_audio": "/tmp/gradio/435d41f664ea91565eb2a4531f168b99cd0e2123d9783051ac110f6176a0fdbe/daming1.mp3", + "prompt_text": "Sam, this is my dad.\nThis is my grandpa.\nThat is my grandma.\nBut who is that?\nOh, this is my grandma.", + "dialect_prompt_text": "", + "remark": "Ming" + }, + { + "prompt_audio": "/tmp/gradio/34aebf28bbf889b39dc10b44b63640fdf43542dc08195dd3a9fd1b8ca42a1f05/Lingling1.mp3", + "prompt_text": "Hi, Sam.\nHi, Amy.\nI'm Ling Ling.\nHe's Da Ming.", + "dialect_prompt_text": "", + "remark": "Ling" + } + ], + "text_inputs": [ + "[S1] 一 <|pause:60|> 听力测试(共四节,满分60分)<|pause:200|>", + "[S2] 一 <|pause:60|> 听力测试(共四节,满分60分)<|pause:200|>" + ] +} \ No newline at end of file diff --git a/fix_vllm_install.sh b/fix_vllm_install.sh new file mode 100755 index 0000000..48cdfed --- /dev/null +++ b/fix_vllm_install.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# ============================= +# 修复 VLLM 安装(从当前状态继续) +# ============================= + +set -e + +# 初始化 conda 环境 +source /root/anaconda3/etc/profile.d/conda.sh + +# 激活环境 +conda activate soulxpodcast + +# 进入项目目录(确保不在临时目录中) +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast + +# 临时目录路径(根据日志) +TEMP_DIR="/tmp/tmp.aXt4nU03Ph" +SOURCE_DIR="$TEMP_DIR/vllm" + +# 获取系统安装的 vllm 路径(在项目目录中执行,避免导入临时目录的 vllm) +echo "查找系统安装的 vllm 路径..." +# 使用 python -c 但确保 PYTHONPATH 不包含临时目录 +VLLM_PATH=$(PYTHONPATH="" python3 -c "import sys; sys.path = [p for p in sys.path if 'tmp' not in p]; import vllm; import os; print(os.path.dirname(vllm.__file__))" 2>/dev/null) + +# 如果上面的方法不行,尝试直接查找 conda 环境的 site-packages +if [ -z "$VLLM_PATH" ] || [[ "$VLLM_PATH" == *"tmp"* ]]; then + echo "尝试从 conda 环境查找 vllm..." + CONDA_ENV_PATH=$(conda info --envs | grep soulxpodcast | awk '{print $NF}') + if [ -n "$CONDA_ENV_PATH" ]; then + VLLM_PATH="$CONDA_ENV_PATH/lib/python*/site-packages/vllm" + VLLM_PATH=$(python3 -c "import glob; paths = glob.glob('$VLLM_PATH'); print(paths[0] if paths else '')" 2>/dev/null) + fi +fi + +# 如果还是找不到,使用 pip show 来查找 +if [ -z "$VLLM_PATH" ] || [[ "$VLLM_PATH" == *"tmp"* ]]; then + echo "使用 pip show 查找 vllm 安装位置..." + VLLM_LOCATION=$(pip show vllm | grep Location | awk '{print $2}') + if [ -n "$VLLM_LOCATION" ]; then + VLLM_PATH="$VLLM_LOCATION/vllm" + fi +fi + +if [ -z "$VLLM_PATH" ] || [[ "$VLLM_PATH" == *"tmp"* ]] || [ ! -d "$VLLM_PATH" ]; then + echo "错误: 无法找到系统安装的 vllm 路径" + echo "请手动运行: python3 -c 'import vllm; import os; print(os.path.dirname(vllm.__file__))'" + exit 1 +fi + +echo "找到系统 VLLM 路径: $VLLM_PATH" + +# 检查源文件是否存在 +if [ ! -f "${SOURCE_DIR}/vllm/model_executor/layers/sampler.py" ]; then + echo "错误: 源文件不存在: ${SOURCE_DIR}/vllm/model_executor/layers/sampler.py" + exit 1 +fi + +# 替换修改版的文件 +echo "替换修改版文件..." +cp "${SOURCE_DIR}/vllm/model_executor/layers/sampler.py" "${VLLM_PATH}/model_executor/layers/sampler.py" +cp "${SOURCE_DIR}/vllm/model_executor/layers/utils.py" "${VLLM_PATH}/model_executor/layers/utils.py" +cp "${SOURCE_DIR}/vllm/model_executor/sampling_metadata.py" "${VLLM_PATH}/model_executor/sampling_metadata.py" +cp "${SOURCE_DIR}/vllm/sampling_params.py" "${VLLM_PATH}/sampling_params.py" + +echo "✓ 文件替换完成" + +# 清理临时目录 +echo "清理临时文件..." +rm -rf "$TEMP_DIR" + +# 验证安装 +echo "验证安装..." +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast +python3 -c "from vllm import LLM; print('VLLM 安装成功!')" && echo "✓ VLLM 安装完成并验证通过" || echo "✗ VLLM 验证失败" + +echo "" +echo "安装完成! 现在可以使用 --llm_engine vllm 参数了。" diff --git a/install_vllm.sh b/install_vllm.sh new file mode 100755 index 0000000..92f3a53 --- /dev/null +++ b/install_vllm.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# ============================= +# 安装 SoulX-Podcast 修改版 VLLM +# ============================= + +set -e + +echo "开始安装 VLLM (修改版 v0.10.1.1-soulxpodcast)..." + +# 初始化 conda 环境 +source /root/anaconda3/etc/profile.d/conda.sh + +# 激活环境 +conda activate soulxpodcast + +# 进入项目目录 +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast + +# 1. 首先安装基础版本的 vllm 0.10.1 +echo "步骤 1: 安装基础 vllm 0.10.1..." +pip install vllm==0.10.1 + +# 2. 获取已安装的 vllm 路径(在克隆仓库之前,避免路径冲突) +echo "步骤 2: 查找已安装的 vllm 路径..." +VLLM_PATH=$(python3 -c "import vllm; import os; print(os.path.dirname(vllm.__file__))" 2>/dev/null) + +if [ -z "$VLLM_PATH" ]; then + echo "错误: 无法找到已安装的 vllm 路径" + exit 1 +fi + +echo "找到系统 VLLM 路径: $VLLM_PATH" + +# 3. 克隆修改版的 vllm 仓库到临时目录 +echo "步骤 3: 克隆修改版 vllm 仓库..." +TEMP_DIR=$(mktemp -d) +cd "$TEMP_DIR" +git clone https://github.com/Soul-AILab/vllm.git +cd vllm +git checkout v0.10.1.1-soulxpodcast + +# 4. 替换修改版的文件(从克隆的仓库复制到系统安装的路径) +echo "步骤 4: 替换修改版文件..." +SOURCE_DIR="$TEMP_DIR/vllm" +cp "${SOURCE_DIR}/vllm/model_executor/layers/sampler.py" "${VLLM_PATH}/model_executor/layers/sampler.py" +cp "${SOURCE_DIR}/vllm/model_executor/layers/utils.py" "${VLLM_PATH}/model_executor/layers/utils.py" +cp "${SOURCE_DIR}/vllm/model_executor/sampling_metadata.py" "${VLLM_PATH}/model_executor/sampling_metadata.py" +cp "${SOURCE_DIR}/vllm/sampling_params.py" "${VLLM_PATH}/sampling_params.py" + +# 5. 清理临时目录 +echo "步骤 5: 清理临时文件..." +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast +rm -rf "$TEMP_DIR" + +# 6. 验证安装 +echo "步骤 6: 验证安装..." +python3 -c "from vllm import LLM; print('VLLM 安装成功!')" && echo "✓ VLLM 安装完成并验证通过" || echo "✗ VLLM 验证失败" + +echo "" +echo "安装完成! 现在可以使用 --llm_engine vllm 参数了。" diff --git a/run_webui_dialect.sh b/run_webui_dialect.sh new file mode 100755 index 0000000..4b4ad75 --- /dev/null +++ b/run_webui_dialect.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# ============================= +# SoulX-Podcast 启动脚本 +# ============================= + +# 初始化 conda 环境 +source /root/anaconda3/etc/profile.d/conda.sh + +# 激活环境 +conda activate soulxpodcast + +# 进入项目目录 +cd /ygq/rag/workspace/my-Soul-Podcast/SoulX-Podcast + +# 启动 web 界面 +# 如果遇到 CUDA OOM 错误,可以降低 --gpu_memory_utilization 参数(例如 0.3 或 0.4) +python3 webui.py --gpu_memory_utilization 0.3 --llm_engine vllm --model_path /ygq/rag/workspace/SoulX-Podcast-main/pretrained_models/SoulX-Podcast-1.7B-dialect +# python3 webui.py --model_path /ygq/rag/workspace/SoulX-Podcast-main/pretrained_models/SoulX-Podcast-1.7B-dialect diff --git a/soulxpodcast/engine/llm_engine.py b/soulxpodcast/engine/llm_engine.py index 6694974..e25feb1 100644 --- a/soulxpodcast/engine/llm_engine.py +++ b/soulxpodcast/engine/llm_engine.py @@ -88,7 +88,15 @@ def __init__(self, model, **kwargs): self.device = "cuda:0" if torch.cuda.is_available() else "cpu" os.environ["VLLM_USE_V1"] = "0" if SUPPORT_VLLM: - self.model = LLM(model=model, enforce_eager=True, dtype="bfloat16", max_model_len=8192, enable_prefix_caching=True,) + self.model = LLM( + model=model, + enforce_eager=config.enforce_eager, + dtype="bfloat16", + max_model_len=config.max_model_len, + enable_prefix_caching=True, + gpu_memory_utilization=config.gpu_memory_utilization, + tensor_parallel_size=config.tensor_parallel_size, + ) else: raise ImportError("Not Support VLLM now!!!") self.config = config diff --git a/soulxpodcast/utils/dataloader.py b/soulxpodcast/utils/dataloader.py index 4274596..f4ac1e2 100644 --- a/soulxpodcast/utils/dataloader.py +++ b/soulxpodcast/utils/dataloader.py @@ -16,7 +16,12 @@ from soulxpodcast.config import Config, SamplingParams -SPK_DICT = ["<|SPEAKER_0|>", "<|SPEAKER_1|>", "<|SPEAKER_2|>", "<|SPEAKER_3|>",] +# 支持最多10个说话人(与 MAX_SPEAKERS 保持一致) +SPK_DICT = [ + "<|SPEAKER_0|>", "<|SPEAKER_1|>", "<|SPEAKER_2|>", "<|SPEAKER_3|>", + "<|SPEAKER_4|>", "<|SPEAKER_5|>", "<|SPEAKER_6|>", "<|SPEAKER_7|>", + "<|SPEAKER_8|>", "<|SPEAKER_9|>", +] TEXT_START, TEXT_END, AUDIO_START = "<|text_start|>", "<|text_end|>", "<|semantic_token_start|>" TASK_PODCAST = "<|task_podcast|>" @@ -117,12 +122,22 @@ def __getitem__(self, idx): # 4. feature for llm prompt_text = normalize_text(prompt_text) # remove some space and strange character + # 检查 spk_idx 是否在 SPK_DICT 范围内 + if spk_idx >= len(SPK_DICT): + raise ValueError(f"说话人索引 {spk_idx} 超出范围,SPK_DICT 只支持 {len(SPK_DICT)} 个说话人(索引 0-{len(SPK_DICT)-1})") prompt_text = f"{SPK_DICT[spk_idx]}{TEXT_START}{prompt_text}{TEXT_END}{AUDIO_START}" if spk_idx == 0: prompt_text = f"{TASK_PODCAST}{prompt_text}" prompt_text_ids = self.text_tokenizer.encode(prompt_text) prompt_text_ids_list.append(prompt_text_ids) if use_dialect_prompt: + # 检查 dialect_prompt_text 列表长度是否足够 + if "dialect_prompt_text" not in data or not isinstance(data["dialect_prompt_text"], list): + raise ValueError(f"dialect_prompt_text 不存在或不是列表,但 use_dialect_prompt=True") + if spk_idx >= len(data["dialect_prompt_text"]): + raise ValueError(f"dialect_prompt_text 列表长度不足: 需要索引 {spk_idx},但列表长度只有 {len(data.get('dialect_prompt_text', []))}") + if spk_idx >= len(SPK_DICT): + raise ValueError(f"说话人索引 {spk_idx} 超出范围,SPK_DICT 只支持 {len(SPK_DICT)} 个说话人(索引 0-{len(SPK_DICT)-1})") dialect_prompt_text = normalize_text(data["dialect_prompt_text"][spk_idx]) dialect_prompt_text = f"{SPK_DICT[spk_idx]}{TEXT_START}{dialect_prompt_text}{TEXT_END}{AUDIO_START}" dialect_prompt_text_ids = self.text_tokenizer.encode(dialect_prompt_text) @@ -151,6 +166,9 @@ def __getitem__(self, idx): for text, spk in zip(data["text"], data["spk"]): # 4. feature for llm text = normalize_text(text) + # 检查 spk 是否在 SPK_DICT 范围内 + if spk >= len(SPK_DICT): + raise ValueError(f"说话人索引 {spk} 超出范围,SPK_DICT 只支持 {len(SPK_DICT)} 个说话人(索引 0-{len(SPK_DICT)-1})") text = f"{SPK_DICT[spk]}{TEXT_START}{text}{TEXT_END}{AUDIO_START}" text_ids = self.text_tokenizer.encode(text) diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..0955a70 --- /dev/null +++ b/todo.md @@ -0,0 +1,42 @@ + +# done +- 合成合成文本对应任务的预览和下载现在只有最后一个,增加每个合成文本任务对应的预览和下载,放到到对应文本的位置方便操作 +- 增加所有任务的合并总的合并,文件放到当前任务根目录,如 ./20251213_042314/all.wav +- 子放到separated 子文件夹 +- 增加当前配置导出和导入功能,实现快速复用配置。 +- 每个人说话的需要合起来,现在停顿的都被分开了 +- ui 优化 + - 说话人设置,收起来后,还是会展示所有说话人标签在 收起来的标题行 + - "合成文本输入框支"最大行数设置成现在的默认行数, 默认行数减少一半。更多的内容,使用滑动看更多; + - 导出配置,支持指定名称 + - 当前结果模块上面,增加合成按钮 + - 全局设置,位置放到配置管理下面,变成可收起的模块,默认收起 + - 全局设置中的停顿(ms) 功能,放到对话内容模块中,和输入框数量在同一排 + - 说话人设置优化: + - 添加说话人和批量删除等功能,放到该模块(说话人设置)的第一排。 + - 用于快速删除说话人功能的勾选说话人的功能现在需要切换到不同标签内,不方便操作,优化下 + - "合成文本输入框支"持显示行号 + -[x] 说人名对应起来,现在是s1,不好区分 -> 增加了标签说明 + - 增加每个任务的处理时间展示 + - 整体合并的时候,每个任务间增加停顿时间 +# bugs +-[x] 5个人,会报错 +-[x] 第7个人显示,没跟到备注名变化 +-[x] 只有第一个合成文本输入框的任务 +-[] 加载配置 时候,标签没有根据备注重新变化 + +# 优化 +## 没想好 + +- 增加试题专版,说话者分区,题干和题目分区,对话分别从里面选 +- 不应该是"不同说话者间停顿(ms)",而是每个[S1]之间(新句子),都需要增加停顿 + - 一键给句末增加/删除 制定毫秒数停顿的功能 +## 可行 +- ui 优化 + - "合成文本输入框支"同人的文本,文字着色一致 + +- 增加网页音频浏览,方便点题评讲 +- 生成任务后,增加对应配置导出到目录本次合成任务目录-> config.json +- Amy 角色素材搞个丰富点的 + + diff --git a/webui.py b/webui.py index 4eed466..c64fcd6 100644 --- a/webui.py +++ b/webui.py @@ -1,602 +1,63 @@ -import re -import gradio as gr -from tqdm import tqdm -from argparse import ArgumentParser -from typing import Literal, List, Tuple -import sys +# -*- coding: utf-8 -*- +""" +SoulX-Podcast WebUI Entry Point + +This file serves as the backward-compatible entry point for launching the WebUI. +The actual implementation has been modularized into the webui/ package. +""" + import importlib.util from datetime import datetime +from argparse import ArgumentParser import torch -import numpy as np -import random -import s3tokenizer - -from soulxpodcast.models.soulxpodcast import SoulXPodcast -from soulxpodcast.config import Config, SoulXPodcastLLMConfig, SamplingParams -from soulxpodcast.utils.dataloader import ( - PodcastInferHandler, - SPK_DICT, TEXT_START, TEXT_END, AUDIO_START, TASK_PODCAST -) - - -S1_PROMPT_WAV = "example/audios/female_mandarin.wav" -S2_PROMPT_WAV = "example/audios/male_mandarin.wav" - -def load_dialect_prompt_data(): - """ - 加载方言提示文本文件并格式化为嵌套字典。 - 返回结构: {dialect_key: {display_name: full_text, ...}, ...} - """ - dialect_data = {} - - dialect_files = [ - ("sichuan", "example/dialect_prompt/sichuan.txt", "<|Sichuan|>"), - ("yueyu", "example/dialect_prompt/yueyu.txt", "<|Yue|>"), - ("henan", "example/dialect_prompt/henan.txt", "<|Henan|>"), - ] - - for key, file_path, prefix in dialect_files: - dialect_data[key] = {"(无)": ""} - try: - with open(file_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - for i, line in enumerate(lines): - line = line.strip() - if line: - full_text = f"{prefix}{line}" - display_name = f"例{i+1}: {line[:20]}..." - dialect_data[key][display_name] = full_text - except FileNotFoundError: - print(f"[WARNING] 方言文件未找到: {file_path}") - except Exception as e: - print(f"[WARNING] 读取方言文件失败 {file_path}: {e}") - - return dialect_data - -DIALECT_PROMPT_DATA = load_dialect_prompt_data() -DIALECT_CHOICES = ["(无)", "sichuan", "yueyu", "henan"] - - -EXAMPLES_LIST = [ - [ - None, "", "", None, "", "", "" - ], - [ - S1_PROMPT_WAV, - "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", - "", - S2_PROMPT_WAV, - "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", - "", - "[S1] 哈喽,AI时代的冲浪先锋们!欢迎收听《AI生活进行时》。啊,一个充满了未来感,然后,还有一点点,<|laughter|>神经质的播客节目,我是主持人小希。\n[S2] 哎,大家好呀!我是能唠,爱唠,天天都想唠的唠嗑!\n[S1] 最近活得特别赛博朋克哈!以前老是觉得AI是科幻片儿里的,<|sigh|> 现在,现在连我妈都用AI写广场舞文案了。\n[S2] 这个例子很生动啊。是的,特别是生成式AI哈,感觉都要炸了! 诶,那我们今天就聊聊AI是怎么走进我们的生活的哈!", - ], - [ - S1_PROMPT_WAV, - "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", - "<|Sichuan|>要得要得!前头几个耍洋盘,我后脚就背起铺盖卷去景德镇耍泥巴,巴适得喊老天爷!", - S2_PROMPT_WAV, - "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", - "<|Sichuan|>哎哟喂,这个搞反了噻!黑神话里头唱曲子的王二浪早八百年就在黄土高坡吼秦腔喽,游戏组专门跑切录的原汤原水,听得人汗毛儿都立起来!", - "[S1] <|Sichuan|>各位《巴适得板》的听众些,大家好噻!我是你们主持人晶晶。今儿天气硬是巴适,不晓得大家是在赶路嘛,还是茶都泡起咯,准备跟我们好生摆一哈龙门阵喃?\n[S2] <|Sichuan|>晶晶好哦,大家安逸噻!我是李老倌。你刚开口就川味十足,摆龙门阵几个字一甩出来,我鼻子头都闻到茶香跟火锅香咯!\n[S1] <|Sichuan|>就是得嘛!李老倌,我前些天带个外地朋友切人民公园鹤鸣茶社坐了一哈。他硬是搞不醒豁,为啥子我们一堆人围到杯茶就可以吹一下午壳子,从隔壁子王嬢嬢娃儿耍朋友,扯到美国大选,中间还掺几盘斗地主。他说我们四川人简直是把摸鱼刻进骨子里头咯!\n[S2] <|Sichuan|>你那个朋友说得倒是有点儿趣,但他莫看到精髓噻。摆龙门阵哪是摸鱼嘛,这是我们川渝人特有的交际方式,更是一种活法。外省人天天说的松弛感,根根儿就在这龙门阵里头。今天我们就要好生摆一哈,为啥子四川人活得这么舒坦。就先从茶馆这个老窝子说起,看它咋个成了我们四川人的魂儿!", - ], - [ - S1_PROMPT_WAV, - "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", - "<|Yue|>真係冇讲错啊!攀山滑雪嘅语言专家几巴闭,都唔及我听日拖成副身家去景德镇玩泥巴,呢铺真系发哂白日梦咯!", - S2_PROMPT_WAV, - "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", - "<|Yue|>咪搞错啊!陕北民谣响度唱咗几十年,黑神话边有咁大面啊?你估佢哋抄游戏咩!", - "[S1] <|Yue|>哈囉大家好啊,歡迎收聽我哋嘅節目。喂,我今日想問你樣嘢啊,你覺唔覺得,嗯,而家揸電動車,最煩,最煩嘅一樣嘢係咩啊?\n[S2] <|Yue|>梗係充電啦。大佬啊,搵個位都已經好煩,搵到個位仲要喺度等,你話快極都要半個鐘一個鐘,真係,有時諗起都覺得好冇癮。\n[S1] <|Yue|>係咪先。如果我而家同你講,充電可以快到同入油差唔多時間,你信唔信先?喂你平時喺油站入滿一缸油,要幾耐啊?五六分鐘?\n[S2] <|Yue|>差唔多啦,七八分鐘,點都走得啦。電車喎,可以做到咁快?你咪玩啦。", - ], - [ - S1_PROMPT_WAV, - "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", - "<|Henan|>俺这不是怕恁路上不得劲儿嘛!那景德镇瓷泥可娇贵着哩,得先拿咱河南人这实诚劲儿给它揉透喽。", - S2_PROMPT_WAV, - "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", - "<|Henan|>恁这想法真闹挺!陕北民谣比黑神话早几百年都有了,咱可不兴这弄颠倒啊,中不?恁这想法真闹挺!那陕北民谣在黄土高坡响了几百年,咋能说是跟黑神话学的咧?咱得把这事儿捋直喽,中不中!", - "[S1] <|Henan|>哎,大家好啊,欢迎收听咱这一期嘞《瞎聊呗,就这么说》,我是恁嘞老朋友,燕子。\n[S2] <|Henan|>大家好,我是老张。燕子啊,今儿瞅瞅你这个劲儿,咋着,是有啥可得劲嘞事儿想跟咱唠唠?\n[S1] <|Henan|>哎哟,老张,你咋恁懂我嘞!我跟你说啊,最近我刷手机,老是刷住些可逗嘞方言视频,特别是咱河南话,咦~我哩个乖乖,一听我都憋不住笑,咋说嘞,得劲儿哩很,跟回到家一样。\n[S2] <|Henan|>你这回可算说到根儿上了!河南话,咱往大处说说,中原官话,它真嘞是有一股劲儿搁里头。它可不光是说话,它脊梁骨后头藏嘞,是咱一整套、鲜鲜活活嘞过法儿,一种活人嘞道理。\n[S1] <|Henan|>活人嘞道理?哎,这你这一说,我嘞兴致“腾”一下就上来啦!觉住咱这嗑儿,一下儿从搞笑视频蹿到文化顶上了。那你赶紧给我白话白话,这里头到底有啥道道儿?我特别想知道——为啥一提起咱河南人,好些人脑子里“蹦”出来嘞头一个词儿,就是实在?这个实在,骨子里到底是啥嘞?", - ], -] - - -model: SoulXPodcast = None -dataset: PodcastInferHandler = None -def initiate_model(config: Config, enable_tn: bool=False): - global model - if model is None: - model = SoulXPodcast(config) - - global dataset - if dataset is None: - dataset = PodcastInferHandler(model.llm.tokenizer, None, config) - -_i18n_key2lang_dict = dict( - # Speaker1 Prompt - spk1_prompt_audio_label=dict( - en="Speaker 1 Prompt Audio", - zh="说话人 1 参考语音", - ), - spk1_prompt_text_label=dict( - en="Speaker 1 Prompt Text", - zh="说话人 1 参考文本", - ), - spk1_prompt_text_placeholder=dict( - en="text of speaker 1 Prompt audio.", - zh="说话人 1 参考文本", - ), - spk1_dialect_prompt_text_label=dict( - en="Speaker 1 Dialect Prompt Text", - zh="说话人 1 方言提示文本", - ), - spk1_dialect_prompt_text_placeholder=dict( - en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", - zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", - ), - # Speaker2 Prompt - spk2_prompt_audio_label=dict( - en="Speaker 2 Prompt Audio", - zh="说话人 2 参考语音", - ), - spk2_prompt_text_label=dict( - en="Speaker 2 Prompt Text", - zh="说话人 2 参考文本", - ), - spk2_prompt_text_placeholder=dict( - en="text of speaker 2 prompt audio.", - zh="说话人 2 参考文本", - ), - spk2_dialect_prompt_text_label=dict( - en="Speaker 2 Dialect Prompt Text", - zh="说话人 2 方言提示文本", - ), - spk2_dialect_prompt_text_placeholder=dict( - en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", - zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", - ), - # Dialogue input textbox - dialogue_text_input_label=dict( - en="Dialogue Text Input", - zh="合成文本输入", - ), - dialogue_text_input_placeholder=dict( - en="[S1]text[S2]text[S1]text...", - zh="[S1]文本[S2]文本[S1]文本...", - ), - # Generate button - generate_btn_label=dict( - en="Generate Audio", - zh="合成", - ), - # Generated audio - generated_audio_label=dict( - en="Generated Dialogue Audio", - zh="合成的对话音频", - ), - # Warining1: invalid text for prompt - warn_invalid_spk1_prompt_text=dict( - en='Invalid speaker 1 prompt text, should not be empty and strictly follow: "xxx"', - zh='说话人 1 参考文本不合规,不能为空,格式:"xxx"', - ), - warn_invalid_spk2_prompt_text=dict( - en='Invalid speaker 2 prompt text, should strictly follow: "[S2]xxx"', - zh='说话人 2 参考文本不合规,格式:"[S2]xxx"', - ), - warn_invalid_dialogue_text=dict( - en='Invalid dialogue input text, should strictly follow: "[S1]xxx[S2]xxx..."', - zh='对话文本输入不合规,格式:"[S1]xxx[S2]xxx..."', - ), - # Warining3: incomplete prompt info - warn_incomplete_prompt=dict( - en="Please provide prompt audio and text for both speaker 1 and speaker 2", - zh="请提供说话人 1 与说话人 2 的参考语音与参考文本", - ), -) - - -global_lang: Literal["zh", "en"] = "zh" - -def i18n(key): - global global_lang - return _i18n_key2lang_dict[key][global_lang] - -def check_monologue_text(text: str, prefix: str = None) -> bool: - text = text.strip() - # Check speaker tags - if prefix is not None and (not text.startswith(prefix)): - return False - # Remove prefix - if prefix is not None: - text = text.removeprefix(prefix) - text = text.strip() - # If empty? - if len(text) == 0: - return False - return True - -def check_dialect_prompt_text(text: str, prefix: str = None) -> bool: - text = text.strip() - # Check Dialect Prompt prefix tags - if prefix is not None and (not text.startswith(prefix)): - return False - text = text.strip() - # If empty? - if len(text) == 0: - return False - return True - -def check_dialogue_text(text_list: List[str]) -> bool: - if len(text_list) == 0: - return False - for text in text_list: - if not ( - check_monologue_text(text, "[S1]") - or check_monologue_text(text, "[S2]") - or check_monologue_text(text, "[S3]") - or check_monologue_text(text, "[S4]") - ): - return False - return True - -def process_single(target_text_list, prompt_wav_list, prompt_text_list, use_dialect_prompt, dialect_prompt_text): - spks, texts = [], [] - for target_text in target_text_list: - pattern = r'(\[S[1-9]\])(.+)' - match = re.match(pattern, target_text) - text, spk = match.group(2), int(match.group(1)[2])-1 - spks.append(spk) - texts.append(text) - - global dataset - dataitem = {"key": "001", "prompt_text": prompt_text_list, "prompt_wav": prompt_wav_list, - "text": texts, "spk": spks, } - if use_dialect_prompt: - dataitem.update({ - "dialect_prompt_text": dialect_prompt_text - }) - dataset.update_datasource( - [ - dataitem - ] - ) - - # assert one data only; - data = dataset[0] - prompt_mels_for_llm, prompt_mels_lens_for_llm = s3tokenizer.padding(data["log_mel"]) # [B, num_mels=128, T] - spk_emb_for_flow = torch.tensor(data["spk_emb"]) - prompt_mels_for_flow = torch.nn.utils.rnn.pad_sequence(data["mel"], batch_first=True, padding_value=0) # [B, T', num_mels=80] - prompt_mels_lens_for_flow = torch.tensor(data['mel_len']) - text_tokens_for_llm = data["text_tokens"] - prompt_text_tokens_for_llm = data["prompt_text_tokens"] - spk_ids = data["spks_list"] - sampling_params = SamplingParams(use_ras=True,win_size=25,tau_r=0.2) - infos = [data["info"]] - processed_data = { - "prompt_mels_for_llm": prompt_mels_for_llm, - "prompt_mels_lens_for_llm": prompt_mels_lens_for_llm, - "prompt_text_tokens_for_llm": prompt_text_tokens_for_llm, - "text_tokens_for_llm": text_tokens_for_llm, - "prompt_mels_for_flow_ori": prompt_mels_for_flow, - "prompt_mels_lens_for_flow": prompt_mels_lens_for_flow, - "spk_emb_for_flow": spk_emb_for_flow, - "sampling_params": sampling_params, - "spk_ids": spk_ids, - "infos": infos, - "use_dialect_prompt": use_dialect_prompt, - } - if use_dialect_prompt: - processed_data.update({ - "dialect_prompt_text_tokens_for_llm": data["dialect_prompt_text_tokens"], - "dialect_prefix": data["dialect_prefix"], - }) - return processed_data - - -def dialogue_synthesis_function( - target_text: str, - spk1_prompt_text: str | None = "", - spk1_prompt_audio: str | None = None, - spk1_dialect_prompt_text: str | None = "", - spk2_prompt_text: str | None = "", - spk2_prompt_audio: str | None = None, - spk2_dialect_prompt_text: str | None = "", - seed: int = 1988, -): - - seed = int(seed) - torch.manual_seed(seed) - np.random.seed(seed) - random.seed(seed) - - # Check prompt info - target_text_list: List[str] = re.findall(r"(\[S[0-9]\][^\[\]]*)", target_text) - target_text_list = [text.strip() for text in target_text_list] - if not check_dialogue_text(target_text_list): - gr.Warning(message=i18n("warn_invalid_dialogue_text")) - return None - - # Go synthesis - progress_bar = gr.Progress(track_tqdm=True) - prompt_wav_list = [spk1_prompt_audio, spk2_prompt_audio] - prompt_text_list = [spk1_prompt_text, spk2_prompt_text] - use_dialect_prompt = spk1_dialect_prompt_text.strip()!="" or spk2_dialect_prompt_text.strip()!="" - dialect_prompt_text_list = [spk1_dialect_prompt_text, spk2_dialect_prompt_text] - data = process_single( - target_text_list, - prompt_wav_list, - prompt_text_list, - use_dialect_prompt, - dialect_prompt_text_list, - ) - results_dict = model.forward_longform( - **data - ) - target_audio = None - for i in range(len(results_dict['generated_wavs'])): - if target_audio is None: - target_audio = results_dict['generated_wavs'][i] - else: - target_audio = torch.concat([target_audio, results_dict['generated_wavs'][i]], axis=1) - return (24000, target_audio.cpu().squeeze(0).numpy()) - - -def update_example_choices(dialect_key: str): - - if dialect_key == "(无)": - choices = ["(请先选择方言)"] - - return gr.update(choices=choices, value="(无)"), gr.update(choices=choices, value="(无)") - - choices = list(DIALECT_PROMPT_DATA.get(dialect_key, {}).keys()) - - return gr.update(choices=choices, value="(无)"), gr.update(choices=choices, value="(无)") - -def update_prompt_text(dialect_key: str, example_key: str): - if dialect_key == "(无)" or example_key in ["(无)", "(请先选择方言)"]: - return gr.update(value="") - - - full_text = DIALECT_PROMPT_DATA.get(dialect_key, {}).get(example_key, "") - return gr.update(value=full_text) - - -def render_interface() -> gr.Blocks: - with gr.Blocks(title="SoulX-Podcast", theme=gr.themes.Default()) as page: - - with gr.Row(): - lang_choice = gr.Radio( - choices=["中文", "English"], - value="中文", - label="Display Language/显示语言", - type="index", - interactive=True, - scale=3, - ) - seed_input = gr.Number( - label="Seed (种子)", - value=1988, - step=1, - interactive=True, - scale=1, - ) - - with gr.Row(): - - with gr.Column(scale=1): - with gr.Group(visible=True) as spk1_prompt_group: - spk1_prompt_audio = gr.Audio( - label=i18n("spk1_prompt_audio_label"), - type="filepath", - editable=False, - interactive=True, - ) - spk1_prompt_text = gr.Textbox( - label=i18n("spk1_prompt_text_label"), - placeholder=i18n("spk1_prompt_text_placeholder"), - lines=3, - ) - spk1_dialect_prompt_text = gr.Textbox( - label=i18n("spk1_dialect_prompt_text_label"), - placeholder=i18n("spk1_dialect_prompt_text_placeholder"), - value="", - lines=3, - ) - - with gr.Column(scale=1, visible=True): - with gr.Group(visible=True) as spk2_prompt_group: - spk2_prompt_audio = gr.Audio( - label=i18n("spk2_prompt_audio_label"), - type="filepath", - editable=False, - interactive=True, - ) - spk2_prompt_text = gr.Textbox( - label=i18n("spk2_prompt_text_label"), - placeholder=i18n("spk2_prompt_text_placeholder"), - lines=3, - ) - spk2_dialect_prompt_text = gr.Textbox( - label=i18n("spk2_dialect_prompt_text_label"), - placeholder=i18n("spk2_dialect_prompt_text_placeholder"), - value="", - lines=3, - ) - - with gr.Column(scale=2): - with gr.Row(): - dialogue_text_input = gr.Textbox( - label=i18n("dialogue_text_input_label"), - placeholder=i18n("dialogue_text_input_placeholder"), - lines=18, - ) - - # Generate button - with gr.Row(): - generate_btn = gr.Button( - value=i18n("generate_btn_label"), - variant="primary", - scale=3, - size="lg", - ) - - # Long output audio - generate_audio = gr.Audio( - label=i18n("generated_audio_label"), - interactive=False, - ) - - - with gr.Row(): - inputs_for_examples = [ - spk1_prompt_audio, - spk1_prompt_text, - spk1_dialect_prompt_text, - spk2_prompt_audio, - spk2_prompt_text, - spk2_dialect_prompt_text, - dialogue_text_input, - ] - - gr.Examples( - examples=EXAMPLES_LIST, - inputs=inputs_for_examples, - label="播客模板示例 (点击加载)", - examples_per_page=5, - ) - - with gr.Accordion("方言提示文本 (Dialect Prompt) 选择器", open=False): - gr.Markdown("选择方言后,请分别为 S1 和 S2 选择一个示例。") - dialect_selector = gr.Dropdown( - label="选择方言 (Select Dialect)", - choices=DIALECT_CHOICES, - value="(无)", - interactive=True - ) - with gr.Row(): - s1_dialect_example_selector = gr.Dropdown( - label="S1 方言示例 (S1 Dialect Example)", - choices=["(请先选择方言)"], - value="(无)", - interactive=True, - elem_classes="gradio-dropdown" - ) - s2_dialect_example_selector = gr.Dropdown( - label="S2 方言示例 (S2 Dialect Example)", - choices=["(请先选择方言)"], - value="(无)", - interactive=True, - elem_classes="gradio-dropdown" - ) - - dialect_selector.change( - fn=update_example_choices, - inputs=[dialect_selector], - outputs=[s1_dialect_example_selector, s2_dialect_example_selector] - ) - - s1_dialect_example_selector.change( - fn=update_prompt_text, - inputs=[dialect_selector, s1_dialect_example_selector], - outputs=[spk1_dialect_prompt_text] - ) - - s2_dialect_example_selector.change( - fn=update_prompt_text, - inputs=[dialect_selector, s2_dialect_example_selector], - outputs=[spk2_dialect_prompt_text] - ) +import numpy as np +import random +from tqdm import tqdm - def _change_component_language(lang): - global global_lang - global_lang = ["zh", "en"][lang] - return [ - - # spk1_prompt_{audio,text,dialect_prompt_text} - gr.update(label=i18n("spk1_prompt_audio_label")), - gr.update( - label=i18n("spk1_prompt_text_label"), - placeholder=i18n("spk1_prompt_text_placeholder"), - ), - gr.update( - label=i18n("spk1_dialect_prompt_text_label"), - placeholder=i18n("spk1_dialect_prompt_text_placeholder"), - ), - # spk2_prompt_{audio,text} - gr.update(label=i18n("spk2_prompt_audio_label")), - gr.update( - label=i18n("spk2_prompt_text_label"), - placeholder=i18n("spk2_prompt_text_placeholder"), - ), - gr.update( - label=i18n("spk2_dialect_prompt_text_label"), - placeholder=i18n("spk2_dialect_prompt_text_placeholder"), - ), - # dialogue_text_input - gr.update( - label=i18n("dialogue_text_input_label"), - placeholder=i18n("dialogue_text_input_placeholder"), - ), - # generate_btn - gr.update(value=i18n("generate_btn_label")), - # generate_audio - gr.update(label=i18n("generated_audio_label")), - ] +from soulxpodcast.config import Config, SoulXPodcastLLMConfig - lang_choice.change( - fn=_change_component_language, - inputs=[lang_choice], - outputs=[ - spk1_prompt_audio, - spk1_prompt_text, - spk1_dialect_prompt_text, - spk2_prompt_audio, - spk2_prompt_text, - spk2_dialect_prompt_text, - dialogue_text_input, - generate_btn, - generate_audio, - ], - ) - - generate_btn.click( - fn=dialogue_synthesis_function, - inputs=[ - dialogue_text_input, - spk1_prompt_text, - spk1_prompt_audio, - spk1_dialect_prompt_text, - spk2_prompt_text, - spk2_prompt_audio, - spk2_dialect_prompt_text, - seed_input, - ], - outputs=[generate_audio], - ) - return page +from webui import render_interface, initiate_model def get_args(): + """Parse command line arguments.""" parser = ArgumentParser() - parser.add_argument('--model_path', - required=True, - type=str, - help='model path') - parser.add_argument('--llm_engine', - type=str, - default="hf", - help='model execute engine') - parser.add_argument('--fp16_flow', - action='store_true', - help='enable fp16 flow') - parser.add_argument('--seed', - type=int, - default=1988, - help='random seed for generation') - parser.add_argument('--port', - type=int, - default=7860, - help='gradio port for web app') + parser.add_argument( + '--model_path', + required=True, + type=str, + help='model path' + ) + parser.add_argument( + '--llm_engine', + type=str, + default="hf", + help='model execute engine' + ) + parser.add_argument( + '--fp16_flow', + action='store_true', + help='enable fp16 flow' + ) + parser.add_argument( + '--seed', + type=int, + default=1988, + help='random seed for generation' + ) + parser.add_argument( + '--gpu_memory_utilization', + type=float, + default=0.5, + help='GPU memory utilization ratio for VLLM (default: 0.5, lower if OOM)' + ) + parser.add_argument( + '--max_model_len', + type=int, + default=8192, + help='Maximum model length for VLLM (default: 8192)' + ) args = parser.parse_args() return args @@ -606,8 +67,9 @@ def get_args(): # Initiate model hf_config = SoulXPodcastLLMConfig.from_initial_and_json( - initial_values={"fp16_flow": args.fp16_flow}, - json_file=f"{args.model_path}/soulxpodcast_config.json") + initial_values={"fp16_flow": args.fp16_flow}, + json_file=f"{args.model_path}/soulxpodcast_config.json" + ) llm_engine = args.llm_engine if llm_engine == "vllm": @@ -615,15 +77,23 @@ def get_args(): llm_engine = "hf" timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S,%f')[:-3] tqdm.write(f"[{timestamp}] - [WARNING]: No install VLLM, switch to hf engine.") - config = Config(model=args.model_path, enforce_eager=True, llm_engine=llm_engine, - hf_config=hf_config) + + config = Config( + model=args.model_path, + enforce_eager=True, + llm_engine=llm_engine, + hf_config=hf_config, + gpu_memory_utilization=args.gpu_memory_utilization, + max_model_len=args.max_model_len + ) torch.manual_seed(args.seed) np.random.seed(args.seed) random.seed(args.seed) initiate_model(config) - print("[INFO] SoulX-Podcast loaded") + print("[INFO] SoulX-Podcast loaded") + page = render_interface() page.queue() - page.launch(share=False, server_name="0.0.0.0", server_port=args.port) + page.launch(share=False) diff --git a/webui/__init__.py b/webui/__init__.py new file mode 100644 index 0000000..5e5f78c --- /dev/null +++ b/webui/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +SoulX-Podcast WebUI Package + +This package provides a modular Gradio-based web interface for SoulX-Podcast. +""" + +from .interface import render_interface +from .synthesis import initiate_model, get_model, get_dataset + +__all__ = [ + "render_interface", + "initiate_model", + "get_model", + "get_dataset", +] diff --git a/webui/callbacks.py b/webui/callbacks.py new file mode 100644 index 0000000..56aa5b8 --- /dev/null +++ b/webui/callbacks.py @@ -0,0 +1,954 @@ +# -*- coding: utf-8 -*- +""" +UI callback functions for SoulX-Podcast WebUI. +""" + +import re +import os +import time +from datetime import datetime +from typing import List + +import numpy as np +import gradio as gr +import soundfile as sf + +from .constants import MAX_SPEAKERS, MAX_TEXT_INPUTS +from .i18n import ( + i18n, + get_i18n_dict, + get_speaker_display_label, + get_language, + set_language, +) +from .synthesis import dialogue_synthesis_function +from .file_manager import create_all_zip, write_json_file +from .config_manager import build_current_config_dict + + +# ============================================================================= +# Speaker Management Callbacks +# ============================================================================= + +def update_speakers_visibility(num_speakers: int, remarks=None): + """更新说话人列的可见性和标签""" + remark_list = list(remarks) if remarks else [] + updates = [] + for i in range(MAX_SPEAKERS): + visible = (i < num_speakers) + remark_val = remark_list[i] if i < len(remark_list) else "" + label = get_speaker_display_label(i + 1, remark_val) + if visible: + updates.append(gr.update(visible=True, label=label, value=False)) + else: + updates.append(gr.update(visible=False, value=False)) + return updates + + +def add_speaker(current_num: int, *remarks): + """添加一个说话人""" + remark_list = list(remarks) if remarks else [] + new_num = min(current_num + 1, MAX_SPEAKERS) + checkbox_updates = update_speakers_visibility(new_num, remark_list) + column_updates = [] + for i in range(MAX_SPEAKERS): + remark_val = remark_list[i] if i < len(remark_list) else "" + column_updates.append( + gr.update( + visible=(i < new_num), + label=get_speaker_display_label(i + 1, remark_val) + ) + ) + return new_num, *checkbox_updates, *column_updates + + +def quick_add_speakers(current_num: int, add_count, *remarks): + """快速添加指定数量的说话人""" + remark_list = list(remarks) if remarks else [] + add_count = int(add_count) if add_count else 1 + add_count = max(1, min(add_count, MAX_SPEAKERS - current_num)) + new_num = min(current_num + add_count, MAX_SPEAKERS) + checkbox_updates = update_speakers_visibility(new_num, remark_list) + column_updates = [] + for i in range(MAX_SPEAKERS): + remark_val = remark_list[i] if i < len(remark_list) else "" + column_updates.append( + gr.update( + visible=(i < new_num), + label=get_speaker_display_label(i + 1, remark_val) + ) + ) + return new_num, *checkbox_updates, *column_updates + + +def batch_delete_speakers(current_num: int, *all_values): + """批量删除选中的说话人,并重新排列剩余说话人及其数据""" + # all_values格式: (checkbox1, audio1, text1, dialect1, remark1, checkbox2, ...) + speaker_data = [] + for i in range(MAX_SPEAKERS): + base = i * 5 + checkbox_val = all_values[base] if base < len(all_values) else False + audio_val = all_values[base + 1] if base + 1 < len(all_values) else None + text_val = all_values[base + 2] if base + 2 < len(all_values) else "" + dialect_val = all_values[base + 3] if base + 3 < len(all_values) else "" + remark_val = all_values[base + 4] if base + 4 < len(all_values) else "" + speaker_data.append( + dict( + checkbox=checkbox_val, + audio=audio_val, + text=text_val, + dialect=dialect_val, + remark=remark_val, + ) + ) + + selected_indices = { + i for i, spk in enumerate(speaker_data) if spk["checkbox"] and i < current_num + } + + def _build_updates(target_num: int, kept: list): + updates = [] + for i in range(MAX_SPEAKERS): + if i < target_num and i < len(kept): + spk = kept[i] + label = get_speaker_display_label(i + 1, spk["remark"]) + updates.extend( + [ + gr.update(visible=True, label=label, value=False), + gr.update(value=spk["audio"]), + gr.update(value=spk["text"]), + gr.update(value=spk["dialect"]), + gr.update(value=spk["remark"]), + ] + ) + else: + updates.extend( + [ + gr.update(visible=False, value=False), + gr.update(value=None), + gr.update(value=""), + gr.update(value=""), + gr.update(value=""), + ] + ) + tab_updates = [ + gr.update( + visible=(i < target_num), + label=get_speaker_display_label( + i + 1, kept[i]["remark"] if i < len(kept) else "" + ), + ) + for i in range(MAX_SPEAKERS) + ] + return updates, tab_updates + + if not selected_indices: + gr.Warning("请至少选择一个说话人进行删除") + kept_list = [speaker_data[i] for i in range(current_num)] + updates, tab_updates = _build_updates(current_num, kept_list) + return current_num, *updates, *tab_updates + + remaining_count = current_num - len(selected_indices) + if remaining_count < 1: + gr.Warning("至少需要保留1个说话人") + kept_list = [speaker_data[i] for i in range(current_num)] + updates, tab_updates = _build_updates(current_num, kept_list) + return current_num, *updates, *tab_updates + + kept_indices = [i for i in range(current_num) if i not in selected_indices] + kept_list = [speaker_data[i] for i in kept_indices] + updates, tab_updates = _build_updates(remaining_count, kept_list) + return remaining_count, *updates, *tab_updates + + +def select_all_checkboxes(current_num: int): + """全选所有可见的复选框""" + updates = [] + for i in range(MAX_SPEAKERS): + if i < current_num: + updates.append(gr.update(value=True)) + else: + updates.append(gr.update()) + return updates + + +def select_none_checkboxes(current_num: int): + """取消全选所有复选框""" + updates = [] + for i in range(MAX_SPEAKERS): + updates.append(gr.update(value=False)) + return updates + + +def update_single_speaker_label(remark: str, idx: int): + """根据备注更新单个说话人的复选框与 Tab 标签""" + label = get_speaker_display_label(idx, remark) + return gr.update(label=label), gr.update(label=label) + + +def refresh_all_speaker_labels_after_load(num_speakers: int, *remarks): + """ + 配置加载后,显式刷新所有说话人的复选框和Tab标签 + 这个函数用于解决配置加载时标签不立即更新的问题 + """ + from datetime import datetime + current_time = datetime.now().strftime('%H-%M-%S') + print(f"[{current_time}] 刷新所有说话人标签...") + + remark_list = list(remarks) if remarks else [] + num = int(num_speakers) if num_speakers else 1 + num = max(1, min(num, MAX_SPEAKERS)) + + checkbox_updates = [] + tab_updates = [] + + for i in range(MAX_SPEAKERS): + remark_val = remark_list[i] if i < len(remark_list) else "" + label = get_speaker_display_label(i + 1, remark_val) + + if i < num: + checkbox_updates.append(gr.update(label=label, visible=True)) + tab_updates.append(gr.update(label=label, visible=True)) + else: + checkbox_updates.append(gr.update(label=label, visible=False)) + tab_updates.append(gr.update(label=label, visible=False)) + + print(f"[{current_time}] 已刷新 {num} 个说话人的标签") + return (*checkbox_updates, *tab_updates) + + +def update_speaker_accordion_label(num_speakers: int, *remarks): + """ + 更新说话人设置 Accordion 的标题,显示所有说话人的标签信息 + """ + remark_list = list(remarks) if remarks else [] + num = int(num_speakers) if num_speakers else 1 + num = max(1, min(num, MAX_SPEAKERS)) + + # 构建说话人标签列表 + speaker_labels = [] + for i in range(num): + remark_val = remark_list[i] if i < len(remark_list) else "" + label = get_speaker_display_label(i + 1, remark_val) + speaker_labels.append(label) + + # 生成标题 + if speaker_labels: + labels_str = ", ".join(speaker_labels) + title = f"👥 说话人设置 / Speakers ({labels_str})" + else: + title = "👥 说话人设置 / Speakers" + + return gr.update(label=title) + + +def _build_speaker_labels(num_speakers: int, remarks=None): + """生成当前可见说话人的标签列表""" + remark_list = list(remarks) if remarks else [] + labels = [] + for i in range(max(1, min(int(num_speakers) if num_speakers else 1, MAX_SPEAKERS))): + remark_val = remark_list[i] if i < len(remark_list) else "" + labels.append(get_speaker_display_label(i + 1, remark_val)) + return labels + + +def update_speaker_selection_choices(num_speakers: int, *remarks): + """更新快捷勾选组件的选项""" + labels = _build_speaker_labels(num_speakers, remarks) + return gr.update(choices=labels, value=[]) + + +def selection_group_to_checkboxes(selected_labels, num_speakers: int, *remarks): + """将快捷勾选结果同步到各说话人复选框""" + labels = _build_speaker_labels(num_speakers, remarks) + selected_set = set(selected_labels or []) + updates = [] + for i in range(MAX_SPEAKERS): + if i < len(labels): + updates.append(gr.update(value=(labels[i] in selected_set), visible=True)) + else: + updates.append(gr.update(value=False, visible=False)) + return updates + + +def select_all_selection_group(num_speakers: int, *remarks): + """同步全选到快捷勾选组件""" + labels = _build_speaker_labels(num_speakers, remarks) + return gr.update(value=labels) + + +def select_none_selection_group(): + """同步全不选到快捷勾选组件""" + return gr.update(value=[]) + + +# ============================================================================= +# Text Input Management +# ============================================================================= + +def update_text_inputs_visibility(num_inputs): + """更新文本输入框的可见性""" + num = int(num_inputs) if num_inputs else 1 + num = max(1, min(num, MAX_TEXT_INPUTS)) + updates = [] + audio_updates = [] + download_updates = [] + for i in range(MAX_TEXT_INPUTS): + is_visible = (i < num) + updates.append(gr.update( + visible=is_visible, + label=f"{i18n('dialogue_text_input_label')} {i+1}" + )) + # 预览组件应该和文本输入框保持相同的可见性 + # 这样当有音频生成时,预览组件才能正确显示 + audio_updates.append(gr.update(visible=is_visible)) + download_updates.append(gr.update(visible=False)) + return num, *updates, *audio_updates, *download_updates + + +# ============================================================================= +# Synthesis Processing +# ============================================================================= + +def process_single_synthesis( + target_text: str, + num_speakers: int, + seed: int, + diff_spk_pause_ms: int, + speaker_args: List, + task_number: int, + base_output_dir: str, + timestamp: str, +): + """ + 处理单个合成任务 + task_number: 任务编号(从1开始) + base_output_dir: 基础输出目录(时间戳文件夹) + timestamp: 统一的时间戳 + Returns: (audio_result, saved_files, zip_file_path, output_dir, task_time_seconds) + """ + task_start_time = time.time() + current_time = datetime.now().strftime('%H-%M-%S') + + speaker_configs = [] + # 支持两种格式:3个一组(audio, text, dialect)或4个一组(audio, text, dialect, remark) + # 根据参数数量自动判断格式 + step = 4 if len(speaker_args) >= num_speakers * 4 else 3 + for i in range(0, min(num_speakers * step, len(speaker_args)), step): + if i + 2 < len(speaker_args): + audio = speaker_args[i] if speaker_args[i] is not None else None + text = speaker_args[i+1] if speaker_args[i+1] is not None else "" + dialect = speaker_args[i+2] if speaker_args[i+2] is not None else "" + speaker_configs.append((text, audio, dialect)) + + task_subdir = f"{task_number:03d}" + output_dir = os.path.join(base_output_dir, task_subdir) + os.makedirs(output_dir, exist_ok=True) + + print(f"[{current_time}] 开始处理任务 {task_number}") + + try: + result = dialogue_synthesis_function( + target_text, + speaker_configs, + seed, + int(diff_spk_pause_ms) if diff_spk_pause_ms is not None else 0, + output_dir=output_dir, + save_separated=True, + timestamp=timestamp + ) + + task_end_time = time.time() + task_time = task_end_time - task_start_time + current_time_end = datetime.now().strftime('%H-%M-%S') + + if result is None: + # dialogue_synthesis_function 返回 None 表示失败 + print(f"[{current_time_end}] 任务 {task_number} 处理失败,耗时: {task_time:.2f} 秒") + return None, [], None, output_dir, task_time + + audio_result, saved_files = result + print(f"[{current_time_end}] 任务 {task_number} 处理完成,耗时: {task_time:.2f} 秒") + return audio_result, saved_files, None, output_dir, task_time + except Exception as e: + task_end_time = time.time() + task_time = task_end_time - task_start_time + current_time_end = datetime.now().strftime('%H-%M-%S') + error_msg = f"process_single_synthesis 执行失败: {str(e)}" + print(f"[{current_time_end}] [ERROR] {error_msg}") + print(f"[{current_time_end}] 任务 {task_number} 处理失败,耗时: {task_time:.2f} 秒") + import traceback + traceback.print_exc() + return None, [], None, output_dir, task_time + + +def write_log_to_file(log_content: str, log_file_path: str): + """将日志内容写入文件""" + try: + with open(log_file_path, 'a', encoding='utf-8') as f: + f.write(log_content + '\n') + except Exception as e: + print(f"[WARNING] 写入日志文件失败: {str(e)}") + + +def collect_and_synthesize_queue( + num_text_inputs, + num_speakers, + seed, + diff_spk_pause_ms, + task_pause_ms, + language_idx, + *all_text_and_speaker_args +): + """ + 处理队列中的所有任务(生成器版本,每完成一个任务就更新预览) + all_text_and_speaker_args格式: (text1, ..., textN, audio1, text1, dialect1, remark1, audio2, text2, dialect2, remark2, ...) + language_idx: 语言索引 (0=中文, 1=English 或 "中文"/"English") + task_pause_ms: 任务间的停顿时间(毫秒) + """ + global_lang = get_language() + num_text = int(num_text_inputs) if num_text_inputs else 1 + num_speaker = int(num_speakers) + task_pause_seconds = (int(task_pause_ms) if task_pause_ms is not None else 500) / 1000.0 + + text_inputs = list(all_text_and_speaker_args[:MAX_TEXT_INPUTS]) + speaker_args = list(all_text_and_speaker_args[MAX_TEXT_INPUTS:]) + + valid_texts = [] + valid_indices = [] + for i, text in enumerate(text_inputs[:num_text]): + if text and text.strip(): + valid_texts.append(text) + valid_indices.append(i) + + if not valid_texts: + empty_audio_updates = [gr.update(visible=False) for _ in range(MAX_TEXT_INPUTS)] + empty_download_updates = [gr.update(visible=False) for _ in range(MAX_TEXT_INPUTS)] + yield ( + None, + "所有输入框均为空,请至少填写一个文本输入", + gr.update(visible=False), + gr.update(interactive=True), # Left generate button + gr.update(interactive=True), # Right generate button + *empty_audio_updates, + *empty_download_updates, + ) + return + + total_start_time = time.time() + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + base_output_dir = os.path.join(os.getcwd(), "outputs", "separated_speakers", timestamp) + os.makedirs(base_output_dir, exist_ok=True) + + # 创建日志文件 + log_file_path = os.path.join(base_output_dir, "synthesis.log") + current_time = datetime.now().strftime('%H-%M-%S') + initial_log = f"[{current_time}] 开始处理 {len(valid_texts)} 个任务\n" + initial_log += f"[{current_time}] 输出目录: {os.path.abspath(base_output_dir)}\n" + initial_log += f"[{current_time}] 任务间停顿时间: {task_pause_ms if task_pause_ms is not None else 500} ms\n" + write_log_to_file(initial_log, log_file_path) + + # 只显示总体进度,不显示每个任务的进度 + # 使用 track_tqdm=False 避免在每个文本框下显示进度条 + progress_bar = gr.Progress(track_tqdm=False) + all_info_messages = [] + task_audio_results = {} + all_complete_audio_files = [] + all_generated_files = [] + task_times = [] # 记录每个任务的耗时 + + # 初始化所有预览为不可见 + audio_preview_updates = [gr.update(visible=False) for _ in range(MAX_TEXT_INPUTS)] + download_updates = [gr.update(visible=False) for _ in range(MAX_TEXT_INPUTS)] + + for task_idx, (text_idx, target_text) in enumerate(zip(valid_indices, valid_texts)): + # 不显示每个任务的进度,避免在每个文本框下显示进度条 + # 只在开始时显示一次总体进度 + if task_idx == 0 and len(valid_texts) > 1: + progress_bar(0, desc=f"开始处理 {len(valid_texts)} 个任务") + + task_start_time = time.time() + try: + task_number = task_idx + 1 + audio_result, saved_files, zip_file_path, output_dir, task_time = process_single_synthesis( + target_text, num_speaker, seed, diff_spk_pause_ms, speaker_args, + task_number, base_output_dir, timestamp + ) + + task_times.append(task_time) + current_time = datetime.now().strftime('%H-%M-%S') + task_log_msg = f"[{current_time}] 任务 {task_idx + 1} (输入框 {text_idx + 1}) 处理完成,耗时: {task_time:.2f} 秒" + write_log_to_file(task_log_msg, log_file_path) + + # 检查处理是否成功 + if audio_result is None or not saved_files: + error_msg = f"任务 {task_idx + 1} (输入框 {text_idx + 1}) 处理失败,未生成音频文件,耗时: {task_time:.2f} 秒" + all_info_messages.append(error_msg) + print(f"[WARNING] {error_msg}") + write_log_to_file(f"[{current_time}] [WARNING] {error_msg}", log_file_path) + continue + + task_audio_results[text_idx] = audio_result + all_generated_files.extend(saved_files) + + print(f"[INFO] 任务 {task_idx + 1} (输入框 {text_idx + 1}) 完成,音频已生成") + + complete_files = [f for f in saved_files if "complete_dialogue" in os.path.basename(f)] + if complete_files: + all_complete_audio_files.extend(complete_files) + + task_subdir_name = f"{task_number:03d}" + info_message = f"═══════════════════════════════════\n" + info_message += f"任务 {task_idx + 1}/{len(valid_texts)} (输入框 {text_idx + 1})\n" + info_message += f"═══════════════════════════════════\n" + info_message += f"⏱️ 处理时间: {task_time:.2f} 秒\n" + info_message += f"{i18n('files_saved_to')}\n" + info_message += f"基础文件夹: {os.path.abspath(base_output_dir)}\n" + info_message += f"任务子文件夹: {task_subdir_name}/\n" + info_message += f"完整路径: {os.path.abspath(output_dir)}\n\n" + + if saved_files: + info_message += f"{i18n('files_generated_count').format(count=len(saved_files))}\n\n" + + complete_files = [f for f in saved_files if "complete_dialogue" in os.path.basename(f)] + if complete_files: + info_message += f"📁 {i18n('complete_dialogue_audio')}:\n" + for f in complete_files: + info_message += f" • {os.path.basename(f)}\n" + info_message += "\n" + + speaker_groups = {} + for f in saved_files: + basename = os.path.basename(f) + if "speaker" in basename and "complete_dialogue" not in basename: + match = re.search(r'speaker(\d+)', basename) + if match: + spk_num = match.group(1) + if spk_num not in speaker_groups: + speaker_groups[spk_num] = [] + speaker_groups[spk_num].append(basename) + + for spk_num in sorted(speaker_groups.keys(), key=int): + files = sorted(speaker_groups[spk_num]) + complete_audio = [f for f in files if "_complete_" in f] + parts = [f for f in files if "_part" in f] + + info_message += f"🎤 {i18n('speaker_label').format(num=spk_num)}:\n" + if complete_audio: + for filename in complete_audio: + info_message += f" • {filename} {i18n('complete_audio_label')}\n" + if parts: + for filename in sorted(parts): + info_message += f" • {filename}\n" + info_message += "\n" + else: + info_message += f"{i18n('no_files_saved')}\n" + + all_info_messages.append(info_message) + + # 每完成一个任务,立即更新该任务的预览 + current_info_message = f"📂 所有任务文件保存在统一的时间戳文件夹中:\n" + current_info_message += f" {os.path.abspath(base_output_dir)}\n" + current_info_message += f" 每个任务的文件保存在对应的编号子文件夹中 (001/, 002/, 003/, ...)\n" + current_info_message += f" 分段语音保存在各任务子文件夹的 separated/ 子文件夹中\n" + current_info_message += "\n" + current_info_message += "═══════════════════════════════════\n\n" + current_info_message += "\n\n".join(all_info_messages) + current_info_message += f"\n\n⏳ 进行中: 已完成 {task_idx + 1}/{len(valid_texts)} 个任务" + + # 更新当前任务的预览 + # 预览组件应该和文本输入框保持相同的可见性 + # 这样当有音频生成时,预览组件才能正确显示 + current_audio_preview_updates = [] + current_download_updates = [] + for i in range(MAX_TEXT_INPUTS): + # 检查这个输入框是否在有效输入框中(即文本输入框是否可见) + is_text_input_visible = i in valid_indices or i < num_text + + if i in task_audio_results: + # 有音频结果,显示预览 + if global_lang == "zh": + audio_label = f"任务 {i+1} 音频预览" + else: + audio_label = f"Task {i+1} Audio Preview" + + print(f"[INFO] 更新预览组件 {i+1}: 显示音频预览") + + current_audio_preview_updates.append(gr.update( + visible=True, + value=task_audio_results[i], + label=audio_label + )) + current_download_updates.append(gr.update(visible=False)) + elif is_text_input_visible: + # 文本输入框可见但还没有音频,保持预览组件可见(显示为空) + if global_lang == "zh": + audio_label = f"任务 {i+1} 音频预览" + else: + audio_label = f"Task {i+1} Audio Preview" + + current_audio_preview_updates.append(gr.update( + visible=True, + value=None, + label=audio_label + )) + current_download_updates.append(gr.update(visible=False)) + else: + # 文本输入框不可见,预览组件也不可见 + current_audio_preview_updates.append(gr.update(visible=False)) + current_download_updates.append(gr.update(visible=False)) + + # 计算当前合并音频(如果有多个任务已完成) + current_preview_audio_value = None + if len(all_complete_audio_files) > 0 and task_idx == 0: + # 第一个任务完成时,使用第一个任务的音频作为预览 + current_preview_audio_value = audio_result + elif len(all_complete_audio_files) > 1: + # 多个任务完成时,尝试合并已完成的音频 + try: + temp_merged_path = os.path.join(base_output_dir, "temp_merged.wav") + merged_audio_data = None + sample_rate = 24000 + + for idx, audio_file in enumerate(all_complete_audio_files): + if os.path.exists(audio_file): + audio_data, sr = sf.read(audio_file) + if sample_rate != sr: + print(f"[WARNING] 采样率不一致: {audio_file} 为 {sr}Hz,期望 {sample_rate}Hz") + + if len(audio_data.shape) > 1: + audio_data = np.mean(audio_data, axis=1) + + if merged_audio_data is None: + merged_audio_data = audio_data + else: + # 使用可配置的任务间停顿时间 + pause_samples = int(task_pause_seconds * sample_rate) + silence = np.zeros(pause_samples) + merged_audio_data = np.concatenate([merged_audio_data, silence, audio_data]) + + if merged_audio_data is not None: + sf.write(temp_merged_path, merged_audio_data, sample_rate) + current_preview_audio_value = (sample_rate, merged_audio_data) + except Exception as e: + print(f"[WARNING] 临时合并音频失败: {str(e)}") + if task_audio_results: + current_preview_audio_value = list(task_audio_results.values())[-1] + elif task_audio_results: + current_preview_audio_value = list(task_audio_results.values())[-1] + + # 每完成一个任务就 yield 一次更新 + yield ( + current_preview_audio_value, + current_info_message, + gr.update(visible=False), # 下载文件在最后才生成 + gr.update(interactive=False), # Left generate button (处理中禁用) + gr.update(interactive=False), # Right generate button (处理中禁用) + *current_audio_preview_updates, + *current_download_updates, + ) + + # 如果不是最后一个任务,添加任务间停顿 + if task_idx < len(valid_texts) - 1 and task_pause_seconds > 0: + current_time = datetime.now().strftime('%H-%M-%S') + pause_log = f"[{current_time}] 任务间停顿 {task_pause_seconds:.2f} 秒..." + write_log_to_file(pause_log, log_file_path) + time.sleep(task_pause_seconds) + + except Exception as e: + task_end_time = time.time() + task_time = task_end_time - task_start_time + current_time = datetime.now().strftime('%H-%M-%S') + error_msg = f"任务 {task_idx + 1} 处理失败: {str(e)}\n" + error_msg += f"耗时: {task_time:.2f} 秒" + all_info_messages.append(error_msg) + write_log_to_file(f"[{current_time}] [ERROR] {error_msg}", log_file_path) + task_times.append(task_time) # 即使失败也记录时间 + import traceback + traceback.print_exc() + + # 合并所有任务的完整对话音频 + merged_audio_path = None + if all_complete_audio_files and len(all_complete_audio_files) > 0: + try: + current_time = datetime.now().strftime('%H-%M-%S') + merge_start_time = time.time() + write_log_to_file(f"[{current_time}] 开始合并所有任务音频...", log_file_path) + + merged_audio_path = os.path.join(base_output_dir, "all.wav") + merged_audio_data = None + sample_rate = 24000 + + for idx, audio_file in enumerate(all_complete_audio_files): + if os.path.exists(audio_file): + audio_data, sr = sf.read(audio_file) + if sample_rate != sr: + print(f"[WARNING] 采样率不一致: {audio_file} 为 {sr}Hz,期望 {sample_rate}Hz") + + if len(audio_data.shape) > 1: + audio_data = np.mean(audio_data, axis=1) + + if merged_audio_data is None: + merged_audio_data = audio_data + else: + # 使用可配置的任务间停顿时间 + pause_samples = int(task_pause_seconds * sample_rate) + silence = np.zeros(pause_samples) + merged_audio_data = np.concatenate([merged_audio_data, silence, audio_data]) + + if merged_audio_data is not None: + sf.write(merged_audio_path, merged_audio_data, sample_rate) + merge_time = time.time() - merge_start_time + current_time = datetime.now().strftime('%H-%M-%S') + print(f"[INFO] 已合并所有任务音频到: {merged_audio_path}") + write_log_to_file(f"[{current_time}] 音频合并完成,耗时: {merge_time:.2f} 秒", log_file_path) + all_generated_files.append(merged_audio_path) + except Exception as e: + current_time = datetime.now().strftime('%H-%M-%S') + print(f"[ERROR] 合并音频文件时出错: {str(e)}") + write_log_to_file(f"[{current_time}] [ERROR] 合并音频文件时出错: {str(e)}", log_file_path) + import traceback + traceback.print_exc() + + # 更新总体进度为完成 + if len(valid_texts) > 1: + progress_bar(1.0, desc=f"已完成所有 {len(valid_texts)} 个任务") + + # 计算总耗时 + total_end_time = time.time() + total_time = total_end_time - total_start_time + current_time = datetime.now().strftime('%H-%M-%S') + + # 记录总耗时和各任务耗时到日志 + total_log = f"\n[{current_time}] {'='*50}\n" + total_log += f"[{current_time}] 所有任务处理完成\n" + total_log += f"[{current_time}] 总任务数: {len(valid_texts)}\n" + if task_times: + total_log += f"[{current_time}] 各任务耗时: " + for i, t in enumerate(task_times, 1): + total_log += f"任务{i}({t:.2f}s) " + total_log += "\n" + avg_time = sum(task_times) / len(task_times) + total_log += f"[{current_time}] 平均任务耗时: {avg_time:.2f} 秒\n" + total_log += f"[{current_time}] 总耗时: {total_time:.2f} 秒\n" + total_log += f"[{current_time}] {'='*50}\n" + write_log_to_file(total_log, log_file_path) + + # 创建 all.zip + all_zip_path = None + if all_generated_files: + all_zip_path = create_all_zip(base_output_dir, all_generated_files) + + # 构建最终信息 + final_info_message = f"📂 所有任务文件保存在统一的时间戳文件夹中:\n" + final_info_message += f" {os.path.abspath(base_output_dir)}\n" + final_info_message += f" 每个任务的文件保存在对应的编号子文件夹中 (001/, 002/, 003/, ...)\n" + final_info_message += f" 分段语音保存在各任务子文件夹的 separated/ 子文件夹中\n" + if merged_audio_path and os.path.exists(merged_audio_path): + final_info_message += f" 📁 合并音频文件: {os.path.basename(merged_audio_path)}\n" + if all_zip_path and os.path.exists(all_zip_path): + final_info_message += f" 📦 所有文件压缩包: {os.path.basename(all_zip_path)}\n" + final_info_message += f" 📝 日志文件: synthesis.log\n" + final_info_message += f" ⚙️ 配置文件: config.json\n" + final_info_message += "\n" + final_info_message += "═══════════════════════════════════\n\n" + final_info_message += "\n\n".join(all_info_messages) + final_info_message += f"\n\n{'='*50}\n" + final_info_message += f"⏱️ 总处理时间: {total_time:.2f} 秒\n" + if task_times: + final_info_message += f"⏱️ 各任务耗时: " + for i, t in enumerate(task_times, 1): + final_info_message += f"任务{i}({t:.2f}s) " + final_info_message += "\n" + avg_time = sum(task_times) / len(task_times) + final_info_message += f"⏱️ 平均任务耗时: {avg_time:.2f} 秒\n" + final_info_message += f"✅ 已完成所有任务 ({len(valid_texts)}/{len(valid_texts)})\n" + + # 生成最终更新 + final_audio_preview_updates = [] + final_download_updates = [] + + preview_audio_value = None + if merged_audio_path and os.path.exists(merged_audio_path): + try: + audio_data, sample_rate = sf.read(merged_audio_path) + preview_audio_value = (sample_rate, audio_data) + except Exception as e: + print(f"[WARNING] 读取 all.wav 文件失败: {str(e)}") + if task_audio_results: + preview_audio_value = list(task_audio_results.values())[-1] + elif task_audio_results: + preview_audio_value = list(task_audio_results.values())[-1] + + for i in range(MAX_TEXT_INPUTS): + # 检查这个输入框是否在有效输入框中(即文本输入框是否可见) + is_text_input_visible = i in valid_indices or i < num_text + + if i in task_audio_results: + # 有音频结果,显示预览 + if global_lang == "zh": + audio_label = f"任务 {i+1} 音频预览" + else: + audio_label = f"Task {i+1} Audio Preview" + + final_audio_preview_updates.append(gr.update( + visible=True, + value=task_audio_results[i], + label=audio_label + )) + final_download_updates.append(gr.update(visible=False)) + elif is_text_input_visible: + # 文本输入框可见但还没有音频,保持预览组件可见(显示为空) + if global_lang == "zh": + audio_label = f"任务 {i+1} 音频预览" + else: + audio_label = f"Task {i+1} Audio Preview" + + final_audio_preview_updates.append(gr.update( + visible=True, + value=None, + label=audio_label + )) + final_download_updates.append(gr.update(visible=False)) + else: + # 文本输入框不可见,预览组件也不可见 + final_audio_preview_updates.append(gr.update(visible=False)) + final_download_updates.append(gr.update(visible=False)) + + # 导出配置到任务目录 + try: + current_time = datetime.now().strftime('%H-%M-%S') + print(f"[{current_time}] 开始导出配置到任务目录...") + + # 构建配置字典 + # speaker_args 格式:每4个一组 (audio, text, dialect, remark) + # 但需要确保长度符合 MAX_SPEAKERS * 4 的要求 + speaker_values = list(speaker_args) + + # 确保 speaker_values 长度符合要求(MAX_SPEAKERS * 4) + # 如果不足,补齐空值 + expected_length = MAX_SPEAKERS * 4 + while len(speaker_values) < expected_length: + # 根据位置判断应该填充什么类型的值 + idx = len(speaker_values) + if idx % 4 == 0: + # audio 位置,填充 None + speaker_values.append(None) + else: + # text, dialect, remark 位置,填充空字符串 + speaker_values.append("") + + config_dict = build_current_config_dict( + language_idx=language_idx, + seed=seed, + diff_spk_pause_ms=diff_spk_pause_ms, + task_pause_ms=task_pause_ms, + num_speakers=num_speaker, + num_text_inputs=num_text, + text_inputs=text_inputs, + speaker_values=speaker_values, + ) + + # 保存配置到任务目录 + config_file_path = os.path.join(base_output_dir, "config.json") + write_json_file(config_file_path, config_dict) + current_time = datetime.now().strftime('%H-%M-%S') + print(f"[{current_time}] ✅ 配置已导出到: {config_file_path}") + write_log_to_file(f"[{current_time}] ✅ 配置已导出到: {config_file_path}", log_file_path) + except Exception as e: + current_time = datetime.now().strftime('%H-%M-%S') + error_msg = f"导出配置失败: {str(e)}" + print(f"[{current_time}] [ERROR] {error_msg}") + write_log_to_file(f"[{current_time}] [ERROR] {error_msg}", log_file_path) + import traceback + traceback.print_exc() + + download_file_update = None + if all_zip_path and os.path.exists(all_zip_path): + download_label = f"{i18n('download_all_files_label')} - all.zip" + download_file_update = gr.update(visible=True, value=all_zip_path, label=download_label) + else: + download_file_update = gr.update(visible=False, value=None) + + # 最后一次 yield,返回最终结果 + yield ( + preview_audio_value, + final_info_message, + download_file_update, + gr.update(interactive=True), # Left generate button + gr.update(interactive=True), # Right generate button + *final_audio_preview_updates, + *final_download_updates, + ) + + +# ============================================================================= +# Language Switch Callback +# ============================================================================= + +def change_component_language(lang, *remarks): + """Change language for all components.""" + if isinstance(lang, str): + set_language("zh" if lang == "中文" else "en") + else: + try: + set_language(["zh", "en"][int(lang)]) + except Exception: + set_language("zh") + global_lang = get_language() + i18n_dict = get_i18n_dict() + + checkbox_updates = [] + input_updates = [] + + remark_list = list(remarks) if remarks else [] + for i in range(MAX_SPEAKERS): + remark_val = remark_list[i] if i < len(remark_list) else "" + checkbox_updates.append(gr.update(label=get_speaker_display_label(i + 1, remark_val))) + + for i in range(MAX_SPEAKERS): + input_updates.extend([ + gr.update(label=i18n(f"spk{i+1}_prompt_audio_label") if f"spk{i+1}_prompt_audio_label" in i18n_dict else f"说话人 {i+1} 参考语音"), + gr.update( + label=i18n(f"spk{i+1}_prompt_text_label") if f"spk{i+1}_prompt_text_label" in i18n_dict else f"说话人 {i+1} 参考文本", + ), + gr.update( + label=i18n(f"spk{i+1}_dialect_prompt_text_label") if f"spk{i+1}_dialect_prompt_text_label" in i18n_dict else f"说话人 {i+1} 方言提示文本", + ), + ]) + + updates = checkbox_updates + input_updates + + for i in range(MAX_TEXT_INPUTS): + updates.append(gr.update( + label=f"{i18n('dialogue_text_input_label')} {i+1}", + )) + + for i in range(MAX_TEXT_INPUTS): + if global_lang == "zh": + updates.append(gr.update(label=f"任务 {i+1} 音频预览")) + updates.append(gr.update(label=f"任务 {i+1} 下载")) + else: + updates.append(gr.update(label=f"Task {i+1} Audio Preview")) + updates.append(gr.update(label=f"Task {i+1} Download")) + + updates.extend([ + gr.update(value=i18n("generate_btn_label")), # Left generate button + gr.update(value=i18n("generate_btn_label")), # Right generate button + gr.update(label=i18n("generated_audio_label")), + gr.update(value=f"➕ {i18n('add_speaker_btn_label')}"), + gr.update(label=i18n('quick_add_num_label')), + gr.update(value=f"🚀 {i18n('quick_add_btn_label')}"), + gr.update(value=f"☑️ {i18n('select_all_btn_label')}"), + gr.update(value=f"☐ {i18n('select_none_btn_label')}"), + gr.update(value=f"🗑️ {i18n('batch_delete_btn_label')}"), + gr.update( + label=i18n("separated_files_info_label"), + placeholder=i18n("separated_files_info_placeholder"), + ), + gr.update(label=i18n("download_all_files_label")), + gr.update(label=i18n("diff_spk_pause_label")), + gr.update(label=i18n("task_pause_label")), + ]) + return updates + diff --git a/webui/components.py b/webui/components.py new file mode 100644 index 0000000..df89d8f --- /dev/null +++ b/webui/components.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +UI component creation functions for SoulX-Podcast WebUI. +""" + +import gradio as gr + +from .constants import DIALECT_PROMPT_DATA +from .i18n import get_speaker_display_label + + +# ============================================================================= +# Speaker Components +# ============================================================================= + +def create_speaker_group(spk_num: int): + """创建一个说话人组件组""" + with gr.Group(visible=True) as group: + # 添加复选框用于选择删除 + checkbox = gr.Checkbox( + label=get_speaker_display_label(spk_num), + value=False, + scale=0, + ) + remark = gr.Textbox( + label="备注名", + placeholder="例如:佩奇", + lines=1, + ) + prompt_audio = gr.Audio( + label=f"说话人 {spk_num} 参考语音", + type="filepath", + editable=False, + interactive=True, + ) + prompt_text = gr.Textbox( + label=f"说话人 {spk_num} 参考文本", + placeholder=f"说话人 {spk_num} 参考文本", + lines=3, + ) + dialect_prompt_text = gr.Textbox( + label=f"说话人 {spk_num} 方言提示文本", + placeholder="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>", + value="", + lines=3, + ) + return group, checkbox, remark, prompt_audio, prompt_text, dialect_prompt_text + + +# ============================================================================= +# Dialect Selection Functions +# ============================================================================= + +def update_example_choices(dialect_key: str): + """Update example choices based on selected dialect.""" + if dialect_key == "(无)": + choices = ["(请先选择方言)"] + return gr.update(choices=choices, value="(无)"), gr.update(choices=choices, value="(无)") + + choices = list(DIALECT_PROMPT_DATA.get(dialect_key, {}).keys()) + return gr.update(choices=choices, value="(无)"), gr.update(choices=choices, value="(无)") + + +def update_prompt_text(dialect_key: str, example_key: str): + """Update prompt text based on dialect and example selection.""" + if dialect_key == "(无)" or example_key in ["(无)", "(请先选择方言)"]: + return gr.update(value="") + + full_text = DIALECT_PROMPT_DATA.get(dialect_key, {}).get(example_key, "") + return gr.update(value=full_text) + diff --git a/webui/config_manager.py b/webui/config_manager.py new file mode 100644 index 0000000..4f630a4 --- /dev/null +++ b/webui/config_manager.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +""" +Configuration import/export management for SoulX-Podcast WebUI. +""" + +import os +import uuid +from datetime import datetime +from typing import List, Tuple + +import gradio as gr + +from .constants import CONFIG_DIR, MAX_SPEAKERS, MAX_TEXT_INPUTS +from .i18n import get_speaker_display_label +from .utils import coerce_audio_value_to_path, coerce_gradio_file_to_path +from .file_manager import ( + ensure_config_dir, + list_config_files, + read_json_file, + write_json_file, +) + + +# ============================================================================= +# Config Building +# ============================================================================= + +def build_current_config_dict( + language_idx, + seed, + diff_spk_pause_ms, + task_pause_ms, + num_speakers, + num_text_inputs, + text_inputs: List[str], + speaker_values: List, +) -> dict: + """Build a configuration dictionary from current UI state.""" + # language_idx: 0=中文, 1=English. If it comes as a string, we need to handle it. + if isinstance(language_idx, str): + language = "zh" if language_idx == "中文" else "en" + else: + try: + language = "zh" if int(language_idx) == 0 else "en" + except (ValueError, TypeError): + # Fallback if somehow it's neither string '中文'/'English' nor int-able + language = "zh" + + num_speakers = int(num_speakers) if num_speakers else 1 + num_text_inputs = int(num_text_inputs) if num_text_inputs else 1 + + # speaker_values 格式: [audio1, text1, dialect1, remark1, audio2, text2, dialect2, remark2, ...] + speakers = [] + for i in range(MAX_SPEAKERS): + base = i * 4 + audio_val = speaker_values[base] if base < len(speaker_values) else None + text_val = speaker_values[base + 1] if base + 1 < len(speaker_values) else "" + dialect_val = speaker_values[base + 2] if base + 2 < len(speaker_values) else "" + remark_val = speaker_values[base + 3] if base + 3 < len(speaker_values) else "" + speakers.append( + { + "prompt_audio": coerce_audio_value_to_path(audio_val), + "prompt_text": text_val if text_val is not None else "", + "dialect_prompt_text": dialect_val if dialect_val is not None else "", + "remark": remark_val if remark_val is not None else "", + } + ) + + cfg = { + "version": "1.0", + "export_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "language": language, + "num_speakers": num_speakers, + "num_text_inputs": num_text_inputs, + "seed": int(seed) if seed is not None else 1988, + "diff_spk_pause_ms": int(diff_spk_pause_ms) if diff_spk_pause_ms is not None else 0, + "task_pause_ms": int(task_pause_ms) if task_pause_ms is not None else 500, + "speakers": speakers[:num_speakers], + "text_inputs": [t if t is not None else "" for t in text_inputs[:num_text_inputs]], + } + return cfg + + +# ============================================================================= +# Config Export +# ============================================================================= + +def export_current_config( + language_idx, + seed, + diff_spk_pause_ms, + task_pause_ms, + num_speakers, + num_text_inputs, + config_name, + *values, +) -> Tuple[str, str]: + """ + Export current configuration to a JSON file. + values: text_inputs(MAX_TEXT_INPUTS) + speaker_inputs(MAX_SPEAKERS*3) + config_name: optional custom name for the config file + Returns: (file_path, status_message) + """ + ensure_config_dir() + text_inputs = list(values[:MAX_TEXT_INPUTS]) + speaker_values = list(values[MAX_TEXT_INPUTS:]) + + cfg = build_current_config_dict( + language_idx=language_idx, + seed=seed, + diff_spk_pause_ms=diff_spk_pause_ms, + task_pause_ms=task_pause_ms, + num_speakers=num_speakers, + num_text_inputs=num_text_inputs, + text_inputs=text_inputs, + speaker_values=speaker_values, + ) + + # Use custom name if provided, otherwise use default + config_name_clean = (config_name or "").strip() + if config_name_clean: + # Remove .json extension if user added it + if config_name_clean.lower().endswith('.json'): + config_name_clean = config_name_clean[:-5] + # Sanitize filename + import re + config_name_clean = re.sub(r'[^\w\-_\.]', '_', config_name_clean) + fname = f"{config_name_clean}.json" + else: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + fname = f"soulx_podcast_config_{ts}.json" + + out_path = os.path.join(CONFIG_DIR, fname) + # 极小概率同秒冲突,追加随机后缀 + if os.path.exists(out_path): + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + if config_name_clean: + out_path = os.path.join(CONFIG_DIR, f"{config_name_clean}_{ts}_{uuid.uuid4().hex[:8]}.json") + else: + out_path = os.path.join(CONFIG_DIR, f"soulx_podcast_config_{ts}_{uuid.uuid4().hex[:8]}.json") + write_json_file(out_path, cfg) + return out_path, f'✅ 已导出配置到: {out_path}\n(如果要在下拉菜单里看到新文件,请点击"刷新列表")' + + +def refresh_config_dropdown(current_value): + """Refresh the config dropdown with available files.""" + files = list_config_files() + new_value = current_value if current_value in files else (files[0] if files else None) + return gr.update(choices=files, value=new_value) + + +# ============================================================================= +# Config Loading +# ============================================================================= + +def apply_loaded_config(cfg: dict) -> Tuple[list, str]: + """ + 将 cfg 应用到界面,返回一组 updates,顺序与 outputs 对齐。 + Returns: (updates_list, warning_text) + """ + # 兼容旧字段/缺失字段 + num_speakers = int(cfg.get("num_speakers") or 1) + num_speakers = max(1, min(num_speakers, MAX_SPEAKERS)) + + num_text_inputs = int(cfg.get("num_text_inputs") or 1) + num_text_inputs = max(1, min(num_text_inputs, MAX_TEXT_INPUTS)) + + seed_val = int(cfg.get("seed") or 1988) + diff_pause_val = int(cfg.get("diff_spk_pause_ms") or 0) + task_pause_val = int(cfg.get("task_pause_ms") or 500) + + speakers = cfg.get("speakers") or [] + text_inputs = cfg.get("text_inputs") or [] + + # speakers_state, num_text_inputs_state, num_text_inputs_selector, seed_input, diff_spk_pause_input, task_pause_input + result = [ + num_speakers, + num_text_inputs, + gr.update(value=num_text_inputs), + gr.update(value=seed_val), + gr.update(value=diff_pause_val), + gr.update(value=task_pause_val), + ] + + warnings = [] + normalized_speakers = [] + for i in range(MAX_SPEAKERS): + spk = speakers[i] if i < len(speakers) else {} + if i < num_speakers: + audio_path = spk.get("prompt_audio") + if isinstance(audio_path, str) and audio_path.strip(): + if not os.path.isabs(audio_path): + audio_path = os.path.join(CONFIG_DIR, audio_path) + if not os.path.exists(audio_path): + warnings.append(f"说话人{i+1}参考语音不存在: {audio_path}") + audio_path = None + elif os.path.isdir(audio_path): + # 如果路径是目录而不是文件,设置为 None + warnings.append(f"说话人{i+1}参考语音路径是目录而非文件: {audio_path}") + audio_path = None + else: + audio_path = None + normalized_speakers.append( + dict( + audio=audio_path, + text=spk.get("prompt_text", "") or "", + dialect=spk.get("dialect_prompt_text", "") or "", + remark=spk.get("remark", "") or "", + ) + ) + else: + normalized_speakers.append( + dict(audio=None, text="", dialect="", remark="") + ) + + # speaker checkboxes (在备注字段更新后,使用最新的备注值更新标签) + # 注意:顺序必须与 interface.py 中的 outputs 列表匹配 + for i in range(MAX_SPEAKERS): + if i < num_speakers: + remark_val = normalized_speakers[i]["remark"] + result.append( + gr.update( + visible=True, + value=False, + label=get_speaker_display_label(i + 1, remark_val), + ) + ) + else: + result.append(gr.update(visible=False, value=False)) + + # speaker audio, text, dialect, remark (先更新这些字段,以便后续标签更新能读取到正确的备注值) + audio_updates = [] + text_updates = [] + dialect_updates = [] + remark_updates = [] + for i in range(MAX_SPEAKERS): + spk = normalized_speakers[i] + audio_updates.append(gr.update(value=spk["audio"])) + text_updates.append(gr.update(value=spk["text"])) + dialect_updates.append(gr.update(value=spk["dialect"])) + remark_updates.append(gr.update(value=spk["remark"])) + result.extend(audio_updates) + result.extend(text_updates) + result.extend(dialect_updates) + result.extend(remark_updates) + + # speaker tabs (在备注字段更新后,使用最新的备注值更新标签) + for i in range(MAX_SPEAKERS): + result.append( + gr.update( + visible=(i < num_speakers), + label=get_speaker_display_label(i + 1, normalized_speakers[i]["remark"]), + ) + ) + + # dialogue text inputs + for i in range(MAX_TEXT_INPUTS): + if i < num_text_inputs: + t = text_inputs[i] if i < len(text_inputs) else "" + result.append(gr.update(visible=True, value=t)) + else: + result.append(gr.update(visible=False, value="")) + + warn_text = "" + if warnings: + warn_text = "⚠️ 加载完成,但有部分资源缺失:\n- " + "\n- ".join(warnings) + return result, warn_text + + +def _create_empty_updates(): + """Create empty updates for error cases.""" + empty_updates = [] + empty_updates.extend([ + 1, # speakers_state + 1, # num_text_inputs_state + gr.update(value=1), # num_text_inputs_selector + gr.update(value=1988), # seed_input + gr.update(value=0), # diff_spk_pause_input + gr.update(value=500), # task_pause_input + ]) + for _ in range(MAX_SPEAKERS): + empty_updates.append(gr.update(visible=False, value=False)) # checkboxes + for _ in range(MAX_SPEAKERS): + empty_updates.append(gr.update(value=None)) # audio + for _ in range(MAX_SPEAKERS): + empty_updates.append(gr.update(value="")) # text + for _ in range(MAX_SPEAKERS): + empty_updates.append(gr.update(value="")) # dialect + for _ in range(MAX_SPEAKERS): + empty_updates.append(gr.update(value="")) # remark + for i in range(MAX_SPEAKERS): + empty_updates.append(gr.update(visible=False, label=get_speaker_display_label(i + 1))) # tabs + for _ in range(MAX_TEXT_INPUTS): + empty_updates.append(gr.update(visible=False, value="")) + return empty_updates + + +def load_uploaded_and_apply(file_obj): + """Load configuration from uploaded file and apply it.""" + path = coerce_gradio_file_to_path(file_obj) + if not path or not os.path.exists(path): + gr.Warning("请先选择一个 JSON 配置文件") + empty_updates = _create_empty_updates() + return (*empty_updates, "未选择文件,无法加载。") + + try: + cfg = read_json_file(path) + except Exception as e: + gr.Warning(f"读取配置失败: {e}") + empty_updates = _create_empty_updates() + return (*empty_updates, f"读取配置失败: {e}") + + # 自动保存到 config/ 目录 + ensure_config_dir() + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + base_name = os.path.basename(path) + safe_name = base_name if base_name.lower().endswith(".json") else f"{base_name}.json" + save_name = f"uploaded_{ts}_{safe_name}" + save_path = os.path.join(CONFIG_DIR, save_name) + if os.path.exists(save_path): + save_path = os.path.join(CONFIG_DIR, f"uploaded_{ts}_{uuid.uuid4().hex[:8]}_{safe_name}") + try: + write_json_file(save_path, cfg) + except Exception as e: + gr.Warning(f"保存配置到 config/ 失败: {e}") + + updates, warn_text = apply_loaded_config(cfg) + status = f"✅ 已加载配置: {os.path.abspath(save_path)}" + if warn_text: + status += f"\n\n{warn_text}" + status += '\n(若要在下拉菜单中看到新文件,请点击"刷新列表")' + return (*updates, status) + + +def load_selected_and_apply(selected_filename: str): + """Load configuration from selected dropdown file and apply it.""" + if not selected_filename: + gr.Warning("请先在下拉菜单选择一个配置文件") + empty_updates = _create_empty_updates() + return (*empty_updates, "未选择配置文件,无法加载。") + + path = os.path.join(CONFIG_DIR, selected_filename) + if not os.path.exists(path): + gr.Warning(f"配置文件不存在: {path}") + empty_updates = _create_empty_updates() + return (*empty_updates, f"配置文件不存在: {path}") + + try: + cfg = read_json_file(path) + except Exception as e: + gr.Warning(f"读取配置失败: {e}") + empty_updates = _create_empty_updates() + return (*empty_updates, f"读取配置失败: {e}") + + updates, warn_text = apply_loaded_config(cfg) + status = f"✅ 已加载配置: {os.path.abspath(path)}" + if warn_text: + status += f"\n\n{warn_text}" + return (*updates, status) + diff --git a/webui/constants.py b/webui/constants.py new file mode 100644 index 0000000..022e26c --- /dev/null +++ b/webui/constants.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +""" +Constants and configuration data for SoulX-Podcast WebUI. +""" + +import os +from typing import Dict, List + +# ============================================================================= +# Path Constants +# ============================================================================= + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CONFIG_DIR = os.path.join(BASE_DIR, "config") + +S1_PROMPT_WAV = "example/audios/female_mandarin.wav" +S2_PROMPT_WAV = "example/audios/male_mandarin.wav" + +# ============================================================================= +# UI Configuration Constants +# ============================================================================= + +MAX_SPEAKERS = 10 +MAX_TEXT_INPUTS = 10 + +# ============================================================================= +# Dialect Data +# ============================================================================= + +def load_dialect_prompt_data() -> Dict[str, Dict[str, str]]: + """ + 加载方言提示文本文件并格式化为嵌套字典。 + 返回结构: {dialect_key: {display_name: full_text, ...}, ...} + """ + dialect_data = {} + + dialect_files = [ + ("sichuan", "example/dialect_prompt/sichuan.txt", "<|Sichuan|>"), + ("yueyu", "example/dialect_prompt/yueyu.txt", "<|Yue|>"), + ("henan", "example/dialect_prompt/henan.txt", "<|Henan|>"), + ] + + for key, file_path, prefix in dialect_files: + dialect_data[key] = {"(无)": ""} + try: + full_path = os.path.join(BASE_DIR, file_path) + with open(full_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + for i, line in enumerate(lines): + line = line.strip() + if line: + full_text = f"{prefix}{line}" + display_name = f"例{i+1}: {line[:20]}..." + dialect_data[key][display_name] = full_text + except FileNotFoundError: + print(f"[WARNING] 方言文件未找到: {file_path}") + except Exception as e: + print(f"[WARNING] 读取方言文件失败 {file_path}: {e}") + + return dialect_data + + +DIALECT_PROMPT_DATA = load_dialect_prompt_data() +DIALECT_CHOICES = ["(无)", "sichuan", "yueyu", "henan"] + +# ============================================================================= +# Examples Data +# ============================================================================= + +EXAMPLES_LIST: List[List] = [ + [ + None, "", "", None, "", "", None, "", "", None, "", "", "" + ], + [ + S1_PROMPT_WAV, + "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", + "", + S2_PROMPT_WAV, + "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", + "", + None, "", "", + None, "", "", + "[S1] 哈喽,AI时代的冲浪先锋们!欢迎收听《AI生活进行时》。啊,一个充满了未来感,然后,还有一点点,<|laughter|>神经质的播客节目,我是主持人小希。\n[S2] 哎,大家好呀!我是能唠,爱唠,天天都想唠的唠嗑!\n[S1] 最近活得特别赛博朋克哈!以前老是觉得AI是科幻片儿里的,<|sigh|> 现在,现在连我妈都用AI写广场舞文案了。\n[S2] 这个例子很生动啊。是的,特别是生成式AI哈,感觉都要炸了! 诶,那我们今天就聊聊AI是怎么走进我们的生活的哈!", + ], + [ + S1_PROMPT_WAV, + "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", + "<|Sichuan|>要得要得!前头几个耍洋盘,我后脚就背起铺盖卷去景德镇耍泥巴,巴适得喊老天爷!", + S2_PROMPT_WAV, + "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", + "<|Sichuan|>哎哟喂,这个搞反了噻!黑神话里头唱曲子的王二浪早八百年就在黄土高坡吼秦腔喽,游戏组专门跑切录的原汤原水,听得人汗毛儿都立起来!", + None, "", "", + None, "", "", + "[S1] <|Sichuan|>各位《巴适得板》的听众些,大家好噻!我是你们主持人晶晶。今儿天气硬是巴适,不晓得大家是在赶路嘛,还是茶都泡起咯,准备跟我们好生摆一哈龙门阵喃?\n[S2] <|Sichuan|>晶晶好哦,大家安逸噻!我是李老倌。你刚开口就川味十足,摆龙门阵几个字一甩出来,我鼻子头都闻到茶香跟火锅香咯!\n[S1] <|Sichuan|>就是得嘛!李老倌,我前些天带个外地朋友切人民公园鹤鸣茶社坐了一哈。他硬是搞不醒豁,为啥子我们一堆人围到杯茶就可以吹一下午壳子,从隔壁子王嬢嬢娃儿耍朋友,扯到美国大选,中间还掺几盘斗地主。他说我们四川人简直是把摸鱼刻进骨子里头咯!\n[S2] <|Sichuan|>你那个朋友说得倒是有点儿趣,但他莫看到精髓噻。摆龙门阵哪是摸鱼嘛,这是我们川渝人特有的交际方式,更是一种活法。外省人天天说的松弛感,根根儿就在这龙门阵里头。今天我们就要好生摆一哈,为啥子四川人活得这么舒坦。就先从茶馆这个老窝子说起,看它咋个成了我们四川人的魂儿!", + ], + [ + S1_PROMPT_WAV, + "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", + "<|Yue|>真係冇讲错啊!攀山滑雪嘅语言专家几巴闭,都唔及我听日拖成副身家去景德镇玩泥巴,呢铺真系发哂白日梦咯!", + S2_PROMPT_WAV, + "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", + "<|Yue|>咪搞错啊!陕北民谣响度唱咗几十年,黑神话边有咁大面啊?你估佢哋抄游戏咩!", + None, "", "", + None, "", "", + "[S1] <|Yue|>哈囉大家好啊,歡迎收聽我哋嘅節目。喂,我今日想問你樣嘢啊,你覺唔覺得,嗯,而家揸電動車,最煩,最煩嘅一樣嘢係咩啊?\n[S2] <|Yue|>梗係充電啦。大佬啊,搵個位都已經好煩,搵到個位仲要喺度等,你話快極都要半個鐘一個鐘,真係,有時諗起都覺得好冇癮。\n[S1] <|Yue|>係咪先。如果我而家同你講,充電可以快到同入油差唔多時間,你信唔信先?喂你平時喺油站入滿一缸油,要幾耐啊?五六分鐘?\n[S2] <|Yue|>差唔多啦,七八分鐘,點都走得啦。電車喎,可以做到咁快?你咪玩啦。", + ], + [ + S1_PROMPT_WAV, + "喜欢攀岩、徒步、滑雪的语言爱好者,以及过两天要带着全部家当去景德镇做陶瓷的白日梦想家。", + "<|Henan|>俺这不是怕恁路上不得劲儿嘛!那景德镇瓷泥可娇贵着哩,得先拿咱河南人这实诚劲儿给它揉透喽。", + S2_PROMPT_WAV, + "呃,还有一个就是要跟大家纠正一点,就是我们在看电影的时候,尤其是游戏玩家,看电影的时候,在看到那个到西北那边的这个陕北民谣,嗯,这个可能在想,哎,是不是他是受到了黑神话的启发?", + "<|Henan|>恁这想法真闹挺!陕北民谣比黑神话早几百年都有了,咱可不兴这弄颠倒啊,中不?恁这想法真闹挺!那陕北民谣在黄土高坡响了几百年,咋能说是跟黑神话学的咧?咱得把这事儿捋直喽,中不中!", + None, "", "", + None, "", "", + "[S1] <|Henan|>哎,大家好啊,欢迎收听咱这一期嘞《瞎聊呗,就这么说》,我是恁嘞老朋友,燕子。\n[S2] <|Henan|>大家好,我是老张。燕子啊,今儿瞅瞅你这个劲儿,咋着,是有啥可得劲嘞事儿想跟咱唠唠?\n[S1] <|Henan|>哎哟,老张,你咋恁懂我嘞!我跟你说啊,最近我刷手机,老是刷住些可逗嘞方言视频,特别是咱河南话,咦~我哩个乖乖,一听我都憋不住笑,咋说嘞,得劲儿哩很,跟回到家一样。\n[S2] <|Henan|>你这回可算说到根儿上了!河南话,咱往大处说说,中原官话,它真嘞是有一股劲儿搁里头。它可不光是说话,它脊梁骨后头藏嘞,是咱一整套、鲜鲜活活嘞过法儿,一种活人嘞道理。\n[S1] <|Henan|>活人嘞道理?哎,这你这一说,我嘞兴致'腾'一下就上来啦!觉住咱这嗑儿,一下儿从搞笑视频蹿到文化顶上了。那你赶紧给我白话白话,这里头到底有啥道道儿?我特别想知道——为啥一提起咱河南人,好些人脑子里'蹦'出来嘞头一个词儿,就是实在?这个实在,骨子里到底是啥嘞?", + ], +] + diff --git a/webui/file_manager.py b/webui/file_manager.py new file mode 100644 index 0000000..33df3db --- /dev/null +++ b/webui/file_manager.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +File management utilities for SoulX-Podcast WebUI. +Handles config file operations and ZIP file creation. +""" + +import os +import json +import zipfile +from datetime import datetime +from typing import List, Optional + +from .constants import CONFIG_DIR +from .i18n import i18n + + +# ============================================================================= +# Config Directory Operations +# ============================================================================= + +def ensure_config_dir(): + """Ensure the config directory exists.""" + os.makedirs(CONFIG_DIR, exist_ok=True) + + +def list_config_files() -> List[str]: + """返回 config/ 下的 JSON 文件名列表(仅文件名,不含路径)。""" + ensure_config_dir() + try: + files = [f for f in os.listdir(CONFIG_DIR) if f.lower().endswith(".json")] + except Exception: + files = [] + # 按文件名倒序(通常包含时间戳) + return sorted(files, reverse=True) + + +def read_json_file(path: str) -> dict: + """Read a JSON file and return its contents.""" + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def write_json_file(path: str, data: dict): + """Write data to a JSON file atomically.""" + tmp_path = f"{path}.tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp_path, path) + + +# ============================================================================= +# ZIP File Operations +# ============================================================================= + +def create_zip_file( + file_list: List[str], + output_dir: str, + timestamp: str = None, + file_number: int = None +) -> Optional[str]: + """ + 创建包含所有文件的 zip 压缩包 + + Args: + file_list: 要打包的文件路径列表 + output_dir: 输出目录 + timestamp: 时间戳(如果不提供则自动生成) + file_number: 文件序号(用于文件名前缀) + + Returns: + zip 文件路径,如果失败则返回 None + """ + if not file_list: + return None + + try: + if timestamp is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + + # 如果提供了文件序号,则在文件名前添加序号前缀 + if file_number is not None: + zip_filename = os.path.join(output_dir, f"{file_number:03d}_all_audio_files.zip") + else: + zip_filename = os.path.join(output_dir, "all_audio_files.zip") + + with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + for file_path in file_list: + if os.path.exists(file_path): + # 只保存文件名,不包含完整路径 + arcname = os.path.basename(file_path) + zipf.write(file_path, arcname) + print(f"[INFO] {i18n('log_file_added_to_zip').format(filename=arcname)}") + + print(f"[INFO] {i18n('log_zip_created').format(filename=zip_filename)}") + return zip_filename + except Exception as e: + print(f"[ERROR] {i18n('log_error_creating_zip').format(error=str(e))}") + import traceback + traceback.print_exc() + return None + + +def create_all_zip(base_output_dir: str, all_files: List[str]) -> Optional[str]: + """ + 创建包含所有任务文件的 all.zip 压缩包,保持目录结构 + + Args: + base_output_dir: 基础输出目录(时间戳文件夹) + all_files: 所有要打包的文件路径列表 + + Returns: + all.zip 文件路径,如果失败则返回 None + """ + if not all_files: + return None + + try: + zip_filename = os.path.join(base_output_dir, "all.zip") + + with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + for file_path in all_files: + if os.path.exists(file_path): + # 保持相对路径结构,相对于base_output_dir + # 使用 os.path.relpath 更安全,可以处理各种路径格式 + try: + arcname = os.path.relpath(file_path, base_output_dir) + # 确保路径使用正斜杠(zip文件标准) + arcname = arcname.replace(os.sep, '/') + except ValueError: + # 如果文件不在同一驱动器上(Windows),使用文件名 + arcname = os.path.basename(file_path) + zipf.write(file_path, arcname) + print(f"[INFO] 已添加文件到 all.zip: {arcname}") + + print(f"[INFO] 已创建 all.zip: {zip_filename}") + return zip_filename + except Exception as e: + print(f"[ERROR] 创建 all.zip 时出错: {str(e)}") + import traceback + traceback.print_exc() + return None + diff --git a/webui/i18n.py b/webui/i18n.py new file mode 100644 index 0000000..88a6a97 --- /dev/null +++ b/webui/i18n.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +""" +Internationalization (i18n) support for SoulX-Podcast WebUI. +""" + +from typing import Literal + +# ============================================================================= +# Global Language State +# ============================================================================= + +global_lang: Literal["zh", "en"] = "zh" + + +def set_language(lang: Literal["zh", "en"]): + """Set the global language.""" + global global_lang + global_lang = lang + + +def get_language() -> Literal["zh", "en"]: + """Get the current global language.""" + global global_lang + return global_lang + + +# ============================================================================= +# Internationalization Dictionary +# ============================================================================= + +_i18n_key2lang_dict = dict( + # Speaker1 Prompt + spk1_prompt_audio_label=dict( + en="Speaker 1 Prompt Audio", + zh="说话人 1 参考语音", + ), + spk1_prompt_text_label=dict( + en="Speaker 1 Prompt Text", + zh="说话人 1 参考文本", + ), + spk1_prompt_text_placeholder=dict( + en="text of speaker 1 Prompt audio.", + zh="说话人 1 参考文本", + ), + spk1_dialect_prompt_text_label=dict( + en="Speaker 1 Dialect Prompt Text", + zh="说话人 1 方言提示文本", + ), + spk1_dialect_prompt_text_placeholder=dict( + en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", + zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", + ), + # Speaker2 Prompt + spk2_prompt_audio_label=dict( + en="Speaker 2 Prompt Audio", + zh="说话人 2 参考语音", + ), + spk2_prompt_text_label=dict( + en="Speaker 2 Prompt Text", + zh="说话人 2 参考文本", + ), + spk2_prompt_text_placeholder=dict( + en="text of speaker 2 prompt audio.", + zh="说话人 2 参考文本", + ), + spk2_dialect_prompt_text_label=dict( + en="Speaker 2 Dialect Prompt Text", + zh="说话人 2 方言提示文本", + ), + spk2_dialect_prompt_text_placeholder=dict( + en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", + zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", + ), + # Speaker3 Prompt + spk3_prompt_audio_label=dict( + en="Speaker 3 Prompt Audio", + zh="说话人 3 参考语音", + ), + spk3_prompt_text_label=dict( + en="Speaker 3 Prompt Text", + zh="说话人 3 参考文本", + ), + spk3_prompt_text_placeholder=dict( + en="text of speaker 3 Prompt audio.", + zh="说话人 3 参考文本", + ), + spk3_dialect_prompt_text_label=dict( + en="Speaker 3 Dialect Prompt Text", + zh="说话人 3 方言提示文本", + ), + spk3_dialect_prompt_text_placeholder=dict( + en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", + zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", + ), + # Speaker4 Prompt + spk4_prompt_audio_label=dict( + en="Speaker 4 Prompt Audio", + zh="说话人 4 参考语音", + ), + spk4_prompt_text_label=dict( + en="Speaker 4 Prompt Text", + zh="说话人 4 参考文本", + ), + spk4_prompt_text_placeholder=dict( + en="text of speaker 4 Prompt audio.", + zh="说话人 4 参考文本", + ), + spk4_dialect_prompt_text_label=dict( + en="Speaker 4 Dialect Prompt Text", + zh="说话人 4 方言提示文本", + ), + spk4_dialect_prompt_text_placeholder=dict( + en="Dialect prompt text with prefix: <|Sichuan|>/<|Yue|>/<|Henan|> ", + zh="带前缀方言提示词思维链文本,前缀如下:<|Sichuan|>/<|Yue|>/<|Henan|>,如:<|Sichuan|>走嘛,切吃那家新开的麻辣烫,听别个说味道硬是霸道得很,好吃到不摆了,去晚了还得排队!", + ), + # Dialogue input textbox + dialogue_text_input_label=dict( + en="Dialogue Text Input", + zh="合成文本输入", + ), + dialogue_text_input_placeholder=dict( + en="[S1]text[S2]text[S3]text... (Use [S1], [S2], [S3], etc. to specify speakers)", + zh="[S1]文本[S2]文本[S3]文本... (使用 [S1], [S2], [S3] 等指定说话人)", + ), + # Generate button + generate_btn_label=dict( + en="Generate Audio", + zh="合成", + ), + # Generated audio + generated_audio_label=dict( + en="Generated Dialogue Audio", + zh="合成的对话音频", + ), + # Warining1: invalid text for prompt + warn_invalid_spk1_prompt_text=dict( + en='Invalid speaker 1 prompt text, should not be empty and strictly follow: "xxx"', + zh='说话人 1 参考文本不合规,不能为空,格式:"xxx"', + ), + warn_invalid_spk2_prompt_text=dict( + en='Invalid speaker 2 prompt text, should strictly follow: "[S2]xxx"', + zh='说话人 2 参考文本不合规,格式:"[S2]xxx"', + ), + warn_invalid_dialogue_text=dict( + en='Invalid dialogue input text, should strictly follow: "[S1]xxx[S2]xxx..."', + zh='对话文本输入不合规,格式:"[S1]xxx[S2]xxx..."', + ), + # Warining3: incomplete prompt info + warn_incomplete_prompt=dict( + en="Please provide prompt audio and text for all speakers used in the dialogue", + zh="请为对话中使用的所有说话人提供参考语音与参考文本", + ), + # Speaker manage controls + add_speaker_btn_label=dict( + en="Add 1 Speaker", + zh="添加1个说话人", + ), + quick_add_num_label=dict( + en="Quick Add Count", + zh="快速添加数量", + ), + quick_add_btn_label=dict( + en="Quick Add", + zh="快速添加", + ), + select_all_btn_label=dict( + en="Select All", + zh="全选", + ), + select_none_btn_label=dict( + en="Select None", + zh="全不选", + ), + batch_delete_btn_label=dict( + en="Delete Selected", + zh="批量删除选中", + ), + # Separated audio files info + separated_files_info_label=dict( + en="Separated Audio Files Info", + zh="分离音频文件信息", + ), + separated_files_info_placeholder=dict( + en="Separated speaker audio files will be saved in outputs/separated_speakers/ directory", + zh="分离的说话者音频文件将保存在 outputs/separated_speakers/ 目录下", + ), + # Download files + download_all_files_label=dict( + en="Download All Audio Files (ZIP)", + zh="下载所有音频文件 (ZIP)", + ), + # File info messages + files_saved_to=dict( + en="Audio files saved to:", + zh="音频文件已保存到:", + ), + files_generated_count=dict( + en="Files generated this time (total: {count}):", + zh="本次生成的文件 (共 {count} 个):", + ), + complete_dialogue_audio=dict( + en="Complete Dialogue Audio", + zh="整体对话音频", + ), + speaker_label=dict( + en="Speaker {num}", + zh="说话者 {num}", + ), + complete_audio_label=dict( + en="(Complete Audio)", + zh="(完整音频)", + ), + zip_file_created=dict( + en="Zip file created: {filename}", + zh="压缩包已创建: {filename}", + ), + download_hint=dict( + en="(You can download all files below)", + zh="(可在下方下载所有文件)", + ), + no_files_saved=dict( + en="(No files saved, may be disabled or error occurred)", + zh="(未保存文件,可能已禁用或出现错误)", + ), + # Different speaker pause + diff_spk_pause_label=dict( + en="Different-speaker pause (ms)", + zh="不同说话者间停顿(ms)", + ), + task_pause_label=dict( + en="Task interval pause (ms)", + zh="任务间停顿(ms)", + ), + # Log messages (for console) + log_saved_complete_dialogue=dict( + en="Saved complete dialogue audio", + zh="已保存整体对话音频", + ), + log_saved_speaker_complete=dict( + en="Saved speaker {num} complete audio", + zh="已保存说话者 {num} 完整音频", + ), + log_saved_speaker_part=dict( + en="Saved speaker {num} part {part}", + zh="已保存说话者 {num} 片段 {part}", + ), + log_all_files_saved=dict( + en="All audio files saved to: {dir}", + zh="所有音频文件已保存到: {dir}", + ), + log_total_files_saved=dict( + en="Total {count} files saved", + zh="共保存 {count} 个文件", + ), + log_file_added_to_zip=dict( + en="Added file to zip: {filename}", + zh="已添加文件到压缩包: {filename}", + ), + log_zip_created=dict( + en="Zip file created: {filename}", + zh="压缩包已创建: {filename}", + ), + log_error_saving_files=dict( + en="Error saving audio files: {error}", + zh="保存音频文件时出错: {error}", + ), + log_error_creating_zip=dict( + en="Error creating zip file: {error}", + zh="创建压缩包时出错: {error}", + ), +) + + +# ============================================================================= +# i18n Functions +# ============================================================================= + +def i18n(key: str) -> str: + """Get internationalized text for a given key.""" + global global_lang + if key in _i18n_key2lang_dict: + return _i18n_key2lang_dict[key][global_lang] + return key + + +def get_i18n_dict() -> dict: + """Get the full i18n dictionary (for checking key existence).""" + return _i18n_key2lang_dict + + +def get_select_speaker_label(idx: int) -> str: + """返回带语言的 选择说话人/Select Speaker 标签。""" + global global_lang + if global_lang == "en": + return f"Select Speaker {idx}" + return f"选择说话人 {idx}" + + +def get_speaker_display_label(idx: int, remark: str = "") -> str: + """ + 返回用于 Tab/复选框显示的标签。 + 若有备注,则显示为 S{idx}:备注,否则回退到语言化的“选择说话人 {idx}”。 + """ + remark_clean = (remark or "").strip() + if remark_clean: + return f"S{idx}:{remark_clean}" + return get_select_speaker_label(idx) + diff --git a/webui/interface.py b/webui/interface.py new file mode 100644 index 0000000..81106a6 --- /dev/null +++ b/webui/interface.py @@ -0,0 +1,614 @@ +# -*- coding: utf-8 -*- +""" +Main interface rendering for SoulX-Podcast WebUI. +""" + +import os +import gradio as gr + +from .constants import MAX_SPEAKERS, MAX_TEXT_INPUTS +from .i18n import i18n, get_i18n_dict, get_speaker_display_label, get_language +from .file_manager import list_config_files +from .components import create_speaker_group +from .callbacks import ( + add_speaker, + quick_add_speakers, + batch_delete_speakers, + select_all_checkboxes, + select_none_checkboxes, + update_text_inputs_visibility, + collect_and_synthesize_queue, + change_component_language, + update_single_speaker_label, + update_speaker_accordion_label, + update_speaker_selection_choices, + selection_group_to_checkboxes, + select_all_selection_group, + select_none_selection_group, + refresh_all_speaker_labels_after_load, +) +from .config_manager import ( + export_current_config, + refresh_config_dropdown, + load_uploaded_and_apply, + load_selected_and_apply, +) + +# Custom CSS for better UI +CSS = """ +.container { max_width: 1400px; margin: auto; } +.header-row { align-items: center; margin-bottom: 20px; border-bottom: 1px solid #eee; padding-bottom: 10px; } +.header-logo { height: 50px; object-fit: contain; } +.section-header { margin-top: 10px; margin-bottom: 5px; font-size: 1.1em; font-weight: bold; color: #444; } +.generate-btn { font-size: 1.3em !important; font-weight: bold !important; min-height: 80px !important; } +.tab-nav { border-bottom: none !important; } +""" + +def render_interface() -> gr.Blocks: + """Render the main Gradio interface.""" + _i18n_key2lang_dict = get_i18n_dict() + + with gr.Blocks(title="SoulX-Podcast", theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="slate"), css=CSS) as page: + + # ================= Header ================= + with gr.Row(elem_classes=["header-row", "container"]): + with gr.Column(scale=8): + gr.Markdown("# 🎙️ SoulX-Podcast WebUI") + with gr.Column(scale=4, min_width=200): + gr.Markdown("[📖 帮助文档](https://github.com/Y-G-Q/SoulX-Podcast) | [🔗 GitHub](https://github.com/Y-G-Q/SoulX-Podcast)") + + # ================= Main Content ================= + with gr.Row(elem_classes=["container"]): + + # ================= LEFT COLUMN: Production Workshop (70%) ================= + with gr.Column(scale=7): + + # --- 1. Speaker Settings (Tabs) --- + # Initialize with default speaker label + initial_accordion_label = "👥 说话人设置 / Speakers (选择说话人 1)" + with gr.Accordion(initial_accordion_label, open=False) as speaker_accordion: + speakers_state = gr.State(value=1) + + speaker_checkbox_list = [] + speaker_remark_list = [] + speaker_audio_list = [] + speaker_text_list = [] + speaker_dialect_list = [] + speaker_tabs_list = [] # List of Tab components to toggle visibility + + # 操作区置顶 + with gr.Row(): + add_speaker_btn = gr.Button(f"➕ {i18n('add_speaker_btn_label')}", variant="secondary", scale=2) + with gr.Group(): + with gr.Row(): + quick_add_num = gr.Number( + label="", + value=1, + minimum=1, + maximum=MAX_SPEAKERS, + step=1, + precision=0, + scale=1, + container=False, + min_width=60 + ) + quick_add_btn = gr.Button(f"🚀 {i18n('quick_add_btn_label')}", variant="primary", scale=2, min_width=80) + + batch_delete_btn = gr.Button(f"🗑️ {i18n('batch_delete_btn_label')}", variant="stop", scale=1) + + # Select all/none buttons (small) + select_all_btn = gr.Button(f"☑️", variant="secondary", scale=0, min_width=50) + select_none_btn = gr.Button(f"☐", variant="secondary", scale=0, min_width=50) + + speaker_selection_group = gr.CheckboxGroup( + label="快速勾选要删除的说话人", + choices=[get_speaker_display_label(1)], + value=[], + interactive=True, + ) + + with gr.Tabs() as speaker_tabs_container: + for i in range(MAX_SPEAKERS): + tab_label = get_speaker_display_label(i + 1) + with gr.Tab(label=tab_label, visible=(i < 1)) as tab: + group, checkbox, remark, audio, text, dialect = create_speaker_group(i + 1) + speaker_checkbox_list.append(checkbox) + speaker_remark_list.append(remark) + speaker_audio_list.append(audio) + speaker_text_list.append(text) + speaker_dialect_list.append(dialect) + speaker_tabs_list.append(tab) + + # 备注变化时同步更新 Tab 与复选框标签,以及 Accordion 标题 + idx_state = gr.State(i + 1) + remark.change( + fn=update_single_speaker_label, + inputs=[remark, idx_state], + outputs=[checkbox, tab], + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + # --- 2. Dialogue Input --- + gr.Markdown("### 📝 对话内容 / Dialogue", elem_classes=["section-header"]) + + num_text_inputs_state = gr.State(value=1) + + # Number of inputs selector + with gr.Row(): + num_text_inputs_selector = gr.Number( + label="输入框数量 / Number of Inputs", + value=1, + minimum=1, + maximum=MAX_TEXT_INPUTS, + step=1, + precision=0, + interactive=True, + scale=1, + ) + diff_spk_pause_input = gr.Number( + label="不同说话人停顿(ms)", + value=0, + minimum=0, + step=50, + interactive=True, + scale=1, + ) + task_pause_input = gr.Number( + label="任务间停顿(ms)", + value=500, + minimum=0, + step=100, + interactive=True, + scale=1, + ) + + # Main text input area + dialogue_text_inputs_list = [] + dialogue_audio_preview_list = [] # Audio previews for each task + dialogue_download_list = [] # Download files for each task + + # We still need the list for the backend logic + with gr.Group(): + for i in range(MAX_TEXT_INPUTS): + dialogue_text_input = gr.Code( + label=f"{i18n('dialogue_text_input_label')} {i+1}", + value="", + language="javascript", + lines=6, + max_lines=12, + interactive=True, + show_line_numbers=True, + visible=(i < 1), + ) + dialogue_text_inputs_list.append(dialogue_text_input) + + # Audio preview component - directly below the text input + # The preview will show audio when it's generated, no progress info + preview = gr.Audio( + label=f"任务 {i+1} 音频预览" if get_language() == "zh" else f"Task {i+1} Audio Preview", + visible=(i < 1), # Same visibility as text input + interactive=False, + show_download_button=True, + value=None # No audio initially + ) + download = gr.File( + label=f"任务 {i+1} 下载" if get_language() == "zh" else f"Task {i+1} Download", + visible=False + ) + dialogue_audio_preview_list.append(preview) + dialogue_download_list.append(download) + + # Update inputs visibility when number changes + num_text_inputs_selector.change( + fn=update_text_inputs_visibility, + inputs=[num_text_inputs_selector], + outputs=[num_text_inputs_state] + dialogue_text_inputs_list + dialogue_audio_preview_list + dialogue_download_list + ) + + # --- 3. Generate Button --- + gr.Markdown("### ⚙️ 生成 / Generate", elem_classes=["section-header"]) + + generate_btn = gr.Button( + value=i18n("generate_btn_label"), + variant="primary", + elem_classes=["generate-btn"], + ) + + # ================= RIGHT COLUMN: Finished Goods Warehouse (30%) ================= + with gr.Column(scale=3): + + # --- Config Management (Collapsed Menu) --- + with gr.Accordion("🛠️ 配置管理 / Config", open=False): + gr.Markdown("**导入配置**") + config_file_choices = list_config_files() + with gr.Tabs(): + with gr.Tab("选择预设"): + config_dropdown = gr.Dropdown( + label="选择配置文件", + choices=config_file_choices, + value=config_file_choices[0] if config_file_choices else None, + interactive=True + ) + with gr.Row(): + refresh_config_list_btn = gr.Button("刷新", size="sm") + load_selected_config_btn = gr.Button("加载", variant="primary", size="sm") + with gr.Tab("上传文件"): + import_config_uploader = gr.File(label="JSON文件", file_types=[".json"]) + load_uploaded_config_btn = gr.Button("加载上传", variant="primary", size="sm") + load_selected_status = gr.Textbox(label="加载状态", interactive=False, lines=2) + load_uploaded_status = gr.Textbox(visible=False) # Hidden status for upload + + gr.Markdown("---") + gr.Markdown("**导出配置**") + export_config_name_input = gr.Textbox( + label="配置名称(可选)", + placeholder="留空将使用默认名称", + lines=1, + interactive=True + ) + with gr.Row(): + export_config_btn = gr.Button("导出当前配置", size="sm") + export_config_file = gr.File(label="导出文件", interactive=False, height=50) + export_config_status = gr.Textbox(label="状态", interactive=False, lines=1, show_label=False) + + # --- Global Settings (Collapsed Menu) --- + with gr.Accordion("⚙️ 全局设置 / Global Settings", open=False): + lang_choice = gr.Dropdown( + choices=["中文", "English"], + value="中文", + label="语言/Language", + interactive=True, + scale=1 + ) + seed_input = gr.Number( + label="Seed (种子)", + value=1988, + step=1, + interactive=True, + scale=1, + ) + + # --- Output Area --- + gr.Markdown("### 🔊 当前结果 / Output", elem_classes=["section-header"]) + + # Generate button above output area + generate_btn_right = gr.Button( + value=i18n("generate_btn_label"), + variant="primary", + elem_classes=["generate-btn"], + ) + + generate_audio = gr.Audio( + label="完整音频", + interactive=False, + show_download_button=True + ) + + # --- History / Details --- + gr.Markdown("### 📜 历史记录 / History", elem_classes=["section-header"]) + + # Using the textbox to show details/history log as requested in wireframe logic (list) + # But since we don't have a real list component backed by data, we keep the textbox info + # and maybe the download file. + + separated_files_info = gr.Textbox( + label="生成日志", + show_label=False, + interactive=False, + lines=20, + visible=True, + elem_id="history-log" + ) + + download_file = gr.File( + label="下载全部 (ZIP)", + visible=False, + ) + + # ================= Event Handlers ================= + + # Speaker Management + # Note: We pass speaker_tabs_list instead of speaker_columns to toggle visibility of Tabs + add_speaker_btn.click( + fn=add_speaker, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speakers_state] + speaker_checkbox_list + speaker_tabs_list + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + # Keep quick add logic compatible + quick_add_btn.click( + fn=quick_add_speakers, + inputs=[speakers_state, quick_add_num] + speaker_remark_list, + outputs=[speakers_state] + speaker_checkbox_list + speaker_tabs_list + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + select_all_btn.click( + fn=select_all_checkboxes, + inputs=[speakers_state], + outputs=speaker_checkbox_list + ).then( + fn=select_all_selection_group, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + select_none_btn.click( + fn=select_none_checkboxes, + inputs=[speakers_state], + outputs=speaker_checkbox_list + ).then( + fn=select_none_selection_group, + outputs=[speaker_selection_group], + ) + + # Batch Delete + all_speaker_inputs_for_delete = [] + for i in range(MAX_SPEAKERS): + all_speaker_inputs_for_delete.extend([ + speaker_checkbox_list[i], + speaker_audio_list[i], + speaker_text_list[i], + speaker_dialect_list[i], + speaker_remark_list[i], + ]) + + all_speaker_outputs_for_delete = [] + for i in range(MAX_SPEAKERS): + all_speaker_outputs_for_delete.extend([ + speaker_checkbox_list[i], + speaker_audio_list[i], + speaker_text_list[i], + speaker_dialect_list[i], + speaker_remark_list[i], + ]) + + batch_delete_btn.click( + fn=batch_delete_speakers, + inputs=[speakers_state] + all_speaker_inputs_for_delete, + outputs=[speakers_state] + all_speaker_outputs_for_delete + speaker_tabs_list + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + # 快捷勾选同步 + speaker_selection_group.change( + fn=selection_group_to_checkboxes, + inputs=[speaker_selection_group, speakers_state] + speaker_remark_list, + outputs=speaker_checkbox_list, + ) + + # Config Events + all_speaker_inputs_for_config = [] + for i in range(MAX_SPEAKERS): + all_speaker_inputs_for_config.extend([ + speaker_audio_list[i], + speaker_text_list[i], + speaker_dialect_list[i], + speaker_remark_list[i], + ]) + + export_config_btn.click( + fn=export_current_config, + inputs=[ + lang_choice, + seed_input, + diff_spk_pause_input, + task_pause_input, + speakers_state, + num_text_inputs_state, + export_config_name_input, + *dialogue_text_inputs_list, + *all_speaker_inputs_for_config, + ], + outputs=[export_config_file, export_config_status], + ) + + refresh_config_list_btn.click( + fn=refresh_config_dropdown, + inputs=[config_dropdown], + outputs=[config_dropdown], + ) + + load_uploaded_config_btn.click( + fn=load_uploaded_and_apply, + inputs=[import_config_uploader], + outputs=[ + speakers_state, + num_text_inputs_state, + num_text_inputs_selector, + seed_input, + diff_spk_pause_input, + task_pause_input, + *speaker_checkbox_list, + *speaker_audio_list, + *speaker_text_list, + *speaker_dialect_list, + *speaker_remark_list, + *speaker_tabs_list, # Updated to tabs + *dialogue_text_inputs_list, + load_uploaded_status, + ], + ).then( + fn=refresh_all_speaker_labels_after_load, + inputs=[speakers_state] + speaker_remark_list, + outputs=[*speaker_checkbox_list, *speaker_tabs_list], + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + load_selected_config_btn.click( + fn=load_selected_and_apply, + inputs=[config_dropdown], + outputs=[ + speakers_state, + num_text_inputs_state, + num_text_inputs_selector, + seed_input, + diff_spk_pause_input, + task_pause_input, + *speaker_checkbox_list, + *speaker_audio_list, + *speaker_text_list, + *speaker_dialect_list, + *speaker_remark_list, + *speaker_tabs_list, # Updated to tabs + *dialogue_text_inputs_list, + load_selected_status, + ], + ).then( + fn=refresh_all_speaker_labels_after_load, + inputs=[speakers_state] + speaker_remark_list, + outputs=[*speaker_checkbox_list, *speaker_tabs_list], + ).then( + fn=update_speaker_accordion_label, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_accordion], + ).then( + fn=update_speaker_selection_choices, + inputs=[speakers_state] + speaker_remark_list, + outputs=[speaker_selection_group], + ) + + # Generate Events + all_speaker_inputs = [] + for i in range(MAX_SPEAKERS): + all_speaker_inputs.extend([ + speaker_audio_list[i], + speaker_text_list[i], + speaker_dialect_list[i], + speaker_remark_list[i] # 添加备注信息 + ]) + + # Left column generate button + generate_btn.click( + fn=collect_and_synthesize_queue, + inputs=( + [num_text_inputs_state] + + [speakers_state, seed_input, diff_spk_pause_input, task_pause_input, lang_choice] + + dialogue_text_inputs_list + + all_speaker_inputs + ), + outputs=[ + generate_audio, + separated_files_info, + download_file, + generate_btn, + generate_btn_right, + *dialogue_audio_preview_list, + *dialogue_download_list, + ], + ) + + # Right column generate button (same function) + generate_btn_right.click( + fn=collect_and_synthesize_queue, + inputs=( + [num_text_inputs_state] + + [speakers_state, seed_input, diff_spk_pause_input, task_pause_input, lang_choice] + + dialogue_text_inputs_list + + all_speaker_inputs + ), + outputs=[ + generate_audio, + separated_files_info, + download_file, + generate_btn, + generate_btn_right, + *dialogue_audio_preview_list, + *dialogue_download_list, + ], + ) + + # Language Switch + # Note: We need to update this to handle the new component structure if necessary + # The change_component_language function returns a long list of updates. + # We need to make sure the inputs/outputs match exactly what that function expects. + # Since I changed some components (like Tabs instead of Columns), I should check if + # change_component_language updates visibility of columns. + + # Checking callbacks.py: change_component_language returns updates for labels mainly. + # It does NOT seem to return updates for the speaker columns/tabs visibility directly, + # but it returns updates for labels of inputs. + # Let's verify the list length. + + # The function returns: + # checkbox_updates (MAX_SPEAKERS) + # input_updates (MAX_SPEAKERS * 3) + # dialogue inputs (MAX_TEXT_INPUTS) + # dialogue previews/downloads (MAX_TEXT_INPUTS * 2) + # fixed updates (11 items) + + # The outputs list in the original code was: + # speaker_checkbox_list + all_speaker_inputs + dialogue_text_inputs_list + + # dialogue_audio_preview_list + dialogue_download_list + [fixed_list] + + # This structure seems preserved in my variables. + # speaker_checkbox_list is same. + # all_speaker_inputs is same. + # dialogue_text_inputs_list is same. + # ... + # So it should work fine, as it doesn't touch the Tabs/Columns themselves. + + lang_choice.change( + fn=change_component_language, + inputs=[lang_choice] + speaker_remark_list, + outputs=( + speaker_checkbox_list + + all_speaker_inputs + + dialogue_text_inputs_list + + dialogue_audio_preview_list + + dialogue_download_list + + [ + generate_btn, + generate_btn_right, # Right column generate button + generate_audio, + add_speaker_btn, + quick_add_num, # hidden but exists + quick_add_btn, # hidden but exists + select_all_btn, + select_none_btn, + batch_delete_btn, + separated_files_info, + download_file, + diff_spk_pause_input, + task_pause_input, + ] + ), + ) + + return page diff --git a/webui/synthesis.py b/webui/synthesis.py new file mode 100644 index 0000000..2b0a6ef --- /dev/null +++ b/webui/synthesis.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +""" +Core synthesis logic for SoulX-Podcast WebUI. +""" + +import re +import os +from collections import defaultdict +from datetime import datetime +from typing import List, Tuple, Optional + +import torch +import numpy as np +import gradio as gr +import s3tokenizer +import soundfile as sf + +from soulxpodcast.models.soulxpodcast import SoulXPodcast +from soulxpodcast.config import Config, SamplingParams +from soulxpodcast.utils.dataloader import PodcastInferHandler + +from .i18n import i18n +from .utils import check_dialogue_text + + +# ============================================================================= +# Global Model State +# ============================================================================= + +model: SoulXPodcast = None +dataset: PodcastInferHandler = None + + +def get_model() -> SoulXPodcast: + """Get the global model instance.""" + global model + return model + + +def get_dataset() -> PodcastInferHandler: + """Get the global dataset instance.""" + global dataset + return dataset + + +def initiate_model(config: Config, enable_tn: bool = False): + """Initialize the global model and dataset.""" + global model + if model is None: + model = SoulXPodcast(config) + + global dataset + if dataset is None: + dataset = PodcastInferHandler(model.llm.tokenizer, None, config) + + +# ============================================================================= +# Data Processing +# ============================================================================= + +def process_single( + target_text_list: List[str], + prompt_wav_list: List[str], + prompt_text_list: List[str], + use_dialect_prompt: bool, + dialect_prompt_text: List[str], +) -> dict: + """Process a single synthesis request.""" + spks, texts = [], [] + for target_text in target_text_list: + pattern = r'(\[S([1-9]|[1-9][0-9]+)\])(.+)' + match = re.match(pattern, target_text, flags=re.DOTALL) + if not match: + print(f"process_single: target_text: {target_text}, not match") + continue + spk_num = int(match.group(2)) + text = match.group(3).strip() + # print(f"process_single: target_text: \nstart{target_text}\nend, spk_num: {spk_num},\n text: \nstart{text}\nend") + spk = spk_num - 1 # S1->0, S2->1, etc. + spks.append(spk) + texts.append(text) + + # 检查是否成功解析出文本和说话人 + if not texts or not spks: + error_msg = f"process_single: 未能从 target_text_list 中解析出有效的文本或说话人。spks={spks}, texts={texts}" + print(error_msg) + raise ValueError(error_msg) + + global dataset + dataitem = { + "key": "001", + "prompt_text": prompt_text_list, + "prompt_wav": prompt_wav_list, + "text": texts, + "spk": spks, + } + if use_dialect_prompt: + dataitem.update({ + "dialect_prompt_text": dialect_prompt_text + }) + dataset.update_datasource([dataitem]) + + # assert one data only; + data = dataset[0] + if data is None: + error_msg = "process_single: dataset[0] 返回 None,数据处理失败。请检查音频文件路径和格式是否正确。" + print(error_msg) + raise ValueError(error_msg) + + prompt_mels_for_llm, prompt_mels_lens_for_llm = s3tokenizer.padding(data["log_mel"]) + spk_emb_for_flow = torch.tensor(data["spk_emb"]) + prompt_mels_for_flow = torch.nn.utils.rnn.pad_sequence( + data["mel"], batch_first=True, padding_value=0 + ) + prompt_mels_lens_for_flow = torch.tensor(data['mel_len']) + text_tokens_for_llm = data["text_tokens"] + prompt_text_tokens_for_llm = data["prompt_text_tokens"] + spk_ids = data["spks_list"] + sampling_params = SamplingParams(use_ras=True, win_size=25, tau_r=0.2) + infos = [data["info"]] + + processed_data = { + "prompt_mels_for_llm": prompt_mels_for_llm, + "prompt_mels_lens_for_llm": prompt_mels_lens_for_llm, + "prompt_text_tokens_for_llm": prompt_text_tokens_for_llm, + "text_tokens_for_llm": text_tokens_for_llm, + "prompt_mels_for_flow_ori": prompt_mels_for_flow, + "prompt_mels_lens_for_flow": prompt_mels_lens_for_flow, + "spk_emb_for_flow": spk_emb_for_flow, + "sampling_params": sampling_params, + "spk_ids": spk_ids, + "infos": infos, + "use_dialect_prompt": use_dialect_prompt, + } + if use_dialect_prompt: + processed_data.update({ + "dialect_prompt_text_tokens_for_llm": data["dialect_prompt_text_tokens"], + "dialect_prefix": data["dialect_prefix"], + }) + return processed_data + + +# ============================================================================= +# Core Synthesis Function +# ============================================================================= + +def dialogue_synthesis_function( + target_text: str, + speaker_configs_list: List[Tuple[str, str, str]], + seed: int = 1988, + diff_spk_pause_ms: int = 0, + output_dir: Optional[str] = None, + save_separated: bool = True, + timestamp: Optional[str] = None, +): + """ + 合成对话音频 + speaker_configs_list: 说话人配置列表,每个元素为 (prompt_text, prompt_audio, dialect_prompt_text) + output_dir: 输出目录,用于保存分离的说话者音频文件 + save_separated: 是否保存分离的说话者音频文件 + timestamp: 时间戳(如果不提供则自动生成) + """ + import random + + seed = int(seed) + torch.manual_seed(seed) + np.random.seed(seed) + random.seed(seed) + + # Check prompt info + # 首先按行分割文本,记录每个片段属于哪一行 + lines = target_text.split('\n') + + # 匹配 [S1]... 到下一个 [Sx] 或文本结尾 + pattern = r'\[S([1-9]|[1-9][0-9]+)\](.*?)(?=\[S([1-9]|[1-9][0-9]+)\]|$)' + + # 重新组合完整匹配:说话人标签 + 内容 + target_text_list: List[str] = [] + spk_seq: List[int] = [] + pause_after_ms_list: List[int] = [] + line_indices: List[int] = [] + pause_token_pattern = re.compile(r'<\|pause:(\d+)\|>') + + # 按行处理文本,记录每个片段属于哪一行 + for line_idx, line in enumerate(lines): + line = line.strip() + if not line: + continue + + matches = list(re.finditer(pattern, line, re.DOTALL)) + for match in matches: + spk_num_str = match.group(1) + content = match.group(2) + try: + spk_num_int = int(spk_num_str) + except Exception: + spk_num_int = -1 + # 按停顿标记拆分内容 + parts = re.split(r'(<\|pause:\d+\|>)', content) + last_idx_with_text = None + for p in parts: + if p is None or p == '': + continue + pause_m = pause_token_pattern.fullmatch(p.strip()) + if pause_m is not None: + if last_idx_with_text is not None: + try: + pause_ms = int(pause_m.group(1)) + except Exception: + pause_ms = 0 + pause_after_ms_list[last_idx_with_text] = max(0, pause_ms) + continue + text_part = p.strip() + if len(text_part) == 0: + continue + full_text = f"[S{spk_num_str}]{text_part}" + target_text_list.append(full_text) + spk_seq.append(spk_num_int) + pause_after_ms_list.append(0) + line_indices.append(line_idx) + last_idx_with_text = len(target_text_list) - 1 + + # 找出对话中使用的最大说话人编号 + max_spk_used = 0 + for text in target_text_list: + match = re.match(r'\[S([1-9]|[1-9][0-9]+)\]', text) + if match: + spk_num = int(match.group(1)) + max_spk_used = max(max_spk_used, spk_num) + + if max_spk_used == 0: + gr.Warning(message="对话文本中未找到有效的说话人标签([S1], [S2]等)") + return None + + num_speakers = len(speaker_configs_list) + if max_spk_used > num_speakers: + gr.Warning(message=f"对话中使用了[S{max_spk_used}],但只提供了{num_speakers}个说话人配置") + return None + + if not check_dialogue_text(target_text_list, max_speakers=num_speakers): + print(f"dialogue_synthesis_function: target_text_list: {target_text_list}, not match") + gr.Warning(message=i18n("warn_invalid_dialogue_text")) + return None + + # 检查所有使用的说话人是否都有配置 + for i in range(max_spk_used): + if i >= len(speaker_configs_list): + gr.Warning(message=f"说话人 {i+1} 缺少配置") + return None + config = speaker_configs_list[i] + if not config[1] or not config[0]: + gr.Warning(message=f"说话人 {i+1} 缺少参考语音或参考文本") + return None + + # Go synthesis + # 使用 track_tqdm=False 避免影响外部预览框的显示 + progress_bar = gr.Progress(track_tqdm=False) + prompt_wav_list = [config[1] for config in speaker_configs_list[:max_spk_used]] + prompt_text_list = [config[0] for config in speaker_configs_list[:max_spk_used]] + use_dialect_prompt = any(config[2].strip() != "" for config in speaker_configs_list[:max_spk_used]) + dialect_prompt_text_list = [config[2] for config in speaker_configs_list[:max_spk_used]] + + try: + data = process_single( + target_text_list, + prompt_wav_list, + prompt_text_list, + use_dialect_prompt, + dialect_prompt_text_list, + ) + except Exception as e: + error_msg = f"处理数据时出错: {str(e)}" + print(f"[ERROR] {error_msg}") + import traceback + traceback.print_exc() + gr.Warning(message=error_msg) + return None, [] + + if data is None: + error_msg = "process_single 返回 None,数据处理失败" + print(f"[ERROR] {error_msg}") + gr.Warning(message=error_msg) + return None, [] + + try: + global model + results_dict = model.forward_longform(**data) + except Exception as e: + error_msg = f"模型推理时出错: {str(e)}" + print(f"[ERROR] {error_msg}") + import traceback + traceback.print_exc() + gr.Warning(message=error_msg) + return None, [] + + target_audio = None + sample_rate = 24000 + num_segments = len(results_dict['generated_wavs']) + saved_files = [] + + # 验证片段数量是否匹配 + if num_segments != len(spk_seq): + print(f"[WARNING] 音频片段数量 ({num_segments}) 与说话者序列长度 ({len(spk_seq)}) 不匹配") + + # 按顺序记录生成的音频片段 + ordered_segment_infos: List[dict] = [] + speaker_part_counter: dict[int, int] = defaultdict(int) + + for i in range(num_segments): + seg = results_dict['generated_wavs'][i] + + if i < len(spk_seq): + current_speaker = spk_seq[i] + if current_speaker > 0: + speaker_part_counter[current_speaker] += 1 + part_idx = speaker_part_counter[current_speaker] + seg_copy = seg.clone().detach() + current_line_idx = line_indices[i] if i < len(line_indices) else -1 + ordered_segment_infos.append({ + "turn_index": i, + "speaker": current_speaker, + "part_idx": part_idx, + "audio": seg_copy, + "line_idx": current_line_idx, + }) + + # 合并到整体音频 + if target_audio is None: + target_audio = seg + else: + prefer_ms = 0 + if i > 0 and (i - 1) < len(pause_after_ms_list): + prefer_ms = int(pause_after_ms_list[i - 1]) + if prefer_ms <= 0: + insert_silence = False + if i > 0 and (i - 1) < len(spk_seq) and i < len(spk_seq): + prev_spk = spk_seq[i - 1] + curr_spk = spk_seq[i] + insert_silence = (prev_spk != curr_spk) + if insert_silence and diff_spk_pause_ms and diff_spk_pause_ms > 0: + prefer_ms = int(diff_spk_pause_ms) + if prefer_ms and prefer_ms > 0: + silence_len = int((prefer_ms / 1000.0) * sample_rate) + if silence_len > 0: + silence = torch.zeros((1, silence_len), dtype=seg.dtype, device=seg.device) + target_audio = torch.concat([target_audio, silence], dim=1) + target_audio = torch.concat([target_audio, seg], dim=1) + + # 保存音频文件 + if save_separated and output_dir: + try: + os.makedirs(output_dir, exist_ok=True) + if timestamp is None: + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + + separated_dir = os.path.join(output_dir, "separated") + os.makedirs(separated_dir, exist_ok=True) + + sentences_dir = os.path.join(output_dir, "sentences") + os.makedirs(sentences_dir, exist_ok=True) + + file_counter = 1 + + # 保存整体音频文件 + if target_audio is not None: + complete_audio_filename = os.path.join(output_dir, "complete_dialogue.wav") + sf.write(complete_audio_filename, target_audio.cpu().squeeze(0).numpy(), sample_rate) + saved_files.append(complete_audio_filename) + print(f"[INFO] {i18n('log_saved_complete_dialogue')}: {complete_audio_filename}") + + # 按对话顺序保存分离的说话者音频片段 + if ordered_segment_infos: + for seg_info in ordered_segment_infos: + seg_audio_np = seg_info["audio"].cpu().squeeze(0).numpy() + part_filename = os.path.join( + separated_dir, + f"{file_counter:03d}_speaker{seg_info['speaker']}_part{seg_info['part_idx']}.wav" + ) + sf.write(part_filename, seg_audio_np, sample_rate) + saved_files.append(part_filename) + print(f"[INFO] {i18n('log_saved_speaker_part').format(num=seg_info['speaker'], part=seg_info['part_idx'])}: {part_filename}") + file_counter += 1 + + # 按行合并音频片段并保存 + if ordered_segment_infos: + line_to_segments: dict[int, List[dict]] = defaultdict(list) + for idx, seg_info in enumerate(ordered_segment_infos): + line_idx = seg_info.get("line_idx", -1) + if line_idx >= 0: + seg_info_with_pause = seg_info.copy() + turn_index = seg_info.get("turn_index", idx) + if turn_index < len(pause_after_ms_list): + seg_info_with_pause["pause_after_ms"] = pause_after_ms_list[turn_index] + else: + seg_info_with_pause["pause_after_ms"] = 0 + line_to_segments[line_idx].append(seg_info_with_pause) + + valid_line_indices = sorted(line_to_segments.keys()) + + sentence_counter = 1 + for line_idx in valid_line_indices: + segments = line_to_segments[line_idx] + if not segments: + continue + + line_audio = None + for seg_idx, seg_info in enumerate(segments): + seg_audio = seg_info["audio"] + + if line_audio is None: + line_audio = seg_audio + else: + if seg_idx > 0: + prev_pause_ms = segments[seg_idx - 1].get("pause_after_ms", 0) + if prev_pause_ms > 0: + silence_len = int((prev_pause_ms / 1000.0) * sample_rate) + if silence_len > 0: + silence = torch.zeros((1, silence_len), dtype=seg_audio.dtype, device=seg_audio.device) + line_audio = torch.concat([line_audio, silence], dim=1) + line_audio = torch.concat([line_audio, seg_audio], dim=1) + + if line_audio is not None: + sentence_filename = os.path.join(sentences_dir, f"sentence{sentence_counter}.wav") + sf.write(sentence_filename, line_audio.cpu().squeeze(0).numpy(), sample_rate) + saved_files.append(sentence_filename) + print(f"[INFO] 已保存按行合并音频 sentence{sentence_counter}: {sentence_filename}") + sentence_counter += 1 + + if saved_files: + print(f"[INFO] {i18n('log_all_files_saved').format(dir=output_dir)}") + print(f"[INFO] {i18n('log_total_files_saved').format(count=len(saved_files))}") + except Exception as e: + print(f"[ERROR] {i18n('log_error_saving_files').format(error=str(e))}") + import traceback + traceback.print_exc() + + return (sample_rate, target_audio.cpu().squeeze(0).numpy()), saved_files + diff --git a/webui/utils.py b/webui/utils.py new file mode 100644 index 0000000..ba70b84 --- /dev/null +++ b/webui/utils.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +""" +Utility functions for SoulX-Podcast WebUI. +""" + +import re +from typing import Optional, List + + +# ============================================================================= +# Type Coercion Functions +# ============================================================================= + +def coerce_gradio_file_to_path(file_obj) -> Optional[str]: + """ + 兼容 gr.File 的不同返回形态: + - str 路径 + - dict(含 name/path) + - 对象(含 name 属性) + """ + if file_obj is None: + return None + if isinstance(file_obj, str): + return file_obj + if isinstance(file_obj, dict): + return file_obj.get("name") or file_obj.get("path") + return getattr(file_obj, "name", None) + + +def coerce_audio_value_to_path(audio_val) -> Optional[str]: + """ + gr.Audio(type="filepath") 通常返回 str 路径;这里做额外兼容。 + """ + if audio_val is None: + return None + if isinstance(audio_val, str): + return audio_val + if isinstance(audio_val, dict): + return audio_val.get("name") or audio_val.get("path") + return getattr(audio_val, "name", None) + + +# ============================================================================= +# Text Validation Functions +# ============================================================================= + +def check_monologue_text(text: str, prefix: str = None) -> bool: + """Check if monologue text is valid.""" + text = text.strip() + # Check speaker tags + if prefix is not None and (not text.startswith(prefix)): + return False + # Remove prefix + if prefix is not None: + text = text.removeprefix(prefix) + text = text.strip() + # If empty? + if len(text) == 0: + return False + return True + + +def check_dialect_prompt_text(text: str, prefix: str = None) -> bool: + """Check if dialect prompt text is valid.""" + text = text.strip() + # Check Dialect Prompt prefix tags + if prefix is not None and (not text.startswith(prefix)): + return False + text = text.strip() + # If empty? + if len(text) == 0: + return False + return True + + +def check_dialogue_text(text_list: List[str], max_speakers: int = None) -> bool: + """Check if dialogue text list is valid.""" + if len(text_list) == 0: + return False + for text in text_list: + # 检查是否匹配 [S1] 到 [S{max_speakers}] 格式 + pattern = r'^\[S([1-9]|[1-9][0-9]+)\].*' + match = re.match(pattern, text.strip(), flags=re.DOTALL) + if not match: + return False + spk_num = int(match.group(1)) + if spk_num < 1: + return False + if max_speakers is not None and spk_num > max_speakers: + return False + return True +