Compare commits
425
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a28a46010 | ||
|
|
211b192f4a | ||
|
|
4a99f15851 | ||
|
|
08e8e8c884 | ||
|
|
46c324c1ce | ||
|
|
2bc4c2a18d | ||
|
|
923f7b3c32 | ||
|
|
95223f4800 | ||
|
|
9c40f4542b | ||
|
|
bf7d83f26e | ||
|
|
b09e5023b5 | ||
|
|
586bd96e51 | ||
|
|
127bfe32fc | ||
|
|
2f587b22b3 | ||
|
|
48608775e0 | ||
|
|
eaaacef869 | ||
|
|
871c97fd99 | ||
|
|
fa30dc6768 | ||
|
|
c28d903d02 | ||
|
|
397982556c | ||
|
|
517d11ed4c | ||
|
|
625d22d6f6 | ||
|
|
2ecd43a5e6 | ||
|
|
986093a207 | ||
|
|
237765af9e | ||
|
|
d1bfe54ffd | ||
|
|
df34b9818c | ||
|
|
3d7e8dfaa1 | ||
|
|
1d52a51206 | ||
|
|
4f22f145d2 | ||
|
|
a8e08b5508 | ||
|
|
9543a99bee | ||
|
|
a0dde24066 | ||
|
|
58a754ac2c | ||
|
|
9fcedabf1c | ||
|
|
c1b9e088e3 | ||
|
|
edb7010184 | ||
|
|
0ea124a1c0 | ||
|
|
ccbc721c2d | ||
|
|
d8bf3dc865 | ||
|
|
0c5201ebf3 | ||
|
|
cce0131889 | ||
|
|
3cf49110ae | ||
|
|
0c3073e491 | ||
|
|
4a1bfd7b75 | ||
|
|
a944650fde | ||
|
|
b55b6f03ad | ||
|
|
97f4a0c365 | ||
|
|
4ccc0aa5ee | ||
|
|
198662124c | ||
|
|
a68bafb73f | ||
|
|
08916ea598 | ||
|
|
5329d8767d | ||
|
|
f1f52c9d41 | ||
|
|
08b50473f5 | ||
|
|
798f3f4192 | ||
|
|
4937ff9a5b | ||
|
|
da88ced2a5 | ||
|
|
f344099e3b | ||
|
|
11b7a3c7fc | ||
|
|
12ce9be0c0 | ||
|
|
6c2eb70f8a | ||
|
|
122c439c41 | ||
|
|
764e28a1aa | ||
|
|
8b28214d77 | ||
|
|
561267d10b | ||
|
|
d3ab465983 | ||
|
|
1ce69b2d1a | ||
|
|
516ab1595f | ||
|
|
f81ebf1f9d | ||
|
|
0c527ef541 | ||
|
|
55065cc2bc | ||
|
|
d0430d846d | ||
|
|
7ff08f6070 | ||
|
|
0bab378191 | ||
|
|
af2cf2a4d1 | ||
|
|
0dbc69d27f | ||
|
|
7c64fedede | ||
|
|
6da635cf74 | ||
|
|
8bfd6512de | ||
|
|
ffa3f1eded | ||
|
|
87def26983 | ||
|
|
057a9ef638 | ||
|
|
df3a4cb2cf | ||
|
|
06003e0b3a | ||
|
|
068c3f1f1e | ||
|
|
46c051477d | ||
|
|
52b22c0b8d | ||
|
|
b31b31b89f | ||
|
|
9757092aae | ||
|
|
47a8c7c691 | ||
|
|
1033eb78b9 | ||
|
|
1f2703a3a3 | ||
|
|
af77ff8e0b | ||
|
|
8b199980dc | ||
|
|
26fa2ddf92 | ||
|
|
282616ebe1 | ||
|
|
5735616794 | ||
|
|
6ce60f31a2 | ||
|
|
102f114ae6 | ||
|
|
4494381176 | ||
|
|
84e82b7d31 | ||
|
|
e54b6d9aa5 | ||
|
|
984f0e9fa5 | ||
|
|
1218e01683 | ||
|
|
9081d3c22a | ||
|
|
09df168119 | ||
|
|
b00b6098c9 | ||
|
|
a41ac5d89e | ||
|
|
99ea0bc6f9 | ||
|
|
e6c607b582 | ||
|
|
ed5e55a5fc | ||
|
|
a0b58e42f7 | ||
|
|
44d164c2b7 | ||
|
|
14fcba3bed | ||
|
|
36ed7d694a | ||
|
|
383518854c | ||
|
|
f2724520f4 | ||
|
|
e2c691fa98 | ||
|
|
e7084f1bbc | ||
|
|
8674797e7f | ||
|
|
0a8081f9c3 | ||
|
|
d4461b1bf8 | ||
|
|
24e6575fd4 | ||
|
|
fcff4ccf45 | ||
|
|
f7abd5a891 | ||
|
|
1291346dda | ||
|
|
552a67d209 | ||
|
|
3436dbdab2 | ||
|
|
ec659ebc1f | ||
|
|
a217786539 | ||
|
|
6fb1bf5e49 | ||
|
|
9ad18b6f3d | ||
|
|
35e0b27913 | ||
|
|
1e92a9f4b1 | ||
|
|
05a728313b | ||
|
|
29515feb35 | ||
|
|
5c346693e3 | ||
|
|
3717fc0cbe | ||
|
|
af13209b47 | ||
|
|
d96fe777e8 | ||
|
|
41ee406634 | ||
|
|
c3d2ddcc83 | ||
|
|
3a817a2526 | ||
|
|
78d3e867ef | ||
|
|
db3a888b77 | ||
|
|
1f5882ba44 | ||
|
|
e471cdbaa6 | ||
|
|
cb6dfe3a11 | ||
|
|
ae842ea39b | ||
|
|
bc95f20ad3 | ||
|
|
3c835b10b7 | ||
|
|
4344bcaf1a | ||
|
|
3373c83d84 | ||
|
|
03401c0b40 | ||
|
|
665518d218 | ||
|
|
ed0932d078 | ||
|
|
4ebffc9128 | ||
|
|
face03b42b | ||
|
|
8760c548d1 | ||
|
|
18bfa834e0 | ||
|
|
afb35f5469 | ||
|
|
b85067cc1c | ||
|
|
4c0a0a934c | ||
|
|
3437d78772 | ||
|
|
9cb78c3157 | ||
|
|
2bf402d9a5 | ||
|
|
cdb14c4956 | ||
|
|
a42f4d3083 | ||
|
|
8367bc12bf | ||
|
|
667d12c37b | ||
|
|
45980761ee | ||
|
|
4807f9c654 | ||
|
|
f75837834e | ||
|
|
287978b6a9 | ||
|
|
70b04135ec | ||
|
|
53df881108 | ||
|
|
14c32a96d1 | ||
|
|
aff2baad27 | ||
|
|
010e532b38 | ||
|
|
2b61bafd73 | ||
|
|
63ff510768 | ||
|
|
8621d3b64d | ||
|
|
5c1ab55abc | ||
|
|
df938d0ac2 | ||
|
|
666e747c3e | ||
|
|
09f3654a13 | ||
|
|
ec56310a89 | ||
|
|
86f29abfa5 | ||
|
|
18dab7b4ca | ||
|
|
41bfffeefe | ||
|
|
5fd7ecbea2 | ||
|
|
c056da620d | ||
|
|
f70654dc46 | ||
|
|
e3e3685226 | ||
|
|
969939317c | ||
|
|
f8c5c2b965 | ||
|
|
6d7da1a4af | ||
|
|
3f97a8f709 | ||
|
|
dee8cfd0d1 | ||
|
|
11951d35dd | ||
|
|
2b69130a42 | ||
|
|
c8e6317c8d | ||
|
|
4e52c14a04 | ||
|
|
4a21a59fb9 | ||
|
|
309f00a707 | ||
|
|
9054b197b0 | ||
|
|
10532907f2 | ||
|
|
9095d748b6 | ||
|
|
ef82e69f28 | ||
|
|
1020d64cbf | ||
|
|
2add12df54 | ||
|
|
98f05aab66 | ||
|
|
9b83253b87 | ||
|
|
ec6f127136 | ||
|
|
19d7f7bd83 | ||
|
|
c632da3588 | ||
|
|
4b3c050ddd | ||
|
|
1438f150d0 | ||
|
|
a76003e2a4 | ||
|
|
1ea6f182fb | ||
|
|
5185a1678b | ||
|
|
47f09ba89b | ||
|
|
04da79f0a8 | ||
|
|
460d091442 | ||
|
|
e736822a01 | ||
|
|
7bc5e4bcaa | ||
|
|
3f0f0d42c5 | ||
|
|
7f4f6862d2 | ||
|
|
7ef2f8718c | ||
|
|
775cbd037c | ||
|
|
75053cc2d8 | ||
|
|
5d93512f9c | ||
|
|
861e1ac254 | ||
|
|
d408715120 | ||
|
|
f6ca9525a2 | ||
|
|
f1f05ae290 | ||
|
|
4521ffbc79 | ||
|
|
8ee1845ecb | ||
|
|
ddc87f4ad8 | ||
|
|
1df7df8b77 | ||
|
|
8c6e7f358d | ||
|
|
b7f27093b7 | ||
|
|
1498ac0b53 | ||
|
|
06428ba6d0 | ||
|
|
1d90607bd6 | ||
|
|
c9431fdec6 | ||
|
|
1a67f20117 | ||
|
|
86992ff44e | ||
|
|
ec5a5212f5 | ||
|
|
362d4959e0 | ||
|
|
19b88e615a | ||
|
|
5d8d1a78eb | ||
|
|
c336e01faa | ||
|
|
44b7e38853 | ||
|
|
d017f66969 | ||
|
|
cd8f2683b0 | ||
|
|
668ed764eb | ||
|
|
186b9ec7d4 | ||
|
|
832503712a | ||
|
|
984eaf1600 | ||
|
|
2646148ea9 | ||
|
|
f09ae688c5 | ||
|
|
2540583f1d | ||
|
|
273229976b | ||
|
|
28fcba7958 | ||
|
|
42fdfd6e07 | ||
|
|
4af18f7b9f | ||
|
|
f285fa8bd2 | ||
|
|
f267739aab | ||
|
|
54a714bfcf | ||
|
|
2af4285d06 | ||
|
|
4c85b066be | ||
|
|
b09b26b112 | ||
|
|
6b719ef1a7 | ||
|
|
1021b31f69 | ||
|
|
735510595a | ||
|
|
ddac505706 | ||
|
|
15f1e2e1ab | ||
|
|
581e404fce | ||
|
|
7bae739819 | ||
|
|
374713c2e5 | ||
|
|
cae5d55ef6 | ||
|
|
3434f1de0d | ||
|
|
4c598b2aa3 | ||
|
|
e489ad7015 | ||
|
|
1653c689e5 | ||
|
|
6346122036 | ||
|
|
c709f129fc | ||
|
|
025c7b09b6 | ||
|
|
adf615a8af | ||
|
|
70e8e1c356 | ||
|
|
da9310e814 | ||
|
|
413ac911dc | ||
|
|
c1b1348031 | ||
|
|
b3678bf513 | ||
|
|
dc0b84655b | ||
|
|
c0e594ab43 | ||
|
|
c834879563 | ||
|
|
8849bcacfc | ||
|
|
ae78204c21 | ||
|
|
c9b80e2340 | ||
|
|
bdf70c9954 | ||
|
|
b327b3cdad | ||
|
|
d191b24a1d | ||
|
|
5cdc6c8f2d | ||
|
|
bb7fd9fa1d | ||
|
|
faf263d242 | ||
|
|
104e3a72b4 | ||
|
|
ba7823d571 | ||
|
|
f4d032f4ef | ||
|
|
c80ab2b4e9 | ||
|
|
4955436280 | ||
|
|
fcc6c9ea74 | ||
|
|
53aa51762f | ||
|
|
1a62111692 | ||
|
|
2e67805a6f | ||
|
|
30d8b91bdb | ||
|
|
d6a6dbadbe | ||
|
|
e12191e6f7 | ||
|
|
c900b6e70d | ||
|
|
9c24251d04 | ||
|
|
3a1ed6d3fa | ||
|
|
597301fed9 | ||
|
|
99974c8e75 | ||
|
|
43aeb4a526 | ||
|
|
05429427b8 | ||
|
|
e8f7712006 | ||
|
|
af1164e849 | ||
|
|
047c5364c9 | ||
|
|
c0cb92a494 | ||
|
|
4292cad55d | ||
|
|
2cc6387892 | ||
|
|
7d12f53ca6 | ||
|
|
738f9316bf | ||
|
|
86a067a8e3 | ||
|
|
5583ca7812 | ||
|
|
824bcb1d63 | ||
|
|
fdb79d16e3 | ||
|
|
675031fe07 | ||
|
|
2e43bfd9f7 | ||
|
|
3f7e0c26c3 | ||
|
|
43f95bc723 | ||
|
|
c471c45542 | ||
|
|
3dad764d2c | ||
|
|
29488ce0e7 | ||
|
|
24466ef7f2 | ||
|
|
d8af6a6d5b | ||
|
|
246f0d2bc0 | ||
|
|
52667c7bdc | ||
|
|
3cfdc86826 | ||
|
|
363553ecf9 | ||
|
|
3e840da456 | ||
|
|
febbb3fcf9 | ||
|
|
6195d5407a | ||
|
|
62b6469a70 | ||
|
|
99e72ae3aa | ||
|
|
8f0c112da6 | ||
|
|
200d8f967c | ||
|
|
6eebfb7e16 | ||
|
|
60b23116f2 | ||
|
|
e701bf80c8 | ||
|
|
d965eacd25 | ||
|
|
0068acb849 | ||
|
|
1a17d4510d | ||
|
|
a93db5b461 | ||
|
|
a700d740ab | ||
|
|
acfe50980f | ||
|
|
4e4c50045a | ||
|
|
08226ff96d | ||
|
|
4bd70b6aa2 | ||
|
|
83bfeffab5 | ||
|
|
2a701d00c6 | ||
|
|
7caace733e | ||
|
|
3a952fca76 | ||
|
|
69c8d7bcee | ||
|
|
9f725c83c6 | ||
|
|
97d6f68b44 | ||
|
|
ed471e3966 | ||
|
|
bdf448ab17 | ||
|
|
26d17b8d08 | ||
|
|
0428cd5177 | ||
|
|
2811a6225f | ||
|
|
7fc3dcbc2b | ||
|
|
2830dfc15f | ||
|
|
cded924a99 | ||
|
|
53ea464bf0 | ||
|
|
5899bf9027 | ||
|
|
d6de4d91ff | ||
|
|
a2360db51c | ||
|
|
52fb96618e | ||
|
|
72f10c96fd | ||
|
|
30d38632b0 | ||
|
|
75cd1c4646 | ||
|
|
697bd70ebe | ||
|
|
4601d5ce60 | ||
|
|
5a307b49f7 | ||
|
|
1c19fbbd75 | ||
|
|
d204e99e17 | ||
|
|
51730916f0 | ||
|
|
4129836a07 | ||
|
|
1befb1b09a | ||
|
|
a96e75c718 | ||
|
|
d74eef703d | ||
|
|
37f36ac821 | ||
|
|
57e7329377 | ||
|
|
c5c4262d4a | ||
|
|
4755f4cb4c | ||
|
|
4530f7755b | ||
|
|
bab68fd256 | ||
|
|
4b844b327d | ||
|
|
7bf270cae9 | ||
|
|
ada51d7937 | ||
|
|
fa203f52ff | ||
|
|
6bfefd0df5 | ||
|
|
f010a0ac79 | ||
|
|
c7e6c4b2bf | ||
|
|
7ea1f743fd | ||
|
|
3833092253 | ||
|
|
a3ace77eb5 | ||
|
|
a2e11108e2 | ||
|
|
76b0818d7c | ||
|
|
3f14436b17 | ||
|
|
e840c6df4d | ||
|
|
b13ec81872 |
+25
@@ -21,6 +21,10 @@ eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
!engines/rvc/impl/lib/
|
||||
!engines/rvc/training/bundled_runtime/lib/
|
||||
!engines/rvc/training/bundled_runtime/lib/**
|
||||
engines/rvc/training/bundled_runtime/lib/**/__pycache__/
|
||||
engines/rvc/training/bundled_runtime/lib/**/*.py[cod]
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -135,6 +139,9 @@ celerybeat.pid
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.local
|
||||
tests/.env
|
||||
tests/.env.local
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
@@ -142,6 +149,7 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
comfyui_env/
|
||||
/runtimes/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -212,6 +220,13 @@ docs/Dev reports/CLAUDE_VERSION_MANAGEMENT_GUIDE.md
|
||||
.claude-code.json
|
||||
.claude/
|
||||
|
||||
#codex
|
||||
*AGENTS.md
|
||||
AGENTS.md
|
||||
|
||||
# Antigravity / agent configuration
|
||||
.agent/
|
||||
|
||||
IgnoredForGitHubDocs/
|
||||
|
||||
# Windows Zone.Identifier files
|
||||
@@ -228,3 +243,13 @@ ignore/
|
||||
|
||||
# Test output files
|
||||
tests/output/
|
||||
|
||||
# TTS Audio Suite settings cache (user preferences)
|
||||
.inline_tag_settings_cache.json
|
||||
|
||||
# AI Agent skills
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# others
|
||||
node_modules/
|
||||
|
||||
+1762
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,54 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Shmuel Ronen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Shmuel Ronen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Third-Party Model Licenses
|
||||
──────────────────────────
|
||||
|
||||
The project code is MIT. Model weights carry their own licenses:
|
||||
|
||||
─────────────────── ──────────────────────────────────────────────────────── ──────────────
|
||||
Engine License Commercial Use
|
||||
─────────────────── ──────────────────────────────────────────────────────── ──────────────
|
||||
F5-TTS CC-BY-NC-4.0 No
|
||||
ChatterBox MIT Yes
|
||||
ChatterBox 23L MIT Yes
|
||||
VibeVoice MIT (research-only per model card) No
|
||||
Higgs Audio 2 Boson Higgs Audio 2 Community License Conditional
|
||||
Higgs Audio v3 Boson Higgs Audio v3 Research and Non-Commercial License No
|
||||
IndexTTS 2 / 2.5 bilibili Model Use License Conditional
|
||||
CosyVoice3 Apache-2.0 Yes
|
||||
Qwen3-TTS Apache-2.0 Yes
|
||||
Granite ASR Apache-2.0 Yes
|
||||
Step Audio EditX Apache-2.0 (verify before commercial use) Conditional
|
||||
Echo-TTS CC-BY-NC-SA-4.0 No
|
||||
Fish Audio S2 Pro Fish Audio Research License No
|
||||
Dots TTS Apache-2.0 Yes
|
||||
DramaBox LTX-2 Community License Conditional
|
||||
OmniVoice Apache-2.0 Yes
|
||||
MOSS-TTS Apache-2.0 Yes
|
||||
MOSS-SoundEffect v2 Apache-2.0 Yes
|
||||
RVC MIT (framework); community models vary Varies
|
||||
─────────────────── ──────────────────────────────────────────────────────── ──────────────
|
||||
|
||||
Users are responsible for complying with respective model licenses.
|
||||
|
||||
+187
-454
@@ -4,472 +4,205 @@
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This extension features a **unified modular architecture** supporting multiple TTS engines:
|
||||
- **Unified Node Interface**: Single set of nodes (TTS Text, TTS SRT, Voice Changer) that work with any engine via clean delegation
|
||||
- **ComfyUI Model Management Integration**: All TTS models now integrate with ComfyUI's native model management system, enabling "Clear VRAM" functionality and automatic memory management
|
||||
- **Universal Model Loading System**: Standardized model loading interface across all engines (ChatterBox, F5-TTS, Higgs Audio, VibeVoice, Step Audio EditX, Qwen3-TTS, RVC, Audio Separation) with fallback support
|
||||
- **Engine Processors**: Internal processing engines for each TTS system (ChatterBox, F5-TTS, Higgs Audio, VibeVoice, Step Audio EditX, Qwen3-TTS) handling engine-specific orchestration
|
||||
- **Engine Adapters**: Modular adapters for ChatterBox, F5-TTS, Higgs Audio 2, VibeVoice, Step Audio EditX, Qwen3-TTS, and RVC voice conversion
|
||||
- **Inline Edit Tag System**: Universal post-processing system applying Step Audio EditX effects (emotion, style, speed, paralinguistic) to any TTS engine's output via inline tags like `<Laughter>` or `<happy>`
|
||||
- **Centralized Download System**: Unified downloader eliminates HuggingFace cache duplication with direct downloads to organized TTS/ folder structure, with full extra_model_paths.yaml support for shared model directories
|
||||
- **Thread-Safe Architecture**: Stateless ChatterBox wrapper eliminates shared state corruption (Note: parallel processing is slower than sequential)
|
||||
- **Universal Streaming Infrastructure**: Unified streaming system with configurable workers (batch_size parameter) - sequential mode (batch_size=0) recommended for optimal performance
|
||||
- **Multilingual Support**: German and Norwegian models for ChatterBox, plus ChatterBox Official 23-Lang supporting 23 languages including Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swedish, Swahili, and Turkish
|
||||
- **Smart Language Grouping**: SRT processing by language groups to minimize model switching
|
||||
- **Character Voice Management**: Centralized character voice system with flexible input types
|
||||
- **Comprehensive Audio Processing**: Interactive waveform analyzer, vocal separation, audio mixing, and specialized audio editing
|
||||
- **Professional Voice Conversion**: RVC implementation with UVR5 vocal separation and advanced pitch control
|
||||
**Unified modular architecture** - all engines share the same layered pattern:
|
||||
|
||||
1. **Engine Node** (`nodes/engines/<engine>_engine_node.py`) - ComfyUI configuration UI
|
||||
2. **Unified Nodes** (`nodes/unified/`) - thin delegation layer, routes to engine processors
|
||||
3. **Processor** (`nodes/<engine>/<engine>_processor.py`) - orchestration, chunking, character/pause/tag handling
|
||||
4. **SRT Processor** (`nodes/<engine>/<engine>_srt_processor.py`) - SRT-specific orchestration
|
||||
5. **Adapter** (`engines/adapters/<engine>_adapter.py`) - bridges processor to engine implementation
|
||||
6. **Engine Implementation** (`engines/<engine>/`) - actual model inference
|
||||
7. **Optional Isolated Runtime** (`utils/runtimes/`) - worker subprocess layer for fragile engine stacks
|
||||
|
||||
**Key architectural rules:**
|
||||
- Chunking happens in the **processor**, not the adapter (`generate_single()` on adapter = raw single call)
|
||||
- Runtime routing happens through `ModelLoadConfig.runtime_mode` + `runtime_profile`, not ad-hoc subprocess calls
|
||||
- Shared runtime workers are currently used for fragile engine families such as VibeVoice, Qwen3-TTS / ASR, Granite forced alignment, and Higgs Audio 2. Engines that support the modern stack run natively in the main Transformers 5 environment.
|
||||
- YAML (`docs/Dev reports/tts_audio_suite_engines.yaml`) is source of truth for engine doc tables → run `python3 scripts/generate_engine_tables.py --readme` to regenerate
|
||||
- Auxiliary YAML (`docs/Dev reports/tts_audio_suite_aux_models.yaml`) is source of truth for helper/post-process model docs → run `python3 scripts/generate_aux_model_docs.py`
|
||||
- All models download to `ComfyUI/models/TTS/<model-name>/`
|
||||
- Engine registry: `utils/models/engine_registry.py`
|
||||
|
||||
## Engines
|
||||
|
||||
19 engines follow the pattern above:
|
||||
|
||||
| Engine | Adapter | Processor | SRT Processor | Engine Node |
|
||||
|--------|---------|-----------|---------------|-------------|
|
||||
| ChatterBox | `chatterbox_adapter.py` | `nodes/chatterbox/chatterbox_tts_node.py` | `chatterbox_srt_node.py` | `chatterbox_engine_node.py` |
|
||||
| ChatterBox 23-Lang | `chatterbox_official_23lang_adapter.py` | `nodes/chatterbox_official_23lang/` | same folder | `chatterbox_official_23lang_engine_node.py` |
|
||||
| F5-TTS | `f5tts_adapter.py` | `nodes/f5tts/f5tts_node.py` | `f5tts_srt_node.py` | `f5tts_engine_node.py` |
|
||||
| Higgs Audio 2 | `higgs_audio_adapter.py` | — | `nodes/higgs_audio/higgs_audio_srt_processor.py` | `higgs_audio_engine_node.py` |
|
||||
| Higgs Audio v3 | `higgs_audio_v3_adapter.py` | `nodes/higgs_audio_v3/higgs_audio_v3_processor.py` | `higgs_audio_v3_srt_processor.py` | `higgs_audio_v3_engine_node.py` |
|
||||
| VibeVoice | `vibevoice_adapter.py` | `nodes/vibevoice/vibevoice_processor.py` | — | `vibevoice_engine_node.py` |
|
||||
| IndexTTS-2 | — | `engines/index_tts/` | — | `index_tts_engine_node.py` |
|
||||
| Step Audio EditX | `step_audio_editx_adapter.py` | `nodes/step_audio_editx/step_audio_editx_processor.py` | `step_audio_editx_srt_processor.py` | `step_audio_editx_engine_node.py` |
|
||||
| CosyVoice3 | `cosyvoice_adapter.py` | `engines/processors/cosyvoice_processor.py` | `nodes/cosyvoice/cosyvoice_srt_processor.py` | `cosyvoice_engine_node.py` |
|
||||
| Qwen3-TTS | `qwen3_tts_adapter.py` | `nodes/qwen3_tts/qwen3_tts_processor.py` | same folder | `qwen3_tts_engine_node.py` |
|
||||
| MOSS-TTS | `moss_tts_adapter.py` | `nodes/moss_tts/moss_tts_processor.py` | `moss_tts_srt_processor.py` | `moss_tts_engine_node.py` |
|
||||
| Granite ASR | `asr_granite_adapter.py` | — | — | `granite_asr_engine_node.py` |
|
||||
| Echo-TTS | `echo_tts_adapter.py` | `nodes/echo_tts/echo_tts_processor.py` | `echo_tts_srt_processor.py` | `echo_tts_engine_node.py` |
|
||||
| Fish Audio S2 Pro | `fish_audio_s2_adapter.py` | `nodes/fish_audio_s2/fish_audio_s2_processor.py` | `fish_audio_s2_srt_processor.py` | `fish_audio_s2_engine_node.py` |
|
||||
| Dots TTS | `dots_tts_adapter.py` | `nodes/dots_tts/dots_tts_processor.py` | `dots_tts_srt_processor.py` | `dots_tts_engine_node.py` |
|
||||
| DramaBox | `dramabox_adapter.py` | `nodes/dramabox/dramabox_processor.py` | `dramabox_srt_processor.py` | `dramabox_engine_node.py` |
|
||||
| OmniVoice | `omnivoice_adapter.py` | `nodes/omnivoice/omnivoice_processor.py` | `omnivoice_srt_processor.py` | `omnivoice_engine_node.py` |
|
||||
| MOSS-SoundEffect v2 | `moss_soundeffect_v2_adapter.py` | — | — | `moss_soundeffect_v2_engine_node.py` |
|
||||
| RVC | — | `engines/rvc/` | — | `rvc_engine_node.py` |
|
||||
|
||||
**Engine implementations live in:**
|
||||
- `engines/chatterbox/`, `engines/chatterbox_official_23lang/`, `engines/f5tts/`, `engines/higgs_audio/`, `engines/higgs_audio_v3/`, `engines/vibevoice_engine/`, `engines/step_audio_editx/`, `engines/cosyvoice/`, `engines/qwen3_tts/`, `engines/qwen3_asr/`, `engines/moss_tts/`, `engines/moss_soundeffect_v2/`, `engines/granite_asr/`, `engines/echo_tts/`, `engines/fish_audio_s2/`, `engines/dots_tts/`, `engines/dramabox/`, `engines/omnivoice/`, `engines/rvc/`
|
||||
|
||||
## Documentation Files
|
||||
|
||||
**README.md** - Main project documentation with installation guide, features overview, and usage instructions for the TTS Audio Suite
|
||||
**README.md** - Main project docs, installation, features overview
|
||||
**CLAUDE.md** - Dev guidelines for Claude Code
|
||||
**CHANGELOG.md** - Full version history
|
||||
**docs/BUMP_SCRIPT_INSTRUCTIONS.md** - Version bump process
|
||||
|
||||
**CLAUDE.md** - Development guidelines and project context for Claude Code when working with this codebase, including critical thinking requirements and response style guidelines
|
||||
### User Docs (`docs/`)
|
||||
- `CHARACTER_SWITCHING_GUIDE.md` - [CharacterName] tag system
|
||||
- `PARAMETER_SWITCHING_GUIDE.md` - Per-segment parameter override syntax
|
||||
- `INLINE_EDIT_TAGS_USER_GUIDE.md` - Step Audio EditX inline tags
|
||||
- `HIGGS_AUDIO_V3_INLINE_TAGS.md` - Higgs Audio v3 native paralinguistic tags
|
||||
- `OMNIVOICE_TAGS_GUIDE.md` - OmniVoice native non-verbal tags and pronunciation overrides
|
||||
- `MOSS_TTS_PROMPT_FIELDS_GUIDE.md` - Official MOSS whole-segment prompt fields and inline `<>` translation limits
|
||||
- `DRAMABOX_PROMPTING_GUIDE.md` - DramaBox expressive scene prompts, voice references, controls, hardware, and license
|
||||
- `COSYVOICE3_TAGS_GUIDE.md` - CosyVoice3 native paralinguistic tags
|
||||
- `CHATTERBOX_V2_SPECIAL_TOKENS.md` - ChatterBox v2 emotion tokens
|
||||
- `IndexTTS2_Emotion_Control_Guide.md` - IndexTTS-2 vector, text, audio, and blended emotion controls
|
||||
- `VOCAL_REMOVAL_GUIDE.md` - Vocal separation guide
|
||||
- `qwen3_tts_optimizations.md` - Qwen3-TTS torch.compile setup
|
||||
- `MODEL_DOWNLOAD_SOURCES.md` - All HF repo links (auto-generated)
|
||||
- `MODEL_LAYOUTS.md` - Folder structures (auto-generated)
|
||||
- `AUX_MODEL_SOURCES.md`, `AUX_MODEL_LAYOUTS.md` - Auxiliary helper-model registries (auto-generated)
|
||||
- `ENGINE_COMPARISON.md`, `LANGUAGE_SUPPORT.md`, `FEATURE_COMPARISON.md` - Auto-generated tables
|
||||
|
||||
**CHANGELOG.md** - Complete version history with detailed feature additions, bug fixes, and architectural improvements across all versions
|
||||
### Dev Docs (`docs/Dev reports/`)
|
||||
- `tts_audio_suite_engines.yaml` - **Source of truth** for all engine metadata
|
||||
- `tts_audio_suite_aux_models.yaml` - **Source of truth** for helper/post-process model metadata
|
||||
- `SRT_IMPLEMENTATION.md` - SRT timing technical details
|
||||
- `ISOLATED_RUNTIMES_PLAN.md` - original runtime isolation plan and scope
|
||||
- `TRANSFORMERS_5_QWEN3_TTS_REPORT.md` - why Qwen3-TTS moved to shared legacy T4 runtime
|
||||
- `TRANSFORMERS_5_HIGGS_AUDIO_REPORT.md` - why Higgs Audio 2 moved to shared legacy T4 runtime
|
||||
|
||||
### User Documentation
|
||||
### New Engine Guides (`docs/New Engines Guides/`)
|
||||
- `README.md` - Start-here friendly workflow for users guiding LLMs through new engine integrations
|
||||
- `01_FIRST_ASK_THE_LLM_TO_RESEARCH_THE_OFFICIAL_MODEL.md` - Required official model capability audit before coding
|
||||
- `02_CHECK_EXISTING_COMFYUI_IMPLEMENTATIONS.md` - How to use existing ComfyUI implementations as references, not source of truth
|
||||
- `03_DECIDE_ENGINE_SCOPE.md` - Decide whether the engine needs TTS, Voice Changer, ASR, or special nodes; TTS implies SRT support
|
||||
- `04_IMPLEMENTATION_ORDER_FOR_LLM.md` - Step-by-step implementation order with Qwen3-TTS and Step Audio EditX reference rules
|
||||
- `05_REQUIRED_PARITY_CHECKLIST.md` - Must-pass architecture, lifecycle, cache, tag, SRT, docs, and UX checklist
|
||||
- `06_USER_PROMPTS_TO_COPY_PASTE.md` - Ready prompts for users instructing an LLM
|
||||
- `07_PR_REVIEW_CHECKLIST.md` - Maintainer/contributor checklist before merge
|
||||
|
||||
## Core Files
|
||||
|
||||
**docs/CHARACTER_SWITCHING_GUIDE.md** - Guide for multi-character TTS using [CharacterName] tags, voice organization, and character voice management system
|
||||
|
||||
**docs/CHATTERBOX_V2_SPECIAL_TOKENS.md** - Complete guide for ChatterBox Official 23-Lang v2 special emotion and sound tokens (<giggle>, <whisper>, <sigh>, etc.) with 30+ expressive tokens for enhanced TTS generation
|
||||
|
||||
**docs/INLINE_EDIT_TAGS_USER_GUIDE.md** - Comprehensive guide for inline edit tag system enabling Step Audio EditX post-processing on any TTS engine output. Covers paralinguistic tags (<Laughter>, <Breathing>), emotion tags (<happy>, <sad>), style tags (<whisper>, <serious>), and speed control tags, with iteration control, language support notes, and best practices
|
||||
|
||||
**docs/🌊_Audio_Wave_Analyzer-Complete_User_Guide.md** - Complete user guide for Audio Wave Analyzer with interactive waveform visualization and timing extraction
|
||||
|
||||
**docs/VERSION_3.1_RELEASE_GUIDE.md** - Release documentation for character switching system and overlapping subtitles support
|
||||
|
||||
### Developer Documentation
|
||||
|
||||
**docs/Dev reports/F5TTS_INTEGRATION_SPECIFICATION.md** - Technical specification for F5-TTS integration architecture
|
||||
|
||||
**docs/Dev reports/F5TTS_STREAMING_INTEGRATION_PLAN.md** - Universal streaming architecture plan to eliminate spaghetti code and enable F5-TTS streaming
|
||||
|
||||
**docs/Dev reports/STREAMING_ARCHITECTURE.md** - Streaming parallel processing architecture documentation with feasibility analysis for multiple engines
|
||||
|
||||
**docs/Dev reports/STATELESS_WRAPPER_IMPLEMENTATION_PLAN.md** - Stateless ChatterBox wrapper implementation solving shared model state corruption in parallel processing
|
||||
|
||||
**docs/Dev reports/POST_V4.2.3_DEVELOPMENT_REVIEW.md** - Comprehensive analysis of 45 commits implementing universal streaming architecture and performance insights
|
||||
|
||||
**docs/Dev reports/PAUSE_TAGS_IMPLEMENTATION_REPORT.md** - Implementation details for pause tag system with timing control syntax
|
||||
|
||||
**docs/Dev reports/REFACTORING_PLAN.md** - Architectural refactoring plan and migration strategy documentation
|
||||
|
||||
## Core Architecture Files
|
||||
|
||||
**__init__.py** - Main ComfyUI extension entry point defining WEB_DIRECTORY and delegating to nodes.py for node registration
|
||||
|
||||
**nodes.py** - Central module loader with sophisticated node discovery, conditional imports, and startup diagnostics for all TTS/VC/Audio features
|
||||
|
||||
## Unified Engine Architecture
|
||||
|
||||
### Engine Implementations
|
||||
|
||||
**engines/chatterbox/** - Complete ChatterBox TTS and VC engine implementation
|
||||
- **tts.py** - ChatterBox TTS engine with multilingual support, perth watermarking, and ComfyUI model management integration
|
||||
- **vc.py** - ChatterBox Voice Conversion with multilingual model loading
|
||||
- **language_models.py** - Language model registry for English, German, and Norwegian models
|
||||
- **audio_timing.py** - Audio timing utilities and time-stretching functionality
|
||||
- **streaming_model_manager.py** - Pre-loading and management of multiple language models for streaming workers
|
||||
- **batch_processor.py** - Batch processor for character group processing with true parallel inference
|
||||
- **character_grouper.py** - Character grouping utilities for batch processing optimization
|
||||
- **stateless_wrapper.py** - Thread-safe stateless wrapper eliminating shared state corruption in parallel generation
|
||||
- **models/** - Complete ChatterBox model architecture (S3Gen, T3, tokenizers, voice encoder)
|
||||
|
||||
**engines/chatterbox_official_23lang/** - ChatterBox Official 23-Lang multilingual engine implementation
|
||||
- **tts.py** - Main ChatterBox Official 23-Lang TTS engine with 23-language support
|
||||
- **vc.py** - Voice conversion engine supporting all 23 languages with refinement passes
|
||||
- **language_models.py** - Language model configuration and multilingual support definitions
|
||||
- **stateless_wrapper.py** - Thread-safe wrapper for multilingual parallel processing
|
||||
- **batch_processor.py**, **adaptive_processor.py**, **gpu_aware_processor.py** - Advanced processing systems
|
||||
- **models/** - Complete multilingual model architecture (S3Gen, T3, tokenizers, voice encoder) optimized for 23-language generation
|
||||
|
||||
**engines/f5tts/** - F5-TTS engine implementation and editing capabilities
|
||||
- **f5tts.py** - Main F5-TTS wrapper with ComfyUI integration
|
||||
- **f5tts_edit_engine.py** - Speech editing engine for targeted word replacement
|
||||
- **audio_compositing.py** - Audio compositing with crossfade and segment processing
|
||||
|
||||
**engines/higgs_audio/** - Higgs Audio 2 engine implementation with voice cloning
|
||||
- **higgs_audio.py** - Main Higgs Audio 2 wrapper with voice cloning and chunking support
|
||||
- **higgs_audio_downloader.py** - Model auto-download system for generation and tokenizer models
|
||||
- **boson_multimodal/** - Complete boson_multimodal implementation from HiggsAudio team
|
||||
|
||||
**engines/vibevoice_engine/** - VibeVoice engine implementation with multi-speaker and long-form capabilities
|
||||
- **vibevoice_engine.py** - Main VibeVoice wrapper with 90-minute generation and multi-speaker support
|
||||
- **vibevoice_downloader.py** - Model auto-download system for Microsoft VibeVoice models (1.5B and 7B)
|
||||
|
||||
**engines/step_audio_editx/** - Step Audio EditX engine implementation with audio editing capabilities
|
||||
- **step_audio_editx.py** - Main Step Audio EditX wrapper with zero-shot voice cloning and emotion/style/speed editing support
|
||||
- **step_audio_editx_downloader.py** - Model auto-download system for Step Audio EditX models
|
||||
- **step_audio_editx_impl/** - Complete Step Audio EditX implementation with FunASR integration
|
||||
- **config/** - Edit configuration and prompt definitions for emotion, style, speed, and paralinguistic editing
|
||||
- **funasr_detach/** - Complete FunASR framework implementation for speech processing (models, frontends, tokenizers, metrics)
|
||||
- **stepvocoder/** - CosyVoice2 BigVGAN vocoder implementation for high-quality audio synthesis
|
||||
- **tts.py**, **tts_infer.py** - TTS generation engines with multi-modal processing
|
||||
- **tokenizer.py** - Speech tokenization system
|
||||
- **model_loader.py** - Model loading and management utilities
|
||||
- **quantization/** - AWQ quantization support for VRAM reduction
|
||||
|
||||
**engines/cosyvoice/** - CosyVoice3 TTS engine with multilingual voice cloning
|
||||
- **cosyvoice.py** - Core CosyVoice3 engine wrapper with zero-shot, instruct, and cross-lingual modes
|
||||
- **cosyvoice_downloader.py** - Auto-download system for Fun-CosyVoice3-0.5B model
|
||||
- **impl/** - Bundled CosyVoice library from FunAudioLLM/CosyVoice repository
|
||||
|
||||
**engines/processors/cosyvoice_processor.py** - CosyVoice3 TTS processor with pause tags, character switching, and chunking support
|
||||
|
||||
**engines/qwen3_tts/** - Qwen3-TTS engine with CustomVoice (presets), VoiceDesign (text-to-voice), and Base (voice cloning) models
|
||||
- **qwen3_tts.py** - Core wrapper with VRAM management and 3 model type support (0.6B/1.7B sizes)
|
||||
- **qwen3_tts_downloader.py** - Auto-download system for 6 model variants and tokenizer
|
||||
- **impl/** - Bundled official Qwen3-TTS implementation
|
||||
|
||||
**engines/rvc/** - RVC (Real-time Voice Conversion) engine implementation
|
||||
- **__init__.py** - RVC engine initialization and ComfyUI integration
|
||||
- **hubert_downloader.py** - HuBERT model auto-download from Hugging Face with TTS/ folder organization
|
||||
- **impl/** - Complete RVC implementation with vocal separation and voice conversion
|
||||
- **rvc_audio.py** - Audio processing utilities for RVC pipeline
|
||||
- **rvc_downloader.py** - Model downloading system for RVC, UVR, and Karafan models
|
||||
- **rvc_utils.py** - Utility functions for device detection and file management
|
||||
- **uvr5_cli.py** - UVR5 vocal separation command-line interface
|
||||
- **lib/** - Core RVC libraries and model implementations
|
||||
- **separators.py** - Audio separation algorithms (MDX, VR, UVR5)
|
||||
- **model_utils.py** - Model loading and hash utilities
|
||||
- **audio.py** - Audio I/O and format conversion utilities
|
||||
- **utils.py** - General utilities and merge functions
|
||||
- **karafan/** - Karafan separation model implementation
|
||||
- **infer_pack/** - RVC inference and model loading components
|
||||
- **uvr5_pack/** - UVR5 vocal separation algorithms
|
||||
|
||||
### Engine Adapters
|
||||
|
||||
**engines/adapters/chatterbox_adapter.py** - ChatterBox engine adapter providing standardized interface for unified nodes
|
||||
|
||||
**engines/adapters/f5tts_adapter.py** - F5-TTS engine adapter with parameter mapping and cache integration
|
||||
|
||||
**engines/adapters/higgs_audio_adapter.py** - Higgs Audio 2 engine adapter with voice cloning and parameter validation
|
||||
|
||||
**engines/adapters/vibevoice_adapter.py** - VibeVoice engine adapter with multi-speaker format conversion and parameter mapping
|
||||
|
||||
**engines/adapters/step_audio_editx_adapter.py** - Step Audio EditX engine adapter with voice cloning and audio editing parameter mapping
|
||||
|
||||
**engines/adapters/cosyvoice_adapter.py** - CosyVoice3 engine adapter with zero-shot, instruct, and cross-lingual mode support and character switching
|
||||
|
||||
**engines/adapters/qwen3_tts_adapter.py** - Qwen3-TTS adapter with intelligent model selection (CustomVoice/VoiceDesign/Base), character-to-speaker mapping, and audio hash integration
|
||||
|
||||
**engines/adapters/chatterbox_streaming_adapter.py** - ChatterBox streaming adapter bridging existing implementation to universal streaming system
|
||||
|
||||
**engines/adapters/f5tts_streaming_adapter.py** - F5-TTS streaming adapter enabling parallel processing with language model switching
|
||||
**`__init__.py`** - ComfyUI entry point
|
||||
**`nodes.py`** - Central node discovery and registration
|
||||
**`install.py`** - Dependency installer with smart skip guards
|
||||
|
||||
## Node Implementations
|
||||
|
||||
### Engine Configuration Nodes
|
||||
### Unified Interface
|
||||
- `nodes/unified/tts_text_node.py` - Universal TTS text node
|
||||
- `nodes/unified/tts_srt_node.py` - Universal SRT node
|
||||
- `nodes/unified/voice_changer_node.py` - Universal voice conversion
|
||||
- `nodes/unified/asr_transcribe_node.py` - Universal ASR node
|
||||
|
||||
**nodes/engines/chatterbox_engine_node.py** - ChatterBox engine configuration node for language, device, and generation parameters
|
||||
### Shared / Special Nodes
|
||||
- `nodes/shared/character_voices_node.py` - Character voice management (NARRATOR_VOICE output)
|
||||
- `nodes/shared/unified_voice_designer_node.py` - Unified Qwen VoiceDesign, MOSS VoiceGenerator, and reference-free OmniVoice design
|
||||
- `nodes/shared/save_character_voice_node.py` - Explicit output node for saving any NARRATOR_VOICE into the established voice library
|
||||
- `nodes/omnivoice/omnivoice_instruction_builder_node.py` - OmniVoice voice-design instruction helper with custom visual builder UI
|
||||
- `nodes/text/phoneme_text_normalizer_node.py` - Multilingual text preprocessing
|
||||
- `nodes/text/asr_punctuation_truecase_node.py` - Standalone punctuation / truecase cleanup for raw ASR text
|
||||
- `nodes/subtitles/text_to_srt_builder_node.py` - Build SRT from transcript text plus timing data, or estimate timings from plain text
|
||||
- `nodes/subtitles/srt_advanced_options_node.py` - Subtitle readability / segmentation policy options
|
||||
- `nodes/text/tts_tag_editor_node.py` - 🏷️ Multiline TTS Tag Editor: rich text editor with character/language/parameter dropdowns, preset system, syntax highlighting, undo/redo — pairs with `web/string_multiline_tag_editor.js`
|
||||
- `nodes/step_audio_editx_special/step_audio_editx_audio_editor_node.py` - 🎨 Audio Editor: post-process ANY engine's audio with Step Audio EditX (14 emotions, 32 styles, paralinguistic effects like `<Laughter>`, speed control) — universal, not just for Step Audio EditX engine
|
||||
- `nodes/engines/index_tts_emotion_options_node.py` - IndexTTS-2 emotion radar chart
|
||||
|
||||
**nodes/engines/f5tts_engine_node.py** - F5-TTS engine configuration node with model selection and generation settings
|
||||
|
||||
**nodes/engines/higgs_audio_engine_node.py** - Higgs Audio 2 engine configuration node with voice cloning parameters and generation settings
|
||||
|
||||
**nodes/engines/vibevoice_engine_node.py** - VibeVoice engine configuration node with multi-speaker modes and long-form generation settings
|
||||
|
||||
**nodes/engines/rvc_engine_node.py** - RVC voice conversion node with pitch control and quality settings
|
||||
|
||||
**nodes/engines/index_tts_engine_node.py** - IndexTTS-2 engine configuration node with character voice selection and generation parameters
|
||||
|
||||
**nodes/engines/index_tts_emotion_options_node.py** - 🌈 IndexTTS-2 Emotion Vectors node with interactive radar chart for 8-emotion control
|
||||
|
||||
**nodes/engines/step_audio_editx_engine_node.py** - ⚙️ Step Audio EditX Engine configuration node with zero-shot voice cloning, quantization options (int4/int8), and generation parameters for both clone and edit modes
|
||||
|
||||
**nodes/engines/cosyvoice_engine_node.py** - ⚙️ CosyVoice3 Engine configuration node with mode selection (zero_shot, instruct, cross_lingual), speed control, and FP16 support
|
||||
|
||||
**nodes/engines/qwen3_tts_engine_node.py** - ⚙️ Qwen3-TTS Engine configuration node with unified voice_preset dropdown (9 speakers + zero-shot), model size selection (0.6B/1.7B), language support (10 languages), and conditional instruction field
|
||||
|
||||
### Unified Interface Nodes
|
||||
|
||||
**nodes/unified/tts_text_node.py** - Universal TTS text generation node working with any configured engine
|
||||
|
||||
**nodes/unified/tts_srt_node.py** - Universal SRT subtitle processing node - clean delegation layer that routes to engine-specific processors
|
||||
|
||||
**nodes/unified/voice_changer_node.py** - Universal voice conversion node with multilingual model support and flexible audio inputs
|
||||
|
||||
### Shared Components
|
||||
|
||||
**nodes/shared/character_voices_node.py** - Character voice management system providing NARRATOR_VOICE outputs for any TTS node
|
||||
|
||||
### Text Processing Nodes
|
||||
|
||||
**nodes/text/phoneme_text_normalizer_node.py** - 📝 Phoneme Text Normalizer with multilingual text preprocessing for improved TTS pronunciation. Features 4 processing methods: Pass-through, Unicode Decomposition (ą→a̧), IPA Phonemization (espeak backend), and Character Mapping (ASCII fallback). Supports auto-language detection and cross-platform phonemizer support.
|
||||
|
||||
### Engine-Specific Nodes
|
||||
|
||||
*Note: These are internal processors/engines used by the Unified nodes, not direct ComfyUI interface nodes. They handle engine-specific orchestration while the Unified nodes provide the user interface.*
|
||||
|
||||
**nodes/chatterbox/** - ChatterBox engine implementation nodes (called by Unified nodes)
|
||||
- **chatterbox_tts_node.py** - ChatterBox TTS engine node with streaming batch processing and character switching
|
||||
- **chatterbox_srt_node.py** - ChatterBox SRT engine node with streaming parallel processing and timing modes
|
||||
- **chatterbox_vc_node.py** - ChatterBox voice conversion engine with iterative refinement
|
||||
|
||||
**nodes/chatterbox_official_23lang/** - ChatterBox Official 23-Lang multilingual processors
|
||||
- **chatterbox_official_23lang_processor.py** - Main TTS processor supporting all 23 languages with character switching
|
||||
- **chatterbox_official_23lang_srt_processor.py** - SRT subtitle processor with multilingual timing and audio assembly
|
||||
- **chatterbox_official_23lang_vc_processor.py** - Voice conversion processor supporting all 23 languages with refinement passes
|
||||
|
||||
**nodes/f5tts/** - F5-TTS specific nodes
|
||||
- **f5tts_node.py** - Direct F5-TTS generation node
|
||||
- **f5tts_srt_node.py** - F5-TTS SRT processing with language grouping
|
||||
- **f5tts_edit_node.py** - F5-TTS speech editor for word replacement
|
||||
- **f5tts_edit_options_node.py** - Advanced F5-TTS editing configuration
|
||||
|
||||
**nodes/higgs_audio/** - Higgs Audio 2 internal processors
|
||||
- **higgs_audio_srt_processor.py** - Higgs Audio SRT orchestrator with multi-speaker support, character switching, and timing modes (internal processor used by Unified SRT node)
|
||||
|
||||
**nodes/vibevoice/** - VibeVoice internal processors
|
||||
- **vibevoice_processor.py** - VibeVoice TTS orchestrator with multi-speaker support and long-form generation handling (internal processor used by Unified TTS node)
|
||||
|
||||
**nodes/step_audio_editx/** - Step Audio EditX internal processors
|
||||
- **step_audio_editx_processor.py** - Step Audio EditX TTS orchestrator with zero-shot voice cloning and emotion/style/speed editing (internal processor used by Unified TTS node)
|
||||
- **step_audio_editx_srt_processor.py** - Step Audio EditX SRT processor with character switching and inline edit tag support for subtitle-based TTS workflows
|
||||
|
||||
**nodes/cosyvoice/** - CosyVoice3 internal processors
|
||||
- **cosyvoice_srt_processor.py** - CosyVoice3 SRT processor with character switching, timing modes, and interrupt handling
|
||||
- **cosyvoice_vc_processor.py** - CosyVoice3 voice conversion processor with zero-shot, instruct, and cross-lingual voice cloning modes
|
||||
|
||||
**nodes/qwen3_tts/** - Qwen3-TTS internal processors
|
||||
- **qwen3_tts_processor.py** - Qwen3-TTS TTS processor with character switching (CustomVoice presets + Base cloning + VoiceDesign), pause tags, language switching, and MD5-based character-to-speaker mapping
|
||||
- **qwen3_tts_voice_designer_node.py** - 🎨 Qwen3-TTS Voice Designer node creating voices from text descriptions with smart disk caching and unified NARRATOR_VOICE output
|
||||
|
||||
### Step Audio EditX Specialized Nodes
|
||||
|
||||
**nodes/step_audio_editx_special/step_audio_editx_audio_editor_node.py** - 🎨 Step Audio EditX - Audio Editor node for post-processing audio with 14 emotions (happy, sad, angry, etc.), 32 styles (whisper, serious, child, etc.), speed control (faster/slower), 10 paralinguistic effects (<Laughter>, <Breathing>, <Sigh>), denoising, and VAD. Supports iterative editing (1-5 passes) and iteration caching for experimentation
|
||||
|
||||
### Audio Processing System
|
||||
|
||||
**nodes/audio/analyzer_node.py** - Interactive Audio Wave Analyzer with web interface and timing extraction
|
||||
|
||||
**nodes/audio/analyzer_options_node.py** - Configuration options for audio analysis (silence detection, peak analysis)
|
||||
|
||||
**nodes/audio/recorder_node.py** - Voice recording node with microphone input and silence detection
|
||||
|
||||
**nodes/audio/vocal_removal_node.py** - Professional vocal/instrumental separation using UVR5, MDX, VR, and Karafan models
|
||||
|
||||
**nodes/audio/merge_audio_node.py** - Advanced audio mixing with multiple algorithms and pitch control
|
||||
|
||||
**nodes/audio/rvc_pitch_options_node.py** - Advanced pitch extraction settings for RVC voice conversion
|
||||
|
||||
### Video Analysis System
|
||||
|
||||
**nodes/video/mouth_movement_analyzer_node.py** - 🗣️ Silent Speech Analyzer for detecting mouth movement in silent video frames to extract precise timing for TTS SRT synchronization
|
||||
|
||||
**nodes/video/viseme_options_node.py** - Advanced viseme detection configuration with vowel/consonant detection, word prediction, and temporal analysis settings
|
||||
|
||||
### RVC Model Management
|
||||
|
||||
**nodes/models/load_rvc_model_node.py** - RVC model loader with FAISS index support and automatic downloading
|
||||
|
||||
## Base Classes and Foundation
|
||||
|
||||
**nodes/base/base_node.py** - Universal base class for all nodes with device resolution, model management, and cleanup
|
||||
|
||||
**nodes/base/f5tts_base_node.py** - F5-TTS specific base class extending universal foundation with F5-TTS requirements
|
||||
### Audio / Video Nodes
|
||||
- `nodes/audio/analyzer_node.py` - Audio Wave Analyzer
|
||||
- `nodes/audio/vocal_removal_node.py` - Vocal/instrumental separation
|
||||
- `nodes/audio/recorder_node.py` - Microphone recording
|
||||
- `nodes/audio/merge_audio_node.py` - Audio mixing
|
||||
- `nodes/video/mouth_movement_analyzer_node.py` - Silent speech timing extractor
|
||||
- `nodes/models/load_rvc_model_node.py` - RVC model loader
|
||||
|
||||
## Utility Systems
|
||||
|
||||
### Model Management
|
||||
|
||||
**utils/models/manager.py** - Intelligent model discovery and caching with multilingual support for both TTS and VC models, integrated with ComfyUI model management and extra_model_paths.yaml for shared storage support
|
||||
|
||||
**utils/models/smart_loader.py** - Universal smart model loader preventing duplicate model loading across all engines and modes
|
||||
|
||||
**utils/models/f5tts_manager.py** - F5-TTS specific model management extending base manager functionality, now with ComfyUI integration
|
||||
|
||||
**utils/models/language_mapper.py** - Language-to-model mapping system with fallback support
|
||||
|
||||
**utils/models/fallback_utils.py** - Model fallback and error recovery utilities
|
||||
|
||||
**utils/models/comfyui_model_wrapper/** - Modular ComfyUI model wrapper system with engine-specific handlers (base_wrapper.py, model_manager.py, cache_utils.py, engine_handlers.py) enabling TTS models to integrate with ComfyUI's native model management
|
||||
|
||||
**utils/models/unified_model_interface.py** - Universal model loading interface providing standardized factory pattern for all engines (ChatterBox, F5-TTS, Higgs Audio, RVC, Audio Separation) with ComfyUI integration
|
||||
|
||||
**utils/models/factory_config.py** - Standardized `ModelLoadConfig` dataclass for consistent factory parameters across all TTS engines
|
||||
|
||||
**utils/models/engine_registry.py** - Engine capability registry defining what each engine supports (voice conversion, multilingual switching, corruption recovery, etc.)
|
||||
|
||||
**utils/models/fallback_handler.py** - Generic fallback chain utility for graceful model loading degradation (e.g., try local → download → fallback language)
|
||||
|
||||
**utils/models/exceptions.py** - Standardized exception hierarchy for all model loading operations (TTSModelLoadingError, TTSModelNotFoundError, TTSDeviceError, etc.)
|
||||
|
||||
**utils/models/extra_paths.py** - ComfyUI extra_model_paths.yaml integration system enabling shared model directories across multiple ComfyUI installations with intelligent path resolution and fallback support
|
||||
|
||||
### Audio Processing
|
||||
|
||||
**utils/audio/processing.py** - Comprehensive audio utilities for tensor manipulation, normalization, and format conversion
|
||||
|
||||
**utils/ffmpeg_utils.py** - Centralized FFmpeg dependency handling with graceful fallbacks for audio conversion and timing analysis
|
||||
|
||||
**utils/audio/analysis.py** - Advanced audio analysis including waveform processing, silence detection, and timing extraction
|
||||
|
||||
**utils/audio/audio_hash.py** - Centralized content-based hashing for consistent cache keys across all processing modes
|
||||
|
||||
**utils/audio/cache.py** - Unified caching system for TTS engines with engine-specific cache management
|
||||
|
||||
**utils/audio/chunk_combiner.py** - Modular chunk combination utility with smart auto-selection based on text analysis (sentence boundaries, commas, forced splits) and detailed timing information
|
||||
|
||||
**utils/audio/chunk_timing.py** - Chunk combination timing information helper providing standardized timing info integration across all TTS engines
|
||||
|
||||
### Text Processing
|
||||
|
||||
**utils/text/character_parser.py** - Universal character switching system with [CharacterName] tag parsing and language-aware processing
|
||||
|
||||
**utils/text/chunking.py** - Enhanced text chunking with sentence boundary detection and character limits
|
||||
|
||||
**utils/text/pause_processor.py** - Pause tag parsing supporting [pause:xx] syntax for precise timing control
|
||||
|
||||
**utils/text/segment_parameters.py** - Per-segment parameter system enabling inline control of seed, temperature, cfg, speed, and other engine-specific parameters via pipe-separated syntax like [Alice|seed:42|temperature:0.5]. Supports aliases (cfg_weight→cfg, temp→temperature) and case-insensitive input with universal parameter filtering and validation across all TTS engines
|
||||
|
||||
**utils/text/phonemizer_utils.py** - F5-TTS multilingual phonemization system with IPA conversion, cross-platform backend support (espeak-phonemizer-windows/phonemizer), smart language detection, and model-specific exceptions for optimal quality
|
||||
|
||||
**utils/text/step_audio_editx_special_tags.py** - Step Audio EditX inline edit tag system with tag conversion, stripping, detection, and UI option generation for paralinguistic effects, emotions, styles, and speed control. Enables universal post-processing of TTS output across all engines
|
||||
|
||||
**utils/text/cosyvoice_special_tags.py** - CosyVoice3 special tag system for parsing and handling CosyVoice-specific inline tags and control tokens for emotion, style, and paralinguistic effects
|
||||
|
||||
### Voice Management
|
||||
|
||||
**utils/voice/discovery.py** - Enhanced voice file discovery with multi-path fallback system (models/voices/, models/TTS/voices/, extra_model_paths.yaml directories, voices_examples/), character mapping, and alias loading with priority support
|
||||
|
||||
**utils/voice/multilingual_engine.py** - Central orchestrator for multilingual TTS with language switching optimization
|
||||
|
||||
### System Integration
|
||||
|
||||
**utils/system/import_manager.py** - Smart dependency resolution managing bundled vs system installations
|
||||
|
||||
**utils/system/subprocess.py** - Safe subprocess execution with error handling and process isolation
|
||||
|
||||
### Compatibility System
|
||||
|
||||
**utils/compatibility/transformers_patches.py** - Centralized transformers compatibility patches (monkey patches) managing version compatibility across different transformers library versions including FlashAttentionKwargs, BaseStreamer, DynamicCache properties, and VibeVoice generation method signatures
|
||||
|
||||
**utils/compatibility/numba_compat.py** - Centralized Numba/Librosa compatibility system with fast startup testing and intelligent JIT fallback management for Python 3.13+ compatibility
|
||||
|
||||
### Downloads
|
||||
|
||||
**utils/downloads/unified_downloader.py** - Centralized download system for all engines eliminating HuggingFace cache duplication with direct HTTP downloads to organized TTS/ structure, featuring full extra_model_paths.yaml support for shared model directories
|
||||
|
||||
**utils/downloads/model_downloader.py** - RVC-specific model auto-download system with direct downloads (legacy, integrated with unified system)
|
||||
|
||||
### Timing and SRT
|
||||
|
||||
**utils/timing/engine.py** - Advanced SRT timing engine with smart audio synchronization
|
||||
|
||||
**utils/timing/assembly.py** - Audio segment assembly supporting multiple timing modes
|
||||
|
||||
**utils/timing/parser.py** - SRT subtitle parsing with timestamp extraction and validation
|
||||
|
||||
**utils/timing/reporting.py** - Timing analysis and report generation
|
||||
|
||||
### Streaming System
|
||||
|
||||
**utils/streaming/__init__.py** - Universal streaming system initialization
|
||||
|
||||
**utils/streaming/streaming_types.py** - Universal data structures (StreamingSegment, StreamingResult) eliminating format conversions
|
||||
|
||||
**utils/streaming/streaming_interface.py** - Abstract streaming interface for engine adapters
|
||||
|
||||
**utils/streaming/streaming_coordinator.py** - Universal coordinator replacing all format-specific routers
|
||||
|
||||
**utils/streaming/work_queue_processor.py** - Engine-agnostic parallel processing system
|
||||
|
||||
## Web Interface Components
|
||||
|
||||
### TTS Tag Editor (Modularized Architecture)
|
||||
|
||||
**web/string_multiline_tag_editor.js**, **widget-event-handlers.js**, **widget-ui-builder.js**, **widget-parameter-section.js**, **widget-preset-system.js**, **editor-state.js**, **tag-utilities.js**, **syntax-highlighter.js**, **font-controls.js** - Modular TTS tag editor with event handling, UI sections, parameters, presets, state management, tag utilities, syntax highlighting, and font controls for character/parameter tag editing with undo/redo, formatting, and persistence
|
||||
|
||||
### Audio Analysis
|
||||
|
||||
**web/audio_analyzer_interface.js** - Main ComfyUI integration and node communication for Audio Wave Analyzer
|
||||
|
||||
**web/audio_analyzer_core.js** - Core JavaScript functionality for waveform visualization
|
||||
|
||||
**web/audio_analyzer_ui.js** - User interface components and control elements
|
||||
|
||||
**web/audio_analyzer_visualization.js** - Waveform rendering and canvas-based visualization
|
||||
|
||||
**web/audio_analyzer_regions.js** - Timing region management and interaction system
|
||||
|
||||
**web/audio_analyzer_controls.js** - Audio playback controls and user interaction handling
|
||||
|
||||
**web/audio_analyzer_widgets.js** - Custom ComfyUI widget implementations
|
||||
|
||||
**web/audio_analyzer_drawing.js** - Canvas drawing utilities and rendering functions
|
||||
|
||||
**web/audio_analyzer_events.js** - Event handling for mouse and keyboard interactions
|
||||
|
||||
**web/audio_analyzer_layout.js** - Layout management and UI positioning
|
||||
|
||||
**web/audio_analyzer_node_integration.js** - Node integration bridge for ComfyUI communication
|
||||
|
||||
**web/chatterbox_voice_capture.js** - Voice recording interface for microphone input
|
||||
|
||||
**web/index_tts_emotion_radar.js** - ComfyUI integration for 🌈 IndexTTS-2 Emotion Vectors radar chart
|
||||
|
||||
**web/emotion_radar_canvas_widget.js** - Interactive radar chart widget with dynamic color blending and click/drag controls
|
||||
|
||||
**web/chatterbox_srt_showcontrol.js** - SRT subtitle display and timing controls
|
||||
|
||||
**web/step_audio_editx_showcontrol.js** - Step Audio EditX SRT subtitle display and timing controls
|
||||
|
||||
**web/qwen3_tts_widgets.js** - Qwen3-TTS widget locking system for conditional instruction field based on voice_preset and model_size
|
||||
|
||||
**web/audio_analyzer.css** - Styling for audio analyzer interface components
|
||||
|
||||
## Development Tools
|
||||
|
||||
**scripts/bump_version_enhanced.py** - Advanced automated version management with changelog generation and git integration
|
||||
|
||||
**scripts/version_utils.py** - Version management utilities and helper functions
|
||||
|
||||
## Configuration Files
|
||||
|
||||
**requirements.txt** - Core dependencies including ChatterboxTTS, audio processing libraries, and optional F5-TTS dependencies
|
||||
|
||||
**pyproject.toml** - Project metadata with ComfyUI registry configuration and dependency specifications
|
||||
|
||||
## Example Workflows
|
||||
|
||||
**example_workflows/📺 Chatterbox SRT.json** - Complete SRT processing workflow with ChatterBox engine
|
||||
|
||||
**example_workflows/Chatterbox integration.json** - General ChatterBox TTS and Voice Conversion demonstration
|
||||
|
||||
**example_workflows/👄 F5-TTS Speech Editor Workflow.json** - F5-TTS speech editing with Audio Wave Analyzer
|
||||
|
||||
**example_workflows/🎤 📺 F5-TTS SRT and Normal Generation.json** - F5-TTS SRT processing and standard generation
|
||||
|
||||
## Voice Examples
|
||||
|
||||
**voices_examples/** - Character voice reference files with both audio samples and text descriptions
|
||||
- **male/** - Male voice examples with reference audio and text
|
||||
- **female/** - Female voice examples with reference audio and text
|
||||
- **higgs_audio/** - Higgs Audio 2 voice presets (belinda, en_woman, en_man, chadwick, vex, etc.) with config.json
|
||||
- **#character_alias_map.txt** - Character alias mapping configuration
|
||||
- Individual character voice files (Clint Eastwood, David Attenborough, Morgan Freeman, Sophie Anderson)
|
||||
|
||||
## Project Management
|
||||
|
||||
**IgnoredForGitHubDocs/** - Development documentation excluded from main repository
|
||||
- **FOLDER_STRUCTURE_REFACTORING_PLAN.md** - Architectural refactoring planning
|
||||
- **CHATTERBOX_MODEL_BUG_REPORT.md** - Bug tracking and resolution documentation
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
The TTS Audio Suite follows a **unified modular architecture** where:
|
||||
|
||||
1. **Engine Nodes** (`nodes/engines/`) provide user configuration interfaces
|
||||
2. **Unified Nodes** (`nodes/unified/`) serve as clean delegation layers routing to appropriate engine processors
|
||||
3. **Engine Processors** (`nodes/chatterbox/`, `nodes/f5tts/`, `nodes/higgs_audio/`, `nodes/step_audio_editx/`) handle engine-specific orchestration and workflow logic
|
||||
4. **Engine Implementations** (`engines/`) handle the actual TTS/VC processing and model inference
|
||||
5. **Adapters** (`engines/adapters/`) bridge low-level engines to higher-level processors
|
||||
6. **Inline Edit System** (`utils/text/step_audio_editx_special_tags.py`) provides universal post-processing with Step Audio EditX across all engines
|
||||
7. **Utilities** (`utils/`) provide shared functionality across all components
|
||||
8. **Web Interface** (`web/`) enables interactive features like audio analysis
|
||||
|
||||
This layered design ensures consistent user experience while allowing engine-specific optimizations. The unified nodes act as thin delegation layers, eliminating code duplication and maintaining architectural consistency across all TTS engines. The inline edit tag system adds a universal post-processing layer, enabling Step Audio EditX capabilities (emotion, style, speed, paralinguistic effects) to be applied to any TTS engine's output.
|
||||
### Model Management (`utils/models/`)
|
||||
- `unified_model_interface.py` - Universal factory pattern for all engines
|
||||
- `engine_registry.py` - Engine capability definitions
|
||||
- `factory_config.py` - standardized model load config, runtime mode/profile normalization
|
||||
- `manager.py` - Model discovery and caching
|
||||
- `comfyui_model_wrapper/` - ComfyUI native model management integration
|
||||
- `extra_paths.py` - extra_model_paths.yaml support
|
||||
|
||||
### Isolated Runtimes (`utils/runtimes/`)
|
||||
- `profiles.py` - named runtime profiles (`vibevoice_transformers4_shared`, dedicated variants, etc.)
|
||||
- `launcher.py` - runtime bootstrap, venv creation, Windows toolchain env setup
|
||||
- `session.py`, `protocol.py` - JSONL worker transport and message protocol
|
||||
- `bootstrap.py` - shared runtime bootstrap helpers
|
||||
- `vibevoice_proxy.py`, `qwen3_tts_proxy.py`, `qwen3_asr_proxy.py`, `higgs_audio_proxy.py` - parent-process proxies
|
||||
- `workers/` - worker subprocess entrypoints for VibeVoice, Qwen3-TTS, Qwen3-ASR/aligner, Higgs Audio
|
||||
- Current shared legacy T4 runtime profile is reused by:
|
||||
- VibeVoice / Kugel
|
||||
- Qwen3-TTS
|
||||
- Qwen3-ASR and Granite's optional Qwen forced aligner
|
||||
- Higgs Audio 2
|
||||
|
||||
### Audio (`utils/audio/`)
|
||||
- `processing.py` - Tensor manipulation, normalization, format conversion
|
||||
- `cache.py` - Unified TTS caching system
|
||||
- `chunk_combiner.py` - Smart chunk combination (auto/concatenate/crossfade/silence_padding)
|
||||
- `chunk_timing.py` - Standardized chunk timing info across engines
|
||||
- `audio_hash.py` - Content-based cache key hashing
|
||||
- `analysis.py` - Waveform analysis, silence detection, timing extraction
|
||||
- `edit_post_processor.py` - Batch inline edit tag post-processing
|
||||
|
||||
### Text (`utils/text/`)
|
||||
- `character_parser.py` - [CharacterName] tag parsing
|
||||
- `chunking.py` - Sentence-boundary-aware text splitting
|
||||
- `pause_processor.py` - [pause:Ns] tag parsing and silence generation
|
||||
- `segment_parameters.py` - Per-segment parameter parsing ([seed:42|temp:0.5])
|
||||
- `step_audio_editx_special_tags.py` - Inline edit tag system
|
||||
- `cosyvoice_special_tags.py` - CosyVoice3 native tags
|
||||
- `phonemizer_utils.py` - F5-TTS multilingual phonemization
|
||||
|
||||
### Timing / SRT (`utils/timing/`)
|
||||
- `engine.py` - SRT timing engine
|
||||
- `assembly.py` - Audio segment assembly (multiple timing modes)
|
||||
- `parser.py` - SRT parsing and validation
|
||||
- `reporting.py` - Timing report generation
|
||||
|
||||
### Other Utils
|
||||
- `utils/voice/discovery.py` - Voice discovery, user-voice priority, and multi-path fallback
|
||||
- `utils/voice/designers.py` - Whitelisted voice-designer provider registry
|
||||
- `utils/voice/character_saver.py` - Shared `.wav` / `.reference.txt` / `.txt` character persistence
|
||||
- `utils/voice/character_logging.py` - Shared resolved voice labels and boxed prompt previews
|
||||
- `utils/downloads/unified_downloader.py` - Centralized HF download system
|
||||
- `utils/compatibility/transformers_patches.py` - transformers version compatibility patches
|
||||
- `utils/compatibility/numba_compat.py` - Numba/Librosa Python 3.13+ compatibility
|
||||
- `utils/ffmpeg_utils.py` - FFmpeg with graceful fallback
|
||||
- `utils/asr/` - ASR types, adapter registry, pipeline
|
||||
|
||||
## Web Interface
|
||||
|
||||
### TTS Tag Editor (modular JS)
|
||||
`web/string_multiline_tag_editor.js` + `widget-*.js`, `editor-state.js`, `tag-utilities.js`, `syntax-highlighter.js`, `font-controls.js`
|
||||
|
||||
### Audio Analyzer
|
||||
`web/audio_analyzer_*.js` (core, ui, visualization, regions, controls, widgets, drawing, events, layout, node_integration)
|
||||
|
||||
### Other Web Files
|
||||
- `web/chatterbox_voice_capture.js` - Microphone recording UI
|
||||
- `web/index_tts_emotion_radar.js` + `emotion_radar_canvas_widget.js` - IndexTTS-2 radar chart
|
||||
- `web/qwen3_tts_widgets.js` - Qwen model-specific widget enablement and legacy workflow migration
|
||||
- `web/asr_srt_preset_widgets.js` - ASR SRT preset locking
|
||||
|
||||
## Scripts & Config
|
||||
- `scripts/bump_version_enhanced.py` - Version bump with changelog (use `patch`/`minor`/`major`)
|
||||
- `scripts/generate_engine_tables.py` - Regenerate all docs from YAML (`--readme` flag for README too)
|
||||
- `requirements.txt`, `pyproject.toml` - Dependencies and project metadata
|
||||
|
||||
+483
-120
@@ -8,17 +8,70 @@ Unified architecture supporting ChatterBox, F5-TTS, and future engines like RVC:
|
||||
• 🎭 Character Voices (voice reference management)
|
||||
"""
|
||||
|
||||
# Note: PYTORCH_ALLOC_CONF should be set in ComfyUI launch script if needed
|
||||
# Setting it here causes "allocator mismatch" errors because ComfyUI already imported torch
|
||||
|
||||
# Import from the main nodes.py file which handles the new unified architecture
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ComfyUI 0.12+ owns a top-level ``utils`` package, while this long-standing
|
||||
# node pack also imports its helpers through ``utils.*``. Preserve ComfyUI's
|
||||
# loaded package and extend only its module search path with this pack's utils.
|
||||
_project_root = os.path.dirname(__file__)
|
||||
_suite_utils_root = os.path.abspath(os.path.join(_project_root, "utils"))
|
||||
if _project_root in sys.path:
|
||||
sys.path.remove(_project_root)
|
||||
sys.path.insert(0, _project_root)
|
||||
|
||||
_loaded_utils = sys.modules.get("utils")
|
||||
if _loaded_utils is None:
|
||||
import utils as _loaded_utils
|
||||
|
||||
_utils_search_path = getattr(_loaded_utils, "__path__", None)
|
||||
if _utils_search_path is None:
|
||||
raise ImportError(
|
||||
"TTS Audio Suite cannot extend the loaded top-level 'utils' module because it is not a package"
|
||||
)
|
||||
|
||||
_normalized_utils_paths = {os.path.normcase(os.path.abspath(path)) for path in _utils_search_path}
|
||||
if os.path.normcase(_suite_utils_root) not in _normalized_utils_paths:
|
||||
_utils_search_path.insert(0, _suite_utils_root)
|
||||
|
||||
# When this pack is imported before ComfyUI imports its own helpers, locate the
|
||||
# active ComfyUI utils directory by its stable core modules and add it as the
|
||||
# fallback side of the same package search path.
|
||||
for _search_root in sys.path:
|
||||
_candidate_utils = os.path.abspath(os.path.join(_search_root or os.curdir, "utils"))
|
||||
_normalized_candidate = os.path.normcase(_candidate_utils)
|
||||
if _normalized_candidate in _normalized_utils_paths or _normalized_candidate == os.path.normcase(_suite_utils_root):
|
||||
continue
|
||||
if all(os.path.isfile(os.path.join(_candidate_utils, filename)) for filename in ("extra_config.py", "install_util.py")):
|
||||
_utils_search_path.append(_candidate_utils)
|
||||
_normalized_utils_paths.add(_normalized_candidate)
|
||||
|
||||
from utils.hf_download_logging import configure_hf_download_logging
|
||||
|
||||
|
||||
# Keep every engine's Hugging Face download output readable. Download failures
|
||||
# are still reported by the suite's downloader error handling.
|
||||
configure_hf_download_logging()
|
||||
|
||||
# Note: PyTorch inductor patches removed - not needed for PyTorch 2.10+ with triton-windows 3.6+
|
||||
# Qwen3-TTS torch.compile optimizations require:
|
||||
# - PyTorch 2.10.0+ with CUDA 13.0
|
||||
# - triton-windows 3.6.0+ (Windows) or triton 3.6.0+ (Linux)
|
||||
# See docs/qwen3_tts_optimizations.md for installation instructions
|
||||
|
||||
# Enable TensorFloat32 for better performance on Ampere+ GPUs (RTX 30xx+)
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.set_float32_matmul_precision('high')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CRITICAL: Apply compatibility patches BEFORE any other imports
|
||||
#
|
||||
# Timing is critical: The monkey-patches must be applied BEFORE libraries are imported
|
||||
# by any other module. If we wait until after other imports, they will already
|
||||
# be loaded and our monkey-patches won't affect the already-imported module references.
|
||||
#
|
||||
# PyTorch patches solve TWO PyTorch 2.9 issues:
|
||||
# 1. TorchCodec DLL incompatibility on Windows - Global patch uses scipy instead
|
||||
# 2. PyTorch 2.9's changed torchaudio.load() returning raw int16 - safe_load_audio() normalizes
|
||||
@@ -38,59 +91,45 @@ try:
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not apply PyTorch patches: {e}")
|
||||
|
||||
# Apply transformers compatibility patches
|
||||
try:
|
||||
# Load transformers_patches directly by file path
|
||||
transformers_patches_path = os.path.join(os.path.dirname(__file__), "utils", "compatibility", "transformers_patches.py")
|
||||
spec = importlib.util.spec_from_file_location("transformers_patches_module", transformers_patches_path)
|
||||
transformers_patches_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(transformers_patches_module)
|
||||
# Transformers compatibility patches DEFERRED to first engine use.
|
||||
# The patches module is deprecated (all patches are for old transformers versions),
|
||||
# and importing it eagerly pulls in transformers (~1.3s).
|
||||
# Patches will be applied lazily when an engine first imports transformers.
|
||||
_transformers_patches_applied = False
|
||||
def _apply_transformers_patches_once():
|
||||
"""Apply transformers patches lazily, on first engine use."""
|
||||
global _transformers_patches_applied
|
||||
if _transformers_patches_applied:
|
||||
return
|
||||
_transformers_patches_applied = True
|
||||
try:
|
||||
transformers_patches_path = os.path.join(os.path.dirname(__file__), "utils", "compatibility", "transformers_patches.py")
|
||||
spec = importlib.util.spec_from_file_location("transformers_patches_module", transformers_patches_path)
|
||||
transformers_patches_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(transformers_patches_module)
|
||||
transformers_patches_module.apply_transformers_patches(verbose=True)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not apply Transformers patches: {e}")
|
||||
|
||||
# Apply the patches (will only apply on transformers 4.54+, silently skip on older versions)
|
||||
transformers_patches_module.apply_transformers_patches(verbose=True)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not apply Transformers patches: {e}")
|
||||
|
||||
# Smart Numba Compatibility System - tests and applies fixes only when needed
|
||||
try:
|
||||
# Load numba_compat directly by file path to avoid package import issues
|
||||
numba_compat_path = os.path.join(os.path.dirname(__file__), "utils", "compatibility", "numba_compat.py")
|
||||
spec = importlib.util.spec_from_file_location("numba_compat_module", numba_compat_path)
|
||||
numba_compat_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(numba_compat_module)
|
||||
|
||||
# Apply smart compatibility setup (fast startup test)
|
||||
compatibility_results = numba_compat_module.setup_numba_compatibility(quick_startup=True, verbose=False)
|
||||
except Exception:
|
||||
# Fallback to simple approach if compatibility module not found
|
||||
import sys
|
||||
import os
|
||||
if sys.version_info >= (3, 13):
|
||||
# Basic test: try librosa.stft and apply workaround if it fails
|
||||
try:
|
||||
import numpy as np
|
||||
import librosa
|
||||
test_audio = np.random.randn(512).astype(np.float32)
|
||||
_ = librosa.stft(test_audio, hop_length=256, n_fft=512)
|
||||
# Only show when there's a problem, not success
|
||||
# Mark that we've tested numba compatibility
|
||||
import sys
|
||||
sys.modules['__main__']._tts_numba_tested = True
|
||||
except Exception as e:
|
||||
if "'function' object has no attribute 'get_call_template'" in str(e):
|
||||
os.environ['NUMBA_DISABLE_JIT'] = '1'
|
||||
os.environ['NUMBA_ENABLE_CUDASIM'] = '1'
|
||||
try:
|
||||
import numba
|
||||
numba.config.DISABLE_JIT = True
|
||||
except ImportError:
|
||||
pass
|
||||
print("🔧 Applied numba JIT workaround for Python 3.13 compatibility")
|
||||
else:
|
||||
print(f"⚠️ Librosa test failed with different error: {e}")
|
||||
else:
|
||||
# Only show warning when JIT is disabled (indicates a problem)
|
||||
pass
|
||||
# Numba/Librosa compatibility check at startup.
|
||||
# Do NOT force NUMBA_DISABLE_JIT on Python 3.13 anymore:
|
||||
# newer stacks (for example numba 0.64 + librosa 0.11) can work normally,
|
||||
# and forcing the env var can itself trigger the get_call_template crash.
|
||||
# For older Python + NumPy 2.x, keep the existing thorough compatibility test.
|
||||
if sys.version_info < (3, 13):
|
||||
try:
|
||||
import numpy as _np
|
||||
if int(_np.__version__.split('.')[0]) >= 2:
|
||||
numba_compat_path = os.path.join(os.path.dirname(__file__), "utils", "compatibility", "numba_compat.py")
|
||||
_spec = importlib.util.spec_from_file_location("numba_compat_module", numba_compat_path)
|
||||
_numba_compat = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_numba_compat)
|
||||
_numba_compat.setup_numba_compatibility(quick_startup=False, verbose=True)
|
||||
except Exception:
|
||||
# If the compatibility test itself crashes, that means numba JIT is broken —
|
||||
# disable it and warn the user.
|
||||
os.environ['NUMBA_DISABLE_JIT'] = '1'
|
||||
print("⚠️ TTS Audio Suite: Numba JIT crash detected at startup — disabling JIT (NUMBA_DISABLE_JIT=1)")
|
||||
|
||||
# TorchCodec note: Removed torchcodec dependency to eliminate FFmpeg system requirement
|
||||
# torchaudio.load() works fine with fallback backends (soundfile, scipy)
|
||||
@@ -98,9 +137,38 @@ import warnings
|
||||
import sys
|
||||
import os
|
||||
|
||||
def check_dependencies():
|
||||
"""Fast check for critical dependencies without importing them into memory"""
|
||||
critical_packages = ['torch', 'torchaudio', 'transformers', 'librosa', 'numba', 'soundfile', 'accelerate']
|
||||
missing = []
|
||||
|
||||
for pkg in critical_packages:
|
||||
# Avoid importlib.util.find_spec for namespace packages or if spec is None
|
||||
try:
|
||||
if importlib.util.find_spec(pkg) is None:
|
||||
missing.append(pkg)
|
||||
except Exception:
|
||||
missing.append(pkg)
|
||||
|
||||
if missing:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"⚠️ TTS AUDIO SUITE: CRITICAL DEPENDENCIES MISSING ⚠️")
|
||||
print(f"{'='*80}")
|
||||
print(f"The following required packages are missing: {', '.join(missing)}")
|
||||
print(f"")
|
||||
install_script = os.path.join(os.path.dirname(__file__), "install.py")
|
||||
print(f"Please run the TTS Audio Suite installation script:")
|
||||
print(f'"{sys.executable}" "{install_script}"')
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Version disclosure for troubleshooting
|
||||
def print_critical_versions():
|
||||
"""Print versions of critical packages for troubleshooting"""
|
||||
"""Print versions of critical packages for troubleshooting.
|
||||
|
||||
Uses importlib.metadata to read versions without importing the actual
|
||||
packages. This avoids pulling in transformers (~1.3s), librosa (~0.7s),
|
||||
and other heavy modules just to print a version string at startup.
|
||||
"""
|
||||
critical_packages = [
|
||||
('numpy', 'NumPy'),
|
||||
('librosa', 'Librosa'),
|
||||
@@ -112,18 +180,19 @@ def print_critical_versions():
|
||||
('soundfile', 'SoundFile'),
|
||||
]
|
||||
|
||||
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
||||
|
||||
version_info = []
|
||||
for pkg_name, display_name in critical_packages:
|
||||
try:
|
||||
module = __import__(pkg_name)
|
||||
version = getattr(module, '__version__', 'unknown')
|
||||
version_info.append(f"{display_name} {version}")
|
||||
except ImportError:
|
||||
ver = _pkg_version(pkg_name)
|
||||
version_info.append(f"{display_name} {ver}")
|
||||
except PackageNotFoundError:
|
||||
version_info.append(f"{display_name} not installed")
|
||||
|
||||
print(f"ℹ️ Critical package versions: {', '.join(version_info)}")
|
||||
|
||||
def check_ffmpeg_availability():
|
||||
def check_ffmpeg_availability():
|
||||
"""Check ffmpeg availability and log status"""
|
||||
try:
|
||||
# Load ffmpeg_utils directly by file path to avoid package import issues
|
||||
@@ -152,8 +221,9 @@ def check_ffmpeg_availability():
|
||||
print("⚠️ FFmpeg not found - using fallback audio processing (reduced quality)")
|
||||
print("💡 Install FFmpeg for optimal performance: https://ffmpeg.org/download.html")
|
||||
|
||||
# Print versions and check dependencies immediately for troubleshooting
|
||||
print_critical_versions()
|
||||
# Print versions and check dependencies immediately for troubleshooting
|
||||
check_dependencies()
|
||||
print_critical_versions()
|
||||
check_ffmpeg_availability()
|
||||
|
||||
# Check for old ChatterBox extension conflict
|
||||
@@ -228,24 +298,33 @@ if 'utils' in sys.modules:
|
||||
for key in to_delete:
|
||||
del sys.modules[key]
|
||||
|
||||
# Get the path to the nodes.py file
|
||||
nodes_py_path = os.path.join(os.path.dirname(__file__), "nodes.py")
|
||||
# In pytest harness mode, avoid bootstrapping full ComfyUI node graph.
|
||||
if os.environ.get("COMFYUI_TESTING") == "1":
|
||||
IS_DEV = False
|
||||
VERSION = "test"
|
||||
SEPARATOR = "=" * 70
|
||||
VERSION_DISPLAY = "test"
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
else:
|
||||
# Get the path to the nodes.py file
|
||||
nodes_py_path = os.path.join(os.path.dirname(__file__), "nodes.py")
|
||||
|
||||
# Load nodes.py as a module
|
||||
spec = importlib.util.spec_from_file_location("nodes_main", nodes_py_path)
|
||||
nodes_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(nodes_module)
|
||||
# Load nodes.py as a module
|
||||
spec = importlib.util.spec_from_file_location("nodes_main", nodes_py_path)
|
||||
nodes_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(nodes_module)
|
||||
|
||||
# Import constants and utilities
|
||||
IS_DEV = nodes_module.IS_DEV
|
||||
VERSION = nodes_module.VERSION
|
||||
SEPARATOR = nodes_module.SEPARATOR
|
||||
VERSION_DISPLAY = nodes_module.VERSION_DISPLAY
|
||||
# Import constants and utilities
|
||||
IS_DEV = nodes_module.IS_DEV
|
||||
VERSION = nodes_module.VERSION
|
||||
SEPARATOR = nodes_module.SEPARATOR
|
||||
VERSION_DISPLAY = nodes_module.VERSION_DISPLAY
|
||||
|
||||
# The new unified architecture handles all node registration in nodes.py
|
||||
# Just import the mappings that nodes.py creates
|
||||
NODE_CLASS_MAPPINGS = nodes_module.NODE_CLASS_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS = nodes_module.NODE_DISPLAY_NAME_MAPPINGS
|
||||
# The new unified architecture handles all node registration in nodes.py
|
||||
# Just import the mappings that nodes.py creates
|
||||
NODE_CLASS_MAPPINGS = nodes_module.NODE_CLASS_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS = nodes_module.NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# Extension info
|
||||
__version__ = VERSION_DISPLAY
|
||||
@@ -258,35 +337,68 @@ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# Register API endpoint for widget data
|
||||
def setup_api_routes():
|
||||
"""Setup API routes for widget communication"""
|
||||
try:
|
||||
import json
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
def setup_api_routes():
|
||||
"""Setup API routes for widget communication"""
|
||||
try:
|
||||
import json
|
||||
import folder_paths
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
def _get_ui_data_dir():
|
||||
base_dir = os.path.join(folder_paths.get_system_user_directory("tts_audio_suite"), "ui")
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
return base_dir
|
||||
|
||||
def _get_omnivoice_preset_library_path():
|
||||
return os.path.join(_get_ui_data_dir(), "omnivoice_instruction_builder_presets.json")
|
||||
|
||||
from utils.voice.alias_api import register_character_alias_routes
|
||||
register_character_alias_routes(PromptServer.instance.routes, web)
|
||||
from utils.audio_cpp.capability_api import register_audio_cpp_capability_routes
|
||||
register_audio_cpp_capability_routes(PromptServer.instance.routes, web)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/index-tts-emotion-presets")
|
||||
async def get_index_tts_emotion_presets_endpoint(request):
|
||||
"""Return presets stored beside the IndexTTS resources under models/TTS."""
|
||||
try:
|
||||
from .utils.text.index_tts_emotion import load_emotion_presets
|
||||
return web.json_response({"presets": load_emotion_presets()})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving IndexTTS emotion presets: {e}")
|
||||
return web.json_response({"presets": {}, "error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/api/tts-audio-suite/index-tts-emotion-presets")
|
||||
async def save_index_tts_emotion_presets_endpoint(request):
|
||||
"""Atomically persist the IndexTTS emotion preset library."""
|
||||
try:
|
||||
from .utils.text.index_tts_emotion import save_emotion_presets
|
||||
data = await request.json()
|
||||
presets = data.get("presets", {})
|
||||
path = save_emotion_presets(presets)
|
||||
return web.json_response({"status": "success", "count": len(presets), "path": path})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error saving IndexTTS emotion presets: {e}")
|
||||
return web.json_response({"status": "error", "error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/available-characters")
|
||||
async def get_available_characters_endpoint(request):
|
||||
"""API endpoint to get available TTS character voices including aliases"""
|
||||
try:
|
||||
# Load voice discovery directly by file path to avoid package import issues
|
||||
voice_discovery_path = os.path.join(os.path.dirname(__file__), "utils", "voice", "discovery.py")
|
||||
spec = importlib.util.spec_from_file_location("voice_discovery_module", voice_discovery_path)
|
||||
voice_discovery_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(voice_discovery_module)
|
||||
|
||||
characters = list(voice_discovery_module.get_available_characters())
|
||||
# Also get character aliases
|
||||
aliases = list(voice_discovery_module.voice_discovery._character_aliases.keys()) if hasattr(voice_discovery_module.voice_discovery, '_character_aliases') else []
|
||||
# Combine and deduplicate
|
||||
all_chars = sorted(set(characters + aliases))
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/available-characters")
|
||||
async def get_available_characters_endpoint(request):
|
||||
"""API endpoint to get available TTS character voices including aliases"""
|
||||
try:
|
||||
from utils.voice import discovery as voice_discovery_module
|
||||
characters = list(voice_discovery_module.get_available_characters())
|
||||
aliases = list(voice_discovery_module.voice_discovery.get_character_aliases().keys())
|
||||
# Combine and deduplicate
|
||||
all_chars = sorted(set(characters + aliases))
|
||||
return web.json_response({"characters": all_chars})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving available characters: {e}")
|
||||
return web.json_response({"characters": [], "error": str(e)})
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/available-languages")
|
||||
async def get_available_languages_endpoint(request):
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/available-languages")
|
||||
async def get_available_languages_endpoint(request):
|
||||
"""API endpoint to get available language codes from the canonical language mapper"""
|
||||
try:
|
||||
# Load language_mapper directly by file path to avoid package import issues
|
||||
@@ -299,38 +411,289 @@ def setup_api_routes():
|
||||
languages = sorted(set(language_mapper_module.LANGUAGE_ALIASES.values()))
|
||||
return web.json_response({"languages": languages})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving available languages: {e}")
|
||||
# Fallback list
|
||||
return web.json_response({"languages": ["en", "de", "fr", "ja", "es", "it", "pt", "th", "no"], "error": str(e)})
|
||||
print(f"⚠️ Error retrieving available languages: {e}")
|
||||
# Fallback list
|
||||
return web.json_response({"languages": ["en", "de", "fr", "ja", "es", "it", "pt", "th", "no"], "error": str(e)})
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/omnivoice-presets")
|
||||
async def get_omnivoice_presets_endpoint(request):
|
||||
"""Return the persisted OmniVoice instruction builder preset library."""
|
||||
try:
|
||||
library_path = _get_omnivoice_preset_library_path()
|
||||
if not os.path.exists(library_path):
|
||||
return web.json_response({"presets": [], "builtinStates": {}, "builtinLayouts": {}})
|
||||
with open(library_path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
presets = payload.get("presets", []) if isinstance(payload, dict) else []
|
||||
builtin_states = payload.get("builtinStates", {}) if isinstance(payload, dict) else {}
|
||||
builtin_layouts = payload.get("builtinLayouts", {}) if isinstance(payload, dict) else {}
|
||||
if not isinstance(presets, list):
|
||||
presets = []
|
||||
if not isinstance(builtin_states, dict):
|
||||
builtin_states = {}
|
||||
if not isinstance(builtin_layouts, dict):
|
||||
builtin_layouts = {}
|
||||
return web.json_response({"presets": presets, "builtinStates": builtin_states, "builtinLayouts": builtin_layouts})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving OmniVoice preset library: {e}")
|
||||
return web.json_response({"presets": [], "builtinStates": {}, "builtinLayouts": {}, "error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/api/tts-audio-suite/omnivoice-presets")
|
||||
async def save_omnivoice_presets_endpoint(request):
|
||||
"""Persist the OmniVoice instruction builder preset library."""
|
||||
try:
|
||||
data = await request.json()
|
||||
presets = data.get("presets", [])
|
||||
builtin_states = data.get("builtinStates", {})
|
||||
builtin_layouts = data.get("builtinLayouts", {})
|
||||
if not isinstance(presets, list):
|
||||
return web.json_response({"error": "presets must be a list"}, status=400)
|
||||
if not isinstance(builtin_states, dict):
|
||||
return web.json_response({"error": "builtinStates must be an object"}, status=400)
|
||||
if not isinstance(builtin_layouts, dict):
|
||||
return web.json_response({"error": "builtinLayouts must be an object"}, status=400)
|
||||
|
||||
library_path = _get_omnivoice_preset_library_path()
|
||||
payload = {"presets": presets, "builtinStates": builtin_states, "builtinLayouts": builtin_layouts}
|
||||
with open(library_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
return web.json_response({"status": "success", "count": len(presets)})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error saving OmniVoice preset library: {e}")
|
||||
return web.json_response({"status": "error", "error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/voice-input-devices")
|
||||
async def get_voice_input_devices_endpoint(request):
|
||||
"""Return input devices without risking a main-process PortAudio hang."""
|
||||
try:
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
probe_script = r"""
|
||||
import json
|
||||
import sounddevice as sd
|
||||
|
||||
devices = []
|
||||
seen = set()
|
||||
for device in sd.query_devices():
|
||||
try:
|
||||
max_input_channels = int(device.get("max_input_channels", 0))
|
||||
except Exception:
|
||||
max_input_channels = 0
|
||||
if max_input_channels <= 0:
|
||||
continue
|
||||
|
||||
name = str(device.get("name", "")).strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
|
||||
seen.add(name)
|
||||
devices.append(name)
|
||||
|
||||
print(json.dumps({"devices": devices}))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe_script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip()
|
||||
stdout = (result.stdout or "").strip()
|
||||
error_message = stderr or stdout or f"device probe exited with code {result.returncode}"
|
||||
return web.json_response({"devices": [], "error": error_message}, status=500)
|
||||
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
devices = payload.get("devices", [])
|
||||
if not isinstance(devices, list):
|
||||
devices = []
|
||||
|
||||
return web.json_response({"devices": devices})
|
||||
except subprocess.TimeoutExpired:
|
||||
return web.json_response(
|
||||
{"devices": [], "error": "Timed out while probing audio input devices. Leaving the dropdown on system default avoids startup hangs."},
|
||||
status=504,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving voice input devices: {e}")
|
||||
return web.json_response({"devices": [], "error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/api/tts-audio-suite/settings")
|
||||
async def set_inline_tag_settings_endpoint(request):
|
||||
"""API endpoint to receive settings from frontend for inline edit tags"""
|
||||
"""API endpoint to receive settings from frontend for inline edit tags and restore VC"""
|
||||
print("🔧 Settings endpoint called") # Immediate print to verify endpoint is reached
|
||||
try:
|
||||
data = await request.json()
|
||||
precision = data.get("precision", "auto")
|
||||
device = data.get("device", "auto")
|
||||
vc_engine = data.get("vc_engine", "chatterbox_23lang")
|
||||
cosyvoice_variant = data.get("cosyvoice_variant", "RL")
|
||||
|
||||
print(f"🔧 Received settings: precision={precision}, device={device}")
|
||||
print(f"🔧 Received settings: precision={precision}, device={device}, vc_engine={vc_engine}, cosyvoice_variant={cosyvoice_variant}")
|
||||
|
||||
# Load edit_post_processor directly by file path to avoid package import issues
|
||||
edit_post_processor_path = os.path.join(os.path.dirname(__file__), "utils", "audio", "edit_post_processor.py")
|
||||
spec = importlib.util.spec_from_file_location("edit_post_processor_module", edit_post_processor_path)
|
||||
edit_post_processor_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(edit_post_processor_module)
|
||||
# Import edit_post_processor using normal import to ensure we get the same module instance
|
||||
# that will be used during workflow execution
|
||||
# CRITICAL: Must use the same module instance, not create a new one via importlib!
|
||||
try:
|
||||
from utils.audio import edit_post_processor as edit_post_processor_module
|
||||
except ImportError:
|
||||
# Fallback: Load directly by file path if normal import fails
|
||||
edit_post_processor_path = os.path.join(os.path.dirname(__file__), "utils", "audio", "edit_post_processor.py")
|
||||
spec = importlib.util.spec_from_file_location("utils.audio.edit_post_processor", edit_post_processor_path)
|
||||
edit_post_processor_module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["utils.audio.edit_post_processor"] = edit_post_processor_module # Register in sys.modules!
|
||||
spec.loader.exec_module(edit_post_processor_module)
|
||||
|
||||
# Store in global settings that edit_post_processor can access
|
||||
edit_post_processor_module.set_inline_tag_settings(precision=precision, device=device)
|
||||
edit_post_processor_module.set_inline_tag_settings(precision=precision, device=device, vc_engine=vc_engine, cosyvoice_variant=cosyvoice_variant)
|
||||
|
||||
return web.json_response({"status": "success", "precision": precision, "device": device})
|
||||
return web.json_response({"status": "success", "precision": precision, "device": device, "vc_engine": vc_engine, "cosyvoice_variant": cosyvoice_variant})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error setting inline tag settings: {e}")
|
||||
return web.json_response({"status": "error", "error": str(e)})
|
||||
|
||||
def get_voice_discovery_module():
|
||||
"""Return the shared discovery module used by nodes and save notifications."""
|
||||
from utils.voice import discovery as voice_discovery_module
|
||||
return voice_discovery_module
|
||||
|
||||
def resolve_character_voice(voice_name):
|
||||
"""Resolve a dropdown key through the shared discovery cache."""
|
||||
voice_discovery_module = get_voice_discovery_module()
|
||||
voice_discovery_module.get_available_voices(force_refresh=False)
|
||||
return voice_discovery_module.load_voice_reference(voice_name)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/voice-library")
|
||||
async def get_voice_library_endpoint(request):
|
||||
"""Return current dropdown keys for Character Voices."""
|
||||
try:
|
||||
voice_discovery_module = get_voice_discovery_module()
|
||||
force_refresh = request.query.get("refresh", "0").strip().lower() in {"1", "true", "yes"}
|
||||
voices = voice_discovery_module.get_available_voices(force_refresh=force_refresh)
|
||||
response = web.json_response({"voices": voices})
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error serving voice library: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/voice-preview")
|
||||
async def get_voice_preview_endpoint(request):
|
||||
"""
|
||||
Stream selected Character Voices dropdown audio for browser preview playback.
|
||||
|
||||
Query params:
|
||||
- voice_name: exact dropdown key from get_available_voices()
|
||||
"""
|
||||
try:
|
||||
voice_name = request.query.get("voice_name", "").strip()
|
||||
if not voice_name or voice_name == "none":
|
||||
return web.json_response({"error": "voice_name is required and cannot be 'none'"}, status=400)
|
||||
|
||||
audio_path, _ = resolve_character_voice(voice_name)
|
||||
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
return web.json_response({"error": f"Voice file not found: {voice_name}"}, status=404)
|
||||
|
||||
# Direct stream of resolved local audio file.
|
||||
response = web.FileResponse(path=audio_path)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error serving voice preview audio: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/voice-info")
|
||||
async def get_voice_info_endpoint(request):
|
||||
"""Return canonical metadata for a Character Voices dropdown entry."""
|
||||
try:
|
||||
voice_name = request.query.get("voice_name", "").strip()
|
||||
if not voice_name or voice_name == "none":
|
||||
return web.json_response({"error": "voice_name is required and cannot be 'none'"}, status=400)
|
||||
|
||||
audio_path, reference_text = resolve_character_voice(voice_name)
|
||||
if not audio_path or not os.path.exists(audio_path):
|
||||
return web.json_response({"error": f"Voice file not found: {voice_name}"}, status=404)
|
||||
|
||||
response = web.json_response({
|
||||
"voice_name": voice_name,
|
||||
"reference_text": reference_text or "",
|
||||
})
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error serving voice metadata: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/api/tts-audio-suite/audio-analyzer-preview")
|
||||
async def audio_analyzer_preview_endpoint(request):
|
||||
"""
|
||||
Analyze file-based Audio Wave Analyzer inputs without queueing the ComfyUI graph.
|
||||
|
||||
Connected AUDIO inputs still require graph execution because the browser/backend
|
||||
route cannot access an upstream tensor that has not been computed.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
audio_file = (data.get("audio_file") or "").strip()
|
||||
if not audio_file:
|
||||
return web.json_response({"error": "audio_file is required for preview analysis"}, status=400)
|
||||
|
||||
node_id = str(data.get("node_id") or "preview")
|
||||
|
||||
analyzer_node_path = os.path.join(os.path.dirname(__file__), "nodes", "audio", "analyzer_node.py")
|
||||
spec = importlib.util.spec_from_file_location("tts_audio_suite_audio_analyzer_node", analyzer_node_path)
|
||||
analyzer_node_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(analyzer_node_module)
|
||||
|
||||
analyzer_node = analyzer_node_module.AudioAnalyzerNode()
|
||||
analyzer_node.analyze_audio(
|
||||
audio_file=audio_file,
|
||||
analysis_method=data.get("analysis_method", "silence"),
|
||||
precision_level=data.get("precision_level", "milliseconds"),
|
||||
visualization_points=int(data.get("visualization_points", 2000)),
|
||||
audio=None,
|
||||
options=data.get("options"),
|
||||
manual_regions=data.get("manual_regions", ""),
|
||||
region_labels=data.get("region_labels", ""),
|
||||
export_format=data.get("export_format", "f5tts"),
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
import folder_paths
|
||||
cache_file = os.path.join(folder_paths.get_output_directory(), f"audio_analyzer_cache_{node_id}.json")
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
|
||||
response = web.json_response(payload)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"⚠️ Audio analyzer preview failed: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/api/tts-audio-suite/training-progress")
|
||||
async def get_training_progress_endpoint(request):
|
||||
"""Return live training progress snapshots for one or all tracked training nodes."""
|
||||
try:
|
||||
from engines.training.progress_registry import get_training_progress_snapshot
|
||||
|
||||
node_id = request.query.get("node_id")
|
||||
snapshot = get_training_progress_snapshot(node_id=node_id)
|
||||
response = web.json_response({"nodes": snapshot})
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error retrieving training progress: {e}")
|
||||
return web.json_response({"nodes": {}, "error": str(e)}, status=500)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not setup API routes: {e}")
|
||||
|
||||
# Setup API routes when extension loads
|
||||
setup_api_routes()
|
||||
|
||||
# nodes.py already handles all the startup output and status reporting
|
||||
# nodes.py already handles all the startup output and status reporting
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Advanced multiline string editor node that extends ComfyUI's standard multiline widget with a sidebar containing context-aware controls for TTS-specific tags (character switching, parameters, pauses). The node features real-time tag generation, preset management, and intelligent syntax support based on the CHARACTER_SWITCHING_GUIDE and PARAMETER_SWITCHING_GUIDE.
|
||||
Advanced multiline string editor node that extends ComfyUI's standard multiline widget with a sidebar containing context-aware controls for TTS-specific tags (character switching, parameters, pauses, and engine-native inline controls). The node features real-time tag generation, preset management, engine-aware quick swapping, and intelligent syntax support based on the CHARACTER_SWITCHING_GUIDE and PARAMETER_SWITCHING_GUIDE.
|
||||
|
||||
---
|
||||
|
||||
@@ -85,10 +85,14 @@ Advanced multiline string editor node that extends ComfyUI's standard multiline
|
||||
- Split by paragraph breaks or sentence punctuation
|
||||
- Batch apply parameters across multiple `[Character]text` blocks
|
||||
|
||||
#### Tag Inspector
|
||||
- Show existing tags in selection/current line
|
||||
- Checkbox UI to toggle tags on/off temporarily
|
||||
- Quick-edit dialog for existing tag values
|
||||
#### Tag Inspector
|
||||
- Show existing tags in selection/current line
|
||||
- Checkbox UI to toggle tags on/off temporarily
|
||||
- Click a character, language, audio-reference, parameter, or supported engine-native inline tag to open a color-coded quick-swap palette
|
||||
- Click a palette option once to keep the palette open, then click again to commit; press-and-hold, drag, and release selects in one gesture
|
||||
- Palette choices follow the selected inline engine (including IndexTTS-2, Higgs Audio v3, Step Audio EditX, CosyVoice3, and OmniVoice)
|
||||
- Quoted IndexTTS-2 text emotion tags remain direct editable text and do not open a replacement palette
|
||||
- Long bracket and angle tags wrap inside the editor rather than overflowing horizontally
|
||||
|
||||
#### Auto-Formatting
|
||||
- Button: "Auto-Format Tags" → organize tags consistently
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Auxiliary Model Layouts
|
||||
|
||||
Use this document for helper/post-process model folder structures and placement notes.
|
||||
These are not engine weights. They are optional models used by standalone utility nodes.
|
||||
|
||||
For repository/source URLs, use [AUX_MODEL_SOURCES.md](AUX_MODEL_SOURCES.md).
|
||||
|
||||
## Punctuation / Truecase
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/punctuation/
|
||||
├── punctuation_fullstop_truecase_english/
|
||||
├── punctuation_fullstop_truecase_romance/
|
||||
└── punct_cap_seg_47_language/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Models download lazily on first use by the punctuation node.
|
||||
- These are helper models for text cleanup, not TTS/ASR engines.
|
||||
- The English model is the lightest and is mainly intended for raw English ASR text.
|
||||
- The 47-language model is much heavier and should be treated as the multilingual fallback.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Auxiliary Model Sources
|
||||
|
||||
Helper/post-process models used by standalone utility nodes.
|
||||
These are not engines and are documented separately to avoid polluting the engine registry.
|
||||
|
||||
## Punctuation / Truecase
|
||||
|
||||
Optional punctuation and true-casing post-process models for raw ASR text. Designed mainly for low-punctuation transcripts such as Granite outputs, but usable on any plain text.
|
||||
|
||||
**Used by:** 📝 ASR Punctuation / Truecase
|
||||
|
||||
| Model | Repo | Size | Auto-Download | Languages | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| English Fullstop + Truecase | [1-800-BAD-CODE/punctuation_fullstop_truecase_english](https://huggingface.co/1-800-BAD-CODE/punctuation_fullstop_truecase_english) | ~210MB | ✅ | English | English-only. Best on lowercased, unpunctuated ASR text. |
|
||||
| Romance Fullstop + Truecase | [1-800-BAD-CODE/punctuation_fullstop_truecase_romance](https://huggingface.co/1-800-BAD-CODE/punctuation_fullstop_truecase_romance) | ~144MB | ✅ | Spanish, French, Italian, Portuguese, Catalan, Galician | Romance-language punctuation and true-casing model. |
|
||||
| 47-Language Fullstop + Truecase | [1-800-BAD-CODE/punct_cap_seg_47_language](https://huggingface.co/1-800-BAD-CODE/punct_cap_seg_47_language) | ~1.11GB | ✅ | 47 languages | Heavier multilingual fallback with the widest language coverage supported by the punctuators package. |
|
||||
|
||||
*Generated from [tts_audio_suite_aux_models.yaml](Dev%20reports/tts_audio_suite_aux_models.yaml).*
|
||||
@@ -12,21 +12,26 @@
|
||||
|
||||
**⚠️ IMPORTANT: Use positional arguments, NOT --commit/--changelog flags**
|
||||
|
||||
```bash
|
||||
# EASIEST: Just use 'patch' - script auto-increments the version
|
||||
python3 scripts/bump_version_enhanced.py patch "<commit_desc>" "<changelog_desc>"
|
||||
```powershell
|
||||
# Windows: use the canonical ComfyUI environment for this project
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' patch "<commit_desc>" "<changelog_desc>"
|
||||
|
||||
# OR: Specify exact version if needed
|
||||
python3 scripts/bump_version_enhanced.py <version> "<commit_desc>" "<changelog_desc>"
|
||||
# OR: specify an exact version if needed
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' <version> "<commit_desc>" "<changelog_desc>"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/macOS: use the available Python 3 interpreter
|
||||
python3 scripts/bump_version_enhanced.py patch "<commit_desc>" "<changelog_desc>"
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
#### Multiline Format (Recommended Standard)
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
# Patch release (bug fixes) - CORRECT FORMAT
|
||||
python3 scripts/bump_version_enhanced.py 3.2.9 "Fix character alias resolution
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' 3.2.9 "Fix character alias resolution
|
||||
|
||||
Technical details:
|
||||
- Fix parser bypassing character tags in single mode
|
||||
@@ -37,8 +42,8 @@ Technical details:
|
||||
- Improve character name recognition accuracy
|
||||
- Better error handling for invalid character names"
|
||||
|
||||
# Minor release (new features) - CORRECT FORMAT
|
||||
python3 scripts/bump_version_enhanced.py 3.3.0 "Add Higgs Audio 2 TTS engine
|
||||
# Minor release (new features) - CORRECT FORMAT
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' 3.3.0 "Add Higgs Audio 2 TTS engine
|
||||
|
||||
Implementation details:
|
||||
- Integrate boson_multimodal voice cloning system
|
||||
@@ -50,7 +55,7 @@ Implementation details:
|
||||
- Multiple built-in voice presets available"
|
||||
|
||||
# Major release (breaking changes) - CORRECT FORMAT
|
||||
python3 scripts/bump_version_enhanced.py 4.0.0 "Complete unified architecture implementation
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' 4.0.0 "Complete unified architecture implementation
|
||||
|
||||
Breaking changes:
|
||||
- Migrate all nodes to unified interface pattern
|
||||
@@ -64,22 +69,22 @@ Breaking changes:
|
||||
```
|
||||
|
||||
#### Auto-Increment Examples (Recommended)
|
||||
```bash
|
||||
# Auto-increment patch version (4.5.25 → 4.5.26) - CORRECT FORMAT
|
||||
python3 scripts/bump_version_enhanced.py patch "Fix character parsing issues" "Fix character name handling in TTS generation"
|
||||
```powershell
|
||||
# Auto-increment patch version (4.5.25 → 4.5.26) - Windows
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' patch "Fix character parsing issues" "Fix character name handling in TTS generation"
|
||||
|
||||
# Auto-increment minor version (4.5.25 → 4.6.0) - CORRECT FORMAT
|
||||
python3 scripts/bump_version_enhanced.py minor "Add new TTS engine support" "Add Higgs Audio 2 TTS engine with voice cloning"
|
||||
# Auto-increment minor version (4.5.25 → 4.6.0) - Windows
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' minor "Add new TTS engine support" "Add Higgs Audio 2 TTS engine with voice cloning"
|
||||
```
|
||||
|
||||
#### Single-Line Format (Only for Super Minor Changes)
|
||||
```bash
|
||||
python3 scripts/bump_version_enhanced.py patch "Fix typo in node tooltip" "Fix typo in audio analyzer tooltip"
|
||||
```powershell
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' patch "Fix typo in node tooltip" "Fix typo in audio analyzer tooltip"
|
||||
```
|
||||
|
||||
#### Dry-Run Preview (Test Before Committing)
|
||||
```bash
|
||||
python3 scripts/bump_version_enhanced.py patch "Fix preview issues" "Fix preview not reflecting filter parameters" --dry-run
|
||||
```powershell
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' patch "Fix preview issues" "Fix preview not reflecting filter parameters" --dry-run
|
||||
```
|
||||
|
||||
#### Auto-Categorization System
|
||||
@@ -99,21 +104,22 @@ python3 scripts/bump_version_enhanced.py patch "Fix preview issues" "Fix preview
|
||||
- **Commit**: Technical implementation details for developers
|
||||
- **Changelog**: User-facing benefits and impacts
|
||||
|
||||
**Bash Syntax Notes:**
|
||||
**Command Syntax Notes:**
|
||||
- Multiline strings need proper quoting (opening quote on first line, closing quote on last line)
|
||||
- Use `\` (backslash) for line continuation in bash commands
|
||||
- The Windows command uses PowerShell's `&` call operator and the canonical project Python path
|
||||
- The Linux/macOS command uses `python3`
|
||||
- Don't add manual category prefixes like "Fixed:" - script handles categorization automatically!
|
||||
|
||||
### Interactive Mode (Recommended for Complex Changes)
|
||||
|
||||
```bash
|
||||
python3 scripts/bump_version_enhanced.py 3.2.9 --interactive
|
||||
```powershell
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' 3.2.9 --interactive
|
||||
```
|
||||
|
||||
### Legacy Mode (Same Description for Both)
|
||||
|
||||
```bash
|
||||
python3 scripts/bump_version_enhanced.py 3.2.9 "Fix bugs and improve stability"
|
||||
```powershell
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' 3.2.9 "Fix bugs and improve stability"
|
||||
```
|
||||
|
||||
### What the Script Does
|
||||
@@ -239,13 +245,14 @@ git commit -m "Prepare for version bump"
|
||||
- Use semantic versioning: `4.5.25` (not `v4.5.25` or `4.5`)
|
||||
- Or use auto-increment: `patch`, `minor`, `major`
|
||||
|
||||
**Bash syntax errors with multiline**
|
||||
- Make sure opening quote is on same line as `--commit` or `--changelog`
|
||||
**Command syntax errors with multiline**
|
||||
- Make sure opening quote is on same line as `--commit` or `--changelog`
|
||||
- Make sure closing quote is on its own line
|
||||
- Use `\` for line continuation
|
||||
- On Windows, use the PowerShell command shown above
|
||||
- On Linux/macOS, use `python3`
|
||||
|
||||
**Want to see what will happen before committing?**
|
||||
```bash
|
||||
```powershell
|
||||
# Add --dry-run to preview changelog categorization
|
||||
python3 scripts/bump_version_enhanced.py patch "description" "changelog" --dry-run
|
||||
```
|
||||
& 'J:\stablediffusion1111s2\Data\Packages\ComfyUIPy129\test_env_error\Scripts\python.exe' 'scripts\bump_version_enhanced.py' patch "description" "changelog" --dry-run
|
||||
```
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
CosyVoice3 has **native paralinguistic tag support** built into the model. Unlike Step Audio EditX post-processing, these tags are processed **during TTS generation** for natural integration.
|
||||
|
||||
**⚠️ IMPORTANT**: Step Audio EditX post-processing is **DISABLED** for CosyVoice3. EditX syntax like `<Laughter:2>`, `<emotion:happy>`, `<style:whisper>` will be stripped but **not processed**. Use CosyVoice3 native tags instead.
|
||||
**⚠️ IMPORTANT**: Step Audio EditX post-processing is **DISABLED** for CosyVoice3. EditX syntax like `<Laughter:2>`, `<emotion:happy>`, `<style:whisper>` will be stripped but **not processed**. Use CosyVoice3 native tags instead.
|
||||
|
||||
The multiline TTS tag editor has an engine-aware `Inline Tags` panel with a dedicated `CosyVoice3` mode that inserts these native tags directly.
|
||||
|
||||
---
|
||||
|
||||
@@ -216,7 +218,7 @@ A: Yes! Include tags in subtitle text.
|
||||
|
||||
## Related Guides
|
||||
|
||||
- **[Inline Edit Tags Guide](INLINE_EDIT_TAGS_USER_GUIDE.md)** - Step Audio EditX tags for other engines
|
||||
- **[Step Audio EditX Inline Tags Guide](INLINE_EDIT_TAGS_USER_GUIDE.md)** - Step Audio EditX tags for other engines
|
||||
- **[Parameter Switching Guide](PARAMETER_SWITCHING_GUIDE.md)** - Per-segment parameter control
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# DramaBox LoRA training
|
||||
|
||||
TTS Audio Suite exposes the official DramaBox audio-branch IC-LoRA trainer
|
||||
through the unified `🎓 Model Training` flow. The bundled scripts are pinned to
|
||||
the same upstream DramaBox revision as the inference implementation.
|
||||
|
||||
See the official DramaBox
|
||||
[LoRA training guide](https://github.com/resemble-ai/DramaBox#training-a-lora-on-top-of-dramabox)
|
||||
for the upstream dataset format and training behavior.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Build a `⚙️ DramaBox Engine`.
|
||||
2. Create the dataset either externally or entirely inside ComfyUI:
|
||||
`🎞️ Training Clip Staging` → `🧾 DramaBox Dataset Rows`.
|
||||
3. Connect the resulting manifest to `📦 DramaBox Dataset Prep` and keep
|
||||
`dataset_type` set to `manifest`.
|
||||
4. Provide at least two clips per speaker.
|
||||
5. Connect the dataset to `🎛️ DramaBox Training Config` and then to `🎓 Model Training`.
|
||||
6. Select the resulting adapter in the DramaBox engine, or enter its path in the
|
||||
advanced LoRA override field.
|
||||
|
||||
The dataset node accepts:
|
||||
|
||||
- JSONL/JSON manifests with `audio_filepath` (or `audio_path`) and `text` (or
|
||||
`transcript`)
|
||||
- TSV rows with audio path and text
|
||||
- the official `gemini_synthetic` and `libriheavy` index formats
|
||||
|
||||
Manifest rows may include `speaker`, `speaker_id`, `language`, and `duration`.
|
||||
If `speaker` is omitted, rows are grouped as `speaker_1`. Duration and audio
|
||||
metadata are measured without loading the waveform into the GPU. The suite
|
||||
converts all accepted formats into the `~`-delimited speaker index required by
|
||||
the upstream training loop. Clips are restricted to 2–20 seconds by default.
|
||||
|
||||
For an all-ComfyUI dataset, connect one or more `AUDIO` sources to
|
||||
`🎞️ Training Clip Staging`, then enter one transcript per clip in
|
||||
`🧾 DramaBox Dataset Rows`. Speaker and language lines are optional; shared
|
||||
defaults are used when those lines are blank.
|
||||
|
||||
### Transcripts and scene descriptions
|
||||
|
||||
The official trainer accepts either plain spoken transcripts or the same
|
||||
scene-style prompt format used for inference. For example, both of these are
|
||||
valid training text:
|
||||
|
||||
```text
|
||||
This is the spoken sentence.
|
||||
A woman speaks warmly, "This is the spoken sentence."
|
||||
```
|
||||
|
||||
Use scene descriptions only when they accurately describe the clip. Plain
|
||||
transcripts remain valid and are the safer choice when no reliable style or
|
||||
scene annotation is available.
|
||||
|
||||
## What training does
|
||||
|
||||
The first preprocessing pass uses Gemma and the DramaBox audio VAE to create
|
||||
cached conditions and audio latents. The training process then attaches a LoRA
|
||||
to the audio transformer branch. It saves periodic checkpoints and exports the
|
||||
selected adapter to:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/dramabox/loras/<adapter_name>/
|
||||
```
|
||||
|
||||
The job directory, normalized index, preprocessing cache, progress file, and
|
||||
logs are stored under:
|
||||
|
||||
```text
|
||||
ComfyUI/output/tts_audio_suite_training/dramabox/
|
||||
```
|
||||
|
||||
`continue_from` is a warm start from an existing LoRA checkpoint; it is not an
|
||||
exact optimizer-state resume. Use saved checkpoints to compare quality rather
|
||||
than assuming the last step is best. Optional upstream validation can be
|
||||
enabled with a `val_config` YAML path, but it launches full DramaBox inference
|
||||
at each save step. It requires a second GPU: set `validation_gpu` to that
|
||||
physical CUDA device index. The suite rejects validation on the training GPU
|
||||
instead of allowing both full model processes to compete for the same VRAM.
|
||||
|
||||
DramaBox LoRA inference supports normal transformer precision, `fp8_cast`, and
|
||||
the optional `torch.compile` path. With normal precision the live adapter is
|
||||
reversibly merged for fast inference. With FP8 storage the BF16 adapter remains
|
||||
unmerged above the immutable FP8 base weights, avoiding unsafe mixed-dtype
|
||||
weight fusion while retaining the main FP8 memory saving.
|
||||
|
||||
The base DramaBox runtime is reused when the selected adapter or LoRA strength
|
||||
changes. Strength updates are applied directly to the live PEFT adapter, while
|
||||
the generated-audio cache still treats adapter path, file revision, and strength
|
||||
as distinct generation settings. Replacing an adapter with a different rank may
|
||||
retrace compiled transformer blocks, but does not reload the base checkpoint.
|
||||
|
||||
## CPU-safe preflight
|
||||
|
||||
Training and Gemma/VAE preprocessing are GPU workloads. For development or
|
||||
validation without touching CUDA, enable `dry_run` in the training config and
|
||||
the dataset node's `dry_run`/`preprocess_now` controls. This writes the
|
||||
normalized index and official command/config without loading DramaBox weights.
|
||||
@@ -0,0 +1,163 @@
|
||||
# DramaBox Prompting Guide
|
||||
|
||||
DramaBox is an English expressive TTS engine. It accepts ordinary narration,
|
||||
dialogue in quotation marks, and natural-language stage directions in one
|
||||
prompt.
|
||||
|
||||
## Basic prompts
|
||||
|
||||
Use quoted text for speech and surrounding prose for delivery:
|
||||
|
||||
```text
|
||||
A tired detective speaks quietly in a rain-soaked office. "I knew this case would find me again."
|
||||
```
|
||||
|
||||
`prompt_template` defaults to `"{seg}"`. `{seg}` is replaced by the current
|
||||
plain fragment, so the default marks the whole fragment as literal spoken
|
||||
dialogue. For example, `Hello.` becomes `"Hello."`. Clear the template field to
|
||||
send plain text unchanged.
|
||||
|
||||
Customize the template to add delivery context, for example
|
||||
`A man speaks warmly, "{seg}"`. Quote-only input is normalized without adding
|
||||
another pair of quotes. Complete scene prompts with directions outside their
|
||||
quotation marks remain unchanged. Every non-empty template must contain `{seg}`.
|
||||
If it is omitted accidentally, TTS Audio Suite warns once and appends
|
||||
`"{seg}"` automatically instead of failing generation.
|
||||
|
||||
For a one-segment override, `prompt_template` (or its `template` alias)
|
||||
automatically enables templating for that segment and then reverts to the node
|
||||
setting:
|
||||
|
||||
```text
|
||||
[Narrator|template:A woman whispers, "{seg}"] This line uses a custom wrapper.
|
||||
```
|
||||
|
||||
DramaBox can render non-verbal and delivery cues when they are described
|
||||
naturally:
|
||||
|
||||
```text
|
||||
She tries to stay serious, then breaks into a short laugh. "That is the worst excuse I have ever heard." She sighs and continues more gently. "But I believe you."
|
||||
```
|
||||
|
||||
Write one continuous scene paragraph. DramaBox does not require newlines as
|
||||
prompt syntax. In TTS Audio Suite, an untagged newline starts another generated
|
||||
segment, so use prose action directions and quoted dialogue in the same
|
||||
paragraph when they should remain one coherent DramaBox scene.
|
||||
|
||||
Do not use ChatterBox V2 special tokens such as `[giggle]`. DramaBox was
|
||||
trained for prose-style scene direction, not that token vocabulary.
|
||||
|
||||
## Voice references
|
||||
|
||||
Reference audio is optional. Connect narrator audio or use a character voice
|
||||
file to clone its speaker and delivery. Upstream uses the first 10 seconds, so
|
||||
a clean single-speaker clip is the useful input; a transcript is not required.
|
||||
|
||||
Without a reference, DramaBox uses its built-in voice behavior.
|
||||
|
||||
DramaBox can occasionally produce a near-silent sample for a particular
|
||||
combination of reference audio, reference duration, generation duration, and
|
||||
seed. TTS Audio Suite checks the decoded waveform and prints a warning when both
|
||||
its RMS and peak levels are conservatively near silence. The audio is preserved;
|
||||
the suite does not retry or change parameters automatically. Try another
|
||||
generation duration, reference duration/audio, guidance setting, or seed for
|
||||
the affected segment. A different seed can help some combinations but is not a
|
||||
guaranteed fix.
|
||||
|
||||
The warning is also propagated to node outputs. TTS Text includes affected
|
||||
segments in `generation_info`. TTS SRT marks affected subtitle numbers in
|
||||
`timing_report`, including the parameters that may be worth testing for that
|
||||
segment.
|
||||
|
||||
## Character and pause tags
|
||||
|
||||
TTS Audio Suite character tags still work. Each tagged character is generated
|
||||
as a separate DramaBox segment:
|
||||
|
||||
```text
|
||||
[Alice] "We should leave now."
|
||||
[Bob] He answers without looking up. "Give me one minute."
|
||||
[pause:0.8]
|
||||
[Alice] "You said that five minutes ago."
|
||||
```
|
||||
|
||||
Suite pause tags create exact silence outside the model. Natural pauses inside
|
||||
a spoken scene are better expressed in the prose prompt.
|
||||
|
||||
## Engine controls
|
||||
|
||||
- `cfg_scale`: text/prompt guidance. Official default: `2.5`.
|
||||
- `stg_scale`: skip-token guidance. Official default: `1.5`.
|
||||
- `duration_multiplier`: scales the estimated speaking duration. Official
|
||||
default: `1.1`.
|
||||
- `gen_duration`: explicit generated-audio duration from `0` to `60` seconds.
|
||||
`0` keeps automatic prompt-based estimation.
|
||||
- `ref_duration`: uses the first `3` to `30` seconds of a voice reference.
|
||||
The default is `10`; audio later in the source file is ignored.
|
||||
- `rescale_scale`: CFG latent rescaling. Use `auto` or a fixed value from
|
||||
`0` to `1`.
|
||||
- `watermark`: enables the optional official Perth output watermark. It is off
|
||||
by default and requires Perth.
|
||||
- `seed`: supplied by the unified TTS Text or SRT node.
|
||||
|
||||
Segment overrides support `seed`, `cfg_scale`, `stg_scale`, and
|
||||
`duration_multiplier`, `gen_duration`, `ref_duration`, and `rescale_scale`.
|
||||
Watermarking remains a whole-engine setting rather than a segment override.
|
||||
|
||||
DramaBox performs its own duration-aware long-form chunking. The suite does
|
||||
not split a DramaBox scene by character count before passing it to the model.
|
||||
Automatically estimated scenes above 45 seconds use text chunking. A nonzero
|
||||
`gen_duration` remains one native generation so its explicit 0–60 second
|
||||
target is preserved.
|
||||
|
||||
The unified SRT node's **Native Duration Targeting** option passes each
|
||||
subtitle's duration to DramaBox before final timing assembly. For subtitles
|
||||
containing multiple character or pause-separated fragments, the available
|
||||
speech time is allocated proportionally after explicit pause durations and
|
||||
inline `gen_duration` overrides are accounted for. The selected SRT timing
|
||||
mode still performs its normal final correction.
|
||||
|
||||
## Negative Prompt and Segment Switching
|
||||
|
||||
DramaBox uses CFG and exposes its negative prompt in the engine node. The
|
||||
default discourages robotic, distorted, noisy, muffled, unclear, and monotone
|
||||
speech. Override it for one character segment with:
|
||||
|
||||
```text
|
||||
[Alice|negative:robotic, muffled] "Keep this line clean and intimate."
|
||||
[Bob|neg:noise, static] "This line uses a different negative prompt."
|
||||
```
|
||||
|
||||
The segment override ends at the next character tag.
|
||||
|
||||
## Memory and Performance
|
||||
|
||||
- `fast` keeps all components on CUDA for the fastest repeated generation.
|
||||
- `staged` is an experimental strategy for lowering peak VRAM. It loads and
|
||||
releases Gemma, the voice encoder, and audio decoder by stage, at the cost of
|
||||
reloading them for each generated segment or long-form chunk.
|
||||
- `sequential` is a more aggressive experimental strategy for lowering peak
|
||||
VRAM. It additionally keeps the diffusion transformer in system RAM while
|
||||
another major stage uses CUDA. It transfers the transformer for every
|
||||
generated segment or long-form chunk and is therefore substantially slower.
|
||||
Actual peak usage varies with the environment, generation settings, and
|
||||
other loaded components; no minimum GPU size is guaranteed.
|
||||
System RAM must hold the offloaded transformer (about 3.4GB with FP8 or
|
||||
6.6GB without it).
|
||||
- `fp8_cast` uses the official LTX FP8 transformer weight-storage policy and
|
||||
upcasts linear weights during inference. It can lower VRAM and may be slower.
|
||||
- `compile_model` compiles the diffusion transformer blocks with DramaBox's
|
||||
bundled LTX compilation path. The first generation can take substantially
|
||||
longer while kernels compile; later denoising may be faster.
|
||||
|
||||
## Requirements and license
|
||||
|
||||
The full download is approximately 16.4GB and the official runtime requires
|
||||
an NVIDIA CUDA GPU. Fast mode targets roughly 24GB VRAM; the experimental
|
||||
staged modes can run with less memory at a speed cost. Output is 48kHz stereo.
|
||||
The optional official Perth watermark is applied only when enabled and the
|
||||
dependency is available.
|
||||
|
||||
DramaBox uses the LTX-2 Community License. Entities with at least USD 10
|
||||
million in annual revenue require a separate paid commercial license. Review
|
||||
the bundled license before production use.
|
||||
@@ -0,0 +1,183 @@
|
||||
# DramaBox and Chatterbox Multilingual V3 Capability and Scope
|
||||
|
||||
Research date: 2026-07-25
|
||||
|
||||
## Official references
|
||||
|
||||
- DramaBox code: `resemble-ai/DramaBox` at
|
||||
`a70a5818e103c1c9fef22409c1e0c707ebf4f8a7`
|
||||
- DramaBox weights: `ResembleAI/Dramabox` at
|
||||
`404f967f653fa1170dc15a9d1ddd3fdb9a0a842d`
|
||||
- Chatterbox code: `resemble-ai/chatterbox` at
|
||||
`5de7a54aa4e5e2baadb0182dde554908b48b85c2`
|
||||
- Chatterbox weights: `ResembleAI/chatterbox` at
|
||||
`5bb1f6ee58e50c3b8d408bc82a6d3740c2db6e18`
|
||||
- ComfyUI reference only: `kat3ri/ComfyUI-DramaBox` at
|
||||
`715fcb11cc14d8c185438e2319b52fc00163941c`
|
||||
|
||||
The repositories were cloned under
|
||||
`IgnoredForGitHubDocs/For_reference/`.
|
||||
|
||||
## DramaBox capability report
|
||||
|
||||
### Native scope
|
||||
|
||||
- Task: English text-to-speech with optional zero-shot voice cloning.
|
||||
- Expressive control: prompt-driven speaker description, delivery, emotion,
|
||||
pauses, laughs, sighs, and transitions.
|
||||
- Voice input: optional reference audio; upstream uses up to 10 seconds.
|
||||
- No native voice conversion, ASR, or audio editing API.
|
||||
- No language control. The official model is English-only.
|
||||
- No extra special node is required. Its structured scene prompt fits the
|
||||
existing unified text and SRT nodes.
|
||||
|
||||
### Native generation parameters
|
||||
|
||||
- `cfg_scale` (official warm-server default `2.5`)
|
||||
- `stg_scale` (default `1.5`)
|
||||
- `duration_multiplier` (default `1.1`)
|
||||
- `seed` (default `42`)
|
||||
- `ref_duration` (default `10.0` seconds)
|
||||
- `rescale_scale` (`auto` by default)
|
||||
- `gen_duration` (`0` means automatic)
|
||||
- Official long-form chunk limits and crossfade parameters
|
||||
|
||||
The initial suite UI exposes `cfg_scale`, `stg_scale`, and
|
||||
`duration_multiplier`. Seed remains owned by the unified TTS nodes.
|
||||
Reference duration, rescale, steps, modality guidance, and explicit output
|
||||
duration stay on official defaults because exposing them would add expert
|
||||
controls without a demonstrated suite use case. The official duration-aware
|
||||
long-form path is used automatically instead of adding duplicate chunk UI.
|
||||
|
||||
### Audio and generation behavior
|
||||
|
||||
- The LTX audio decoder returns stereo audio at 48 kHz.
|
||||
- The base model was trained on clips around 20 seconds. Current upstream
|
||||
supports longer clips with a silence-prior correction and automatically
|
||||
chunks prompts targeting about 37 seconds with a 45-second cap.
|
||||
- The official long-form chunker preserves the scene/speaker prefix and quote
|
||||
groups, then joins chunks with a 50 ms equal-power crossfade.
|
||||
- Upstream applies the Perth watermark only in `generate_to_file()`, not in
|
||||
the in-memory `generate()` method. The suite wrapper must therefore apply
|
||||
the watermark to in-memory output explicitly.
|
||||
|
||||
### Model layout
|
||||
|
||||
Organized destination: `ComfyUI/models/TTS/dramabox/DramaBox/`
|
||||
|
||||
- `dramabox-dit-v1.safetensors` — 6,575,225,528 bytes
|
||||
- `dramabox-audio-components.safetensors` — 1,942,831,020 bytes
|
||||
- `assets/silence_latent_frame.pt` — 1,501 bytes
|
||||
- `gemma-3-12b-it-bnb-4bit/`
|
||||
- two safetensor shards plus tokenizer/config files from
|
||||
`unsloth/gemma-3-12b-it-bnb-4bit`
|
||||
|
||||
The implementation must use the suite downloader with `local_dir`-style
|
||||
organized downloads and disable Transformers/Hugging Face fallback downloads.
|
||||
|
||||
### Dependencies and runtime
|
||||
|
||||
The official requirements include Torch/Torchaudio 2.8, Transformers 4.45+,
|
||||
bitsandbytes 0.45+, Accelerate, PEFT, PyAV, Einops, SentencePiece,
|
||||
Safetensors, PyYAML, and Perth. The official source imports successfully in
|
||||
the configured suite validation environment with Torch 2.10 and Transformers
|
||||
5.10, so DramaBox belongs in the main Transformers 5 environment.
|
||||
|
||||
The optional NVIDIA RE-USE reference denoiser is intentionally excluded:
|
||||
its Mamba dependencies have no practical Windows installation path and its
|
||||
NSCLv1 non-commercial license is a poor default for the suite.
|
||||
|
||||
### License
|
||||
|
||||
DramaBox code and weights are under the LTX-2 Community License, not MIT.
|
||||
The license requires attribution, use restrictions, modified-file notices,
|
||||
and a separate paid license for entities with at least USD 10 million in
|
||||
annual revenue. The upstream license must ship beside any bundled inference
|
||||
code, and the engine UI/docs must disclose the restriction.
|
||||
|
||||
## Existing ComfyUI reference notes
|
||||
|
||||
`kat3ri/ComfyUI-DramaBox` confirms useful ComfyUI audio-shape handling,
|
||||
organized model paths, the warm `TTSServer` API, and practical UI ranges.
|
||||
It must not be copied as architecture:
|
||||
|
||||
- It auto-clones source code at runtime.
|
||||
- It has no unified model lifecycle, cache, character/pause integration, SRT
|
||||
processor, interrupt handling, or generation report integration.
|
||||
- It directly calls the engine from a standalone node.
|
||||
- It patches partially imported bitsandbytes modules globally.
|
||||
- Its README says output is watermarked, but its node calls the unwatermarked
|
||||
in-memory upstream path.
|
||||
|
||||
## Chatterbox Multilingual V3 capability report
|
||||
|
||||
V3 is not a new engine. Official upstream loads it as an opt-in checkpoint through
|
||||
`ChatterboxMultilingualTTS.from_pretrained(..., t3_model="v3")`; the only
|
||||
model-family change is selecting `t3_mtl23ls_v3.safetensors` instead of the
|
||||
V2 T3 checkpoint. Its official generation path also skips the legacy
|
||||
alignment analyzer, uses repetition penalty `1.2`, and removes the final
|
||||
degraded pre-EOS speech-token artifact. It keeps
|
||||
the same 500M architecture, 23-language list, 24 kHz output, voice-reference
|
||||
mode, tokenizer, voice encoder, S3Gen decoder, and generation parameters:
|
||||
|
||||
- `language_id`
|
||||
- `exaggeration`
|
||||
- `cfg_weight`
|
||||
- `temperature`
|
||||
- `repetition_penalty`
|
||||
- `min_p`
|
||||
- `top_p`
|
||||
|
||||
The suite forwards V3 `exaggeration` using the upstream/native scale. Manual
|
||||
testing found little or no audible response across values, so this remains a
|
||||
current checkpoint limitation rather than a suite-side scaling issue.
|
||||
|
||||
The existing `chatterbox_official_23lang` engine already implements Unified
|
||||
TTS Text, Unified SRT TTS, voice references, caching, character switching,
|
||||
pause tags, parameter switching, and lifecycle handling. V3 therefore
|
||||
extends its `model_version` choices and downloader requirements. It must not
|
||||
create a second engine node or duplicate processors.
|
||||
|
||||
## Integration scope
|
||||
|
||||
### DramaBox
|
||||
|
||||
- Unified TTS Text: yes
|
||||
- Unified SRT TTS: yes
|
||||
- Character tags and narrator fallback: yes
|
||||
- Pause tags: yes
|
||||
- Segment switching: `seed`, `cfg_scale`, `stg_scale`, and
|
||||
`duration_multiplier`
|
||||
- Generated audio cache: yes
|
||||
- Long-form strategy: official duration-aware chunker; ignore suite
|
||||
character-count chunking inside each already separated character/pause
|
||||
segment
|
||||
- Clear VRAM: full TTSServer teardown and lazy reload because the quantized
|
||||
Gemma stack should not be copied to system RAM
|
||||
- Runtime: main environment
|
||||
- Voice Changer / ASR / editing / special node: no
|
||||
|
||||
### Chatterbox Multilingual V3
|
||||
|
||||
- Extend existing Official 23-Lang model version control with V3.
|
||||
- Keep V2 available for backward compatibility.
|
||||
- Make V3 the suite default for new configurations so the newly requested
|
||||
version is immediately selected. Upstream still defaults to V2 and exposes
|
||||
V3 as opt-in.
|
||||
- Existing saved workflows with V1/V2 values continue to load unchanged.
|
||||
|
||||
## Validation matrix
|
||||
|
||||
- Static import and registration checks
|
||||
- Chatterbox V1/V2/V3 file-resolution tests without downloading weights
|
||||
- DramaBox downloader layout checks without downloading the 16+ GB models
|
||||
- DramaBox TTS processor tests with a fake adapter for character, pause,
|
||||
cache-facing parameter, audio-shape, and combination behavior
|
||||
- SRT processor interrupt and timing-path tests with fake generation
|
||||
- Live FL-MCP checks after restarting ComfyUI:
|
||||
- engine and unified nodes register
|
||||
- smallest DramaBox text workflow loads
|
||||
- smallest DramaBox SRT workflow loads
|
||||
- full generation is attempted only if all 16+ GB weights and adequate
|
||||
VRAM are available
|
||||
- Human assessment remains required for subjective audio quality.
|
||||
@@ -0,0 +1,573 @@
|
||||
# Echo-TTS PR #250 - Comprehensive Code Review
|
||||
|
||||
**Date:** 2026-02-05
|
||||
**PR Author:** drphero (Brent)
|
||||
**Reviewer:** Claude (TTS Audio Suite maintainer perspective)
|
||||
**Reference Implementation:** Qwen3-TTS
|
||||
**Guides Referenced:**
|
||||
|
||||
- `docs/NEW_ENGINE_IMPLEMENTATION_GUIDE.md`
|
||||
- `docs/Dev reports/fails_to_avoid_TTS_Engine_Implementation.md`
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Echo-TTS integration has **major architectural issues** that deviate significantly from project standards. While it functions, the implementation embeds 180+ lines of orchestration logic directly in the unified node instead of using the standard processor pattern, making it non-reusable and harder to maintain.
|
||||
|
||||
**Status:** ⚠️ **NEEDS SIGNIFICANT REFACTORING** - Critical architectural issues, missing standard integrations
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues (MUST FIX)
|
||||
|
||||
### 1. ❌ **Missing Audio Caching System**
|
||||
|
||||
**Severity:** CRITICAL
|
||||
**Location:** `engines/adapters/echo_tts_adapter.py`
|
||||
|
||||
**Issue:**
|
||||
|
||||
- No import of `utils.audio.cache`
|
||||
- No `audio_cache` instance creation
|
||||
- No `generate_cache_key()` calls before generation
|
||||
- No `cache_audio()` calls after generation
|
||||
- No cache hit checks
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```bash
|
||||
$ grep "audio_cache\|cache_audio\|generate_cache_key" engines/adapters/echo_tts_adapter.py
|
||||
# No matches found
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Every identical generation request re-generates audio (wasting time + VRAM)
|
||||
- No performance benefits from repeated requests
|
||||
- Inconsistent with ALL other engines (Qwen3, Step Audio EditX, F5-TTS, ChatterBox)
|
||||
|
||||
**Reference Implementation (Qwen3-TTS):**
|
||||
|
||||
```python
|
||||
# qwen3_tts_adapter.py:49
|
||||
self.audio_cache = get_audio_cache()
|
||||
|
||||
# qwen3_tts_adapter.py:349-372
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
'qwen3_tts',
|
||||
text=text,
|
||||
model_type='CustomVoice',
|
||||
speaker=speaker,
|
||||
# ... all params ...
|
||||
)
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
return cached_audio[0]
|
||||
# ... generate ...
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
```
|
||||
|
||||
**Fix Required:**
|
||||
|
||||
1. Add `from utils.audio.cache import get_audio_cache` import
|
||||
2. Initialize `self.audio_cache = get_audio_cache()` in `__init__`
|
||||
3. Generate cache keys in `_generate_audio_for_text()` BEFORE generation
|
||||
4. Check cache and return if hit
|
||||
5. Store results in cache after generation with duration calculation
|
||||
|
||||
---
|
||||
|
||||
### 2. ❌ **Non-Standard Architecture - Logic Embedded in Unified Node**
|
||||
|
||||
**Severity:** CRITICAL (Architecture)
|
||||
**Location:** `nodes/unified/tts_text_node.py` lines 1128-1304
|
||||
|
||||
**Issue:**
|
||||
Echo-TTS embeds **ALL orchestration logic** (character switching, pause tags, segment parameters) directly in the unified TTS text node instead of in a processor or adapter. This is **180+ lines of engine-specific code** in what should be a generic unified interface.
|
||||
|
||||
**What's embedded in unified node (lines 1128-1304):**
|
||||
|
||||
- Character parser setup and configuration (30+ lines)
|
||||
- Character voice mapping logic (40+ lines)
|
||||
- Pause tag processing (embedded in character loop)
|
||||
- Segment parameter application
|
||||
- Audio generation loop with character switching
|
||||
- Chunk timing and combination
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```python
|
||||
# nodes/unified/tts_text_node.py:1128
|
||||
elif engine_type == "echo_tts":
|
||||
# 180+ lines of orchestration logic here
|
||||
# Character parser setup
|
||||
# Voice mapping
|
||||
# Segment processing
|
||||
# Pause tag handling
|
||||
# Audio generation
|
||||
```
|
||||
|
||||
**Standard Architecture (Qwen3-TTS, Step Audio EditX, IndexTTS):**
|
||||
|
||||
```
|
||||
Unified Node (lines 1306-1450):
|
||||
├─ Setup character parser (10 lines)
|
||||
├─ Build voice_mapping (50 lines)
|
||||
└─ Call processor.process_text(text, voice_mapping, ...)
|
||||
|
||||
Processor (qwen3_tts_processor.py):
|
||||
├─ Character switching orchestration
|
||||
├─ Pause tag processing
|
||||
├─ Segment parameter handling
|
||||
└─ Audio generation coordination
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
|
||||
- **Code duplication**: 180 lines of orchestration logic that should be reusable
|
||||
- **Maintenance burden**: Changes to character switching require editing unified node instead of processor
|
||||
- **Non-standard**: Every other engine (Qwen3, Step EditX, IndexTTS, VibeVoice) uses processor pattern
|
||||
- **Not reusable**: Logic cannot be reused by other nodes (e.g., batch processing, API endpoints)
|
||||
- **Harder to test**: Cannot unit test Echo-TTS orchestration without loading entire unified node
|
||||
|
||||
**Fix Required:**
|
||||
Create `nodes/echo_tts/echo_tts_processor.py` following the Qwen3-TTS pattern:
|
||||
|
||||
1. Move character parser setup to processor `__init__`
|
||||
2. Move voice mapping logic to processor
|
||||
3. Implement `process_text(text, voice_mapping, seed, ...)` method
|
||||
4. Move pause tag processing to processor
|
||||
5. Move segment parameter handling to processor
|
||||
6. Move character switching loop to processor
|
||||
7. Update unified node to call `processor.process_text()` (10 lines instead of 180)
|
||||
|
||||
**Reference:** See `nodes/qwen3_tts/qwen3_tts_processor.py` lines 260-441 for correct processor pattern.
|
||||
|
||||
---
|
||||
|
||||
### 3. ❌ **Missing Interrupt Handling**
|
||||
|
||||
**Severity:** CRITICAL (User Experience)
|
||||
**Location:** `nodes/echo_tts/echo_tts_srt_processor.py`
|
||||
|
||||
**Issue:**
|
||||
|
||||
- No import of `comfy.model_management`
|
||||
- No interrupt checks in subtitle processing loop
|
||||
- Users cannot cancel long-running SRT generations
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```bash
|
||||
$ grep "interrupt_processing" nodes/echo_tts/
|
||||
# No matches found
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Users MUST wait for entire SRT generation to complete (could be hours for long videos)
|
||||
- No way to cancel if wrong settings are used
|
||||
- Other engines ALL support interruption (Qwen3, Higgs, VibeVoice, CosyVoice)
|
||||
|
||||
**Reference Implementation (Qwen3-TTS SRT):**
|
||||
|
||||
```python
|
||||
# qwen3_tts_srt_processor.py:12
|
||||
import comfy.model_management as model_management
|
||||
|
||||
# In main loop (line ~200):
|
||||
for i, subtitle in enumerate(subtitles):
|
||||
if model_management.interrupt_processing:
|
||||
raise InterruptedError(f"Qwen3-TTS SRT generation interrupted at subtitle {i+1}/{len(subtitles)}")
|
||||
# ... process subtitle ...
|
||||
```
|
||||
|
||||
**Fix Required:**
|
||||
|
||||
1. Add `import comfy.model_management as model_management` at top of SRT processor
|
||||
2. Add interrupt check at start of main subtitle loop
|
||||
3. Add interrupt check inside character segment loops (if applicable)
|
||||
4. Raise `InterruptedError` with informative message
|
||||
|
||||
---
|
||||
|
||||
## Important Issues (SHOULD FIX)
|
||||
|
||||
### 4. ⚠️ **Languages Parameter Should Be Removed**
|
||||
|
||||
**Severity:** IMPORTANT
|
||||
**Location:** `nodes/engines/echo_tts_engine_node.py`
|
||||
|
||||
**Issue:**
|
||||
The engine node has a `languages` string input parameter that appears to be incorrect:
|
||||
|
||||
- No language parameter exists in the official Echo-TTS inference API
|
||||
- All text presets in official repo are English-only
|
||||
- Uses byte-level UTF-8 tokenization (not language-specific)
|
||||
- The parsed `languages` parameter in the adapter is never actually used in generation
|
||||
|
||||
**Evidence:**
|
||||
|
||||
```python
|
||||
# echo_tts_adapter.py:188-195 - parsed but never used
|
||||
def _parse_languages(self) -> List[str]:
|
||||
langs = self.config.get("languages", "en")
|
||||
# ... parsing logic ...
|
||||
return parts or ["en"]
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
|
||||
- User confusion (users think they can generate in multiple languages)
|
||||
- Non-functional parameter (doesn't affect output)
|
||||
- Echo-TTS is English-only model
|
||||
|
||||
**Fix Required:**
|
||||
Remove the `languages` widget entirely from `nodes/engines/echo_tts_engine_node.py`
|
||||
|
||||
---
|
||||
|
||||
### 5. ⚠️ **Missing ComfyUI Progress Bars**
|
||||
|
||||
**Severity:** IMPORTANT (UX)
|
||||
**Location:** `engines/adapters/echo_tts_adapter.py`
|
||||
|
||||
**Issue:**
|
||||
|
||||
- No progress bar creation in generation methods
|
||||
- User has no feedback during long generations
|
||||
|
||||
**Reference Implementation (Qwen3-TTS):**
|
||||
|
||||
```python
|
||||
# qwen3_tts_adapter.py:763-788
|
||||
def _create_progress_bar(self, max_tokens: int, text: str = ""):
|
||||
try:
|
||||
import comfy.utils
|
||||
# Estimate tokens based on text
|
||||
estimated_tokens = int(len(text) * 1.5)
|
||||
progress_total = min(estimated_tokens, max_tokens)
|
||||
return comfy.utils.ProgressBar(progress_total)
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
```
|
||||
|
||||
**Fix Required:**
|
||||
|
||||
1. Add progress bar creation in `_generate_audio_for_text()`
|
||||
2. Update progress during sampling/generation
|
||||
3. Estimate total steps based on `num_steps` parameter
|
||||
|
||||
---
|
||||
|
||||
### 6. ⚠️ **Incorrect SRT Timing Mode Restriction**
|
||||
|
||||
**Severity:** IMPORTANT (Bug)
|
||||
**Location:** `engines/adapters/echo_tts_adapter.py:373-376`
|
||||
|
||||
**Issue:**
|
||||
The adapter has an incorrect check that restricts timing modes:
|
||||
|
||||
```python
|
||||
# Echo-TTS SRT support is minimal; fall back to pad_with_silence if needed
|
||||
if timing_mode not in ["pad_with_silence", "concatenate"]:
|
||||
print(f"WARNING: Echo-TTS SRT: timing_mode '{timing_mode}' not supported, using pad_with_silence")
|
||||
timing_mode = "pad_with_silence"
|
||||
```
|
||||
|
||||
However, the `AudioAssemblyEngine` called on line 389 **DOES support all timing modes** (`stretch_to_fit`, `smart_natural`, etc.). This check is unnecessarily restrictive.
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Users cannot use `stretch_to_fit` or other advanced timing modes
|
||||
- Warning message is misleading - modes ARE supported by the assembly engine
|
||||
- Artificially limits functionality that would work fine
|
||||
|
||||
**Fix Required:**
|
||||
Remove lines 373-376 entirely. The `AudioAssemblyEngine.assemble_by_timing_mode()` already handles all timing modes correctly and will raise proper errors for unsupported modes.
|
||||
|
||||
---
|
||||
|
||||
## Minor Issues (NICE TO FIX)
|
||||
|
||||
### 7. 📝 **`[S1]` Text Normalization Missing `[S2]` Safety Check**
|
||||
|
||||
**Severity:** MINOR
|
||||
**Location:** `engines/adapters/echo_tts_adapter.py:81-95`
|
||||
|
||||
**Issue:**
|
||||
The `_normalize_prompt_text()` method correctly adds `[S1]` prefix to follow Echo-TTS WhisperD format, but is missing the `[S2]` safety check from the official implementation.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
```python
|
||||
# PR code (line 95)
|
||||
return f"[S1] {cleaned}".strip() # Always adds [S1]
|
||||
```
|
||||
|
||||
**Result with `[S2]` input:**
|
||||
|
||||
```python
|
||||
Input: "[S2] Hello there"
|
||||
Output: "[S1] [S2] Hello there" # Wrong - double prefix
|
||||
```
|
||||
|
||||
**Official behavior (inference.py:125-126):**
|
||||
|
||||
```python
|
||||
if not text.startswith("[") and not text.startswith("(") and 'S1' not in text and 'S2' not in text:
|
||||
text = "[S1] " + text
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
|
||||
- Minor issue - most users won't use `[S2]` tags
|
||||
- Creates double prefix `[S1] [S2]` which is ugly but harmless (doesn't break generation)
|
||||
- Deviates from official Echo-TTS behavior
|
||||
|
||||
**Note about `[S2]`:**
|
||||
Echo-TTS does NOT have multi-speaker support. `[S1]` and `[S2]` are cosmetic WhisperD transcription format tags that don't change the voice. All audio uses the single reference voice regardless of these tags.
|
||||
|
||||
**Fix suggested:**
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def _normalize_prompt_text(text: str) -> str:
|
||||
if not text:
|
||||
return "[S1]"
|
||||
|
||||
cleaned = text.strip()
|
||||
|
||||
# Don't add [S1] if S1/S2 already present or text starts with bracket
|
||||
if 'S1' in cleaned or 'S2' in cleaned or cleaned.startswith('[') or cleaned.startswith('('):
|
||||
return cleaned
|
||||
|
||||
return f"[S1] {cleaned}".strip()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. 📝 **Force Speaker KV Warning**
|
||||
|
||||
**Severity:** MINOR (Documentation)
|
||||
**Location:** `nodes/engines/echo_tts_engine_node.py:154`
|
||||
|
||||
**Issue:**
|
||||
|
||||
```python
|
||||
"tooltip": "... ⚠️ Not compatible with pause tags ([pause:...])."
|
||||
```
|
||||
|
||||
**Observation:**
|
||||
|
||||
- Warning about pause tag incompatibility is present
|
||||
- BUT pause tags aren't implemented yet anyway
|
||||
- Warning will become relevant once pause tags are added
|
||||
|
||||
**Action:** Keep warning, but verify compatibility once pause tags are implemented
|
||||
|
||||
---
|
||||
|
||||
## Positive Observations ✅
|
||||
|
||||
### What Was Done Well
|
||||
|
||||
1. **✅ Unified Model Interface Integration**
|
||||
|
||||
- Correctly uses `unified_model_interface.load_model()` (line 132)
|
||||
- Follows factory pattern with `register_echo_tts_factory()`
|
||||
- Integrated with ComfyUI model management
|
||||
|
||||
2. **✅ Proper Node Registration**
|
||||
|
||||
- Registered in `nodes.py` (lines 119-120, 475-476)
|
||||
- Correct category: `"TTS Audio Suite/Engines"`
|
||||
|
||||
3. **✅ Device Management**
|
||||
|
||||
- Uses `resolve_torch_device()` utility
|
||||
- Graceful fallback from CUDA to CPU with warnings
|
||||
|
||||
4. **✅ Character Switching Support**
|
||||
|
||||
- Implemented in unified TTS text node (lines 1128-1200)
|
||||
- Uses character parser correctly
|
||||
|
||||
5. **✅ Extensive Segment Parameter Support**
|
||||
|
||||
- Registered in `utils/text/segment_parameters.py` with **12 parameters**
|
||||
- Supports: `seed`, `num_steps`, `cfg_scale_text`, `cfg_scale_speaker`, `cfg_min_t`, `cfg_max_t`, `truncation_factor`, `rescale_k`, `rescale_sigma`, `speaker_kv_scale`, `speaker_kv_max_layers`, `speaker_kv_min_t`, `sequence_length`
|
||||
- Most comprehensive segment parameter support in the project
|
||||
- Allows per-character control: `[Alice|seed:42|cfg_scale_text:5.0]`
|
||||
|
||||
6. **✅ Chunking System**
|
||||
|
||||
- Uses `ImprovedChatterBoxChunker` (line 331)
|
||||
- Uses `ChunkTimingHelper` for combination (line 341)
|
||||
|
||||
7. **✅ SRT Processor Structure**
|
||||
|
||||
- Separate SRT processor file (good separation of concerns)
|
||||
- Uses `AudioAssemblyEngine` for timing assembly
|
||||
- Handles overlap detection with smart fallback
|
||||
|
||||
8. **✅ Reference Audio Processing**
|
||||
|
||||
- Robust `_prepare_reference_audio()` method (lines 140-186)
|
||||
- Handles multiple input formats (dict, string, tensor)
|
||||
- Resampling to Echo-TTS native 44100 Hz
|
||||
- Normalization and 5-minute limit
|
||||
|
||||
9. **✅ Seed Reproducibility**
|
||||
|
||||
- Sets both `torch.manual_seed()` and `torch.cuda.manual_seed()` (lines 202-205)
|
||||
|
||||
---
|
||||
|
||||
## Comparison to Implementation Guide
|
||||
|
||||
### Guide Checklist
|
||||
|
||||
| Requirement | Status | Notes |
|
||||
| --------------------------- | ---------- | --------------------------- |
|
||||
| **Phase 1: Foundation** | | |
|
||||
| Core engine implementation | ✅ Pass | Via unified interface |
|
||||
| Model loading & downloading | ✅ Pass | Downloader not checked yet |
|
||||
| Engine configuration node | ✅ Pass | Clean implementation |
|
||||
| Character switching | ✅ Pass | In unified node |
|
||||
| Pause tag support | ✅ Pass | d |
|
||||
| Caching system | ❌ **FAIL** | Not implemented |
|
||||
| VRAM management | ✅ Pass | Via unified interface |
|
||||
| Interrupt handling | ❌ **FAIL** | Not implemented |
|
||||
| | | |
|
||||
| **Phase 2: SRT Support** | | |
|
||||
| SRT processor | ✅ Pass | Implemented |
|
||||
| Timing & assembly | ✅ Pass | Uses AudioAssemblyEngine |
|
||||
| Character switching in SRT | ✅ Pass | |
|
||||
| | | |
|
||||
| **Unified Systems** | | |
|
||||
| UnifiedModelInterface | ✅ Pass | Correctly used |
|
||||
| ComfyUIModelWrapper | ✅ Pass | Via unified interface |
|
||||
| UnifiedCacheManager | ❌ **FAIL** | Not used |
|
||||
| CharacterParser | ✅ Pass | Used in unified node |
|
||||
| PauseTagProcessor | ✅ Pass | d |
|
||||
| LanguageMapper | ⚠️ N/A | Echo-TTS uses language list |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Echo-TTS integration has **significant architectural problems** that must be addressed before merge:
|
||||
|
||||
### Critical Issues (Must Fix):
|
||||
|
||||
1. **Non-standard architecture** - 180+ lines of orchestration logic embedded in unified node instead of processor
|
||||
2. **Missing audio caching** - Performance issue, all other engines have this
|
||||
3. **Missing interrupt handling** - User experience issue, cannot cancel SRT generations
|
||||
|
||||
### Important Issues (Should Fix):
|
||||
|
||||
4. **Languages parameter** - Non-functional, should be removed (English-only model)
|
||||
5. **Progress bars** - No user feedback during generation
|
||||
|
||||
**Recommendation:** This PR needs **major refactoring** before merge. The architectural deviation (embedding logic in unified node) is particularly problematic as it:
|
||||
|
||||
- Creates maintenance burden (180 lines of code that should be in processor)
|
||||
- Breaks code reusability (logic cannot be used by other nodes)
|
||||
- Deviates from established patterns (Qwen3, Step EditX, IndexTTS all use processor pattern)
|
||||
|
||||
The PR author did good work on the Echo-TTS integration itself, but the project architecture needs to be followed.
|
||||
|
||||
---
|
||||
|
||||
## Future Work (Post-Merge)
|
||||
|
||||
### Engine Bundling
|
||||
|
||||
**Current Status:** Echo-TTS is installed via `pip install echo-tts` (not bundled).
|
||||
|
||||
**Recommendation for future:** Consider bundling Echo-TTS like other engines (Step Audio EditX, F5-TTS) to enable:
|
||||
|
||||
1. **Custom modifications** - Add it/s progress indicators, custom features
|
||||
2. **Dependency isolation** - Avoid conflicts with user's environment
|
||||
3. **Version stability** - Lock to specific tested version
|
||||
4. **Patches and fixes** - Apply project-specific patches without waiting for upstream
|
||||
|
||||
**Rationale for deferring:**
|
||||
- Echo-TTS is simple with minimal dependencies (no immediate conflicts expected)
|
||||
- PR scope is already large with architecture refactoring
|
||||
- Can be added in follow-up PR after real-world testing
|
||||
- Easy migration path (create `engines/echo_tts/bundled/` when needed)
|
||||
|
||||
**When to bundle:**
|
||||
- If dependency conflicts arise in practice
|
||||
- If we need to add custom features (progress bars, patches)
|
||||
- If upstream makes breaking changes
|
||||
|
||||
**Action:** Track as future enhancement, not blocking for initial merge.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples to Share with PR Author
|
||||
|
||||
### 1. Cache Integration Example
|
||||
|
||||
```python
|
||||
# In __init__:
|
||||
from utils.audio.cache import get_audio_cache
|
||||
self.audio_cache = get_audio_cache()
|
||||
|
||||
# In _generate_audio_for_text():
|
||||
# BEFORE generation:
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
audio_component = generate_stable_audio_component(reference_audio=ref_audio)
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
'echo_tts',
|
||||
text=text,
|
||||
audio_component=audio_component,
|
||||
ref_text=ref_text,
|
||||
num_steps=params['num_steps'],
|
||||
cfg_scale_text=params['cfg_scale_text'],
|
||||
cfg_scale_speaker=params['cfg_scale_speaker'],
|
||||
seed=seed,
|
||||
# ... all params that affect output ...
|
||||
)
|
||||
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached Echo-TTS audio: '{text[:30]}...'")
|
||||
return cached_audio[0], self.SAMPLE_RATE
|
||||
|
||||
# AFTER generation:
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, 'echo_tts')
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
```
|
||||
|
||||
###
|
||||
|
||||
### 3. Interrupt Handling Example
|
||||
|
||||
```python
|
||||
# In echo_tts_srt_processor.py, add import:
|
||||
import comfy.model_management as model_management
|
||||
|
||||
# In process_srt_content(), in main loop:
|
||||
for i, subtitle in enumerate(subtitles):
|
||||
# Check for interrupt
|
||||
if model_management.interrupt_processing:
|
||||
raise InterruptedError(
|
||||
f"Echo-TTS SRT generation interrupted at subtitle {i+1}/{len(subtitles)}"
|
||||
)
|
||||
|
||||
# ... existing generation code ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Review completed:** 2026-02-05
|
||||
**Next step:** Share findings with PR author and request changes
|
||||
@@ -0,0 +1,67 @@
|
||||
# Isolated Runtimes Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Keep ComfyUI in one modern main runtime while routing fragile engines into
|
||||
their own pinned Python environments.
|
||||
|
||||
## Why
|
||||
|
||||
- `transformers` major-version breaks now hit engines differently
|
||||
- one broken HF stack should not poison the whole node runtime
|
||||
- per-engine rollback is simpler than global dependency rollback
|
||||
|
||||
## User impact
|
||||
|
||||
- isolated runtimes duplicate dependencies on disk
|
||||
- large packages such as PyTorch will usually be installed again inside the worker runtime
|
||||
- this must be warned in:
|
||||
- docs
|
||||
- node tooltips
|
||||
- runtime creation logs
|
||||
|
||||
## Contract
|
||||
|
||||
ComfyUI process:
|
||||
- owns UI, node orchestration, model discovery, caching policy
|
||||
- prepares normalized request payloads
|
||||
- launches a worker only for engines configured as `runtime_mode=isolated`
|
||||
|
||||
Worker process:
|
||||
- runs in a dedicated Python env
|
||||
- receives a JSON request
|
||||
- performs load / generate / unload actions
|
||||
- returns JSON response + output artifact paths
|
||||
|
||||
## Initial profiles
|
||||
|
||||
- `vibevoice_transformers4`
|
||||
- `qwen3_tts_transformers5`
|
||||
- `step_audio_editx_transformers5`
|
||||
- `moss_tts_transformers5`
|
||||
|
||||
## First target
|
||||
|
||||
VibeVoice / KugelAudio
|
||||
|
||||
Reason:
|
||||
- highest dependency fragility
|
||||
- strongest evidence that global `transformers 5` compatibility is not enough
|
||||
- likely template for future HF-heavy engines
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Add config + registry support for runtime routing
|
||||
2. Add launcher + request/response protocol
|
||||
3. Add VibeVoice worker entrypoint
|
||||
4. Add opt-in routing from unified interface
|
||||
5. Add user-facing runtime path configuration
|
||||
6. Expand to Qwen / MOSS / Step Audio only after VibeVoice works
|
||||
|
||||
## Non-goals
|
||||
|
||||
- no silent env creation
|
||||
- no arbitrary command execution
|
||||
- no shell-constructed user input
|
||||
- no automatic migration of all engines
|
||||
- no pretending disk duplication does not exist
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
This document tracks architectural issues and inconsistencies that need refactoring to improve modularity and reduce code duplication.
|
||||
|
||||
## SRT Processing Architecture Issues
|
||||
|
||||
### Problem: Inconsistent SRT Implementation Approaches
|
||||
Different engines use completely different patterns for SRT processing:
|
||||
## SRT Processing Architecture Issues
|
||||
|
||||
### Problem: VibeVoice Native Multi-Speaker SRT Uses Non-Contiguous Global Slots
|
||||
- **Issue**: Later subtitles can request global `Speaker 2`/`Speaker 3` IDs while VibeVoice truncates and renumbers the supplied voice prompts from zero.
|
||||
- **Impact**: A subtitle containing only later global speakers can bind the wrong reference or leave a requested speaker without a matching voice prompt.
|
||||
- **Solution**: Use the global character map only to select references, then compact each subtitle to request-local speakers `0..N-1` with references in the same order.
|
||||
- **Regression test**: Cover subtitle 1 `[Alice]...[Bob]...`, followed by subtitle 2 `[Bob]...[Rick]...`.
|
||||
- **Priority**: High
|
||||
|
||||
### Problem: Inconsistent SRT Implementation Approaches
|
||||
Different engines use completely different patterns for SRT processing:
|
||||
|
||||
1. **ChatterBox (Old)**: Uses `ChatterboxSRTTTSNode` class (should be processor)
|
||||
2. **VibeVoice**: Uses proper `VibeVoiceSRTProcessor` class with full implementation
|
||||
@@ -136,4 +143,4 @@ Each engine reimplements:
|
||||
---
|
||||
|
||||
*Last Updated: 2025-01-XX*
|
||||
*Add new issues to this file as they are discovered during development*
|
||||
*Add new issues to this file as they are discovered during development*
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
TTS Audio Suite - Transformers 5.x Higgs Audio 2 Investigation Report
|
||||
|
||||
Scope
|
||||
- Problem: Higgs Audio 2 regressed after the suite moved the main environment to Transformers 5.x.
|
||||
- Goal: determine whether Higgs Audio 2 is a good native Transformers 5 patch target, or whether it should move to runtime isolation.
|
||||
|
||||
Environment
|
||||
- Windows / ComfyUI runtime.
|
||||
- Main env on Transformers 5.x.
|
||||
- Direct repro and validation performed in the real Windows Python environment outside the ComfyUI UI wrapper.
|
||||
|
||||
Findings Summary
|
||||
- Higgs Audio 2 is not blocked at the shallow import/API level only.
|
||||
- Some real compatibility fixes were possible:
|
||||
- bundled cache API adaptation
|
||||
- cache-arg rename fixes
|
||||
- major cold-load regression fix
|
||||
- However, the remaining failure is decode-path semantics, especially around StaticCache and CUDA graph replay.
|
||||
- Higgs can be made to load and "run" on Transformers 5, but the high-performance path is not trustworthy and produced incorrect audio.
|
||||
- This is not a good short-path patch target like IndexTTS.
|
||||
|
||||
Reproduction
|
||||
1. Transformers 5 main environment.
|
||||
2. Higgs Audio 2 loaded through the normal TTS Text path with voice cloning.
|
||||
3. Observed one or more of:
|
||||
- multi-minute cold-load stalls
|
||||
- StaticCache API crashes
|
||||
- CUDA graph capture/runtime failures
|
||||
- runaway/gibberish generation
|
||||
- near-empty or silent output
|
||||
|
||||
Key Diagnostic Evidence
|
||||
- Initial cold-load timing showed:
|
||||
- `⏱️ Higgs Audio: Core model load took 440.61s`
|
||||
- Tokenizer/HuBERT stages were not the bottleneck:
|
||||
- text tokenizer around 1-2s
|
||||
- audio tokenizer around 2-5s
|
||||
- The 440s stall was inside `HiggsAudioModel.from_pretrained(...)`, specifically Hugging Face missing-key initialization.
|
||||
- Direct checkpoint/model key comparison in runtime showed:
|
||||
- `missing_count 0`
|
||||
- `unexpected_count 0`
|
||||
- After bypassing unnecessary missing-key initialization for complete local checkpoints, direct timing dropped to:
|
||||
- `⏱️ Higgs Audio: Core model load took 58.46s`
|
||||
- later direct runs around `33-58s` depending on cache state
|
||||
|
||||
- StaticCache/API drift evidence:
|
||||
- `'StaticCache' object is not subscriptable`
|
||||
- later mask/cache shape mismatches around static cache width `1024`
|
||||
|
||||
- Cache-arg drift evidence:
|
||||
- bundled Higgs/LLaMA path mixed `past_key_value` and `past_key_values`
|
||||
- led to duplicated kwarg failures and broken decode semantics
|
||||
|
||||
- CUDA graph replay evidence:
|
||||
- direct runtime probe with graph replay active produced:
|
||||
- immediate or near-immediate audio termination
|
||||
- tiny garbage clips
|
||||
- or invalid decode behavior
|
||||
- direct runtime probe with StaticCache but without CUDA graph replay produced:
|
||||
- normal-length audio token generation
|
||||
- reasonable token spread
|
||||
- real waveform output
|
||||
|
||||
Attempts and Results (chronological)
|
||||
1) StaticCache compatibility shims
|
||||
- Added bundled helpers to access cache tensors/device across old tuple caches and new Cache/StaticCache APIs.
|
||||
- Result: fixed immediate StaticCache indexing crashes.
|
||||
|
||||
2) CUDA graph failure fallback cleanup
|
||||
- On graph capture failure, forced cache recreation and switched to DynamicCache instead of continuing with stale StaticCache assumptions.
|
||||
- Result: removed some follow-on crashes after graph failure, but not the core correctness issue.
|
||||
|
||||
3) Attention backend adjustment
|
||||
- Stopped forcing eager attention on CUDA and preferred SDPA instead.
|
||||
- Result: better alignment with the intended high-performance path, but did not fully restore correctness.
|
||||
|
||||
4) TTS defaults forcing audio generation
|
||||
- Set Higgs TTS defaults to force audio generation rather than letting the model drift in text mode.
|
||||
- Result: prevented obvious free-running text-style generation, but did not solve bad output.
|
||||
|
||||
5) Cold-load regression fix
|
||||
- Added a local complete-checkpoint detector and skipped Hugging Face missing-key initialization when the local Higgs checkpoint was complete.
|
||||
- Result: large real improvement to cold-load time.
|
||||
|
||||
6) Cache-argument adaptation
|
||||
- Patched bundled Higgs/LLaMA call sites for Transformers 5 cache-arg naming.
|
||||
- Result: moved runtime further, but surfaced deeper semantic issues instead of fixing them outright.
|
||||
|
||||
7) CUDA graph runner PyTorch compatibility
|
||||
- Wrapped input-buffer updates in inference mode to satisfy current PyTorch restrictions.
|
||||
- Result: removed one explicit runtime crash, but not the underlying decode corruption.
|
||||
|
||||
8) StaticCache without CUDA graph replay direct comparison
|
||||
- Direct Windows probes showed:
|
||||
- `enable_cuda_graphs=True` with graph replay: corrupted/too-short audio output
|
||||
- `enable_cuda_graphs=False`: long audio-token generation and real waveform output
|
||||
- `StaticCache` with graph replay skipped: closer to sane behavior than replay path
|
||||
- Result: isolated the remaining failure to the CUDA graph replay path on Transformers 5.
|
||||
|
||||
Interpretation
|
||||
- Higgs Audio 2 on Transformers 5 is not blocked by a single missing symbol or a few renamed APIs.
|
||||
- The remaining problem is decode/runtime behavior, especially the interaction between:
|
||||
- StaticCache
|
||||
- bundled Higgs decode logic
|
||||
- LLaMA attention/cache semantics on current Transformers 5
|
||||
- CUDA graph replay
|
||||
- That is behavior-level drift, not just compatibility-shim work.
|
||||
|
||||
Decision
|
||||
- Higgs Audio 2 on Transformers 5 is still theoretically patchable.
|
||||
- It is not a good use of time right now.
|
||||
- The engine reached the same bad zone as Qwen3-TTS, but with even more sensitivity around CUDA graphs and cache behavior.
|
||||
- Runtime isolation is the correct path for now.
|
||||
|
||||
Operational Recommendation
|
||||
- Do not continue native Transformers 5 Higgs patching in the main branch.
|
||||
- Route Higgs Audio 2 into an isolated legacy runtime instead.
|
||||
- Prefer a dedicated Higgs runtime first rather than assuming shared legacy parity.
|
||||
- Revisit native Transformers 5 support only if Higgs becomes important enough to justify deeper decode/cuda-graph compatibility work.
|
||||
|
||||
Notes
|
||||
- The cold-load fix was a real improvement and may still be worth revisiting later, even outside the final T5 decision.
|
||||
- The rest of this investigation should be treated as a branch-local investigation record, not as a stable main-branch solution.
|
||||
@@ -0,0 +1,118 @@
|
||||
TTS Audio Suite - Transformers 5.x Qwen3-TTS Investigation Report
|
||||
|
||||
Scope
|
||||
- Problem: Qwen3-TTS garbled audio or load failure on Transformers 5.0.0.
|
||||
- Goal: identify a safe compatibility path or conclude unsupported for now.
|
||||
|
||||
Findings Summary
|
||||
- The tokenizer path in Transformers 5.0.0 is the core blocker.
|
||||
- AutoProcessor/AutoTokenizer paths either fail or apply an incorrect regex fix and lead to broken token IDs.
|
||||
- Direct Qwen2Tokenizer loading via vocab/merges is broken in this environment (encoder remains empty or vocab_size=1).
|
||||
- The fast tokenizer build using tokenizers BPE can be created in isolation, but in ComfyUI runtime PreTrainedTokenizerFast fails to load vocab from a temporary directory (error: unable to load vocabulary).
|
||||
- The model config's eos/pad tokens (<|im_end|>, <|endoftext|>) are not present in vocab.json; safe fallback tokens that exist are <s>, <unk>, </s>.
|
||||
|
||||
Reproduction (Windows / ComfyUI)
|
||||
1) Transformers 5.0.0 environment.
|
||||
2) Qwen3-TTS model folder present with vocab.json and merges.txt.
|
||||
3) Qwen3-TTS load fails or produces garbled output.
|
||||
|
||||
Key Diagnostic Evidence
|
||||
- Fast tokenizer build using tokenizers (BPE) works in an isolated test:
|
||||
- vocab_size=151643
|
||||
- bos=<s> (id 128245), unk=<unk> (id 128244), eos/pad=</s> (id 128247)
|
||||
- In ComfyUI runtime, PreTrainedTokenizerFast.from_pretrained(tmpdir) fails with:
|
||||
- "Unable to load vocabulary from file"
|
||||
- The tokenizer_config.json tokens (<|im_end|>, <|endoftext|>) do not exist in vocab.json.
|
||||
|
||||
Attempts and Results (chronological)
|
||||
1) Rope fallback normalization for transformers 5.0.0
|
||||
- Added resolve_rope_type_with_fallback and patched rope_scaling.
|
||||
- Reduced KeyError crashes but did not fix garbled audio.
|
||||
2) AutoProcessor / AutoTokenizer (fix_mistral_regex)
|
||||
- On 5.0.0, fix_mistral_regex triggers duplicate-arg errors or incorrect regex warning.
|
||||
- Still leads to bad tokenization.
|
||||
3) Direct Qwen2Tokenizer(vocab/merges)
|
||||
- In runtime, encoder remains empty; vocab_size collapses to 1.
|
||||
- Garbled output even when forced ids are set.
|
||||
4) Fast tokenizer BPE build using tokenizers (Tokenizer + BPE)
|
||||
- Works in isolated script; IDs look correct.
|
||||
- Fails in runtime when wrapped by PreTrainedTokenizerFast (from_pretrained tmpdir).
|
||||
5) Token ID correction
|
||||
- For tokens not present in vocab.json, fallback to <s>/<unk>/<\s>.
|
||||
- Valid in test but runtime build still blocked by tokenizer load error.
|
||||
|
||||
Conclusion
|
||||
- With Transformers 5.0.0 as installed in the reported environment, the tokenizer path is blocked.
|
||||
- This is not fixed by rope patches or token id corrections alone.
|
||||
- A working solution likely requires either:
|
||||
- a Transformers build that accepts tokenizer_object (or can load tokenizer.json without requiring vocab/merges), or
|
||||
- downgrading Transformers to a 4.x release known to work with Qwen3-TTS.
|
||||
|
||||
Recommendation
|
||||
- Temporarily mark Transformers 5.0.0 as unsupported for Qwen3-TTS and emit a clear warning.
|
||||
- Provide guidance to downgrade Transformers or use a compatible build.
|
||||
|
||||
Notes
|
||||
- No changes in this report alter project behavior.
|
||||
|
||||
Addendum - Transformers 5.10.2 Re-check
|
||||
|
||||
Scope
|
||||
- Problem: determine whether newer Transformers 5.10.2 improves Qwen3-TTS enough to avoid runtime isolation.
|
||||
- Goal: distinguish shallow API drift from deeper generation/runtime incompatibility.
|
||||
|
||||
Environment
|
||||
- Windows / ComfyUI runtime.
|
||||
- Main env on Transformers 5.10.2.
|
||||
- Direct repro also performed in the real Windows Python environment outside the ComfyUI UI wrapper.
|
||||
|
||||
What Changed vs 5.0.0
|
||||
- The failure mode is different.
|
||||
- The first blocker is no longer the tokenizer collapse described above.
|
||||
- Qwen3-TTS can be pushed significantly further on 5.10.2 with local compatibility shims.
|
||||
|
||||
Key Diagnostic Evidence
|
||||
- Direct model load initially failed with:
|
||||
- `KeyError: 'default'`
|
||||
- source: `ROPE_INIT_FUNCTIONS[self.rope_type]`
|
||||
- Current Transformers 5.10.2 no longer exposes a legacy `"default"` RoPE entry:
|
||||
- available keys observed in runtime: `dynamic`, `linear`, `llama3`, `longrope`, `proportional`, `yarn`
|
||||
- After restoring a local `"default"` compatibility entry, model load succeeded.
|
||||
- Generation then failed on changed Hugging Face masking APIs:
|
||||
- `create_causal_mask() got an unexpected keyword argument 'input_embeds'`
|
||||
- After adapting those call sites to the current HF signature, generation progressed further.
|
||||
- Final direct generation failure on 5.10.2 was:
|
||||
- `RuntimeError: Expected size for first two dimensions of batch2 tensor to be: [16, 277] but got: [16, 139].`
|
||||
- source: SDPA attention path during generation.
|
||||
|
||||
Attempts and Results (5.10.2)
|
||||
1) Legacy RoPE compatibility restore
|
||||
- Added a local fallback for legacy `"default"` RoPE initialization.
|
||||
- Result: Qwen3-TTS load progressed and completed successfully.
|
||||
2) Mask helper API adaptation
|
||||
- Updated bundled Qwen mask call sites to match current HF `create_causal_mask` / `create_sliding_window_causal_mask` signatures.
|
||||
- Result: generation progressed beyond the earlier immediate crash.
|
||||
3) Direct generation validation with real voice-clone inputs
|
||||
- Used real reference audio and transcript in the Windows environment.
|
||||
- Result: no longer a trivial import/load/signature issue; generation hit a tensor-shape mismatch inside SDPA.
|
||||
|
||||
Interpretation
|
||||
- Transformers 5.10.2 is not blocked in the same way as 5.0.0.
|
||||
- Some problems are still shallow compatibility issues and can be adapted cleanly.
|
||||
- However, once generation reaches SDPA tensor-shape mismatch territory, this stops being a simple API-shim problem.
|
||||
- At that point, continuing the patch path means re-validating model behavior against newer Hugging Face generation/mask/cache semantics, not just restoring removed symbols.
|
||||
|
||||
Decision
|
||||
- Qwen3-TTS on Transformers 5.10.2 is still technically patchable in principle.
|
||||
- It is not a good short-path target like IndexTTS.
|
||||
- The suite should prefer runtime isolation for Qwen3-TTS instead of continuing deep Transformers 5 surgery.
|
||||
|
||||
Operational Recommendation
|
||||
- Treat Qwen3-TTS as a legacy-stack engine for now.
|
||||
- Route it into the shared legacy Transformers 4 runtime used by compatible engines (same family already proven for VibeVoice/Qwen in prior testing).
|
||||
- Keep the main environment on Transformers 5.
|
||||
- Revisit native Transformers 5 support only if Qwen3-TTS becomes strategically important enough to justify deeper behavior-level compatibility work.
|
||||
|
||||
Notes
|
||||
- The transient 5.10.2 patch attempts were not kept as the main solution path.
|
||||
- The current project direction is runtime isolation, not continued in-place Transformers 5 patching for Qwen3-TTS.
|
||||
@@ -32,12 +32,12 @@ VIBEVOICE_MODELS = {
|
||||
"vibevoice-1.5B": {
|
||||
"repo": "microsoft/VibeVoice-1.5B",
|
||||
"files": [...], # 5.4GB total
|
||||
},
|
||||
"vibevoice-7B": {
|
||||
"repo": "microsoft/VibeVoice-Large",
|
||||
"files": [...], # 9.3GB total
|
||||
}
|
||||
}
|
||||
},
|
||||
"vibevoice-7B": {
|
||||
"repo": "aoi-ot/VibeVoice-Large", # community mirror (Microsoft repo removed)
|
||||
"files": [...], # 9.3GB total
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Use `unified_downloader` for all downloads
|
||||
@@ -206,4 +206,4 @@ git+https://github.com/microsoft/VibeVoice.git
|
||||
|
||||
- Created: 2025-08-29
|
||||
- Author: Claude (TTS Audio Suite Assistant)
|
||||
- Status: Ready for implementation
|
||||
- Status: Ready for implementation
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# TTS Audio Suite - Auxiliary Model Reference Data
|
||||
# Source of truth for helper/post-process model dropdowns that are not engines.
|
||||
|
||||
categories:
|
||||
- id: punctuation_truecase
|
||||
name: Punctuation / Truecase
|
||||
node_targets:
|
||||
- "📝 ASR Punctuation / Truecase"
|
||||
description: "Optional punctuation and true-casing post-process models for raw ASR text. Designed mainly for low-punctuation transcripts such as Granite outputs, but usable on any plain text."
|
||||
models:
|
||||
- id: pcs_en
|
||||
name: "English Fullstop + Truecase"
|
||||
dropdown_label: "English Fullstop + Truecase (EN, ~210MB)"
|
||||
repo_id: "1-800-BAD-CODE/punctuation_fullstop_truecase_english"
|
||||
pretrained_name: "pcs_en"
|
||||
model_dir: "punctuation_fullstop_truecase_english"
|
||||
spe_filename: "spe_32k_lc_en.model"
|
||||
model_filename: "punct_cap_seg_en.onnx"
|
||||
config_filename: "config.yaml"
|
||||
size: "~210MB"
|
||||
auto_download: true
|
||||
languages:
|
||||
- "English"
|
||||
notes: "English-only. Best on lowercased, unpunctuated ASR text."
|
||||
- id: pcs_romance
|
||||
name: "Romance Fullstop + Truecase"
|
||||
dropdown_label: "Romance Fullstop + Truecase (ES/FR/IT/PT/CA/GL, ~144MB)"
|
||||
repo_id: "1-800-BAD-CODE/punctuation_fullstop_truecase_romance"
|
||||
pretrained_name: "pcs_romance"
|
||||
model_dir: "punctuation_fullstop_truecase_romance"
|
||||
spe_filename: "sp.model"
|
||||
model_filename: "model.onnx"
|
||||
config_filename: "config.yaml"
|
||||
size: "~144MB"
|
||||
auto_download: true
|
||||
languages:
|
||||
- "Spanish"
|
||||
- "French"
|
||||
- "Italian"
|
||||
- "Portuguese"
|
||||
- "Catalan"
|
||||
- "Galician"
|
||||
notes: "Romance-language punctuation and true-casing model."
|
||||
- id: pcs_47lang
|
||||
name: "47-Language Fullstop + Truecase"
|
||||
dropdown_label: "47-Language Fullstop + Truecase (~1.11GB)"
|
||||
repo_id: "1-800-BAD-CODE/punct_cap_seg_47_language"
|
||||
pretrained_name: "pcs_47lang"
|
||||
model_dir: "punct_cap_seg_47_language"
|
||||
spe_filename: "spe_unigram_64k_lowercase_47lang.model"
|
||||
model_filename: "punct_cap_seg_47lang.onnx"
|
||||
config_filename: "config.yaml"
|
||||
size: "~1.11GB"
|
||||
auto_download: true
|
||||
languages:
|
||||
- "47 languages"
|
||||
notes: "Heavier multilingual fallback with the widest language coverage supported by the punctuators package."
|
||||
model_layouts_markdown: |
|
||||
# Auxiliary Model Layouts
|
||||
|
||||
Use this document for helper/post-process model folder structures and placement notes.
|
||||
These are not engine weights. They are optional models used by standalone utility nodes.
|
||||
|
||||
For repository/source URLs, use [AUX_MODEL_SOURCES.md](AUX_MODEL_SOURCES.md).
|
||||
|
||||
## Punctuation / Truecase
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/punctuation/
|
||||
├── punctuation_fullstop_truecase_english/
|
||||
├── punctuation_fullstop_truecase_romance/
|
||||
└── punct_cap_seg_47_language/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Models download lazily on first use by the punctuation node.
|
||||
- These are helper models for text cleanup, not TTS/ASR engines.
|
||||
- The English model is the lightest and is mainly intended for raw English ASR text.
|
||||
- The 47-language model is much heavier and should be treated as the multilingual fallback.
|
||||
File diff suppressed because it is too large
Load Diff
+23
-12
@@ -2,15 +2,26 @@
|
||||
|
||||
## Engine Comparison
|
||||
|
||||
| Engine | Models | Size | TTS | SRT | VC | Special Features | Languages |
|
||||
| ------------------ | ----------------------------------------- | ------------ | --- | --- | --- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **F5-TTS** | Base, v1, E2TTS + 8 lang models | ~1.2GB each | ✅ | ✅ | ❌ | Targeted Word/Speech Editing, Speed control | 🇺🇸🇩🇪🇪🇸🇫🇷🇮🇹🇯🇵🇧🇷🇵🇱🇮🇳🇹🇭 |
|
||||
| **ChatterBox** | EN, DE×3, IT, FR, RU, HY, KA, JA, KO, NO | ~4.3GB | ✅ | ✅ | ✅ | Expressiveness slider | 🇺🇸🇩🇪🇫🇷🇮🇹🇯🇵🇰🇷🇷🇺🇳🇴🇦🇲🇬🇪 |
|
||||
| **ChatterBox 23L** | v1, v2, Vietnamese (Viterbox) | ~4.3GB | ✅ | ✅ | ✅ | 24 languages in single model, emotion tokens (v2 - doesn't work) | 🇺🇸🇨🇳🇩🇪🇪🇸🇫🇷🇮🇹🇯🇵🇰🇷🇷🇺🇵🇹🇵🇱🇮🇳🇦🇪🇹🇷🇹🇭🇳🇴🇻🇳🇩🇰🇫🇮🇬🇷🇮🇱🇲🇾🇳🇱🇸🇪🇰🇪(+9) |
|
||||
| **VibeVoice** | 1.5B, 7B, Hindi-1.5B, Hindi-7B | 5.4GB / 18GB | ✅ | ✅ | ❌ | 90-min long-form, Native 4-speaker, 4-bit quantization | 🇺🇸🇨🇳🇮🇳 |
|
||||
| **Higgs Audio 2** | 3B | ~9GB | ✅ | ✅ | ❌ | 3 multi-speaker, CUDA graphs (55+ tokens/sec) | 🇺🇸🇨🇳🇩🇪🇪🇸🇰🇷 |
|
||||
| **IndexTTS-2** | IndexTTS-2 | ? | ✅ | ✅ | ❌ | Emotion Control: 8 vectors, Text as reference, Audio as reference | 🇺🇸🇨🇳🇯🇵 |
|
||||
| **CosyVoice3** | 0.5B, 0.5B-RL | ~5.4GB | ✅ | ✅ | ✅ | Paralinguistic tags | 🇺🇸🇨🇳🇯🇵🇰🇷 |
|
||||
| **Qwen3-TTS** | 0.6B, 1.7B (CustomVoice/VoiceDesign/Base) | ~3-6GB | ✅ | ✅ | ❌ | Voice design | 🇺🇸🇨🇳🇩🇪🇪🇸🇫🇷🇮🇹🇯🇵🇰🇷🇷🇺🇵🇹 |
|
||||
| **Step Audio EditX** | 3B LLM + CosyVoice | ~7GB | ✅ | ✅ | ❌ | Second Pass Speech Editing Node: 14 emotions, 32 speaking styles, Paralinguistic effects | 🇺🇸🇨🇳🇯🇵🇰🇷 |
|
||||
| **RVC** | Community .pth | 100-300MB | ❌ | ❌ | ✅ | Real-time VC, Pitch shift (±14), 6 HuBERT models, Language-independent | 🇺🇸🇨🇳🇩🇪🇪🇸🇫🇷🇮🇹🇯🇵🇰🇷🇷🇺🇧🇷🇵🇱🇮🇳🇦🇪🇹🇷🇹🇭🇳🇴🇻🇳🇦🇲🇬🇪🇩🇰🇫🇮🇬🇷🇮🇱🇲🇾🇳🇱🇸🇪🇰🇪 |
|
||||
| Engine | Isolation | Models | Size | TTS | SRT | VC | ASR | Sound Effects | Training | License | Special Features | Languages |
|
||||
| ------------------ | --------- | ----------------------------------------- | ------------ | :-: | :-: | :-: | :-: | :-----------: | :------: | ------------------------ | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **F5-TTS** | Main | Base, v1, E2TTS + 8 lang models | ~1.2GB each | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | CC-BY-NC-4.0 | Targeted Word/Speech Editing, Speed control | 10 |
|
||||
| **ChatterBox** | Main | EN, DE×3, IT, FR, RU, HY, KA, JA, KO, NO | ~4.3GB | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | MIT | Expressiveness slider | 10 |
|
||||
| **ChatterBox 23L** | Main | v1, v2, v3, Vietnamese (Viterbox), Egyptian Arabic (oddadmix) | ~4.3GB | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | MIT | V1, V2, and V3 official checkpoints, Emotion tokens (v2; currently ineffective), V3 skips the legacy alignment analyzer and trims the final token artifact | 25 |
|
||||
| **VibeVoice** | Shared | 1.5B, 7B, KugelAudio-0 (7B), kugel-2 (7B), Hindi-1.5B/7B | 5.4GB / 18GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | MIT (research-only per model card) | 90-min long-form, Native 4-speaker (Base models), Multilingual (KugelAudio variants), 4-bit quantization | 27 |
|
||||
| **Higgs Audio 2** | Shared | 3B | ~9GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Boson Higgs Audio 2 Community License | 3 multi-speaker, CUDA graphs (55+ tokens/sec) | 5 |
|
||||
| **Higgs Audio v3** | Main | 4B | ~8GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Boson Higgs Audio v3 Research and Non-Commercial License | Native inline emotion/style/prosody/SFX tags, Zero-shot voice cloning, 100+ language support | 100+ |
|
||||
| **IndexTTS 2 / 2.5** | Main | IndexTTS-2, IndexTTS-2.5 | ~4.7GB / ~5.49GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | bilibili Model Use License | Emotion Control: 8 vectors, Text as reference, Audio as reference, IndexTTS-2.5 official internal feature-duration scaling (not prosody planning), IndexTTS-2.5 pronunciation annotations | 5 |
|
||||
| **CosyVoice3** | Main | 0.5B, 0.5B-RL | ~5.4GB | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | Apache-2.0 | Paralinguistic tags | 4 |
|
||||
| **Qwen3-TTS** | Shared | 0.6B, 1.7B (CustomVoice/VoiceDesign/Base) | ~3-6GB | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | Apache-2.0 | Voice design, ASR (Automatic Speech Recognition) | 10 |
|
||||
| **Granite ASR** | Main | granite-4.0-1b-speech, granite-speech-4.1-2b, granite-speech-4.1-2b-plus | ~4.6GB | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | Apache-2.0 | Native speaker attribution / diarization (plus model variant), Native word-level timestamps (plus model variant), ASR (Automatic Speech Recognition), Custom timestamps/SRT via reused Qwen forced aligner, Speech translation (experimental), Optional forced aligner auto-routed through shared legacy T4 runtime | 6 |
|
||||
| **Step Audio EditX** | Main | 3B LLM + CosyVoice | ~7GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Apache-2.0 (verify before commercial use) | Second Pass Speech Editing Node: 14 emotions, 32 speaking styles, Paralinguistic effects, Selectable main, shared, or dedicated Python runtime (shared Transformers 4 runtime recommended) | 4 |
|
||||
| **Echo-TTS** | Main | echo-tts-base + fish-s1-dac-min | ~5.3GB + ~1.8GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | CC-BY-NC-SA-4.0 | Diffusion-based (~30s best), Force Speaker KV (speaker drift control) | 1 |
|
||||
| **Fish Audio S2 Pro** | Main | S2 Pro 4B / FP8 | ~10.3GB / ~8.0GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Fish Audio Research License | Free-form sub-word emotion/prosody tags, Native multi-speaker and multi-turn dialogue with dynamic speaker references, Zero-shot voice cloning, Optional per-segment custom character switching, Configurable 4K-32K native context with reduced KV-cache VRAM, Optional community FP8 weight-only checkpoint with BF16 activations, Optional on-the-fly BitsAndBytes INT8/NF4 for the official checkpoint | 80+ languages |
|
||||
| **Dots TTS** | Main | dots.tts-base, dots.tts-soar, dots.tts-mf | ~6GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Apache-2.0 | Official auto language detect / language control, SOAR and MeanFlow distilled variants | 19 |
|
||||
| **DramaBox** | Main | DramaBox 3.3B | ~16.4GB | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | LTX-2 Community License | Expressive scene prompting and stage directions, Native and SRT-aware duration targeting, Official duration-aware long-form chunking with scene-prefix preservation, Optional 10-second zero-shot voice reference, CFG negative prompt with per-segment switching, Explicit generation/reference durations, CFG rescale control, and optional Perth watermark, Experimental staged and sequential strategies for lowering peak VRAM; official LTX FP8-cast storage, Optional official torch.compile path, Official audio-branch IC-LoRA training workflow | 1 |
|
||||
| **OmniVoice** | Main | OmniVoice | ~3.7GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Apache-2.0 | Inline non-verbal tags and pronunciation overrides, Reference-free voice design, 600+ language support, Upstream long-form chunk orchestration | 600+ |
|
||||
| **MOSS-TTS** | Main | Local 1.7B, Delay 8B v1.5/1.0, LAION Voice Acting 8B community fine-tune, VoiceGenerator 1.7B, SoundEffect 8B v1, TTSD 8B | ~8.5GB tokenizer + ~6.1GB/17GB/18GB model | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | Apache-2.0 | Reference-free voice design with MOSS-VoiceGenerator, Native 1-5 speaker TTSD dialogue, 31-language generation with MOSS-TTS-v1.5, Optional LAION community 8B voice-acting fine-tune, Config-based discovery of compatible local MOSS full checkpoints, Prompt-only sound-effect generation with MOSS-SoundEffect v1, Long-form generation (TTSD/Delay), Duration token hint, Local/Delay/TTSD variants, Initial integrated LoRA training workflow (Delay 8B) | 24 |
|
||||
| **MOSS-SoundEffect v2** | Main | MOSS-SoundEffect-v2.0 | ~11.2GB | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | Apache-2.0 | Durations up to 30 seconds, Native negative prompting, CFG, flow shift, and diffusion-step controls, Prompt-only text-to-sound generation, 48 kHz mono output, Seeded generation | 2 |
|
||||
| **RVC** | Main | Community .pth | 100-300MB | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | MIT (framework); community models vary | Real-time VC, Integrated training workflow, Pitch shift (±14), 6 HuBERT models, Language-independent | Any |
|
||||
|
||||
*Isolation column: `Main` runs in the main ComfyUI environment. `Shared` uses a shared secondary runtime reused by multiple engines. `Dedicated` uses an engine-specific secondary runtime.*
|
||||
+19
-10
@@ -2,13 +2,22 @@
|
||||
|
||||
## Feature Comparison Matrix
|
||||
|
||||
| Feature | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | IndexTTS-2 | CosyVoice3 | Qwen3-TTS | Step Audio EditX | RVC |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| **Voice Cloning** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (Base model) | ✅ | ⚠️ (needs training) |
|
||||
| **Native Multi-Speaker** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Voice Conversion** | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
| **Emotion Control** | ❌ | ❌ | ⚠️ (v2 tags - doesn't work) | ❌ | ⚠️ (via prompt) | ✅ (8 emotions) | ⚠️ (via instruct) | ⚠️ (via instruct) | ✅ (14 emotions) | ❌ |
|
||||
| **Native Long-form (90min)** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | N/A |
|
||||
| **Community Finetunes** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| **VRAM Efficient** | ✅ | ✅ | ✅ | ⚠️ (5-18GB) | ⚠️ (9GB) | ⚠️ (9-12GB) | ✅ (5.4GB) | ✅ (3-6GB) | ⚠️ (7GB) | ✅ |
|
||||
| **Speed/Performance** | ✅ Very Fast | ✅ Fast | ✅ Fast | ⚠️ | ⚠️ | ⚠️ | ✅ Fast | ⚠️ | ⚠️ | ✅ Fast |
|
||||
| Feature | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | Higgs Audio v3 | IndexTTS 2 / 2.5 | CosyVoice3 | Qwen3-TTS | Granite ASR | Step Audio EditX | Echo-TTS | Fish Audio S2 Pro | Dots TTS | DramaBox | OmniVoice | MOSS-TTS | MOSS-SoundEffect v2 | RVC |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| **TTS** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **SRT** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| **Voice Conversion** | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| **ASR (Transcribe)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Sound Effects** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
| **Training** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
| **Voice Cloning** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (Base model) | ❌ | ✅ | ✅ | ✅ Reference audio plus exact transcript | ✅ | ✅ Optional reference audio; configurable 3-30 second window from the beginning of the source, default 10 seconds | ✅ | ✅ | ❌ | ⚠️ (needs training) |
|
||||
| **Reference Transcript†** | **Required** | Not used | Not used | Not used | Optional | Optional | Not used | Conditional | Conditional | N/A | **Required** | Not used | **Required** | Optional | Not used | **Required** | Conditional | Not used | N/A |
|
||||
| **Native Multi-Speaker** | ❌ | ❌ | ❌ | ✅ (Base only, Kugel uses fallback) | ✅ | ❌ | ❌ | ❌ | ❌ | ⚠️ (Plus variant speaker attribution / diarization) | ❌ | ❌ | ✅ Native dialogue is default; optional custom mode generates each [Character] segment independently as local speaker 0 | ❌ | ❌ | ❌ | ✅ (TTSD v1.0; 1-5 speakers) | ❌ | ❌ |
|
||||
| **Emotion Control** | ❌ | ❌ | ⚠️ (v2 tags - doesn't work) | ❌ | ⚠️ (via prompt) | ✅ (native inline tags) | ✅ (8 emotions) | ⚠️ (via instruct) | ⚠️ (via instruct) | ❌ | ✅ (14 emotions) | ❌ | ✅ Free-form inline natural-language tags | ❌ | ✅ Natural-language scene prompt and stage directions | ⚠️ (voice-design instruct + inline non-verbal tags) | ✅ (MOSS-VoiceGenerator instruction-conditioned voice design) | ❌ | ❌ |
|
||||
| **Native Long-form** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Configurable 4K-32K native context; suite text chunking is bypassed | ❌ | ✅ Official duration-aware quote-group chunking; ~37s target / 45s cap | ✅ (uses upstream audio_chunk_duration / audio_chunk_threshold orchestration; bypasses suite char-based chunk splitting) | ✅ (TTSD/Delay long-form; use chunk orchestration for very long inputs) | ❌ | N/A |
|
||||
| **Community Finetunes** | ✅ | ✅ | ✅ | ✅ KugelAudio, Hindi | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Official audio-branch IC-LoRA adapters can be trained and loaded | ❌ | ✅ (Compatible full local checkpoints, LAION Voice Acting 8B auto-download, and LoRA adapter inference/training supported) | ❌ | ✅ |
|
||||
| **VRAM Efficient** | ✅ | ✅ | ✅ | ⚠️ (5-18GB) | ⚠️ (9GB) | ⚠️ (~8-10GB) | ⚠️ (9-12GB) | ✅ (5.4GB) | ✅ (3-6GB) | ✅ (~4.6GB) | ⚠️ (7GB) | ⚠️ (~7GB total) | ⚠️ 8K context measured at ~15.2GB BF16, ~11.2GB FP8, ~11.2GB BNB INT8, or ~8.9GB BNB NF4; BF16 codec and activations; BNB is a load-time option for the official checkpoint | ⚠️ (main env works; 2B-class model is not lightweight) | ✅ Fast mode is ~24GB; experimental FP8, staged, and sequential options can reduce VRAM, but practical peak usage varies by environment and loaded components | ⚠️ (main env works; 2B-class model is not lightweight) | ⚠️ (Local 1.7B smaller; tokenizer is large) | ⚠️ (runs in the main ComfyUI environment but remains GPU-heavy) | ✅ |
|
||||
| **Speed/Performance** | ✅ Very Fast | ✅ Fast | ✅ Fast | ⚠️ | ⚠️ | ⚠️ CUDA recommended | ⚠️ | ✅ Fast | ⚠️ | ⚠️ Moderate | ⚠️ | ✅ Fast (diffusion, realtime-capable) | ✅ Main-environment subprocess with reliable teardown; local compile measurements: ~40 it/s BF16, ~11.8 it/s NF4 at ~8.9GB VRAM, and ~3.7 it/s INT8 at ~11.2GB VRAM; quality comparison pending | ⚠️ Moderate; mf variant is faster | ⚠️ Fast favors repeated generation; staged reloads temporary components; sequential also transfers the transformer; optional block-level torch.compile accelerates repeat denoising | ✅ Fast; upstream reports sub-realtime RTF | ✅ Fast with CUDA/FlashAttention | ⚠️ (100 diffusion steps by default) | ✅ Fast |
|
||||
| **No Narrator Required** | ❌ | ✅ (default speaker) | ✅ (default speaker) | ✅ (zero-shot / default speaker) | ✅ (basic TTS if no narrator/reference is provided) | ✅ (zero-shot) | ❌ | ✅ (cross-lingual or instruct mode) | ✅ (Base default voice or CustomVoice presets) | N/A | ❌ | ❌ | ✅ Reference audio is optional | ✅ (default speaker) | ✅ Voice reference is optional | ✅ (native default voice; instruct also works without narrator) | ✅ (direct TTS and prompt-only generation) | ❌ | N/A |
|
||||
|
||||
† **Reference Transcript:** Conditional means the transcript is required only for the specific mode: CosyVoice3 zero-shot, Qwen3-TTS full Base cloning, or MOSS-TTSD cloned-speaker dialogue. Higgs Audio 2, Higgs Audio v3, and Dots TTS accept matching text when provided but do not require it.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Fish Audio S2 Pro Inline Tags
|
||||
|
||||
Use the suite's public angle-bracket syntax for Fish's free-form instructions:
|
||||
|
||||
```text
|
||||
<whisper in small voice>Hello. <professional broadcast tone>Good evening.
|
||||
```
|
||||
|
||||
The Fish adapter converts these to native `[...]` instructions only when text reaches the Fish engine. Do not write Fish tags with square brackets in suite text because `[Character]` is reserved for character switching.
|
||||
|
||||
Fish also supports normal suite tags such as `[pause:500ms]`, character switching, and per-segment parameters. Those are processed by the suite before Fish inference.
|
||||
|
||||
## Language Prompting
|
||||
|
||||
Fish has no native language dropdown or parameter. The engine can instead prepend a natural inline instruction from the suite's resolved segment language:
|
||||
|
||||
- `language_prompting = Auto Inline Tag`: non-English resolved languages become tags such as `<German>` or `<French>`
|
||||
- `language_prompting = Off`: no automatic language instruction is added
|
||||
|
||||
English is only added when the user explicitly requested it with a language tag such as `[en:Bob]` or `[English:Bob]`. Implicit/default English stays untagged.
|
||||
|
||||
## Character Switching Modes
|
||||
|
||||
The engine defaults to `Native Multi-Speaker`. All `[Character]` turns in a generated block are sent through one Fish dialogue request, preserving native multi-turn context and long-form behavior.
|
||||
|
||||
Select `Custom Character Switching` to generate every parsed character segment independently. Each call uses only that character's reference and is remapped to Fish speaker 0. This can reduce speaker leakage, but it requires more calls and does not preserve context between character turns. SRT subtitle boundaries and timing are preserved in both modes.
|
||||
|
||||
The engine UI separates checkpoint choice from load-time quantization:
|
||||
|
||||
- `model_variant`: `s2-pro` or the separate `s2-pro-fp8` checkpoint
|
||||
- `quantization`: `none`, `bnb_int8`, or `bnb_nf4` for on-the-fly quantization of the official `s2-pro` checkpoint
|
||||
|
||||
The BNB options reuse the official files and do not download a second model copy.
|
||||
|
||||
The S2 Pro weights use the Fish Audio Research License: research and non-commercial use are allowed; commercial use requires a separate Fish Audio license.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Higgs Audio v3 Inline Tags
|
||||
|
||||
Higgs Audio v3 uses native inline control tokens in the text prompt. Type these tags directly in **TTS Text** or **TTS SRT** when using the Higgs Audio v3 engine.
|
||||
|
||||
## Syntax
|
||||
|
||||
Canonical native tags use:
|
||||
|
||||
```text
|
||||
<|category:value|>
|
||||
```
|
||||
|
||||
TTS Audio Suite also accepts a convenience alias form without the bars:
|
||||
|
||||
```text
|
||||
<category:value>
|
||||
```
|
||||
|
||||
Aliases are normalized internally to the canonical Higgs syntax. The editor inserts the canonical `<|...|>` form on purpose.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
<|emotion:amusement|>Wait, that was actually funny.
|
||||
<|style:whispering|>Keep your voice down.
|
||||
I need a second.<|prosody:pause|> Okay, continue.
|
||||
That was perfect. <|sfx:laughter|>Haha, absolutely perfect.
|
||||
```
|
||||
|
||||
## Supported Categories
|
||||
|
||||
Emotion:
|
||||
|
||||
```text
|
||||
elation, amusement, enthusiasm, determination, pride, contentment, affection,
|
||||
relief, contemplation, confusion, surprise, awe, longing, arousal, anger, fear,
|
||||
disgust, bitterness, sadness, shame, helplessness
|
||||
```
|
||||
|
||||
Style:
|
||||
|
||||
```text
|
||||
singing, shouting, whispering
|
||||
```
|
||||
|
||||
Prosody:
|
||||
|
||||
```text
|
||||
speed_very_slow, speed_slow, speed_fast, speed_very_fast,
|
||||
pitch_low, pitch_high, pause, long_pause,
|
||||
expressive_high, expressive_low
|
||||
```
|
||||
|
||||
Sound effects:
|
||||
|
||||
```text
|
||||
cough, laughter, crying, screaming, burping, humming, sigh, sniff, sneeze
|
||||
```
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- Put delivery-wide tags like emotion, style, speed, pitch, and expressiveness at the start of the sentence or turn.
|
||||
- Put positional tags like `<|prosody:pause|>`, `<|prosody:long_pause|>`, and `<|sfx:...|>` exactly where they should happen.
|
||||
- Pair SFX tags with written sound text, for example `<|sfx:laughter|>Haha` or `<|sfx:sigh|>Ahh`.
|
||||
- Canonical Higgs syntax is `<|...|>`. Alias input like `<emotion:amusement>` is accepted by TTS Audio Suite for convenience, but the official form stays `<|emotion:amusement|>`.
|
||||
- Higgs Audio v3 does not use Step Audio EditX second-pass inline processing here. Use Higgs native tags instead of Step semantics when targeting Higgs.
|
||||
|
||||
## Character Voices
|
||||
|
||||
Higgs Audio v3 supports reference audio cloning. A transcript of the reference audio strongly improves cloning quality. Character voice `.txt` files are passed as reference transcripts when available.
|
||||
|
||||
## Multiline Editor
|
||||
|
||||
The multiline TTS tag editor has an `Inline Tags` panel with a dedicated `Higgs Audio v3` mode. That panel inserts canonical native Higgs tags directly.
|
||||
|
||||
## License
|
||||
|
||||
Higgs Audio v3 is released under the Boson Higgs Audio v3 Research and Non-Commercial License. Production, hosted API, or revenue-generating use requires a separate commercial license from Boson AI.
|
||||
@@ -1,92 +0,0 @@
|
||||
# IndexTTS-2 Updates Log
|
||||
|
||||
This document tracks updates applied to our bundled IndexTTS-2 code from the upstream repository.
|
||||
|
||||
## Update Process
|
||||
1. **Check for upstream changes:** `cd IgnoredForGitHubDocs/For_reference/index_tts && git pull`
|
||||
2. **Review changes:** `git diff OLD_COMMIT..NEW_COMMIT -- indextts/infer_v2.py`
|
||||
3. **Apply relevant changes** to `engines/index_tts/indextts/infer_v2.py`
|
||||
4. **Test functionality** with our IndexTTS-2 nodes
|
||||
5. **Document changes** in this log
|
||||
|
||||
---
|
||||
|
||||
## 2025-09-18: Major Update - Cache & Emotion Improvements
|
||||
|
||||
**Reference commit range:** `8336824..64cb31a` (September 11 → September 18, 2025)
|
||||
**Upstream commits reviewed:** 5 commits affecting `infer_v2.py`
|
||||
|
||||
### 🔧 Changes Applied:
|
||||
|
||||
#### 1. **Emotion Vector Normalization Function** (commit: 8aa8064)
|
||||
- **Added:** `normalize_emo_vec()` helper function
|
||||
- **Purpose:** Prevents voice identity loss from overly strong emotion settings
|
||||
- **Implementation:**
|
||||
- Applies emotion bias factors to reduce problematic emotions
|
||||
- Caps total emotion sum to 0.8 maximum
|
||||
- Protects against extreme emotion vector values
|
||||
- **User benefit:** Better emotion control without losing speaker characteristics
|
||||
|
||||
#### 2. **Audio Length Limiting** (commit: 0828dcb)
|
||||
- **Added:** `_load_and_cut_audio()` helper function
|
||||
- **Purpose:** Prevents memory/VRAM issues from overly long reference audio
|
||||
- **Implementation:**
|
||||
- Automatically truncates audio to 15 seconds maximum
|
||||
- Supports different sample rates (16kHz, 22kHz)
|
||||
- Provides verbose logging of truncation
|
||||
- **User benefit:** More stable generation with long reference audio files
|
||||
|
||||
#### 3. **Persistent Cache Buildup Fix** (commit: 64cb31a)
|
||||
- **Added:** Proper cache clearing with `torch.cuda.empty_cache()`
|
||||
- **Purpose:** Solves memory accumulation issues during multiple generations
|
||||
- **Implementation:**
|
||||
- Clears old cache variables before loading new audio
|
||||
- Calls `torch.cuda.empty_cache()` to free GPU memory
|
||||
- Applied to both speaker and emotion cache invalidation
|
||||
- **User benefit:** Better memory management during batch processing
|
||||
|
||||
#### 4. **BigVGAN CUDA Kernel Import Fix** (commits: ee23371, e409c4a)
|
||||
- **Fixed:** Corrected import path for BigVGAN custom CUDA kernel
|
||||
- **Changed:** `indextts.BigVGAN.alias_free_activation.cuda` → `indextts.s2mel.modules.bigvgan.alias_free_activation.cuda`
|
||||
- **Purpose:** Ensures proper CUDA acceleration when available
|
||||
- **User benefit:** Better performance with CUDA-enabled setups
|
||||
|
||||
### 📋 Implementation Status:
|
||||
|
||||
| Change | Applied to Bundled Code | Tested | Notes |
|
||||
|--------|------------------------|--------|-------|
|
||||
| `normalize_emo_vec()` | ✅ **Applied** | ⏳ Pending | Core emotion control improvement |
|
||||
| `_load_and_cut_audio()` | ✅ **Applied** | ⏳ Pending | Memory stability fix |
|
||||
| Cache clearing fixes | ✅ **Applied** | ⏳ Pending | Critical for batch processing |
|
||||
| BigVGAN import fix | ✅ **Applied** | ⏳ Pending | Performance improvement |
|
||||
|
||||
### 🎯 User-Facing Improvements:
|
||||
- **Better emotion control:** Emotion vectors now auto-normalize to prevent voice identity loss
|
||||
- **Memory stability:** Long reference audio automatically truncated to prevent crashes
|
||||
- **Batch processing:** Improved cache management for multiple generations
|
||||
- **Performance:** Fixed CUDA kernel loading for faster inference
|
||||
|
||||
### 🧪 Testing Required:
|
||||
- [ ] Emotion vector controls in radar chart widget
|
||||
- [ ] Long reference audio handling (>15 seconds)
|
||||
- [ ] Multiple sequential generations (cache clearing)
|
||||
- [ ] CUDA performance with FP16 enabled
|
||||
|
||||
---
|
||||
|
||||
## Next Update Check: 2025-10-18
|
||||
|
||||
**Monitoring:** Watch for commits to `indextts/infer_v2.py` in https://github.com/index-tts/index-tts
|
||||
|
||||
**Update command:**
|
||||
```bash
|
||||
cd IgnoredForGitHubDocs/For_reference/index_tts
|
||||
git pull origin main
|
||||
git log --oneline LAST_COMMIT..HEAD -- indextts/infer_v2.py
|
||||
```
|
||||
|
||||
**Priority changes to watch for:**
|
||||
- Performance optimizations
|
||||
- Memory management improvements
|
||||
- Bug fixes in emotion processing
|
||||
- New model loading features
|
||||
@@ -1,4 +1,15 @@
|
||||
# Inline Edit Tags - User Guide
|
||||
# Step Audio EditX Inline Tags - User Guide
|
||||
|
||||
## Scope
|
||||
|
||||
This guide is specifically for **Step Audio EditX** inline post-processing tags.
|
||||
|
||||
The multiline TTS tag editor now has an engine-aware `Inline Tags` panel with separate modes for:
|
||||
- `Step Audio EditX`
|
||||
- `Higgs Audio v3`
|
||||
- `CosyVoice3`
|
||||
|
||||
Use the Step mode when you want post-process editing tags. Use the Higgs or Cosy modes when you want those engines' native generation tags.
|
||||
|
||||
## 💡 Inline vs Manual Node Workflow
|
||||
|
||||
@@ -15,7 +26,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Inline edit tags allow you to apply **Step Audio EditX** post-processing effects to TTS-generated audio from ANY engine (F5-TTS, ChatterBox, Higgs Audio, VibeVoice, etc.). These tags are automatically stripped before TTS generation and applied as a second processing pass.
|
||||
Inline edit tags allow you to apply **Step Audio EditX** post-processing effects to TTS-generated audio from engines that support the Step inline workflow. These tags are automatically stripped before TTS generation and applied as a second processing pass.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
@@ -661,6 +672,10 @@ A: Just remove them from your text. No special setting needed.
|
||||
|
||||
## Support
|
||||
|
||||
Related guides:
|
||||
- `docs/HIGGS_AUDIO_V3_INLINE_TAGS.md` for Higgs Audio v3 native tags
|
||||
- `docs/COSYVOICE3_TAGS_GUIDE.md` for CosyVoice3 native tags
|
||||
|
||||
For issues, feature requests, or questions:
|
||||
- GitHub Issues: https://github.com/diodiogod/TTS-Audio-Suite/issues
|
||||
- Check console output for detailed processing information
|
||||
|
||||
@@ -12,15 +12,27 @@ IndexTTS-2 supports multiple emotion control methods that can be combined for so
|
||||
- **Text Emotion**: AI-powered QwenEmotion analysis from text descriptions with dynamic templates
|
||||
- **Character Tag Emotions**: Per-character emotion control using `[Character:emotion_ref]` syntax
|
||||
|
||||
## Emotion Control Priority
|
||||
|
||||
You can only connect to the Engine node one source of control emotion: Either audio, text, or vectors.
|
||||
|
||||
When using tags on the text iself, **Character tag emotions** (highest priority) - `[Alice:angry_bob]` overrides all other emotion control settings for that character segment
|
||||
## Emotion Control Inputs and Blending
|
||||
|
||||
The IndexTTS-2 Engine has two emotion inputs:
|
||||
|
||||
- **`emotion_control`**: vector controls or Qwen text emotion
|
||||
- **`emotion_audio`**: an audio reference from an AUDIO or Character Voices node
|
||||
|
||||
Both inputs may be connected at the same time. Text emotion is analyzed into an
|
||||
8-value vector; audio emotion remains an audio-derived conditioning signal. The
|
||||
engine blends the two signals in its latent emotion-conditioning space rather
|
||||
than converting the audio into the eight visible vector values.
|
||||
|
||||
For backward compatibility, the original `emotion_control` socket still accepts
|
||||
legacy audio connections, but new workflows should use `emotion_audio` for
|
||||
audio references. A character tag such as `[Alice:angry_bob]` supplies a
|
||||
segment-local audio reference and can also be combined with vector/text emotion
|
||||
for that segment.
|
||||
|
||||
## Method 1: Direct Audio Reference
|
||||
|
||||
Connect any audio file directly to the IndexTTS-2 Engine's `emotion_control` input.
|
||||
Connect any audio file directly to the IndexTTS-2 Engine's `emotion_audio` input.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -37,7 +49,7 @@ Connect any audio file directly to the IndexTTS-2 Engine's `emotion_control` inp
|
||||
**Example:**
|
||||
|
||||
```
|
||||
AUDIO node → IndexTTS-2 Engine (emotion_control)
|
||||
AUDIO node → IndexTTS-2 Engine (emotion_audio)
|
||||
```
|
||||
|
||||
## Method 2: Character Voices Audio Reference
|
||||
@@ -48,7 +60,7 @@ Use the `opt_narrator` output from the 🎭 Character Voices node as an emotion
|
||||
|
||||
1. Add a 🎭 Character Voices node
|
||||
2. Select a voice with the desired emotional expression
|
||||
3. Connect `opt_narrator` output to IndexTTS-2 Engine `emotion_control` input
|
||||
3. Connect `opt_narrator` output to IndexTTS-2 Engine `emotion_audio` input
|
||||
|
||||
**Advantages:**
|
||||
|
||||
@@ -59,12 +71,13 @@ Use the `opt_narrator` output from the 🎭 Character Voices node as an emotion
|
||||
**Example workflow:**
|
||||
|
||||
```
|
||||
🎭 Character Voices (David_Attenborough) → opt_narrator → IndexTTS-2 Engine (emotion_control)
|
||||
🎭 Character Voices (David_Attenborough) → opt_narrator → IndexTTS-2 Engine (emotion_audio)
|
||||
```
|
||||
|
||||
## Method 3: Emotion Vectors
|
||||
|
||||
Use the 🌈 IndexTTS-2 Emotion Vectors node for precise manual control over 8 different emotions.
|
||||
## Method 3: Emotion Vectors
|
||||
|
||||
Use the 🌈 IndexTTS-2 Emotion Vectors node for precise manual control over 8 different emotions.
|
||||
Connect its `emotion_control` output to the IndexTTS-2 Engine's `emotion_control` input.
|
||||
|
||||
**Available emotions:**
|
||||
|
||||
@@ -84,9 +97,10 @@ Use the 🌈 IndexTTS-2 Emotion Vectors node for precise manual control over 8 d
|
||||
- Start with single emotions, then experiment with combinations
|
||||
- Use the `random` buttom to get a completely random emotion pattern. Might be too strong.
|
||||
|
||||
## Method 4: Text Emotion (Dynamic Analysis)
|
||||
|
||||
Use the 🌈 IndexTTS-2 Text Emotion node for AI-powered emotion analysis with dynamic templates.
|
||||
## Method 4: Text Emotion (Dynamic Analysis)
|
||||
|
||||
Use the 🌈 IndexTTS-2 Text Emotion node for AI-powered emotion analysis with dynamic templates.
|
||||
Connect its `emotion_control` output to the IndexTTS-2 Engine's `emotion_control` input.
|
||||
|
||||
### Static Text Emotion
|
||||
|
||||
@@ -124,11 +138,104 @@ Analysis: "Worried parent speaking: Where have you been?"
|
||||
Result: Anxious, concerned vocal expression
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Character Tag Emotion Control
|
||||
|
||||
Control emotions per character using inline tags in your text: `[Character:emotion_ref]`
|
||||
---
|
||||
|
||||
## Combining Audio with Vectors or Text
|
||||
|
||||
Connect both emotion sources when you want an audio performance to provide the
|
||||
base delivery while vectors or Qwen text analysis add a targeted emotional
|
||||
direction:
|
||||
|
||||
```text
|
||||
🎭 Character Voices (opt_narrator) ──→ emotion_audio
|
||||
🌈 Emotion Vectors or Text Emotion ──→ emotion_control
|
||||
IndexTTS-2 Engine
|
||||
```
|
||||
|
||||
`emotion_alpha` is the shared overall emotion-intensity control. The audio
|
||||
reference and vector/text signal are blended during IndexTTS-2 conditioning;
|
||||
they are not generated as two separate voices and mixed afterward.
|
||||
|
||||
For example, an audio reference can provide a natural speaking style while a
|
||||
`[sad:+0.2|calm:-0.1]` inline adjustment adds a restrained sadness to one
|
||||
segment. A Qwen text preset can be used the same way.
|
||||
|
||||
Character audio and inline emotion parameters can share one tag:
|
||||
|
||||
```text
|
||||
[Bob:br_ivan_raiva3|sad:+0.25|calm:-0.10] Bob speaks with a restrained overlay.
|
||||
[Bob:br_ivan_raiva3|emotion:"quiet grief masking frustration"] Bob uses Qwen text emotion too.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Inline Emotion Switching
|
||||
|
||||
Numeric emotion tags can replace or adjust the vector for one text segment:
|
||||
|
||||
```text
|
||||
[sad:0.7|calm:0.2] This uses explicit absolute values.
|
||||
[sad:+0.3|calm:-0.2] This modifies the connected vector.
|
||||
[vector:0,0,0.7,0,0,0.4,0,0.2] This supplies all eight absolute values.
|
||||
[vector:+0,+0,+0.3,+0,+0,+0,+0,-0.2] This supplies eight deltas.
|
||||
```
|
||||
|
||||
The full-vector order is `happy, angry, sad, afraid, disgusted, melancholic,
|
||||
surprised, calm`. Unsigned named values are absolute; `+` and `-` named values
|
||||
are relative. A full vector is relative only when every value carries a sign.
|
||||
|
||||
Text-emotion controls support saved presets and quoted descriptions:
|
||||
|
||||
```text
|
||||
[emotion:restrained_anger] Text using a saved preset.
|
||||
[emotion:"Restrained anger masking disappointment"] Direct text control.
|
||||
[emotion:"Analyze this delivery as nervous anticipation: {seg}"] Dynamic control.
|
||||
```
|
||||
|
||||
Inline controls override connected global vector/text values for that segment.
|
||||
A character audio emotion reference such as `[Alice:sad_reference]` replaces the
|
||||
global audio reference for that segment, but it can still blend with the
|
||||
segment's vector/text control. Inline settings revert at the next segment and do
|
||||
not mutate the connected vector.
|
||||
|
||||
The TTS Tag Editor provides the same interactive radar used by the IndexTTS-2
|
||||
Emotion Vectors node. Click an existing numeric emotion tag to open its radar
|
||||
as a contextual popover beside the tag. Create tags from **Inline Tags →
|
||||
IndexTTS-2**, and use **Manage Emotion Presets** in its Text Emotion section for
|
||||
the preset library. Text and vector presets are stored in
|
||||
`models/TTS/IndexTTS/emotion_presets.json`.
|
||||
|
||||
Radar changes appear in the editor text immediately. Intermediate drag/input
|
||||
states are not added to undo history: closing the popover commits one undoable
|
||||
change, while Cancel or Escape restores the tag exactly as it was when opened.
|
||||
|
||||
The editor's **Inline Tags** tab also includes an **IndexTTS-2** engine panel for
|
||||
inserting full absolute/delta vectors, named emotion values, saved text presets,
|
||||
quoted descriptions, and dynamic descriptions containing `{seg}`.
|
||||
|
||||
Emotion controls can be composed directly on character tags: place the caret on
|
||||
`[Bob:audio_reference]` and add a vector or text emotion to append it as a pipe
|
||||
parameter.
|
||||
|
||||
The named-emotion panel includes a magnitude slider and a press-drag-release
|
||||
radial picker: direction chooses the emotion and distance chooses its value.
|
||||
The operation dropdown determines whether that value is absolute, a positive
|
||||
delta, or a negative delta. Saved text and vector presets refresh in the sidebar
|
||||
immediately after they are changed in the preset manager.
|
||||
|
||||
Clicking a saved `[emotion:preset_name]` tag in the editor opens a small anchored
|
||||
preset dropdown, allowing that line's preset to be swapped without opening the
|
||||
full manager. Adding an emotion control while the caret is inside a pure emotion
|
||||
tag replaces that tag; when the caret is inside a character/audio tag, the
|
||||
editor appends or updates the emotion parameter after the existing `|` fields.
|
||||
|
||||
Named tags remain readable while only some emotions are active. If radar editing
|
||||
activates all eight emotions, the editor automatically converts the result to the
|
||||
shorter ordered `[vector:...]` form.
|
||||
|
||||
## Character Tag Emotion Control
|
||||
|
||||
Control emotions per character using inline tags in your text: `[Character:emotion_ref]`
|
||||
|
||||
**Syntax:**
|
||||
|
||||
@@ -145,8 +252,9 @@ Control emotions per character using inline tags in your text: `[Character:emoti
|
||||
|
||||
```
|
||||
Hello everyone! [Alice:happy_sarah] I'm so excited to be here today!
|
||||
[Bob:angry_tom] That's completely unacceptable behavior.
|
||||
[Narrator:David] Meanwhile, in a distant galaxy...
|
||||
[Bob:angry_tom] That's completely unacceptable behavior.
|
||||
[Narrator:David] Meanwhile, in a distant galaxy...
|
||||
[Bob:br_ivan_raiva3|sad:+0.25] Bob uses an audio reference plus a vector delta.
|
||||
|
||||
*assuming happy_sarah, angry_tom and David are alias or character voices in yout folder with that name
|
||||
```
|
||||
@@ -155,11 +263,13 @@ Hello everyone! [Alice:happy_sarah] I'm so excited to be here today!
|
||||
|
||||
|
||||
|
||||
**Character tag priority:**
|
||||
|
||||
- Character tags override ALL other emotion settings for that specific character
|
||||
- Other characters use global emotion settings
|
||||
- Allows mixing different emotions in the same audio
|
||||
**Character tag behavior:**
|
||||
|
||||
- Character tags select the speaker and can provide a segment-local audio emotion reference
|
||||
- A segment-local audio reference replaces the global audio reference for that segment
|
||||
- Global or inline vector/text controls can still blend with that audio reference
|
||||
- Other characters use the global audio and vector/text settings
|
||||
- Allows mixing different emotion sources in the same audio
|
||||
|
||||
## Emotion Alpha Control
|
||||
|
||||
@@ -211,4 +321,4 @@ Welcome to our show! [Bob:serious_narrator] But first, a serious announcement.
|
||||
|
||||
---
|
||||
|
||||
This comprehensive emotion control system gives you unprecedented flexibility in creating expressive, emotionally rich TTS audio for any application.
|
||||
This comprehensive emotion control system gives you unprecedented flexibility in creating expressive, emotionally rich TTS audio for any application.
|
||||
|
||||
+108
-30
@@ -2,40 +2,118 @@
|
||||
|
||||
## Language Support by Engine
|
||||
|
||||
| Language | Code | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | IndexTTS-2 | CosyVoice3 | Qwen3-TTS | Step Audio EditX | RVC |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 🇺🇸 **English** | EN | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 🇨🇳 **Chinese** | ZH | ❌ | ❌ | ✅ | ✅ | ✅ (Mandarin) | ✅ | ✅ + 18 dialects | ✅ | ✅ (Mandarin + Sichuanese, Cantonese) | ✅ |
|
||||
| 🇩🇪 **German** | DE | ✅ | ✅ (×3) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| 🇪🇸 **Spanish** | ES | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| 🇫🇷 **French** | FR | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| 🇮🇹 **Italian** | IT | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| 🇯🇵 **Japanese** | JA | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ ? | ✅ | ✅ | ✅ | ✅ |
|
||||
| 🇰🇷 **Korean** | KO | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| 🇷🇺 **Russian** | RU | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
|
||||
| 🇧🇷 **Portuguese** | PT | ✅ (BR) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ (EU/BR*) | ❌ | ✅ |
|
||||
| 🇵🇱 **Polish** | PL | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Hindi** | HI | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇪 **Arabic** | AR | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇹🇷 **Turkish** | TR | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇹🇭 **Thai** | TH | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇳🇴 **Norwegian** | NO | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇻🇳 **Vietnamese** | VI | ❌ | ❌ | ✅ (Viterbox) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇲 **Armenian** | HY | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇬🇪 **Georgian** | KA | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇩🇰 **Danish** | DA | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇫🇮 **Finnish** | FI | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇬🇷 **Greek** | EL | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇱 **Hebrew** | HE | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇲🇾 **Malay** | MS | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇳🇱 **Dutch** | NL | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇸🇪 **Swedish** | SV | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇰🇪 **Swahili** | SW | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Language | Code | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | Higgs Audio v3 | IndexTTS 2 / 2.5 | CosyVoice3 | Qwen3-TTS | Granite ASR | Step Audio EditX | Echo-TTS | Fish Audio S2 Pro | Dots TTS | DramaBox | OmniVoice | MOSS-TTS | MOSS-SoundEffect v2 | RVC |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 🇺🇸 **English** | EN | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ Tier 1 | ✅ | ✅ Official model is English-only | ✅ | ✅ | ✅ Officially demonstrated prompt language | ✅ |
|
||||
| 🇨🇳 **Chinese** | ZH | ❌ | ❌ | ✅ | ✅ | ✅ (Mandarin) | ✅ | ✅ | ✅ + 18 dialects | ✅ | ❌ | ✅ (Mandarin + Sichuanese, Cantonese) | ❌ | ✅ Tier 1 | ✅ (Mandarin; official also exposes YUE separately outside this matrix) | ❌ | ✅ | ✅ | ✅ Officially demonstrated prompt language | ✅ |
|
||||
| 🇩🇪 **German** | DE | ✅ | ✅ (×3) | ✅ | ✅ (Kugel) | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇪🇸 **Spanish** | ES | ✅ | ❌ | ✅ | ✅ (Kugel) | ✅ | ✅ | ✅ IndexTTS-2.5 | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇫🇷 **French** | FR | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇮🇹 **Italian** | IT | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇯🇵 **Japanese** | JA | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ✅ IndexTTS-2.5 | ✅ | ✅ | ✅ (4.1-2b supports Japanese; plus variant does not) | ✅ | ❌ | ✅ Tier 1 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇰🇷 **Korean** | KO | ❌ | ✅ | ✅ | ✅ (Kugel) | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇷🇺 **Russian** | RU | ❌ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇧🇷 **Portuguese** | PT | ✅ (BR) | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ (EU/BR*) | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ (Official PT tag is generic; upstream does not expose separate PT-BR/PT-PT tags and it may lean more European Portuguese than Brazilian Portuguese) | ❌ | ✅ (generic PT; official language space is much broader than this matrix) | ✅ | ❌ | ✅ |
|
||||
| 🇵🇱 **Polish** | PL | ✅ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇮🇳 **Hindi** | HI | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇪🇬 **Arabic** | AR | ❌ | ❌ | ✅ (Egyptian) | ✅ (Kugel) | ❌ | ✅ | ✅ IndexTTS-2.5 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇹🇷 **Turkish** | TR | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇹🇭 **Thai** | TH | ✅ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇳🇴 **Norwegian** | NO | ❌ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
| 🇻🇳 **Vietnamese** | VI | ❌ | ❌ | ✅ (Viterbox) | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇦🇲 **Armenian** | HY | ❌ | ✅ | ❌ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
| 🇬🇪 **Georgian** | KA | ❌ | ✅ | ❌ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
| 🇩🇰 **Danish** | DA | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇫🇮 **Finnish** | FI | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇬🇷 **Greek** | EL | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇮🇱 **Hebrew** | HE | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇲🇾 **Malay** | MS | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇳🇱 **Dutch** | NL | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇸🇪 **Swedish** | SV | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🇰🇪 **Swahili** | SW | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
|
||||
| 🇿🇦 **Afrikaans** | AF | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇱 **Albanian** | SQ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Assamese** | AS | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇸 **Asturian** | AST | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇿 **Azerbaijani** | AZ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇷🇺 **Bashkir** | BA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇸 **Basque** | EU | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇧🇾 **Belarusian** | BE | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇧🇩 **Bengali** | BN | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇧🇦 **Bosnian** | BS | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇧🇬 **Bulgarian** | BG | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇸 **Catalan** | CA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇵🇭 **Cebuano** | CEB | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇶 **Central Kurdish** | CKB | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇭🇷 **Croatian** | HR | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇨🇿 **Czech** | CS | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇷🇺 **Eastern Mari** | MHR | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🌍 **Esperanto** | EO | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇪 **Estonian** | ET | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇸 **Galician** | GL | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Gujarati** | GU | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇭🇹 **Haitian Creole** | HT | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇳🇬 **Hausa** | HA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇭🇺 **Hungarian** | HU | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇩 **Indonesian** | ID | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇩 **Javanese** | JV | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Kannada** | KN | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇰🇿 **Kazakh** | KK | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇷🇼 **Kinyarwanda** | RW | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇰🇬 **Kyrgyz** | KY | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇱🇻 **Latvian** | LV | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇨🇩 **Lingala** | LN | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇱🇹 **Lithuanian** | LT | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇰🇪 **Luo** | LUO | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇲🇰 **Macedonian** | MK | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Malayalam** | ML | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇲🇹 **Maltese** | MT | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇳🇿 **Māori** | MI | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Marathi** | MR | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇲🇳 **Mongolian** | MN | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇳🇵 **Nepali** | NE | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇫🇷 **Occitan** | OC | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇷 **Persian** | FA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇷🇴 **Romanian** | RO | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇿🇦 **Sepedi** | NSO | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇷🇸 **Serbian** | SR | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇿🇼 **Shona** | SN | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇸🇰 **Slovak** | SK | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇸🇮 **Slovene** | SL | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇵🇭 **Tagalog** | TL | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇹🇯 **Tajik** | TG | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Tamil** | TA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Telugu** | TE | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇺🇦 **Ukrainian** | UK | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇵🇰 **Urdu** | UR | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇨🇳 **Uyghur** | UG | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇺🇿 **Uzbek** | UZ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇿🇦 **Xhosa** | XH | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇿🇦 **Zulu** | ZU | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (polished) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇲🇼 **Chichewa/Nyanja** | NY | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇳 **Eastern Punjabi** | PA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇺🇬 **Ganda** | LG | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇸 **Icelandic** | IS | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇮🇪 **Irish** | GA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇩🇿 **Kabyle** | KAB | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇨🇻 **Kabuverdianu** | KEA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇰🇪 **Kamba** | KAM | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇻🇦 **Latin** | LA | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇱🇺 **Luxembourgish** | LB | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇪🇹 **Oromo** | OM | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇫 **Pashto** | PS | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇵🇰 **Sindhi** | SD | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇸🇴 **Somali** | SO | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🇦🇴 **Umbundu** | UMB | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| 🏴 **Welsh** | CY | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (usable) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- **CosyVoice3 Chinese**: Includes 18+ dialects (Cantonese, Sichuan, Dongbei, Shanghai, etc.)
|
||||
- **Higgs Audio 2**: Trained on EN, ZH (Mandarin), KO, DE, ES (English majority) - 10M hours AudioVerse dataset
|
||||
- **Higgs Audio v3**: Official model card lists 102 languages total: 85 polished-production languages and 17 usable but less polished languages.
|
||||
- **IndexTTS-2**: Trained on 55K+ hours - ZH, EN, JA primary.
|
||||
- **RVC**: Language-independent (work as post-processors VC with any TTS output)
|
||||
- **MOSS-TTS**: Official list includes ZH, EN, DE, ES, FR, JA, IT, HU, KO, RU, FA, AR, PL, PT, CS, DA, SV, EL, TR; HU/FA/CS are not separate columns in this matrix.
|
||||
- **OmniVoice**: Official model supports 600+ languages; this matrix only shows the suite's comparison subset.
|
||||
- **RVC**: Language-agnostic voice conversion/post-processing; it is marked supported for every language row.
|
||||
- Qwen3-TTS: Supports pt-BR only with instruction (voice design or harcoded voices). Base can't do instructions, so it will always ouput pt-PT.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Model Download Sources
|
||||
|
||||
All entries below are generated from the single source of truth YAML.
|
||||
Use this as the canonical list of model repositories/links for offline setup.
|
||||
|
||||
## F5-TTS
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| F5TTS_Base | [SWivid/F5-TTS](https://huggingface.co/SWivid/F5-TTS/tree/main/F5TTS_Base) | ~1.2GB | ✅ | English base model |
|
||||
| F5TTS_v1_Base | [SWivid/F5-TTS](https://huggingface.co/SWivid/F5-TTS/tree/main/F5TTS_v1_Base) | ~1.2GB | ✅ | English v1 model |
|
||||
| E2TTS_Base | [SWivid/E2-TTS](https://huggingface.co/SWivid/E2-TTS/tree/main/E2TTS_Base) | ~1.2GB | ✅ | English E2-TTS model |
|
||||
| F5-DE | [aihpi/F5-TTS-German](https://huggingface.co/aihpi/F5-TTS-German) | ~1.2GB | ✅ | German finetune |
|
||||
| F5-ES | [jpgallegoar/F5-Spanish](https://huggingface.co/jpgallegoar/F5-Spanish) | ~1.2GB | ✅ | Spanish finetune |
|
||||
| F5-FR | [RASPIAUDIO/F5-French-MixedSpeakers-reduced](https://huggingface.co/RASPIAUDIO/F5-French-MixedSpeakers-reduced) | ~1.2GB | ✅ | French finetune |
|
||||
| F5-JP | [Jmica/F5TTS](https://huggingface.co/Jmica/F5TTS) | ~1.2GB | ✅ | Japanese finetune |
|
||||
| F5-Hindi-Small | [SPRINGLab/F5-Hindi-24KHz](https://huggingface.co/SPRINGLab/F5-Hindi-24KHz) | ~632MB | ✅ | Hindi finetune |
|
||||
| Vocos Mel-24kHz | [charactr/vocos-mel-24khz](https://huggingface.co/charactr/vocos-mel-24khz) | N/A | ✅ | Optional vocoder |
|
||||
|
||||
## ChatterBox
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| English | [ResembleAI/chatterbox](https://huggingface.co/ResembleAI/chatterbox) | ~2GB | ✅ | .pt model set |
|
||||
| German | [stlohrey/chatterbox_de](https://huggingface.co/stlohrey/chatterbox_de) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| German (havok2) | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| German (SebastianBodza) | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Italian | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .pt model set |
|
||||
| French | [Thomcles/ChatterBox-fr](https://huggingface.co/Thomcles/ChatterBox-fr) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Russian | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Armenian | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Georgian | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Japanese | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Korean | [niobures/Chatterbox-TTS](https://huggingface.co/niobures/Chatterbox-TTS) | ~4.3GB | ✅ | .safetensors model set |
|
||||
| Norwegian | [akhbar/chatterbox-tts-norwegian](https://huggingface.co/akhbar/chatterbox-tts-norwegian) | ~4.3GB | ✅ | .safetensors model set |
|
||||
|
||||
## ChatterBox 23L
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Official 23-Lang (v1/v2/v3) | [ResembleAI/chatterbox](https://huggingface.co/ResembleAI/chatterbox) | ~4.3GB | ✅ | v1 + v2 + v3 T3 checkpoints with shared tokenizer, voice encoder, and S3Gen |
|
||||
| Russian stress dictionary (Russian only) | [Vuizur/add-stress-to-epub release](https://github.com/Vuizur/add-stress-to-epub/releases/download/v1.0.1/russian_dict.zip) | ~1.5GB | ✅ | Auxiliary Official 23-Lang Russian stress-labeling data; downloads on demand only when Russian stress support is used |
|
||||
| Vietnamese (Viterbox) | [dolly-vn/viterbox](https://huggingface.co/dolly-vn/viterbox) | ~4.3GB | ✅ | Vietnamese community finetune used by downloader |
|
||||
| Egyptian Arabic (oddadmix) | [oddadmix/chatterbox-egyptian-v0](https://huggingface.co/oddadmix/chatterbox-egyptian-v0) | ~4.3GB | ✅ | Egyptian Arabic community finetune (architecture v2) |
|
||||
|
||||
## VibeVoice
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| vibevoice-1.5B | [microsoft/VibeVoice-1.5B](https://huggingface.co/microsoft/VibeVoice-1.5B) | ~5.4GB | ✅ | Microsoft official model |
|
||||
| vibevoice-7B | [aoi-ot/VibeVoice-Large](https://huggingface.co/aoi-ot/VibeVoice-Large) | ~18GB | ✅ | Community mirror used by downloader |
|
||||
| kugelaudio-0-open | [kugelaudio/kugelaudio-0-open](https://huggingface.co/kugelaudio/kugelaudio-0-open) | ~18GB | ✅ | KugelAudio multilingual 7B variant |
|
||||
| kugel-2 | [kugelaudio/kugel-2](https://huggingface.co/kugelaudio/kugel-2) | ~18.7GB | ✅ | KugelAudio v2 merged 7B variant |
|
||||
|
||||
## Higgs Audio 2
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| higgs-audio-v2-3B | [bosonai/higgs-audio-v2-generation-3B-base](https://huggingface.co/bosonai/higgs-audio-v2-generation-3B-base) | ~9GB | ✅ | Generation model |
|
||||
| Audio tokenizer | [bosonai/higgs-audio-v2-tokenizer](https://huggingface.co/bosonai/higgs-audio-v2-tokenizer) | ~200MB | ✅ | Tokenizer model |
|
||||
|
||||
## Higgs Audio v3
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| higgs-audio-v3-tts-4b | [bosonai/higgs-audio-v3-tts-4b](https://huggingface.co/bosonai/higgs-audio-v3-tts-4b) | ~8GB | ✅ | Official 4B multilingual controllable TTS model |
|
||||
|
||||
## IndexTTS 2 / 2.5
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| IndexTTS-2 | [IndexTeam/IndexTTS-2](https://huggingface.co/IndexTeam/IndexTTS-2) | Multiple files | ✅ | Main TTS engine |
|
||||
| IndexTTS-2.5 | [IndexTeam/IndexTTS-2.5](https://huggingface.co/IndexTeam/IndexTTS-2.5) | ~5.49GB | ✅ | Multilingual backend with bundled codec and official feature-duration scaling |
|
||||
| w2v-bert-2.0 | [facebook/w2v-bert-2.0](https://huggingface.co/facebook/w2v-bert-2.0) | ~2GB | ✅ | Semantic feature extractor |
|
||||
| qwen0.6bemo4-merge | Included with IndexTTS-2 | Included | ✅ | Text emotion model bundle |
|
||||
|
||||
## CosyVoice3
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Fun-CosyVoice3-0.5B / 0.5B-RL | [FunAudioLLM/Fun-CosyVoice3-0.5B-2512](https://huggingface.co/FunAudioLLM/Fun-CosyVoice3-0.5B-2512) | ~5.4GB first variant (+~2GB second) | ✅ | Both variants share common files |
|
||||
|
||||
## Qwen3-TTS
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| CustomVoice 0.6B | [Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) | ~1.5GB | ✅ | Preset voices + instructions |
|
||||
| CustomVoice 1.7B | [Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) | ~4.2GB | ✅ | Preset voices + instructions |
|
||||
| VoiceDesign 1.7B | [Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign) | ~4.2GB | ✅ | Text-to-voice design model |
|
||||
| Base 0.6B | [Qwen/Qwen3-TTS-12Hz-0.6B-Base](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base) | ~1.5GB | ✅ | Zero-shot voice cloning |
|
||||
| Base 1.7B | [Qwen/Qwen3-TTS-12Hz-1.7B-Base](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-Base) | ~4.2GB | ✅ | Zero-shot voice cloning |
|
||||
| Qwen3-ASR-1.7B | [Qwen/Qwen3-ASR-1.7B](https://huggingface.co/Qwen/Qwen3-ASR-1.7B) | N/A | ✅ | ASR transcribe model |
|
||||
| Qwen3-ForcedAligner-0.6B | [Qwen/Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B) | N/A | ✅ | Word-level timestamps |
|
||||
|
||||
## Granite ASR
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| granite-4.0-1b-speech | [ibm-granite/granite-4.0-1b-speech](https://huggingface.co/ibm-granite/granite-4.0-1b-speech) | ~4.6GB | ✅ | Main Granite ASR / AST model |
|
||||
| granite-speech-4.1-2b | [ibm-granite/granite-speech-4.1-2b](https://huggingface.co/ibm-granite/granite-speech-4.1-2b) | ~4.6GB | ✅ | Granite Speech 4.1 2B multilingual model (supports Japanese) |
|
||||
| granite-speech-4.1-2b-plus | [ibm-granite/granite-speech-4.1-2b-plus](https://huggingface.co/ibm-granite/granite-speech-4.1-2b-plus) | ~4.6GB | ✅ | Plus variant supporting native speaker diarization and word-level timestamps |
|
||||
| Qwen3-ForcedAligner-0.6B | [Qwen/Qwen3-ForcedAligner-0.6B](https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B) | N/A | ✅ | Optional custom word-level timestamps/SRT path; reused from Qwen folder |
|
||||
|
||||
## Step Audio EditX
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Step-Audio-EditX | [stepfun-ai/Step-Audio-EditX](https://huggingface.co/stepfun-ai/Step-Audio-EditX) | ~7GB | ✅ | Main 3B audio editing model |
|
||||
| Step-Audio-Tokenizer | [stepfun-ai/Step-Audio-Tokenizer](https://huggingface.co/stepfun-ai/Step-Audio-Tokenizer) | Included | ✅ | Tokenizer bundle used by Step EditX |
|
||||
|
||||
## Echo-TTS
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| echo-tts-base (model + PCA state) | [jordand/echo-tts-base](https://huggingface.co/jordand/echo-tts-base) | ~5.3GB | ✅ | pytorch_model.safetensors + pca_state.safetensors |
|
||||
| fish-s1-dac-min (audio codec) | [jordand/fish-s1-dac-min](https://huggingface.co/jordand/fish-s1-dac-min) | ~1.8GB | ✅ | pytorch_model.safetensors — audio codec required by Echo-TTS |
|
||||
|
||||
## Fish Audio S2 Pro
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| S2 Pro | [fishaudio/s2-pro](https://huggingface.co/fishaudio/s2-pro) | ~10.3GB | ✅ | Official 4B model and codec; BNB INT8/NF4 are optional load-time quantization modes that reuse these files; non-commercial license |
|
||||
| S2 Pro FP8 | [drbaph/s2-pro-fp8](https://huggingface.co/drbaph/s2-pro-fp8) | ~8.0GB | ✅ | Community weight-only FP8 checkpoint; BF16 activations; RTX 4090/5090-class CUDA GPU required |
|
||||
|
||||
## Dots TTS
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| dots.tts-base | [rednote-hilab/dots.tts-base](https://huggingface.co/rednote-hilab/dots.tts-base) | ~6GB | ✅ | Official base checkpoint with tokenizer, vocoder, speaker encoder, and latent stats |
|
||||
| dots.tts-soar | [rednote-hilab/dots.tts-soar](https://huggingface.co/rednote-hilab/dots.tts-soar) | ~6GB | ✅ | Official SOAR checkpoint for higher-quality zero-shot cloning |
|
||||
| dots.tts-mf | [rednote-hilab/dots.tts-mf](https://huggingface.co/rednote-hilab/dots.tts-mf) | ~6GB | ✅ | Official MeanFlow-distilled checkpoint for faster inference |
|
||||
|
||||
## DramaBox
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| DramaBox DiT + audio components | [ResembleAI/Dramabox](https://huggingface.co/ResembleAI/Dramabox) | ~8.5GB | ✅ | Official merged DramaBox transformer and LTX audio VAE/vocoder components |
|
||||
| Gemma 3 12B 4-bit text encoder | [unsloth/gemma-3-12b-it-bnb-4bit](https://huggingface.co/unsloth/gemma-3-12b-it-bnb-4bit) | ~7.8GB | ✅ | Official pre-quantized text encoder; loaded locally with no HF cache fallback |
|
||||
|
||||
## OmniVoice
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| OmniVoice | [k2-fsa/OmniVoice](https://huggingface.co/k2-fsa/OmniVoice) | ~3.7GB | ✅ | Official main model including bundled audio_tokenizer assets |
|
||||
|
||||
## MOSS-TTS
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| MOSS-TTS-Local-Transformer | [OpenMOSS-Team/MOSS-TTS-Local-Transformer](https://huggingface.co/OpenMOSS-Team/MOSS-TTS-Local-Transformer) | ~6.1GB | ✅ | Official 1.7B local-transformer model |
|
||||
| MOSS-TTS | [OpenMOSS-Team/MOSS-TTS](https://huggingface.co/OpenMOSS-Team/MOSS-TTS) | ~17GB | ✅ | Official 8B delay model |
|
||||
| MOSS-TTS-v1.5 | [OpenMOSS-Team/MOSS-TTS-v1.5](https://huggingface.co/OpenMOSS-Team/MOSS-TTS-v1.5) | ~17GB | ✅ | Current official 8B delay model with 31 languages and more stable voice cloning |
|
||||
| MOSS-TTS v1.5 Voice Acting 8B (Community - LAION) | [laion/moss-tts-v1.5-8b-voice-acting](https://huggingface.co/laion/moss-tts-v1.5-8b-voice-acting) | ~17GB | ✅ | Third-party full MOSS-TTS v1.5 fine-tune for expressive voice acting; not an official OpenMOSS model |
|
||||
| MOSS-VoiceGenerator | [OpenMOSS-Team/MOSS-VoiceGenerator](https://huggingface.co/OpenMOSS-Team/MOSS-VoiceGenerator) | ~4.2GB | ✅ | Official 1.7B reference-free voice-design model |
|
||||
| MOSS-TTSD-v1.0 | [OpenMOSS-Team/MOSS-TTSD-v1.0](https://huggingface.co/OpenMOSS-Team/MOSS-TTSD-v1.0) | ~18GB | ✅ | Official 8B native multi-speaker dialogue model |
|
||||
| MOSS-SoundEffect | [OpenMOSS-Team/MOSS-SoundEffect](https://huggingface.co/OpenMOSS-Team/MOSS-SoundEffect) | ~17GB | ✅ | Official MOSS v1 prompt-only sound-effect checkpoint; uses the shared MOSS audio tokenizer |
|
||||
| MOSS-Audio-Tokenizer | [OpenMOSS-Team/MOSS-Audio-Tokenizer](https://huggingface.co/OpenMOSS-Team/MOSS-Audio-Tokenizer) | ~8.5GB | ✅ | Shared official codec required by MOSS-TTS |
|
||||
|
||||
## MOSS-SoundEffect v2
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| MOSS-SoundEffect-v2.0 | [OpenMOSS-Team/MOSS-SoundEffect-v2.0](https://huggingface.co/OpenMOSS-Team/MOSS-SoundEffect-v2.0) | ~11.2GB | ✅ | Official DiT + DAC VAE + Qwen3 text-encoder sound-effect pipeline |
|
||||
|
||||
## RVC
|
||||
|
||||
| Component | Source | Size | Auto-Download | Notes |
|
||||
|---|---|---|---|---|
|
||||
| RVC character pack | [SayanoAI/RVC-Studio (RVC folder)](https://huggingface.co/datasets/SayanoAI/RVC-Studio/tree/main/RVC) | Varies | ✅ | Default auto-download characters: Claire, Sayano, Mae_v2, Fuji, Monika (extras also available) |
|
||||
| RVC index pack (.index) | [SayanoAI/RVC-Studio (.index folder)](https://huggingface.co/datasets/SayanoAI/RVC-Studio/tree/main/RVC/.index) | Varies | ✅ | Optional FAISS indexes for improved voice similarity |
|
||||
| content-vec-best.safetensors | [lengyue233/content-vec-best](https://huggingface.co/lengyue233/content-vec-best) | ~300MB | ✅ | Voice feature model |
|
||||
| rmvpe.pt | [lj1995/VoiceConversionWebUI](https://huggingface.co/lj1995/VoiceConversionWebUI) | ~55MB | ✅ | Pitch extraction model |
|
||||
| pretrained_v2 (f0 G/D pairs) | [lj1995/VoiceConversionWebUI](https://huggingface.co/lj1995/VoiceConversionWebUI/tree/main/pretrained_v2) | ~300MB total | ✅ | Training init checkpoints for 32k/40k/48k RVC runs; downloaded on first training use |
|
||||
|
||||
*Generated from [tts_audio_suite_engines.yaml](Dev%20reports/tts_audio_suite_engines.yaml).*
|
||||
@@ -0,0 +1,466 @@
|
||||
# Model Folder Layouts
|
||||
|
||||
Use this document for folder structures and placement notes.
|
||||
For repository/source URLs, use [MODEL_DOWNLOAD_SOURCES.md](MODEL_DOWNLOAD_SOURCES.md).
|
||||
|
||||
## ChatterBox
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/chatterbox/
|
||||
```
|
||||
|
||||
Legacy (still supported):
|
||||
|
||||
```text
|
||||
ComfyUI/models/chatterbox/
|
||||
```
|
||||
|
||||
Required base files:
|
||||
|
||||
- `conds.pt`
|
||||
- `s3gen.pt`
|
||||
- `t3_cfg.pt`
|
||||
- `tokenizer.json`
|
||||
- `ve.pt`
|
||||
|
||||
Optional multilingual subfolders:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/chatterbox/
|
||||
├── English/
|
||||
├── German/
|
||||
└── Norwegian/
|
||||
```
|
||||
|
||||
## ChatterBox Official 23-Lang
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/chatterbox_official_23lang/
|
||||
├── ChatterBox Official 23-Lang/
|
||||
│ ├── t3_23lang.safetensors
|
||||
│ ├── t3_mtl23ls_v2.safetensors
|
||||
│ ├── grapheme_mtl_merged_expanded_v1.json
|
||||
│ ├── s3gen.pt
|
||||
│ ├── ve.pt
|
||||
│ ├── mtl_tokenizer.json
|
||||
│ └── conds.pt
|
||||
└── russian_text_stresser/
|
||||
├── russian_dict.db
|
||||
└── simple_cases.pkl
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- v1 and v2 coexist in one directory.
|
||||
- Vietnamese (Viterbox) is available as a community finetune option.
|
||||
- `russian_text_stresser/` is auxiliary Russian-only data for Official 23-Lang stress labeling and downloads on demand.
|
||||
|
||||
## F5-TTS
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/F5-TTS/
|
||||
```
|
||||
|
||||
Legacy (still supported):
|
||||
|
||||
```text
|
||||
ComfyUI/models/F5-TTS/
|
||||
```
|
||||
|
||||
Typical structure:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/F5-TTS/
|
||||
├── F5TTS_Base/
|
||||
│ ├── model_1200000.safetensors
|
||||
│ └── vocab.txt
|
||||
├── F5TTS_v1_Base/
|
||||
│ ├── model_1250000.safetensors
|
||||
│ └── vocab.txt
|
||||
└── vocos/
|
||||
├── config.yaml
|
||||
└── pytorch_model.bin
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- F5-TTS models auto-download on first use.
|
||||
- `vocab.txt` is required per model.
|
||||
- Vocos is optional and also auto-downloadable.
|
||||
|
||||
## F5-TTS Voice References
|
||||
|
||||
```text
|
||||
ComfyUI/models/voices/
|
||||
├── character1.wav
|
||||
├── character1.reference.txt
|
||||
├── narrator.wav
|
||||
└── narrator.txt
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- WAV, clean speech, 5-30s (24kHz recommended).
|
||||
- Text must match spoken audio.
|
||||
- Naming: `name.wav` + `name.reference.txt` (preferred) or `name.txt`.
|
||||
|
||||
Inference guidelines:
|
||||
|
||||
1. Keep reference audio under ~12s with a little trailing silence.
|
||||
2. Use uppercase letter-by-letter only when desired.
|
||||
3. Use spaces/punctuation for explicit pauses.
|
||||
4. Keep a space after sentence-ending punctuation.
|
||||
5. For Chinese output, preprocess numbers if needed.
|
||||
|
||||
## Higgs Audio 2
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/HiggsAudio/
|
||||
└── higgs-audio-v2-3B/
|
||||
├── generation/
|
||||
├── tokenizer/
|
||||
└── voices/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Both generation model and tokenizer auto-download.
|
||||
- Place reference audio/transcription in `voices/`.
|
||||
|
||||
## VibeVoice
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/VibeVoice/
|
||||
├── vibevoice-1.5B/
|
||||
└── vibevoice-7B/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Both variants auto-download on first use.
|
||||
- `vibevoice-7B` uses a community mirror in downloader config.
|
||||
|
||||
## RVC
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/RVC/
|
||||
├── *.pth
|
||||
├── content-vec-best.safetensors
|
||||
├── rmvpe.pt
|
||||
├── hubert/
|
||||
├── pretrained_v2/
|
||||
│ ├── f0G32k.pth
|
||||
│ ├── f0D32k.pth
|
||||
│ ├── f0G40k.pth
|
||||
│ ├── f0D40k.pth
|
||||
│ ├── f0G48k.pth
|
||||
│ └── f0D48k.pth
|
||||
└── .index/
|
||||
```
|
||||
|
||||
Legacy (still supported):
|
||||
|
||||
```text
|
||||
ComfyUI/models/RVC/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Base models auto-download.
|
||||
- Character `.pth` models can be auto-downloaded defaults or user-provided.
|
||||
- `pretrained_v2/` is used by the integrated RVC trainer and auto-downloads on first training run.
|
||||
- Training datasets, logs, progress snapshots, and resumable checkpoints are stored under `ComfyUI/output/tts_audio_suite_training/rvc/`, not inside the custom node repo.
|
||||
- UVR models are downloaded under `ComfyUI/models/TTS/UVR/` (or legacy `ComfyUI/models/UVR/`).
|
||||
|
||||
## IndexTTS-2
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/IndexTTS/
|
||||
├── IndexTTS-2/
|
||||
└── w2v-bert-2.0/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Emotion components and semantic feature models auto-download.
|
||||
|
||||
## Step Audio EditX
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/step_audio_editx/
|
||||
├── Step-Audio-EditX/
|
||||
│ └── CosyVoice-300M-25Hz/
|
||||
└── FunASR-Paraformer/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Main model, tokenizer assets, and speech stack auto-download.
|
||||
|
||||
## Higgs Audio v3
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/higgs_audio_v3/
|
||||
└── higgs-audio-v3-tts-4b/
|
||||
├── config.json
|
||||
├── tokenizer.json
|
||||
├── tokenizer_config.json
|
||||
├── chat_template.jinja
|
||||
├── model.safetensors
|
||||
├── model.safetensors.index.json
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Downloads into its own `higgs_audio_v3` folder.
|
||||
- Requires the main Transformers 5 environment.
|
||||
- Reference transcript `.txt` files are optional but improve cloning quality.
|
||||
|
||||
## DramaBox
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/dramabox/
|
||||
├── DramaBox/
|
||||
├── dramabox-dit-v1.safetensors
|
||||
├── dramabox-audio-components.safetensors
|
||||
├── assets/
|
||||
│ └── silence_latent_frame.pt
|
||||
└── gemma-3-12b-it-bnb-4bit/
|
||||
├── config.json
|
||||
├── model-00001-of-00002.safetensors
|
||||
├── model-00002-of-00002.safetensors
|
||||
├── model.safetensors.index.json
|
||||
└── tokenizer and processor files...
|
||||
└── loras/
|
||||
└── <adapter_name>/
|
||||
├── adapter_config.json
|
||||
└── adapter_model.safetensors
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Both repositories download directly into the organized suite folder.
|
||||
- Transformers is forced into local-only loading after download.
|
||||
- Requires NVIDIA CUDA. Fast mode targets approximately 24GB VRAM; experimental staged/sequential modes can run with less.
|
||||
- Integrated training exports LoRA adapters into `dramabox/loras/<adapter_name>/`.
|
||||
- Training jobs, normalized datasets, preprocessing caches, checkpoints, and logs are stored under `ComfyUI/output/tts_audio_suite_training/dramabox/`.
|
||||
- The LTX-2 Community License requires a paid license for entities with at
|
||||
least USD 10 million in annual revenue.
|
||||
|
||||
## CosyVoice3
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/CosyVoice/
|
||||
└── Fun-CosyVoice3-0.5B/
|
||||
├── llm.pt
|
||||
├── llm.rl.pt
|
||||
└── shared model files...
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- First selected variant downloads first.
|
||||
- Shared files are reused across variants.
|
||||
|
||||
## Qwen3-TTS and Qwen3-ASR
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/qwen3_tts/
|
||||
├── Qwen3-TTS-12Hz-0.6B-CustomVoice/
|
||||
├── Qwen3-TTS-12Hz-1.7B-CustomVoice/
|
||||
├── Qwen3-TTS-12Hz-1.7B-VoiceDesign/
|
||||
├── Qwen3-TTS-12Hz-0.6B-Base/
|
||||
├── Qwen3-TTS-12Hz-1.7B-Base/
|
||||
├── qwen2-audio-encoder/
|
||||
└── asr/
|
||||
├── Qwen3-ASR-1.7B/
|
||||
└── Qwen3-ForcedAligner-0.6B/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Only selected variants download.
|
||||
- Shared tokenizer assets are reused.
|
||||
|
||||
## MOSS-TTS
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/
|
||||
├── MOSS-TTS-Local-Transformer/
|
||||
├── MOSS-TTS-v1.5/
|
||||
├── moss-tts-v1.5-8b-voice-acting/ # Community - LAION
|
||||
├── MOSS-TTS/
|
||||
├── MOSS-VoiceGenerator/
|
||||
├── MOSS-SoundEffect/
|
||||
├── MOSS-TTSD-v1.0/
|
||||
├── MOSS-Audio-Tokenizer/
|
||||
└── loras/
|
||||
└── <adapter_name>/
|
||||
├── adapter_config.json
|
||||
└── adapter_model.safetensors
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `MOSS-Audio-Tokenizer` is required by the official TTS and TTSD variants.
|
||||
- `MOSS-TTS-Local-Transformer` is the smaller 1.7B model.
|
||||
- `MOSS-TTS-v1.5` is the current 8B delay model with 31-language support.
|
||||
- `moss-tts-v1.5-8b-voice-acting` is an optional third-party LAION full fine-tune for expressive speech, not an official OpenMOSS model.
|
||||
- Other compatible full checkpoints placed here are discovered from their `config.json`; unsupported MOSS architectures are rejected explicitly.
|
||||
- `MOSS-TTS` is the legacy official 8B delay model.
|
||||
- `MOSS-VoiceGenerator` is the 1.7B voice-design provider used by Voice Designer.
|
||||
- `MOSS-SoundEffect` is the v1 sound-effect checkpoint used through the MOSS-TTS engine and 🌩️ Sound Effects.
|
||||
- `MOSS-TTSD-v1.0` is the official 8B native multi-speaker dialogue model.
|
||||
- Integrated training currently exports LoRA adapters into `moss_tts/loras/<adapter_name>/`.
|
||||
- Training jobs, temporary manifests, and checkpoints are stored under `ComfyUI/output/tts_audio_suite_training/moss_tts/`.
|
||||
|
||||
## MOSS-SoundEffect v2
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_soundeffect_v2/
|
||||
└── MOSS-SoundEffect-v2.0/
|
||||
├── model_index.json
|
||||
├── scheduler/
|
||||
├── text_encoder/
|
||||
├── tokenizer/
|
||||
├── transformer/
|
||||
└── vae/
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This is a separate v2 diffusion family, not a MOSS-TTS checkpoint variant.
|
||||
- It runs in the configured ComfyUI environment; the official Apache-2.0 inference package is bundled without modifying its dependencies.
|
||||
- The 🌩️ Sound Effects node limits generation to the official 30-second maximum.
|
||||
|
||||
## Granite ASR
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/granite_asr/
|
||||
└── granite-4.0-1b-speech/
|
||||
├── config.json
|
||||
├── chat_template.jinja
|
||||
├── model-00001-of-00003.safetensors
|
||||
├── model-00002-of-00003.safetensors
|
||||
├── model-00003-of-00003.safetensors
|
||||
└── tokenizer / processor files...
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Granite downloads into its own `granite_asr` folder.
|
||||
- `granite-speech-4.1-2b-plus` adds native speaker diarization and native word-level timestamps, but drops Japanese.
|
||||
- If Granite word timestamps are enabled, it lazily reuses `Qwen3-ForcedAligner-0.6B` from the Qwen ASR folder instead of duplicating that model.
|
||||
|
||||
## Echo-TTS
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/
|
||||
├── echo-tts-base/
|
||||
│ ├── pytorch_model.safetensors
|
||||
│ └── pca_state.safetensors
|
||||
└── fish-s1-dac-min/
|
||||
└── pytorch_model.safetensors
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Both components are required and auto-downloaded on first use.
|
||||
- License: CC-BY-NC-SA (non-commercial).
|
||||
|
||||
## Fish Audio S2 Pro
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/fish_audio_s2_pro/
|
||||
├── codec.pth
|
||||
├── config.json
|
||||
├── model-00001-of-00002.safetensors
|
||||
├── model-00002-of-00002.safetensors
|
||||
├── model.safetensors.index.json
|
||||
└── tokenizer.json
|
||||
```
|
||||
|
||||
Optional FP8 variant:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/fish_audio_s2_pro_fp8/
|
||||
├── codec.pth
|
||||
├── config.json
|
||||
├── model.safetensors
|
||||
├── quantization_info.json
|
||||
└── tokenizer.json
|
||||
```
|
||||
|
||||
The complete official repository metadata and tokenizer files are downloaded alongside these files. License: Fish Audio Research License (non-commercial without a separate commercial license).
|
||||
|
||||
The `s2-pro-bnb-int8` and `s2-pro-bnb-nf4` options reuse `fish_audio_s2_pro/` and quantize its official checkpoint while loading. They do not download another model copy and require `bitsandbytes`.
|
||||
|
||||
## Dots TTS
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/dots_tts/
|
||||
├── dots.tts-base/
|
||||
├── dots.tts-soar/
|
||||
└── dots.tts-mf/
|
||||
```
|
||||
|
||||
Typical checkpoint contents:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/dots_tts/dots.tts-soar/
|
||||
├── added_tokens.json
|
||||
├── chat_template.jinja
|
||||
├── config.json
|
||||
├── latent_stats.pt
|
||||
├── llm_config.json
|
||||
├── merges.txt
|
||||
├── model.safetensors
|
||||
├── speaker_encoder.safetensors
|
||||
├── special_tokens_map.json
|
||||
├── tokenizer.json
|
||||
├── tokenizer_config.json
|
||||
├── vocab.json
|
||||
└── vocoder.safetensors
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Downloads into a dedicated `dots_tts/` folder.
|
||||
- Native sample rate is 48kHz.
|
||||
- Main-environment support works on Transformers 5; on Windows, `normalize_text` falls back to no-op if `WeTextProcessing` is unavailable.
|
||||
|
||||
## OmniVoice
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/omnivoice/
|
||||
└── OmniVoice/
|
||||
```
|
||||
|
||||
Main model contents:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/omnivoice/OmniVoice/
|
||||
├── chat_template.jinja
|
||||
├── config.json
|
||||
├── model.safetensors
|
||||
├── tokenizer.json
|
||||
├── tokenizer_config.json
|
||||
└── audio_tokenizer/
|
||||
├── config.json
|
||||
├── model.safetensors
|
||||
└── preprocessor_config.json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Downloads into a dedicated `omnivoice/` folder.
|
||||
- Native sample rate is 24kHz.
|
||||
- The main OmniVoice model stays in the main Transformers 5 environment.
|
||||
- Voice cloning requires reference audio plus explicit reference text in this suite.
|
||||
@@ -0,0 +1,119 @@
|
||||
# MOSS LoRA Guide
|
||||
|
||||
This guide defines the local adapter layout and loading behavior for **MOSS-TTS LoRA adapters** in TTS Audio Suite.
|
||||
|
||||
## Local Folder Layout
|
||||
|
||||
MOSS LoRA adapters should live under:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/loras/
|
||||
```
|
||||
|
||||
Each adapter must have its **own folder**:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/loras/
|
||||
MyNorwegianLoRA/
|
||||
adapter_config.json
|
||||
adapter_model.safetensors
|
||||
```
|
||||
|
||||
Recommended structure:
|
||||
|
||||
```text
|
||||
<adapter_name>/
|
||||
adapter_config.json
|
||||
adapter_model.safetensors
|
||||
```
|
||||
|
||||
Also accepted:
|
||||
|
||||
```text
|
||||
<adapter_name>/
|
||||
adapter_config.json
|
||||
adapter_model.bin
|
||||
```
|
||||
|
||||
## Required Files
|
||||
|
||||
Each adapter folder must contain:
|
||||
|
||||
- `adapter_config.json`
|
||||
- one adapter weights file:
|
||||
- `adapter_model.safetensors` preferred
|
||||
- or `adapter_model.bin`
|
||||
|
||||
Extra files are allowed, for example:
|
||||
|
||||
- `README.md`
|
||||
- training metadata
|
||||
- tokenizer notes
|
||||
|
||||
## What Is Not Supported
|
||||
|
||||
Do not place loose adapter files directly inside:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/loras/
|
||||
```
|
||||
|
||||
This is **not** a supported layout:
|
||||
|
||||
```text
|
||||
loras/
|
||||
adapter_config.json
|
||||
some_weights.safetensors
|
||||
```
|
||||
|
||||
This is also **not** supported:
|
||||
|
||||
```text
|
||||
loras/
|
||||
MyLoRA.safetensors
|
||||
```
|
||||
|
||||
One adapter must always be one folder.
|
||||
|
||||
## Engine Node Behavior
|
||||
|
||||
The MOSS engine node supports two adapter paths:
|
||||
|
||||
1. **Local LoRA dropdown**
|
||||
- lists valid adapter folders discovered under `models/TTS/moss_tts/loras`
|
||||
|
||||
2. **Advanced override field**
|
||||
- accepts:
|
||||
- a local adapter folder path
|
||||
- a Hugging Face repo id
|
||||
|
||||
If you enter a Hugging Face repo id, TTS Audio Suite installs it into:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/loras/<owner__repo>/
|
||||
```
|
||||
|
||||
Then it loads the adapter from that local folder.
|
||||
|
||||
## Why This Layout Exists
|
||||
|
||||
TTS Audio Suite does **not** use Hugging Face cache as the final managed storage location for MOSS LoRAs.
|
||||
|
||||
This layout keeps adapters:
|
||||
|
||||
- visible in the MOSS dropdown
|
||||
- stored inside the normal TTS model tree
|
||||
- ready for future training output
|
||||
- separate from ComfyUI diffusion LoRA folders
|
||||
|
||||
## Training Output Standard
|
||||
|
||||
Future MOSS training should write adapters in exactly this format:
|
||||
|
||||
```text
|
||||
ComfyUI/models/TTS/moss_tts/loras/<run_name>/
|
||||
adapter_config.json
|
||||
adapter_model.safetensors
|
||||
```
|
||||
|
||||
That keeps training, manual installs, and inference all using the same contract.
|
||||
@@ -0,0 +1,258 @@
|
||||
# MOSS Training Guide
|
||||
|
||||
This guide explains the **practical dataset meaning** of the current MOSS training nodes in TTS Audio Suite.
|
||||
|
||||
Use this if `🧾 MOSS Dataset Rows` feels unclear.
|
||||
|
||||
## Current Training Scope
|
||||
|
||||
Current first training slice supports:
|
||||
|
||||
- **MOSS-TTS 8B v1.0 and v1.5 (Delay)**
|
||||
- **LAION MOSS-TTS v1.5 Voice Acting 8B community full checkpoint (Delay, compatibility path; training results not yet validated by the suite maintainers)**
|
||||
- **LoRA adapter training**
|
||||
|
||||
The model selected on the connected MOSS engine is used for dataset preparation and training. Prepare the dataset again after switching between v1.0 and v1.5.
|
||||
|
||||
The LAION Voice Acting checkpoint uses the same Delay architecture and can use this LoRA training path, but the suite maintainers have not completed an inference or training run with its full weights. Treat it as community-tested support and report results or incompatibilities.
|
||||
|
||||
It does **not** currently support:
|
||||
|
||||
- Local 1.7B training
|
||||
- TTSD training
|
||||
- full-parameter training from the node path
|
||||
|
||||
## Normal Training Flow
|
||||
|
||||
Current ComfyUI flow:
|
||||
|
||||
1. `🎞️ Training Clip Staging`
|
||||
2. `🧾 MOSS Dataset Rows`
|
||||
3. `📦 MOSS Dataset Prep`
|
||||
4. `🎛️ MOSS Training Config`
|
||||
5. `🎓 Model Training`
|
||||
|
||||
If clips and transcripts are already prepared on disk, you can skip the first two
|
||||
nodes. Set `dataset_source` on `📦 MOSS Dataset Prep` to a folder containing
|
||||
same-name audio and text pairs:
|
||||
|
||||
```text
|
||||
my_dataset/
|
||||
├── clip001.wav
|
||||
├── clip001.txt
|
||||
├── clip002.flac
|
||||
└── clip002.txt
|
||||
```
|
||||
|
||||
Each `.txt` file must contain the transcript spoken in its matching audio file.
|
||||
Folder scanning supports WAV, FLAC, MP3, OGG, and M4A. Subfolders are ignored
|
||||
unless `recursive_folder_scan` is enabled. Existing JSONL manifest paths continue
|
||||
to work unchanged.
|
||||
|
||||
## The Important Fields
|
||||
|
||||
### `text_lines`
|
||||
|
||||
This is the easiest one:
|
||||
|
||||
- one line per staged clip
|
||||
- each line is the **transcript/text of that clip**
|
||||
|
||||
Example:
|
||||
|
||||
- `clip001` audio says: `Hello there.`
|
||||
- `clip002` audio says: `How are you?`
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
Hello there.
|
||||
How are you?
|
||||
```
|
||||
|
||||
That is the normal default dataset shape:
|
||||
|
||||
- `audio + transcript`
|
||||
|
||||
For many voice LoRAs, this is enough.
|
||||
|
||||
### `reference_clip_lines`
|
||||
|
||||
This is **advanced**. Most users should leave it blank at first.
|
||||
|
||||
It means:
|
||||
|
||||
- which other staged clip should be used as the **reference audio** for that row
|
||||
|
||||
That teaches the model a different pattern:
|
||||
|
||||
- `text` says **what to say**
|
||||
- `reference audio` says **whose voice/style to follow**
|
||||
- `target audio` is the **correct output**
|
||||
|
||||
Example:
|
||||
|
||||
- `clip001` = Bob saying: `My name is Bob.`
|
||||
- `clip002` = Bob saying: `Welcome to the show.`
|
||||
|
||||
Row 2 can be:
|
||||
|
||||
- target audio = `clip002`
|
||||
- text = `Welcome to the show.`
|
||||
- reference = `clip001`
|
||||
|
||||
That teaches:
|
||||
|
||||
- “use `clip001` as the reference voice, then produce the target line from `clip002`”
|
||||
|
||||
## When To Ignore `reference_clip_lines`
|
||||
|
||||
Ignore it for:
|
||||
|
||||
- first MOSS LoRA tests
|
||||
- single-speaker voice LoRAs
|
||||
- fixed-voice adaptation
|
||||
- plain language/domain adaptation
|
||||
|
||||
That means:
|
||||
|
||||
- just use `audio + transcript`
|
||||
- maybe also use `instruction`
|
||||
|
||||
This is the best default.
|
||||
|
||||
## When To Use `reference_clip_lines`
|
||||
|
||||
Use it only if you deliberately want **reference-conditioned training**.
|
||||
|
||||
That means teaching the model:
|
||||
|
||||
- how to use a separate reference clip for voice cloning
|
||||
- how to follow reference style/prosody
|
||||
- how to pick speaker identity from reference audio
|
||||
|
||||
This makes more sense for:
|
||||
|
||||
- reference-conditioned voice cloning behavior
|
||||
- multi-speaker conditioning datasets
|
||||
- style-following datasets
|
||||
|
||||
## How A Model Learns Voice Cloning
|
||||
|
||||
Think of it this way:
|
||||
|
||||
- if a TTS model only sees `audio + transcript`, it learns how to do TTS for the voices/styles present in the data
|
||||
- if a TTS model also sees `ref_audio`, it can learn a different pattern:
|
||||
- text says **what to say**
|
||||
- reference audio says **whose voice/style to imitate**
|
||||
- target audio is the correct output
|
||||
|
||||
That is how a model can learn **reference-conditioned voice cloning**, not just plain TTS.
|
||||
|
||||
So if your goal is to fine-tune that specific ability:
|
||||
|
||||
- “give the model a reference clip and make it speak in that voice/style”
|
||||
|
||||
then `ref_audio` is when this field makes sense.
|
||||
|
||||
If your goal is only:
|
||||
|
||||
- “make the model speak in this one fixed voice better”
|
||||
|
||||
then `ref_audio` usually does not matter much, and plain `audio + transcript` is the better starting point.
|
||||
|
||||
## `instruction` vs `ref_audio`
|
||||
|
||||
These are different tools.
|
||||
|
||||
### Use `instruction` for explicit text labels
|
||||
|
||||
Examples:
|
||||
|
||||
- `speaking angrily`
|
||||
- `whispering`
|
||||
- `calm narration`
|
||||
|
||||
This is usually clearer when you want to label the desired behavior directly.
|
||||
|
||||
### Use `quality` for recording/presentation quality
|
||||
|
||||
Examples:
|
||||
|
||||
- `telephone call quality`
|
||||
- `studio recording`
|
||||
- `noisy room recording`
|
||||
|
||||
This is better when the label is about how the audio sounds as a recording or presentation format, not about acting/performance.
|
||||
|
||||
### Use `ref_audio` for example audio conditioning
|
||||
|
||||
This is for:
|
||||
|
||||
- “sound like this other clip”
|
||||
- “follow this speaker/style from audio”
|
||||
|
||||
So for many datasets:
|
||||
|
||||
- `instruction` is the simpler control
|
||||
- `ref_audio` is the more advanced control
|
||||
|
||||
## Practical Recommendations
|
||||
|
||||
### Best first test
|
||||
|
||||
Use:
|
||||
|
||||
- staged clips
|
||||
- correct transcripts
|
||||
- no reference clips
|
||||
- optional `instruction` only if clearly useful
|
||||
|
||||
In other words:
|
||||
|
||||
- `text_lines`: yes
|
||||
- `reference_clip_lines`: leave blank
|
||||
|
||||
### If training a single person's voice
|
||||
|
||||
Start with:
|
||||
|
||||
- `audio + transcript`
|
||||
|
||||
Do **not** add `ref_audio` unless you specifically want to teach reference-conditioned cloning behavior.
|
||||
|
||||
### If training language adaptation like the Norwegian LoRA
|
||||
|
||||
Start with:
|
||||
|
||||
- `audio + transcript`
|
||||
|
||||
That is the normal base case.
|
||||
|
||||
You can still use reference audio later at inference time, because MOSS supports optional reference-based cloning, but the training dataset does not need to be built around `ref_audio`.
|
||||
|
||||
## Validation In Plain Terms
|
||||
|
||||
`📦 MOSS Dataset Prep` also mentions validation.
|
||||
|
||||
That means:
|
||||
|
||||
- a small holdout part of the dataset used only to check training progress
|
||||
- not used to update weights
|
||||
|
||||
If you do not have a separate validation manifest:
|
||||
|
||||
- leave `validation_source` blank
|
||||
- let the node auto-split the main dataset
|
||||
|
||||
## Short Version
|
||||
|
||||
If you want the least confusing starting point:
|
||||
|
||||
- use `🎞️ Training Clip Staging`
|
||||
- use `🧾 MOSS Dataset Rows`
|
||||
- fill only `text_lines`
|
||||
- leave `reference_clip_lines` blank
|
||||
- train the LoRA
|
||||
|
||||
That is the correct first path for most users.
|
||||
@@ -0,0 +1,152 @@
|
||||
# MOSS-TTS Prompt Fields Guide
|
||||
|
||||
## Overview
|
||||
|
||||
MOSS-TTS exposes official whole-segment prompt fields through the `⚙️ MOSS-TTS Engine` node.
|
||||
|
||||
These are not Step Audio EditX effects and they are not positional insertions. They are passed to the official MOSS prompt structure for the entire generated segment.
|
||||
|
||||
Supported official fields:
|
||||
|
||||
- `instruction`
|
||||
- `quality`
|
||||
- `sound_event`
|
||||
- `ambient_sound`
|
||||
- `language`
|
||||
- `duration_tokens`
|
||||
|
||||
## Stability Note
|
||||
|
||||
`ambient_sound` should currently be treated as experimental on base `MOSS-TTS` inside this suite.
|
||||
|
||||
Reason:
|
||||
|
||||
- the official prompt schema exposes `ambient_sound`
|
||||
- but the clearest public OpenMOSS example we found is in the `MOSS-SoundEffect` documentation path, not as a validated short-utterance base-TTS control
|
||||
|
||||
So on `MOSS-TTS` you may get:
|
||||
|
||||
- weak or missing ambience
|
||||
- longer-than-expected generation
|
||||
- unstable tails if the model is pushed to the token cap
|
||||
|
||||
## Important Limitation
|
||||
|
||||
MOSS does **not** treat these like exact inline insertions inside the sentence.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
[Alice|sound_event:Laughter] Hello there.
|
||||
```
|
||||
|
||||
That means the whole segment is conditioned with the event. It is **not** positional the way Step Audio EditX paralinguistic tags are.
|
||||
|
||||
## Engine Fields
|
||||
|
||||
You can set official fields directly on the engine node:
|
||||
|
||||
- `instruction`: whole-segment speaking instruction
|
||||
- `quality`: whole-segment quality/style hint
|
||||
- `sound_event`: whole-segment event hint such as `Laughter`, `Sigh`, `Breathing`
|
||||
- `ambient_sound`: whole-segment ambience hint such as `Rain`, `Crowd`, `Forest`
|
||||
|
||||
These apply to every generated segment that uses that engine config.
|
||||
|
||||
## Per-Segment Override Syntax
|
||||
|
||||
For per-segment overrides, use `[]` parameter switching.
|
||||
|
||||
Supported forms:
|
||||
|
||||
```text
|
||||
[Narrator|instruction:Speak softly and calmly] Hello there.
|
||||
[Narrator|quality:Studio recording] Hello there.
|
||||
[Narrator|sound_event:Laughter] Hello there.
|
||||
[Narrator|ambient_sound:Rain on window] Hello there.
|
||||
```
|
||||
|
||||
Inside normal character tags, combine them like this:
|
||||
|
||||
```text
|
||||
[Alice|instruction:Speak softly and calmly] Hello there.
|
||||
[Bob|quality:Telephone call quality|ambient_sound:Office room tone] Hi.
|
||||
[Alice|sound_event:Laughter] That's funny.
|
||||
```
|
||||
|
||||
These are whole-segment overrides, not exact insertion points.
|
||||
|
||||
## Why Not `<>`
|
||||
|
||||
MOSS does not have true native inline positioning for these controls.
|
||||
|
||||
So `<>` should stay free for real inline post-processing tags such as Step Audio EditX effects. Using `<>` for MOSS prompt fields would blur two different systems and create confusion.
|
||||
|
||||
## Examples
|
||||
|
||||
### Direct engine fields
|
||||
|
||||
Set on the engine node:
|
||||
|
||||
- `sound_event = Laughter`
|
||||
- `quality = Studio recording`
|
||||
|
||||
Text:
|
||||
|
||||
```text
|
||||
[Alice] Hello there!
|
||||
```
|
||||
|
||||
Result: the entire Alice segment is conditioned with `Laughter` and `Studio recording`.
|
||||
|
||||
### Per-segment `[]` syntax
|
||||
|
||||
```text
|
||||
[Alice|sound_event:Laughter] Hello there!
|
||||
[Bob|ambient_sound:Rain] Nice to meet you.
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- Alice segment uses `sound_event = Laughter`
|
||||
- Bob segment uses `ambient_sound = Rain`
|
||||
|
||||
## Conflicts and Precedence
|
||||
|
||||
- If you set an engine field and also override it in `[]`, the `[]` value wins for that segment.
|
||||
- These MOSS prompt fields are not supposed to use `<>`.
|
||||
|
||||
## Native TTSD Dialogue Note
|
||||
|
||||
`MOSS-TTSD-v1.0` generates one native dialogue request across the formatted `[S1]...[S5]` conversation.
|
||||
|
||||
So these official fields still apply to the whole TTSD request, not to one precise speaker turn inside that request.
|
||||
|
||||
## Native TTSD Compatibility Rules
|
||||
|
||||
Native TTSD mode is strict in this suite.
|
||||
|
||||
If any of these are detected, generation **fails with an explicit error** instead of auto-switching models:
|
||||
|
||||
- `pause tags`
|
||||
- inline Step Audio EditX tags
|
||||
- per-segment `[]` parameter changes
|
||||
- more than 5 speakers
|
||||
|
||||
This applies to both normal TTS Text and SRT workflows.
|
||||
|
||||
Required action when that happens:
|
||||
|
||||
- switch to `Custom Character Switching`
|
||||
- choose a standard MOSS model (`MOSS-TTS-Local-Transformer` or `MOSS-TTS`)
|
||||
|
||||
## When To Use Something Else
|
||||
|
||||
If you need exact placement of laughter/breathing/cough inside the sentence, use:
|
||||
|
||||
- Step Audio EditX inline tags and audio editor workflow
|
||||
- an engine with true native positional tag support, such as CosyVoice3
|
||||
|
||||
Use MOSS prompt fields when you want official MOSS conditioning, not precise insertion control.
|
||||
|
||||
If you specifically want generated environmental audio like rain, traffic, or scene beds, `MOSS-SoundEffect` is the more correct OpenMOSS model family target than base `MOSS-TTS`.
|
||||
@@ -0,0 +1,195 @@
|
||||
# 🏷️ Multiline TTS Tag Editor Guide
|
||||
|
||||
This guide covers the practical user-facing workflow for the `🏷️ Multiline TTS Tag Editor` node.
|
||||
|
||||
For related topics, also see:
|
||||
|
||||
- [Character Switching Guide](CHARACTER_SWITCHING_GUIDE.md)
|
||||
- [Parameter Switching Guide](PARAMETER_SWITCHING_GUIDE.md)
|
||||
- [Step Audio EditX Inline Tags User Guide](INLINE_EDIT_TAGS_USER_GUIDE.md)
|
||||
- [Higgs Audio v3 Inline Tags](HIGGS_AUDIO_V3_INLINE_TAGS.md)
|
||||
- [CosyVoice3 Tags Guide](COSYVOICE3_TAGS_GUIDE.md)
|
||||
- [OmniVoice Native Tags Guide](OMNIVOICE_TAGS_GUIDE.md)
|
||||
|
||||
## What This Editor Is For
|
||||
|
||||
The editor is meant to make tag-heavy TTS text and SRT subtitle editing faster inside ComfyUI without manually rebuilding bracket syntax all the time.
|
||||
|
||||
It supports:
|
||||
|
||||
- Character switching tags
|
||||
- Language switching tags
|
||||
- Per-segment parameter overrides
|
||||
- Engine-aware inline tags for Step Audio EditX, Higgs Audio v3, CosyVoice3, and OmniVoice
|
||||
- Presets and edit history
|
||||
- SRT-aware highlighting and timing editing
|
||||
|
||||
## Main Views
|
||||
|
||||
The top bar includes four views:
|
||||
|
||||
- `Editor` for direct text editing
|
||||
- `History` for restoring earlier snapshots
|
||||
- `Presets` for quick saved snippets or character setups
|
||||
- `Library` for built-in reference material
|
||||
|
||||
## Basic Tag Editing
|
||||
|
||||
Use the left panel to insert or modify tags without writing every bracket manually.
|
||||
|
||||
Common examples:
|
||||
|
||||
```text
|
||||
[Alice]
|
||||
[en:Alice]
|
||||
[Alice|seed:42|temperature:0.7]
|
||||
[pause:1s]
|
||||
```
|
||||
|
||||
Useful behavior:
|
||||
|
||||
- Character names are inserted at the caret or wrapped around the current selection
|
||||
- Language and speaker can be combined in one tag
|
||||
- Parameters can be stacked with `|`
|
||||
- Keep pauses separate and before the tag they precede: `[pause:1s] [Alice|temperature:0.7]`
|
||||
- `Format` moves a pause nested with character or parameter parts into that standalone form
|
||||
- Presets can store either quick snippets or reusable speaker setups
|
||||
|
||||
## Inline Tags
|
||||
|
||||
The editor has an engine-aware `Inline Tags` panel.
|
||||
|
||||
Step Audio EditX examples:
|
||||
|
||||
```text
|
||||
<Laughter>
|
||||
<emotion:happy>
|
||||
<style:whisper:2>
|
||||
<restore:1@2>
|
||||
```
|
||||
|
||||
Higgs Audio v3 examples:
|
||||
|
||||
```text
|
||||
<|emotion:amusement|>
|
||||
<|style:whispering|>
|
||||
<|prosody:pause|>
|
||||
```
|
||||
|
||||
CosyVoice3 examples:
|
||||
|
||||
```text
|
||||
<breath>
|
||||
<laughing>that was funny</laughing>
|
||||
```
|
||||
|
||||
OmniVoice examples:
|
||||
|
||||
```text
|
||||
<laughter>
|
||||
<sigh>
|
||||
<question-ei>
|
||||
```
|
||||
|
||||
Use the dedicated inline tag controls in the sidebar when you do not want to type these by hand.
|
||||
|
||||
Important differences:
|
||||
|
||||
- `Step Audio EditX` tags are post-process controls
|
||||
- `Higgs Audio v3`, `CosyVoice3`, and `OmniVoice` tags are native generation controls
|
||||
- `OmniVoice` editor insertion uses suite-default angle-tag aliases and the processor converts them internally to official native tags during generation
|
||||
|
||||
## SRT Editing
|
||||
|
||||
When the text looks like valid SRT, the editor highlights subtitle numbers and timings differently and enables subtitle-specific editing tools.
|
||||
|
||||
Example:
|
||||
|
||||
```srt
|
||||
1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
[Alice] Hello there.
|
||||
|
||||
2
|
||||
00:00:04,500 --> 00:00:08,000
|
||||
[Bob] This is the next subtitle.
|
||||
```
|
||||
|
||||
### Drag Timing Controls
|
||||
|
||||
Timing lines are directly draggable:
|
||||
|
||||
- Drag the left timestamp to change cue start
|
||||
- Drag the right timestamp to change cue end
|
||||
- Drag the arrow to move the whole cue while keeping its duration
|
||||
|
||||
Modifiers:
|
||||
|
||||
- Hold `Shift` while dragging start or end to move the neighboring cue boundary by the same delta and preserve the existing gap
|
||||
- Hold `Alt` while dragging for finer timing adjustment
|
||||
|
||||
### Merge Cues
|
||||
|
||||
Sometimes TTS sounds better when two short subtitles are spoken as one longer chunk.
|
||||
|
||||
Use:
|
||||
|
||||
- `Alt+click` on a cue number to merge that cue with the next cue
|
||||
- `Alt+Shift+click` on a cue number to merge that cue with the previous cue
|
||||
|
||||
Merge behavior:
|
||||
|
||||
- Keeps the first cue start time
|
||||
- Keeps the second cue end time
|
||||
- Combines the text into one subtitle body
|
||||
- Renumbers the remaining cues
|
||||
|
||||
### Split Cues
|
||||
|
||||
Sometimes a subtitle is too long and you want to split it into two timed parts.
|
||||
|
||||
Use:
|
||||
|
||||
- Place the caret inside subtitle text
|
||||
- Press `Ctrl+Shift+Enter`
|
||||
|
||||
Split behavior:
|
||||
|
||||
- Splits the cue at the caret position
|
||||
- Estimates the new timing boundary from the text proportion on each side
|
||||
- Gives a small bias to the left side when the split happens after punctuation
|
||||
- Enforces a minimum duration for both resulting cues
|
||||
- Renumbers following cues
|
||||
|
||||
## Shortcuts
|
||||
|
||||
Core shortcuts:
|
||||
|
||||
- `Ctrl+Z` or `Cmd+Z` = Undo
|
||||
- `Ctrl+Shift+Z` / `Cmd+Shift+Z` or `Ctrl+Y` = Redo
|
||||
- `Alt+Z` = Undo
|
||||
- `Alt+Shift+Z` = Redo
|
||||
- `Ctrl+Shift+Enter` = Split current SRT cue at caret
|
||||
- `Alt+click` on cue number = Merge with next cue
|
||||
- `Alt+Shift+click` on cue number = Merge with previous cue
|
||||
|
||||
Sidebar insertion shortcuts:
|
||||
|
||||
- `Alt+C` = Add character tag
|
||||
- `Alt+L` = Add language tag
|
||||
- `Alt+P` = Add parameter tag
|
||||
- `Alt+1`, `Alt+2`, `Alt+3` = Load preset slot
|
||||
|
||||
## Editing Tips
|
||||
|
||||
- Use merge when subtitles are too short for natural speech flow
|
||||
- Use split when a single subtitle is too dense or needs better pacing
|
||||
- Keep SRT text semantically clean before generation so later timing edits remain predictable
|
||||
- Use presets for repeated speaker or parameter combinations
|
||||
- Use the `Library` tab as a quick reminder, not as the full manual
|
||||
|
||||
## Known Limits
|
||||
|
||||
- SRT merge and split are designed for standard subtitle blocks, not malformed subtitle text
|
||||
- Split timing is estimated from text distribution, so it is a good first pass, not perfect forced alignment
|
||||
- If you make large structural SRT edits, review the final timings before generation
|
||||
@@ -0,0 +1,120 @@
|
||||
# 01 - First Ask The LLM To Research The Official Model
|
||||
|
||||
Do this before implementation.
|
||||
|
||||
The official model implementation is the source of truth. Not this suite, not another ComfyUI node pack, not a random demo script. The LLM must understand what the model actually supports before it writes code.
|
||||
|
||||
## What The User Should Ask
|
||||
|
||||
Copy this to the LLM:
|
||||
|
||||
```text
|
||||
Do not write code yet.
|
||||
|
||||
Research the official implementation for this model/engine:
|
||||
|
||||
[PASTE OFFICIAL REPO, MODEL CARD, PAPER, OR PACKAGE LINK HERE]
|
||||
|
||||
Produce a capability report for TTS Audio Suite. Read the official repo, model card, examples, docs, paper if useful, license, dependency files, and inference scripts.
|
||||
|
||||
The report must answer:
|
||||
|
||||
1. What task does this model actually support?
|
||||
- TTS (in this suite, TTS means both Unified TTS Text and Unified SRT TTS)
|
||||
- Voice Changer / voice conversion
|
||||
- ASR
|
||||
- Audio editing
|
||||
- Other special feature
|
||||
|
||||
2. What native parameters does the official implementation expose?
|
||||
|
||||
3. What features are real native model capabilities?
|
||||
|
||||
4. What features are not supported natively?
|
||||
|
||||
5. What model files are required, and where do they come from?
|
||||
|
||||
6. What voice/reference input mode does it require?
|
||||
- Reference audio only
|
||||
- Reference audio plus exact transcript/reference text
|
||||
- Either reference-audio-only or reference-audio-plus-text modes
|
||||
- No reference audio
|
||||
|
||||
7. What sample rate, channel layout, and audio tensor/file format does it expect?
|
||||
|
||||
8. What dependencies and versions does it require?
|
||||
|
||||
9. Does it conflict with common packages already used by TTS Audio Suite?
|
||||
|
||||
10. What license applies to the code and weights?
|
||||
|
||||
11. Does it need only unified nodes, or does it need a special node?
|
||||
|
||||
12. Is this a short-form or long-form generation model?
|
||||
- If long-form, what is the native segmentation strategy?
|
||||
- Should chunking be text-length based, duration/minute based, or model-internal?
|
||||
- Which controls should be ignored or overridden (for example, max chars per chunk) to match native behavior?
|
||||
|
||||
Do not start implementation until this report is complete.
|
||||
```
|
||||
|
||||
## What The Capability Report Must Decide
|
||||
|
||||
The report must decide the actual scope:
|
||||
|
||||
- TTS integration, which includes both Unified TTS Text and Unified SRT TTS.
|
||||
- Voice Changer / voice conversion.
|
||||
- ASR / transcription.
|
||||
- Special node for a native unique feature.
|
||||
- No integration, if the model is not suitable.
|
||||
|
||||
Do not ask whether SRT is natively supported by the model. For TTS engines, SRT support is implemented by TTS Audio Suite by generating subtitle text segments and assembling timing with the suite timing systems.
|
||||
|
||||
If the model has a native feature that does not fit existing unified nodes, say so. Do not hide it inside a generic node if it needs a special UI.
|
||||
|
||||
Also decide the reference archetype early:
|
||||
|
||||
- Use Qwen3-TTS as default reference for standard TTS/SRT architecture.
|
||||
- Use VibeVoice as reference when the target engine is long-form and needs duration-aware/minute-aware segmentation behavior.
|
||||
|
||||
## Native Features Come First
|
||||
|
||||
Implement native official features first.
|
||||
|
||||
Good native examples:
|
||||
|
||||
- Official speaker/reference audio support.
|
||||
- Official language parameter.
|
||||
- Official emotion/style controls.
|
||||
- Official voice conversion mode.
|
||||
- Official ASR timestamps.
|
||||
|
||||
Bad unsupported examples unless discussed first:
|
||||
|
||||
- Fake speed control by time-stretching output.
|
||||
- Fake emotion by post-processing audio.
|
||||
- Fake language dropdown when the model is English-only.
|
||||
- Fake SRT timing controls that bypass the suite timing engine or imply unsupported model behavior.
|
||||
|
||||
Extra suite-added features can be useful, but they need maintainer discussion first.
|
||||
|
||||
## Minimum Research Checklist
|
||||
|
||||
The LLM should inspect:
|
||||
|
||||
- Official README.
|
||||
- Model card.
|
||||
- Inference examples.
|
||||
- CLI or demo scripts.
|
||||
- Python package metadata.
|
||||
- Requirements files.
|
||||
- Download instructions.
|
||||
- License files.
|
||||
- Hugging Face or ModelScope file layout.
|
||||
- Open issues if the model has known broken behavior.
|
||||
|
||||
If any of these cannot be found, the report must say that clearly instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Next: [02 - Check Existing ComfyUI Implementations](02_CHECK_EXISTING_COMFYUI_IMPLEMENTATIONS.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
# 02 - Check Existing ComfyUI Implementations
|
||||
|
||||
After official model research, check whether someone already made a ComfyUI implementation.
|
||||
|
||||
This is useful, but dangerous if followed blindly.
|
||||
|
||||
## What The User Should Ask
|
||||
|
||||
Copy this to the LLM:
|
||||
|
||||
```text
|
||||
Search for existing ComfyUI implementations of this model.
|
||||
|
||||
If you find any, clone them into:
|
||||
|
||||
IgnoredForGitHubDocs/For_reference/[ENGINE_NAME]/
|
||||
|
||||
Study them as references only. Do not treat them as the source of truth. Compare every feature and parameter against the official model implementation.
|
||||
|
||||
Produce notes that answer:
|
||||
|
||||
1. Which ComfyUI implementations exist?
|
||||
2. What files or patterns are useful as reference?
|
||||
3. What dependencies or install problems do they reveal?
|
||||
4. What UI controls do they expose?
|
||||
5. Which controls are native official model features?
|
||||
6. Which controls are artificial additions?
|
||||
7. Which parts should not be copied into TTS Audio Suite?
|
||||
```
|
||||
|
||||
## Where To Put References
|
||||
|
||||
Clone references here:
|
||||
|
||||
```text
|
||||
IgnoredForGitHubDocs/For_reference/[ENGINE_NAME]/
|
||||
```
|
||||
|
||||
Do not place reference repos in the main package folders. They are study material, not suite code.
|
||||
|
||||
## How To Use Them
|
||||
|
||||
Use existing ComfyUI implementations to learn:
|
||||
|
||||
- How the model loads in a ComfyUI environment.
|
||||
- Which dependencies break easily.
|
||||
- Which model files are expected.
|
||||
- Which audio formats are accepted.
|
||||
- Which UI patterns users may expect.
|
||||
- Which edge cases others already hit.
|
||||
|
||||
But always compare back to the official implementation.
|
||||
|
||||
## What Not To Copy Blindly
|
||||
|
||||
Do not copy:
|
||||
|
||||
- Fake parameters that are not official model features.
|
||||
- Random post-processing presented as native model behavior.
|
||||
- Bad model download paths.
|
||||
- Silent downloads into cache directories.
|
||||
- Large engine logic stuffed directly into ComfyUI node UI code.
|
||||
- Dependency pins that break other engines without analysis.
|
||||
- UI controls that imply unsupported capabilities.
|
||||
|
||||
## Maintainer Discussion Required
|
||||
|
||||
If a ComfyUI implementation adds something useful but non-native, discuss it first in an issue or PR.
|
||||
|
||||
Examples:
|
||||
|
||||
- Artificial speed control.
|
||||
- Post-processing emotion controls.
|
||||
- Non-native denoise controls.
|
||||
- Any special workflow that changes model behavior beyond official inference.
|
||||
|
||||
The default implementation should expose native model capabilities first.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [01 - Research The Official Model](01_FIRST_ASK_THE_LLM_TO_RESEARCH_THE_OFFICIAL_MODEL.md) | Next: [03 - Decide Engine Scope](03_DECIDE_ENGINE_SCOPE.md)
|
||||
@@ -0,0 +1,103 @@
|
||||
# 03 - Decide Engine Scope
|
||||
|
||||
Before coding, decide exactly what the integration should include.
|
||||
|
||||
This prevents half-integrations where basic TTS works but the required SRT path, cache, tags, VRAM, or docs are missing.
|
||||
|
||||
## What The User Should Ask
|
||||
|
||||
Copy this to the LLM:
|
||||
|
||||
```text
|
||||
Using the official capability report and ComfyUI reference notes, decide the TTS Audio Suite scope for this engine.
|
||||
|
||||
Do not write code yet.
|
||||
|
||||
Produce a short scope document with:
|
||||
|
||||
1. Which node family should be supported?
|
||||
- If this is TTS, include both Unified TTS Text and Unified SRT TTS.
|
||||
2. Which native features will be implemented now?
|
||||
3. Which native features will be intentionally skipped, and why?
|
||||
4. Which non-native suite-added features are proposed, if any?
|
||||
5. Which existing engine should be used as implementation reference?
|
||||
6. What files/modules will likely need changes?
|
||||
7. Does this engine stay in the Main Environment, or does it need runtime isolation?
|
||||
8. What manual tests will prove the integration works?
|
||||
```
|
||||
|
||||
## Runtime Decision Order
|
||||
|
||||
Test the official package with `--no-deps` in the main T5 environment first. Prefer a small compatibility patch when practical; use the existing shared T4 SHRED runtime only if T5 is genuinely incompatible. Never create another environment or download/reinstall Torch or Transformers without explicit maintainer approval.
|
||||
|
||||
## Node Types To Choose From
|
||||
|
||||
Decide whether the engine needs:
|
||||
|
||||
- TTS integration: Unified TTS Text plus Unified SRT TTS. These are a mandatory pair for TTS engines.
|
||||
- Voice Changer / voice conversion.
|
||||
- ASR Transcribe.
|
||||
- A special feature node.
|
||||
- No integration.
|
||||
|
||||
Do not add a special node just because it looks interesting. Add one only when the official model has a capability that does not fit the unified nodes.
|
||||
|
||||
## Native Features vs Suite Enhancements
|
||||
|
||||
Separate these clearly.
|
||||
|
||||
Native model features:
|
||||
|
||||
- Supported by official model code or docs.
|
||||
- Should be implemented first when practical.
|
||||
- Can be exposed in the engine node UI.
|
||||
|
||||
Suite-added enhancements:
|
||||
|
||||
- Added by TTS Audio Suite around the model.
|
||||
- May be useful, but must be explicit.
|
||||
- Need maintainer discussion if they change behavior or user expectations.
|
||||
|
||||
## Written Scope Required
|
||||
|
||||
The LLM should write a scope before implementation.
|
||||
|
||||
A good scope looks like this:
|
||||
|
||||
```text
|
||||
Engine scope:
|
||||
- Supports TTS: yes, with both Unified TTS Text and Unified SRT TTS
|
||||
- SRT approach: generate each subtitle independently and assemble timing with AudioAssemblyEngine
|
||||
- Supports Voice Changer: no, official model has no voice conversion mode
|
||||
- Supports ASR: no
|
||||
- Needs special node: yes, official model has native voice design from text description
|
||||
|
||||
Native parameters to expose:
|
||||
- seed
|
||||
- temperature
|
||||
- top_p
|
||||
- language
|
||||
|
||||
Do not expose:
|
||||
- speed, because official model has no speed parameter
|
||||
|
||||
Primary reference:
|
||||
- Qwen3-TTS for TTS/SRT architecture
|
||||
|
||||
Manual tests:
|
||||
- TTS Text basic generation
|
||||
- TTS Text with character tags
|
||||
- TTS Text with pause tags
|
||||
- SRT generation with interrupt test
|
||||
- Clear VRAM then regenerate
|
||||
|
||||
Runtime plan:
|
||||
- Main Environment only
|
||||
- or Shared Runtime / Dedicated Runtime if dependency conflicts are expected
|
||||
```
|
||||
|
||||
If the scope is vague, do not code yet.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [02 - Check Existing ComfyUI Implementations](02_CHECK_EXISTING_COMFYUI_IMPLEMENTATIONS.md) | Next: [04 - Implementation Order For LLM](04_IMPLEMENTATION_ORDER_FOR_LLM.md)
|
||||
@@ -0,0 +1,186 @@
|
||||
# 04 - Implementation Order For LLM
|
||||
|
||||
Use this order when the capability report and scope are complete.
|
||||
|
||||
Do not start from random files. Follow the architecture.
|
||||
|
||||
## What The User Should Ask
|
||||
|
||||
Copy this to the LLM:
|
||||
|
||||
```text
|
||||
Implement this engine using the TTS Audio Suite architecture.
|
||||
|
||||
Before editing files, read:
|
||||
|
||||
- PROJECT_INDEX.md
|
||||
- docs/New Engines Guides/NEW_ENGINE_IMPLEMENTATION_GUIDE.md
|
||||
- docs/New Engines Guides/fails_to_avoid_TTS_Engine_Implementation.md
|
||||
|
||||
Use Qwen3-TTS as the primary modern reference for full TTS/SRT architecture and suite parity.
|
||||
Use Step Audio EditX as a secondary reference for wrapper/model lifecycle and special post-processing patterns.
|
||||
If another existing engine is closer to this model, inspect that too.
|
||||
|
||||
Do not copy any reference blindly. The official model implementation decides native capabilities.
|
||||
```
|
||||
|
||||
## Reference Engines
|
||||
|
||||
Primary reference:
|
||||
|
||||
- `engines/adapters/qwen3_tts_adapter.py`
|
||||
- `nodes/qwen3_tts/qwen3_tts_processor.py`
|
||||
- `nodes/qwen3_tts/qwen3_tts_srt_processor.py`
|
||||
- `nodes/engines/qwen3_tts_engine_node.py`
|
||||
|
||||
Use Qwen3-TTS for:
|
||||
|
||||
- Adapter/processor separation.
|
||||
- Character handling.
|
||||
- Pause tags.
|
||||
- Segment parameters.
|
||||
- Cache integration.
|
||||
- SRT processor structure that reuses the main TTS processor for generation.
|
||||
- Interrupt handling.
|
||||
- Progress feedback.
|
||||
- Console observability parity, such as input-text echo and useful live generation progress.
|
||||
- Unified model loading.
|
||||
|
||||
Secondary reference:
|
||||
|
||||
- `engines/step_audio_editx/`
|
||||
- `engines/adapters/step_audio_editx_adapter.py`
|
||||
- `nodes/step_audio_editx/`
|
||||
- `nodes/engines/step_audio_editx_engine_node.py`
|
||||
|
||||
Use Step Audio EditX for:
|
||||
|
||||
- Wrapper/model lifecycle patterns.
|
||||
- Special post-processing behavior.
|
||||
- Engine-specific extra node patterns.
|
||||
- Model loading edge cases.
|
||||
|
||||
Long-form reference:
|
||||
|
||||
- `engines/vibevoice_engine/`
|
||||
- `engines/adapters/vibevoice_adapter.py`
|
||||
- `nodes/vibevoice/vibevoice_processor.py`
|
||||
|
||||
Use VibeVoice when the target engine is long-form and needs duration-aware segmentation behavior:
|
||||
|
||||
- Prefer model-appropriate duration/minute-based segmentation when required.
|
||||
- Do not force short-form `max_chars_per_chunk` behavior if it conflicts with native long-form generation strategy.
|
||||
- If the suite already provides chunk splitting / chunk combination controls in the unified node, reuse those. Do not add engine-local chunk-silence or chunk-combination controls unless the official model has a clearly separate native long-form mechanism.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Follow this order:
|
||||
|
||||
1. Confirm scope.
|
||||
2. Verify model download layout.
|
||||
3. Add or update downloader/model layout logic.
|
||||
4. Build the engine wrapper around official inference.
|
||||
5. Register the unified model factory.
|
||||
6. Build the adapter.
|
||||
7. Build the main processor.
|
||||
8. For TTS engines, build the SRT processor too. The SRT processor should call the main TTS processor/generation path for each subtitle instead of duplicating generation logic. Build VC, ASR, or special processors only if scoped.
|
||||
9. Add the engine configuration node UI.
|
||||
10. Wire node and adapter registration.
|
||||
11. Add segment parameter support if the engine has native parameters.
|
||||
12. Add generated audio cache where applicable.
|
||||
13. Add interrupt checks in long loops.
|
||||
14. Add progress feedback for long generation.
|
||||
15. Update docs/YAML metadata.
|
||||
16. Run automated and live ComfyUI validation. Use FL-MCP-assisted validation when it is installed and connected; otherwise perform the same checks manually. Follow `tests/FL_MCP_VALIDATION.md`.
|
||||
17. Run the required parity checklist.
|
||||
|
||||
## Live ComfyUI Validation Rule
|
||||
|
||||
Passing imports or pytest is not enough for a new engine. Validate it in the canonical Windows ComfyUI installation after implementation.
|
||||
|
||||
If [ComfyUI_FL-MCP](https://github.com/filliptm/ComfyUI_FL-MCP) is available, the LLM should use it to inspect and operate the live ComfyUI instance. Treat it as an optional test driver, not a project dependency and not a substitute for the existing test suite.
|
||||
|
||||
The LLM should:
|
||||
|
||||
- After changing Python code, restart the canonical Windows ComfyUI process before testing. An already-running process still has the old modules loaded.
|
||||
- Use PowerShell to identify the process listening on port `8188`, verify its command line belongs to the canonical ComfyUI `main.py`, stop only that process, and relaunch it with the canonical Windows Python.
|
||||
- Wait for `http://127.0.0.1:8188/system_stats` to respond before using FL-MCP.
|
||||
- Refresh the existing ComfyUI browser tab after restart and confirm the FL-MCP browser bridge has reconnected before calling canvas-only tools.
|
||||
- Confirm the engine node and the relevant unified node are registered.
|
||||
- Load or construct the smallest useful workflow.
|
||||
- Inspect workflow JSON for the expected node types, links, and widget values.
|
||||
- Queue the workflow and wait for completion.
|
||||
- Inspect execution history and report the full actionable error if execution fails.
|
||||
- Confirm the expected audio output artifact exists.
|
||||
- Capture a canvas screenshot for UI and broken-node inspection.
|
||||
- Exercise TTS Text and SRT for every TTS engine, plus any other scoped capability.
|
||||
- Record what was actually tested, what was skipped, and why.
|
||||
|
||||
Screenshots prove only visible workflow state. They do not prove that generation succeeded or that audio is correct. Execution history and output artifacts are required evidence, and the user must still judge subjective audio quality.
|
||||
|
||||
Do not install FL-MCP, alter its safety settings, or enable destructive tools unless the user authorizes it. If FL-MCP is unavailable, report that fact and follow the manual fallback in `tests/FL_MCP_VALIDATION.md`.
|
||||
|
||||
Repeat the edit, restart, reconnect, and validation cycle after every implementation fix that changes imported Python code. Frontend-only changes may require a hard browser refresh as well. Do not claim that a fix was tested against a ComfyUI process started before the fix was written.
|
||||
|
||||
## Architecture Rule
|
||||
|
||||
Unified nodes should stay thin.
|
||||
|
||||
Reference engines are examples only. Every engine must have dedicated processors and adapters; share only engine-neutral utilities.
|
||||
|
||||
Do not put hundreds of lines of engine-specific orchestration into:
|
||||
|
||||
- `nodes/unified/tts_text_node.py`
|
||||
- `nodes/unified/tts_srt_node.py`
|
||||
- `nodes/unified/voice_changer_node.py`
|
||||
- `nodes/unified/asr_transcribe_node.py`
|
||||
|
||||
Engine-specific orchestration belongs in processors and adapters.
|
||||
|
||||
For TTS engines, SRT support is mandatory. It is the suite's subtitle-driven TTS flow, not a native capability the model has to expose.
|
||||
|
||||
## SRT Reuses TTS Generation
|
||||
|
||||
Follow the Qwen3-TTS pattern: the SRT processor is the timing/orchestration layer, not a second implementation of the engine.
|
||||
|
||||
The SRT processor should:
|
||||
|
||||
- Import or instantiate the engine's main TTS processor.
|
||||
- Parse SRT subtitles and loop through subtitle entries.
|
||||
- Build the subtitle-specific voice mapping and seed/parameter context.
|
||||
- Call the main TTS processor, usually `processor.process_text(...)`, for each subtitle's text.
|
||||
- Reuse the same adapter, cache keys, character handling, pause tags, parameter switching, progress hooks, and model lifecycle used by normal TTS Text generation.
|
||||
- Assemble subtitle audio with the suite timing systems, such as `AudioAssemblyEngine`, after generation.
|
||||
|
||||
The SRT processor should not:
|
||||
|
||||
- Call the raw model directly through a separate generation path.
|
||||
- Reimplement character switching, pause tags, parameter switching, cache, or reference-audio handling differently from the TTS processor.
|
||||
- Add separate fake SRT-only parameters that do not exist in the normal TTS path or the suite timing layer.
|
||||
|
||||
## Chunking Rule
|
||||
|
||||
If an engine needs long-text chunking, prefer the suite chunking and shared chunk combiner unless the official model exposes a real native long-form/chunking system that is materially different.
|
||||
|
||||
Do not:
|
||||
|
||||
- Add duplicate engine-node controls for silence between chunks, chunk combination method, or similar suite-owned chunk-assembly behavior.
|
||||
- Bypass the shared chunk combiner with a custom engine-local join path unless there is a documented official reason.
|
||||
|
||||
## Registration Areas To Check
|
||||
|
||||
The LLM should inspect and update the relevant registration files for the chosen scope:
|
||||
|
||||
- `nodes.py`
|
||||
- `engines/adapters/__init__.py`
|
||||
- `utils/models/unified_model_interface.py`
|
||||
- `utils/models/engine_registry.py`
|
||||
- `utils/text/segment_parameters.py`
|
||||
- `docs/Dev reports/tts_audio_suite_engines.yaml`
|
||||
- `docs/Dev reports/tts_audio_suite_aux_models.yaml` if helper models are added
|
||||
|
||||
Only update files that are actually needed for the scoped integration.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [03 - Decide Engine Scope](03_DECIDE_ENGINE_SCOPE.md) | Next: [05 - Required Parity Checklist](05_REQUIRED_PARITY_CHECKLIST.md)
|
||||
@@ -0,0 +1,120 @@
|
||||
# 05 - Required Parity Checklist
|
||||
|
||||
Paste this checklist to the LLM before review.
|
||||
|
||||
If the LLM cannot answer these questions, the implementation is not ready.
|
||||
|
||||
## Copy-Paste Checklist
|
||||
|
||||
```text
|
||||
Review the new engine implementation against this TTS Audio Suite parity checklist.
|
||||
|
||||
Architecture:
|
||||
- Did you use a processor instead of putting engine logic in unified nodes?
|
||||
- Are unified nodes only doing thin routing/delegation?
|
||||
- Is engine-specific orchestration in processors/adapters?
|
||||
|
||||
Model loading and lifecycle:
|
||||
- Did you use unified_model_interface.load_model() in all model-loading paths?
|
||||
- Does Clear VRAM / unload / reload work?
|
||||
- Did you avoid __del__ destructors that auto-unload models after generation?
|
||||
- Are model variants unloaded or reused correctly instead of accumulating VRAM?
|
||||
|
||||
Downloads and dependencies:
|
||||
- Did you verify the real Hugging Face / ModelScope / official file layout?
|
||||
- Do models download into organized ComfyUI/models/TTS/ folders?
|
||||
- Did you prevent silent downloads into random cache folders?
|
||||
- Did you document dependency conflicts or install.py changes?
|
||||
- Did you test `--no-deps` on Main/T5 and simple patching before falling back to the shared T4 runtime?
|
||||
- Did you avoid creating another environment or downloading/reinstalling Torch or Transformers without explicit maintainer approval?
|
||||
- If runtime isolation is needed, did you document the default mode and the reason in YAML/README?
|
||||
|
||||
Audio format:
|
||||
- Did you verify the model native sample rate?
|
||||
- Does output use valid ComfyUI audio dict/tensor shape?
|
||||
- Did you resample only when needed?
|
||||
- Did you avoid pitch/speed bugs from wrong sample-rate metadata?
|
||||
|
||||
Generated audio cache:
|
||||
- Did you add generated audio cache where applicable?
|
||||
- Does the cache key include text, voice/reference audio identity, model variant, seed, and all behavior-affecting params?
|
||||
- Did you test generating the same input twice and confirm cache behavior?
|
||||
|
||||
Text features:
|
||||
- Did you wire character tags if this node supports text generation?
|
||||
- Did you handle narrator fallback?
|
||||
- Did you wire pause tags?
|
||||
- Did you wire parameter switching for real native parameters?
|
||||
- Did you avoid adding fake unsupported parameters?
|
||||
- Did you remove misleading controls like language selection if unsupported?
|
||||
- If the engine supports language control, did you normalize the user-facing language UI/logs honestly and map them back to official backend codes without breaking segment/alias language switching?
|
||||
- Did you reuse the suite chunking/chunk-combination controls instead of inventing duplicate engine-local chunk UI or chunk-silence parameters?
|
||||
|
||||
SRT features:
|
||||
- If this is a TTS engine, did you implement the SRT path?
|
||||
- Did you add interrupt checks in long SRT loops?
|
||||
- Does SRT use the shared timing/assembly/reporting systems correctly, using the real existing APIs instead of guessed helper names?
|
||||
- Did you avoid unnecessary timing-mode restrictions?
|
||||
- Does character switching work in SRT if text character tags are supported?
|
||||
|
||||
UX:
|
||||
- Did you add progress feedback for long generation?
|
||||
- Did you preserve useful console observability parity instead of making the engine run silently (for example input-text echo and live it/s/progress)?
|
||||
- Are error messages clear when required inputs are missing?
|
||||
- Are tooltips honest about limitations, license, VRAM, and expected text length?
|
||||
|
||||
Docs and metadata:
|
||||
- Did you update YAML-backed engine metadata?
|
||||
- Did you regenerate docs/tables if metadata changed?
|
||||
- Did you document model paths and download sources?
|
||||
- Did you add examples or notes for special native features?
|
||||
- If the engine needs a secondary environment, does the engine table clearly show that?
|
||||
|
||||
Manual tests:
|
||||
- Did TTS Text work?
|
||||
- Did SRT TTS work for every TTS engine?
|
||||
- Did Voice Changer work if scoped?
|
||||
- Did ASR work if scoped?
|
||||
- Did character tags work?
|
||||
- Did pause tags work?
|
||||
- Did parameter switching work?
|
||||
- Did Clear VRAM then regenerate work, and did unload actually tear down runtime/cache state instead of only moving weights to CPU?
|
||||
- Did interrupt/cancel work in long generation?
|
||||
|
||||
Live ComfyUI integration evidence:
|
||||
- Was validation run in the canonical Windows ComfyUI environment?
|
||||
- Was ComfyUI restarted after the final Python changes, and can you show that the tested process started after those edits?
|
||||
- After restart, was the browser refreshed and the FL-MCP browser bridge confirmed connected before canvas checks?
|
||||
- If FL-MCP was available, did you follow tests/FL_MCP_VALIDATION.md and identify which MCP checks were run?
|
||||
- If FL-MCP was unavailable, did you perform and document the equivalent manual checks instead of claiming MCP validation?
|
||||
- Are the engine node and relevant unified nodes present without import or registration errors?
|
||||
- Does the saved workflow JSON contain the expected node types, links, and widget values?
|
||||
- Does the workflow load without missing/broken node state?
|
||||
- Was the workflow queued, and does execution history show successful completion or the full actionable error?
|
||||
- Does each successful run produce the expected audio output artifact?
|
||||
- Was a canvas screenshot captured for UI inspection without treating the screenshot as execution proof?
|
||||
- Did you record tested, failed, and skipped cases, including the reason for every skip?
|
||||
- Did a human assess subjective audio quality separately?
|
||||
```
|
||||
|
||||
## Important Failures This Prevents
|
||||
|
||||
This checklist exists because working audio is not enough.
|
||||
|
||||
Common failures:
|
||||
|
||||
- Engine works once, then fails after ComfyUI unloads models.
|
||||
- SRT generation cannot be cancelled.
|
||||
- Cache is missing, so repeated generations waste time and VRAM.
|
||||
- UI exposes fake language/speed/emotion controls.
|
||||
- Reference audio uses the wrong sample rate and changes pitch.
|
||||
- Character tags work in TTS Text but not SRT.
|
||||
- Pause tags break because segments are handled incorrectly.
|
||||
- Model files download to a hidden cache instead of `ComfyUI/models/TTS/`.
|
||||
- Unified node gets filled with engine-specific code.
|
||||
|
||||
If any item fails, fix it before PR review.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [04 - Implementation Order For LLM](04_IMPLEMENTATION_ORDER_FOR_LLM.md) | Next: [06 - User Prompts To Copy Paste](06_USER_PROMPTS_TO_COPY_PASTE.md)
|
||||
@@ -0,0 +1,162 @@
|
||||
# 06 - User Prompts To Copy Paste
|
||||
|
||||
These prompts are for a user guiding an LLM.
|
||||
|
||||
Use them in order. Do not skip the research prompts.
|
||||
|
||||
## Prompt 1 - Official Model Research
|
||||
|
||||
```text
|
||||
Do not write code yet.
|
||||
|
||||
I want to add this model/engine to TTS Audio Suite:
|
||||
|
||||
[PASTE OFFICIAL LINK]
|
||||
|
||||
Read the official repo, model card, docs, examples, license, dependency files, model download instructions, and inference scripts.
|
||||
|
||||
Produce a capability report for TTS Audio Suite. It must explain:
|
||||
|
||||
- What task the model supports: TTS, Voice Changer, ASR, audio editing, or special feature. If it supports TTS, TTS Audio Suite requires both Unified TTS Text and Unified SRT TTS.
|
||||
- Native official parameters.
|
||||
- Native official features.
|
||||
- Unsupported features that should not be exposed as UI controls.
|
||||
- Required model files and expected folder layout.
|
||||
- Sample rate and audio format.
|
||||
- Dependencies and likely conflicts.
|
||||
- License restrictions.
|
||||
- Whether this needs a special node beyond unified nodes.
|
||||
|
||||
Do not implement anything until this report is complete.
|
||||
```
|
||||
|
||||
## Prompt 2 - Existing ComfyUI Implementations
|
||||
|
||||
```text
|
||||
Search for existing ComfyUI implementations of this model.
|
||||
|
||||
If you find any, clone them into:
|
||||
|
||||
IgnoredForGitHubDocs/For_reference/[ENGINE_NAME]/
|
||||
|
||||
Study them only as references. Compare every feature against the official implementation.
|
||||
|
||||
Produce notes explaining:
|
||||
|
||||
- Which implementations exist.
|
||||
- Which files/patterns are useful.
|
||||
- Which dependencies or install issues they reveal.
|
||||
- Which UI controls are native model features.
|
||||
- Which UI controls are artificial or should not be copied.
|
||||
- What should be discussed with the maintainer before adding.
|
||||
|
||||
Do not treat third-party ComfyUI code as the source of truth.
|
||||
```
|
||||
|
||||
## Prompt 3 - Decide Scope
|
||||
|
||||
```text
|
||||
Using the official capability report and ComfyUI reference notes, decide the integration scope.
|
||||
|
||||
Do not write code yet.
|
||||
|
||||
Create a scope summary with:
|
||||
|
||||
- Whether this is a TTS integration. If yes, include both Unified TTS Text and Unified SRT TTS.
|
||||
- Whether to support Voice Changer.
|
||||
- Whether to support ASR.
|
||||
- Whether a special node is needed.
|
||||
- Native parameters to expose.
|
||||
- Native features to implement now.
|
||||
- Native features intentionally skipped, with reasons.
|
||||
- Non-native suite-added features proposed, if any.
|
||||
- Existing engine references to inspect.
|
||||
- Manual tests required.
|
||||
```
|
||||
|
||||
## Prompt 4 - Implementation Plan
|
||||
|
||||
```text
|
||||
Create an implementation plan for this engine in TTS Audio Suite.
|
||||
|
||||
Read first:
|
||||
|
||||
- PROJECT_INDEX.md
|
||||
- docs/New Engines Guides/NEW_ENGINE_IMPLEMENTATION_GUIDE.md
|
||||
- docs/New Engines Guides/fails_to_avoid_TTS_Engine_Implementation.md
|
||||
|
||||
Use Qwen3-TTS as the primary modern reference for full TTS/SRT architecture and suite parity.
|
||||
Use Step Audio EditX as a secondary reference for wrapper/model lifecycle and special post-processing patterns.
|
||||
|
||||
Do not copy references blindly. The official implementation decides capabilities.
|
||||
|
||||
The plan must include files/modules to change, model download approach, model lifecycle approach, processor/adapter design, tag support, cache support, SRT support for TTS engines, VC/ASR support if scoped, docs updates, and manual tests.
|
||||
```
|
||||
|
||||
## Prompt 5 - Implement First Phase
|
||||
|
||||
```text
|
||||
Implement the first phase only.
|
||||
|
||||
Focus on:
|
||||
|
||||
- Model download/layout.
|
||||
- Engine wrapper.
|
||||
- Unified model factory registration.
|
||||
- Adapter skeleton with real generation path.
|
||||
- Engine node UI for native official parameters only.
|
||||
|
||||
Do not add fake parameters. Do not implement special non-native features unless they were explicitly approved.
|
||||
|
||||
After editing, summarize changed files and what still needs implementation.
|
||||
```
|
||||
|
||||
## Prompt 6 - Review For Suite Parity
|
||||
|
||||
```text
|
||||
Review this implementation against:
|
||||
|
||||
- docs/New Engines Guides/05_REQUIRED_PARITY_CHECKLIST.md
|
||||
- docs/New Engines Guides/fails_to_avoid_TTS_Engine_Implementation.md
|
||||
|
||||
Be strict. Find missing architecture, cache, VRAM, interrupt, audio shape, sample rate, tag, SRT, docs, and fake-parameter issues.
|
||||
|
||||
Return findings ordered by severity with file references.
|
||||
```
|
||||
|
||||
## Prompt 7 - Check Fake Or Non-Native Parameters
|
||||
|
||||
```text
|
||||
Compare the engine UI and adapter parameters against the official model implementation.
|
||||
|
||||
List every exposed parameter and mark it as:
|
||||
|
||||
- Native official model parameter.
|
||||
- Suite infrastructure parameter.
|
||||
- Non-native enhancement.
|
||||
- Unsupported/fake and should be removed.
|
||||
|
||||
If any non-native enhancement exists, explain whether it was approved by the maintainer.
|
||||
```
|
||||
|
||||
## Prompt 8 - Prepare PR Summary
|
||||
|
||||
```text
|
||||
Prepare a PR summary for this engine integration.
|
||||
|
||||
Include:
|
||||
|
||||
- Official model capability summary.
|
||||
- Existing ComfyUI references studied.
|
||||
- Supported node types.
|
||||
- Native features implemented.
|
||||
- Native features skipped, with reasons.
|
||||
- Any non-native suite-added features, with approval note.
|
||||
- Model download paths.
|
||||
- Manual ComfyUI test results.
|
||||
- Known limitations.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [05 - Required Parity Checklist](05_REQUIRED_PARITY_CHECKLIST.md) | Next: [07 - PR Review Checklist](07_PR_REVIEW_CHECKLIST.md)
|
||||
@@ -0,0 +1,142 @@
|
||||
# 07 - PR Review Checklist
|
||||
|
||||
Use this before merging a new engine PR.
|
||||
|
||||
A PR is not ready just because it generates audio.
|
||||
|
||||
## Required PR Evidence
|
||||
|
||||
The PR author or LLM should provide:
|
||||
|
||||
- Official model capability report.
|
||||
- Existing ComfyUI reference notes, if any.
|
||||
- Selected engine scope.
|
||||
- Native parameters implemented.
|
||||
- Native features intentionally skipped, with reasons.
|
||||
- Non-native suite-added features, if any, with maintainer approval note.
|
||||
- Model download path and file layout.
|
||||
- Manual ComfyUI test results.
|
||||
- Known limitations.
|
||||
|
||||
If this evidence is missing, ask for it before deep review.
|
||||
|
||||
## Architecture Review
|
||||
|
||||
Check:
|
||||
|
||||
- Unified nodes are thin.
|
||||
- Engine-specific orchestration lives in processors/adapters.
|
||||
- TTS Text processor exists for every TTS engine.
|
||||
- SRT processor exists for every TTS engine. Missing SRT support requires an explicit maintainer-approved exception.
|
||||
- VC or ASR processors follow existing suite patterns when scoped.
|
||||
- Similar existing engines were used as references, not blindly copied.
|
||||
|
||||
Primary reference for modern full TTS/SRT integration:
|
||||
|
||||
- Qwen3-TTS.
|
||||
|
||||
Secondary reference for wrapper/lifecycle and special post-processing:
|
||||
|
||||
- Step Audio EditX.
|
||||
|
||||
## Model Lifecycle Review
|
||||
|
||||
Check:
|
||||
|
||||
- All model loading paths use `unified_model_interface.load_model()` unless there is a documented exception.
|
||||
- ComfyUI unload/reload/Clear VRAM works.
|
||||
- No `__del__` destructor unloads the model after generation.
|
||||
- Device movement is handled safely.
|
||||
- Quantized models are not moved with unsupported `.to()` calls.
|
||||
- Multiple model variants do not accumulate in VRAM unnecessarily.
|
||||
|
||||
## Downloads And Dependencies Review
|
||||
|
||||
Check:
|
||||
|
||||
- Official model file layout was verified.
|
||||
- Downloads go to organized `ComfyUI/models/TTS/` paths.
|
||||
- No hidden or automatic downloads to random cache directories.
|
||||
- `install.py` or requirements changes are justified.
|
||||
- Dependency pins do not break existing engines.
|
||||
- License restrictions are documented.
|
||||
|
||||
## Feature Review
|
||||
|
||||
Check:
|
||||
|
||||
- UI exposes native model parameters only, plus clearly documented suite infrastructure options.
|
||||
- Unsupported controls are not present.
|
||||
- Language controls are only present when official model supports language control.
|
||||
- Character tags work if text generation is supported.
|
||||
- Narrator fallback works.
|
||||
- Pause tags work.
|
||||
- Segment parameter switching works for real parameters.
|
||||
- Inline edit/post-processing behavior respects segment boundaries if used.
|
||||
- Special nodes are justified by native model capability.
|
||||
|
||||
## SRT Review
|
||||
|
||||
For every TTS engine, check the SRT path:
|
||||
|
||||
- Interrupt/cancel works in long loops.
|
||||
- Timing modes use the shared timing/assembly systems correctly.
|
||||
- There are no unnecessary restrictions on timing modes.
|
||||
- Character switching works in SRT if character tags are supported.
|
||||
- Pause tags and generated segment durations do not corrupt timing.
|
||||
- Timing report/generation info is consistent with other engines.
|
||||
|
||||
## Cache And Progress Review
|
||||
|
||||
Check:
|
||||
|
||||
- Generated audio cache exists where applicable.
|
||||
- Cache key includes all behavior-affecting parameters.
|
||||
- Reference audio identity is included when relevant.
|
||||
- Progress bars or progress messages exist for long generation.
|
||||
- Interrupt checks are present inside long generation loops, not only before the loop.
|
||||
|
||||
## Audio Review
|
||||
|
||||
Check:
|
||||
|
||||
- Output tensor shape is valid for ComfyUI.
|
||||
- Sample rate is correct and documented.
|
||||
- Reference audio is resampled to the native rate where required.
|
||||
- Audio is not accidentally squeezed from 3D to invalid 2D output.
|
||||
- Pitch/speed are not changed by wrong sample-rate metadata.
|
||||
|
||||
## Documentation Review
|
||||
|
||||
Check:
|
||||
|
||||
- YAML-backed engine metadata is updated if engine docs changed.
|
||||
- Generated docs/tables are regenerated when required.
|
||||
- Model download sources are documented.
|
||||
- Model layout docs are updated if needed.
|
||||
- README or user docs mention important limitations.
|
||||
- Tooltips are honest about license, VRAM, speed, and model limits.
|
||||
|
||||
## Manual Test Matrix
|
||||
|
||||
At minimum, test scoped features:
|
||||
|
||||
- Basic TTS Text generation.
|
||||
- TTS Text with character tags.
|
||||
- TTS Text with pause tags.
|
||||
- TTS Text with parameter switching.
|
||||
- For TTS engines, SRT generation.
|
||||
- For TTS engines, SRT interrupt/cancel.
|
||||
- Voice Changer if supported.
|
||||
- ASR if supported.
|
||||
- Clear VRAM / unload models, then generate again.
|
||||
- Repeat identical generation to check cache behavior.
|
||||
- Missing input error paths.
|
||||
|
||||
## Final Merge Rule
|
||||
|
||||
Do not merge until the implementation passes the required parity checklist or the remaining gaps are explicitly accepted by the maintainer.
|
||||
|
||||
---
|
||||
|
||||
Navigation: [Guide Hub](README.md) | Previous: [06 - User Prompts To Copy Paste](06_USER_PROMPTS_TO_COPY_PASTE.md)
|
||||
+14
-11
@@ -308,11 +308,13 @@ from utils.downloads.unified_downloader import UnifiedDownloader
|
||||
|
||||
**File:** `engines/adapters/[engine_name]_adapter.py`
|
||||
|
||||
#### Step 4: Create Engine Configuration Node
|
||||
|
||||
**File:** `nodes/engines/[engine_name]_engine_node.py`
|
||||
|
||||
### Phase 2: Unified Systems Integration
|
||||
#### Step 4: Create Engine Configuration Node
|
||||
|
||||
**File:** `nodes/engines/[engine_name]_engine_node.py`
|
||||
|
||||
**Model dropdown rule:** Always keep canonical/downloadable model choices visible and add detected installations as separate `local:ModelName` choices; selecting a canonical choice should reuse its organized local installation when available, not replace or hide either choice.
|
||||
|
||||
### Phase 2: Unified Systems Integration
|
||||
|
||||
#### Step 5: Integrate with Unified Model Loading
|
||||
|
||||
@@ -462,11 +464,12 @@ Also to test, requirements and dependencies need to be added.
|
||||
- [ ] Character switching works with `[CharacterName] text`
|
||||
- [ ] Language switching works (if applicable)
|
||||
- [ ] Pause tags work with `[pause:1.5s]`
|
||||
- [ ] Caching works (same input = cached output)
|
||||
- [ ] Model auto-download works
|
||||
- [ ] VRAM management works (model unloads)
|
||||
- [ ] Different parameter combinations work
|
||||
- [ ] **Interrupt handling works** - User can stop SRT generation and it stops within ~1 segment
|
||||
- [ ] Caching works (same input = cached output)
|
||||
- [ ] Model auto-download works
|
||||
- [ ] VRAM management works (model unloads)
|
||||
- [ ] Different parameter combinations work
|
||||
- [ ] Engine prints a standard `Settings:` summary with the active generation/load parameters
|
||||
- [ ] **Interrupt handling works** - User can stop SRT generation and it stops within ~1 segment
|
||||
|
||||
### Phase 4: SRT Implementation
|
||||
|
||||
@@ -645,4 +648,4 @@ Update the engines comparison table.
|
||||
- Model management logic
|
||||
- Audio format conversion utilities
|
||||
|
||||
---
|
||||
---
|
||||
@@ -0,0 +1,79 @@
|
||||
# So You Want To Add A New Engine?
|
||||
|
||||
This folder is the start-here guide for adding a new engine to TTS Audio Suite with help from an LLM.
|
||||
|
||||
The goal is simple: do not let the LLM jump straight into coding. A new engine is not ready just because it can generate one audio clip. It must fit the suite architecture, model loading, cache, tags, SRT timing, docs, and ComfyUI lifecycle.
|
||||
|
||||
## Start Here
|
||||
|
||||
If you want support for a new TTS engine, Voice Changer, ASR, or special audio model, follow these guides in order.
|
||||
|
||||
For this suite, TTS support means both **Unified TTS Text** and **Unified SRT TTS**. SRT is our own text-to-subtitle generation flow around a TTS engine, not an optional native model feature.
|
||||
|
||||
1. [First Ask The LLM To Research The Official Model](01_FIRST_ASK_THE_LLM_TO_RESEARCH_THE_OFFICIAL_MODEL.md)
|
||||
2. [Check Existing ComfyUI Implementations](02_CHECK_EXISTING_COMFYUI_IMPLEMENTATIONS.md)
|
||||
3. [Decide Engine Scope](03_DECIDE_ENGINE_SCOPE.md)
|
||||
4. [Implementation Order For LLM](04_IMPLEMENTATION_ORDER_FOR_LLM.md)
|
||||
5. [Required Parity Checklist](05_REQUIRED_PARITY_CHECKLIST.md)
|
||||
6. [User Prompts To Copy Paste](06_USER_PROMPTS_TO_COPY_PASTE.md)
|
||||
7. [PR Review Checklist](07_PR_REVIEW_CHECKLIST.md)
|
||||
|
||||
Keep these technical references nearby:
|
||||
|
||||
- [Deep technical implementation guide](NEW_ENGINE_IMPLEMENTATION_GUIDE.md)
|
||||
- [Known traps and failures](fails_to_avoid_TTS_Engine_Implementation.md)
|
||||
|
||||
## The Rule
|
||||
|
||||
The official model implementation is the source of truth.
|
||||
|
||||
Existing ComfyUI implementations are useful references, but they are not authorities. They may add fake or artificial features that the official model does not support. Do not copy those by default.
|
||||
|
||||
Example: if another implementation adds a `speed` parameter by stretching audio after generation, that is not a native model feature. It may still be useful, but it must be discussed with the maintainer first in an issue or PR.
|
||||
|
||||
## What To Tell Your LLM First
|
||||
|
||||
Copy this before asking for implementation:
|
||||
|
||||
```text
|
||||
Do not write code yet.
|
||||
|
||||
I want to add a new engine to TTS Audio Suite. First, read these files:
|
||||
|
||||
- PROJECT_INDEX.md
|
||||
- docs/New Engines Guides/README.md
|
||||
- docs/New Engines Guides/01_FIRST_ASK_THE_LLM_TO_RESEARCH_THE_OFFICIAL_MODEL.md
|
||||
- docs/New Engines Guides/02_CHECK_EXISTING_COMFYUI_IMPLEMENTATIONS.md
|
||||
- docs/New Engines Guides/03_DECIDE_ENGINE_SCOPE.md
|
||||
- docs/New Engines Guides/NEW_ENGINE_IMPLEMENTATION_GUIDE.md
|
||||
- docs/New Engines Guides/fails_to_avoid_TTS_Engine_Implementation.md
|
||||
|
||||
Then research the official model implementation and produce a capability report. Do not start coding until the report explains the native model features, required nodes, model files, sample rate, dependencies, license, and supported parameters.
|
||||
```
|
||||
|
||||
## Reference Engines
|
||||
|
||||
Use these references after the official model research is done:
|
||||
|
||||
- **Qwen3-TTS**: primary modern reference for full TTS/SRT architecture and suite parity.
|
||||
- **Step Audio EditX**: secondary reference for wrapper/model lifecycle patterns and special post-processing cases.
|
||||
|
||||
Do not copy either blindly. The new engine must be scoped from the official implementation first.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
A new engine is not done until the answer to these questions is yes:
|
||||
|
||||
- Does it use the suite architecture instead of stuffing engine logic into unified nodes?
|
||||
- Does it load models through the unified model interface?
|
||||
- Does model unload/reload/Clear VRAM work?
|
||||
- Did someone explicitly decide whether this engine belongs in the Main Environment or needs runtime isolation?
|
||||
- Does it support generated audio cache where applicable?
|
||||
- Does it handle ComfyUI audio shape and sample rate correctly?
|
||||
- Does it wire character tags, pause tags, and parameter switching where applicable?
|
||||
- If it is a TTS engine, does its SRT path support interrupt handling and correct timing modes?
|
||||
- Does the UI avoid fake unsupported controls?
|
||||
- Are docs and engine metadata updated?
|
||||
- Did someone test it manually in ComfyUI?
|
||||
|
||||
If the answer is no, the PR is not ready.
|
||||
+28
@@ -56,11 +56,22 @@
|
||||
- **Cache key pattern**: `audio_cache.generate_cache_key(engine_type, text=..., audio_component=..., **all_params)`
|
||||
- **Duration calculation**: Update `_calculate_duration()` with engine sample rate (e.g., 24000 for Step Audio EditX, F5-TTS)
|
||||
|
||||
### Engine Settings Logging
|
||||
- **Missing standard print**: New engines should print the usual `Settings:` summary once per run with the active generation/load parameters so validation can confirm what actually executed
|
||||
- **Resolved prompt preview**: Reuse `utils.voice.character_logging.format_resolved_character_block()` for boxed text previews so logs show the voice that will actually speak, not only the parser alias
|
||||
|
||||
### Model Lifecycle - __del__ Destructor
|
||||
- **CRITICAL**: Remove `__del__` from engine classes - causes automatic unload after generation ends (when object goes out of scope)
|
||||
- **Pattern**: F5-TTS and ChatterBox don't have `__del__`, only IndexTTS and StepAudio did (wrong)
|
||||
- **Correct behavior**: Models stay in VRAM for reuse; only unload on explicit Clear VRAM button, engine switch, or parameter change
|
||||
|
||||
### Clear VRAM - Runtime Teardown vs CPU Offload
|
||||
- **Wrong**: Assuming every engine is safe to unload with only `.to('cpu')`
|
||||
- **Symptom**: Clear VRAM frees only part of VRAM; most memory disappears only after closing ComfyUI
|
||||
- **Cause**: Runtime-heavy engines can keep compiled graphs, prompt caches, workspaces, or runtime objects alive after CPU offload
|
||||
- **Correct**: Add an engine-specific unload handler that clears internal caches, drops runtime/model references, then runs `gc.collect()`, `torch.cuda.empty_cache()`, and `torch.cuda.ipc_collect()` when available
|
||||
- **Prevention**: If the engine uses warmup, `torch.compile`, streaming caches, or a custom runtime object, explicitly test Clear VRAM and reload instead of assuming generic offload parity
|
||||
|
||||
### Generation Info Reports - Modular Pattern
|
||||
- **CRITICAL - Consistency required**: ALL engines must use `ChunkCombiner` + `ChunkTimingHelper` for generation reports
|
||||
- **Correct pattern** (from ChatterBox 23-Lang, F5-TTS):
|
||||
@@ -74,10 +85,27 @@
|
||||
### Segment Parameter Registration
|
||||
- **Forgot to add**: New engines to `PARAMETER_ENGINES` in `utils/text/segment_parameters.py` for `[seed:X]` tag support
|
||||
|
||||
### Language UI Normalization
|
||||
- **Wrong**: Exposing raw backend language codes in the engine node just because the official runtime accepts them
|
||||
- **Symptom**: New engine UI looks inconsistent with the suite, logs show backend codes instead of readable names, and character-tag / alias languages do not map cleanly to node config values
|
||||
- **Correct**: Keep user-facing language controls normalized to honest display names when the suite already does that, then map them back to the official backend codes in the adapter/runtime layer
|
||||
- **Prevention**: Reuse or add a small engine-local language helper so node UI, processor logs, segment language overrides, and adapter normalization all agree on the same mapping
|
||||
|
||||
### SRT Processor Import Pattern
|
||||
- **Wrong**: Using `from nodes.xxx import` - fails with `'nodes' is not a package`
|
||||
- **Correct**: Use `importlib.util.spec_from_file_location()` for cross-module imports in dynamically loaded processors
|
||||
|
||||
### SRT Shared API Guessing
|
||||
- **Wrong**: Inventing shared helper names like `AudioAssemblyEngine.assemble(...)`, `TimingReportGenerator`, or `AdjustedSRTGenerator`
|
||||
- **Correct**: Open a working SRT processor first and match the exact API that already exists in `utils/timing/assembly.py`, `utils/timing/engine.py`, and `utils/timing/reporting.py`
|
||||
- **Real pattern**:
|
||||
- `stretch_to_fit` → `TimedAudioAssembler.assemble_timed_audio(...)`
|
||||
- `pad_with_silence` → `AudioAssemblyEngine(...).assemble_with_overlaps(...)`
|
||||
- `concatenate` → `TimingEngine.calculate_concatenation_adjustments(...)` + `assemble_concatenation(...)`
|
||||
- `smart_natural` → `TimingEngine.calculate_smart_timing_adjustments(...)` + `assemble_smart_natural(...)`
|
||||
- reporting / adjusted SRT → `SRTReportGenerator().generate_timing_report(...)` and `generate_adjusted_srt_string(...)`
|
||||
- **Prevention**: Never assume shared helper names. Grep existing SRT processors and copy the real call pattern exactly.
|
||||
|
||||
### ComfyUI Audio Dict Format
|
||||
- **Wrong**: Passing `audio_tensor` directly to functions expecting raw tensor
|
||||
- **Correct**: Extract waveform with `audio_tensor.get('waveform')` - ComfyUI audio is `{"waveform": tensor, "sample_rate": int}`
|
||||
@@ -0,0 +1,73 @@
|
||||
# OmniVoice Native Tags Guide
|
||||
|
||||
OmniVoice has its own official inline square-bracket control tokens upstream, but **this suite does not expose `[]` for OmniVoice tags**.
|
||||
|
||||
Inside TTS Audio Suite, you can write the suite-default angle-tag aliases in text:
|
||||
|
||||
```text
|
||||
<laughter>
|
||||
<sigh>
|
||||
<question-ei>
|
||||
```
|
||||
|
||||
The OmniVoice processor converts those aliases internally to the official OmniVoice native form before generation:
|
||||
|
||||
```text
|
||||
[laughter]
|
||||
[sigh]
|
||||
[question-ei]
|
||||
```
|
||||
|
||||
Do **not** type OmniVoice non-verbal tags in `[]` form in suite text. In this suite, `[]` belongs to character, language, parameter, and pause syntax.
|
||||
|
||||
## Supported Non-Verbal Tags
|
||||
|
||||
Official OmniVoice non-verbal meanings exposed by this suite through `<>` aliases:
|
||||
|
||||
```text
|
||||
laughter
|
||||
sigh
|
||||
confirmation-en
|
||||
question-en
|
||||
question-ah
|
||||
question-oh
|
||||
question-ei
|
||||
question-yi
|
||||
surprise-ah
|
||||
surprise-oh
|
||||
surprise-wa
|
||||
surprise-yo
|
||||
dissatisfaction-hnn
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
[Alice] <laughter> You really got me there.
|
||||
[Bob] <sigh> Fine, let's try again.
|
||||
[Narrator] <question-ei> Really?
|
||||
[Narrator] <surprise-oh> I didn't expect that.
|
||||
```
|
||||
|
||||
## Important Behavior
|
||||
|
||||
- OmniVoice uses native generation tags here, not Step Audio EditX inline post-processing.
|
||||
- User-facing suite syntax stays in `<>` form for OmniVoice non-verbal tags.
|
||||
- `[]` is reserved for suite structural syntax like `[Alice]`, `[en:Alice]`, `[pause:1s]`, and parameter switching.
|
||||
- Do not rely on Step-style `<Laughter:2>` or `<emotion:happy>` semantics in the OmniVoice text path.
|
||||
- If you want Step Audio EditX as a second pass on OmniVoice output, use the separate `🎨 Audio Editor` node manually after generation.
|
||||
|
||||
## Multiline Tag Editor
|
||||
|
||||
The `🏷️ Multiline TTS Tag Editor` has a dedicated `OmniVoice` mode in the `Inline Tags` panel.
|
||||
|
||||
- The editor inserts suite-default angle-tag aliases like `<laughter>`
|
||||
- The processor converts them internally to official OmniVoice square tags during generation
|
||||
- The editor does not encourage raw OmniVoice `[]` input because `[]` is suite syntax
|
||||
|
||||
## Sources
|
||||
|
||||
This behavior follows the official OmniVoice documentation for non-verbal symbols and pronunciation control:
|
||||
|
||||
- [OmniVoice GitHub README](https://github.com/k2-fsa/OmniVoice)
|
||||
- [OmniVoice Hugging Face model card](https://huggingface.co/k2-fsa/OmniVoice)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The TTS Audio Suite supports **per-segment parameter control** through inline tags. This allows you to override TTS generation parameters (seed, temperature, cfg, speed, etc.) on a per-segment basis, providing fine-grained control over individual audio segments without modifying node-level defaults.
|
||||
The TTS Audio Suite supports **per-segment parameter control** through bracket tags like `[Alice|seed:42|temp:0.5]`. This allows you to override TTS generation parameters (seed, temperature, cfg, speed, etc.) on a per-segment basis, providing fine-grained control over individual audio segments without modifying node-level defaults.
|
||||
|
||||
Parameters are applied **only to the current segment** and automatically revert to node defaults for subsequent segments.
|
||||
|
||||
@@ -75,13 +75,45 @@ Parameters are applied **only to the current segment** and automatically revert
|
||||
| `seed` | — | int | 0-4294967295 | Random seed for reproducible generation |
|
||||
| `temperature` | `temp` | float | 0.1-2.0 | Randomness/creativity (lower=more deterministic) |
|
||||
|
||||
### Engine-Specific Parameters
|
||||
|
||||
#### ChatterBox & ChatterBox Official 23-Lang
|
||||
| Parameter | Alias | Type | Range | Description |
|
||||
|-----------|-------|------|-------|-------------|
|
||||
| `cfg` | `cfg_weight` | float | 0.0-1.0 | CFG weight for speaker guidance |
|
||||
| `exaggeration` | `exag` | float | 0.0-2.0 | Voice emotion exaggeration |
|
||||
### Engine-Specific Parameters
|
||||
|
||||
#### MOSS-TTS
|
||||
| Parameter | Alias | Type | Range | Description |
|
||||
|-----------|-------|------|-------|-------------|
|
||||
| `top_p` | `topp` | float | 0.0-1.0 | Nucleus sampling probability |
|
||||
| `top_k` | `topk` | int | 1-100 | Top-k sampling |
|
||||
| `audio_temperature` | `audio_temp` | float | 0.1-2.5 | MOSS audio sampling temperature |
|
||||
| `audio_top_p` | — | float | 0.0-1.0 | MOSS audio nucleus sampling |
|
||||
| `audio_top_k` | — | int | 1-200 | MOSS audio top-k sampling |
|
||||
| `repetition_penalty` | `rep_penalty` | float | 0.5-3.0 | MOSS repetition penalty |
|
||||
| `audio_repetition_penalty` | `audio_rep_penalty` | float | 0.5-3.0 | MOSS audio repetition penalty |
|
||||
| `duration_tokens` | `tokens` | int | 0-8192 | MOSS target output length hint in audio tokens |
|
||||
| `max_new_tokens` | — | int | 64-16384 | Hard generation token limit |
|
||||
| `n_vq_for_inference` | `n_vq` | int | 0-32 | Local 1.7B only: RVQ layers/codebooks for inference |
|
||||
| `instruction` | — | string | text | Whole-segment speaking instruction |
|
||||
| `quality` | — | string | text | Whole-segment recording/presentation quality hint |
|
||||
| `sound_event` | — | string | text | Whole-segment sound event hint |
|
||||
| `ambient_sound` | — | string | text | Whole-segment ambient sound hint |
|
||||
|
||||
#### MOSS Sound Effects
|
||||
|
||||
MOSS-SoundEffect v1 uses the applicable MOSS-TTS parameters above. Both sound-effect engines also support a duration override:
|
||||
|
||||
| Parameter | Alias | Engines | Type | Range | Description |
|
||||
|-----------|-------|---------|------|-------|-------------|
|
||||
| `duration_seconds` | `seconds` | v1, v2 | float | 0.5-300 | Duration of the sound segment |
|
||||
| `inference_steps` | `steps` | v2 | int | 1-150 | Diffusion steps |
|
||||
| `cfg` | — | v2 | float | 0.0-20.0 | Prompt guidance strength |
|
||||
| `sigma_shift` | — | v2 | float | 0.0-10.0 | Flow-matching schedule shift |
|
||||
| `negative_prompt` | `negative`, `neg` | v2 | string | text | Sounds or qualities to discourage |
|
||||
|
||||
See the [Sound Effects Guide](SOUND_EFFECTS_GUIDE.md) for pauses, crossfades, long-duration chunking, and complete examples.
|
||||
|
||||
#### ChatterBox & ChatterBox Official 23-Lang
|
||||
| Parameter | Alias | Type | Range | Description |
|
||||
|-----------|-------|------|-------|-------------|
|
||||
| `cfg` | `cfg_weight` | float | 0.0-1.0 | CFG weight for speaker guidance |
|
||||
| `exaggeration` | `exag` | float | 0.0-2.0 | Voice emotion exaggeration |
|
||||
|
||||
#### F5-TTS
|
||||
| Parameter | Alias | Type | Range | Description |
|
||||
@@ -103,27 +135,111 @@ Parameters are applied **only to the current segment** and automatically revert
|
||||
| `top_p` | `topp` | float | 0.0-1.0 | Nucleus sampling probability |
|
||||
| `inference_steps` | `steps` | int | 1-100 | Number of inference steps |
|
||||
|
||||
#### IndexTTS-2
|
||||
#### IndexTTS 2 / 2.5
|
||||
| Parameter | Alias | Type | Range | Description |
|
||||
|-----------|-------|------|-------|-------------|
|
||||
| `cfg` | — | float | 0.0-20.0 | CFG strength |
|
||||
| `top_p` | `topp` | float | 0.0-1.0 | Nucleus sampling probability |
|
||||
| `top_k` | `topk` | int | 1-100 | Top-k sampling |
|
||||
| `emotion_alpha` | — | float | 0.0-1.0 | Emotion control strength |
|
||||
| `emotion_alpha` | — | float | 0.0-1.0 | Shared audio/vector/text emotion intensity |
|
||||
| `duration_factor` | `dur_factor` | float | 0.5-2.0 | Official IndexTTS-2.5 internal feature-duration scaling; 0.5 shorter/faster, 2.0 longer/slower |
|
||||
|
||||
`duration_factor` is a 2.5-only upstream parameter. It uses nearest-neighbor scaling inside the semantic length regulator after speech codes are generated. It is not natural prosody planning, exact-seconds targeting, waveform playback-speed control, or an inference-performance control. IndexTTS continues to use the suite's ordinary final timing modes in TTS SRT.
|
||||
|
||||
Switching the engine node between IndexTTS-2 and IndexTTS-2.5 invalidates the cached Text/SRT processor and model identity. `language`, `duration_factor`, and `text_normalization` also participate in the generated-audio cache identity, so changing a supported 2.5 generation parameter cannot return audio produced with the previous setting.
|
||||
|
||||
IndexTTS-2 also supports inline emotion controls. Named unsigned values replace
|
||||
that dimension; explicitly signed values adjust the connected vector:
|
||||
|
||||
```text
|
||||
[sad:0.7|calm:0.2] Absolute values for this segment.
|
||||
[sad:+0.3|calm:-0.2] Adjust the connected vector for this segment.
|
||||
```
|
||||
|
||||
All eight values can be supplied in the official order `happy, angry, sad,
|
||||
afraid, disgusted, melancholic, surprised, calm`:
|
||||
|
||||
```text
|
||||
[vector:0,0,0.7,0,0,0.4,0,0.2] Absolute replacement.
|
||||
[vector:+0,+0,+0.3,+0,+0,+0,+0,-0.2] Relative adjustment.
|
||||
```
|
||||
|
||||
Full relative vectors require an explicit sign on every value. Results are
|
||||
clamped to IndexTTS-2's supported range and revert to the connected vector at
|
||||
the next segment.
|
||||
|
||||
Text emotion can use a saved preset or quoted text. `{seg}` is expanded with
|
||||
the current segment before QwenEmotion analysis:
|
||||
|
||||
```text
|
||||
[emotion:restrained_anger] A saved preset.
|
||||
[emotion:"Quiet grief masking frustration"] A direct description.
|
||||
[emotion:"Infer nervous anticipation from this line: {seg}"] Dynamic analysis.
|
||||
```
|
||||
|
||||
Click a numeric emotion tag in the TTS Tag Editor to open a contextual radar
|
||||
directly beside that tag. The editor also creates and manages text
|
||||
presets in `models/TTS/IndexTTS/emotion_presets.json`.
|
||||
|
||||
IndexTTS-2 has separate engine inputs for these sources: connect vector or text
|
||||
emotion to `emotion_control` and audio emotion references to `emotion_audio`.
|
||||
Both may be connected simultaneously; IndexTTS-2 blends them during emotion
|
||||
conditioning. Inline vector/text controls override the connected vector/text
|
||||
values for their segment, while `[Character:emotion_ref]` selects a
|
||||
segment-local audio reference that can still blend with vector/text emotion.
|
||||
When an emotion control is inserted with the caret inside a character/audio tag,
|
||||
the editor appends or updates it as another pipe parameter, for example
|
||||
`[Bob:br_ivan_raiva3|sad:+0.25]`.
|
||||
|
||||
The tag editor's quick-swap palette is engine-aware: parameter choices are
|
||||
filtered to the selected inline engine, while each engine's supported native
|
||||
emotion/style/prosody/sound tags use their own replacement choices. Named
|
||||
emotion presets can be swapped from the text; quoted `[emotion:"..."]` text is
|
||||
intentionally left as direct editable content rather than treated as a preset.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Advanced Usage
|
||||
|
||||
### Combining with Character Switching
|
||||
|
||||
Parameters work seamlessly with character and language tags:
|
||||
### Combining with Character Switching
|
||||
|
||||
Parameters work seamlessly with character and language tags:
|
||||
|
||||
```
|
||||
[de:Alice|seed:42] German Alice with specific seed.
|
||||
[fr:Bob|temperature:0.7] French Bob with higher temperature.
|
||||
[seed:123|de:Alice] Order-independent syntax.
|
||||
```
|
||||
[seed:123|de:Alice] Order-independent syntax.
|
||||
```
|
||||
|
||||
### MOSS Whole-Segment Prompt Fields
|
||||
|
||||
MOSS official prompt fields are better treated as per-segment parameters, not inline `<>` tags:
|
||||
|
||||
```
|
||||
[Alice|instruction:Speak softly and calmly] Hello there.
|
||||
[Bob|quality:Telephone call quality|ambient_sound:Office room tone] Can you hear me?
|
||||
[Alice|sound_event:Laughter] That's actually funny.
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
- These are whole-segment controls
|
||||
- They are not positional inline effects
|
||||
- Keep `<>` free for true inline post-processing tags like Step Audio EditX
|
||||
|
||||
### DramaBox Prompt Templates
|
||||
|
||||
`prompt_template` (alias `template`) applies a `{seg}` wrapper and enables
|
||||
templating for that segment automatically:
|
||||
|
||||
```text
|
||||
[Narrator|template:A woman whispers, "{seg}"] This line is whispered.
|
||||
[Narrator] This line returns to the DramaBox engine-node settings.
|
||||
```
|
||||
|
||||
The template should include `{seg}`. If it is omitted, DramaBox warns once and
|
||||
appends `"{seg}"` automatically. A separate inline enable parameter is not
|
||||
required.
|
||||
|
||||
### Per-Segment Fine-Tuning in SRT
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# 🌩️ Sound Effects Guide
|
||||
|
||||
The `🌩️ Sound Effects` node generates non-speech audio from a written description. It works with any connected engine that advertises sound-effect support.
|
||||
|
||||
## Engines
|
||||
|
||||
| Model | Engine node | Notes |
|
||||
|---|---|---|
|
||||
| MOSS-SoundEffect v1 | `⚙️ MOSS-TTS Engine` | Autoregressive MOSS 8B model |
|
||||
| MOSS-SoundEffect v2 | `⚙️ MOSS SoundEffect v2 Engine` | 48 kHz diffusion model; up to 30 seconds per native generation |
|
||||
|
||||
Connecting a speech-only engine stops with a user-facing compatibility error.
|
||||
|
||||
## Basic workflow
|
||||
|
||||
1. Select a sound-effect model on its engine node.
|
||||
2. Connect the engine to `🌩️ Sound Effects`.
|
||||
3. Describe the sound rather than words to be spoken.
|
||||
4. Choose the duration and seed, then queue the workflow.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Heavy rain hitting a metal rooftop, distant rolling thunder, occasional wind gusts.
|
||||
```
|
||||
|
||||
Descriptions generally work best when they state the source, environment, distance, texture, and progression of the sound.
|
||||
|
||||
## Timeline segments
|
||||
|
||||
Separate descriptions with parameter tags to generate multiple segments and concatenate them:
|
||||
|
||||
```text
|
||||
[seconds:4|seed:42] Bright application startup chime. [seconds:2|cfg:5] Low, dark shutdown tone.
|
||||
```
|
||||
|
||||
`duration_seconds` is the default duration for every generated segment. `[seconds:X]` overrides it for the following segment.
|
||||
|
||||
Newlines also create segments, as they do in TTS. They are optional because a parameter tag can start another segment on the same line.
|
||||
|
||||
## Pauses
|
||||
|
||||
Use a standalone pause tag to insert exact silence:
|
||||
|
||||
```text
|
||||
[seconds:4] Startup chime. [pause:1.2] [seconds:2] Shutdown tone.
|
||||
```
|
||||
|
||||
The aliases `[wait:X]` and `[stop:X]` are also accepted. Durations may use seconds or milliseconds:
|
||||
|
||||
```text
|
||||
[wait:500ms]
|
||||
```
|
||||
|
||||
Keep pauses separate from parameter tags:
|
||||
|
||||
```text
|
||||
[pause:1.2] [cfg:7.5] Thunder crack.
|
||||
```
|
||||
|
||||
Do not combine them as `[pause:1.2|cfg:7.5]`.
|
||||
|
||||
## Crossfade and long sounds
|
||||
|
||||
`crossfade_seconds` overlaps adjacent generated segments to soften their join. Set it to `0` for a hard join.
|
||||
|
||||
A pause creates an exact silent boundary, so crossfade is not applied across that pause.
|
||||
|
||||
MOSS-SoundEffect v2 has a native 30-second generation limit. Longer requested segments are generated as overlapping chunks, joined with the selected crossfade, and trimmed to the requested duration.
|
||||
|
||||
## Per-segment parameters
|
||||
|
||||
Common parameters:
|
||||
|
||||
| Tag | Engines | Purpose |
|
||||
|---|---|---|
|
||||
| `seed` | v1, v2 | Generated variation |
|
||||
| `seconds` / `duration_seconds` | v1, v2 | Segment duration |
|
||||
| `temperature` | v1 | Sampling randomness |
|
||||
| `top_p`, `top_k` | v1 | Sampling limits |
|
||||
| `repetition_penalty` | v1 | Discourage repetition |
|
||||
| `duration_tokens` | v1 | Native duration-token control |
|
||||
| `max_new_tokens` | v1 | Generation token limit |
|
||||
| `steps` / `inference_steps` | v2 | Diffusion steps |
|
||||
| `cfg` | v2 | Prompt guidance strength |
|
||||
| `sigma_shift` | v2 | Flow-matching schedule shift |
|
||||
| `negative_prompt`, `negative`, `neg` | v2 | Sounds or qualities to discourage |
|
||||
|
||||
Example using a negative prompt:
|
||||
|
||||
```text
|
||||
[seconds:8|cfg:5|neg:speech, music] Dense forest ambience with insects and distant birds.
|
||||
```
|
||||
|
||||
Unsupported parameters are ignored with a warning rather than being sent blindly to the engine.
|
||||
|
||||
## Seed and cache behavior
|
||||
|
||||
- `seed: 0` chooses a random seed.
|
||||
- Reusing a positive seed with identical settings makes the request repeatable.
|
||||
- With audio caching enabled, an identical segment and configuration can reuse its generated audio.
|
||||
- Changing the description, seed, duration, engine configuration, or inline parameters invalidates that cached result.
|
||||
|
||||
## MOSS-SoundEffect v2 first-run compilation
|
||||
|
||||
The v2 DiT uses `torch.compile`. Its first generation may spend several minutes compiling before progress begins. Compatible compilation artifacts are cached and can be reused across later ComfyUI sessions.
|
||||
|
||||
This compile delay is separate from model downloading and normal generation time.
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Per-Segment Parameter Switching](PARAMETER_SWITCHING_GUIDE.md)
|
||||
- [Multiline TTS Tag Editor](MULTILINE_TTS_TAG_EDITOR_GUIDE.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Text Chunking Guide
|
||||
|
||||
TTS Audio Suite supports long text generation through the shared unified chunking system.
|
||||
|
||||
## What It Does
|
||||
|
||||
- splits long text into smaller generation chunks
|
||||
- preserves sentence boundaries when possible
|
||||
- can fall back to comma-based splitting for very long sentences
|
||||
- combines generated audio using the suite chunk combiner
|
||||
|
||||
## Main Controls
|
||||
|
||||
- `enable_chunking`
|
||||
- turns chunking on or off
|
||||
- `max_chars_per_chunk`
|
||||
- character limit per chunk
|
||||
- `chunk_combination_method`
|
||||
- `auto`
|
||||
- `concatenate`
|
||||
- `silence_padding`
|
||||
- `crossfade`
|
||||
- `silence_between_chunks_ms`
|
||||
- silence inserted when the selected combination method uses silence padding
|
||||
|
||||
## Auto Combination Behavior
|
||||
|
||||
When `chunk_combination_method=auto`, the suite picks a join strategy based on text size:
|
||||
|
||||
- longer text tends toward `silence_padding`
|
||||
- medium text tends toward `crossfade`
|
||||
- shorter chunked text tends toward `concatenate`
|
||||
|
||||
The exact choice is made by the shared chunk combiner, not by engine-local custom logic.
|
||||
|
||||
## Important Architecture Rule
|
||||
|
||||
Chunking is a suite feature.
|
||||
|
||||
- new engines should reuse the shared chunking and chunk-combination controls
|
||||
- engines should not invent duplicate local chunk-silence or chunk-join settings unless the official model has a real distinct native long-form system
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Example Workflows](../README.md#-example-workflows)
|
||||
- [Character Switching Guide](./CHARACTER_SWITCHING_GUIDE.md)
|
||||
- [Multiline TTS Tag Editor Guide](./MULTILINE_TTS_TAG_EDITOR_GUIDE.md)
|
||||
@@ -11,6 +11,33 @@ This document tracks updates applied to our bundled IndexTTS-2 code from the ups
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11: IndexTTS-2.5 Version Integration
|
||||
|
||||
**Official sources:** `index-tts/index-tts` commit `b5ea881bec284b72f0b1cc04e0a724ff0c6b93e9`; model snapshot `ba2480d9f7f629eb18f6acaebb357679d9ba88a4`
|
||||
|
||||
### Changes applied
|
||||
|
||||
- Added IndexTTS-2.5 as a selectable version of the existing `index_tts` engine.
|
||||
- Bundled the official 25 Hz semantic codec, multilingual tokenizer, Japanese G2P, and NeMo normalization bridge.
|
||||
- Preserved suite dual-source audio plus vector/text emotion blending.
|
||||
- Added Chinese, English, Japanese, Spanish, and Arabic conditioning.
|
||||
- Added the official 2.5-only `duration_factor`, documented honestly as nearest-neighbor internal semantic-feature scaling rather than natural prosody or exact-duration planning.
|
||||
- Deliberately excluded IndexTTS-2.5 from TTS SRT's native-duration option; the suite-owned exact-seconds extrapolation was removed after source and listening review.
|
||||
- Kept legacy IndexTTS-2 checkpoints, FP16 loading, MaskGCT, workflows, and node identity intact.
|
||||
- Pinned the audited Hugging Face model revision and retained the main Transformers 5 environment.
|
||||
- Added model-aware Text/SRT processor and audio-cache identities so switching 2.0/2.5 or a 2.5 generation parameter cannot reuse stale output.
|
||||
- Documented the suite's manual finding that 2.5 is not a universal cloning-quality upgrade: 2.0 may retain speaker resemblance better under strong different-speaker emotion transfer.
|
||||
|
||||
### Validation status
|
||||
|
||||
- [x] Python compilation
|
||||
- [x] Bundled backend import under `TTS_SUITE_TEST_VENV_PYTHON`
|
||||
- [x] Full checkpoint download and live ComfyUI generation
|
||||
- [x] Manual audio-quality review of the official duration factor and 2.0/2.5 speaker resemblance
|
||||
- [x] Live 2.5 → 2.0 model switching after processor-cache invalidation fix
|
||||
|
||||
---
|
||||
|
||||
## 2025-09-18: Major Update - Cache & Emotion Improvements
|
||||
|
||||
**Reference commit range:** `8336824..64cb31a` (September 11 → September 18, 2025)
|
||||
@@ -207,6 +234,47 @@ This document tracks updates applied to our bundled IndexTTS-2 code from the ups
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-11: Upstream Audit Before Release
|
||||
|
||||
**Upstream repository checked:** `index-tts/index-tts` (`main`)
|
||||
**Upstream head observed:** `b5bd657` (2026-07-08)
|
||||
**Check performed:** 2026-07-11
|
||||
|
||||
### Relevant upstream changes reviewed
|
||||
|
||||
| Commit | Upstream change | Bundled status | Release action |
|
||||
|---|---|---|---|
|
||||
| `843972e` | Coerce QwenEmotion JSON emotion scores to `float` and reject non-numeric values clearly | **Applied** in bundled `clamp_score()` | Run a text-emotion generation with numeric-string classifier output |
|
||||
| `b154a1b` | WebUI text/vector preset save/load management | **Already covered differently** by the ComfyUI-native preset manager and `emotion_presets.json` integration | No direct port needed |
|
||||
| `b5bd657` | Expose `--accel` and `--torch-compile`, add optional acceleration extras and WebUI settings | **Applied selectively**; suite now forwards `use_accel` into the bundled GPT path, while retaining ComfyUI-owned dependency handling | Validate acceleration fallback on compatible and non-accelerated setups |
|
||||
| `7264ce2` | Improve IndexTTS-2 model resource checks and HF cache handling | **Suite-owned downloader differs** and needs a separate comparison if download failures are reported | No blind copy into bundled code |
|
||||
|
||||
### Findings
|
||||
|
||||
- No upstream change was found that invalidates the current eight-emotion vector order,
|
||||
Qwen text-emotion syntax, audio-reference blending, or the suite's inline tag format.
|
||||
- The upstream QwenEmotion string-score fix is directly relevant to the suite's text-emotion
|
||||
path and should be applied before a release.
|
||||
- The upstream acceleration work is not a drop-in replacement because this repository
|
||||
bundles and adapts IndexTTS-2. The suite already contains the acceleration modules, but
|
||||
`utils/models/unified_model_interface.py` should be checked so `use_accel` reaches the
|
||||
bundled `IndexTTS2` constructor.
|
||||
- Upstream WebUI presets are not copied verbatim: the suite's ComfyUI editor has a richer
|
||||
vector/radar, inline-tag, sidebar, and filesystem preset implementation.
|
||||
|
||||
### Release follow-up checklist
|
||||
|
||||
- [x] Apply the upstream `clamp_score()` numeric coercion.
|
||||
- [x] Pass `use_accel` through the unified IndexTTS-2 factory; validate fallback behavior.
|
||||
- [ ] Run a Qwen text-emotion generation using numeric-string classifier output.
|
||||
- [ ] Verify acceleration flags on a compatible CUDA installation and on a setup without
|
||||
optional acceleration dependencies.
|
||||
- [ ] Recheck bundled model-resource validation against the current upstream `check` logic.
|
||||
|
||||
---
|
||||
|
||||
## Next Update Check: 2025-11-20
|
||||
|
||||
**Monitoring:** Watch for commits to `indextts/infer_v2.py` in https://github.com/index-tts/index-tts
|
||||
@@ -222,4 +290,4 @@ git log --oneline 1d5d079..HEAD -- indextts/infer_v2.py
|
||||
- Additional performance optimizations
|
||||
- Bug fixes in new acceleration code
|
||||
- Breaking API changes
|
||||
- Model loading improvements
|
||||
- Model loading improvements
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Qwen3-TTS Optimization Guide
|
||||
|
||||
This document explains how to enable torch.compile optimizations for Qwen3-TTS to achieve ~1.5-2x speedup.
|
||||
|
||||
## Requirements
|
||||
|
||||
**CRITICAL**: torch.compile optimizations require specific versions tested with the reference implementation:
|
||||
|
||||
- **PyTorch 2.10.0+** with CUDA 13.0 support
|
||||
- **triton-windows 3.6.0+** (Windows) or **triton 3.6.0+** (Linux)
|
||||
- **Visual Studio C++ Build Tools** on Windows (a real `cl.exe` toolchain, not just stale PATH entries)
|
||||
- Python 3.12 (recommended)
|
||||
|
||||
**Note**: PyTorch 2.8.x and 2.9.x have compatibility issues with triton on Windows. Only PyTorch 2.10+ is supported.
|
||||
|
||||
### Installation
|
||||
|
||||
#### Windows (Tested Configuration)
|
||||
```bash
|
||||
# Upgrade PyTorch to 2.10 with CUDA 13.0
|
||||
pip install --upgrade torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu130
|
||||
|
||||
# Install triton-windows 3.6.x
|
||||
pip install -U "triton-windows<3.7"
|
||||
```
|
||||
|
||||
Also install **Visual Studio Build Tools** with the C++ toolchain:
|
||||
|
||||
- Workload: `Desktop development with C++`
|
||||
- Or at minimum the MSVC x64/x86 build tools component
|
||||
|
||||
If `cl.exe` is missing, `torch.compile` in the isolated Qwen runtime will fall back to the non-compiled path.
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
python -c "import torch; print(f'PyTorch: {torch.__version__}'); import triton; print(f'Triton: {triton.__version__}')"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
PyTorch: 2.10.0+cu130
|
||||
Triton: 3.6.0.post25
|
||||
```
|
||||
|
||||
#### Linux
|
||||
```bash
|
||||
# Upgrade PyTorch to 2.10 with CUDA 13.0
|
||||
pip install --upgrade torch==2.10.0 torchvision==0.25.0 torchaudio==2.10.0 --index-url https://download.pytorch.org/whl/cu130
|
||||
|
||||
# triton is usually bundled with PyTorch on Linux
|
||||
# Verify version is 3.6.0+
|
||||
python -c "import triton; print(triton.__version__)"
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Main environment**: no special configuration beyond a working compiler toolchain.
|
||||
|
||||
**Shared / Dedicated Runtime isolation**:
|
||||
- TTS Audio Suite now tries to detect Visual Studio automatically and inject a proper MSVC build environment into the isolated worker
|
||||
- This is meant to preserve `torch.compile` parity with old direct Transformers 4 environments
|
||||
- It still depends on the Build Tools actually being installed
|
||||
|
||||
**Note**: The `reduce-overhead` mode (which includes automatic CUDA graphs) may still have issues with cudaMallocAsync. Use `compile_mode="default"` for best compatibility.
|
||||
|
||||
## Usage in Qwen3-TTS Engine Node
|
||||
|
||||
Once the environment is configured:
|
||||
|
||||
1. Enable `use_torch_compile`: **True**
|
||||
2. Keep `use_cuda_graphs`: **False** (not needed - reduce-overhead includes it)
|
||||
3. Set `compile_mode`: **reduce-overhead** (recommended)
|
||||
|
||||
### Settings Explanation
|
||||
|
||||
- **use_torch_compile**: Enables torch.compile optimization (~1.4-3x speedup)
|
||||
- First generation will be slower (compilation time)
|
||||
- Subsequent generations are much faster
|
||||
|
||||
- **use_cuda_graphs**: Manual CUDA graph capture
|
||||
- **Keep FALSE** when using reduce-overhead mode (it already includes automatic CUDA graphs)
|
||||
- Only enable if using `compile_mode=default` and you want manual graph capture
|
||||
|
||||
- **compile_mode**:
|
||||
- `reduce-overhead` (RECOMMENDED): Includes automatic CUDA graphs, fast compilation
|
||||
- `max-autotune`: Longer compilation, marginal gains
|
||||
- `default`: No automatic CUDA graphs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "'KernelNamespace' object has no attribute 'mm'"
|
||||
**Cause**: Incompatible PyTorch and triton-windows versions.
|
||||
|
||||
**Solution**: Upgrade to PyTorch 2.10.0 + triton-windows 3.6.0 (see Installation section).
|
||||
|
||||
### Error: "operator torchvision::nms does not exist"
|
||||
**Cause**: torchvision version doesn't match PyTorch version.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
pip install --upgrade torchvision --index-url https://download.pytorch.org/whl/cu130
|
||||
```
|
||||
|
||||
### Error: "cudaMallocAsync does not support checkPoolLiveAllocations"
|
||||
**Cause**: Using `compile_mode="reduce-overhead"` which enables automatic CUDA graphs.
|
||||
|
||||
**Solution**: Use `compile_mode="default"` instead (recommended for Windows).
|
||||
|
||||
### Error: "Failed to find C compiler. Please specify via CC environment variable."
|
||||
**Cause**: Triton/Inductor could not find a usable Windows C++ toolchain.
|
||||
|
||||
**Solution**:
|
||||
1. Install or repair Visual Studio Build Tools with the C++ workload
|
||||
2. Restart ComfyUI so the new environment is visible
|
||||
3. If using Shared/Dedicated Runtime, let TTS Audio Suite auto-detect the toolchain
|
||||
|
||||
If detection still fails, your machine state is broken, not just the Python package stack.
|
||||
|
||||
### Warning: "TensorFloat32 tensor cores... not enabled"
|
||||
**Info**: This is just a performance hint, not an error. TF32 is automatically enabled by the extension for better performance.
|
||||
|
||||
## Performance Expectations
|
||||
|
||||
**Tested on RTX 4090 with PyTorch 2.10.0 + triton-windows 3.6.0:**
|
||||
|
||||
| Configuration | Speed | Speedup | Notes |
|
||||
|--------------|-------|---------|-------|
|
||||
| **Baseline** (no optimizations) | 5.0 it/s | 1.0x | Standard inference |
|
||||
| **torch.compile mode=default** | 8.5 it/s | 1.7x | ✅ RECOMMENDED for Windows |
|
||||
| **+ manual CUDA graphs** | 8.6 it/s | 1.72x | Negligible gain (~0.1 it/s) |
|
||||
| **mode=reduce-overhead** | FAILS | - | ❌ cudaMallocAsync error on Windows |
|
||||
| **mode=max-autotune** | FAILS | - | ❌ cudaMallocAsync error on Windows |
|
||||
|
||||
### Key Findings
|
||||
|
||||
- **torch.compile alone provides ~1.7x speedup** with mode="default"
|
||||
- **Manual CUDA graphs add almost nothing** on top of torch.compile
|
||||
- **reduce-overhead and max-autotune fail on Windows** (work on Linux)
|
||||
- **First generation is slower** (~30-60s compilation overhead)
|
||||
- **Subsequent generations are fast** (compiled kernels cached)
|
||||
- **Model unloading works correctly** even with CUDA graphs enabled
|
||||
- **Shared Runtime should preserve these gains** if the Windows compiler toolchain is installed and detected correctly
|
||||
|
||||
## Notes
|
||||
|
||||
- The reference implementation (dffdeeq/Qwen3-TTS-streaming) uses the same approach
|
||||
- torch.compile caches compiled kernels, so subsequent ComfyUI sessions start faster
|
||||
- Model unloading works normally - no special handling needed (unlike manual CUDA graphs)
|
||||
@@ -4,7 +4,7 @@
|
||||
try:
|
||||
from .chatterbox_adapter import ChatterBoxEngineAdapter
|
||||
CHATTERBOX_ADAPTER_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
except Exception as e:
|
||||
CHATTERBOX_ADAPTER_AVAILABLE = False
|
||||
# Create dummy class for compatibility
|
||||
class ChatterBoxEngineAdapter:
|
||||
@@ -14,7 +14,7 @@ except ImportError as e:
|
||||
try:
|
||||
from .f5tts_adapter import F5TTSEngineAdapter
|
||||
F5TTS_ADAPTER_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
except Exception as e:
|
||||
F5TTS_ADAPTER_AVAILABLE = False
|
||||
# Create dummy class for compatibility
|
||||
class F5TTSEngineAdapter:
|
||||
@@ -24,14 +24,85 @@ except ImportError as e:
|
||||
try:
|
||||
from .cosyvoice_adapter import CosyVoiceAdapter
|
||||
COSYVOICE_ADAPTER_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
except Exception as e:
|
||||
COSYVOICE_ADAPTER_AVAILABLE = False
|
||||
# Create dummy class for compatibility
|
||||
class CosyVoiceAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"CosyVoice adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .echo_tts_adapter import EchoTTSEngineAdapter
|
||||
ECHO_TTS_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
ECHO_TTS_ADAPTER_AVAILABLE = False
|
||||
class EchoTTSEngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"Echo-TTS adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .dots_tts_adapter import DotsTTSEngineAdapter
|
||||
DOTS_TTS_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
DOTS_TTS_ADAPTER_AVAILABLE = False
|
||||
class DotsTTSEngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"Dots TTS adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .dramabox_adapter import DramaBoxEngineAdapter
|
||||
DRAMABOX_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
DRAMABOX_ADAPTER_AVAILABLE = False
|
||||
class DramaBoxEngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"DramaBox adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .fish_audio_s2_adapter import FishAudioS2Adapter
|
||||
FISH_AUDIO_S2_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
FISH_AUDIO_S2_ADAPTER_AVAILABLE = False
|
||||
class FishAudioS2Adapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"Fish Audio S2 adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .omnivoice_adapter import OmniVoiceEngineAdapter
|
||||
OMNIVOICE_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
OMNIVOICE_ADAPTER_AVAILABLE = False
|
||||
class OmniVoiceEngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"OmniVoice adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .moss_tts_adapter import MossTTSEngineAdapter
|
||||
MOSS_TTS_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
MOSS_TTS_ADAPTER_AVAILABLE = False
|
||||
class MossTTSEngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"MOSS-TTS adapter not available: {e}")
|
||||
|
||||
try:
|
||||
from .higgs_audio_v3_adapter import HiggsAudioV3EngineAdapter
|
||||
HIGGS_AUDIO_V3_ADAPTER_AVAILABLE = True
|
||||
except Exception as e:
|
||||
HIGGS_AUDIO_V3_ADAPTER_AVAILABLE = False
|
||||
class HiggsAudioV3EngineAdapter:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(f"Higgs Audio v3 adapter not available: {e}")
|
||||
|
||||
__all__ = [
|
||||
'ChatterBoxEngineAdapter', 'F5TTSEngineAdapter', 'CosyVoiceAdapter',
|
||||
'CHATTERBOX_ADAPTER_AVAILABLE', 'F5TTS_ADAPTER_AVAILABLE', 'COSYVOICE_ADAPTER_AVAILABLE'
|
||||
'ChatterBoxEngineAdapter', 'F5TTSEngineAdapter', 'CosyVoiceAdapter', 'EchoTTSEngineAdapter',
|
||||
'DotsTTSEngineAdapter', 'DramaBoxEngineAdapter', 'OmniVoiceEngineAdapter',
|
||||
'MossTTSEngineAdapter', 'HiggsAudioV3EngineAdapter',
|
||||
'CHATTERBOX_ADAPTER_AVAILABLE', 'F5TTS_ADAPTER_AVAILABLE', 'COSYVOICE_ADAPTER_AVAILABLE',
|
||||
'ECHO_TTS_ADAPTER_AVAILABLE', 'DOTS_TTS_ADAPTER_AVAILABLE',
|
||||
'DRAMABOX_ADAPTER_AVAILABLE', 'OMNIVOICE_ADAPTER_AVAILABLE',
|
||||
'MOSS_TTS_ADAPTER_AVAILABLE', 'HIGGS_AUDIO_V3_ADAPTER_AVAILABLE',
|
||||
'MossSoundEffectV2Adapter'
|
||||
]
|
||||
|
||||
from .moss_soundeffect_v2_adapter import MossSoundEffectV2Adapter
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""audio.cpp adapter for the Suite's unified ASR pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from utils.asr.types import ASRRequest, ASRResult, ASRSegment, ASRWord
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
|
||||
|
||||
_NATIVE_CHUNK_FAMILIES = {
|
||||
"fun_asr_nano",
|
||||
"higgs_audio_stt",
|
||||
"hviske_asr",
|
||||
"qwen3_asr",
|
||||
"vibevoice_asr",
|
||||
"voxtral_realtime",
|
||||
}
|
||||
|
||||
# audio.cpp release-0.5.1 keeps stale offline decoder state for these loaders:
|
||||
# the first request transcribes normally and later requests return empty text.
|
||||
# A fresh owned process is currently the only reliable reset contract.
|
||||
_RESTART_BETWEEN_CHUNKS_FAMILIES = {"nemotron_asr", "voxtral_realtime"}
|
||||
|
||||
|
||||
def _session(config: Mapping[str, Any]):
|
||||
from utils.audio_cpp.session import get_audio_cpp_session
|
||||
|
||||
return get_audio_cpp_session(dict(config))
|
||||
|
||||
|
||||
def _advanced_options(config: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
value = config.get("advanced_options", config.get("request_options", {}))
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid audio.cpp advanced JSON: {exc.msg}") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("audio.cpp advanced options must be a JSON object")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _audio_path(audio: Mapping[str, Any]) -> str:
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = audio.get("sample_rate")
|
||||
if not torch.is_tensor(waveform):
|
||||
raise TypeError("audio.cpp ASR input must contain a waveform tensor")
|
||||
if sample_rate is None or int(sample_rate) <= 0:
|
||||
raise ValueError("audio.cpp ASR input must contain a positive sample_rate")
|
||||
return os.path.abspath(
|
||||
AudioProcessingUtils.save_audio_to_temp_file(waveform, int(sample_rate))
|
||||
)
|
||||
|
||||
|
||||
def _waveform_3d(audio: Mapping[str, Any]) -> tuple[torch.Tensor, int]:
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = int(audio.get("sample_rate") or 0)
|
||||
if not torch.is_tensor(waveform):
|
||||
raise TypeError("audio.cpp ASR input must contain a waveform tensor")
|
||||
if sample_rate <= 0:
|
||||
raise ValueError("audio.cpp ASR input must contain a positive sample_rate")
|
||||
if waveform.ndim == 1:
|
||||
waveform = waveform.unsqueeze(0).unsqueeze(0)
|
||||
elif waveform.ndim == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
elif waveform.ndim != 3:
|
||||
raise ValueError(
|
||||
"audio.cpp ASR waveform must have [samples], [channels, samples], or "
|
||||
"[batch, channels, samples] shape"
|
||||
)
|
||||
if waveform.shape[0] != 1:
|
||||
raise ValueError("audio.cpp ASR accepts one audio item at a time")
|
||||
if waveform.shape[-1] <= 0:
|
||||
raise ValueError("audio.cpp ASR input audio is empty")
|
||||
return waveform.detach().cpu(), sample_rate
|
||||
|
||||
|
||||
def _chunk_ranges(
|
||||
total_samples: int,
|
||||
sample_rate: int,
|
||||
chunk_size: int,
|
||||
overlap: int,
|
||||
) -> list[tuple[int, int]]:
|
||||
if chunk_size <= 0:
|
||||
return [(0, total_samples)]
|
||||
if overlap < 0:
|
||||
raise ValueError("ASR overlap must be zero or greater")
|
||||
if overlap >= chunk_size:
|
||||
raise ValueError("ASR overlap must be smaller than chunk_size")
|
||||
|
||||
chunk_samples = chunk_size * sample_rate
|
||||
if total_samples <= chunk_samples:
|
||||
return [(0, total_samples)]
|
||||
step_samples = (chunk_size - overlap) * sample_rate
|
||||
ranges = []
|
||||
start = 0
|
||||
while start < total_samples:
|
||||
end = min(start + chunk_samples, total_samples)
|
||||
ranges.append((start, end))
|
||||
if end >= total_samples:
|
||||
break
|
||||
start += step_samples
|
||||
return ranges
|
||||
|
||||
|
||||
def _normalized_token(value: str) -> str:
|
||||
return re.sub(r"[^\w]+", "", value, flags=re.UNICODE).casefold()
|
||||
|
||||
|
||||
def _merge_transcript(parts: Iterable[str]) -> str:
|
||||
merged: list[str] = []
|
||||
for part in parts:
|
||||
incoming = str(part or "").strip().split()
|
||||
if not incoming:
|
||||
continue
|
||||
if not merged:
|
||||
merged.extend(incoming)
|
||||
continue
|
||||
limit = min(len(merged), len(incoming), 80)
|
||||
duplicate_count = 0
|
||||
for size in range(limit, 0, -1):
|
||||
left = [_normalized_token(token) for token in merged[-size:]]
|
||||
right = [_normalized_token(token) for token in incoming[:size]]
|
||||
if all(left) and left == right:
|
||||
duplicate_count = size
|
||||
break
|
||||
merged.extend(incoming[duplicate_count:])
|
||||
return " ".join(merged).strip()
|
||||
|
||||
|
||||
def _offset_words(
|
||||
words: Iterable[ASRWord], offset: float, unique_after: Optional[float]
|
||||
) -> list[ASRWord]:
|
||||
shifted = []
|
||||
for word in words:
|
||||
item = ASRWord(start=word.start + offset, end=word.end + offset, text=word.text)
|
||||
if unique_after is not None and (item.start + item.end) / 2.0 < unique_after:
|
||||
continue
|
||||
shifted.append(item)
|
||||
return shifted
|
||||
|
||||
|
||||
def _offset_segments(
|
||||
segments: Iterable[ASRSegment], offset: float, unique_after: Optional[float]
|
||||
) -> list[ASRSegment]:
|
||||
shifted = []
|
||||
for segment in segments:
|
||||
item = ASRSegment(
|
||||
start=segment.start + offset,
|
||||
end=segment.end + offset,
|
||||
text=segment.text,
|
||||
speaker=segment.speaker,
|
||||
)
|
||||
if unique_after is not None and (item.start + item.end) / 2.0 < unique_after:
|
||||
continue
|
||||
shifted.append(item)
|
||||
return shifted
|
||||
|
||||
|
||||
def _seconds(value: Any, sample_rate: int) -> float:
|
||||
try:
|
||||
return max(0.0, float(value) / float(sample_rate))
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _words(payload: Mapping[str, Any], sample_rate: int) -> list[ASRWord]:
|
||||
words = []
|
||||
for item in payload.get("words") or []:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
text = str(item.get("word", item.get("text", ""))).strip()
|
||||
if not text:
|
||||
continue
|
||||
words.append(
|
||||
ASRWord(
|
||||
start=_seconds(item.get("start_sample"), sample_rate),
|
||||
end=_seconds(item.get("end_sample"), sample_rate),
|
||||
text=text,
|
||||
)
|
||||
)
|
||||
return words
|
||||
|
||||
|
||||
def _plain_segments(payload: Mapping[str, Any], sample_rate: int) -> list[ASRSegment]:
|
||||
segments = []
|
||||
for item in payload.get("segments") or []:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
text = str(item.get("text", "")).strip()
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=_seconds(item.get("start_sample"), sample_rate),
|
||||
end=_seconds(item.get("end_sample"), sample_rate),
|
||||
text=text,
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
|
||||
def _speaker_segments(payload: Mapping[str, Any], sample_rate: int) -> list[ASRSegment]:
|
||||
segments = []
|
||||
for item in payload.get("speaker_turns") or []:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
speaker = str(item.get("speaker_id", "")).strip()
|
||||
if speaker and not speaker.lower().startswith("speaker"):
|
||||
speaker = f"Speaker {speaker}"
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=_seconds(item.get("start_sample"), sample_rate),
|
||||
end=_seconds(item.get("end_sample"), sample_rate),
|
||||
text=str(item.get("text", "")).strip(),
|
||||
speaker=speaker or None,
|
||||
)
|
||||
)
|
||||
return segments
|
||||
|
||||
|
||||
def _attach_words(segments: Iterable[ASRSegment], words: Iterable[ASRWord]) -> None:
|
||||
segment_list = list(segments)
|
||||
for word in words:
|
||||
midpoint = (word.start + word.end) / 2.0
|
||||
target = next(
|
||||
(segment for segment in segment_list if segment.start <= midpoint <= segment.end),
|
||||
None,
|
||||
)
|
||||
if target is not None:
|
||||
target.words.append(word)
|
||||
|
||||
|
||||
class AudioCppASREngineAdapter:
|
||||
"""Normalize audio.cpp transcript/timing output into ``ASRResult``."""
|
||||
|
||||
def __init__(self, engine_data: Dict[str, Any]):
|
||||
self.engine_data = dict(engine_data)
|
||||
self.config = dict(engine_data.get("config", engine_data))
|
||||
|
||||
def _session_config(self) -> Dict[str, Any]:
|
||||
config = dict(self.config)
|
||||
if str(config.get("connection_mode", "auto")).lower() != "external_server":
|
||||
config["requested_task"] = "asr"
|
||||
config["task"] = "asr"
|
||||
return config
|
||||
|
||||
def transcribe(self, req: ASRRequest) -> ASRResult:
|
||||
if req.task != "transcribe":
|
||||
raise ValueError(
|
||||
"audio.cpp release-0.5.1 ASR loaders support transcription, not the "
|
||||
"Unified ASR translate mode"
|
||||
)
|
||||
|
||||
config = self._session_config()
|
||||
family = str(config.get("family", "")).strip()
|
||||
warnings: list[str] = []
|
||||
notes: list[str] = []
|
||||
options = _advanced_options(config)
|
||||
|
||||
# VibeVoice-ASR owns diarization across its full recording. Independent
|
||||
# Suite requests can restart speaker numbering, so preserve its native
|
||||
# chunking only for this mode. All other ASR uses Suite-side windows.
|
||||
native_diarization = (
|
||||
family == "vibevoice_asr" and req.diarization and req.chunk_size > 0
|
||||
)
|
||||
if native_diarization:
|
||||
options.setdefault("audio_chunk_mode", "fixed")
|
||||
options.setdefault("audio_chunk_seconds", int(req.chunk_size))
|
||||
if req.overlap > 0:
|
||||
notes.append(
|
||||
"VibeVoice-ASR diarization uses native chunking to preserve speaker "
|
||||
"identity; the Suite overlap setting is not applied."
|
||||
)
|
||||
elif family in _NATIVE_CHUNK_FAMILIES:
|
||||
options.setdefault("audio_chunk_mode", "none")
|
||||
|
||||
if req.timestamps == "word" and family == "qwen3_asr":
|
||||
session_options = config.get("session_options") or {}
|
||||
aligner = session_options.get("qwen3_asr.forced_aligner_model_path")
|
||||
if aligner:
|
||||
options["return_timestamps"] = True
|
||||
else:
|
||||
warnings.append(
|
||||
"Qwen3-ASR word timestamps require the optional Qwen3 Forced Aligner; "
|
||||
"transcription continued without downloading that auxiliary model."
|
||||
)
|
||||
|
||||
waveform, source_rate = _waveform_3d(req.audio)
|
||||
ranges = (
|
||||
[(0, waveform.shape[-1])]
|
||||
if native_diarization
|
||||
else _chunk_ranges(
|
||||
waveform.shape[-1], source_rate, int(req.chunk_size), int(req.overlap)
|
||||
)
|
||||
)
|
||||
session = _session(config)
|
||||
if str(getattr(session, "task", "asr")) != "asr":
|
||||
raise ValueError(
|
||||
f"audio.cpp model '{session.model_id}' is configured for task "
|
||||
f"'{session.task}', not ASR"
|
||||
)
|
||||
restart_between_chunks = (
|
||||
len(ranges) > 1 and family in _RESTART_BETWEEN_CHUNKS_FAMILIES
|
||||
)
|
||||
if restart_between_chunks and not bool(getattr(session, "owned", False)):
|
||||
raise RuntimeError(
|
||||
f"audio.cpp release-0.5.1 {family} returns empty text after its first "
|
||||
"offline request. Suite-side chunking therefore requires a managed "
|
||||
"audio.cpp server so the Suite can reset it between chunks. Set "
|
||||
"connection_mode to managed, or set ASR chunk_size to 0 when using "
|
||||
"an external server."
|
||||
)
|
||||
if restart_between_chunks:
|
||||
notes.append(
|
||||
f"audio.cpp release-0.5.1 {family} requires a managed server reset "
|
||||
"between Suite chunks to avoid empty repeated-request results."
|
||||
)
|
||||
|
||||
display_family = family or "external model"
|
||||
print(f"🎧 audio.cpp ASR: Transcribing with {display_family}...")
|
||||
if len(ranges) > 1:
|
||||
notes.append(
|
||||
f"Suite-side ASR chunking used {len(ranges)} windows of "
|
||||
f"{int(req.chunk_size)}s with {int(req.overlap)}s overlap."
|
||||
)
|
||||
print(
|
||||
f"🧩 audio.cpp ASR: {len(ranges)} chunks "
|
||||
f"({int(req.chunk_size)}s, {int(req.overlap)}s overlap)"
|
||||
)
|
||||
|
||||
payloads: list[Mapping[str, Any]] = []
|
||||
chunk_timings: list[Mapping[str, Any]] = []
|
||||
chunk_diagnostics: list[Dict[str, Any]] = []
|
||||
started_at = time.time()
|
||||
for index, (start, end) in enumerate(ranges, start=1):
|
||||
if index > 1 and restart_between_chunks:
|
||||
print(
|
||||
f"🔄 audio.cpp ASR: Resetting {family} session for chunk "
|
||||
f"{index}/{len(ranges)}"
|
||||
)
|
||||
session.restart_owned_runtime()
|
||||
chunk_waveform = waveform[..., start:end]
|
||||
chunk_rms = float(torch.sqrt(torch.mean(chunk_waveform.float().square())).item())
|
||||
chunk_peak = float(chunk_waveform.float().abs().max().item())
|
||||
temp_path = _audio_path({
|
||||
"waveform": chunk_waveform,
|
||||
"sample_rate": source_rate,
|
||||
})
|
||||
try:
|
||||
request: Dict[str, Any] = {"audio": temp_path, "options": dict(options)}
|
||||
if req.language:
|
||||
request["language"] = req.language
|
||||
result = session.run(request)
|
||||
payload = result.raw if isinstance(result.raw, Mapping) else {}
|
||||
payloads.append(payload)
|
||||
if isinstance(payload.get("timing"), Mapping):
|
||||
chunk_timings.append(payload["timing"])
|
||||
chunk_diagnostics.append({
|
||||
"index": index,
|
||||
"start": round(start / source_rate, 3),
|
||||
"end": round(end / source_rate, 3),
|
||||
"rms": round(chunk_rms, 6),
|
||||
"peak": round(chunk_peak, 6),
|
||||
"text": str(payload.get("text", "")).strip(),
|
||||
"characters": len(str(payload.get("text", "")).strip()),
|
||||
"upstream_timing": (
|
||||
dict(payload["timing"])
|
||||
if isinstance(payload.get("timing"), Mapping)
|
||||
else None
|
||||
),
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if len(ranges) > 1:
|
||||
chunk_chars = len(str(payload.get("text", "")).strip())
|
||||
print(
|
||||
f" ASR chunk {index}/{len(ranges)} complete "
|
||||
f"({chunk_chars} chars, RMS {chunk_rms:.4f}, peak {chunk_peak:.4f})"
|
||||
)
|
||||
|
||||
words: list[ASRWord] = []
|
||||
speaker_segments: list[ASRSegment] = []
|
||||
plain_segments: list[ASRSegment] = []
|
||||
overlap_seconds = float(req.overlap) if len(ranges) > 1 else 0.0
|
||||
for index, ((start, _end), payload) in enumerate(zip(ranges, payloads)):
|
||||
offset = start / source_rate
|
||||
unique_after = offset + overlap_seconds if index > 0 else None
|
||||
words.extend(_offset_words(_words(payload, source_rate), offset, unique_after))
|
||||
speaker_segments.extend(
|
||||
_offset_segments(
|
||||
_speaker_segments(payload, source_rate), offset, unique_after
|
||||
)
|
||||
)
|
||||
plain_segments.extend(
|
||||
_offset_segments(_plain_segments(payload, source_rate), offset, unique_after)
|
||||
)
|
||||
|
||||
if req.diarization:
|
||||
segments = speaker_segments
|
||||
if segments:
|
||||
_attach_words(segments, words)
|
||||
else:
|
||||
warnings.append(
|
||||
f"audio.cpp {family or 'ASR model'} returned no speaker-attributed turns."
|
||||
)
|
||||
segments = plain_segments
|
||||
elif req.timestamps == "word" and words:
|
||||
segments = [
|
||||
ASRSegment(start=word.start, end=word.end, text=word.text, words=[word])
|
||||
for word in words
|
||||
]
|
||||
elif req.timestamps == "word":
|
||||
segments = plain_segments
|
||||
else:
|
||||
segments = []
|
||||
|
||||
text = _merge_transcript(payload.get("text", "") for payload in payloads)
|
||||
if req.diarization and speaker_segments:
|
||||
text = " ".join(
|
||||
f"[{segment.speaker}] {segment.text}" if segment.speaker else segment.text
|
||||
for segment in speaker_segments
|
||||
if segment.text
|
||||
).strip()
|
||||
if not text and speaker_segments:
|
||||
text = " ".join(segment.text for segment in speaker_segments if segment.text).strip()
|
||||
if req.timestamps == "word" and not words:
|
||||
warnings.append(f"audio.cpp {family or 'ASR model'} returned no word timestamps.")
|
||||
empty_chunks = sum(
|
||||
1 for payload in payloads if not str(payload.get("text", "")).strip()
|
||||
)
|
||||
if len(payloads) > 1 and empty_chunks:
|
||||
warnings.append(
|
||||
f"audio.cpp {family or 'ASR model'} returned no text for "
|
||||
f"{empty_chunks} of {len(payloads)} Suite chunks."
|
||||
)
|
||||
|
||||
raw: Dict[str, Any] = {}
|
||||
if warnings:
|
||||
raw["warnings"] = warnings
|
||||
if notes:
|
||||
raw["notes"] = notes
|
||||
if len(payloads) == 1 and chunk_timings:
|
||||
raw["timing"] = dict(chunk_timings[0])
|
||||
elif len(payloads) > 1:
|
||||
raw["timing"] = {
|
||||
"wall_ms": round((time.time() - started_at) * 1000.0, 3),
|
||||
"suite_chunks": len(payloads),
|
||||
"suite_chunk_size_seconds": int(req.chunk_size),
|
||||
"suite_overlap_seconds": int(req.overlap),
|
||||
"upstream_wall_ms": round(
|
||||
sum(float(item.get("wall_ms", 0.0)) for item in chunk_timings), 3
|
||||
),
|
||||
}
|
||||
raw["chunks"] = chunk_diagnostics
|
||||
output_language = next(
|
||||
(
|
||||
str(payload.get("language", "")).strip()
|
||||
for payload in payloads
|
||||
if str(payload.get("language", "")).strip()
|
||||
),
|
||||
str(req.language or "").strip(),
|
||||
) or None
|
||||
print(
|
||||
f"✅ audio.cpp ASR: Complete ({len(text)} chars, "
|
||||
f"{len(segments)} timed/speaker segments)"
|
||||
)
|
||||
return ASRResult(
|
||||
text=text,
|
||||
language=output_language,
|
||||
segments=segments,
|
||||
raw=raw or None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["AudioCppASREngineAdapter"]
|
||||
@@ -0,0 +1,711 @@
|
||||
"""
|
||||
Granite ASR adapter for unified ASR pipeline.
|
||||
Uses Granite speech for transcription/translation and optionally Qwen forced aligner for timestamps.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import comfy.model_management as model_management
|
||||
import comfy.utils
|
||||
import time
|
||||
import re
|
||||
|
||||
from engines.granite_asr.prompting import DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE
|
||||
from utils.asr.types import ASRRequest, ASRResult, ASRSegment, ASRWord
|
||||
|
||||
|
||||
def parse_granite_native_timestamps(ts_text: str, chunk_offset: float) -> Tuple[str, List[ASRSegment]]:
|
||||
"""
|
||||
Parses native timestamped text generated by ibm-granite/granite-speech-4.1-2b-plus.
|
||||
Reconstructs absolute timestamps by unwrapping modulo-1000 rollovers.
|
||||
"""
|
||||
if not ts_text or not ts_text.strip():
|
||||
return "", []
|
||||
|
||||
ts_words = re.split(r"\[T:([^\]]+)\]", ts_text)
|
||||
last_word_end_time = 0.0
|
||||
offset_time = 0.0
|
||||
|
||||
segments: List[ASRSegment] = []
|
||||
plain_words: List[str] = []
|
||||
|
||||
for raw_word, ts_str in zip(ts_words[::2], ts_words[1::2]):
|
||||
word_text = raw_word.strip()
|
||||
if not ts_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
word_end_time = float(ts_str) / 100.0
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
while word_end_time + offset_time < last_word_end_time:
|
||||
offset_time += 10.0
|
||||
absolute_end_time = word_end_time + offset_time
|
||||
|
||||
absolute_start_time = last_word_end_time
|
||||
last_word_end_time = absolute_end_time
|
||||
|
||||
if word_text == "_":
|
||||
continue
|
||||
|
||||
if word_text:
|
||||
plain_words.append(word_text)
|
||||
word = ASRWord(
|
||||
start=absolute_start_time + chunk_offset,
|
||||
end=absolute_end_time + chunk_offset,
|
||||
text=word_text,
|
||||
)
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=word.start,
|
||||
end=word.end,
|
||||
text=word.text,
|
||||
words=[word],
|
||||
)
|
||||
)
|
||||
|
||||
plain_text = " ".join(plain_words)
|
||||
return plain_text, segments
|
||||
|
||||
|
||||
def parse_granite_diarization(text: str, chunk_offset: float, chunk_duration: float) -> Tuple[str, List[ASRSegment]]:
|
||||
"""
|
||||
Parses speaker-attributed ASR text generated by ibm-granite/granite-speech-4.1-2b-plus.
|
||||
Returns:
|
||||
plain_text: Cleaned text without speaker tags.
|
||||
segments: List of ASRSegment instances with speaker property populated.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "", []
|
||||
|
||||
# Find speaker tags like [Speaker 1]:, [Speaker 2]:, etc.
|
||||
# Keep the delimiter matching group so we can reconstruct who is speaking.
|
||||
pattern = r"\[(Speaker \d+)\]:?"
|
||||
parts = re.split(pattern, text)
|
||||
|
||||
segments: List[ASRSegment] = []
|
||||
|
||||
# Handle text before the first speaker tag, if any
|
||||
first_text = parts[0].strip()
|
||||
if first_text:
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=chunk_offset,
|
||||
end=chunk_offset + chunk_duration,
|
||||
text=first_text,
|
||||
speaker=None,
|
||||
words=[],
|
||||
)
|
||||
)
|
||||
|
||||
for speaker, content in zip(parts[1::2], parts[2::2]):
|
||||
segment_text = content.strip()
|
||||
if not segment_text:
|
||||
continue
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=chunk_offset,
|
||||
end=chunk_offset + chunk_duration,
|
||||
text=segment_text,
|
||||
speaker=speaker,
|
||||
words=[],
|
||||
)
|
||||
)
|
||||
|
||||
plain_text = " ".join(seg.text for seg in segments if seg.text).strip()
|
||||
plain_text = re.sub(r"\s+", " ", plain_text).strip()
|
||||
|
||||
# Distribute the chunk_duration proportionally across segments based on character length
|
||||
total_len = sum(len(seg.text) for seg in segments if seg.text)
|
||||
if total_len > 0:
|
||||
current_time = chunk_offset
|
||||
for seg in segments:
|
||||
seg_len = len(seg.text) if seg.text else 0
|
||||
duration = chunk_duration * (seg_len / total_len)
|
||||
seg.start = current_time
|
||||
seg.end = current_time + duration
|
||||
current_time += duration
|
||||
|
||||
return plain_text, segments
|
||||
|
||||
|
||||
def format_granite_diarization_segments(segments: List[ASRSegment]) -> str:
|
||||
"""
|
||||
Normalize Granite speaker attribution to the suite's canonical bracket format:
|
||||
[Speaker 1] text
|
||||
"""
|
||||
parts: List[str] = []
|
||||
for seg in segments:
|
||||
text = (seg.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if seg.speaker:
|
||||
parts.append(f"[{seg.speaker}] {text}")
|
||||
else:
|
||||
parts.append(text)
|
||||
return " ".join(parts).strip()
|
||||
|
||||
|
||||
def remap_words_to_speaker_segments(speaker_segments: List[ASRSegment], aligned_words: List[ASRWord]) -> None:
|
||||
"""
|
||||
Distributes aligned words sequentially to speaker_segments based on text matching.
|
||||
"""
|
||||
if not speaker_segments or not aligned_words:
|
||||
return
|
||||
|
||||
word_idx = 0
|
||||
num_words = len(aligned_words)
|
||||
|
||||
for segment in speaker_segments:
|
||||
# Tokenize segment text to find expected words
|
||||
expected_tokens = [w.lower() for w in re.findall(r"\b\w+\b", segment.text) if w]
|
||||
if not expected_tokens:
|
||||
continue
|
||||
|
||||
segment_words = []
|
||||
match_count = 0
|
||||
limit = len(expected_tokens)
|
||||
|
||||
while word_idx < num_words and match_count < limit:
|
||||
aligned_word = aligned_words[word_idx]
|
||||
aligned_token_clean = "".join(ch for ch in aligned_word.text.lower() if ch.isalnum())
|
||||
expected_token_clean = "".join(ch for ch in expected_tokens[match_count] if ch.isalnum())
|
||||
|
||||
if not aligned_token_clean:
|
||||
segment_words.append(aligned_word)
|
||||
word_idx += 1
|
||||
continue
|
||||
|
||||
if (aligned_token_clean == expected_token_clean or
|
||||
expected_token_clean in aligned_token_clean or
|
||||
aligned_token_clean in expected_token_clean):
|
||||
segment_words.append(aligned_word)
|
||||
word_idx += 1
|
||||
match_count += 1
|
||||
elif (match_count + 1 < limit and
|
||||
("".join(ch for ch in expected_tokens[match_count + 1] if ch.isalnum()) in aligned_token_clean or
|
||||
aligned_token_clean in "".join(ch for ch in expected_tokens[match_count + 1] if ch.isalnum()))):
|
||||
# Missed an expected word in alignment, skip the expected word and match current aligned to next expected
|
||||
match_count += 2
|
||||
segment_words.append(aligned_word)
|
||||
word_idx += 1
|
||||
else:
|
||||
# Mismatch, consume to avoid getting stuck
|
||||
segment_words.append(aligned_word)
|
||||
word_idx += 1
|
||||
match_count += 1
|
||||
|
||||
if segment_words:
|
||||
segment.words = segment_words
|
||||
segment.start = segment_words[0].start
|
||||
segment.end = segment_words[-1].end
|
||||
|
||||
# If there are leftover words, append them to the last segment that had some words
|
||||
if word_idx < num_words and speaker_segments:
|
||||
last_seg = speaker_segments[-1]
|
||||
if last_seg.words:
|
||||
last_seg.words.extend(aligned_words[word_idx:])
|
||||
last_seg.end = aligned_words[-1].end
|
||||
else:
|
||||
last_seg.words = aligned_words[word_idx:]
|
||||
last_seg.start = aligned_words[word_idx].start
|
||||
last_seg.end = aligned_words[-1].end
|
||||
|
||||
|
||||
class GraniteASREngineAdapter:
|
||||
GRANITE_LANGUAGES = {
|
||||
"english": "English",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"spanish": "Spanish",
|
||||
"portuguese": "Portuguese",
|
||||
"japanese": "Japanese",
|
||||
}
|
||||
|
||||
QWEN_ALIGNER_LANGUAGES = {
|
||||
"Chinese",
|
||||
"English",
|
||||
"Cantonese",
|
||||
"French",
|
||||
"German",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Portuguese",
|
||||
"Russian",
|
||||
"Spanish",
|
||||
}
|
||||
|
||||
GENERIC_SPACE_ALIGNER_LANGUAGE = "English"
|
||||
|
||||
def __init__(self, engine_data: Dict[str, Any]):
|
||||
self.engine_data = engine_data
|
||||
self.config = engine_data.get("config", engine_data)
|
||||
|
||||
def _normalize_language(self, language: Optional[str]) -> Optional[str]:
|
||||
if not language or str(language).lower() == "auto":
|
||||
return None
|
||||
normalized = self.GRANITE_LANGUAGES.get(str(language).strip().lower())
|
||||
model_name = self.config.get("model_name", "")
|
||||
if normalized == "Japanese" and "plus" in str(model_name).lower():
|
||||
return None
|
||||
return normalized
|
||||
|
||||
def _get_model(self):
|
||||
from utils.models.unified_model_interface import unified_model_interface, ModelLoadConfig
|
||||
from utils.device import resolve_torch_device
|
||||
|
||||
device = self.config.get("device", "auto")
|
||||
if model_management.cpu_mode():
|
||||
device = "cpu"
|
||||
elif device != "cpu":
|
||||
device = resolve_torch_device(device)
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="granite_asr",
|
||||
model_type="asr",
|
||||
model_name=self.config.get("model_name", "granite-speech-4.1-2b"),
|
||||
model_path=self.config.get("model_name", "granite-speech-4.1-2b"),
|
||||
device=device,
|
||||
additional_params={
|
||||
"precision": self.config.get("dtype", "auto"),
|
||||
"attn_implementation": self.config.get("attn_implementation", "auto"),
|
||||
},
|
||||
)
|
||||
return unified_model_interface.load_model(config)
|
||||
|
||||
def _get_forced_aligner(self):
|
||||
from utils.models.unified_model_interface import unified_model_interface, ModelLoadConfig
|
||||
from utils.models.factory_config import RUNTIME_MODE_SHARED
|
||||
from utils.device import resolve_torch_device
|
||||
|
||||
device = self.config.get("device", "auto")
|
||||
if model_management.cpu_mode():
|
||||
device = "cpu"
|
||||
elif device != "cpu":
|
||||
device = resolve_torch_device(device)
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="qwen3_asr",
|
||||
model_type="aligner",
|
||||
model_name="Qwen3-ForcedAligner-0.6B",
|
||||
model_path="Qwen3-ForcedAligner-0.6B",
|
||||
device=device,
|
||||
runtime_mode=self.config.get("forced_aligner_runtime_mode", RUNTIME_MODE_SHARED),
|
||||
runtime_profile=self.config.get("forced_aligner_runtime_profile"),
|
||||
additional_params={
|
||||
"precision": self.config.get("dtype", "auto"),
|
||||
"attn_implementation": self.config.get("attn_implementation", "auto"),
|
||||
},
|
||||
)
|
||||
return unified_model_interface.load_model(config)
|
||||
|
||||
def _prepare_audio(self, audio: Dict[str, Any]) -> torch.Tensor:
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
if waveform.ndim == 3:
|
||||
waveform = waveform[0]
|
||||
if waveform.shape[0] > 1:
|
||||
waveform = torch.mean(waveform, dim=0)
|
||||
else:
|
||||
waveform = waveform[0]
|
||||
|
||||
if sample_rate != 16000:
|
||||
import torchaudio.functional as TA_F
|
||||
waveform = TA_F.resample(waveform, orig_freq=sample_rate, new_freq=16000)
|
||||
return waveform
|
||||
|
||||
def _alignment_requested(self, req: ASRRequest) -> bool:
|
||||
return req.timestamps == "word"
|
||||
|
||||
def _can_use_aligner(self, req: ASRRequest, normalized_language: Optional[str], warnings: List[str]) -> bool:
|
||||
if not self._alignment_requested(req):
|
||||
return False
|
||||
|
||||
if req.task != "transcribe":
|
||||
warnings.append("Granite timestamps skipped: Qwen forced aligner only works for transcription, not translation.")
|
||||
return False
|
||||
|
||||
if not self.config.get("asr_use_forced_aligner", False):
|
||||
warnings.append("Granite timestamps skipped: Qwen forced aligner is disabled on the Granite engine node.")
|
||||
return False
|
||||
|
||||
if normalized_language and normalized_language not in self.QWEN_ALIGNER_LANGUAGES:
|
||||
warnings.append(f"Granite timestamps skipped: Qwen forced aligner does not support '{normalized_language}'.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _append_once(self, messages: List[str], message: str) -> None:
|
||||
if message not in messages:
|
||||
messages.append(message)
|
||||
|
||||
def _contains_japanese_script(self, text: str) -> bool:
|
||||
for ch in text:
|
||||
code = ord(ch)
|
||||
if 0x3040 <= code <= 0x309F: # Hiragana
|
||||
return True
|
||||
if 0x30A0 <= code <= 0x30FF: # Katakana
|
||||
return True
|
||||
if 0x31F0 <= code <= 0x31FF: # Katakana Phonetic Extensions
|
||||
return True
|
||||
if 0xFF66 <= code <= 0xFF9D: # Halfwidth Katakana
|
||||
return True
|
||||
if 0x3400 <= code <= 0x4DBF: # CJK Unified Ideographs Extension A
|
||||
return True
|
||||
if 0x4E00 <= code <= 0x9FFF: # CJK Unified Ideographs
|
||||
return True
|
||||
if 0xF900 <= code <= 0xFAFF: # CJK Compatibility Ideographs
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_aligner_language(
|
||||
self,
|
||||
normalized_language: Optional[str],
|
||||
transcript: str,
|
||||
notes: List[str],
|
||||
) -> str:
|
||||
if normalized_language:
|
||||
return normalized_language
|
||||
|
||||
if self._contains_japanese_script(transcript):
|
||||
self._append_once(
|
||||
notes,
|
||||
"Granite Auto alignment used a Japanese-script heuristic, so Qwen forced aligner ran in Japanese mode. This is tokenizer routing for alignment, not Granite native language detection.",
|
||||
)
|
||||
return "Japanese"
|
||||
|
||||
self._append_once(
|
||||
notes,
|
||||
"Granite Auto alignment used Qwen's generic space-delimited tokenizer path. 'English' here is only the aligner mode for non-Japanese Granite languages, not detected spoken language.",
|
||||
)
|
||||
return self.GENERIC_SPACE_ALIGNER_LANGUAGE
|
||||
|
||||
def _align_words(
|
||||
self,
|
||||
aligner,
|
||||
chunk_audio,
|
||||
transcript: str,
|
||||
language: str,
|
||||
chunk_offset: float,
|
||||
warnings: List[str],
|
||||
) -> List[ASRSegment]:
|
||||
if not transcript.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = aligner.align(audio=[(chunk_audio, 16000)], text=[transcript], language=[language])
|
||||
if not results:
|
||||
return []
|
||||
|
||||
segments: List[ASRSegment] = []
|
||||
for item in results[0]:
|
||||
word = ASRWord(
|
||||
start=float(item.start_time) + chunk_offset,
|
||||
end=float(item.end_time) + chunk_offset,
|
||||
text=str(item.text),
|
||||
)
|
||||
segments.append(
|
||||
ASRSegment(
|
||||
start=word.start,
|
||||
end=word.end,
|
||||
text=word.text,
|
||||
words=[word],
|
||||
)
|
||||
)
|
||||
return segments
|
||||
except Exception as e:
|
||||
warnings.append(f"Granite timestamps skipped: Qwen forced aligner failed ({e}).")
|
||||
return []
|
||||
|
||||
def _resolve_max_new_tokens(
|
||||
self,
|
||||
req: ASRRequest,
|
||||
*,
|
||||
chunk_num_samples: int,
|
||||
sample_rate: int,
|
||||
timestamps: bool = False,
|
||||
diarization: bool = False,
|
||||
) -> int:
|
||||
base_max_new_tokens = int(self.config.get("max_new_tokens", req.max_new_tokens or 200))
|
||||
chunk_seconds = max(1.0, float(chunk_num_samples) / float(sample_rate))
|
||||
|
||||
# Native timestamp mode expands output heavily because each spoken word is
|
||||
# followed by a [T:NNN] tag. Speaker attribution also adds extra label tokens.
|
||||
# Keep the user-configured cap as the floor, but auto-raise low budgets so
|
||||
# longer chunks do not get truncated mid-transcript.
|
||||
if timestamps:
|
||||
recommended_floor = int(max(256, min(2048, round(chunk_seconds * 16.0))))
|
||||
return max(base_max_new_tokens, recommended_floor)
|
||||
|
||||
if diarization:
|
||||
recommended_floor = int(max(256, min(2048, round(chunk_seconds * 10.0))))
|
||||
return max(base_max_new_tokens, recommended_floor)
|
||||
|
||||
return base_max_new_tokens
|
||||
|
||||
def _run_chunk(
|
||||
self,
|
||||
runtime,
|
||||
chunk_np,
|
||||
req: ASRRequest,
|
||||
normalized_language: Optional[str],
|
||||
target_language: Optional[str],
|
||||
translate_instruction_override: Optional[str],
|
||||
timestamps: bool = False,
|
||||
diarization: bool = False,
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
generation_kwargs = {
|
||||
"max_new_tokens": self._resolve_max_new_tokens(
|
||||
req,
|
||||
chunk_num_samples=len(chunk_np),
|
||||
sample_rate=16000,
|
||||
timestamps=timestamps,
|
||||
diarization=diarization,
|
||||
),
|
||||
"do_sample": bool(self.config.get("do_sample", False)),
|
||||
"num_beams": int(self.config.get("num_beams", 1)),
|
||||
"temperature": float(self.config.get("temperature", 1.0)),
|
||||
"top_k": int(self.config.get("top_k", 50)),
|
||||
"top_p": float(self.config.get("top_p", 1.0)),
|
||||
"repetition_penalty": float(self.config.get("repetition_penalty", 1.0)),
|
||||
"length_penalty": float(self.config.get("length_penalty", 1.0)),
|
||||
"no_repeat_ngram_size": int(self.config.get("no_repeat_ngram_size", 0)),
|
||||
"early_stopping": bool(self.config.get("early_stopping", False)),
|
||||
}
|
||||
result = runtime.transcribe(
|
||||
chunk_np,
|
||||
task=req.task,
|
||||
language=normalized_language,
|
||||
target_language=target_language,
|
||||
translate_instruction_override=translate_instruction_override,
|
||||
generation_kwargs=generation_kwargs,
|
||||
timestamps=timestamps,
|
||||
diarization=diarization,
|
||||
)
|
||||
return result.get("text", ""), result.get("language")
|
||||
|
||||
def transcribe(self, req: ASRRequest) -> ASRResult:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
runtime = self._get_model()
|
||||
waveform = self._prepare_audio(req.audio)
|
||||
wav_np = waveform.cpu().numpy()
|
||||
sample_rate = 16000
|
||||
|
||||
warnings: List[str] = []
|
||||
notes: List[str] = []
|
||||
normalized_language = self._normalize_language(req.language)
|
||||
model_name = self.config.get("model_name", "granite-speech-4.1-2b")
|
||||
is_plus = "plus" in str(model_name).lower()
|
||||
|
||||
diarization_requested = is_plus and req.diarization and req.task == "transcribe"
|
||||
if req.diarization:
|
||||
if not is_plus:
|
||||
warnings.append("Granite diarization skipped: Only 'plus' variants support speaker attribution natively.")
|
||||
elif req.task != "transcribe":
|
||||
warnings.append("Granite diarization skipped: Speaker attribution is only supported for transcription, not translation.")
|
||||
|
||||
target_language = self.config.get("asr_translate_target_language", "English")
|
||||
translate_instruction_override = str(
|
||||
self.config.get("asr_translate_instruction_override", DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE)
|
||||
or DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE
|
||||
).strip()
|
||||
if req.language and str(req.language).lower() != "auto" and not normalized_language:
|
||||
if str(req.language).lower() == "japanese" and "plus" in str(model_name).lower():
|
||||
warnings.append(
|
||||
"Granite Speech Plus variant does not support Japanese. Falling back to Granite auto language handling."
|
||||
)
|
||||
else:
|
||||
warnings.append(
|
||||
f"Granite received unsupported language hint '{req.language}'. Falling back to Granite auto language handling."
|
||||
)
|
||||
if req.task == "translate":
|
||||
self._append_once(
|
||||
notes,
|
||||
f"Granite translation target is '{target_language}'. This target is prompt-driven by the Granite engine node, not a native model target-language parameter.",
|
||||
)
|
||||
if translate_instruction_override != DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE:
|
||||
self._append_once(
|
||||
notes,
|
||||
"Granite custom translation instruction override is active on the engine node. This is still wrapped in Granite's chat format, but malformed or weak instructions can fall back to plain transcription.",
|
||||
)
|
||||
|
||||
use_native_timestamps = is_plus and req.timestamps == "word" and req.task == "transcribe" and not diarization_requested
|
||||
use_aligner = not use_native_timestamps and self._can_use_aligner(req, normalized_language, warnings)
|
||||
aligner = self._get_forced_aligner() if use_aligner else None
|
||||
|
||||
chunk_size = int(req.chunk_size)
|
||||
overlap = int(req.overlap)
|
||||
total_samples = len(wav_np)
|
||||
|
||||
segments: List[ASRSegment] = []
|
||||
full_text_parts: List[str] = []
|
||||
output_language = normalized_language
|
||||
|
||||
if chunk_size > 0:
|
||||
chunk_samples = chunk_size * sample_rate
|
||||
overlap_samples = overlap * sample_rate
|
||||
step_samples = max(1, chunk_samples - overlap_samples)
|
||||
num_chunks = max(1, int((total_samples - overlap_samples + step_samples - 1) / step_samples))
|
||||
|
||||
chunk_progress_bar = comfy.utils.ProgressBar(num_chunks)
|
||||
start_time = time.time()
|
||||
last_print = 0.0
|
||||
|
||||
for i in range(num_chunks):
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
start = i * step_samples
|
||||
end = min(start + chunk_samples, total_samples)
|
||||
chunk_np = wav_np[start:end]
|
||||
text, detected_language = self._run_chunk(
|
||||
runtime,
|
||||
chunk_np,
|
||||
req,
|
||||
normalized_language,
|
||||
target_language,
|
||||
translate_instruction_override,
|
||||
timestamps=use_native_timestamps,
|
||||
diarization=diarization_requested,
|
||||
)
|
||||
if detected_language and not output_language:
|
||||
output_language = detected_language
|
||||
|
||||
if diarization_requested:
|
||||
chunk_offset = start / sample_rate
|
||||
parsed_text, chunk_segments = parse_granite_diarization(text, chunk_offset, len(chunk_np) / sample_rate)
|
||||
if parsed_text:
|
||||
full_text_parts.append(format_granite_diarization_segments(chunk_segments))
|
||||
|
||||
if aligner is not None and parsed_text:
|
||||
aligner_language = self._resolve_aligner_language(normalized_language, parsed_text, notes)
|
||||
word_segments = self._align_words(
|
||||
aligner=aligner,
|
||||
chunk_audio=chunk_np,
|
||||
transcript=parsed_text,
|
||||
language=aligner_language,
|
||||
chunk_offset=chunk_offset,
|
||||
warnings=warnings,
|
||||
)
|
||||
aligned_words = [seg.words[0] for seg in word_segments if seg.words]
|
||||
remap_words_to_speaker_segments(chunk_segments, aligned_words)
|
||||
|
||||
for seg in chunk_segments:
|
||||
if seg.speaker:
|
||||
seg.text = f"[{seg.speaker}] {seg.text}"
|
||||
|
||||
segments.extend(chunk_segments)
|
||||
elif use_native_timestamps:
|
||||
chunk_offset = start / sample_rate
|
||||
parsed_text, chunk_segments = parse_granite_native_timestamps(text, chunk_offset)
|
||||
if parsed_text:
|
||||
full_text_parts.append(parsed_text)
|
||||
segments.extend(chunk_segments)
|
||||
else:
|
||||
if text:
|
||||
full_text_parts.append(text)
|
||||
if aligner is not None and text:
|
||||
aligner_language = self._resolve_aligner_language(normalized_language, text, notes)
|
||||
chunk_offset = start / sample_rate
|
||||
segments.extend(
|
||||
self._align_words(
|
||||
aligner=aligner,
|
||||
chunk_audio=chunk_np,
|
||||
transcript=text,
|
||||
language=aligner_language,
|
||||
chunk_offset=chunk_offset,
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
chunk_progress_bar.update(1)
|
||||
now = time.time()
|
||||
if now - last_print >= 0.5 or (i + 1) == num_chunks:
|
||||
elapsed = now - start_time
|
||||
its = (i + 1) / elapsed if elapsed > 0 else 0.0
|
||||
eta = (num_chunks - (i + 1)) / its if its > 0 else 0.0
|
||||
bar_width = 12
|
||||
filled = int(((i + 1) / num_chunks) * bar_width)
|
||||
bar = f"[{'█' * filled}{'░' * (bar_width - filled)}]"
|
||||
print(f"\r Progress: {bar} {i+1}/{num_chunks} | {its:.1f} it/s | {elapsed:.0f}s | ETA {eta:.0f}s", end="", flush=True)
|
||||
last_print = now
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
elapsed = time.time() - start_time
|
||||
avg = (num_chunks / elapsed) if elapsed > 0 else 0.0
|
||||
print(f"\r Complete: {num_chunks} chunks in {elapsed:.1f}s (avg {avg:.1f} it/s)" + " " * 20)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
text, detected_language = self._run_chunk(
|
||||
runtime,
|
||||
wav_np,
|
||||
req,
|
||||
normalized_language,
|
||||
target_language,
|
||||
translate_instruction_override,
|
||||
timestamps=use_native_timestamps,
|
||||
diarization=diarization_requested,
|
||||
)
|
||||
output_language = detected_language or output_language
|
||||
|
||||
if diarization_requested:
|
||||
parsed_text, chunk_segments = parse_granite_diarization(text, 0.0, len(wav_np) / sample_rate)
|
||||
if parsed_text:
|
||||
full_text_parts.append(format_granite_diarization_segments(chunk_segments))
|
||||
|
||||
if aligner is not None and parsed_text:
|
||||
aligner_language = self._resolve_aligner_language(normalized_language, parsed_text, notes)
|
||||
word_segments = self._align_words(
|
||||
aligner=aligner,
|
||||
chunk_audio=wav_np,
|
||||
transcript=parsed_text,
|
||||
language=aligner_language,
|
||||
chunk_offset=0.0,
|
||||
warnings=warnings,
|
||||
)
|
||||
aligned_words = [seg.words[0] for seg in word_segments if seg.words]
|
||||
remap_words_to_speaker_segments(chunk_segments, aligned_words)
|
||||
|
||||
for seg in chunk_segments:
|
||||
if seg.speaker:
|
||||
seg.text = f"[{seg.speaker}] {seg.text}"
|
||||
|
||||
segments.extend(chunk_segments)
|
||||
elif use_native_timestamps:
|
||||
parsed_text, chunk_segments = parse_granite_native_timestamps(text, 0.0)
|
||||
if parsed_text:
|
||||
full_text_parts.append(parsed_text)
|
||||
segments.extend(chunk_segments)
|
||||
else:
|
||||
if text:
|
||||
full_text_parts.append(text)
|
||||
if aligner is not None and text:
|
||||
aligner_language = self._resolve_aligner_language(normalized_language, text, notes)
|
||||
segments.extend(
|
||||
self._align_words(
|
||||
aligner=aligner,
|
||||
chunk_audio=wav_np,
|
||||
transcript=text,
|
||||
language=aligner_language,
|
||||
chunk_offset=0.0,
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
|
||||
text = " ".join(part for part in full_text_parts if part).strip()
|
||||
raw = None
|
||||
if warnings or notes:
|
||||
raw = {}
|
||||
if warnings:
|
||||
raw["warnings"] = warnings
|
||||
if notes:
|
||||
raw["notes"] = notes
|
||||
return ASRResult(text=text, language=output_language, segments=segments, raw=raw)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Qwen3-ASR adapter for unified ASR pipeline.
|
||||
Uses qwen_asr package (transformers backend) and optional forced aligner.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import comfy.model_management as model_management
|
||||
import comfy.utils
|
||||
import time
|
||||
|
||||
from engines.qwen3_asr.prompting import DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE
|
||||
from utils.asr.types import ASRRequest, ASRResult, ASRSegment, ASRWord
|
||||
|
||||
|
||||
class Qwen3ASREngineAdapter:
|
||||
def __init__(self, engine_data: Dict[str, Any]):
|
||||
self.engine_data = engine_data
|
||||
self.config = engine_data.get("config", engine_data)
|
||||
|
||||
def _resolve_precision(self, precision: str) -> torch.dtype:
|
||||
if precision == "bf16":
|
||||
return torch.bfloat16
|
||||
if precision == "fp16":
|
||||
return torch.float16
|
||||
if precision == "fp32":
|
||||
return torch.float32
|
||||
# auto
|
||||
if torch.cuda.is_available():
|
||||
major, _minor = torch.cuda.get_device_capability()
|
||||
return torch.bfloat16 if major >= 8 else torch.float16
|
||||
return torch.float32
|
||||
|
||||
def _get_model(self, req: ASRRequest):
|
||||
from utils.models.unified_model_interface import unified_model_interface, ModelLoadConfig
|
||||
from utils.device import resolve_torch_device
|
||||
|
||||
device = self.config.get("device", "auto")
|
||||
if model_management.cpu_mode():
|
||||
device = "cpu"
|
||||
elif device != "cpu":
|
||||
device = resolve_torch_device(device)
|
||||
|
||||
# Infer ASR model variant from existing Qwen3 model_size setting
|
||||
model_size = self.config.get("model_size", "1.7B")
|
||||
model_name = "Qwen3-ASR-0.6B" if model_size == "0.6B" else "Qwen3-ASR-1.7B"
|
||||
model_id = model_name
|
||||
|
||||
precision = self.config.get("dtype", "auto")
|
||||
|
||||
attn = self.config.get("attn_implementation", "auto")
|
||||
if attn == "auto":
|
||||
flash_attn = False
|
||||
else:
|
||||
flash_attn = attn == "flash_attention_2"
|
||||
|
||||
forced_aligner_enabled = self.config.get("asr_use_forced_aligner", False)
|
||||
|
||||
forced_aligner_model = "Qwen3-ForcedAligner-0.6B" if forced_aligner_enabled else ""
|
||||
|
||||
additional_params = {
|
||||
"precision": precision,
|
||||
"attn_implementation": "flash_attention_2" if flash_attn else "sdpa",
|
||||
"max_new_tokens": int(self.config.get("max_new_tokens", 256)),
|
||||
}
|
||||
|
||||
if forced_aligner_enabled:
|
||||
additional_params["forced_aligner"] = forced_aligner_model
|
||||
additional_params["forced_aligner_kwargs"] = None
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="qwen3_asr",
|
||||
model_type="asr",
|
||||
model_name=model_name,
|
||||
model_path=model_id,
|
||||
device=device,
|
||||
runtime_mode=self.config.get("runtime_mode", "main_environment"),
|
||||
runtime_profile=self.config.get("runtime_profile"),
|
||||
additional_params=additional_params,
|
||||
)
|
||||
|
||||
return unified_model_interface.load_model(config)
|
||||
|
||||
def _prepare_audio(self, audio: Dict[str, Any]) -> torch.Tensor:
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
if waveform.ndim == 3:
|
||||
waveform = waveform[0]
|
||||
if waveform.shape[0] > 1:
|
||||
waveform = torch.mean(waveform, dim=0)
|
||||
else:
|
||||
waveform = waveform[0]
|
||||
|
||||
if sample_rate != 16000:
|
||||
w = waveform.view(1, 1, -1)
|
||||
w = F.interpolate(w, size=int(w.shape[-1] * 16000 / sample_rate), mode="linear", align_corners=False)
|
||||
waveform = w.view(-1)
|
||||
return waveform
|
||||
|
||||
def _build_translate_context(self, req: ASRRequest, target_language: str, instruction_template: str) -> str:
|
||||
source_language = (
|
||||
req.language
|
||||
if req.language and req.language.lower() != "auto"
|
||||
else "the spoken source language"
|
||||
)
|
||||
context = str(instruction_template or DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE).strip()
|
||||
context = context.replace("{source_language}", source_language)
|
||||
context = context.replace("{language}", source_language)
|
||||
context = context.replace("{target_language}", target_language)
|
||||
return context
|
||||
|
||||
def transcribe(self, req: ASRRequest) -> ASRResult:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
model = self._get_model(req)
|
||||
waveform = self._prepare_audio(req.audio)
|
||||
sample_rate = 16000
|
||||
|
||||
target_language = self.config.get("asr_translate_target_language", "English")
|
||||
translate_instruction_override = str(
|
||||
self.config.get("asr_translate_instruction_override", DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE)
|
||||
or DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE
|
||||
).strip()
|
||||
warnings: List[str] = []
|
||||
notes: List[str] = []
|
||||
translate_mode = req.task == "translate"
|
||||
language = None if not req.language or req.language.lower() == "auto" else req.language
|
||||
if translate_mode:
|
||||
return_ts = False
|
||||
language = target_language
|
||||
notes.append(
|
||||
f"Qwen translation target is '{target_language}'. In this integration, translation is still prompt-driven, but the target language is also forced through Qwen's assistant prompt path instead of leaving language control empty."
|
||||
)
|
||||
if translate_instruction_override != DEFAULT_TRANSLATE_INSTRUCTION_TEMPLATE:
|
||||
notes.append(
|
||||
"Qwen custom translation instruction override is active on the engine node. This only affects ASR translation mode and can change model behavior significantly."
|
||||
)
|
||||
if req.timestamps == "word" or req.use_forced_aligner or self.config.get("asr_use_forced_aligner", False):
|
||||
warnings.append("Qwen timestamps skipped: translation mode returns text only.")
|
||||
else:
|
||||
return_ts = req.timestamps == "word" or req.use_forced_aligner or self.config.get("asr_use_forced_aligner", False)
|
||||
context = (
|
||||
self._build_translate_context(req, target_language, translate_instruction_override)
|
||||
if translate_mode else ""
|
||||
)
|
||||
|
||||
chunk_size = int(req.chunk_size)
|
||||
overlap = int(req.overlap)
|
||||
|
||||
segments: List[ASRSegment] = []
|
||||
full_text_parts: List[str] = []
|
||||
wav_np = waveform.cpu().numpy()
|
||||
total_samples = len(wav_np)
|
||||
progress_bar = None
|
||||
|
||||
if chunk_size > 0:
|
||||
chunk_samples = chunk_size * sample_rate
|
||||
overlap_samples = overlap * sample_rate
|
||||
step_samples = max(1, chunk_samples - overlap_samples)
|
||||
num_chunks = max(1, int((total_samples - overlap_samples + step_samples - 1) / step_samples))
|
||||
|
||||
if getattr(model, "backend", None) == "transformers":
|
||||
try:
|
||||
from utils.asr.progress_callback import ASRProgressStreamer
|
||||
total_tokens = int(self.config.get("max_new_tokens", 256)) * num_chunks
|
||||
progress_bar = comfy.utils.ProgressBar(total_tokens)
|
||||
model._streamer_factory = lambda max_new_tokens, pb: ASRProgressStreamer(
|
||||
max_new_tokens or int(self.config.get("max_new_tokens", 256)),
|
||||
pb,
|
||||
label="ASR",
|
||||
)
|
||||
model._progress_bar = progress_bar
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Chunk-level fallback progress (matches TTS-style console line)
|
||||
chunk_progress_bar = comfy.utils.ProgressBar(num_chunks)
|
||||
start_time = time.time()
|
||||
last_print = 0.0
|
||||
|
||||
for i in range(num_chunks):
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
start = i * step_samples
|
||||
end = min(start + chunk_samples, total_samples)
|
||||
chunk_np = wav_np[start:end]
|
||||
results = model.transcribe(
|
||||
audio=[(chunk_np, sample_rate)],
|
||||
context=[context],
|
||||
language=language,
|
||||
return_time_stamps=return_ts,
|
||||
)
|
||||
res = results[0]
|
||||
full_text_parts.append(res.text)
|
||||
chunk_offset = start / sample_rate
|
||||
|
||||
if return_ts and hasattr(res, "time_stamps") and res.time_stamps:
|
||||
for ts in res.time_stamps:
|
||||
word = ASRWord(
|
||||
start=ts.start_time + chunk_offset,
|
||||
end=ts.end_time + chunk_offset,
|
||||
text=ts.text,
|
||||
)
|
||||
seg = ASRSegment(
|
||||
start=word.start,
|
||||
end=word.end,
|
||||
text=word.text,
|
||||
words=[word],
|
||||
)
|
||||
segments.append(seg)
|
||||
|
||||
try:
|
||||
chunk_progress_bar.update(1)
|
||||
now = time.time()
|
||||
if now - last_print >= 0.5 or (i + 1) == num_chunks:
|
||||
elapsed = now - start_time
|
||||
its = (i + 1) / elapsed if elapsed > 0 else 0.0
|
||||
eta = (num_chunks - (i + 1)) / its if its > 0 else 0.0
|
||||
bar_width = 12
|
||||
filled = int(((i + 1) / num_chunks) * bar_width)
|
||||
bar = f"[{'█' * filled}{'░' * (bar_width - filled)}]"
|
||||
print(f"\r Progress: {bar} {i+1}/{num_chunks} | {its:.1f} it/s | {elapsed:.0f}s | ETA {eta:.0f}s", end="", flush=True)
|
||||
last_print = now
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
elapsed = time.time() - start_time
|
||||
avg = (num_chunks / elapsed) if elapsed > 0 else 0.0
|
||||
print(f"\r Complete: {num_chunks} chunks in {elapsed:.1f}s (avg {avg:.1f} it/s)" + " " * 20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
if getattr(model, "backend", None) == "transformers":
|
||||
try:
|
||||
from utils.asr.progress_callback import ASRProgressStreamer
|
||||
total_tokens = int(self.config.get("max_new_tokens", 256))
|
||||
progress_bar = comfy.utils.ProgressBar(total_tokens)
|
||||
model._streamer_factory = lambda max_new_tokens, pb: ASRProgressStreamer(
|
||||
max_new_tokens or total_tokens,
|
||||
pb,
|
||||
label="ASR",
|
||||
)
|
||||
model._progress_bar = progress_bar
|
||||
except Exception:
|
||||
pass
|
||||
results = model.transcribe(
|
||||
audio=[(wav_np, sample_rate)],
|
||||
context=[context],
|
||||
language=language,
|
||||
return_time_stamps=return_ts,
|
||||
)
|
||||
res = results[0]
|
||||
full_text_parts.append(res.text)
|
||||
if return_ts and hasattr(res, "time_stamps") and res.time_stamps:
|
||||
for ts in res.time_stamps:
|
||||
word = ASRWord(start=ts.start_time, end=ts.end_time, text=ts.text)
|
||||
segments.append(ASRSegment(start=word.start, end=word.end, text=word.text, words=[word]))
|
||||
|
||||
text = " ".join([t for t in full_text_parts if t]).strip()
|
||||
output_language = getattr(results[0], "language", None)
|
||||
if translate_mode:
|
||||
output_language = target_language
|
||||
|
||||
raw = None
|
||||
if warnings or notes:
|
||||
raw = {}
|
||||
if warnings:
|
||||
raw["warnings"] = warnings
|
||||
if notes:
|
||||
raw["notes"] = notes
|
||||
|
||||
return ASRResult(text=text, language=output_language, segments=segments, raw=raw)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Adapter between the suite's TTS processors and an audio.cpp session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
|
||||
|
||||
_CACHE_SAMPLE_RATES: Dict[str, int] = {}
|
||||
_CACHE_SAMPLE_RATES_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_session(config: Mapping[str, Any]):
|
||||
"""Import lazily so the node can still be discovered before optional setup."""
|
||||
from utils.audio_cpp.session import get_audio_cpp_session
|
||||
|
||||
return get_audio_cpp_session(dict(config))
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
class AudioCppEngineAdapter:
|
||||
"""Build generic ``/v1/tasks/run`` requests and retain their real sample rate."""
|
||||
|
||||
_COMMON_REQUEST_FIELDS = (
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"repetition_penalty",
|
||||
"max_tokens",
|
||||
"max_steps",
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"speaking_rate",
|
||||
)
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = dict(config or {})
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_sample_rate: Optional[int] = None
|
||||
self._reference_files: Dict[str, str] = {}
|
||||
self._reference_lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def sample_rate(self) -> Optional[int]:
|
||||
return self._last_sample_rate
|
||||
|
||||
def update_config(self, new_config: Optional[Dict[str, Any]]) -> None:
|
||||
self.config = dict(new_config or {})
|
||||
|
||||
@staticmethod
|
||||
def _reference_text(voice_ref: Any) -> str:
|
||||
if not isinstance(voice_ref, Mapping):
|
||||
return ""
|
||||
return str(
|
||||
voice_ref.get("reference_text")
|
||||
or voice_ref.get("prompt_text")
|
||||
or voice_ref.get("text")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _materialize_reference(self, voice_ref: Any) -> Tuple[Optional[str], str, str, Optional[str]]:
|
||||
"""Return path, transcript, stable hash, and the path that must be removed."""
|
||||
reference_text = AudioCppEngineAdapter._reference_text(voice_ref)
|
||||
if not isinstance(voice_ref, Mapping):
|
||||
return None, reference_text, "default_voice", None
|
||||
|
||||
audio = effective_voice_audio(voice_ref)
|
||||
if audio is None:
|
||||
return None, reference_text, "default_voice", None
|
||||
|
||||
if isinstance(audio, (str, os.PathLike)):
|
||||
path = os.path.abspath(os.path.expanduser(os.fspath(audio)))
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"audio.cpp reference audio not found: {path}")
|
||||
component = generate_stable_audio_component(audio_file_path=path)
|
||||
return path, reference_text, component, None
|
||||
|
||||
if isinstance(audio, Mapping):
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = audio.get("sample_rate")
|
||||
audio_dict = dict(audio)
|
||||
elif torch.is_tensor(audio):
|
||||
waveform = audio
|
||||
sample_rate = voice_ref.get("sample_rate")
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
else:
|
||||
raise TypeError(f"Unsupported audio.cpp voice reference type: {type(audio).__name__}")
|
||||
|
||||
if not torch.is_tensor(waveform):
|
||||
raise TypeError("audio.cpp reference audio must contain a waveform tensor")
|
||||
if sample_rate is None or int(sample_rate) <= 0:
|
||||
raise ValueError("audio.cpp reference audio must contain a positive sample_rate")
|
||||
|
||||
audio_dict["sample_rate"] = int(sample_rate)
|
||||
component = generate_stable_audio_component(reference_audio=audio_dict)
|
||||
if component not in {"ref_audio_error", "ref_audio_error_not_tensor"}:
|
||||
with self._reference_lock:
|
||||
cached_path = self._reference_files.get(component)
|
||||
if cached_path and os.path.isfile(cached_path):
|
||||
return cached_path, reference_text, component, None
|
||||
temp_path = os.path.abspath(
|
||||
AudioProcessingUtils.save_audio_to_temp_file(waveform, int(sample_rate))
|
||||
)
|
||||
self._reference_files[component] = temp_path
|
||||
return temp_path, reference_text, component, None
|
||||
|
||||
# Hash failures must not make unrelated references share one file.
|
||||
temp_path = os.path.abspath(
|
||||
AudioProcessingUtils.save_audio_to_temp_file(waveform, int(sample_rate))
|
||||
)
|
||||
return temp_path, reference_text, component, temp_path
|
||||
|
||||
def close(self) -> None:
|
||||
with self._reference_lock:
|
||||
paths = list(self._reference_files.values())
|
||||
self._reference_files.clear()
|
||||
for path in paths:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _advanced_options(self) -> Dict[str, Any]:
|
||||
value = self.config.get(
|
||||
"advanced_options",
|
||||
self.config.get("request_options", self.config.get("advanced_json", {})),
|
||||
)
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid audio.cpp advanced JSON: {exc.msg}") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("audio.cpp advanced options must be a JSON object")
|
||||
return dict(value)
|
||||
|
||||
def _resolved_task(self, session: Any) -> str:
|
||||
requested = str(self.config.get("task", self.config.get("requested_task", "auto"))).lower()
|
||||
for source in (session, getattr(session, "config", None)):
|
||||
if source is None:
|
||||
continue
|
||||
value = source.get("task") if isinstance(source, Mapping) else getattr(source, "task", None)
|
||||
if str(value).lower() in {"tts", "clon", "vdes"}:
|
||||
return str(value).lower()
|
||||
|
||||
if requested in {"tts", "clon", "vdes"}:
|
||||
return requested
|
||||
if str(self.config.get("connection_mode", "auto")).lower() == "external_server":
|
||||
return "auto"
|
||||
try:
|
||||
from utils.audio_cpp.catalog import resolve_task
|
||||
|
||||
return str(
|
||||
resolve_task(
|
||||
self.config.get("family", ""),
|
||||
self.config.get("package_id", ""),
|
||||
requested="auto",
|
||||
)
|
||||
).lower()
|
||||
except (ImportError, KeyError, TypeError, ValueError):
|
||||
return "tts"
|
||||
|
||||
def _build_request(
|
||||
self,
|
||||
text: str,
|
||||
voice_path: Optional[str],
|
||||
reference_text: str,
|
||||
seed: int,
|
||||
advanced: Dict[str, Any],
|
||||
task: str,
|
||||
) -> Dict[str, Any]:
|
||||
request: Dict[str, Any] = {"text": text, "seed": str(int(seed)), "options": advanced}
|
||||
del task # The persistent session owns its one configured model/task.
|
||||
|
||||
language = str(self.config.get("language", "")).strip()
|
||||
if language and language.lower() not in {"auto", "none"}:
|
||||
request["language"] = language
|
||||
voice_id = str(self.config.get("voice_id", self.config.get("voice", ""))).strip()
|
||||
if voice_id:
|
||||
request["voice_id"] = voice_id
|
||||
if voice_path:
|
||||
request["voice_ref"] = voice_path
|
||||
if reference_text:
|
||||
request["reference_text"] = reference_text
|
||||
instruct = str(self.config.get("instruct", "")).strip()
|
||||
if instruct:
|
||||
request["instruct"] = instruct
|
||||
|
||||
for key in self._COMMON_REQUEST_FIELDS:
|
||||
value = self.config.get(key)
|
||||
if value is not None and value != "":
|
||||
request[key] = value
|
||||
return request
|
||||
|
||||
def _cache_key(
|
||||
self,
|
||||
text: str,
|
||||
audio_component: str,
|
||||
reference_text: str,
|
||||
seed: int,
|
||||
task: str,
|
||||
advanced: Dict[str, Any],
|
||||
character_name: Optional[str],
|
||||
session: Any,
|
||||
) -> str:
|
||||
session_config = getattr(session, "config", {})
|
||||
if not isinstance(session_config, Mapping):
|
||||
session_config = {}
|
||||
session_family = getattr(session, "family", None) or session_config.get(
|
||||
"family", self.config.get("family", "")
|
||||
)
|
||||
session_model_id = getattr(session, "model_id", None) or session_config.get(
|
||||
"model_id", self.config.get("model_id", "")
|
||||
)
|
||||
# Owned servers use a random loopback port on every restart; that port is
|
||||
# transport state, not model identity. External endpoints are stable and
|
||||
# must participate in the cache key.
|
||||
if bool(getattr(session, "owned", False)):
|
||||
session_endpoint = ""
|
||||
else:
|
||||
session_endpoint = getattr(session, "endpoint", None) or self.config.get(
|
||||
"server_url", self.config.get("external_server_url", "")
|
||||
)
|
||||
extra_identity = {
|
||||
"options": advanced,
|
||||
"speaking_rate": self.config.get("speaking_rate"),
|
||||
"connection_mode": self.config.get("connection_mode", "auto"),
|
||||
"server_url": session_endpoint,
|
||||
"binary_path": session_config.get("binary_path", self.config.get("binary_path", "")),
|
||||
"backend": session_config.get("backend", self.config.get("backend", "")),
|
||||
"device": session_config.get("device", self.config.get("device", "")),
|
||||
"load_options": session_config.get("load_options", self.config.get("load_options", {})),
|
||||
"session_options": session_config.get(
|
||||
"session_options", self.config.get("session_options", {})
|
||||
),
|
||||
"default_request_options": session_config.get(
|
||||
"default_request_options", self.config.get("default_request_options", {})
|
||||
),
|
||||
}
|
||||
return self.audio_cache.generate_cache_key(
|
||||
"audio_cpp",
|
||||
text=text,
|
||||
audio_component=audio_component,
|
||||
reference_text=reference_text,
|
||||
family=session_family,
|
||||
package_id=session_config.get("package_id", self.config.get("package_id", "")),
|
||||
model_path=session_config.get("model_path", self.config.get("model_path", "")),
|
||||
model_id=session_model_id,
|
||||
task=task,
|
||||
language=self.config.get("language", ""),
|
||||
voice_id=self.config.get("voice_id", self.config.get("voice", "")),
|
||||
instruct=self.config.get("instruct", ""),
|
||||
temperature=self.config.get("temperature"),
|
||||
top_p=self.config.get("top_p"),
|
||||
top_k=self.config.get("top_k"),
|
||||
repetition_penalty=self.config.get("repetition_penalty"),
|
||||
max_tokens=self.config.get("max_tokens"),
|
||||
max_steps=self.config.get("max_steps"),
|
||||
num_inference_steps=self.config.get("num_inference_steps"),
|
||||
guidance_scale=self.config.get("guidance_scale"),
|
||||
seed=int(seed),
|
||||
request_options=_canonical_json(extra_identity),
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_result(result: Any) -> Tuple[torch.Tensor, int]:
|
||||
waveform = result.get("waveform") if isinstance(result, Mapping) else getattr(result, "waveform", None)
|
||||
sample_rate = result.get("sample_rate") if isinstance(result, Mapping) else getattr(result, "sample_rate", None)
|
||||
|
||||
if waveform is None:
|
||||
named = result.get("named_audio", {}) if isinstance(result, Mapping) else getattr(result, "named_audio", {})
|
||||
values = list(named.values()) if isinstance(named, Mapping) else list(named or [])
|
||||
if len(values) == 1:
|
||||
item = values[0]
|
||||
waveform = item.get("waveform") if isinstance(item, Mapping) else getattr(item, "waveform", None)
|
||||
sample_rate = sample_rate or (item.get("sample_rate") if isinstance(item, Mapping) else getattr(item, "sample_rate", None))
|
||||
|
||||
if waveform is None:
|
||||
raise RuntimeError("audio.cpp returned no primary audio output")
|
||||
if not torch.is_tensor(waveform):
|
||||
waveform = torch.as_tensor(waveform, dtype=torch.float32)
|
||||
waveform = waveform.detach().to(device="cpu", dtype=torch.float32)
|
||||
if waveform.dim() == 1:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
elif waveform.dim() == 3 and waveform.shape[0] == 1:
|
||||
waveform = waveform.squeeze(0)
|
||||
if waveform.dim() != 2:
|
||||
raise ValueError(f"audio.cpp waveform must be [channels, samples], got {tuple(waveform.shape)}")
|
||||
if sample_rate is None or int(sample_rate) <= 0:
|
||||
raise ValueError("audio.cpp returned an invalid sample rate")
|
||||
return waveform.contiguous(), int(sample_rate)
|
||||
|
||||
def generate_single(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]] = None,
|
||||
seed: int = 0,
|
||||
enable_audio_cache: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> Tuple[torch.Tensor, int]:
|
||||
stripped = str(text or "").strip()
|
||||
if not stripped:
|
||||
if self._last_sample_rate is None:
|
||||
raise ValueError("audio.cpp cannot determine a sample rate for empty text")
|
||||
return torch.zeros(1, 0, dtype=torch.float32), self._last_sample_rate
|
||||
|
||||
session = _get_session(self.config)
|
||||
task = self._resolved_task(session)
|
||||
advanced = self._advanced_options()
|
||||
cleanup_path: Optional[str] = None
|
||||
try:
|
||||
voice_path, reference_text, audio_component, cleanup_path = self._materialize_reference(voice_ref)
|
||||
cache_key = self._cache_key(
|
||||
stripped,
|
||||
audio_component,
|
||||
reference_text,
|
||||
seed,
|
||||
task,
|
||||
advanced,
|
||||
character_name,
|
||||
session,
|
||||
)
|
||||
if enable_audio_cache:
|
||||
cached = self.audio_cache.get_cached_audio(cache_key)
|
||||
with _CACHE_SAMPLE_RATES_LOCK:
|
||||
cached_rate = _CACHE_SAMPLE_RATES.get(cache_key)
|
||||
if cached is not None and cached_rate is not None:
|
||||
self._last_sample_rate = cached_rate
|
||||
return cached[0].clone(), cached_rate
|
||||
|
||||
request = self._build_request(stripped, voice_path, reference_text, seed, advanced, task)
|
||||
waveform, sample_rate = self._normalize_result(session.run(request))
|
||||
self._last_sample_rate = sample_rate
|
||||
if enable_audio_cache:
|
||||
duration = waveform.shape[-1] / sample_rate
|
||||
self.audio_cache.cache_audio(cache_key, waveform, duration)
|
||||
with _CACHE_SAMPLE_RATES_LOCK:
|
||||
_CACHE_SAMPLE_RATES[cache_key] = sample_rate
|
||||
return waveform, sample_rate
|
||||
finally:
|
||||
if cleanup_path:
|
||||
try:
|
||||
os.remove(cleanup_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
# Short alias for callers that do not use the older ``EngineAdapter`` suffix.
|
||||
AudioCppAdapter = AudioCppEngineAdapter
|
||||
@@ -0,0 +1,111 @@
|
||||
"""audio.cpp adapter for the Suite's unified Voice Changer node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Mapping
|
||||
|
||||
import torch
|
||||
|
||||
from engines.adapters.audio_cpp_adapter import AudioCppEngineAdapter
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
|
||||
|
||||
def _advanced_options(config: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
value = config.get("advanced_options", config.get("request_options", {}))
|
||||
if value in (None, ""):
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid audio.cpp advanced JSON: {exc.msg}") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("audio.cpp advanced options must be a JSON object")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _materialize(audio: Mapping[str, Any], label: str) -> str:
|
||||
waveform = audio.get("waveform")
|
||||
sample_rate = audio.get("sample_rate")
|
||||
if not torch.is_tensor(waveform):
|
||||
raise TypeError(f"audio.cpp {label} must contain a waveform tensor")
|
||||
if sample_rate is None or int(sample_rate) <= 0:
|
||||
raise ValueError(f"audio.cpp {label} must contain a positive sample_rate")
|
||||
return os.path.abspath(
|
||||
AudioProcessingUtils.save_audio_to_temp_file(waveform, int(sample_rate))
|
||||
)
|
||||
|
||||
|
||||
class AudioCppVoiceConversionAdapter:
|
||||
"""Convert source audio toward a target reference using an audio.cpp VC task."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = dict(config)
|
||||
|
||||
def _session_config(self) -> Dict[str, Any]:
|
||||
config = dict(self.config)
|
||||
if str(config.get("connection_mode", "auto")).lower() != "external_server":
|
||||
config["requested_task"] = "vc"
|
||||
config["task"] = "vc"
|
||||
return config
|
||||
|
||||
def convert_voice(
|
||||
self,
|
||||
source_audio: Dict[str, Any],
|
||||
target_audio: Dict[str, Any],
|
||||
refinement_passes: int = 1,
|
||||
) -> tuple[Dict[str, Any], str]:
|
||||
from utils.audio_cpp.session import get_audio_cpp_session
|
||||
|
||||
config = self._session_config()
|
||||
family = str(config.get("family", "")).strip()
|
||||
passes = max(1, int(refinement_passes))
|
||||
current = source_audio
|
||||
output_rate = int(source_audio["sample_rate"])
|
||||
|
||||
session = get_audio_cpp_session(config)
|
||||
if str(getattr(session, "task", "vc")) != "vc":
|
||||
raise ValueError(
|
||||
f"audio.cpp model '{session.model_id}' is configured for task "
|
||||
f"'{session.task}', not voice conversion"
|
||||
)
|
||||
|
||||
for pass_index in range(passes):
|
||||
source_path = _materialize(current, "source audio")
|
||||
target_path = _materialize(target_audio, "target reference audio")
|
||||
try:
|
||||
request = {
|
||||
"audio": source_path,
|
||||
"voice_ref": target_path,
|
||||
"source_audio": source_path,
|
||||
"target_voice": target_path,
|
||||
"options": _advanced_options(config),
|
||||
}
|
||||
print(
|
||||
f"🔄 audio.cpp VC: {family or 'external model'} pass "
|
||||
f"{pass_index + 1}/{passes}..."
|
||||
)
|
||||
result = session.run(request)
|
||||
waveform, output_rate = AudioCppEngineAdapter._normalize_result(result)
|
||||
current = {"waveform": waveform.unsqueeze(0), "sample_rate": output_rate}
|
||||
finally:
|
||||
for path in (source_path, target_path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
info = (
|
||||
f"Model family: {family or getattr(session, 'family', 'external')}\n"
|
||||
f"Model ID: {session.model_id}\n"
|
||||
f"Task: voice conversion\n"
|
||||
f"Refinement passes: {passes}\n"
|
||||
f"Output sample rate: {output_rate} Hz\n"
|
||||
"Conversion completed successfully"
|
||||
)
|
||||
return current, info
|
||||
|
||||
|
||||
__all__ = ["AudioCppVoiceConversionAdapter"]
|
||||
@@ -23,6 +23,7 @@ from engines.cosyvoice.cosyvoice import CosyVoiceEngine
|
||||
from engines.cosyvoice.cosyvoice_downloader import cosyvoice_downloader
|
||||
from utils.text.character_parser import character_parser
|
||||
from utils.voice.discovery import get_character_mapping, get_available_characters
|
||||
from utils.voice.character_logging import resolved_character_label
|
||||
from utils.audio.cache import get_audio_cache
|
||||
|
||||
|
||||
@@ -336,7 +337,7 @@ class CosyVoiceAdapter:
|
||||
speaker_audio = char_audio
|
||||
if char_text:
|
||||
reference_text = char_text
|
||||
print(f"📖 Using character voice '{character_name}'")
|
||||
print(f"📖 Using character voice '{resolved_character_label(character_name, speaker_audio)}'")
|
||||
|
||||
# Generate cache key for this segment
|
||||
segment_cache_key = self._generate_cache_key(
|
||||
@@ -352,7 +353,7 @@ class CosyVoiceAdapter:
|
||||
# Check cache first
|
||||
cached_segment_audio = self.audio_cache.get_cached_audio(segment_cache_key)
|
||||
if cached_segment_audio:
|
||||
print(f"💾 Using cached CosyVoice3 segment for '{character_name}'")
|
||||
print(f"💾 Using cached CosyVoice3 segment for '{resolved_character_label(character_name, speaker_audio)}'")
|
||||
segment_audio = cached_segment_audio[0]
|
||||
else:
|
||||
# Convert CosyVoice paralinguistic tags from <tag> to [tag]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Dots TTS engine adapter.
|
||||
|
||||
Translates TTS Audio Suite processor calls into the official dots.tts runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.models.language_mapper import resolve_language_alias
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
from engines.dots_tts.languages import normalize_dots_language
|
||||
|
||||
|
||||
class DotsTTSEngineAdapter:
|
||||
"""Adapter for official Dots TTS inference."""
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = config.copy() if config else {}
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_config: Optional[ModelLoadConfig] = None
|
||||
self._load_signature = None
|
||||
|
||||
def update_config(self, new_config: Dict[str, Any]):
|
||||
self.config = new_config.copy() if new_config else {}
|
||||
|
||||
def _build_load_signature(self) -> Tuple[Any, ...]:
|
||||
return (
|
||||
self.config.get("model_variant", "dots.tts-soar"),
|
||||
self.config.get("device", "auto"),
|
||||
self.config.get("precision", "auto"),
|
||||
bool(self.config.get("optimize", False)),
|
||||
int(self.config.get("max_generate_length", 500)),
|
||||
)
|
||||
|
||||
def load_model(
|
||||
self,
|
||||
model_variant: str,
|
||||
device: str = "auto",
|
||||
precision: str = "auto",
|
||||
optimize: bool = False,
|
||||
max_generate_length: int = 500,
|
||||
):
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="dots_tts",
|
||||
model_type="tts",
|
||||
model_name=model_variant,
|
||||
model_path=model_variant,
|
||||
device=device,
|
||||
additional_params={
|
||||
"precision": precision,
|
||||
"optimize": bool(optimize),
|
||||
"max_generate_length": int(max_generate_length),
|
||||
},
|
||||
)
|
||||
self._last_config = config
|
||||
unified_model_interface.load_model(config)
|
||||
|
||||
def _ensure_model_loaded(self):
|
||||
signature = self._build_load_signature()
|
||||
if signature == self._load_signature and self._last_config is not None:
|
||||
return
|
||||
|
||||
self.load_model(
|
||||
model_variant=self.config.get("model_variant", "dots.tts-soar"),
|
||||
device=self.config.get("device", "auto"),
|
||||
precision=self.config.get("precision", "auto"),
|
||||
optimize=self.config.get("optimize", False),
|
||||
max_generate_length=self.config.get("max_generate_length", 500),
|
||||
)
|
||||
self._load_signature = signature
|
||||
|
||||
def _get_engine(self):
|
||||
if self._last_config is None:
|
||||
self._ensure_model_loaded()
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
return unified_model_interface.load_model(self._last_config)
|
||||
|
||||
def _extract_voice_reference(self, voice_ref: Optional[Dict[str, Any]]) -> Tuple[Optional[str], str, str]:
|
||||
if not voice_ref or not isinstance(voice_ref, dict):
|
||||
return None, "", "default_voice"
|
||||
|
||||
prompt_text = (
|
||||
voice_ref.get("reference_text")
|
||||
or voice_ref.get("prompt_text")
|
||||
or voice_ref.get("text")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
ref_audio = effective_voice_audio(voice_ref)
|
||||
|
||||
if ref_audio is None:
|
||||
return None, prompt_text, "default_voice"
|
||||
|
||||
if isinstance(ref_audio, str):
|
||||
return ref_audio, prompt_text, generate_stable_audio_component(audio_file_path=ref_audio)
|
||||
|
||||
if isinstance(ref_audio, dict) and "waveform" in ref_audio:
|
||||
temp_path = AudioProcessingUtils.save_audio_to_temp_file(
|
||||
ref_audio["waveform"],
|
||||
ref_audio.get("sample_rate", self.SAMPLE_RATE),
|
||||
)
|
||||
return temp_path, prompt_text, generate_stable_audio_component(reference_audio=ref_audio)
|
||||
|
||||
if torch.is_tensor(ref_audio):
|
||||
sample_rate = int(voice_ref.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": ref_audio, "sample_rate": sample_rate}
|
||||
temp_path = AudioProcessingUtils.save_audio_to_temp_file(ref_audio, sample_rate)
|
||||
return temp_path, prompt_text, generate_stable_audio_component(reference_audio=audio_dict)
|
||||
|
||||
raise TypeError(f"Unsupported Dots TTS voice reference type: {type(ref_audio)}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
if language is None:
|
||||
return None
|
||||
normalized = str(language).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
normalized_language = normalize_dots_language(normalized)
|
||||
if normalized_language is not None:
|
||||
return normalized_language
|
||||
resolved = resolve_language_alias(normalized)
|
||||
return normalize_dots_language(resolved)
|
||||
|
||||
def generate_single(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
seed: int = 0,
|
||||
enable_audio_cache: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> torch.Tensor:
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return torch.zeros(1, 0, dtype=torch.float32)
|
||||
|
||||
self._ensure_model_loaded()
|
||||
engine = self._get_engine()
|
||||
|
||||
prompt_audio_path, prompt_text, audio_component = self._extract_voice_reference(voice_ref)
|
||||
language = self._normalize_language(self.config.get("language", "auto"))
|
||||
model_variant = self.config.get("model_variant", "dots.tts-soar")
|
||||
template_name = self.config.get("template_name", "tts")
|
||||
num_steps = int(self.config.get("num_steps", 10))
|
||||
guidance_scale = float(self.config.get("guidance_scale", 1.2))
|
||||
speaker_scale = float(self.config.get("speaker_scale", 1.5))
|
||||
normalize_text = bool(self.config.get("normalize_text", False))
|
||||
precision = self.config.get("precision", "auto")
|
||||
optimize = bool(self.config.get("optimize", False))
|
||||
max_generate_length = int(self.config.get("max_generate_length", 500))
|
||||
|
||||
cache_key = None
|
||||
if enable_audio_cache:
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"dots_tts",
|
||||
text=stripped,
|
||||
audio_component=audio_component,
|
||||
prompt_text=prompt_text,
|
||||
model_variant=model_variant,
|
||||
language=language or "none",
|
||||
template_name=template_name,
|
||||
num_steps=num_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
speaker_scale=speaker_scale,
|
||||
normalize_text=normalize_text,
|
||||
max_generate_length=max_generate_length,
|
||||
precision=precision,
|
||||
optimize=optimize,
|
||||
seed=int(seed or 0),
|
||||
device=self.config.get("device", "auto"),
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached Dots TTS audio for '{character_name or 'narrator'}': '{stripped[:30]}...'")
|
||||
return cached_audio[0]
|
||||
|
||||
result = engine.generate(
|
||||
text=stripped,
|
||||
prompt_audio_path=prompt_audio_path,
|
||||
prompt_text=prompt_text or None,
|
||||
language=language,
|
||||
num_steps=num_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
speaker_scale=speaker_scale,
|
||||
normalize_text=normalize_text,
|
||||
template_name=template_name,
|
||||
seed=int(seed or 0),
|
||||
)
|
||||
|
||||
audio_tensor = result["audio"]
|
||||
if not isinstance(audio_tensor, torch.Tensor):
|
||||
audio_tensor = torch.tensor(audio_tensor, dtype=torch.float32)
|
||||
audio_tensor = audio_tensor.detach().float().cpu()
|
||||
if audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
|
||||
if enable_audio_cache and cache_key:
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, "dots_tts")
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
|
||||
return audio_tensor
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Adapter between unified TTS processing and official DramaBox inference."""
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import torch
|
||||
import torchaudio
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
|
||||
|
||||
class DramaBoxEngineAdapter:
|
||||
"""Translate suite voice/config/cache data into DramaBox calls."""
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
SILENCE_RMS_THRESHOLD = 1e-3
|
||||
SILENCE_PEAK_THRESHOLD = 2e-2
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = config.copy() if config else {}
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_config: Optional[ModelLoadConfig] = None
|
||||
self._load_signature = None
|
||||
self._lora_signature = None
|
||||
self.last_generation_status: Dict[str, Any] = {"near_silent": False}
|
||||
|
||||
def update_config(self, new_config: Dict[str, Any]):
|
||||
self.config = new_config.copy() if new_config else {}
|
||||
|
||||
@staticmethod
|
||||
def _lora_revision(path: Any) -> str:
|
||||
"""Return a cheap cache token that changes when a managed adapter is replaced."""
|
||||
value = str(path or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
candidate = os.path.abspath(os.path.expanduser(value))
|
||||
if os.path.isfile(candidate):
|
||||
stat = os.stat(candidate)
|
||||
return f"{candidate}:{stat.st_size}:{stat.st_mtime_ns}"
|
||||
if os.path.isdir(candidate):
|
||||
entries = []
|
||||
for item in os.listdir(candidate):
|
||||
if not item.endswith(".safetensors"):
|
||||
continue
|
||||
item_path = os.path.join(candidate, item)
|
||||
stat = os.stat(item_path)
|
||||
entries.append(f"{item}:{stat.st_size}:{stat.st_mtime_ns}")
|
||||
return f"{candidate}|{'|'.join(sorted(entries))}"
|
||||
except OSError:
|
||||
pass
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _warn_if_near_silent(
|
||||
cls,
|
||||
audio: torch.Tensor,
|
||||
*,
|
||||
character_name: Optional[str],
|
||||
seed: int,
|
||||
cached: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Warn about clearly near-silent model output without altering it."""
|
||||
if not isinstance(audio, torch.Tensor) or audio.numel() == 0:
|
||||
return None
|
||||
|
||||
samples = torch.nan_to_num(audio.detach().float().cpu())
|
||||
rms = float(samples.square().mean().sqrt())
|
||||
peak = float(samples.abs().max())
|
||||
if rms >= cls.SILENCE_RMS_THRESHOLD or peak >= cls.SILENCE_PEAK_THRESHOLD:
|
||||
return None
|
||||
|
||||
rms_db = 20.0 * math.log10(max(rms, 1e-12))
|
||||
peak_db = 20.0 * math.log10(max(peak, 1e-12))
|
||||
source = "cached " if cached else ""
|
||||
print(
|
||||
f"\n⚠️ DramaBox generated a near-silent {source}segment for "
|
||||
f"'{character_name or 'narrator'}' "
|
||||
f"(RMS {rms_db:.1f} dBFS, peak {peak_db:.1f} dBFS)."
|
||||
)
|
||||
print(
|
||||
"⚠️ This can depend on generation duration, reference duration, "
|
||||
"reference audio, guidance settings, and seed."
|
||||
)
|
||||
print(
|
||||
"⚠️ Try changing those parameters for this segment; another seed "
|
||||
"may help, but is not guaranteed to fix it.\n"
|
||||
)
|
||||
return {
|
||||
"near_silent": True,
|
||||
"character": character_name or "narrator",
|
||||
"seed": int(seed),
|
||||
"rms_dbfs": rms_db,
|
||||
"peak_dbfs": peak_db,
|
||||
"cached": bool(cached),
|
||||
}
|
||||
|
||||
def _build_load_signature(self) -> Tuple[Any, ...]:
|
||||
"""Identity of the expensive base runtime, excluding live LoRA state."""
|
||||
return (
|
||||
self.config.get("model_name", "DramaBox"),
|
||||
self.config.get("device", "auto"),
|
||||
self.config.get("precision", "auto"),
|
||||
self.config.get("memory_mode", "fast"),
|
||||
self.config.get("transformer_quantization", "none"),
|
||||
bool(self.config.get("compile_model", False)),
|
||||
)
|
||||
|
||||
def _build_lora_signature(self) -> Tuple[Any, ...]:
|
||||
path = self.config.get("lora_path", "")
|
||||
return (
|
||||
str(path or "").strip(),
|
||||
self._lora_revision(path),
|
||||
float(self.config.get("lora_strength", 1.0)),
|
||||
)
|
||||
|
||||
def _ensure_model_loaded(self):
|
||||
signature = self._build_load_signature()
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
if signature != self._load_signature or self._last_config is None:
|
||||
self._last_config = ModelLoadConfig(
|
||||
engine_name="dramabox",
|
||||
model_type="tts",
|
||||
model_name=self.config.get("model_name", "DramaBox"),
|
||||
device=self.config.get("device", "auto"),
|
||||
additional_params={
|
||||
"precision": self.config.get("precision", "auto"),
|
||||
"memory_mode": self.config.get("memory_mode", "fast"),
|
||||
"transformer_quantization": self.config.get(
|
||||
"transformer_quantization", "none"
|
||||
),
|
||||
"compile_model": bool(self.config.get("compile_model", False)),
|
||||
},
|
||||
)
|
||||
self._load_signature = signature
|
||||
self._lora_signature = None
|
||||
|
||||
engine = unified_model_interface.load_model(self._last_config)
|
||||
lora_signature = self._build_lora_signature()
|
||||
if lora_signature != self._lora_signature:
|
||||
lora_path, lora_revision, lora_strength = lora_signature
|
||||
engine.set_lora(
|
||||
lora_path=lora_path,
|
||||
strength=lora_strength,
|
||||
revision=lora_revision,
|
||||
)
|
||||
self._lora_signature = lora_signature
|
||||
return engine
|
||||
|
||||
def _get_engine(self):
|
||||
return self._ensure_model_loaded()
|
||||
|
||||
def _extract_voice_reference(
|
||||
self, voice_ref: Optional[Dict[str, Any]]
|
||||
) -> Tuple[Optional[str], str, bool]:
|
||||
if not isinstance(voice_ref, dict):
|
||||
return None, "default_voice", False
|
||||
|
||||
audio = effective_voice_audio(voice_ref)
|
||||
if audio is None:
|
||||
return None, "default_voice", False
|
||||
if isinstance(audio, str):
|
||||
return (
|
||||
audio,
|
||||
generate_stable_audio_component(audio_file_path=audio),
|
||||
False,
|
||||
)
|
||||
if isinstance(audio, dict) and "waveform" in audio:
|
||||
path = AudioProcessingUtils.save_audio_to_temp_file(
|
||||
audio["waveform"], audio.get("sample_rate", self.SAMPLE_RATE)
|
||||
)
|
||||
return path, generate_stable_audio_component(reference_audio=audio), True
|
||||
if torch.is_tensor(audio):
|
||||
sample_rate = int(voice_ref.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": audio, "sample_rate": sample_rate}
|
||||
path = AudioProcessingUtils.save_audio_to_temp_file(audio, sample_rate)
|
||||
return (
|
||||
path,
|
||||
generate_stable_audio_component(reference_audio=audio_dict),
|
||||
True,
|
||||
)
|
||||
raise TypeError(f"Unsupported DramaBox voice reference: {type(audio)}")
|
||||
|
||||
def generate_single(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
seed: int = 42,
|
||||
enable_audio_cache: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> torch.Tensor:
|
||||
prompt = (text or "").strip()
|
||||
if not prompt:
|
||||
return torch.zeros(1, 0, dtype=torch.float32)
|
||||
|
||||
voice_path, audio_component, remove_voice_path = self._extract_voice_reference(
|
||||
voice_ref
|
||||
)
|
||||
cfg_scale = float(self.config.get("cfg_scale", 2.5))
|
||||
stg_scale = float(self.config.get("stg_scale", 1.5))
|
||||
duration_multiplier = float(self.config.get("duration_multiplier", 1.1))
|
||||
gen_duration = float(self.config.get("gen_duration", 0.0))
|
||||
ref_duration = float(self.config.get("ref_duration", 10.0))
|
||||
rescale_scale = self.config.get("rescale_scale", "auto")
|
||||
watermark = bool(self.config.get("watermark", False))
|
||||
negative_prompt = str(self.config.get("negative_prompt", ""))
|
||||
model_name = self.config.get("model_name", "DramaBox")
|
||||
|
||||
cache_key = None
|
||||
if enable_audio_cache:
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"dramabox",
|
||||
text=prompt,
|
||||
audio_component=audio_component,
|
||||
model_name=model_name,
|
||||
cfg_scale=cfg_scale,
|
||||
stg_scale=stg_scale,
|
||||
duration_multiplier=duration_multiplier,
|
||||
gen_duration=gen_duration,
|
||||
ref_duration=ref_duration,
|
||||
rescale_scale=rescale_scale,
|
||||
watermark=watermark,
|
||||
prompt_template=str(
|
||||
self.config.get("prompt_template", '"{seg}"')
|
||||
),
|
||||
negative_prompt=negative_prompt,
|
||||
precision=self.config.get("precision", "auto"),
|
||||
transformer_quantization=self.config.get(
|
||||
"transformer_quantization", "none"
|
||||
),
|
||||
memory_mode=self.config.get("memory_mode", "fast"),
|
||||
compile_model=bool(self.config.get("compile_model", False)),
|
||||
lora_path=self.config.get("lora_path", ""),
|
||||
lora_strength=float(self.config.get("lora_strength", 1.0)),
|
||||
lora_revision=self._lora_revision(self.config.get("lora_path", "")),
|
||||
seed=int(seed),
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
cached = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached:
|
||||
print(
|
||||
f"💾 Using cached DramaBox audio for "
|
||||
f"'{character_name or 'narrator'}': '{prompt[:30]}...'"
|
||||
)
|
||||
self.last_generation_status = self._warn_if_near_silent(
|
||||
cached[0],
|
||||
character_name=character_name,
|
||||
seed=int(seed),
|
||||
cached=True,
|
||||
) or {"near_silent": False}
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
result = self._get_engine().generate(
|
||||
prompt=prompt,
|
||||
voice_ref_path=voice_path,
|
||||
cfg_scale=cfg_scale,
|
||||
stg_scale=stg_scale,
|
||||
duration_multiplier=duration_multiplier,
|
||||
gen_duration=gen_duration,
|
||||
ref_duration=ref_duration,
|
||||
rescale_scale=rescale_scale,
|
||||
watermark=watermark,
|
||||
negative_prompt=negative_prompt,
|
||||
seed=int(seed),
|
||||
)
|
||||
finally:
|
||||
if remove_voice_path and voice_path:
|
||||
try:
|
||||
os.unlink(voice_path)
|
||||
except OSError:
|
||||
pass
|
||||
audio = result["audio"]
|
||||
if not isinstance(audio, torch.Tensor):
|
||||
audio = torch.tensor(audio, dtype=torch.float32)
|
||||
audio = audio.detach().float().cpu()
|
||||
if audio.dim() == 1:
|
||||
audio = audio.unsqueeze(0)
|
||||
|
||||
if int(result.get("sample_rate", self.SAMPLE_RATE)) != self.SAMPLE_RATE:
|
||||
audio = torchaudio.functional.resample(
|
||||
audio, int(result["sample_rate"]), self.SAMPLE_RATE
|
||||
)
|
||||
|
||||
self.last_generation_status = self._warn_if_near_silent(
|
||||
audio,
|
||||
character_name=character_name,
|
||||
seed=int(seed),
|
||||
) or {"near_silent": False}
|
||||
|
||||
if enable_audio_cache and cache_key:
|
||||
duration = audio.shape[-1] / self.SAMPLE_RATE
|
||||
self.audio_cache.cache_audio(cache_key, audio, duration)
|
||||
return audio
|
||||
@@ -0,0 +1,821 @@
|
||||
"""
|
||||
Echo-TTS Engine Adapter
|
||||
|
||||
Provides a unified interface for Echo-TTS integration with TTS Audio Suite.
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from functools import partial
|
||||
|
||||
import torch
|
||||
|
||||
# Add project root to path
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.text.chunking import ImprovedChatterBoxChunker
|
||||
from utils.audio.chunk_timing import ChunkTimingHelper
|
||||
from utils.models.extra_paths import get_preferred_download_path
|
||||
|
||||
|
||||
class EchoTTSEngineAdapter:
|
||||
"""Adapter for Echo-TTS model loading and inference."""
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
MAX_REF_SECONDS = 300 # 5 minutes
|
||||
DEFAULT_SEQUENCE_LENGTH = 640
|
||||
DEFAULT_SEQUENCE_SECONDS = 30.0
|
||||
FORCE_KV_GUARD_MIN_SEQUENCE_LENGTH = 160
|
||||
FORCE_KV_GUARD_CHAR_RATE = 12.0
|
||||
FORCE_KV_GUARD_BASE_SECONDS = 1.25
|
||||
FORCE_KV_GUARD_SAFETY_MULTIPLIER = 1.35
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config.copy() if config else {}
|
||||
self.model = None
|
||||
self.ae = None
|
||||
self.pca_state = None
|
||||
self._sample_pipeline = None
|
||||
self._loaded_key = None
|
||||
self._wrapper = None
|
||||
self.audio_cache = get_audio_cache()
|
||||
self.job_tracker = None
|
||||
|
||||
def start_job(self, total_blocks: int, block_texts: list):
|
||||
"""Initialize job tracker for weighted multi-block progress estimation."""
|
||||
import time
|
||||
|
||||
normalized_texts = [max(1, int(v)) for v in (block_texts or [])]
|
||||
self.job_tracker = {
|
||||
"start_time": time.time(),
|
||||
"total_blocks": max(0, int(total_blocks)),
|
||||
"blocks_completed": 0,
|
||||
"block_texts": normalized_texts,
|
||||
"total_text": sum(normalized_texts),
|
||||
"text_completed": 0,
|
||||
"current_block_text": 0,
|
||||
"current_block_steps": 0,
|
||||
"current_block_total_steps": 0,
|
||||
"total_steps_completed": 0,
|
||||
}
|
||||
|
||||
def set_current_block(self, block_idx: int):
|
||||
"""Set current block metadata for weighted ETA calculation."""
|
||||
if not self.job_tracker:
|
||||
return
|
||||
block_texts = self.job_tracker.get("block_texts", [])
|
||||
if block_idx < 0 or block_idx >= len(block_texts):
|
||||
return
|
||||
self.job_tracker["current_block_text"] = block_texts[block_idx]
|
||||
self.job_tracker["current_block_steps"] = 0
|
||||
self.job_tracker["current_block_total_steps"] = 0
|
||||
|
||||
def complete_block(self):
|
||||
"""Mark current block as completed in the job tracker."""
|
||||
if not self.job_tracker:
|
||||
return
|
||||
self.job_tracker["blocks_completed"] += 1
|
||||
self.job_tracker["text_completed"] += self.job_tracker.get("current_block_text", 0)
|
||||
self.job_tracker["total_steps_completed"] += self.job_tracker.get("current_block_steps", 0)
|
||||
|
||||
def end_job(self):
|
||||
"""Clear active job tracker."""
|
||||
self.job_tracker = None
|
||||
|
||||
def update_config(self, new_config: Dict[str, Any]):
|
||||
self.config = new_config.copy() if new_config else {}
|
||||
# If critical load params changed, force reload on next call
|
||||
new_key = self._get_load_key()
|
||||
if self._loaded_key != new_key:
|
||||
self.model = None
|
||||
self.ae = None
|
||||
self.pca_state = None
|
||||
self._sample_pipeline = None
|
||||
self._loaded_key = None
|
||||
|
||||
def _get_load_key(self) -> str:
|
||||
model_id = self.config.get("model", "jordand/echo-tts-base")
|
||||
device = self._resolve_device(self.config.get("device", "auto"))
|
||||
return f"{model_id}::{device}"
|
||||
|
||||
@staticmethod
|
||||
def _filter_kwargs(fn, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
signature = inspect.signature(fn)
|
||||
except (TypeError, ValueError):
|
||||
return kwargs
|
||||
return {key: value for key, value in kwargs.items() if key in signature.parameters}
|
||||
|
||||
def _resolve_device(self, device: str) -> str:
|
||||
device = (device or "auto").lower()
|
||||
if device == "cuda" and not torch.cuda.is_available():
|
||||
print("WARNING: Echo-TTS: CUDA requested but not available. Falling back to CPU.")
|
||||
return "cpu"
|
||||
|
||||
from utils.device import resolve_torch_device
|
||||
resolved = resolve_torch_device(device)
|
||||
|
||||
if resolved == "cpu":
|
||||
print("WARNING: Echo-TTS running on CPU will be very slow.")
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _normalize_prompt_text(text: str) -> str:
|
||||
"""
|
||||
Normalize prompt text following Echo-TTS inference behavior.
|
||||
|
||||
Add [S1] only when text is plain content without speaker/metadata tags.
|
||||
"""
|
||||
if not text:
|
||||
return "[S1]"
|
||||
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return "[S1]"
|
||||
|
||||
if (
|
||||
cleaned.startswith("[")
|
||||
or cleaned.startswith("(")
|
||||
or "S1" in cleaned
|
||||
or "S2" in cleaned
|
||||
):
|
||||
return cleaned
|
||||
|
||||
return f"[S1] {cleaned}".strip()
|
||||
|
||||
@staticmethod
|
||||
def _count_prompt_chars_for_length_guard(prompt_text: str) -> int:
|
||||
"""Count visible prompt characters, excluding bracketed metadata tags."""
|
||||
without_tags = re.sub(r"\[[^\]]+\]", " ", prompt_text or "")
|
||||
return len(" ".join(without_tags.split()))
|
||||
|
||||
@classmethod
|
||||
def _estimate_force_kv_sequence_length(cls, prompt_text: str) -> int:
|
||||
"""
|
||||
Estimate a safer sequence length for Force Speaker KV runs.
|
||||
|
||||
Force-KV can prevent the latent flattening crop from triggering, so using a full
|
||||
default sequence window (640) on short prompts can yield overlong output.
|
||||
"""
|
||||
prompt_chars = cls._count_prompt_chars_for_length_guard(prompt_text)
|
||||
frames_per_second = cls.DEFAULT_SEQUENCE_LENGTH / cls.DEFAULT_SEQUENCE_SECONDS
|
||||
estimated_seconds = (prompt_chars / cls.FORCE_KV_GUARD_CHAR_RATE) + cls.FORCE_KV_GUARD_BASE_SECONDS
|
||||
estimated_seconds *= cls.FORCE_KV_GUARD_SAFETY_MULTIPLIER
|
||||
estimated_sequence_length = int(round(estimated_seconds * frames_per_second))
|
||||
return max(
|
||||
cls.FORCE_KV_GUARD_MIN_SEQUENCE_LENGTH,
|
||||
min(cls.DEFAULT_SEQUENCE_LENGTH, estimated_sequence_length),
|
||||
)
|
||||
|
||||
def _get_local_repo_dir(self, repo_id: str, base_dir: Optional[str] = None) -> str:
|
||||
base_dir = base_dir or get_preferred_download_path("TTS")
|
||||
repo_name = repo_id.split("/")[-1]
|
||||
repo_dir = os.path.join(base_dir, repo_name)
|
||||
os.makedirs(repo_dir, exist_ok=True)
|
||||
return repo_dir
|
||||
|
||||
def _hf_download(self, repo_id: str, filename: str, base_dir: Optional[str] = None) -> str:
|
||||
from huggingface_hub import hf_hub_download
|
||||
repo_dir = self._get_local_repo_dir(repo_id, base_dir=base_dir)
|
||||
return hf_hub_download(
|
||||
repo_id=repo_id,
|
||||
filename=filename,
|
||||
local_dir=repo_dir,
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
|
||||
def _ensure_model_loaded(self):
|
||||
load_key = self._get_load_key()
|
||||
if self.model is not None and self._loaded_key == load_key and self._wrapper is not None:
|
||||
return
|
||||
|
||||
from utils.models.unified_model_interface import unified_model_interface, ModelLoadConfig
|
||||
from utils.device import resolve_torch_device
|
||||
|
||||
device = resolve_torch_device(self.config.get("device", "auto"))
|
||||
model_id = self.config.get("model", "jordand/echo-tts-base")
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="echo_tts",
|
||||
model_type="tts",
|
||||
model_name=model_id,
|
||||
device=device,
|
||||
)
|
||||
|
||||
self._wrapper = unified_model_interface.load_model(config)
|
||||
bundle = self._wrapper.model
|
||||
self.model = bundle.model
|
||||
self.ae = bundle.ae
|
||||
self.pca_state = bundle.pca_state
|
||||
self._sample_pipeline = bundle.sample_pipeline
|
||||
self._loaded_key = load_key
|
||||
|
||||
def _prepare_reference_audio(self, speaker_audio: Any) -> Tuple[torch.Tensor, int]:
|
||||
if speaker_audio is None:
|
||||
raise ValueError("Echo-TTS requires reference audio.")
|
||||
|
||||
if isinstance(speaker_audio, dict) and "waveform" in speaker_audio:
|
||||
waveform = speaker_audio["waveform"]
|
||||
sample_rate = int(speaker_audio.get("sample_rate", self.SAMPLE_RATE))
|
||||
elif isinstance(speaker_audio, str):
|
||||
waveform, sample_rate = AudioProcessingUtils.safe_load_audio(speaker_audio)
|
||||
else:
|
||||
waveform = speaker_audio
|
||||
sample_rate = self.SAMPLE_RATE
|
||||
|
||||
if isinstance(waveform, torch.Tensor):
|
||||
audio = waveform
|
||||
else:
|
||||
audio = torch.tensor(waveform, dtype=torch.float32)
|
||||
|
||||
if audio.dim() == 3:
|
||||
audio = audio[0]
|
||||
if audio.dim() == 2 and audio.shape[0] > 1:
|
||||
audio = audio.mean(dim=0, keepdim=True)
|
||||
if audio.dim() == 2 and audio.shape[0] == 1:
|
||||
audio = audio.squeeze(0)
|
||||
|
||||
# Resample to Echo-TTS sample rate
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
import torchaudio
|
||||
resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=self.SAMPLE_RATE)
|
||||
audio = resampler(audio)
|
||||
sample_rate = self.SAMPLE_RATE
|
||||
|
||||
# Normalize to [-1, 1]
|
||||
max_val = audio.abs().max()
|
||||
if max_val > 0:
|
||||
audio = audio / max_val
|
||||
|
||||
# Ensure shape (1, samples)
|
||||
if audio.dim() == 1:
|
||||
audio = audio.unsqueeze(0)
|
||||
|
||||
# Trim to 5 minutes max
|
||||
max_samples = int(self.MAX_REF_SECONDS * sample_rate)
|
||||
if audio.shape[-1] > max_samples:
|
||||
audio = audio[:, :max_samples]
|
||||
|
||||
return audio, sample_rate
|
||||
|
||||
def _create_progress_bar(self, total_steps: int, text: str = ""):
|
||||
"""
|
||||
Create a ComfyUI progress bar with optional weighted job ETA support.
|
||||
|
||||
Args:
|
||||
total_steps: Number of denoising steps expected for this generation call
|
||||
text: Input text for this generation call (kept for API parity)
|
||||
"""
|
||||
del text # Progress total is step-accurate for Echo-TTS.
|
||||
try:
|
||||
import time
|
||||
import comfy.utils
|
||||
|
||||
class TimedProgressBar:
|
||||
"""ComfyUI progress wrapper that also provides job-level timing helpers."""
|
||||
|
||||
def __init__(self, total: int, tracker: Optional[Dict[str, Any]]):
|
||||
self.total = max(1, int(total))
|
||||
self.current = 0
|
||||
self.start_time = time.time()
|
||||
self.tracker = tracker
|
||||
self.wrapped = comfy.utils.ProgressBar(self.total)
|
||||
if self.tracker is not None:
|
||||
self.tracker["current_block_total_steps"] = self.total
|
||||
self.tracker["current_block_steps"] = 0
|
||||
|
||||
def update(self, delta: int = 1):
|
||||
if delta <= 0:
|
||||
return
|
||||
remaining = max(self.total - self.current, 0)
|
||||
step = min(int(delta), remaining)
|
||||
if step <= 0:
|
||||
return
|
||||
self.current += step
|
||||
self.wrapped.update(step)
|
||||
if self.tracker is not None:
|
||||
self.tracker["current_block_steps"] = self.current
|
||||
|
||||
def get_job_elapsed(self):
|
||||
if self.tracker is not None:
|
||||
return time.time() - self.tracker["start_time"]
|
||||
return time.time() - self.start_time
|
||||
|
||||
def get_job_remaining_str(self):
|
||||
if self.tracker is None:
|
||||
return None
|
||||
total_text = self.tracker.get("total_text", 0)
|
||||
if total_text <= 0:
|
||||
return None
|
||||
|
||||
elapsed = time.time() - self.tracker["start_time"]
|
||||
if elapsed <= 0:
|
||||
return None
|
||||
|
||||
current_block_text = self.tracker.get("current_block_text", 0)
|
||||
current_total_steps = max(self.tracker.get("current_block_total_steps", self.total), 1)
|
||||
current_ratio = min(self.current / current_total_steps, 1.0)
|
||||
|
||||
effective_text_done = self.tracker.get("text_completed", 0) + (current_block_text * current_ratio)
|
||||
if effective_text_done <= 0:
|
||||
return None
|
||||
|
||||
remaining_text = max(total_text - effective_text_done, 0)
|
||||
if remaining_text <= 0:
|
||||
return None
|
||||
|
||||
seconds_per_text = elapsed / effective_text_done
|
||||
remaining_seconds = remaining_text * seconds_per_text
|
||||
|
||||
if remaining_seconds < 60:
|
||||
return f"~{remaining_seconds:.0f}s left"
|
||||
return f"~{remaining_seconds / 60:.1f}m left"
|
||||
|
||||
return TimedProgressBar(total_steps, self.job_tracker)
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
|
||||
@torch.inference_mode()
|
||||
def _sample_euler_cfg_independent_guidances_with_progress(
|
||||
self,
|
||||
model,
|
||||
speaker_latent: torch.Tensor,
|
||||
speaker_mask: torch.Tensor,
|
||||
text_input_ids: torch.Tensor,
|
||||
text_mask: torch.Tensor,
|
||||
rng_seed: int,
|
||||
num_steps: int,
|
||||
cfg_scale_text: float,
|
||||
cfg_scale_speaker: float,
|
||||
cfg_min_t: float,
|
||||
cfg_max_t: float,
|
||||
truncation_factor: Optional[float],
|
||||
rescale_k: Optional[float],
|
||||
rescale_sigma: Optional[float],
|
||||
speaker_kv_scale: Optional[float],
|
||||
speaker_kv_max_layers: Optional[int],
|
||||
speaker_kv_min_t: Optional[float],
|
||||
sequence_length: Optional[int] = None,
|
||||
progress_bar=None,
|
||||
) -> torch.Tensor:
|
||||
"""Echo-TTS sampler equivalent with ComfyUI progress updates per denoise step."""
|
||||
from echo_tts.inference import _multiply_kv_cache, _concat_kv_caches, _temporal_score_rescale
|
||||
|
||||
try:
|
||||
import comfy.model_management as model_management
|
||||
except Exception:
|
||||
model_management = None
|
||||
|
||||
if sequence_length is None:
|
||||
sequence_length = 640
|
||||
|
||||
init_scale = 0.999
|
||||
num_steps = max(1, int(num_steps))
|
||||
device, dtype = model.device, model.dtype
|
||||
batch_size = text_input_ids.shape[0]
|
||||
rng = torch.Generator(device=device).manual_seed(rng_seed)
|
||||
t_schedule = torch.linspace(1.0, 0.0, num_steps + 1, device=device) * init_scale
|
||||
|
||||
text_mask_uncond = torch.zeros_like(text_mask)
|
||||
speaker_mask_uncond = torch.zeros_like(speaker_mask)
|
||||
|
||||
kv_text_cond = model.get_kv_cache_text(text_input_ids, text_mask)
|
||||
kv_speaker_cond = model.get_kv_cache_speaker(speaker_latent.to(dtype))
|
||||
|
||||
if speaker_kv_scale is not None:
|
||||
_multiply_kv_cache(kv_speaker_cond, speaker_kv_scale, speaker_kv_max_layers)
|
||||
|
||||
kv_text_full = _concat_kv_caches(kv_text_cond, kv_text_cond, kv_text_cond)
|
||||
kv_speaker_full = _concat_kv_caches(kv_speaker_cond, kv_speaker_cond, kv_speaker_cond)
|
||||
|
||||
full_text_mask = torch.cat([text_mask, text_mask_uncond, text_mask], dim=0)
|
||||
full_speaker_mask = torch.cat([speaker_mask, speaker_mask, speaker_mask_uncond], dim=0)
|
||||
|
||||
x_t = torch.randn((batch_size, sequence_length, 80), device=device, dtype=torch.float32, generator=rng)
|
||||
if truncation_factor is not None:
|
||||
x_t = x_t * truncation_factor
|
||||
|
||||
for i in range(num_steps):
|
||||
if model_management is not None and getattr(model_management, "interrupt_processing", False):
|
||||
raise InterruptedError("Echo-TTS generation interrupted by user")
|
||||
|
||||
t, t_next = t_schedule[i], t_schedule[i + 1]
|
||||
has_cfg = ((t >= cfg_min_t) * (t <= cfg_max_t)).item()
|
||||
|
||||
if has_cfg:
|
||||
v_cond, v_uncond_text, v_uncond_speaker = model(
|
||||
x=torch.cat([x_t, x_t, x_t], dim=0).to(dtype),
|
||||
t=(torch.ones((batch_size * 3,), device=device) * t).to(dtype),
|
||||
text_mask=full_text_mask,
|
||||
speaker_mask=full_speaker_mask,
|
||||
kv_cache_text=kv_text_full,
|
||||
kv_cache_speaker=kv_speaker_full,
|
||||
).float().chunk(3, dim=0)
|
||||
v_pred = v_cond + cfg_scale_text * (v_cond - v_uncond_text) + cfg_scale_speaker * (v_cond - v_uncond_speaker)
|
||||
else:
|
||||
v_pred = model(
|
||||
x=x_t.to(dtype),
|
||||
t=(torch.ones((batch_size,), device=device) * t).to(dtype),
|
||||
text_mask=text_mask,
|
||||
speaker_mask=speaker_mask,
|
||||
kv_cache_text=kv_text_cond,
|
||||
kv_cache_speaker=kv_speaker_cond,
|
||||
).float()
|
||||
|
||||
if rescale_k is not None and rescale_sigma is not None:
|
||||
v_pred = _temporal_score_rescale(v_pred, x_t, t, rescale_k, rescale_sigma)
|
||||
|
||||
if (
|
||||
speaker_kv_scale is not None
|
||||
and speaker_kv_min_t is not None
|
||||
and t_next < speaker_kv_min_t
|
||||
and t >= speaker_kv_min_t
|
||||
):
|
||||
_multiply_kv_cache(kv_speaker_cond, 1.0 / speaker_kv_scale, speaker_kv_max_layers)
|
||||
kv_speaker_full = _concat_kv_caches(kv_speaker_cond, kv_speaker_cond, kv_speaker_cond)
|
||||
|
||||
x_t = x_t + v_pred * (t_next - t)
|
||||
|
||||
if progress_bar is not None:
|
||||
try:
|
||||
progress_bar.update(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return x_t
|
||||
|
||||
def _generate_audio_for_text(
|
||||
self,
|
||||
text: str,
|
||||
ref_audio: torch.Tensor,
|
||||
ref_text: str,
|
||||
enable_audio_cache: bool = True,
|
||||
) -> Tuple[torch.Tensor, int]:
|
||||
self._ensure_model_loaded()
|
||||
|
||||
# Seed for reproducibility if provided
|
||||
seed = int(self.config.get("seed", 0))
|
||||
if seed:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
|
||||
params = {
|
||||
"num_steps": int(self.config.get("num_steps", 40)),
|
||||
"cfg_scale_text": float(self.config.get("cfg_scale_text", 3.0)),
|
||||
"cfg_scale_speaker": float(self.config.get("cfg_scale_speaker", 8.0)),
|
||||
"cfg_min_t": float(self.config.get("cfg_min_t", 0.5)),
|
||||
"cfg_max_t": float(self.config.get("cfg_max_t", 1.0)),
|
||||
"truncation_factor": self.config.get("truncation_factor", None),
|
||||
"rescale_k": self.config.get("rescale_k", None),
|
||||
"rescale_sigma": self.config.get("rescale_sigma", None),
|
||||
"speaker_kv_scale": self.config.get("speaker_kv_scale", None),
|
||||
"speaker_kv_max_layers": self.config.get("speaker_kv_max_layers", None),
|
||||
"speaker_kv_min_t": self.config.get("speaker_kv_min_t", None),
|
||||
"sequence_length": int(self.config.get("sequence_length", self.DEFAULT_SEQUENCE_LENGTH)),
|
||||
}
|
||||
|
||||
prompt_text = self._normalize_prompt_text(text)
|
||||
effective_sequence_length = params["sequence_length"]
|
||||
if (
|
||||
params["speaker_kv_scale"] is not None
|
||||
and params["sequence_length"] == self.DEFAULT_SEQUENCE_LENGTH
|
||||
):
|
||||
guarded_sequence_length = self._estimate_force_kv_sequence_length(prompt_text)
|
||||
prompt_chars = self._count_prompt_chars_for_length_guard(prompt_text)
|
||||
if guarded_sequence_length != params["sequence_length"]:
|
||||
print(
|
||||
"Echo-TTS: Force-KV sequence_length guard active "
|
||||
f"({params['sequence_length']} -> {guarded_sequence_length}, text_chars={prompt_chars})"
|
||||
)
|
||||
effective_sequence_length = guarded_sequence_length
|
||||
|
||||
cache_key = None
|
||||
if enable_audio_cache:
|
||||
try:
|
||||
ref_audio_hash = hashlib.md5(ref_audio.detach().cpu().contiguous().numpy().tobytes()).hexdigest()
|
||||
audio_component = f"ref_audio_{ref_audio_hash}_{self.SAMPLE_RATE}"
|
||||
except Exception as e:
|
||||
print(f"⚠️ Echo-TTS: Failed to hash reference audio for cache key: {e}")
|
||||
audio_component = "ref_audio_error"
|
||||
|
||||
resolved_device = self.config.get("device", "auto")
|
||||
if self._loaded_key and "::" in self._loaded_key:
|
||||
_, resolved_device = self._loaded_key.split("::", 1)
|
||||
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
'echo_tts',
|
||||
text=prompt_text,
|
||||
audio_component=audio_component,
|
||||
reference_text=ref_text,
|
||||
model=self.config.get("model", "jordand/echo-tts-base"),
|
||||
device=resolved_device,
|
||||
num_steps=params["num_steps"],
|
||||
cfg_scale_text=params["cfg_scale_text"],
|
||||
cfg_scale_speaker=params["cfg_scale_speaker"],
|
||||
cfg_min_t=params["cfg_min_t"],
|
||||
cfg_max_t=params["cfg_max_t"],
|
||||
truncation_factor=params["truncation_factor"],
|
||||
rescale_k=params["rescale_k"],
|
||||
rescale_sigma=params["rescale_sigma"],
|
||||
speaker_kv_scale=params["speaker_kv_scale"],
|
||||
speaker_kv_max_layers=params["speaker_kv_max_layers"],
|
||||
speaker_kv_min_t=params["speaker_kv_min_t"],
|
||||
sequence_length=effective_sequence_length,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached Echo-TTS audio: '{text[:30]}...'")
|
||||
return cached_audio[0], self.SAMPLE_RATE
|
||||
|
||||
progress_bar = self._create_progress_bar(params["num_steps"], text)
|
||||
|
||||
# Resolve sampler
|
||||
sampler_kwargs = {
|
||||
"num_steps": params["num_steps"],
|
||||
"cfg_scale_text": params["cfg_scale_text"],
|
||||
"cfg_scale_speaker": params["cfg_scale_speaker"],
|
||||
"cfg_min_t": params["cfg_min_t"],
|
||||
"cfg_max_t": params["cfg_max_t"],
|
||||
"truncation_factor": params["truncation_factor"],
|
||||
"rescale_k": params["rescale_k"],
|
||||
"rescale_sigma": params["rescale_sigma"],
|
||||
"speaker_kv_scale": params["speaker_kv_scale"],
|
||||
"speaker_kv_max_layers": params["speaker_kv_max_layers"],
|
||||
"speaker_kv_min_t": params["speaker_kv_min_t"],
|
||||
"sequence_length": effective_sequence_length,
|
||||
# Alternate parameter names used by some sampler variants
|
||||
"cfg_scale": params["cfg_scale_text"],
|
||||
"speaker_k_scale": params["speaker_kv_scale"],
|
||||
"speaker_k_max_layers": params["speaker_kv_max_layers"],
|
||||
"speaker_k_min_t": params["speaker_kv_min_t"],
|
||||
"block_size": effective_sequence_length,
|
||||
}
|
||||
try:
|
||||
from echo_tts.inference import sample_euler_cfg_independent_guidances
|
||||
sample_fn = partial(
|
||||
self._sample_euler_cfg_independent_guidances_with_progress,
|
||||
progress_bar=progress_bar,
|
||||
**self._filter_kwargs(sample_euler_cfg_independent_guidances, sampler_kwargs),
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
from echo_tts.samplers import sample_euler_cfg_any, GuidanceMode
|
||||
sample_fn = partial(
|
||||
sample_euler_cfg_any,
|
||||
**self._filter_kwargs(
|
||||
sample_euler_cfg_any,
|
||||
{
|
||||
**sampler_kwargs,
|
||||
"guidance_mode": GuidanceMode.INDEPENDENT,
|
||||
"apg_eta_text": None,
|
||||
"apg_eta_speaker": None,
|
||||
"apg_momentum_text": None,
|
||||
"apg_momentum_speaker": None,
|
||||
"apg_norm_text": None,
|
||||
"apg_norm_speaker": None,
|
||||
},
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
from echo_tts.samplers import sample_euler_cfg
|
||||
sample_fn = partial(
|
||||
sample_euler_cfg,
|
||||
**self._filter_kwargs(sample_euler_cfg, sampler_kwargs),
|
||||
)
|
||||
|
||||
try:
|
||||
with torch.no_grad():
|
||||
result = self._sample_pipeline(
|
||||
model=self.model,
|
||||
fish_ae=self.ae,
|
||||
pca_state=self.pca_state,
|
||||
sample_fn=sample_fn,
|
||||
text_prompt=prompt_text,
|
||||
speaker_audio=ref_audio,
|
||||
rng_seed=seed,
|
||||
pad_to_max_speaker_latent_length=2560,
|
||||
pad_to_max_text_length=768,
|
||||
normalize_text=True,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
if "Inference tensors do not track version counter" in str(e):
|
||||
print("⚠️ Echo-TTS: pca_state tensors corrupted after VRAM clear - forcing model reload...")
|
||||
self.model = None
|
||||
self._loaded_key = None
|
||||
self._ensure_model_loaded()
|
||||
with torch.no_grad():
|
||||
result = self._sample_pipeline(
|
||||
model=self.model,
|
||||
fish_ae=self.ae,
|
||||
pca_state=self.pca_state,
|
||||
sample_fn=sample_fn,
|
||||
text_prompt=prompt_text,
|
||||
speaker_audio=ref_audio,
|
||||
rng_seed=seed,
|
||||
pad_to_max_speaker_latent_length=2560,
|
||||
pad_to_max_text_length=768,
|
||||
normalize_text=True,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
if progress_bar is not None and hasattr(progress_bar, "total") and hasattr(progress_bar, "current"):
|
||||
remaining = progress_bar.total - progress_bar.current
|
||||
if remaining > 0:
|
||||
progress_bar.update(remaining)
|
||||
|
||||
audio = None
|
||||
sample_rate = self.SAMPLE_RATE
|
||||
|
||||
if isinstance(result, dict):
|
||||
audio = result.get("wav") or result.get("audio") or result.get("waveform")
|
||||
sample_rate = int(result.get("sr", result.get("sample_rate", self.SAMPLE_RATE)))
|
||||
elif isinstance(result, tuple) and len(result) >= 1:
|
||||
audio = result[0]
|
||||
if len(result) >= 2 and isinstance(result[1], (int, float)):
|
||||
sample_rate = int(result[1])
|
||||
else:
|
||||
audio = result
|
||||
|
||||
if isinstance(audio, torch.Tensor):
|
||||
audio_tensor = audio
|
||||
else:
|
||||
audio_tensor = torch.tensor(audio, dtype=torch.float32)
|
||||
|
||||
if audio_tensor.dim() > 1:
|
||||
audio_tensor = audio_tensor.squeeze()
|
||||
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
import torchaudio
|
||||
resampler = torchaudio.transforms.Resample(orig_freq=sample_rate, new_freq=self.SAMPLE_RATE)
|
||||
audio_tensor = resampler(audio_tensor)
|
||||
sample_rate = self.SAMPLE_RATE
|
||||
|
||||
if enable_audio_cache and cache_key:
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, 'echo_tts')
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
|
||||
return audio_tensor, sample_rate
|
||||
|
||||
def generate_single(self, text: str, speaker_audio: Any, reference_text: str,
|
||||
seed: int = 0, enable_audio_cache: bool = True) -> torch.Tensor:
|
||||
"""Generate audio for a single chunk of text (no chunking, no combining)."""
|
||||
if seed is not None:
|
||||
self.config["seed"] = seed
|
||||
ref_audio, _ = self._prepare_reference_audio(speaker_audio)
|
||||
ref_text = reference_text or ""
|
||||
audio_tensor, _ = self._generate_audio_for_text(
|
||||
text, ref_audio, ref_text, enable_audio_cache=enable_audio_cache
|
||||
)
|
||||
return audio_tensor
|
||||
|
||||
def process_text(self, text: str, speaker_audio: Any, reference_text: str, seed: int = 0,
|
||||
enable_chunking: bool = True, max_chars_per_chunk: int = 400,
|
||||
chunk_combination_method: str = "auto", silence_between_chunks_ms: int = 100,
|
||||
enable_audio_cache: bool = True, return_info: bool = False):
|
||||
if seed is not None:
|
||||
self.config["seed"] = seed
|
||||
|
||||
ref_audio, _ = self._prepare_reference_audio(speaker_audio)
|
||||
ref_text = reference_text or ""
|
||||
|
||||
if enable_chunking:
|
||||
max_chars = ImprovedChatterBoxChunker.validate_chunking_params(max_chars_per_chunk)
|
||||
text_chunks = ImprovedChatterBoxChunker.split_into_chunks(text, max_chars=max_chars)
|
||||
else:
|
||||
text_chunks = [text]
|
||||
|
||||
audio_segments = []
|
||||
for chunk_idx, chunk in enumerate(text_chunks):
|
||||
audio_tensor, _ = self._generate_audio_for_text(
|
||||
chunk,
|
||||
ref_audio,
|
||||
ref_text,
|
||||
enable_audio_cache=enable_audio_cache,
|
||||
)
|
||||
audio_segments.append(audio_tensor)
|
||||
|
||||
combined_audio, chunk_info = ChunkTimingHelper.combine_audio_with_timing(
|
||||
audio_segments=audio_segments,
|
||||
combination_method=chunk_combination_method,
|
||||
silence_ms=silence_between_chunks_ms,
|
||||
crossfade_duration=0.1,
|
||||
sample_rate=self.SAMPLE_RATE,
|
||||
text_length=len(text),
|
||||
original_text=text,
|
||||
text_chunks=text_chunks,
|
||||
)
|
||||
|
||||
if return_info:
|
||||
return combined_audio, chunk_info
|
||||
return combined_audio
|
||||
|
||||
def process_srt_content(self, srt_content: str, speaker_audio: Any, reference_text: str,
|
||||
seed: int = 0, timing_mode: str = "pad_with_silence",
|
||||
timing_params: Optional[Dict[str, Any]] = None,
|
||||
enable_audio_cache: bool = True):
|
||||
from utils.system.import_manager import import_manager
|
||||
from utils.timing.assembly import AudioAssemblyEngine
|
||||
from utils.timing.engine import TimingEngine
|
||||
from utils.timing.overlap_detection import SRTOverlapHandler
|
||||
|
||||
timing_params = timing_params or {}
|
||||
|
||||
success, modules, _ = import_manager.import_srt_modules()
|
||||
if not success:
|
||||
raise RuntimeError("SRT modules are not available")
|
||||
|
||||
SRTParser = modules.get("SRTParser")
|
||||
if SRTParser is None:
|
||||
raise RuntimeError("SRT parser not available")
|
||||
|
||||
srt_parser = SRTParser()
|
||||
subtitles = srt_parser.parse_srt_content(srt_content, allow_overlaps=True)
|
||||
has_overlaps = SRTOverlapHandler.detect_overlaps(subtitles)
|
||||
current_timing_mode, mode_switched = SRTOverlapHandler.handle_smart_natural_fallback(
|
||||
timing_mode, has_overlaps, "Echo-TTS SRT Adapter"
|
||||
)
|
||||
|
||||
if seed is not None:
|
||||
self.config["seed"] = seed
|
||||
ref_audio, _ = self._prepare_reference_audio(speaker_audio)
|
||||
ref_text = reference_text or ""
|
||||
|
||||
audio_segments = []
|
||||
for subtitle in subtitles:
|
||||
audio_tensor, _ = self._generate_audio_for_text(
|
||||
subtitle.text,
|
||||
ref_audio,
|
||||
ref_text,
|
||||
enable_audio_cache=enable_audio_cache,
|
||||
)
|
||||
audio_segments.append(audio_tensor.cpu())
|
||||
|
||||
adjustments = None
|
||||
processed_segments = None
|
||||
fade_duration = float(timing_params.get("fade_for_StretchToFit", 0.01))
|
||||
|
||||
if current_timing_mode == "concatenate":
|
||||
timing_engine = TimingEngine(self.SAMPLE_RATE)
|
||||
adjustments = timing_engine.calculate_concatenation_adjustments(audio_segments, subtitles)
|
||||
elif current_timing_mode == "smart_natural":
|
||||
timing_engine = TimingEngine(self.SAMPLE_RATE)
|
||||
tolerance = float(timing_params.get("timing_tolerance", 2.0))
|
||||
max_stretch_ratio = float(timing_params.get("max_stretch_ratio", 1.0))
|
||||
min_stretch_ratio = float(timing_params.get("min_stretch_ratio", 0.5))
|
||||
adjustments, processed_segments = timing_engine.calculate_smart_timing_adjustments(
|
||||
audio_segments,
|
||||
subtitles,
|
||||
tolerance,
|
||||
max_stretch_ratio,
|
||||
min_stretch_ratio,
|
||||
torch.device("cpu"),
|
||||
)
|
||||
|
||||
assembler = AudioAssemblyEngine(sample_rate=self.SAMPLE_RATE)
|
||||
final_audio = assembler.assemble_by_timing_mode(
|
||||
audio_segments=audio_segments,
|
||||
subtitles=subtitles,
|
||||
timing_mode=current_timing_mode,
|
||||
device="cpu",
|
||||
adjustments=adjustments,
|
||||
processed_segments=processed_segments,
|
||||
fade_duration=fade_duration,
|
||||
)
|
||||
|
||||
if final_audio.dim() == 1:
|
||||
final_audio = final_audio.unsqueeze(0).unsqueeze(0)
|
||||
elif final_audio.dim() == 2:
|
||||
final_audio = final_audio.unsqueeze(0)
|
||||
|
||||
audio_output = {"waveform": final_audio, "sample_rate": self.SAMPLE_RATE}
|
||||
|
||||
timing_info = srt_parser.get_timing_info(subtitles)
|
||||
mode_info = current_timing_mode
|
||||
if mode_switched:
|
||||
mode_info = f"{current_timing_mode} (switched from {timing_mode} due to overlaps)"
|
||||
|
||||
timing_report = (
|
||||
f"Echo-TTS SRT timing\n"
|
||||
f"Subtitles: {timing_info.get('subtitle_count', 0)}\n"
|
||||
f"Total duration: {timing_info.get('total_duration', 0):.2f}s\n"
|
||||
f"Mode: {mode_info}"
|
||||
)
|
||||
|
||||
total_duration = final_audio.shape[-1] / float(self.SAMPLE_RATE)
|
||||
info = f"Generated {total_duration:.1f}s Echo-TTS SRT audio using {mode_info} mode"
|
||||
|
||||
return audio_output, info, timing_report, srt_content
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Adapter between suite processors and the isolated official Fish S2 runtime."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from engines.fish_audio_s2.downloader import FishAudioS2Downloader
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.text.fish_audio_s2_tags import translate_fish_s2_inline_tags
|
||||
from utils.voice.character_logging import resolved_voice_name
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
|
||||
|
||||
class FishAudioS2Adapter:
|
||||
SAMPLE_RATE = 44100
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = dict(config or {})
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._model_config = None
|
||||
|
||||
def update_config(self, config):
|
||||
self.config = dict(config or {})
|
||||
|
||||
def _engine(self):
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
model_selection = self.config.get("model_variant", "s2-pro")
|
||||
quantization = self.config.get("quantization", "none")
|
||||
model_path = FishAudioS2Downloader.resolve_model_path(model_selection)
|
||||
model_variant = FishAudioS2Downloader.resolve_model_variant(model_selection, model_path)
|
||||
self._model_config = ModelLoadConfig(
|
||||
engine_name="fish_audio_s2", model_type="tts", model_name=model_variant,
|
||||
model_path=model_path, device=self.config.get("device", "auto"),
|
||||
runtime_mode="isolated",
|
||||
additional_params={
|
||||
"model_variant": model_variant,
|
||||
"quantization": quantization,
|
||||
"precision": self.config.get("precision", "bfloat16"),
|
||||
"compile": bool(self.config.get("compile", False)),
|
||||
"context_length": int(self.config.get("context_length", 8192)),
|
||||
},
|
||||
)
|
||||
return unified_model_interface.load_model(self._model_config)
|
||||
|
||||
def _reference(self, voice_ref):
|
||||
if not isinstance(voice_ref, dict):
|
||||
return None, "", "default_voice"
|
||||
text = (voice_ref.get("reference_text") or voice_ref.get("prompt_text") or "").strip()
|
||||
effective_audio = effective_voice_audio(voice_ref)
|
||||
path = effective_audio if isinstance(effective_audio, str) else None
|
||||
if isinstance(effective_audio, dict):
|
||||
path = AudioProcessingUtils.save_audio_to_temp_file(
|
||||
effective_audio["waveform"], effective_audio.get("sample_rate", self.SAMPLE_RATE)
|
||||
)
|
||||
elif torch.is_tensor(effective_audio):
|
||||
path = AudioProcessingUtils.save_audio_to_temp_file(
|
||||
effective_audio, voice_ref.get("sample_rate", self.SAMPLE_RATE)
|
||||
)
|
||||
component = generate_stable_audio_component(audio_file_path=path) if path else "default_voice"
|
||||
return path, text, component
|
||||
|
||||
def generate_single(self, text, voice_ref, seed=0, enable_audio_cache=True, character_name=None):
|
||||
return self.generate_dialogue(
|
||||
[(0, text)], [voice_ref], seed, enable_audio_cache,
|
||||
cache_character=character_name or "narrator",
|
||||
)
|
||||
|
||||
def generate_dialogue(self, turns, voice_refs, seed=0, enable_audio_cache=True,
|
||||
cache_character="native_dialogue"):
|
||||
formatted_turns = []
|
||||
for speaker_index, turn_text in turns:
|
||||
clean_text = translate_fish_s2_inline_tags((turn_text or "").strip())
|
||||
if clean_text:
|
||||
formatted_turns.append(f"<|speaker:{speaker_index}|>{clean_text}")
|
||||
text = "\n".join(formatted_turns)
|
||||
if not text:
|
||||
return torch.zeros(1, 0)
|
||||
|
||||
references = []
|
||||
reference_labels = []
|
||||
components = []
|
||||
reference_texts = []
|
||||
for speaker_index, voice_ref in enumerate(voice_refs):
|
||||
ref_path, ref_text, component = self._reference(voice_ref)
|
||||
if ref_path:
|
||||
if not ref_text:
|
||||
raise ValueError("Fish S2 native speakers require exact reference transcripts")
|
||||
references.append({
|
||||
"audio_path": ref_path,
|
||||
"text": f"<|speaker:{speaker_index}|>{ref_text}",
|
||||
})
|
||||
reference_labels.append(
|
||||
f"local Speaker {speaker_index + 1}={resolved_voice_name(voice_ref)}"
|
||||
)
|
||||
components.append(component)
|
||||
reference_texts.append(ref_text)
|
||||
if references:
|
||||
print(f"🎤 Fish local reference order: {', '.join(reference_labels)}")
|
||||
|
||||
params = {
|
||||
"model_variant": self.config.get("model_variant", "s2-pro"),
|
||||
"quantization": self.config.get("quantization", "none"),
|
||||
"multi_speaker_mode": self.config.get("multi_speaker_mode", "Native Multi-Speaker"),
|
||||
"seed": int(seed), "normalize": bool(self.config.get("normalize", True)),
|
||||
"chunk_length": int(self.config.get("native_chunk_length", 200)),
|
||||
"max_new_tokens": int(self.config.get("max_new_tokens", 1024)),
|
||||
"top_p": float(self.config.get("top_p", 0.8)),
|
||||
"repetition_penalty": float(self.config.get("repetition_penalty", 1.1)),
|
||||
"temperature": float(self.config.get("temperature", 0.8)),
|
||||
"cache_reference": bool(self.config.get("cache_reference", True)),
|
||||
"context_length": int(self.config.get("context_length", 8192)),
|
||||
}
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"fish_audio_s2", text=text, audio_component="|".join(components),
|
||||
reference_text="|".join(reference_texts), character=cache_character, **params,
|
||||
) if enable_audio_cache else None
|
||||
if cache_key:
|
||||
cached = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached:
|
||||
print("💾 Fish Audio S2: Using cached audio")
|
||||
return cached[0]
|
||||
audio, sample_rate = self._engine().generate(text=text, references=references, **params)
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
raise RuntimeError(f"Fish S2 returned unexpected sample rate {sample_rate}")
|
||||
audio = audio.detach().float().cpu()
|
||||
if audio.dim() == 1:
|
||||
audio = audio.unsqueeze(0)
|
||||
if cache_key:
|
||||
self.audio_cache.cache_audio(cache_key, audio, audio.shape[-1] / self.SAMPLE_RATE)
|
||||
return audio
|
||||
@@ -14,6 +14,8 @@ if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
from utils.models.language_mapper import get_model_for_language
|
||||
from utils.models.engine_registry import get_default_runtime_profile
|
||||
from utils.models.factory_config import normalize_runtime_mode, runtime_uses_isolation
|
||||
from engines.higgs_audio.higgs_audio import HiggsAudioEngine
|
||||
from engines.higgs_audio.higgs_audio_downloader import HIGGS_AUDIO_MODELS
|
||||
|
||||
@@ -31,6 +33,9 @@ class HiggsAudioEngineAdapter:
|
||||
self.node = node_instance
|
||||
self.engine_type = "higgs_audio"
|
||||
self.higgs_engine = HiggsAudioEngine()
|
||||
node_config = getattr(node_instance, "config", {}) or {}
|
||||
self._runtime_mode = normalize_runtime_mode(node_config.get("runtime_mode"))
|
||||
self._runtime_profile = node_config.get("runtime_profile")
|
||||
|
||||
def get_model_for_language(self, lang_code: str, default_model: str) -> str:
|
||||
"""
|
||||
@@ -58,22 +63,17 @@ class HiggsAudioEngineAdapter:
|
||||
def load_base_model(self, model_name: str, device: str, enable_cuda_graphs: bool = True):
|
||||
"""
|
||||
Load base Higgs Audio model.
|
||||
|
||||
|
||||
Args:
|
||||
model_name: Model name to load
|
||||
device: Device to load model on
|
||||
enable_cuda_graphs: Whether to enable CUDA Graph optimization
|
||||
"""
|
||||
# Check if the model is already loaded AND engine is initialized
|
||||
# Also check for corruption flags that require recreation
|
||||
current_model = getattr(self.node, 'current_model_name', None)
|
||||
cuda_graph_corrupted = getattr(self.higgs_engine, '_cuda_graph_corrupted', False)
|
||||
needs_recreation = getattr(self.higgs_engine, '_needs_recreation', False)
|
||||
|
||||
|
||||
if (current_model == model_name and
|
||||
self.higgs_engine.engine is not None and
|
||||
not cuda_graph_corrupted and
|
||||
not needs_recreation):
|
||||
self.higgs_engine.engine is not None):
|
||||
print(f"💾 Higgs Audio adapter: Model '{model_name}' already loaded - ensuring device and clearing other models")
|
||||
# Even if model is loaded, ensure it's on the right device and clear other models
|
||||
from utils.models.comfyui_model_wrapper.model_manager import tts_model_manager
|
||||
@@ -81,14 +81,6 @@ class HiggsAudioEngineAdapter:
|
||||
tts_model_manager.ensure_device("higgs_audio", device)
|
||||
return
|
||||
|
||||
if cuda_graph_corrupted or needs_recreation:
|
||||
print(f"🔥 Higgs Audio adapter: Engine corruption detected - forcing complete recreation")
|
||||
# Clear the corruption flags
|
||||
self.higgs_engine._cuda_graph_corrupted = False
|
||||
self.higgs_engine._needs_recreation = False
|
||||
# Force new engine creation by clearing current engine
|
||||
self.higgs_engine.engine = None
|
||||
|
||||
# Determine model paths based on model name
|
||||
if model_name in HIGGS_AUDIO_MODELS:
|
||||
model_config = HIGGS_AUDIO_MODELS[model_name]
|
||||
@@ -98,6 +90,23 @@ class HiggsAudioEngineAdapter:
|
||||
# Default fallback
|
||||
generation_model = "bosonai/higgs-audio-v2-generation-3B-base"
|
||||
tokenizer_model = "bosonai/higgs-audio-v2-tokenizer"
|
||||
|
||||
if runtime_uses_isolation(self._runtime_mode):
|
||||
from utils.models.unified_model_interface import load_tts_model
|
||||
|
||||
self.higgs_engine = load_tts_model(
|
||||
engine_name="higgs_audio",
|
||||
model_name=model_name,
|
||||
device=device,
|
||||
model_path=generation_model,
|
||||
tokenizer_path=tokenizer_model,
|
||||
enable_cuda_graphs=enable_cuda_graphs,
|
||||
runtime_mode=self._runtime_mode,
|
||||
runtime_profile=self._runtime_profile or get_default_runtime_profile("higgs_audio"),
|
||||
)
|
||||
self.node.current_model_name = model_name
|
||||
print(f"✅ Higgs Audio adapter: Loaded model '{model_name}' on {device} via shared runtime")
|
||||
return
|
||||
|
||||
# Initialize the Higgs Audio engine with CUDA Graph setting
|
||||
self.higgs_engine.initialize_engine(
|
||||
@@ -151,7 +160,7 @@ class HiggsAudioEngineAdapter:
|
||||
top_p = params.get("top_p", 0.95)
|
||||
top_k = params.get("top_k", 50)
|
||||
max_new_tokens = params.get("max_new_tokens", 2048)
|
||||
force_audio_gen = params.get("force_audio_gen", False)
|
||||
force_audio_gen = params.get("force_audio_gen", True)
|
||||
ras_win_len = params.get("ras_win_len", 7) # Default from boson_multimodal
|
||||
ras_max_num_repeat = params.get("ras_max_num_repeat", 2) # Default from boson_multimodal
|
||||
enable_cuda_graphs = params.get("enable_cuda_graphs", True) # CUDA Graph toggle
|
||||
@@ -174,7 +183,7 @@ class HiggsAudioEngineAdapter:
|
||||
max_tokens_per_chunk = int(max_chars_per_chunk / 3.5)
|
||||
|
||||
# Initialize engine if not already done
|
||||
if not self.higgs_engine.engine:
|
||||
if not getattr(self.higgs_engine, "engine", None):
|
||||
print(f"🚀 Initializing Higgs Audio engine with model: {model_name}")
|
||||
self.load_base_model(model_name, device)
|
||||
|
||||
@@ -407,7 +416,7 @@ class HiggsAudioEngineAdapter:
|
||||
validated["max_new_tokens"] = max(1, min(4096, int(max_tokens)))
|
||||
|
||||
# Force audio gen validation
|
||||
force_audio_gen = params.get("force_audio_gen", False)
|
||||
force_audio_gen = params.get("force_audio_gen", True)
|
||||
validated["force_audio_gen"] = bool(force_audio_gen)
|
||||
|
||||
# RAS parameters validation
|
||||
@@ -439,4 +448,4 @@ class HiggsAudioEngineAdapter:
|
||||
# Cache settings
|
||||
validated["enable_audio_cache"] = params.get("enable_audio_cache", True)
|
||||
|
||||
return validated
|
||||
return validated
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Higgs Audio v3 adapter for the unified TTS pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.text.pause_processor import PauseTagProcessor
|
||||
|
||||
|
||||
class HiggsAudioV3EngineAdapter:
|
||||
"""Adapter for native Higgs Audio v3 generation."""
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
def __init__(self, node_instance=None):
|
||||
self.node = node_instance
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_config: Optional[ModelLoadConfig] = None
|
||||
|
||||
def load_model(
|
||||
self,
|
||||
model: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
attention: str = "auto",
|
||||
):
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="higgs_audio_v3",
|
||||
model_type="tts",
|
||||
model_name=model,
|
||||
model_path=model,
|
||||
device=device,
|
||||
additional_params={
|
||||
"dtype": dtype,
|
||||
"attention": attention,
|
||||
},
|
||||
)
|
||||
self._last_config = config
|
||||
unified_model_interface.load_model(config)
|
||||
|
||||
def update_model_config(
|
||||
self,
|
||||
model: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
attention: str = "auto",
|
||||
):
|
||||
self.load_model(model, device, dtype, attention)
|
||||
|
||||
def _get_engine(self):
|
||||
if self._last_config is None:
|
||||
raise RuntimeError("Higgs Audio v3 model has not been loaded")
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
return unified_model_interface.load_model(self._last_config)
|
||||
|
||||
def generate_with_pause_tags(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
process_pauses: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> torch.Tensor:
|
||||
engine = self._get_engine()
|
||||
if process_pauses and PauseTagProcessor.has_pause_tags(text):
|
||||
return self._generate_with_pauses(text, voice_ref, params, character_name, engine)
|
||||
return self._generate_direct(text, voice_ref, params, character_name, engine)
|
||||
|
||||
def _generate_direct(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None,
|
||||
engine=None,
|
||||
) -> torch.Tensor:
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
reference_audio, reference_text, audio_component = self._extract_voice_reference(voice_ref)
|
||||
model_name = params.get("model", "higgs-audio-v3-tts-4b")
|
||||
seed = int(params.get("seed", 0) or 0)
|
||||
max_new_tokens = int(params.get("max_new_tokens", 2048))
|
||||
temperature = float(params.get("temperature", 1.0))
|
||||
top_p = float(params.get("top_p", 0.95))
|
||||
top_k = int(params.get("top_k", 50))
|
||||
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"higgs_audio_v3",
|
||||
text=text,
|
||||
model_variant=model_name,
|
||||
audio_component=audio_component,
|
||||
reference_text=reference_text,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
max_new_tokens=max_new_tokens,
|
||||
seed=seed,
|
||||
device=params.get("device", "auto"),
|
||||
dtype=params.get("dtype", "auto"),
|
||||
attention=params.get("attention", "auto"),
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
|
||||
if params.get("enable_audio_cache", True):
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached Higgs Audio v3 audio for '{character_name or 'narrator'}': '{text[:30]}...'")
|
||||
return cached_audio[0]
|
||||
|
||||
audio_tensor, sample_rate = engine.generate(
|
||||
text=text,
|
||||
reference_audio=reference_audio,
|
||||
reference_text=reference_text,
|
||||
max_new_tokens=max_new_tokens,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
raise RuntimeError(f"Higgs Audio v3 returned {sample_rate} Hz; expected {self.SAMPLE_RATE} Hz")
|
||||
|
||||
if params.get("enable_audio_cache", True):
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, "higgs_audio_v3")
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
return audio_tensor
|
||||
|
||||
def _generate_with_pauses(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None,
|
||||
engine=None,
|
||||
) -> torch.Tensor:
|
||||
segments, _ = PauseTagProcessor.parse_pause_tags(text)
|
||||
audio_parts = []
|
||||
for segment_type, content in segments:
|
||||
if segment_type == "text":
|
||||
audio_tensor = self._generate_direct(content, voice_ref, params, character_name, engine)
|
||||
if audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
elif audio_tensor.dim() == 3:
|
||||
audio_tensor = audio_tensor.squeeze(0)
|
||||
audio_parts.append(audio_tensor)
|
||||
elif segment_type == "pause":
|
||||
audio_parts.append(PauseTagProcessor.create_silence_segment(content, self.SAMPLE_RATE))
|
||||
|
||||
if not audio_parts:
|
||||
return torch.zeros(1, 0)
|
||||
return torch.cat(audio_parts, dim=-1)
|
||||
|
||||
def _extract_voice_reference(self, voice_ref: Optional[Dict[str, Any]]) -> Tuple[Optional[Dict[str, Any]], str, str]:
|
||||
if not voice_ref or not isinstance(voice_ref, dict):
|
||||
return None, "", "zero_shot"
|
||||
|
||||
ref_audio = None
|
||||
for key in ("audio", "audio_dict", "reference_audio", "waveform"):
|
||||
if key in voice_ref and voice_ref.get(key) is not None:
|
||||
ref_audio = voice_ref.get(key)
|
||||
break
|
||||
reference_text = (
|
||||
voice_ref.get("reference_text")
|
||||
or voice_ref.get("text")
|
||||
or voice_ref.get("prompt_text")
|
||||
or ""
|
||||
)
|
||||
|
||||
if ref_audio is None and "audio_path" in voice_ref:
|
||||
audio_path = voice_ref.get("audio_path")
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
from utils.audio.processing import AudioProcessingUtils
|
||||
|
||||
waveform, sample_rate = AudioProcessingUtils.safe_load_audio(audio_path)
|
||||
ref_audio = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load Higgs Audio v3 reference audio {audio_path}: {e}")
|
||||
|
||||
if isinstance(ref_audio, dict) and "waveform" in ref_audio:
|
||||
return ref_audio, str(reference_text), generate_stable_audio_component(reference_audio=ref_audio)
|
||||
|
||||
if torch.is_tensor(ref_audio):
|
||||
sample_rate = int(voice_ref.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": ref_audio, "sample_rate": sample_rate}
|
||||
return audio_dict, str(reference_text), generate_stable_audio_component(reference_audio=audio_dict)
|
||||
|
||||
return None, str(reference_text), "zero_shot"
|
||||
@@ -12,7 +12,8 @@ from typing import Dict, Any, Optional, List, Union
|
||||
from engines.index_tts.index_tts import IndexTTSEngine
|
||||
from engines.index_tts.index_tts_downloader import index_tts_downloader
|
||||
from utils.text.character_parser import character_parser
|
||||
from utils.voice.discovery import get_character_mapping, get_available_characters
|
||||
from utils.voice.discovery import get_character_mapping, get_available_characters
|
||||
from utils.voice.character_logging import resolved_character_label
|
||||
from utils.audio.cache import get_audio_cache
|
||||
|
||||
|
||||
@@ -76,7 +77,26 @@ class IndexTTSAdapter:
|
||||
)
|
||||
|
||||
|
||||
def generate(self,
|
||||
@staticmethod
|
||||
def _normalize_emotion_audio(emotion_audio):
|
||||
"""Return a path or waveform dict accepted by IndexTTS-2."""
|
||||
if not isinstance(emotion_audio, dict):
|
||||
return emotion_audio
|
||||
if emotion_audio.get("audio_path"):
|
||||
print(
|
||||
"🎭 Using Character Voices emotion audio: "
|
||||
f"{emotion_audio.get('character_name', 'unknown')} -> "
|
||||
f"{emotion_audio['audio_path']}"
|
||||
)
|
||||
return emotion_audio["audio_path"]
|
||||
if "waveform" in emotion_audio:
|
||||
return emotion_audio
|
||||
nested_audio = emotion_audio.get("audio")
|
||||
if isinstance(nested_audio, dict) and "waveform" in nested_audio:
|
||||
return nested_audio
|
||||
return emotion_audio
|
||||
|
||||
def generate(self,
|
||||
text: str,
|
||||
speaker_audio: Optional[str] = None,
|
||||
emotion_audio: Optional[str] = None,
|
||||
@@ -93,9 +113,12 @@ class IndexTTSAdapter:
|
||||
top_k: int = 30,
|
||||
length_penalty: float = 0.0,
|
||||
num_beams: int = 3,
|
||||
repetition_penalty: float = 10.0,
|
||||
max_mel_tokens: int = 1500,
|
||||
# Streaming parameters
|
||||
repetition_penalty: float = 10.0,
|
||||
max_mel_tokens: int = 1500,
|
||||
language: str = "English",
|
||||
duration_factor: float = 1.0,
|
||||
text_normalization: bool = True,
|
||||
# Streaming parameters
|
||||
stream_return: bool = False,
|
||||
more_segment_before: int = 0,
|
||||
**kwargs) -> torch.Tensor:
|
||||
@@ -119,7 +142,10 @@ class IndexTTSAdapter:
|
||||
length_penalty: Length penalty for beam search
|
||||
num_beams: Number of beams for beam search
|
||||
repetition_penalty: Repetition penalty
|
||||
max_mel_tokens: Maximum mel tokens to generate
|
||||
max_mel_tokens: Maximum mel tokens to generate
|
||||
language: IndexTTS-2.5 language code/name
|
||||
duration_factor: Official 2.5 internal feature-duration multiplier
|
||||
text_normalization: Enable multilingual text normalization
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
@@ -135,9 +161,31 @@ class IndexTTSAdapter:
|
||||
# Parse character switching tags with emotion support
|
||||
processed_segments = self._process_character_tags_with_emotions(text)
|
||||
|
||||
if len(processed_segments) > 1:
|
||||
# Multi-segment character switching - process each segment separately
|
||||
return self._generate_multi_character_segments(processed_segments, speaker_audio, emotion_audio, **kwargs)
|
||||
if len(processed_segments) > 1:
|
||||
# Multi-segment character switching - process each segment separately
|
||||
return self._generate_multi_character_segments(
|
||||
processed_segments, speaker_audio, emotion_audio,
|
||||
emotion_alpha=emotion_alpha,
|
||||
emotion_vector=emotion_vector,
|
||||
use_emotion_text=use_emotion_text,
|
||||
emotion_text=emotion_text,
|
||||
use_random=use_random,
|
||||
interval_silence=interval_silence,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
length_penalty=length_penalty,
|
||||
num_beams=num_beams,
|
||||
repetition_penalty=repetition_penalty,
|
||||
max_mel_tokens=max_mel_tokens,
|
||||
language=language,
|
||||
duration_factor=duration_factor,
|
||||
text_normalization=text_normalization,
|
||||
stream_return=stream_return,
|
||||
more_segment_before=more_segment_before,
|
||||
**kwargs,
|
||||
)
|
||||
elif processed_segments:
|
||||
# Single character segment
|
||||
first_segment = processed_segments[0]
|
||||
@@ -157,19 +205,10 @@ class IndexTTSAdapter:
|
||||
# Determine final speaker and emotion audio
|
||||
final_speaker_audio = speaker_audio
|
||||
|
||||
# Handle Character Voices emotion_audio format
|
||||
if emotion_audio and isinstance(emotion_audio, dict):
|
||||
if "audio_path" in emotion_audio:
|
||||
# Character Voices format: {'audio': {...}, 'audio_path': 'path', ...}
|
||||
final_emotion_audio = emotion_audio["audio_path"]
|
||||
print(f"🎭 Using Character Voices emotion audio: {emotion_audio.get('character_name', 'unknown')} -> {final_emotion_audio}")
|
||||
elif "waveform" in emotion_audio:
|
||||
# Direct AUDIO format: {'waveform': tensor, 'sample_rate': rate}
|
||||
final_emotion_audio = emotion_audio
|
||||
else:
|
||||
final_emotion_audio = emotion_audio
|
||||
else:
|
||||
final_emotion_audio = emotion_audio
|
||||
# Normalize the two supported audio-reference shapes:
|
||||
# Character Voices returns {audio: {waveform, sample_rate}, audio_path: ...},
|
||||
# while ComfyUI AUDIO returns {waveform, sample_rate} directly.
|
||||
final_emotion_audio = self._normalize_emotion_audio(emotion_audio)
|
||||
|
||||
# Only do character mapping if we actually have character tags
|
||||
if has_character_tags:
|
||||
@@ -221,9 +260,12 @@ class IndexTTSAdapter:
|
||||
length_penalty=length_penalty,
|
||||
repetition_penalty=repetition_penalty,
|
||||
max_mel_tokens=max_mel_tokens,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
interval_silence=interval_silence,
|
||||
stream_return=stream_return,
|
||||
max_text_tokens_per_segment=max_text_tokens_per_segment,
|
||||
interval_silence=interval_silence,
|
||||
language=language,
|
||||
duration_factor=duration_factor,
|
||||
text_normalization=text_normalization,
|
||||
stream_return=stream_return,
|
||||
more_segment_before=more_segment_before,
|
||||
**kwargs # Include seed and other kwargs in cache key
|
||||
)
|
||||
@@ -263,18 +305,11 @@ class IndexTTSAdapter:
|
||||
engine_kwargs['stream_return'] = stream_return
|
||||
engine_kwargs['more_segment_before'] = more_segment_before
|
||||
|
||||
# Apply consistent emotion priority: emotion_audio takes precedence over other emotion controls
|
||||
# This ensures consistent behavior whether using character tags or direct engine inputs
|
||||
if final_emotion_audio:
|
||||
# emotion_audio connected - disable other emotion controls
|
||||
final_emotion_vector = None
|
||||
final_use_emotion_text = False
|
||||
final_emotion_text = None
|
||||
else:
|
||||
# No emotion_audio - use provided emotion controls
|
||||
final_emotion_vector = emotion_vector
|
||||
final_use_emotion_text = use_emotion_text
|
||||
final_emotion_text = emotion_text
|
||||
# Audio emotion and vector/text emotion are independent conditioning
|
||||
# sources. IndexTTS-2 blends them in its latent emotion space.
|
||||
final_emotion_vector = emotion_vector
|
||||
final_use_emotion_text = use_emotion_text
|
||||
final_emotion_text = emotion_text
|
||||
|
||||
# Generate audio with OOM protection
|
||||
try:
|
||||
@@ -294,9 +329,12 @@ class IndexTTSAdapter:
|
||||
top_k=top_k,
|
||||
length_penalty=length_penalty,
|
||||
num_beams=num_beams,
|
||||
repetition_penalty=repetition_penalty,
|
||||
max_mel_tokens=max_mel_tokens,
|
||||
**engine_kwargs
|
||||
repetition_penalty=repetition_penalty,
|
||||
max_mel_tokens=max_mel_tokens,
|
||||
language=language,
|
||||
duration_factor=duration_factor,
|
||||
text_normalization=text_normalization,
|
||||
**engine_kwargs
|
||||
)
|
||||
except torch.OutOfMemoryError as e:
|
||||
# Analyze audio after OOM to provide helpful feedback
|
||||
@@ -376,7 +414,7 @@ class IndexTTSAdapter:
|
||||
Returns:
|
||||
Combined audio tensor [1, samples] at 22050 Hz
|
||||
"""
|
||||
audio_segments = []
|
||||
audio_segments = []
|
||||
|
||||
# Get character mapping for all unique characters
|
||||
unique_characters = set()
|
||||
@@ -391,12 +429,22 @@ class IndexTTSAdapter:
|
||||
if unique_characters:
|
||||
character_mapping = get_character_mapping(list(unique_characters), engine_type="index_tts")
|
||||
|
||||
print(f"🎭 IndexTTS-2: Processing {len(segments)} character segment(s) - {', '.join([s.get('character', 'narrator') for s in segments])}")
|
||||
resolved_names = [
|
||||
resolved_character_label(
|
||||
segment.get('character', 'narrator'),
|
||||
character_mapping.get(segment.get('character', 'narrator'), (default_speaker_audio, None)),
|
||||
)
|
||||
for segment in segments
|
||||
]
|
||||
print(f"🎭 IndexTTS-2: Processing {len(segments)} character segment(s) - {', '.join(resolved_names)}")
|
||||
|
||||
for segment in segments:
|
||||
character_name = segment.get('character', 'narrator')
|
||||
segment_text = segment.get('text', '').strip()
|
||||
emotion_ref = segment.get('emotion')
|
||||
emotion_ref = segment.get('emotion')
|
||||
segment_kwargs = dict(kwargs)
|
||||
if segment.get('language'):
|
||||
segment_kwargs['language'] = segment['language']
|
||||
|
||||
if not segment_text:
|
||||
continue
|
||||
@@ -407,7 +455,7 @@ class IndexTTSAdapter:
|
||||
character_audio_path = character_mapping[character_name][0]
|
||||
if character_audio_path:
|
||||
speaker_audio = character_audio_path
|
||||
print(f"📖 Using character voice '{character_name}' | Ref: '{speaker_audio}'")
|
||||
print(f"📖 Using character voice '{resolved_character_label(character_name, speaker_audio)}' | Ref: '{speaker_audio}'")
|
||||
else:
|
||||
print(f"⚠️ Character '{character_name}' has no audio reference, using default")
|
||||
|
||||
@@ -422,24 +470,24 @@ class IndexTTSAdapter:
|
||||
# Generate cache key for this segment
|
||||
segment_cache_key = self._generate_cache_key(
|
||||
text=segment_text,
|
||||
speaker_audio=speaker_audio,
|
||||
emotion_audio=emotion_audio,
|
||||
**kwargs
|
||||
speaker_audio=speaker_audio,
|
||||
emotion_audio=emotion_audio,
|
||||
**segment_kwargs
|
||||
)
|
||||
|
||||
# Check cache first
|
||||
cached_segment_audio = self.audio_cache.get_cached_audio(segment_cache_key)
|
||||
if cached_segment_audio:
|
||||
print(f"💾 Using cached IndexTTS-2 segment for '{character_name}': '{segment_text[:30]}...'")
|
||||
print(f"💾 Using cached IndexTTS-2 segment for '{resolved_character_label(character_name, speaker_audio)}': '{segment_text[:30]}...'")
|
||||
segment_audio = cached_segment_audio[0]
|
||||
else:
|
||||
# Generate audio for this segment with OOM protection
|
||||
try:
|
||||
segment_audio = self.engine.generate(
|
||||
text=segment_text,
|
||||
speaker_audio=speaker_audio,
|
||||
emotion_audio=emotion_audio,
|
||||
**kwargs
|
||||
speaker_audio=speaker_audio,
|
||||
emotion_audio=emotion_audio,
|
||||
**segment_kwargs
|
||||
)
|
||||
except torch.OutOfMemoryError as e:
|
||||
# Analyze audio after OOM in multi-character segments
|
||||
@@ -463,9 +511,18 @@ class IndexTTSAdapter:
|
||||
# Return silence if no segments generated
|
||||
return torch.zeros(1, 22050, dtype=torch.float32)
|
||||
|
||||
def _generate_cache_key(self, **params) -> str:
|
||||
"""Generate cache key for IndexTTS-2."""
|
||||
return self.audio_cache.generate_cache_key('index_tts', **params)
|
||||
def _generate_cache_key(self, **params) -> str:
|
||||
"""Generate cache key for IndexTTS-2."""
|
||||
model_identity = {}
|
||||
if self.engine is not None:
|
||||
model_identity = {
|
||||
"model_name": getattr(self.engine, "model_name", None),
|
||||
"model_version": getattr(self.engine, "model_version", None),
|
||||
"model_path": getattr(self.engine, "model_dir", None),
|
||||
}
|
||||
return self.audio_cache.generate_cache_key(
|
||||
'index_tts', **model_identity, **params
|
||||
)
|
||||
|
||||
def _analyze_audio_after_oom(self, speaker_audio: str, emotion_audio: str, max_mel_tokens: int) -> str:
|
||||
"""
|
||||
@@ -534,16 +591,18 @@ class IndexTTSAdapter:
|
||||
|
||||
return "\n".join(analysis_parts)
|
||||
|
||||
def _get_stable_audio_identifier(self, audio_path: str) -> str:
|
||||
"""
|
||||
Get stable identifier for audio file using centralized audio hashing.
|
||||
"""
|
||||
if not audio_path:
|
||||
return audio_path
|
||||
|
||||
# Use our centralized audio hashing utility
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
return generate_stable_audio_component(audio_file_path=audio_path)
|
||||
def _get_stable_audio_identifier(self, audio_path: str) -> str:
|
||||
"""
|
||||
Get stable identifier for audio file using centralized audio hashing.
|
||||
"""
|
||||
if not audio_path:
|
||||
return audio_path
|
||||
|
||||
# Use our centralized audio hashing utility
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
if isinstance(audio_path, dict):
|
||||
return generate_stable_audio_component(reference_audio=audio_path)
|
||||
return generate_stable_audio_component(audio_file_path=audio_path)
|
||||
|
||||
def get_supported_formats(self) -> List[str]:
|
||||
"""Get supported audio formats."""
|
||||
@@ -580,10 +639,6 @@ class IndexTTSAdapter:
|
||||
|
||||
def unload(self):
|
||||
"""Unload the engine to free memory."""
|
||||
if self.engine:
|
||||
self.engine.unload()
|
||||
self.engine = None
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on deletion."""
|
||||
self.unload()
|
||||
if self.engine:
|
||||
self.engine.unload()
|
||||
self.engine = None
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Suite adapter for MOSS-SoundEffect v2."""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from engines.moss_soundeffect_v2.downloader import MossSoundEffectV2Downloader
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
|
||||
class MossSoundEffectV2Adapter:
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = dict(config)
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._load_config = None
|
||||
|
||||
def _load(self):
|
||||
model = self.config.get("model", MossSoundEffectV2Downloader.MODEL_NAME)
|
||||
model_path = MossSoundEffectV2Downloader().resolve_model_path(model)
|
||||
self._load_config = ModelLoadConfig(
|
||||
engine_name="moss_soundeffect_v2",
|
||||
model_type="tts",
|
||||
model_name=str(model).removeprefix("local:"),
|
||||
model_path=model_path,
|
||||
device=self.config.get("device", "auto"),
|
||||
runtime_mode="main_environment",
|
||||
additional_params={"dtype": self.config.get("dtype", "auto")},
|
||||
)
|
||||
return unified_model_interface.load_model(self._load_config)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
description: str,
|
||||
duration_seconds: float,
|
||||
seed: int,
|
||||
enable_audio_cache: bool,
|
||||
) -> Tuple[torch.Tensor, int, bool]:
|
||||
duration_seconds = round(float(duration_seconds), 1)
|
||||
if duration_seconds > 30.0:
|
||||
raise ValueError(
|
||||
"MOSS-SoundEffect v2 supports a maximum duration of 30 seconds. "
|
||||
"Lower duration_seconds in the 🌩️ Sound Effects node."
|
||||
)
|
||||
params = {
|
||||
"description": description,
|
||||
"model": self.config.get("model"),
|
||||
"duration_seconds": duration_seconds,
|
||||
"inference_steps": self.config.get("inference_steps", 100),
|
||||
"cfg_scale": self.config.get("cfg_scale", 4.0),
|
||||
"sigma_shift": self.config.get("sigma_shift", 5.0),
|
||||
"negative_prompt": self.config.get("negative_prompt", ""),
|
||||
"seed": int(seed),
|
||||
"dtype": self.config.get("dtype", "auto"),
|
||||
"device": self.config.get("device", "auto"),
|
||||
}
|
||||
cache_key = self.audio_cache.generate_cache_key("moss_soundeffect_v2", **params)
|
||||
if enable_audio_cache:
|
||||
cached = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached is not None:
|
||||
return cached[0], 48000, True
|
||||
|
||||
engine = self._load()
|
||||
waveform, sample_rate = engine.generate_sound_effect(**params)
|
||||
waveform = waveform.detach().cpu().float()
|
||||
if enable_audio_cache:
|
||||
self.audio_cache.cache_audio(cache_key, waveform, waveform.shape[-1] / float(sample_rate))
|
||||
return waveform, int(sample_rate), False
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
MOSS-TTS engine adapter.
|
||||
|
||||
Translates TTS Audio Suite processor calls into official MOSS-TTS inference
|
||||
without exposing non-native controls.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.text.pause_processor import PauseTagProcessor
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
|
||||
|
||||
class MossTTSEngineAdapter:
|
||||
"""Adapter for the official MOSS-TTS model API."""
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
def __init__(self, node_instance=None):
|
||||
self.node = node_instance
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_config: Optional[ModelLoadConfig] = None
|
||||
|
||||
def load_model(
|
||||
self,
|
||||
model_variant: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
attn_implementation: str = "auto",
|
||||
codec_model: str = "MOSS-Audio-Tokenizer",
|
||||
lora_adapter: Optional[str] = None,
|
||||
defer_load: bool = False,
|
||||
):
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="moss_tts",
|
||||
model_type="tts",
|
||||
model_name=model_variant,
|
||||
model_path=model_variant,
|
||||
device=device,
|
||||
additional_params={
|
||||
"dtype": dtype,
|
||||
"attn_implementation": attn_implementation,
|
||||
"codec_model": codec_model,
|
||||
"lora_adapter": lora_adapter,
|
||||
},
|
||||
)
|
||||
self._last_config = config
|
||||
if not defer_load:
|
||||
unified_model_interface.load_model(config)
|
||||
|
||||
def update_model_config(
|
||||
self,
|
||||
model_variant: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
attn_implementation: str = "auto",
|
||||
codec_model: str = "MOSS-Audio-Tokenizer",
|
||||
lora_adapter: Optional[str] = None,
|
||||
):
|
||||
self.load_model(model_variant, device, dtype, attn_implementation, codec_model, lora_adapter)
|
||||
|
||||
def _get_engine(self):
|
||||
if self._last_config is None:
|
||||
raise RuntimeError("MOSS-TTS model has not been loaded")
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
return unified_model_interface.load_model(self._last_config)
|
||||
|
||||
def generate_with_pause_tags(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
process_pauses: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> torch.Tensor:
|
||||
engine = self._get_engine()
|
||||
seed = int(params.get("seed", 0) or 0)
|
||||
if seed > 0:
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
|
||||
if process_pauses and PauseTagProcessor.has_pause_tags(text):
|
||||
return self._generate_with_pauses(text, voice_ref, params, character_name, engine)
|
||||
return self._generate_direct(text, voice_ref, params, character_name, engine)
|
||||
|
||||
def _generate_direct(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None,
|
||||
engine=None,
|
||||
) -> torch.Tensor:
|
||||
reference_audio, reference_sample_rate, audio_component = self._extract_voice_reference(voice_ref)
|
||||
model_variant = params.get("model_variant", "MOSS-TTS-Local-Transformer")
|
||||
language = params.get("language", "auto")
|
||||
explicit_duration = params.get("duration_tokens")
|
||||
duration_tokens = max(1, int(explicit_duration)) if explicit_duration else None
|
||||
resolved_max_new_tokens = int(params.get("max_new_tokens", 4096))
|
||||
n_vq_for_inference = params.get("n_vq_for_inference")
|
||||
lora_adapter = params.get("lora_adapter")
|
||||
instruction = params.get("instruction")
|
||||
quality = params.get("quality")
|
||||
sound_event = params.get("sound_event")
|
||||
ambient_sound = params.get("ambient_sound")
|
||||
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"moss_tts",
|
||||
text=text,
|
||||
model_variant=model_variant,
|
||||
audio_component=audio_component,
|
||||
language=language,
|
||||
instruction=instruction,
|
||||
quality=quality,
|
||||
sound_event=sound_event,
|
||||
ambient_sound=ambient_sound,
|
||||
duration_tokens=duration_tokens,
|
||||
audio_temperature=params.get("audio_temperature", params.get("temperature", 1.0)),
|
||||
audio_top_p=params.get("audio_top_p", params.get("top_p", 0.95)),
|
||||
audio_top_k=params.get("audio_top_k", params.get("top_k", 50)),
|
||||
audio_repetition_penalty=params.get("audio_repetition_penalty", params.get("repetition_penalty", 1.1)),
|
||||
max_new_tokens=resolved_max_new_tokens,
|
||||
n_vq_for_inference=n_vq_for_inference,
|
||||
seed=params.get("seed", 0),
|
||||
device=params.get("device", "auto"),
|
||||
dtype=params.get("dtype", "auto"),
|
||||
attn_implementation=params.get("attn_implementation", "auto"),
|
||||
lora_adapter=lora_adapter,
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
|
||||
enable_audio_cache = bool(params.get("enable_audio_cache", True))
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key) if enable_audio_cache else None
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached MOSS-TTS audio for '{character_name or 'narrator'}': '{text[:30]}...'")
|
||||
return cached_audio[0]
|
||||
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
audio_tensor, sample_rate = engine.generate(
|
||||
text=text,
|
||||
reference_audio=reference_audio,
|
||||
reference_sample_rate=reference_sample_rate,
|
||||
language=language,
|
||||
duration_tokens=duration_tokens,
|
||||
instruction=instruction,
|
||||
quality=quality,
|
||||
sound_event=sound_event,
|
||||
ambient_sound=ambient_sound,
|
||||
seed=int(params.get("seed", 0) or 0),
|
||||
audio_temperature=float(params.get("audio_temperature", params.get("temperature", 1.0))),
|
||||
audio_top_p=float(params.get("audio_top_p", params.get("top_p", 0.95))),
|
||||
audio_top_k=int(params.get("audio_top_k", params.get("top_k", 50))),
|
||||
audio_repetition_penalty=float(
|
||||
params.get("audio_repetition_penalty", params.get("repetition_penalty", 1.1))
|
||||
),
|
||||
max_new_tokens=int(resolved_max_new_tokens),
|
||||
n_vq_for_inference=n_vq_for_inference,
|
||||
)
|
||||
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
raise RuntimeError(f"MOSS-TTS returned unexpected sample rate {sample_rate}; expected {self.SAMPLE_RATE}")
|
||||
|
||||
if enable_audio_cache:
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, "moss_tts")
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
return audio_tensor
|
||||
|
||||
def generate_sound_effect(self, description: str, params: Dict[str, Any]) -> torch.Tensor:
|
||||
"""Generate MOSS-SoundEffect v1 audio without a speech or narrator reference."""
|
||||
sound_params = dict(params)
|
||||
sound_params["ambient_sound"] = str(description or "").strip()
|
||||
if not sound_params["ambient_sound"]:
|
||||
raise ValueError("MOSS-SoundEffect requires a non-empty description")
|
||||
return self._generate_direct(
|
||||
text="",
|
||||
voice_ref=None,
|
||||
params=sound_params,
|
||||
character_name="sound_effects",
|
||||
)
|
||||
|
||||
def _generate_with_pauses(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None,
|
||||
engine=None,
|
||||
) -> torch.Tensor:
|
||||
segments, _ = PauseTagProcessor.parse_pause_tags(text)
|
||||
audio_parts = []
|
||||
for segment_type, content in segments:
|
||||
if segment_type == "text":
|
||||
audio_tensor = self._generate_direct(content, voice_ref, params, character_name, engine)
|
||||
if audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
elif audio_tensor.dim() == 3:
|
||||
audio_tensor = audio_tensor.squeeze(0)
|
||||
audio_parts.append(audio_tensor)
|
||||
elif segment_type == "pause":
|
||||
audio_parts.append(PauseTagProcessor.create_silence_segment(content, self.SAMPLE_RATE))
|
||||
|
||||
if not audio_parts:
|
||||
return torch.zeros(1, 0)
|
||||
return torch.cat(audio_parts, dim=-1)
|
||||
|
||||
def _extract_voice_reference(self, voice_ref: Optional[Dict[str, Any]]) -> Tuple[Any, Optional[int], str]:
|
||||
if not voice_ref or not isinstance(voice_ref, dict):
|
||||
return None, None, "default_voice"
|
||||
|
||||
ref_audio = effective_voice_audio(voice_ref)
|
||||
if ref_audio is None:
|
||||
return None, None, "default_voice"
|
||||
|
||||
if isinstance(ref_audio, str):
|
||||
return ref_audio, None, generate_stable_audio_component(audio_file_path=ref_audio)
|
||||
|
||||
if isinstance(ref_audio, dict) and "waveform" in ref_audio:
|
||||
return (
|
||||
ref_audio,
|
||||
int(ref_audio.get("sample_rate", self.SAMPLE_RATE)),
|
||||
generate_stable_audio_component(reference_audio=ref_audio),
|
||||
)
|
||||
|
||||
if torch.is_tensor(ref_audio):
|
||||
sample_rate = int(voice_ref.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": ref_audio, "sample_rate": sample_rate}
|
||||
return audio_dict, sample_rate, generate_stable_audio_component(reference_audio=audio_dict)
|
||||
|
||||
return ref_audio, voice_ref.get("sample_rate"), "custom_voice"
|
||||
|
||||
def _normalize_dialogue_voice_ref(self, voice_ref: Any, speaker_label: str) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
if voice_ref is None:
|
||||
return None, "no_voice"
|
||||
|
||||
if isinstance(voice_ref, str):
|
||||
print(
|
||||
f"❌ MOSS-TTSD {speaker_label}: received character name '{voice_ref}' instead of voice reference. "
|
||||
"Connect Character Voices opt_narrator, not character_name."
|
||||
)
|
||||
return None, "invalid_string_voice"
|
||||
|
||||
if not isinstance(voice_ref, dict):
|
||||
print(f"❌ MOSS-TTSD {speaker_label}: invalid voice reference type {type(voice_ref).__name__}")
|
||||
return None, "invalid_voice"
|
||||
|
||||
ref_audio = effective_voice_audio(voice_ref)
|
||||
reference_text = (
|
||||
voice_ref.get("reference_text")
|
||||
or voice_ref.get("text")
|
||||
or voice_ref.get("prompt_text")
|
||||
or ""
|
||||
)
|
||||
|
||||
if ref_audio is None:
|
||||
return None, "no_audio"
|
||||
|
||||
normalized = {
|
||||
"audio": ref_audio,
|
||||
"sample_rate": voice_ref.get("sample_rate", self.SAMPLE_RATE),
|
||||
"reference_text": str(reference_text or "").strip(),
|
||||
}
|
||||
|
||||
if isinstance(ref_audio, str):
|
||||
audio_component = generate_stable_audio_component(audio_file_path=ref_audio)
|
||||
elif isinstance(ref_audio, dict) and "waveform" in ref_audio:
|
||||
audio_component = generate_stable_audio_component(reference_audio=ref_audio)
|
||||
normalized["sample_rate"] = int(ref_audio.get("sample_rate", self.SAMPLE_RATE))
|
||||
elif torch.is_tensor(ref_audio):
|
||||
audio_component = generate_stable_audio_component(
|
||||
reference_audio={
|
||||
"waveform": ref_audio,
|
||||
"sample_rate": normalized["sample_rate"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
audio_component = "custom_voice"
|
||||
|
||||
if not normalized["reference_text"]:
|
||||
print(f"⚠️ MOSS-TTSD {speaker_label}: reference audio has no transcript; cloning disabled for this speaker")
|
||||
return None, audio_component
|
||||
|
||||
return normalized, audio_component
|
||||
|
||||
def generate_native_dialogue(
|
||||
self,
|
||||
dialogue_text: str,
|
||||
speaker_voices: List[Any],
|
||||
params: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
engine = self._get_engine()
|
||||
normalized_refs: List[Optional[Dict[str, Any]]] = []
|
||||
audio_components = []
|
||||
|
||||
for idx, voice_ref in enumerate(speaker_voices):
|
||||
normalized, audio_component = self._normalize_dialogue_voice_ref(voice_ref, f"S{idx + 1}")
|
||||
normalized_refs.append(normalized)
|
||||
audio_components.append(audio_component)
|
||||
|
||||
model_variant = params.get("model_variant", "MOSS-TTSD-v1.0")
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"moss_tts",
|
||||
text=dialogue_text,
|
||||
model_variant=model_variant,
|
||||
audio_component="ttsd_" + "_".join(audio_components),
|
||||
language=params.get("language", "auto"),
|
||||
instruction=params.get("instruction"),
|
||||
quality=params.get("quality"),
|
||||
sound_event=params.get("sound_event"),
|
||||
ambient_sound=params.get("ambient_sound"),
|
||||
duration_tokens=params.get("duration_tokens"),
|
||||
audio_temperature=params.get("audio_temperature", params.get("temperature", 1.1)),
|
||||
audio_top_p=params.get("audio_top_p", params.get("top_p", 0.9)),
|
||||
audio_top_k=params.get("audio_top_k", params.get("top_k", 50)),
|
||||
audio_repetition_penalty=params.get("audio_repetition_penalty", params.get("repetition_penalty", 1.1)),
|
||||
max_new_tokens=params.get("max_new_tokens", 4096),
|
||||
seed=params.get("seed", 0),
|
||||
device=params.get("device", "auto"),
|
||||
dtype=params.get("dtype", "auto"),
|
||||
attn_implementation=params.get("attn_implementation", "auto"),
|
||||
character="native_multispeaker",
|
||||
)
|
||||
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print("💾 Using cached MOSS-TTSD native dialogue audio")
|
||||
audio_tensor = cached_audio[0]
|
||||
else:
|
||||
audio_tensor, sample_rate = engine.generate_dialogue(
|
||||
dialogue_text=dialogue_text,
|
||||
speaker_references=normalized_refs,
|
||||
language=params.get("language", "auto"),
|
||||
duration_tokens=params.get("duration_tokens"),
|
||||
instruction=params.get("instruction"),
|
||||
quality=params.get("quality"),
|
||||
sound_event=params.get("sound_event"),
|
||||
ambient_sound=params.get("ambient_sound"),
|
||||
seed=int(params.get("seed", 0) or 0),
|
||||
audio_temperature=float(params.get("audio_temperature", params.get("temperature", 1.1))),
|
||||
audio_top_p=float(params.get("audio_top_p", params.get("top_p", 0.9))),
|
||||
audio_top_k=int(params.get("audio_top_k", params.get("top_k", 50))),
|
||||
audio_repetition_penalty=float(
|
||||
params.get("audio_repetition_penalty", params.get("repetition_penalty", 1.1))
|
||||
),
|
||||
max_new_tokens=int(params.get("max_new_tokens", 4096)),
|
||||
)
|
||||
|
||||
if sample_rate != self.SAMPLE_RATE:
|
||||
raise RuntimeError(f"MOSS-TTSD returned unexpected sample rate {sample_rate}; expected {self.SAMPLE_RATE}")
|
||||
|
||||
duration = self.audio_cache._calculate_duration(audio_tensor, "moss_tts")
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration)
|
||||
|
||||
if audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
elif audio_tensor.dim() == 3:
|
||||
audio_tensor = audio_tensor.squeeze(0)
|
||||
|
||||
return {
|
||||
"waveform": audio_tensor,
|
||||
"sample_rate": self.SAMPLE_RATE,
|
||||
"character": "native_multispeaker",
|
||||
"text": dialogue_text,
|
||||
}
|
||||
|
||||
def cleanup(self):
|
||||
self._last_config = None
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
OmniVoice engine adapter.
|
||||
|
||||
Translates TTS Audio Suite processor calls into the official OmniVoice runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
from utils.models.language_mapper import resolve_language_alias
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
|
||||
|
||||
class OmniVoiceEngineAdapter:
|
||||
"""Adapter for official OmniVoice inference."""
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = config.copy() if config else {}
|
||||
self.audio_cache = get_audio_cache()
|
||||
self._last_config: Optional[ModelLoadConfig] = None
|
||||
self._load_signature = None
|
||||
|
||||
def update_config(self, new_config: Dict[str, Any]):
|
||||
self.config = new_config.copy() if new_config else {}
|
||||
|
||||
def _build_load_signature(self) -> Tuple[Any, ...]:
|
||||
return (
|
||||
self.config.get("model_variant", "OmniVoice"),
|
||||
self.config.get("device", "auto"),
|
||||
self.config.get("dtype", "auto"),
|
||||
)
|
||||
|
||||
def load_model(
|
||||
self,
|
||||
model_variant: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
):
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
config = ModelLoadConfig(
|
||||
engine_name="omnivoice",
|
||||
model_type="tts",
|
||||
model_name=model_variant,
|
||||
model_path=model_variant,
|
||||
device=device,
|
||||
additional_params={
|
||||
"dtype": dtype,
|
||||
},
|
||||
)
|
||||
self._last_config = config
|
||||
unified_model_interface.load_model(config)
|
||||
|
||||
def _ensure_model_loaded(self):
|
||||
signature = self._build_load_signature()
|
||||
if signature == self._load_signature and self._last_config is not None:
|
||||
return
|
||||
|
||||
self.load_model(
|
||||
model_variant=self.config.get("model_variant", "OmniVoice"),
|
||||
device=self.config.get("device", "auto"),
|
||||
dtype=self.config.get("dtype", "auto"),
|
||||
)
|
||||
self._load_signature = signature
|
||||
|
||||
def _get_engine(self):
|
||||
if self._last_config is None:
|
||||
self._ensure_model_loaded()
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
|
||||
return unified_model_interface.load_model(self._last_config)
|
||||
|
||||
@staticmethod
|
||||
def _seed_everything(seed: int):
|
||||
if int(seed or 0) <= 0:
|
||||
return
|
||||
torch.manual_seed(int(seed))
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(int(seed))
|
||||
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
torch.xpu.manual_seed_all(int(seed))
|
||||
|
||||
@staticmethod
|
||||
def _first_non_none(*values):
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_waveform_tensor(waveform: torch.Tensor) -> torch.Tensor:
|
||||
if waveform.dim() == 3:
|
||||
waveform = waveform.squeeze(0)
|
||||
if waveform.dim() == 1:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return waveform.detach().float().cpu()
|
||||
|
||||
def _extract_voice_reference(
|
||||
self,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Optional[Union[str, Tuple[torch.Tensor, int]]], str, str]:
|
||||
if not voice_ref or not isinstance(voice_ref, dict):
|
||||
return None, "", "default_voice"
|
||||
|
||||
prompt_text = (
|
||||
voice_ref.get("reference_text")
|
||||
or voice_ref.get("prompt_text")
|
||||
or voice_ref.get("text")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
ref_audio = effective_voice_audio(voice_ref)
|
||||
|
||||
if ref_audio is None:
|
||||
return None, prompt_text, "default_voice"
|
||||
|
||||
if isinstance(ref_audio, str):
|
||||
return ref_audio, prompt_text, generate_stable_audio_component(audio_file_path=ref_audio)
|
||||
|
||||
if isinstance(ref_audio, dict) and "waveform" in ref_audio:
|
||||
waveform = self._normalize_waveform_tensor(ref_audio["waveform"])
|
||||
sample_rate = int(ref_audio.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
return (
|
||||
(waveform, sample_rate),
|
||||
prompt_text,
|
||||
generate_stable_audio_component(reference_audio=audio_dict),
|
||||
)
|
||||
|
||||
if torch.is_tensor(ref_audio):
|
||||
waveform = self._normalize_waveform_tensor(ref_audio)
|
||||
sample_rate = int(voice_ref.get("sample_rate", self.SAMPLE_RATE))
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
return (
|
||||
(waveform, sample_rate),
|
||||
prompt_text,
|
||||
generate_stable_audio_component(reference_audio=audio_dict),
|
||||
)
|
||||
|
||||
raise TypeError(f"Unsupported OmniVoice reference type: {type(ref_audio)}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
if language is None:
|
||||
return None
|
||||
|
||||
normalized = str(language).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
lowered = normalized.lower()
|
||||
if lowered in {"auto", "none"}:
|
||||
return None
|
||||
|
||||
resolved = resolve_language_alias(normalized)
|
||||
|
||||
# OmniVoice uses the base ISO code for Portuguese rather than regional
|
||||
# variants accepted by other suite engines.
|
||||
if resolved in {"pt", "pt-br", "pt-pt"}:
|
||||
return "pt"
|
||||
|
||||
if resolved and resolved.lower() != lowered:
|
||||
return resolved
|
||||
return normalized
|
||||
|
||||
def generate_single(
|
||||
self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
seed: int = 0,
|
||||
enable_audio_cache: bool = True,
|
||||
character_name: Optional[str] = None,
|
||||
) -> torch.Tensor:
|
||||
stripped = (text or "").strip()
|
||||
if not stripped:
|
||||
return torch.zeros(1, 0, dtype=torch.float32)
|
||||
|
||||
self._ensure_model_loaded()
|
||||
engine = self._get_engine()
|
||||
|
||||
ref_audio, prompt_text, audio_component = self._extract_voice_reference(voice_ref)
|
||||
language = self._normalize_language(self.config.get("language", "Auto"))
|
||||
instruct = str(self.config.get("instruct", "") or "").strip() or None
|
||||
model_variant = self.config.get("model_variant", "OmniVoice")
|
||||
device = self.config.get("device", "auto")
|
||||
dtype = self.config.get("dtype", "auto")
|
||||
num_step = int(self.config.get("num_steps", 32))
|
||||
guidance_scale = float(self.config.get("guidance_scale", 2.0))
|
||||
t_shift = float(self.config.get("t_shift", 0.1))
|
||||
speed = float(self.config.get("speed", 1.0))
|
||||
duration_value = float(self.config.get("duration", 0.0))
|
||||
duration = duration_value if duration_value > 0 else None
|
||||
layer_penalty_factor = float(self.config.get("layer_penalty_factor", 5.0))
|
||||
position_temperature = float(self.config.get("position_temperature", 5.0))
|
||||
class_temperature = float(self.config.get("class_temperature", 0.0))
|
||||
denoise = bool(self.config.get("denoise", True))
|
||||
preprocess_prompt = bool(self.config.get("preprocess_prompt", True))
|
||||
postprocess_output = bool(self.config.get("postprocess_output", True))
|
||||
audio_chunk_duration = float(self.config.get("audio_chunk_duration", 15.0))
|
||||
audio_chunk_threshold = float(self.config.get("audio_chunk_threshold", 30.0))
|
||||
|
||||
cache_key = None
|
||||
if enable_audio_cache:
|
||||
cache_key = self.audio_cache.generate_cache_key(
|
||||
"omnivoice",
|
||||
text=stripped,
|
||||
audio_component=audio_component,
|
||||
prompt_text=prompt_text,
|
||||
model_variant=model_variant,
|
||||
language=language or "none",
|
||||
instruct=instruct or "",
|
||||
num_step=num_step,
|
||||
guidance_scale=guidance_scale,
|
||||
t_shift=t_shift,
|
||||
speed=speed,
|
||||
duration=duration if duration is not None else "auto",
|
||||
layer_penalty_factor=layer_penalty_factor,
|
||||
position_temperature=position_temperature,
|
||||
class_temperature=class_temperature,
|
||||
denoise=denoise,
|
||||
preprocess_prompt=preprocess_prompt,
|
||||
postprocess_output=postprocess_output,
|
||||
audio_chunk_duration=audio_chunk_duration,
|
||||
audio_chunk_threshold=audio_chunk_threshold,
|
||||
seed=int(seed or 0),
|
||||
device=device,
|
||||
dtype=dtype,
|
||||
character=character_name or "narrator",
|
||||
)
|
||||
cached_audio = self.audio_cache.get_cached_audio(cache_key)
|
||||
if cached_audio:
|
||||
print(f"💾 Using cached OmniVoice audio for '{character_name or 'narrator'}': '{stripped[:30]}...'")
|
||||
return cached_audio[0]
|
||||
|
||||
voice_clone_prompt = None
|
||||
if ref_audio is not None:
|
||||
if not prompt_text:
|
||||
raise ValueError(
|
||||
"OmniVoice voice cloning requires reference text. "
|
||||
"Direct audio-only narrator input is unsupported. "
|
||||
"Use Character Voices or a narrator voice file with matching reference text."
|
||||
)
|
||||
voice_clone_prompt = engine.create_voice_clone_prompt(
|
||||
ref_audio=ref_audio,
|
||||
ref_text=prompt_text,
|
||||
preprocess_prompt=preprocess_prompt,
|
||||
)
|
||||
|
||||
self._seed_everything(seed)
|
||||
|
||||
result = engine.generate(
|
||||
text=stripped,
|
||||
language=language,
|
||||
voice_clone_prompt=voice_clone_prompt,
|
||||
instruct=instruct,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
num_step=num_step,
|
||||
guidance_scale=guidance_scale,
|
||||
t_shift=t_shift,
|
||||
denoise=denoise,
|
||||
preprocess_prompt=preprocess_prompt,
|
||||
postprocess_output=postprocess_output,
|
||||
layer_penalty_factor=layer_penalty_factor,
|
||||
position_temperature=position_temperature,
|
||||
class_temperature=class_temperature,
|
||||
audio_chunk_duration=audio_chunk_duration,
|
||||
audio_chunk_threshold=audio_chunk_threshold,
|
||||
)
|
||||
|
||||
audio_tensor = result[0]
|
||||
if not isinstance(audio_tensor, torch.Tensor):
|
||||
audio_tensor = torch.tensor(audio_tensor, dtype=torch.float32)
|
||||
audio_tensor = audio_tensor.detach().float().cpu()
|
||||
if audio_tensor.dim() == 1:
|
||||
audio_tensor = audio_tensor.unsqueeze(0)
|
||||
|
||||
if enable_audio_cache and cache_key:
|
||||
duration_seconds = self.audio_cache._calculate_duration(audio_tensor, "omnivoice")
|
||||
self.audio_cache.cache_audio(cache_key, audio_tensor, duration_seconds)
|
||||
|
||||
return audio_tensor
|
||||
@@ -19,7 +19,8 @@ if project_root not in sys.path:
|
||||
|
||||
from engines.qwen3_tts.qwen3_tts import Qwen3TTSEngine
|
||||
from utils.text.pause_processor import PauseTagProcessor
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.audio.cache import get_audio_cache
|
||||
from utils.voice.reference import effective_voice_audio
|
||||
import folder_paths
|
||||
|
||||
|
||||
@@ -44,9 +45,11 @@ class Qwen3TTSEngineAdapter:
|
||||
node_instance: Parent node instance for context
|
||||
"""
|
||||
self.node = node_instance
|
||||
self.engine = None
|
||||
# DO NOT store engine reference - always query unified interface
|
||||
# Storing references causes stale CPU-bound engines when models are unloaded
|
||||
self.audio_cache = get_audio_cache()
|
||||
self.current_model_type = None # Track which model variant is loaded
|
||||
self._last_config = None # Store last ModelLoadConfig for engine retrieval
|
||||
|
||||
# Job-level timing tracker (persists across blocks)
|
||||
self.job_tracker = None
|
||||
@@ -103,9 +106,14 @@ class Qwen3TTSEngineAdapter:
|
||||
Returns:
|
||||
Model type string: "CustomVoice", "VoiceDesign", or "Base"
|
||||
"""
|
||||
# Priority 1: Voice Designer node
|
||||
if context.get("node_type") == "voice_designer":
|
||||
return "VoiceDesign"
|
||||
# Explicit engine selection is authoritative for refactored workflows.
|
||||
explicit_model_type = context.get("model_type")
|
||||
if explicit_model_type in {"Base", "CustomVoice", "VoiceDesign"}:
|
||||
return explicit_model_type
|
||||
|
||||
# Legacy voice designer context.
|
||||
if context.get("node_type") == "voice_designer":
|
||||
return "VoiceDesign"
|
||||
|
||||
# Priority 2: Preset voice selected
|
||||
voice_preset = context.get("voice_preset")
|
||||
@@ -115,13 +123,18 @@ class Qwen3TTSEngineAdapter:
|
||||
# Priority 3: Default to Base (zero-shot)
|
||||
return "Base"
|
||||
|
||||
def load_base_model(self,
|
||||
model_path: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
model_size: str = "1.7B",
|
||||
attn_implementation: str = "auto",
|
||||
context: Optional[Dict[str, Any]] = None):
|
||||
def load_base_model(self,
|
||||
model_path: str,
|
||||
device: str = "auto",
|
||||
dtype: str = "auto",
|
||||
model_size: str = "1.7B",
|
||||
attn_implementation: str = "auto",
|
||||
runtime_mode: str = "main_environment",
|
||||
runtime_profile: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
use_torch_compile: bool = False,
|
||||
use_cuda_graphs: bool = False,
|
||||
compile_mode: str = "reduce-overhead"):
|
||||
"""
|
||||
Load Qwen3-TTS engine via unified interface with intelligent model selection.
|
||||
|
||||
@@ -132,6 +145,9 @@ class Qwen3TTSEngineAdapter:
|
||||
model_size: Model size (0.6B/1.7B)
|
||||
attn_implementation: Attention mechanism (auto/flash_attention_2/sdpa/eager)
|
||||
context: Context dict for model type determination
|
||||
use_torch_compile: Enable torch.compile optimization
|
||||
use_cuda_graphs: Enable CUDA graph capture
|
||||
compile_mode: torch.compile mode (reduce-overhead/max-autotune/default)
|
||||
"""
|
||||
from utils.models.unified_model_interface import unified_model_interface, ModelLoadConfig
|
||||
from utils.device import resolve_torch_device
|
||||
@@ -145,26 +161,113 @@ class Qwen3TTSEngineAdapter:
|
||||
model_size = "1.7B"
|
||||
print("⚠️ VoiceDesign requires 1.7B model, auto-switching from 0.6B")
|
||||
|
||||
# Build model name
|
||||
model_name = f"Qwen3-TTS-12Hz-{model_size}-{model_type}"
|
||||
# Keep the canonical model name separate from a local: model path.
|
||||
model_name = context.get("model_name") or f"Qwen3-TTS-12Hz-{model_size}-{model_type}"
|
||||
|
||||
# Track current model type (unified interface handles unloading automatically)
|
||||
self.current_model_type = model_type
|
||||
|
||||
# Loading message is printed by unified_model_interface
|
||||
|
||||
# Create config and load via unified interface
|
||||
# Create config for unified interface
|
||||
config = ModelLoadConfig(
|
||||
engine_name="qwen3_tts",
|
||||
model_type="tts",
|
||||
model_name=model_name,
|
||||
model_path=model_path if model_path else model_name,
|
||||
device=resolve_torch_device(device),
|
||||
additional_params={
|
||||
"dtype": dtype,
|
||||
"attn_implementation": attn_implementation
|
||||
model_name=model_name,
|
||||
model_path=model_path if model_path else model_name,
|
||||
device=resolve_torch_device(device),
|
||||
runtime_mode=runtime_mode,
|
||||
runtime_profile=runtime_profile,
|
||||
additional_params={
|
||||
"dtype": dtype,
|
||||
"attn_implementation": attn_implementation,
|
||||
"use_torch_compile": use_torch_compile,
|
||||
"use_cuda_graphs": use_cuda_graphs,
|
||||
"compile_mode": compile_mode
|
||||
}
|
||||
)
|
||||
|
||||
self.engine = unified_model_interface.load_model(config)
|
||||
# Store config for later engine retrieval (DO NOT store engine reference)
|
||||
self._last_config = config
|
||||
|
||||
# Load model via unified interface (but don't store the reference)
|
||||
engine = unified_model_interface.load_model(config)
|
||||
|
||||
# Enable streaming optimizations if requested
|
||||
if use_torch_compile or use_cuda_graphs:
|
||||
try:
|
||||
engine.enable_streaming_optimizations(
|
||||
use_compile=use_torch_compile,
|
||||
use_cuda_graphs=use_cuda_graphs,
|
||||
compile_mode=compile_mode
|
||||
)
|
||||
print(f"✅ Qwen3-TTS: Streaming optimizations enabled (compile={use_torch_compile}, cuda_graphs={use_cuda_graphs})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to enable streaming optimizations: {e}")
|
||||
|
||||
def _get_engine(self):
|
||||
"""
|
||||
Get current engine from unified interface.
|
||||
|
||||
CRITICAL: Always query unified interface instead of storing references.
|
||||
This prevents stale CPU-bound engine references when models are unloaded.
|
||||
|
||||
Returns:
|
||||
Current Qwen3-TTS engine from unified interface
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no engine is loaded or config not set
|
||||
"""
|
||||
if self._last_config is None:
|
||||
raise RuntimeError("No model loaded. Call load_base_model() first.")
|
||||
|
||||
# Query unified interface for current engine
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
engine = unified_model_interface.load_model(self._last_config)
|
||||
|
||||
if engine is None:
|
||||
raise RuntimeError("Engine not found in unified interface cache. Model may have been unloaded.")
|
||||
|
||||
return engine
|
||||
|
||||
def _ensure_engine_on_device(self, target_device: str = "auto"):
|
||||
"""
|
||||
Ensure engine is loaded and on the correct device.
|
||||
|
||||
CRITICAL: After unified_model_interface unloads a model variant, the adapter
|
||||
may still hold a reference to a CPU-bound engine. This method detects that
|
||||
and triggers a reload.
|
||||
|
||||
Args:
|
||||
target_device: Target device (auto/cuda/cpu)
|
||||
"""
|
||||
if self.engine is None:
|
||||
raise RuntimeError("Engine not loaded. Call load_base_model() first.")
|
||||
|
||||
# Check if engine has been moved to CPU (likely by model manager unload)
|
||||
# The engine wrapper has a _model attribute that's the actual Qwen3TTSModel
|
||||
if hasattr(self.engine, '_model') and hasattr(self.engine._model, 'model'):
|
||||
actual_model = self.engine._model.model
|
||||
if hasattr(actual_model, 'parameters'):
|
||||
try:
|
||||
first_param = next(actual_model.parameters())
|
||||
current_device = str(first_param.device)
|
||||
|
||||
# If model is on CPU but we want CUDA, it was likely unloaded
|
||||
from utils.device import resolve_torch_device
|
||||
expected_device = resolve_torch_device(target_device)
|
||||
|
||||
if current_device == "cpu" and expected_device != "cpu":
|
||||
print(f"⚠️ Qwen3-TTS engine is on CPU but {expected_device} requested")
|
||||
print(f" This usually means the model was unloaded by unified interface")
|
||||
print(f" Engine reference is stale - please check adapter state")
|
||||
raise RuntimeError(
|
||||
"Qwen3-TTS engine is on wrong device (CPU). "
|
||||
"This indicates the cached processor has a stale engine reference. "
|
||||
"The processor should have been reloaded by update_config()."
|
||||
)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
def generate_with_pause_tags(self,
|
||||
text: str,
|
||||
@@ -185,8 +288,8 @@ class Qwen3TTSEngineAdapter:
|
||||
Returns:
|
||||
Generated audio tensor [1, samples] at 24000 Hz
|
||||
"""
|
||||
if self.engine is None:
|
||||
raise RuntimeError("Engine not loaded. Call load_base_model() first.")
|
||||
# Get current engine from unified interface (always fresh reference)
|
||||
engine = self._get_engine()
|
||||
|
||||
# Extract seed from params and set global torch seed for reproducibility
|
||||
seed = params.get('seed', 0)
|
||||
@@ -198,16 +301,17 @@ class Qwen3TTSEngineAdapter:
|
||||
# Check if text has pause tags
|
||||
if process_pauses and PauseTagProcessor.has_pause_tags(text):
|
||||
# Process pause tags and generate segments
|
||||
return self._generate_with_pauses(text, voice_ref, params, character_name)
|
||||
return self._generate_with_pauses(text, voice_ref, params, character_name, engine)
|
||||
else:
|
||||
# Direct generation without pause processing
|
||||
return self._generate_direct(text, voice_ref, params, character_name)
|
||||
return self._generate_direct(text, voice_ref, params, character_name, engine)
|
||||
|
||||
def _generate_direct(self,
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None) -> torch.Tensor:
|
||||
character_name: Optional[str] = None,
|
||||
engine = None) -> torch.Tensor:
|
||||
"""
|
||||
Direct generation without pause processing.
|
||||
|
||||
@@ -220,18 +324,23 @@ class Qwen3TTSEngineAdapter:
|
||||
Returns:
|
||||
Audio tensor [1, samples]
|
||||
"""
|
||||
# Get engine if not provided
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
# Determine generation method based on current model type
|
||||
if self.current_model_type == "CustomVoice":
|
||||
return self._generate_custom_voice(text, params, character_name)
|
||||
return self._generate_custom_voice(text, params, character_name, engine)
|
||||
elif self.current_model_type == "VoiceDesign":
|
||||
return self._generate_voice_design(text, params, character_name)
|
||||
return self._generate_voice_design(text, params, character_name, engine)
|
||||
else: # Base
|
||||
return self._generate_voice_clone(text, voice_ref, params, character_name)
|
||||
return self._generate_voice_clone(text, voice_ref, params, character_name, engine)
|
||||
|
||||
def _generate_custom_voice(self,
|
||||
text: str,
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None) -> torch.Tensor:
|
||||
character_name: Optional[str] = None,
|
||||
engine = None) -> torch.Tensor:
|
||||
"""
|
||||
Generate with CustomVoice model using preset speakers.
|
||||
|
||||
@@ -261,6 +370,7 @@ class Qwen3TTSEngineAdapter:
|
||||
repetition_penalty=params.get('repetition_penalty', 1.05),
|
||||
max_new_tokens=params.get('max_new_tokens', 2048),
|
||||
seed=params.get('seed', 0),
|
||||
model_size=params.get('model_size', '1.7B'),
|
||||
attn_implementation=params.get('attn_implementation', 'auto'),
|
||||
character=character_name or 'narrator'
|
||||
)
|
||||
@@ -274,7 +384,7 @@ class Qwen3TTSEngineAdapter:
|
||||
|
||||
# Create ComfyUI progress bar and convert to transformers streamer
|
||||
max_new_tokens = params.get('max_new_tokens', 2048)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens, text)
|
||||
|
||||
# Convert progress_bar to transformers streamer for bundled model
|
||||
streamer = None
|
||||
@@ -282,8 +392,12 @@ class Qwen3TTSEngineAdapter:
|
||||
from engines.qwen3_tts.progress_callback import Qwen3TTSProgressStreamer
|
||||
streamer = Qwen3TTSProgressStreamer(max_new_tokens, progress_bar, text_input=text)
|
||||
|
||||
# Get engine if not provided
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
# Generate
|
||||
wavs, sr = self.engine.generate_custom_voice(
|
||||
wavs, sr = engine.generate_custom_voice(
|
||||
text=text,
|
||||
language=language,
|
||||
speaker=speaker,
|
||||
@@ -308,7 +422,8 @@ class Qwen3TTSEngineAdapter:
|
||||
def _generate_voice_design(self,
|
||||
text: str,
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None) -> torch.Tensor:
|
||||
character_name: Optional[str] = None,
|
||||
engine = None) -> torch.Tensor:
|
||||
"""
|
||||
Generate with VoiceDesign model using text description.
|
||||
|
||||
@@ -339,6 +454,7 @@ class Qwen3TTSEngineAdapter:
|
||||
repetition_penalty=params.get('repetition_penalty', 1.05),
|
||||
max_new_tokens=params.get('max_new_tokens', 2048),
|
||||
seed=params.get('seed', 0),
|
||||
model_size=params.get('model_size', '1.7B'),
|
||||
attn_implementation=params.get('attn_implementation', 'auto'),
|
||||
character=character_name or 'narrator'
|
||||
)
|
||||
@@ -352,7 +468,7 @@ class Qwen3TTSEngineAdapter:
|
||||
|
||||
# Create ComfyUI progress bar and convert to transformers streamer
|
||||
max_new_tokens = params.get('max_new_tokens', 2048)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens, text)
|
||||
|
||||
# Convert progress_bar to transformers streamer for bundled model
|
||||
streamer = None
|
||||
@@ -360,8 +476,12 @@ class Qwen3TTSEngineAdapter:
|
||||
from engines.qwen3_tts.progress_callback import Qwen3TTSProgressStreamer
|
||||
streamer = Qwen3TTSProgressStreamer(max_new_tokens, progress_bar, text_input=text)
|
||||
|
||||
# Get engine if not provided
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
# Generate
|
||||
wavs, sr = self.engine.generate_voice_design(
|
||||
wavs, sr = engine.generate_voice_design(
|
||||
text=text,
|
||||
language=language,
|
||||
instruct=instruct,
|
||||
@@ -386,7 +506,8 @@ class Qwen3TTSEngineAdapter:
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None) -> torch.Tensor:
|
||||
character_name: Optional[str] = None,
|
||||
engine = None) -> torch.Tensor:
|
||||
"""
|
||||
Generate with Base model using zero-shot voice cloning.
|
||||
|
||||
@@ -406,24 +527,26 @@ class Qwen3TTSEngineAdapter:
|
||||
# Use voice_ref x_vector_only_mode if provided, otherwise use params
|
||||
x_vector_only = voice_x_vector_only if voice_x_vector_only else params.get('x_vector_only_mode', False)
|
||||
|
||||
# Warn user when x_vector_only mode is forced or ignoring available ref text
|
||||
if x_vector_only and ref_text:
|
||||
print(f"⚠️ Qwen3-TTS: x_vector_only mode enabled - ignoring reference text (using speaker embedding only)")
|
||||
|
||||
# Generate cache key using voice_ref dict (contains waveform + sample_rate)
|
||||
# This ensures different voices generate different cache keys
|
||||
from utils.audio.audio_hash import generate_stable_audio_component
|
||||
if voice_ref and isinstance(voice_ref, dict):
|
||||
# Check if voice_ref has audio tensor or file path
|
||||
if "audio" in voice_ref:
|
||||
# Unified Character Voices format: {"audio": {"waveform": ..., "sample_rate": ...}, "audio_path": ..., ...}
|
||||
audio_dict = voice_ref.get("audio")
|
||||
audio_component = generate_stable_audio_component(reference_audio=audio_dict)
|
||||
elif ref_audio_original and isinstance(ref_audio_original, str):
|
||||
# File path format: {"audio_path": "/path/to/file.wav", "reference_text": "..."}
|
||||
audio_component = generate_stable_audio_component(audio_file_path=ref_audio_original)
|
||||
elif "waveform" in voice_ref:
|
||||
# Direct tensor format: {"waveform": tensor, "sample_rate": 24000}
|
||||
audio_component = generate_stable_audio_component(reference_audio=voice_ref)
|
||||
else:
|
||||
audio_component = "default_voice"
|
||||
elif ref_audio_original and isinstance(ref_audio_original, str):
|
||||
if voice_ref and isinstance(voice_ref, dict):
|
||||
if isinstance(ref_audio_original, str):
|
||||
audio_component = generate_stable_audio_component(audio_file_path=ref_audio_original)
|
||||
elif isinstance(ref_audio_original, dict) and "waveform" in ref_audio_original:
|
||||
audio_component = generate_stable_audio_component(reference_audio=ref_audio_original)
|
||||
elif torch.is_tensor(ref_audio_original):
|
||||
audio_component = generate_stable_audio_component(reference_audio={
|
||||
"waveform": ref_audio_original,
|
||||
"sample_rate": voice_ref.get("sample_rate", 24000),
|
||||
})
|
||||
else:
|
||||
audio_component = "default_voice"
|
||||
elif ref_audio_original is not None and isinstance(ref_audio_original, str):
|
||||
# File path case (voice_ref is None but ref_audio_original extracted)
|
||||
audio_component = generate_stable_audio_component(audio_file_path=ref_audio_original)
|
||||
else:
|
||||
@@ -443,6 +566,7 @@ class Qwen3TTSEngineAdapter:
|
||||
repetition_penalty=params.get('repetition_penalty', 1.05),
|
||||
max_new_tokens=params.get('max_new_tokens', 2048),
|
||||
seed=params.get('seed', 0),
|
||||
model_size=params.get('model_size', '1.7B'),
|
||||
attn_implementation=params.get('attn_implementation', 'auto'),
|
||||
character=character_name or 'narrator'
|
||||
)
|
||||
@@ -456,7 +580,7 @@ class Qwen3TTSEngineAdapter:
|
||||
|
||||
# Create ComfyUI progress bar and convert to transformers streamer
|
||||
max_new_tokens = params.get('max_new_tokens', 2048)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens)
|
||||
progress_bar = self._create_progress_bar(max_new_tokens, text)
|
||||
|
||||
# Convert progress_bar to transformers streamer for bundled model
|
||||
streamer = None
|
||||
@@ -464,8 +588,12 @@ class Qwen3TTSEngineAdapter:
|
||||
from engines.qwen3_tts.progress_callback import Qwen3TTSProgressStreamer
|
||||
streamer = Qwen3TTSProgressStreamer(max_new_tokens, progress_bar, text_input=text)
|
||||
|
||||
# Get engine if not provided
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
# Generate
|
||||
wavs, sr = self.engine.generate_voice_clone(
|
||||
wavs, sr = engine.generate_voice_clone(
|
||||
text=text,
|
||||
language=language,
|
||||
ref_audio=ref_audio,
|
||||
@@ -492,7 +620,8 @@ class Qwen3TTSEngineAdapter:
|
||||
text: str,
|
||||
voice_ref: Optional[Dict[str, Any]],
|
||||
params: Dict[str, Any],
|
||||
character_name: Optional[str] = None) -> torch.Tensor:
|
||||
character_name: Optional[str] = None,
|
||||
engine = None) -> torch.Tensor:
|
||||
"""
|
||||
Generate with pause tag processing.
|
||||
|
||||
@@ -513,10 +642,14 @@ class Qwen3TTSEngineAdapter:
|
||||
audio_parts = []
|
||||
sample_rate = 24000 # Qwen3-TTS native sample rate
|
||||
|
||||
# Get engine if not provided
|
||||
if engine is None:
|
||||
engine = self._get_engine()
|
||||
|
||||
for segment_type, content in segments:
|
||||
if segment_type == 'text':
|
||||
# Generate audio for text segment
|
||||
audio_tensor = self._generate_direct(content, voice_ref, params, character_name)
|
||||
audio_tensor = self._generate_direct(content, voice_ref, params, character_name, engine)
|
||||
|
||||
# Ensure correct shape [1, samples] (2D)
|
||||
if audio_tensor.dim() == 1:
|
||||
@@ -558,10 +691,7 @@ class Qwen3TTSEngineAdapter:
|
||||
return None, None, False
|
||||
|
||||
# Extract reference audio (multiple possible keys)
|
||||
ref_audio_original = (voice_ref.get('prompt_audio_path') or
|
||||
voice_ref.get('audio_path') or
|
||||
voice_ref.get('audio') or
|
||||
voice_ref.get('waveform'))
|
||||
ref_audio_original = effective_voice_audio(voice_ref)
|
||||
|
||||
# Extract reference text (multiple possible keys)
|
||||
ref_text = (voice_ref.get('prompt_text') or
|
||||
@@ -638,19 +768,30 @@ class Qwen3TTSEngineAdapter:
|
||||
# None or unsupported
|
||||
return ref_audio
|
||||
|
||||
def _create_progress_bar(self, max_tokens: int):
|
||||
def _create_progress_bar(self, max_tokens: int, text: str = ""):
|
||||
"""
|
||||
Create ComfyUI progress bar for generation tracking.
|
||||
Create ComfyUI progress bar for generation tracking with accurate estimation.
|
||||
|
||||
Args:
|
||||
max_tokens: Maximum tokens to generate
|
||||
text: Input text for estimating actual token count
|
||||
|
||||
Returns:
|
||||
Progress bar instance or None if ComfyUI utils not available
|
||||
"""
|
||||
try:
|
||||
import comfy.utils
|
||||
return comfy.utils.ProgressBar(max_tokens)
|
||||
|
||||
# Estimate actual tokens based on text length (same heuristic as progress streamer)
|
||||
# Rough heuristic: ~1.5 tokens per character for TTS
|
||||
if text:
|
||||
estimated_tokens = int(len(text) * 1.5)
|
||||
# Cap estimate to max_tokens
|
||||
progress_total = min(estimated_tokens, max_tokens)
|
||||
else:
|
||||
progress_total = max_tokens
|
||||
|
||||
return comfy.utils.ProgressBar(progress_total)
|
||||
except (ImportError, AttributeError):
|
||||
return None # ComfyUI progress not available
|
||||
|
||||
|
||||
@@ -84,20 +84,23 @@ class RVCEngineAdapter:
|
||||
print(f"Error loading RVC models: {e}")
|
||||
raise e
|
||||
|
||||
def convert_voice(
|
||||
self,
|
||||
audio_input: Union[torch.Tensor, np.ndarray, tuple],
|
||||
rvc_model: dict = None,
|
||||
pitch_shift: int = 0,
|
||||
def convert_voice(
|
||||
self,
|
||||
audio_input: Union[torch.Tensor, np.ndarray, tuple],
|
||||
rvc_model: dict = None,
|
||||
pitch_shift: int = 0,
|
||||
index_rate: float = 0.75,
|
||||
rms_mix_rate: float = 0.25,
|
||||
protect: float = 0.25,
|
||||
f0_method: str = 'rmvpe',
|
||||
f0_autotune: bool = False,
|
||||
resample_sr: int = 0,
|
||||
crepe_hop_length: int = 160,
|
||||
**kwargs
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
protect: float = 0.25,
|
||||
f0_method: str = 'rmvpe',
|
||||
f0_autotune: bool = False,
|
||||
resample_sr: int = 0,
|
||||
crepe_hop_length: int = 160,
|
||||
filter_radius: int = 3,
|
||||
use_cache: bool = True,
|
||||
batch_size: int = 1,
|
||||
**kwargs
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Perform voice conversion using RVC.
|
||||
|
||||
@@ -109,10 +112,13 @@ class RVCEngineAdapter:
|
||||
rms_mix_rate: RMS mixing rate (0.0-1.0)
|
||||
protect: Consonant protection (0.0-0.5)
|
||||
f0_method: Pitch extraction method
|
||||
f0_autotune: Enable autotune
|
||||
resample_sr: Resample rate (0 for no resampling)
|
||||
crepe_hop_length: Crepe hop length (16-512)
|
||||
**kwargs: Additional parameters
|
||||
f0_autotune: Enable autotune
|
||||
resample_sr: Resample rate (0 for no resampling)
|
||||
crepe_hop_length: Crepe hop length (16-512)
|
||||
filter_radius: Harvest smoothing radius
|
||||
use_cache: Reuse cached loaded models where possible
|
||||
batch_size: Crepe-family pitch batch size hint
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Tuple of (converted_audio, sample_rate)
|
||||
@@ -156,13 +162,16 @@ class RVCEngineAdapter:
|
||||
f0_up_key=pitch_shift,
|
||||
f0_method=f0_method,
|
||||
index_rate=index_rate,
|
||||
protect=protect,
|
||||
rms_mix_rate=rms_mix_rate,
|
||||
resample_sr=resample_sr,
|
||||
f0_autotune=f0_autotune,
|
||||
crepe_hop_length=crepe_hop_length,
|
||||
**kwargs # Pass through hubert_path and other engine parameters
|
||||
)
|
||||
protect=protect,
|
||||
rms_mix_rate=rms_mix_rate,
|
||||
resample_sr=resample_sr,
|
||||
f0_autotune=f0_autotune,
|
||||
crepe_hop_length=crepe_hop_length,
|
||||
filter_radius=filter_radius,
|
||||
use_cache=use_cache,
|
||||
batch_size=batch_size,
|
||||
**kwargs # Pass through hubert_path and other engine parameters
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError("RVC conversion failed - no output from minimal wrapper")
|
||||
@@ -290,11 +299,14 @@ class RVCEngineAdapter:
|
||||
'index_rate': 0.75,
|
||||
'rms_mix_rate': 0.25,
|
||||
'protect': 0.25,
|
||||
'f0_method': 'rmvpe',
|
||||
'f0_autotune': False,
|
||||
'resample_sr': 0,
|
||||
'crepe_hop_length': 160
|
||||
}
|
||||
'f0_method': 'rmvpe',
|
||||
'f0_autotune': False,
|
||||
'resample_sr': 0,
|
||||
'crepe_hop_length': 160,
|
||||
'filter_radius': 3,
|
||||
'use_cache': True,
|
||||
'batch_size': 1,
|
||||
}
|
||||
|
||||
def validate_parameters(self, **params) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -331,10 +343,19 @@ class RVCEngineAdapter:
|
||||
if params['resample_sr'] in valid_rates:
|
||||
validated['resample_sr'] = int(params['resample_sr'])
|
||||
|
||||
if 'crepe_hop_length' in params:
|
||||
validated['crepe_hop_length'] = max(16, min(512, int(params['crepe_hop_length'])))
|
||||
|
||||
return validated
|
||||
if 'crepe_hop_length' in params:
|
||||
validated['crepe_hop_length'] = max(16, min(512, int(params['crepe_hop_length'])))
|
||||
|
||||
if 'filter_radius' in params:
|
||||
validated['filter_radius'] = max(0, min(7, int(params['filter_radius'])))
|
||||
|
||||
if 'use_cache' in params:
|
||||
validated['use_cache'] = bool(params['use_cache'])
|
||||
|
||||
if 'batch_size' in params:
|
||||
validated['batch_size'] = max(1, min(32, int(params['batch_size'])))
|
||||
|
||||
return validated
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up loaded models and free memory."""
|
||||
@@ -345,4 +366,4 @@ class RVCEngineAdapter:
|
||||
# CRITICAL: Clear minimal wrapper cache - this is where RVC models are actually stored
|
||||
minimal_wrapper.clear_model_cache()
|
||||
|
||||
print("RVC adapter cleanup completed")
|
||||
print("RVC adapter cleanup completed")
|
||||
|
||||
@@ -93,7 +93,9 @@ class StepAudioEditXEngineAdapter:
|
||||
model_path: str,
|
||||
device: str = "auto",
|
||||
torch_dtype: str = "auto",
|
||||
quantization: Optional[str] = None):
|
||||
quantization: Optional[str] = None,
|
||||
runtime_mode: str = "shared_runtime",
|
||||
runtime_profile: Optional[str] = None):
|
||||
"""
|
||||
Load Step Audio EditX engine via unified interface (with caching).
|
||||
|
||||
@@ -113,6 +115,8 @@ class StepAudioEditXEngineAdapter:
|
||||
model_name="Step-Audio-EditX",
|
||||
model_path=model_path, # Downloader will resolve this if it's "local:xxx"
|
||||
device=resolve_torch_device(device),
|
||||
runtime_mode=runtime_mode,
|
||||
runtime_profile=runtime_profile,
|
||||
additional_params={
|
||||
"torch_dtype": torch_dtype,
|
||||
"quantization": quantization
|
||||
@@ -193,7 +197,7 @@ class StepAudioEditXEngineAdapter:
|
||||
prompt_text=prompt_text,
|
||||
temperature=params.get('temperature', 0.7),
|
||||
do_sample=params.get('do_sample', True),
|
||||
max_new_tokens=params.get('max_new_tokens', 8192),
|
||||
max_new_tokens=params.get('max_new_tokens', 1024),
|
||||
seed=params.get('seed', 0),
|
||||
model_path=params.get('model_path', 'Step-Audio-EditX'),
|
||||
device=params.get('device', 'auto'),
|
||||
@@ -210,7 +214,17 @@ class StepAudioEditXEngineAdapter:
|
||||
return cached_audio[0]
|
||||
|
||||
# Create ComfyUI progress bar for generation tracking with time prediction
|
||||
max_new_tokens = params.get('max_new_tokens', 8192)
|
||||
max_new_tokens = params.get('max_new_tokens', 1024)
|
||||
|
||||
# Estimate actual tokens based on text length (same heuristic as Qwen3-TTS)
|
||||
# Rough heuristic: ~0.7 tokens per character for TTS (conservative estimate)
|
||||
if text:
|
||||
estimated_tokens = int(len(text) * 0.7)
|
||||
# Cap estimate to max_tokens
|
||||
progress_total = min(estimated_tokens, max_new_tokens)
|
||||
else:
|
||||
progress_total = max_new_tokens
|
||||
|
||||
progress_bar = None
|
||||
try:
|
||||
import comfy.utils
|
||||
@@ -306,7 +320,7 @@ class StepAudioEditXEngineAdapter:
|
||||
# If no remaining text but still processing, show nothing
|
||||
return None
|
||||
|
||||
progress_bar = TimedProgressBar(max_new_tokens, job_tracker)
|
||||
progress_bar = TimedProgressBar(progress_total, job_tracker)
|
||||
except (ImportError, AttributeError):
|
||||
pass # ComfyUI progress not available
|
||||
|
||||
|
||||
@@ -16,10 +16,16 @@ if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
from utils.models.language_mapper import get_model_for_language
|
||||
from utils.models.engine_registry import get_default_runtime_profile
|
||||
from utils.models.factory_config import normalize_runtime_mode, runtime_uses_isolation
|
||||
from utils.text.pause_processor import PauseTagProcessor
|
||||
from utils.text.character_parser import CharacterParser
|
||||
from engines.vibevoice_engine.vibevoice_downloader import VIBEVOICE_MODELS
|
||||
from utils.text.character_parser import character_parser
|
||||
from engines.vibevoice_engine.vibevoice_downloader import (
|
||||
VIBEVOICE_MODELS,
|
||||
is_kugelaudio_variant_name,
|
||||
)
|
||||
from utils.models.manager import model_manager
|
||||
from utils.voice.character_logging import resolved_character_label
|
||||
|
||||
|
||||
class VibeVoiceEngineAdapter:
|
||||
@@ -36,7 +42,6 @@ class VibeVoiceEngineAdapter:
|
||||
self.node = node_instance
|
||||
self.engine_type = "vibevoice"
|
||||
self.model_manager = model_manager
|
||||
self.character_parser = CharacterParser()
|
||||
self.pause_processor = PauseTagProcessor()
|
||||
|
||||
# Track character to speaker mapping for native multi-speaker mode
|
||||
@@ -49,11 +54,15 @@ class VibeVoiceEngineAdapter:
|
||||
self._device = engine_config.get('device', 'auto')
|
||||
self._attention_mode = engine_config.get('attention_mode', 'auto')
|
||||
self._quantize_4bit = engine_config.get('quantize_llm_4bit', False)
|
||||
self._runtime_mode = normalize_runtime_mode(engine_config.get('runtime_mode'))
|
||||
self._runtime_profile = engine_config.get('runtime_profile')
|
||||
else:
|
||||
self._model_name = 'vibevoice-1.5B'
|
||||
self._device = 'auto'
|
||||
self._attention_mode = 'auto'
|
||||
self._quantize_4bit = False
|
||||
self._runtime_mode = 'main_environment'
|
||||
self._runtime_profile = None
|
||||
|
||||
@property
|
||||
def vibevoice_engine(self):
|
||||
@@ -66,7 +75,9 @@ class VibeVoiceEngineAdapter:
|
||||
model_name=self._model_name,
|
||||
device=self._device,
|
||||
attention_mode=self._attention_mode,
|
||||
quantize_llm_4bit=self._quantize_4bit
|
||||
quantize_llm_4bit=self._quantize_4bit,
|
||||
runtime_mode=self._runtime_mode,
|
||||
runtime_profile=self._runtime_profile or get_default_runtime_profile("vibevoice"),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -79,6 +90,11 @@ class VibeVoiceEngineAdapter:
|
||||
"""Get current processor from engine."""
|
||||
return self.vibevoice_engine.processor if self.vibevoice_engine else None
|
||||
|
||||
@property
|
||||
def is_kugelaudio(self) -> bool:
|
||||
"""Check if current model is KugelAudio."""
|
||||
return getattr(self.vibevoice_engine, 'is_kugelaudio', False) if self.vibevoice_engine else False
|
||||
|
||||
def get_model_for_language(self, lang_code: str, default_model: str) -> str:
|
||||
"""
|
||||
Get VibeVoice model name for specified language.
|
||||
@@ -95,6 +111,10 @@ class VibeVoiceEngineAdapter:
|
||||
# VibeVoice models support both English and Chinese
|
||||
supported_languages = ['en', 'zh', 'zh-cn', 'chinese', 'english']
|
||||
|
||||
# Kugel variants are multilingual, so skip base VibeVoice language gating.
|
||||
if is_kugelaudio_variant_name(default_model):
|
||||
return default_model
|
||||
|
||||
if lang_code.lower() in supported_languages:
|
||||
# Both models support EN/ZH, return the configured one
|
||||
return default_model
|
||||
@@ -149,7 +169,8 @@ class VibeVoiceEngineAdapter:
|
||||
processed_text = processed_text.replace(f'[{lang}:{char}]', f'[{char}]')
|
||||
|
||||
# Warn about language since VibeVoice doesn't have language control
|
||||
if detected_lang not in ['en', 'zh', 'chinese', 'english']:
|
||||
# Skip warning for KugelAudio as it is multilingual
|
||||
if not self.is_kugelaudio and detected_lang not in ['en', 'zh', 'chinese', 'english']:
|
||||
print(f"⚠️ VibeVoice: Language tag '{detected_lang}' found but model only supports EN/ZH")
|
||||
|
||||
return processed_text, detected_lang
|
||||
@@ -165,8 +186,8 @@ class VibeVoiceEngineAdapter:
|
||||
Returns:
|
||||
Tuple of (formatted_text, character_mapping)
|
||||
"""
|
||||
# Parse character segments
|
||||
segments = self.character_parser.parse_text(text)
|
||||
# Parse character segments (using global parser with engine_type suppression)
|
||||
segments = character_parser.parse_text_segments(text, engine_type="vibevoice")
|
||||
|
||||
# Build character to speaker mapping and combine continuous segments
|
||||
character_map = {}
|
||||
@@ -446,7 +467,8 @@ class VibeVoiceEngineAdapter:
|
||||
|
||||
# Generate each character group using VibeVoice format
|
||||
for group_idx, (character, text_list) in enumerate(character_groups):
|
||||
print(f"🎤 Group {group_idx + 1}: Character '{character}' with {len(text_list)} segments")
|
||||
display_name = resolved_character_label(character, voice_mapping.get(character))
|
||||
print(f"🎤 Group {group_idx + 1}: Character '{display_name}' with {len(text_list)} segments")
|
||||
|
||||
# Format as Speaker 1 entries (VibeVoice style) and combine
|
||||
formatted_lines = []
|
||||
@@ -506,15 +528,11 @@ class VibeVoiceEngineAdapter:
|
||||
Returns:
|
||||
Combined audio dict
|
||||
"""
|
||||
# Get speaker voice inputs from engine config for priority system
|
||||
# For manual Speaker format, the main narrator voice comes from the TTS Text node
|
||||
# We need to get it from a different source since voice_mapping may not have 'narrator' key
|
||||
main_narrator_voice = voice_mapping.get('narrator') # Try narrator first
|
||||
if main_narrator_voice is None:
|
||||
# If no 'narrator' key, get it from any available voice (fallback for manual Speaker format)
|
||||
available_voices = [v for v in voice_mapping.values() if v is not None]
|
||||
main_narrator_voice = available_voices[0] if available_voices else None
|
||||
# print(f"🐛 Debug: No 'narrator' key, using fallback voice: {'✅ found' if main_narrator_voice else '❌ none available'}")
|
||||
# Get speaker voice inputs from engine config for priority system.
|
||||
# Speaker 1 must only come from an explicit narrator/speaker-1 input.
|
||||
# Do not synthesize Speaker 1 from discovered character voices, or aliases
|
||||
# will look like they were overridden by a connection that does not exist.
|
||||
main_narrator_voice = voice_mapping.get('narrator')
|
||||
speaker_inputs = {
|
||||
1: main_narrator_voice, # Speaker 1 uses main narrator from TTS Text
|
||||
2: params.get('speaker2_voice'),
|
||||
@@ -524,19 +542,24 @@ class VibeVoiceEngineAdapter:
|
||||
|
||||
# print(f"🐛 Debug: speaker_inputs[1] (main narrator): {'✅ has voice' if speaker_inputs[1] else '❌ no voice'}")
|
||||
|
||||
# Build speaker mapping and format text
|
||||
# Build speaker mapping and format text.
|
||||
# For SRT/global processing we keep speaker numbering local to the current
|
||||
# generation call (Speaker 1..N in order of first appearance), but preserve
|
||||
# the character's global slot when choosing connected speaker override inputs.
|
||||
character_map = {}
|
||||
# Pre-fill speaker_voices with all 4 speaker slots (some may be None)
|
||||
speaker_voices = [
|
||||
speaker_inputs.get(1), # Speaker 1
|
||||
speaker_inputs.get(2), # Speaker 2
|
||||
speaker_inputs.get(3), # Speaker 3
|
||||
speaker_inputs.get(4) # Speaker 4
|
||||
]
|
||||
character_global_slots = {}
|
||||
speaker_voices = []
|
||||
formatted_lines = []
|
||||
segment_characters = [char for char, _ in segments]
|
||||
|
||||
print(f"🎭 Native multi-speaker: Processing {len(segments)} segments with characters: {[char for char, _ in segments]}")
|
||||
print(f"🎭 Native multi-speaker: Processing {len(segments)} segments with characters: {segment_characters}")
|
||||
print(f"🎤 Speaker inputs connected: {[f'Speaker {k}' for k, v in speaker_inputs.items() if v is not None]}")
|
||||
narrator_exists_globally = bool(global_char_to_speaker and "narrator" in global_char_to_speaker)
|
||||
if speaker_inputs.get(1) is not None and "narrator" not in segment_characters:
|
||||
if narrator_exists_globally:
|
||||
print("ℹ️ Narrator input is connected, but this subtitle has no narrator turn; it will not override tagged characters")
|
||||
else:
|
||||
print("ℹ️ No narrator turns exist in this SRT; Speaker 1 maps to the first named character by first appearance")
|
||||
|
||||
for character, text in segments:
|
||||
# Check if this is already a manual "Speaker N:" format
|
||||
@@ -547,50 +570,84 @@ class VibeVoiceEngineAdapter:
|
||||
speaker_idx = manual_speaker - 1 # Convert to 0-based
|
||||
if speaker_idx >= 4:
|
||||
speaker_idx = 3
|
||||
|
||||
# Voice already in pre-filled speaker_voices array
|
||||
voice = speaker_voices[speaker_idx]
|
||||
voice = speaker_inputs.get(manual_speaker)
|
||||
if manual_speaker == 1:
|
||||
print(f"🎤 Manual format 'Speaker {manual_speaker}' -> using {'✅ main narrator (Tony)' if voice else '❌ no narrator, using default'}")
|
||||
else:
|
||||
print(f"🎤 Manual format 'Speaker {manual_speaker}' -> using {'✅ connected input' if voice else '❌ no input, using default'}")
|
||||
|
||||
while len(speaker_voices) <= speaker_idx:
|
||||
speaker_voices.append(None)
|
||||
speaker_voices[speaker_idx] = voice
|
||||
|
||||
formatted_lines.append(f"Speaker {manual_speaker}: {text.strip()}")
|
||||
|
||||
else:
|
||||
# Character tag format - use global mapping if provided (for SRT consistency)
|
||||
# Character tag format
|
||||
if character not in character_map:
|
||||
# Special handling for numeric characters: [1] [2] [3] [4] -> map directly to Speaker N
|
||||
if character.isdigit() and 1 <= int(character) <= 4:
|
||||
speaker_idx = int(character) - 1 # Convert [1] to Speaker 1 (0-based index)
|
||||
global_speaker_num = int(character)
|
||||
speaker_idx = len(character_map)
|
||||
character_map[character] = speaker_idx
|
||||
character_global_slots[character] = global_speaker_num
|
||||
print(f"🔢 Numeric character '[{character}]' -> Speaker {int(character)} (direct mapping)")
|
||||
elif global_char_to_speaker and character in global_char_to_speaker:
|
||||
# Use global mapping for consistent SRT processing
|
||||
speaker_idx = global_char_to_speaker[character] - 1 # Convert to 0-based
|
||||
global_speaker_num = global_char_to_speaker[character]
|
||||
speaker_idx = len(character_map)
|
||||
character_map[character] = speaker_idx
|
||||
character_global_slots[character] = global_speaker_num
|
||||
else:
|
||||
# Fallback to sequential assignment
|
||||
speaker_idx = len(character_map)
|
||||
if speaker_idx >= 4:
|
||||
print(f"⚠️ VibeVoice: Limiting to 4 speakers, '{character}' will use Speaker 4")
|
||||
speaker_idx = 3 # Use 0-based internally, will convert to 1-based for format
|
||||
global_speaker_num = 4
|
||||
else:
|
||||
character_map[character] = speaker_idx
|
||||
global_speaker_num = speaker_idx + 1
|
||||
character_global_slots[character] = global_speaker_num
|
||||
|
||||
# Priority system: speaker inputs override character aliases
|
||||
# Priority system:
|
||||
# - Numeric [1]-[4] always map directly to speaker inputs 1-4.
|
||||
# - If any narrator turn exists in the SRT, Speaker 1 is reserved for narrator.
|
||||
# - Otherwise, Speaker 1..4 map to named characters by first global appearance order.
|
||||
speaker_num = speaker_idx + 1
|
||||
connected_voice = speaker_inputs.get(speaker_num)
|
||||
global_speaker_num = character_global_slots.get(character, speaker_num)
|
||||
connected_voice = None
|
||||
is_numeric_direct = character.isdigit() and 1 <= int(character) <= 4
|
||||
if is_numeric_direct:
|
||||
connected_voice = speaker_inputs.get(global_speaker_num)
|
||||
elif character == "narrator":
|
||||
connected_voice = speaker_inputs.get(1)
|
||||
elif narrator_exists_globally:
|
||||
if global_speaker_num >= 2:
|
||||
connected_voice = speaker_inputs.get(global_speaker_num)
|
||||
else:
|
||||
connected_voice = speaker_inputs.get(global_speaker_num)
|
||||
character_voice = voice_mapping.get(character)
|
||||
|
||||
|
||||
if connected_voice is not None and character_voice is not None:
|
||||
print(f"⚠️ Priority: Speaker {speaker_num} input overrides ['{character}'] alias - using connected voice")
|
||||
if global_speaker_num != speaker_num:
|
||||
print(
|
||||
f"⚠️ Priority: Speaker {global_speaker_num} input overrides ['{character}'] alias "
|
||||
f"- using connected voice as local Speaker {speaker_num}"
|
||||
)
|
||||
else:
|
||||
print(f"⚠️ Priority: Speaker {speaker_num} input overrides ['{character}'] alias - using connected voice")
|
||||
voice = connected_voice
|
||||
elif connected_voice is not None:
|
||||
print(f"🎤 Speaker {speaker_num}: Using connected voice input")
|
||||
if global_speaker_num != speaker_num:
|
||||
print(f"🎤 Speaker {global_speaker_num}: Using connected voice input as local Speaker {speaker_num}")
|
||||
else:
|
||||
print(f"🎤 Speaker {speaker_num}: Using connected voice input")
|
||||
voice = connected_voice
|
||||
else:
|
||||
print(f"🎭 Character '{character}' -> Speaker {speaker_num}, using character voice")
|
||||
if global_speaker_num != speaker_num:
|
||||
print(f"🎭 Character '{character}' -> global Speaker {global_speaker_num}, local Speaker {speaker_num}, using alias/character voice")
|
||||
else:
|
||||
print(f"🎭 Character '{character}' -> Speaker {speaker_num}, using alias/character voice")
|
||||
voice = character_voice
|
||||
|
||||
# Ensure we have enough speaker_voices slots
|
||||
@@ -609,6 +666,18 @@ class VibeVoiceEngineAdapter:
|
||||
print(formatted_text)
|
||||
print("="*60)
|
||||
print(f"🎤 Using {len(speaker_voices)} voice samples for generation")
|
||||
for idx, voice in enumerate(speaker_voices, start=1):
|
||||
if voice is None:
|
||||
print(f" Speaker {idx}: default / no reference")
|
||||
elif isinstance(voice, dict) and voice.get("audio_path"):
|
||||
print(f" Speaker {idx}: file reference -> {voice['audio_path']}")
|
||||
elif isinstance(voice, dict) and "waveform" in voice:
|
||||
waveform = voice["waveform"]
|
||||
sample_rate = voice.get("sample_rate", "unknown")
|
||||
shape = tuple(waveform.shape) if hasattr(waveform, "shape") else "unknown"
|
||||
print(f" Speaker {idx}: waveform reference -> shape {shape}, sr {sample_rate}")
|
||||
else:
|
||||
print(f" Speaker {idx}: unexpected reference type {type(voice).__name__}")
|
||||
|
||||
# Validate and normalize voice references
|
||||
normalized_voices = []
|
||||
@@ -762,14 +831,20 @@ class VibeVoiceEngineAdapter:
|
||||
|
||||
# Process pause tags - returns list of ('text', content) or ('pause', duration)
|
||||
pause_segments, _ = PauseTagProcessor.parse_pause_tags(text)
|
||||
print(f"🎵 VibeVoice: Parsed {len(pause_segments)} pause-aware segments")
|
||||
|
||||
# Generate segment dicts for each pause-separated piece
|
||||
segment_dicts = []
|
||||
|
||||
for seg_type, seg_content in pause_segments:
|
||||
for idx, (seg_type, seg_content) in enumerate(pause_segments, start=1):
|
||||
if seg_type == 'text':
|
||||
# Extract edit tags from this text segment
|
||||
clean_text, edit_tags = get_edit_tags_for_segment(seg_content)
|
||||
preview = clean_text.replace('\n', ' ')[:120]
|
||||
if len(clean_text) > 120:
|
||||
preview += "..."
|
||||
print(f" 🎤 Segment {idx}/{len(pause_segments)}: generating text ({len(clean_text)} chars)")
|
||||
print(f" {preview}")
|
||||
|
||||
# Generate TTS for this text piece
|
||||
result = self.generate_segment(clean_text, voice_ref, params, character)
|
||||
@@ -793,6 +868,7 @@ class VibeVoiceEngineAdapter:
|
||||
else: # seg_type == 'pause'
|
||||
# Create silence segment
|
||||
duration_seconds = seg_content
|
||||
print(f" ⏸️ Segment {idx}/{len(pause_segments)}: inserting {duration_seconds:.2f}s silence")
|
||||
silence = PauseTagProcessor.create_silence_segment(
|
||||
duration_seconds, 24000, device=torch.device('cpu')
|
||||
)
|
||||
@@ -828,7 +904,14 @@ class VibeVoiceEngineAdapter:
|
||||
return None
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources - models are now managed by ModelManager"""
|
||||
"""Clean up resources and unload isolated runtime workers when requested."""
|
||||
self._character_speaker_map.clear()
|
||||
self._speaker_voices.clear()
|
||||
# Don't clean up vibevoice_engine - it just holds references to cached models
|
||||
|
||||
if runtime_uses_isolation(self._runtime_mode):
|
||||
try:
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
unified_model_interface.clear_engine_models("vibevoice")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to unload isolated VibeVoice runtime during cleanup: {e}")
|
||||
# Don't clean up vibevoice_engine - it just holds references to cached models
|
||||
|
||||
+105
-45
@@ -6,53 +6,113 @@ warnings.filterwarnings("ignore", message=".*LoRACompatibleLinear.*")
|
||||
warnings.filterwarnings("ignore", message=".*PerthNet.*")
|
||||
warnings.filterwarnings("ignore", message=".*requires authentication.*")
|
||||
|
||||
# Import main TTS/VC modules with error handling
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from .tts import ChatterboxTTS
|
||||
TTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
TTS_AVAILABLE = False
|
||||
# Create dummy class
|
||||
class ChatterboxTTS:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device):
|
||||
raise ImportError("ChatterboxTTS not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device):
|
||||
raise ImportError("ChatterboxTTS not available - missing dependencies")
|
||||
# --- LAZY ENGINE IMPORTS ---
|
||||
# TTS/VC/F5TTS classes are NOT imported at module level to avoid pulling in
|
||||
# transformers (~5s), diffusers (~3s), and torch._dynamo (~3s) at plugin startup.
|
||||
# Instead they are loaded on first access via module-level __getattr__.
|
||||
# This breaks the import chain:
|
||||
# language_mapper -> engines.chatterbox.language_models -> (this __init__.py)
|
||||
# which previously triggered: .tts -> models.t3 -> transformers/diffusers
|
||||
#
|
||||
# Consumers that only need language_models (CHATTERBOX_MODELS, get_available_languages)
|
||||
# no longer pay the ~8s cost of importing the model definition code.
|
||||
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from .vc import ChatterboxVC
|
||||
VC_AVAILABLE = True
|
||||
except ImportError:
|
||||
VC_AVAILABLE = False
|
||||
# Create dummy class
|
||||
class ChatterboxVC:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device):
|
||||
raise ImportError("ChatterboxVC not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device):
|
||||
raise ImportError("ChatterboxVC not available - missing dependencies")
|
||||
_TTS_AVAILABLE = None
|
||||
_VC_AVAILABLE = None
|
||||
_F5TTS_SUPPORT_AVAILABLE = None
|
||||
_ChatterboxTTS = None
|
||||
_ChatterboxVC = None
|
||||
_ChatterBoxF5TTS = None
|
||||
|
||||
# F5-TTS support modules - import independently
|
||||
try:
|
||||
from .f5tts import ChatterBoxF5TTS, F5TTS_AVAILABLE
|
||||
F5TTS_SUPPORT_AVAILABLE = F5TTS_AVAILABLE
|
||||
except ImportError:
|
||||
F5TTS_SUPPORT_AVAILABLE = False
|
||||
# Create dummy class
|
||||
class ChatterBoxF5TTS:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device, model_name):
|
||||
raise ImportError("F5-TTS not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device, model_name):
|
||||
raise ImportError("F5-TTS not available - missing dependencies")
|
||||
|
||||
def _ensure_tts_loaded():
|
||||
"""Lazily load ChatterboxTTS on first use."""
|
||||
global _TTS_AVAILABLE, _ChatterboxTTS
|
||||
if _TTS_AVAILABLE is not None:
|
||||
return
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from .tts import ChatterboxTTS as _cls
|
||||
_ChatterboxTTS = _cls
|
||||
_TTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
_TTS_AVAILABLE = False
|
||||
class _DummyTTS:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device):
|
||||
raise ImportError("ChatterboxTTS not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device):
|
||||
raise ImportError("ChatterboxTTS not available - missing dependencies")
|
||||
_ChatterboxTTS = _DummyTTS
|
||||
|
||||
|
||||
def _ensure_vc_loaded():
|
||||
"""Lazily load ChatterboxVC on first use."""
|
||||
global _VC_AVAILABLE, _ChatterboxVC
|
||||
if _VC_AVAILABLE is not None:
|
||||
return
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from .vc import ChatterboxVC as _cls
|
||||
_ChatterboxVC = _cls
|
||||
_VC_AVAILABLE = True
|
||||
except ImportError:
|
||||
_VC_AVAILABLE = False
|
||||
class _DummyVC:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device):
|
||||
raise ImportError("ChatterboxVC not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device):
|
||||
raise ImportError("ChatterboxVC not available - missing dependencies")
|
||||
_ChatterboxVC = _DummyVC
|
||||
|
||||
|
||||
def _ensure_f5tts_loaded():
|
||||
"""Lazily load ChatterBoxF5TTS on first use."""
|
||||
global _F5TTS_SUPPORT_AVAILABLE, _ChatterBoxF5TTS
|
||||
if _F5TTS_SUPPORT_AVAILABLE is not None:
|
||||
return
|
||||
try:
|
||||
from .f5tts import ChatterBoxF5TTS as _cls, F5TTS_AVAILABLE
|
||||
_ChatterBoxF5TTS = _cls
|
||||
_F5TTS_SUPPORT_AVAILABLE = F5TTS_AVAILABLE
|
||||
except ImportError:
|
||||
_F5TTS_SUPPORT_AVAILABLE = False
|
||||
class _DummyF5TTS:
|
||||
@classmethod
|
||||
def from_pretrained(cls, device, model_name):
|
||||
raise ImportError("F5-TTS not available - missing dependencies")
|
||||
@classmethod
|
||||
def from_local(cls, path, device, model_name):
|
||||
raise ImportError("F5-TTS not available - missing dependencies")
|
||||
_ChatterBoxF5TTS = _DummyF5TTS
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Module-level lazy attribute access for TTS/VC/F5TTS classes and availability flags."""
|
||||
if name == "ChatterboxTTS":
|
||||
_ensure_tts_loaded()
|
||||
return _ChatterboxTTS
|
||||
elif name == "TTS_AVAILABLE":
|
||||
_ensure_tts_loaded()
|
||||
return _TTS_AVAILABLE
|
||||
elif name == "ChatterboxVC":
|
||||
_ensure_vc_loaded()
|
||||
return _ChatterboxVC
|
||||
elif name == "VC_AVAILABLE":
|
||||
_ensure_vc_loaded()
|
||||
return _VC_AVAILABLE
|
||||
elif name == "ChatterBoxF5TTS":
|
||||
_ensure_f5tts_loaded()
|
||||
return _ChatterBoxF5TTS
|
||||
elif name == "F5TTS_SUPPORT_AVAILABLE":
|
||||
_ensure_f5tts_loaded()
|
||||
return _F5TTS_SUPPORT_AVAILABLE
|
||||
raise AttributeError(f"module 'engines.chatterbox' has no attribute {name!r}")
|
||||
|
||||
# Language models support
|
||||
try:
|
||||
|
||||
@@ -7,8 +7,9 @@ import torch
|
||||
import torchaudio
|
||||
import numpy as np
|
||||
from typing import Tuple, Optional, List, Union
|
||||
import librosa
|
||||
from scipy.signal import stft, istft
|
||||
# librosa (~0.7s) and scipy.signal (~2s) are imported lazily inside functions that
|
||||
# use them, not at module level. This avoids adding ~3s to plugin startup time.
|
||||
# Note: stft/istft from scipy.signal were previously imported but never used.
|
||||
import warnings
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -17,12 +18,28 @@ import shutil
|
||||
import soundfile as sf
|
||||
|
||||
|
||||
class AudioTimingError(Exception):
|
||||
"""Exception raised when audio timing operations fail"""
|
||||
pass
|
||||
|
||||
|
||||
class AudioTimingUtils:
|
||||
class AudioTimingError(Exception):
|
||||
"""Exception raised when audio timing operations fail"""
|
||||
pass
|
||||
|
||||
|
||||
def _stack_stretched_channels(
|
||||
channels: List[torch.Tensor],
|
||||
device: torch.device,
|
||||
) -> torch.Tensor:
|
||||
"""Stack independently stretched channels after reconciling tiny length drift."""
|
||||
if not channels:
|
||||
raise AudioTimingError("No audio channels were processed successfully")
|
||||
common_length = min(channel.size(-1) for channel in channels)
|
||||
if common_length <= 0:
|
||||
raise AudioTimingError("Time stretching produced an empty audio channel")
|
||||
return torch.stack(
|
||||
[channel[..., :common_length] for channel in channels],
|
||||
dim=0,
|
||||
).to(device)
|
||||
|
||||
|
||||
class AudioTimingUtils:
|
||||
"""
|
||||
Utilities for audio timing manipulation and synchronization
|
||||
"""
|
||||
@@ -195,6 +212,7 @@ class PhaseVocoderTimeStretcher:
|
||||
|
||||
try:
|
||||
# Use librosa for phase vocoder time stretching
|
||||
import librosa
|
||||
stretched = librosa.effects.time_stretch(
|
||||
channel_audio,
|
||||
rate=1.0/stretch_factor, # librosa uses rate = 1/stretch_factor
|
||||
@@ -209,7 +227,7 @@ class PhaseVocoderTimeStretcher:
|
||||
stretched_channels.append(torch.from_numpy(stretched))
|
||||
|
||||
# Combine channels
|
||||
result = torch.stack(stretched_channels, dim=0).to(audio.device)
|
||||
result = _stack_stretched_channels(stretched_channels, audio.device)
|
||||
|
||||
# Restore original shape if input was 1D
|
||||
if len(original_shape) == 1:
|
||||
@@ -364,7 +382,7 @@ class FFmpegTimeStretcher:
|
||||
if not stretched:
|
||||
raise AudioTimingError("No audio was processed successfully")
|
||||
|
||||
result = torch.stack(stretched, dim=0).to(audio.device)
|
||||
result = _stack_stretched_channels(stretched, audio.device)
|
||||
return result.squeeze(0) if len(original_shape) == 1 else result
|
||||
|
||||
except Exception as e:
|
||||
@@ -423,7 +441,7 @@ class FFmpegTimeStretcher:
|
||||
|
||||
try:
|
||||
# Stack channels and restore shape
|
||||
result = torch.stack(stretched, dim=0).to(audio.device)
|
||||
result = _stack_stretched_channels(stretched, audio.device)
|
||||
print(f"Successfully processed all channels")
|
||||
return result.squeeze(0) if len(original_shape) == 1 else result
|
||||
|
||||
@@ -705,4 +723,4 @@ def calculate_timing_adjustments(natural_durations: List[float],
|
||||
|
||||
adjustments.append(adjustment)
|
||||
|
||||
return adjustments
|
||||
return adjustments
|
||||
|
||||
@@ -7,7 +7,8 @@ Works with character_grouper.py to process multiple segments simultaneously.
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import torch
|
||||
from .character_grouper import CharacterGroup
|
||||
from .character_grouper import CharacterGroup
|
||||
from utils.voice.character_logging import resolved_character_label
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
@@ -49,7 +50,8 @@ class BatchProcessor:
|
||||
character = character_group.character
|
||||
segments = character_group.segments
|
||||
|
||||
print(f"🚀 BATCH PROCESSING: {character} - {len(segments)} segments in {language}")
|
||||
display_name = resolved_character_label(character, voice_refs.get(character))
|
||||
print(f"🚀 BATCH PROCESSING: {display_name} - {len(segments)} segments in {language}")
|
||||
|
||||
# Collect all texts for batching
|
||||
batch_texts = []
|
||||
@@ -75,7 +77,7 @@ class BatchProcessor:
|
||||
char_audio_prompt = voice_refs[character]
|
||||
|
||||
# THE ACTUAL BATCH PROCESSING - this is the key improvement
|
||||
print(f"⚡ Batch generating {len(batch_texts)} chunks for {character}")
|
||||
print(f"⚡ Batch generating {len(batch_texts)} chunks for {display_name}")
|
||||
print(f"🔧 DEBUG: batch_size from inputs = {inputs.get('batch_size', 4)}")
|
||||
try:
|
||||
batch_audio = self.tts_model.generate_batch(
|
||||
@@ -137,15 +139,15 @@ class BatchProcessor:
|
||||
character = character_group.character
|
||||
segments = character_group.segments
|
||||
|
||||
print(f"→ SEQUENTIAL: {character} - {len(segments)} segments in {language}")
|
||||
char_audio_prompt = voice_refs[character]
|
||||
display_name = resolved_character_label(character, char_audio_prompt)
|
||||
print(f"→ SEQUENTIAL: {display_name} - {len(segments)} segments in {language}")
|
||||
|
||||
results = {}
|
||||
char_audio_prompt = voice_refs[character]
|
||||
|
||||
for segment in segments:
|
||||
for segment in segments:
|
||||
segment_display_idx = segment.original_idx + 1 # 1-based for display
|
||||
|
||||
print(f"🎤 Generating segment {segment_display_idx}/{total_segments} for '{character}' (lang: {language})")
|
||||
print(f"🎤 Generating segment {segment_display_idx}/{total_segments} for '{display_name}' (lang: {language})")
|
||||
|
||||
# Apply chunking
|
||||
if inputs["enable_chunking"] and len(segment.segment_text) > inputs["max_chars_per_chunk"]:
|
||||
@@ -183,4 +185,4 @@ class BatchProcessor:
|
||||
"""Apply crash protection padding to short texts."""
|
||||
if len(text.strip()) < min_length:
|
||||
return padding_template.format(seg=text)
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -41,16 +41,16 @@ CHATTERBOX_MODELS = {
|
||||
"description": "French model trained on 1,400 hours of Emilia dataset - t3_cfg only, uses shared English components",
|
||||
"incomplete": True
|
||||
},
|
||||
"Russian": {
|
||||
"repo": "niobures/Chatterbox-TTS",
|
||||
"format": "safetensors",
|
||||
"subdirectory": "ru/chatterbox-ru-t3k",
|
||||
"description": "Russian model with training artifacts - Complete fine-tuned model"
|
||||
},
|
||||
"Armenian": {
|
||||
"repo": "niobures/Chatterbox-TTS",
|
||||
"format": "safetensors",
|
||||
"subdirectory": "hy",
|
||||
"Russian": {
|
||||
"repo": "niobures/Chatterbox-TTS",
|
||||
"format": "safetensors",
|
||||
"subdirectory": "ru/chatterbox-ru-t3k",
|
||||
"description": "Russian model with training artifacts - Complete fine-tuned model"
|
||||
},
|
||||
"Armenian": {
|
||||
"repo": "niobures/Chatterbox-TTS",
|
||||
"format": "safetensors",
|
||||
"subdirectory": "hy",
|
||||
"description": "Armenian model with training artifacts - Complete fine-tuned model"
|
||||
},
|
||||
"Georgian": {
|
||||
@@ -222,41 +222,52 @@ def is_model_incomplete(language: str) -> bool:
|
||||
config = CHATTERBOX_MODELS.get(language)
|
||||
return config.get("incomplete", False) if config else False
|
||||
|
||||
def is_unified_model(language: str) -> bool:
|
||||
"""Check if a model uses unified/special architecture (like Italian)"""
|
||||
config = CHATTERBOX_MODELS.get(language)
|
||||
return config.get("special") == "unified_model" if config else False
|
||||
|
||||
def get_tokenizer_filename(language: str) -> str:
|
||||
"""Get the correct tokenizer filename for a language model"""
|
||||
if language == "Japanese":
|
||||
return "tokenizer_jp.json"
|
||||
elif language == "Korean":
|
||||
return "tokenizer_en_ko.json"
|
||||
else:
|
||||
return "tokenizer.json"
|
||||
|
||||
def get_model_requirements(language: str) -> List[str]:
|
||||
"""Get list of required files for a ChatterBox model"""
|
||||
|
||||
# Handle special Italian unified model
|
||||
if language == "Italian":
|
||||
return ["chatterbox_italian_final.pt", "config.json"]
|
||||
|
||||
# Handle special cases for incomplete models
|
||||
if language == "French":
|
||||
# French model only has t3_cfg.safetensors, no tokenizer in repo
|
||||
base_requirements = ["t3_cfg.safetensors"]
|
||||
else:
|
||||
# Other models have tokenizers
|
||||
tokenizer_file = get_tokenizer_filename(language)
|
||||
base_requirements = ["t3_cfg.safetensors", tokenizer_file]
|
||||
|
||||
# Complete models need all components
|
||||
if not is_model_incomplete(language):
|
||||
base_requirements.extend(["s3gen.safetensors", "ve.safetensors", "conds.pt"])
|
||||
|
||||
return base_requirements
|
||||
def is_unified_model(language: str) -> bool:
|
||||
"""Check if a model uses unified/special architecture (like Italian)"""
|
||||
config = CHATTERBOX_MODELS.get(language)
|
||||
return config.get("special") == "unified_model" if config else False
|
||||
|
||||
def get_tokenizer_filename(language: str) -> str:
|
||||
"""Get the correct tokenizer filename for a language model"""
|
||||
config = CHATTERBOX_MODELS.get(language)
|
||||
if config and config.get("files", {}).get("tokenizer"):
|
||||
return config["files"]["tokenizer"]
|
||||
if language == "Japanese":
|
||||
return "tokenizer_jp.json"
|
||||
elif language == "Korean":
|
||||
return "tokenizer_en_ko.json"
|
||||
else:
|
||||
return "tokenizer.json"
|
||||
|
||||
def get_model_requirements(language: str) -> List[str]:
|
||||
"""Get list of required files for a ChatterBox model"""
|
||||
config = CHATTERBOX_MODELS.get(language, {})
|
||||
file_overrides = config.get("files", {})
|
||||
|
||||
# Handle special Italian unified model
|
||||
if language == "Italian":
|
||||
return ["chatterbox_italian_final.pt", "config.json"]
|
||||
|
||||
t3_filename = file_overrides.get("t3_cfg", "t3_cfg.safetensors")
|
||||
|
||||
# Handle special cases for incomplete models
|
||||
if language == "French":
|
||||
# French model only has t3_cfg.safetensors, no tokenizer in repo
|
||||
base_requirements = [t3_filename]
|
||||
else:
|
||||
# Other models have tokenizers
|
||||
tokenizer_file = get_tokenizer_filename(language)
|
||||
base_requirements = [t3_filename, tokenizer_file]
|
||||
|
||||
# Complete models need all components
|
||||
if not is_model_incomplete(language):
|
||||
base_requirements.extend([
|
||||
file_overrides.get("s3gen", "s3gen.safetensors"),
|
||||
file_overrides.get("ve", "ve.safetensors"),
|
||||
file_overrides.get("conds", "conds.pt")
|
||||
])
|
||||
|
||||
return base_requirements
|
||||
|
||||
def validate_model_completeness(model_path: str, language: str) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
@@ -334,4 +345,4 @@ def get_available_languages() -> List[str]:
|
||||
else:
|
||||
clean_models.append(model)
|
||||
|
||||
return clean_models
|
||||
return clean_models
|
||||
|
||||
@@ -13,38 +13,62 @@ SPECIAL_TOKENS = [SOT, EOT, UNK, SPACE, "[PAD]", "[SEP]", "[CLS]", "[MASK]"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EnTokenizer:
|
||||
def __init__(self, vocab_file_path):
|
||||
self.tokenizer: Tokenizer = Tokenizer.from_file(vocab_file_path)
|
||||
self.check_vocabset_sot_eot()
|
||||
class EnTokenizer:
|
||||
def __init__(self, vocab_file_path):
|
||||
self.tokenizer: Tokenizer = Tokenizer.from_file(vocab_file_path)
|
||||
self.vocab = self.tokenizer.get_vocab()
|
||||
# TTS Audio Suite patch: adapt tokenizer behavior to community vocab variants
|
||||
# that use lowercase Cyrillic and "[space]" instead of the stock "[SPACE]".
|
||||
self.space_token = self._detect_space_token()
|
||||
self.lowercase_input = self._should_lowercase_input()
|
||||
self.check_vocabset_sot_eot()
|
||||
|
||||
def check_vocabset_sot_eot(self):
|
||||
assert SOT in self.vocab
|
||||
assert EOT in self.vocab
|
||||
|
||||
def _detect_space_token(self):
|
||||
lower_space_variants = [token for token in self.vocab if token.startswith("[space]")]
|
||||
if lower_space_variants:
|
||||
return "[space]"
|
||||
return SPACE
|
||||
|
||||
def _should_lowercase_input(self):
|
||||
has_lower_cyrillic = any(
|
||||
any('\u0430' <= ch <= '\u044f' or ch == '\u0451' for ch in token)
|
||||
for token in self.vocab
|
||||
)
|
||||
has_upper_cyrillic = any(
|
||||
any('\u0410' <= ch <= '\u042f' or ch == '\u0401' for ch in token)
|
||||
for token in self.vocab
|
||||
)
|
||||
return has_lower_cyrillic and not has_upper_cyrillic
|
||||
|
||||
def text_to_tokens(self, text: str):
|
||||
text_tokens = self.encode(text)
|
||||
text_tokens = torch.IntTensor(text_tokens).unsqueeze(0)
|
||||
return text_tokens
|
||||
|
||||
def check_vocabset_sot_eot(self):
|
||||
voc = self.tokenizer.get_vocab()
|
||||
assert SOT in voc
|
||||
assert EOT in voc
|
||||
|
||||
def text_to_tokens(self, text: str):
|
||||
text_tokens = self.encode(text)
|
||||
text_tokens = torch.IntTensor(text_tokens).unsqueeze(0)
|
||||
return text_tokens
|
||||
|
||||
def encode( self, txt: str, verbose=False):
|
||||
"""
|
||||
clean_text > (append `lang_id`) > replace SPACE > encode text using Tokenizer
|
||||
"""
|
||||
txt = txt.replace(' ', SPACE)
|
||||
code = self.tokenizer.encode(txt)
|
||||
ids = code.ids
|
||||
return ids
|
||||
|
||||
def decode(self, seq):
|
||||
def encode( self, txt: str, verbose=False):
|
||||
"""
|
||||
clean_text > (append `lang_id`) > replace SPACE > encode text using Tokenizer
|
||||
"""
|
||||
if self.lowercase_input:
|
||||
txt = txt.lower()
|
||||
txt = txt.replace(' ', self.space_token)
|
||||
code = self.tokenizer.encode(txt)
|
||||
ids = code.ids
|
||||
return ids
|
||||
|
||||
def decode(self, seq):
|
||||
if isinstance(seq, torch.Tensor):
|
||||
seq = seq.cpu().numpy()
|
||||
|
||||
txt: str = self.tokenizer.decode(seq,
|
||||
skip_special_tokens=False)
|
||||
txt = txt.replace(' ', '')
|
||||
txt = txt.replace(SPACE, ' ')
|
||||
txt = txt.replace(EOT, '')
|
||||
txt = txt.replace(UNK, '')
|
||||
return txt
|
||||
txt: str = self.tokenizer.decode(seq,
|
||||
skip_special_tokens=False)
|
||||
txt = txt.replace(' ', '')
|
||||
txt = txt.replace(SPACE, ' ')
|
||||
txt = txt.replace("[space]", ' ')
|
||||
txt = txt.replace(EOT, '')
|
||||
txt = txt.replace(UNK, '')
|
||||
return txt
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import warnings
|
||||
|
||||
|
||||
def create_perth_watermarker():
|
||||
"""
|
||||
Lazily import perth only when watermarking is actually requested.
|
||||
|
||||
Watermarking is disabled by default, so Chatterbox should not fail model load
|
||||
just because perth drags in a fragile librosa import path on some envs.
|
||||
"""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import perth
|
||||
|
||||
watermarker_cls = getattr(perth, "PerthImplicitWatermarker", None)
|
||||
if watermarker_cls is None:
|
||||
raise AttributeError("PerthImplicitWatermarker not available in perth module")
|
||||
|
||||
watermarker = watermarker_cls()
|
||||
if watermarker is None:
|
||||
raise ValueError("PerthImplicitWatermarker returned None")
|
||||
|
||||
return watermarker
|
||||
+116
-76
@@ -4,13 +4,14 @@ import os
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from huggingface_hub import hf_hub_download
|
||||
import warnings
|
||||
|
||||
# Use librosa fallback for Python 3.13 compatibility
|
||||
from utils.audio.librosa_fallback import safe_load, safe_resample
|
||||
# Import safetensors for multilanguage model support
|
||||
from safetensors.torch import load_file
|
||||
from huggingface_hub import hf_hub_download
|
||||
import warnings
|
||||
|
||||
# Use librosa fallback for Python 3.13 compatibility
|
||||
from utils.audio.librosa_fallback import safe_load, safe_resample
|
||||
from .perth_loader import create_perth_watermarker
|
||||
# Import safetensors for multilanguage model support
|
||||
from safetensors.torch import load_file
|
||||
|
||||
# Import folder_paths for model directory detection
|
||||
try:
|
||||
@@ -18,19 +19,9 @@ try:
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
# Import perth with warnings disabled and graceful fallback
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import perth
|
||||
PERTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
perth = None
|
||||
PERTH_AVAILABLE = False
|
||||
|
||||
from .models.t3 import T3
|
||||
from .models.s3tokenizer import S3_SR, drop_invalid_tokens
|
||||
from .models.s3gen import S3GEN_SR, S3Gen
|
||||
from .models.t3 import T3
|
||||
from .models.s3tokenizer import S3_SR, drop_invalid_tokens
|
||||
from .models.s3gen import S3GEN_SR, S3Gen
|
||||
from .models.tokenizers import EnTokenizer
|
||||
from .models.voice_encoder import VoiceEncoder
|
||||
from .models.t3.modules.cond_enc import T3Cond
|
||||
@@ -197,51 +188,93 @@ class ChatterboxTTS:
|
||||
|
||||
return self
|
||||
|
||||
def _init_watermarker_if_needed(self):
|
||||
"""Initialize watermarker on first use if enabled"""
|
||||
if self.enable_watermarking and not self._watermarker_init_attempted:
|
||||
self._watermarker_init_attempted = True
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
self.watermarker = perth.PerthImplicitWatermarker()
|
||||
if self.watermarker is None:
|
||||
raise ValueError("PerthImplicitWatermarker returned None")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize watermarker: {e}")
|
||||
self.watermarker = None
|
||||
self.enable_watermarking = False
|
||||
def _init_watermarker_if_needed(self):
|
||||
"""Initialize watermarker on first use if enabled"""
|
||||
if self.enable_watermarking and not self._watermarker_init_attempted:
|
||||
self._watermarker_init_attempted = True
|
||||
try:
|
||||
self.watermarker = create_perth_watermarker()
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize watermarker: {e}")
|
||||
self.watermarker = None
|
||||
self.enable_watermarking = False
|
||||
|
||||
@classmethod
|
||||
def from_local(cls, ckpt_dir, device, language=None) -> 'ChatterboxTTS':
|
||||
ckpt_dir = Path(ckpt_dir)
|
||||
|
||||
# Resolve "auto" device to actual device
|
||||
from utils.device import resolve_torch_device
|
||||
device = resolve_torch_device(device)
|
||||
|
||||
# Handle Italian unified model (special architecture)
|
||||
if language and is_unified_model(language):
|
||||
return cls._load_unified_model(ckpt_dir, device, language)
|
||||
|
||||
# Determine if this is an incomplete model by checking language or directory structure
|
||||
is_incomplete_model = False
|
||||
if language:
|
||||
is_incomplete_model = is_model_incomplete(language)
|
||||
else:
|
||||
# Check if critical files are missing (indicates incomplete model)
|
||||
ve_exists = any((ckpt_dir / f"ve.{ext}").exists() for ext in ["safetensors", "pt"])
|
||||
s3gen_exists = any((ckpt_dir / f"s3gen.{ext}").exists() for ext in ["safetensors", "pt"])
|
||||
is_incomplete_model = not (ve_exists and s3gen_exists)
|
||||
def from_local(cls, ckpt_dir, device, language=None) -> 'ChatterboxTTS':
|
||||
ckpt_dir = Path(ckpt_dir)
|
||||
|
||||
# Resolve "auto" device to actual device
|
||||
from utils.device import resolve_torch_device
|
||||
device = resolve_torch_device(device)
|
||||
|
||||
resolved_language = language
|
||||
if resolved_language and resolved_language.startswith("local:"):
|
||||
resolved_language = resolved_language[6:]
|
||||
if not resolved_language and ckpt_dir.name in CHATTERBOX_MODELS:
|
||||
resolved_language = ckpt_dir.name
|
||||
|
||||
# Handle Italian unified model (special architecture)
|
||||
if resolved_language and is_unified_model(resolved_language):
|
||||
return cls._load_unified_model(ckpt_dir, device, resolved_language)
|
||||
|
||||
# Self-heal stale built-in language folders when upstream files were added later.
|
||||
if resolved_language and ckpt_dir.name == resolved_language:
|
||||
is_complete, missing_files = validate_model_completeness(str(ckpt_dir), resolved_language)
|
||||
if not is_complete:
|
||||
print(f"⚠️ Local {resolved_language} model incomplete, missing: {missing_files}")
|
||||
model_config = get_model_config(resolved_language)
|
||||
if model_config and model_config.get("repo"):
|
||||
files_to_download = get_model_requirements(resolved_language)
|
||||
model_format = model_config.get("format", "pt")
|
||||
if model_format == "mixed":
|
||||
files_to_download = [
|
||||
f.replace(".pt", ".safetensors") if f.endswith(".pt") else f
|
||||
for f in files_to_download
|
||||
]
|
||||
elif model_format == "pt":
|
||||
files_to_download = [
|
||||
f.replace(".safetensors", ".pt") if f.endswith(".safetensors") else f
|
||||
for f in files_to_download
|
||||
]
|
||||
|
||||
print(f"📥 Attempting to repair missing {resolved_language} files in place")
|
||||
from utils.downloads.unified_downloader import unified_downloader
|
||||
unified_downloader.download_chatterbox_model(
|
||||
repo_id=model_config.get("repo", REPO_ID),
|
||||
model_name=resolved_language,
|
||||
subdirectory=model_config.get("subdirectory"),
|
||||
files=files_to_download,
|
||||
)
|
||||
|
||||
# Determine if this is an incomplete model by checking language or directory structure
|
||||
is_incomplete_model = False
|
||||
if resolved_language:
|
||||
is_incomplete_model = is_model_incomplete(resolved_language)
|
||||
else:
|
||||
# Check if critical files are missing (indicates incomplete model)
|
||||
ve_exists = any((ckpt_dir / f"ve.{ext}").exists() for ext in ["safetensors", "pt"])
|
||||
s3gen_exists = any((ckpt_dir / f"s3gen.{ext}").exists() for ext in ["safetensors", "pt"])
|
||||
is_incomplete_model = not (ve_exists and s3gen_exists)
|
||||
|
||||
# Auto-detect model format with fallback support for incomplete models
|
||||
def load_model_file(base_name: str, required: bool = True):
|
||||
"""Load model file with auto-detection of format (.safetensors preferred over .pt)"""
|
||||
safetensors_path = ckpt_dir / f"{base_name}.safetensors"
|
||||
pt_path = ckpt_dir / f"{base_name}.pt"
|
||||
|
||||
if safetensors_path.exists():
|
||||
return load_file(safetensors_path, device=device)
|
||||
def load_model_file(base_name: str, required: bool = True):
|
||||
"""Load model file with auto-detection of format (.safetensors preferred over .pt)"""
|
||||
if resolved_language:
|
||||
language_config = CHATTERBOX_MODELS.get(resolved_language, {})
|
||||
custom_filename = language_config.get("files", {}).get(base_name)
|
||||
if custom_filename:
|
||||
custom_path = ckpt_dir / custom_filename
|
||||
if custom_path.exists():
|
||||
if custom_path.suffix == ".safetensors":
|
||||
return load_file(custom_path, device=device)
|
||||
if custom_path.suffix == ".pt":
|
||||
return torch.load(custom_path, map_location=device)
|
||||
|
||||
safetensors_path = ckpt_dir / f"{base_name}.safetensors"
|
||||
pt_path = ckpt_dir / f"{base_name}.pt"
|
||||
|
||||
if safetensors_path.exists():
|
||||
return load_file(safetensors_path, device=device)
|
||||
elif pt_path.exists():
|
||||
return torch.load(pt_path, map_location=device)
|
||||
elif not required:
|
||||
@@ -315,12 +348,19 @@ class ChatterboxTTS:
|
||||
new_state_dict[new_key] = value
|
||||
t3_state = new_state_dict
|
||||
|
||||
# Create config with proper settings
|
||||
from .models.t3.t3 import T3Config
|
||||
config = T3Config()
|
||||
|
||||
# Initialize model with config
|
||||
t3 = T3(config)
|
||||
# Create config with proper settings
|
||||
from .models.t3.t3 import T3Config
|
||||
config = T3Config()
|
||||
|
||||
# Community finetunes may change the text vocabulary size.
|
||||
# Build the T3 architecture from checkpoint weights instead of assuming 704.
|
||||
if "text_emb.weight" in t3_state:
|
||||
config.text_tokens_dict_size = t3_state["text_emb.weight"].shape[0]
|
||||
elif "text_head.weight" in t3_state:
|
||||
config.text_tokens_dict_size = t3_state["text_head.weight"].shape[0]
|
||||
|
||||
# Initialize model with config
|
||||
t3 = T3(config)
|
||||
|
||||
# Load state and ensure settings
|
||||
t3.load_state_dict(t3_state)
|
||||
@@ -339,15 +379,15 @@ class ChatterboxTTS:
|
||||
s3gen.to(device).eval()
|
||||
|
||||
# Find the correct tokenizer file (prioritize language-specific tokenizer)
|
||||
tokenizer_file = None
|
||||
|
||||
# First try the language-specific tokenizer based on language parameter
|
||||
if language:
|
||||
expected_tokenizer = get_tokenizer_filename(language)
|
||||
expected_path = ckpt_dir / expected_tokenizer
|
||||
if expected_path.exists():
|
||||
tokenizer_file = str(expected_path)
|
||||
print(f"🔤 Using language-specific tokenizer: {expected_tokenizer}")
|
||||
tokenizer_file = None
|
||||
|
||||
# First try the language-specific tokenizer based on language parameter
|
||||
if resolved_language:
|
||||
expected_tokenizer = get_tokenizer_filename(resolved_language)
|
||||
expected_path = ckpt_dir / expected_tokenizer
|
||||
if expected_path.exists():
|
||||
tokenizer_file = str(expected_path)
|
||||
print(f"🔤 Using language-specific tokenizer: {expected_tokenizer}")
|
||||
|
||||
# If language-specific not found, try available tokenizers
|
||||
if not tokenizer_file:
|
||||
|
||||
+19
-35
@@ -2,11 +2,12 @@ from pathlib import Path
|
||||
import os
|
||||
|
||||
import torch
|
||||
import warnings
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
# Use librosa fallback for Python 3.13 compatibility
|
||||
from utils.audio.librosa_fallback import safe_load
|
||||
import warnings
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
# Use librosa fallback for Python 3.13 compatibility
|
||||
from utils.audio.librosa_fallback import safe_load
|
||||
from .perth_loader import create_perth_watermarker
|
||||
|
||||
# Import folder_paths for model directory detection
|
||||
try:
|
||||
@@ -14,18 +15,8 @@ try:
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
# Import perth with warnings disabled and graceful fallback
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import perth
|
||||
PERTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
perth = None
|
||||
PERTH_AVAILABLE = False
|
||||
|
||||
# Import safetensors for multilanguage model support
|
||||
from safetensors.torch import load_file
|
||||
# Import safetensors for multilanguage model support
|
||||
from safetensors.torch import load_file
|
||||
|
||||
from .models.s3tokenizer import S3_SR
|
||||
from .models.s3gen import S3GEN_SR, S3Gen
|
||||
@@ -82,23 +73,16 @@ class ChatterboxVC:
|
||||
|
||||
return self
|
||||
|
||||
def _init_watermarker_if_needed(self):
|
||||
"""Initialize watermarker on first use if enabled"""
|
||||
if self.enable_watermarking and not self._watermarker_init_attempted:
|
||||
self._watermarker_init_attempted = True
|
||||
try:
|
||||
if PERTH_AVAILABLE and hasattr(perth, 'PerthImplicitWatermarker'):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
self.watermarker = perth.PerthImplicitWatermarker()
|
||||
if self.watermarker is None:
|
||||
raise ValueError("PerthImplicitWatermarker returned None")
|
||||
else:
|
||||
raise AttributeError("PerthImplicitWatermarker not available in perth module")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize watermarker: {e}")
|
||||
self.watermarker = None
|
||||
self.enable_watermarking = False
|
||||
def _init_watermarker_if_needed(self):
|
||||
"""Initialize watermarker on first use if enabled"""
|
||||
if self.enable_watermarking and not self._watermarker_init_attempted:
|
||||
self._watermarker_init_attempted = True
|
||||
try:
|
||||
self.watermarker = create_perth_watermarker()
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to initialize watermarker: {e}")
|
||||
self.watermarker = None
|
||||
self.enable_watermarking = False
|
||||
|
||||
@classmethod
|
||||
def from_local(cls, ckpt_dir, device) -> 'ChatterboxVC':
|
||||
@@ -123,7 +107,7 @@ class ChatterboxVC:
|
||||
|
||||
ref_dict = None
|
||||
if (builtin_voice := ckpt_dir / "conds.pt").exists():
|
||||
states = torch.load(builtin_voice)
|
||||
states = torch.load(builtin_voice, map_location=device)
|
||||
ref_dict = states['gen']
|
||||
|
||||
s3gen = S3Gen()
|
||||
|
||||
@@ -13,8 +13,9 @@ import torch
|
||||
import torchaudio
|
||||
import numpy as np
|
||||
from typing import Tuple, Optional, List, Union
|
||||
import librosa
|
||||
from scipy.signal import stft, istft
|
||||
# librosa (~0.7s) and scipy.signal (~2s) are imported lazily inside functions that
|
||||
# use them, not at module level. This avoids adding ~3s to plugin startup time.
|
||||
# Note: stft/istft from scipy.signal were previously imported but never used.
|
||||
import warnings
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -201,8 +202,9 @@ class PhaseVocoderTimeStretcher:
|
||||
|
||||
try:
|
||||
# Use librosa for phase vocoder time stretching
|
||||
import librosa
|
||||
stretched = librosa.effects.time_stretch(
|
||||
channel_audio,
|
||||
channel_audio,
|
||||
rate=1.0/stretch_factor, # librosa uses rate = 1/stretch_factor
|
||||
hop_length=self.hop_length
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ Works with character_grouper.py to process multiple segments simultaneously.
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import torch
|
||||
from .character_grouper import CharacterGroup
|
||||
from .character_grouper import CharacterGroup
|
||||
from utils.voice.character_logging import resolved_character_label
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
@@ -49,7 +50,8 @@ class BatchProcessor:
|
||||
character = character_group.character
|
||||
segments = character_group.segments
|
||||
|
||||
print(f"🚀 BATCH PROCESSING: {character} - {len(segments)} segments in {language}")
|
||||
display_name = resolved_character_label(character, voice_refs.get(character))
|
||||
print(f"🚀 BATCH PROCESSING: {display_name} - {len(segments)} segments in {language}")
|
||||
|
||||
# Collect all texts for batching
|
||||
batch_texts = []
|
||||
@@ -76,7 +78,7 @@ class BatchProcessor:
|
||||
char_audio_prompt = voice_refs[character]
|
||||
|
||||
# THE ACTUAL BATCH PROCESSING - this is the key improvement
|
||||
print(f"⚡ Batch generating {len(batch_texts)} chunks for {character}")
|
||||
print(f"⚡ Batch generating {len(batch_texts)} chunks for {display_name}")
|
||||
print(f"🔧 DEBUG: batch_size from inputs = {inputs.get('batch_size', 4)}")
|
||||
try:
|
||||
batch_audio = self.tts_model.generate_batch(
|
||||
@@ -138,15 +140,15 @@ class BatchProcessor:
|
||||
character = character_group.character
|
||||
segments = character_group.segments
|
||||
|
||||
print(f"→ SEQUENTIAL: {character} - {len(segments)} segments in {language}")
|
||||
char_audio_prompt = voice_refs[character]
|
||||
display_name = resolved_character_label(character, char_audio_prompt)
|
||||
print(f"→ SEQUENTIAL: {display_name} - {len(segments)} segments in {language}")
|
||||
|
||||
results = {}
|
||||
char_audio_prompt = voice_refs[character]
|
||||
|
||||
for segment in segments:
|
||||
for segment in segments:
|
||||
segment_display_idx = segment.original_idx + 1 # 1-based for display
|
||||
|
||||
print(f"🎤 Generating segment {segment_display_idx}/{total_segments} for '{character}' (lang: {language})")
|
||||
print(f"🎤 Generating segment {segment_display_idx}/{total_segments} for '{display_name}' (lang: {language})")
|
||||
|
||||
# Apply chunking
|
||||
if inputs["enable_chunking"] and len(segment.segment_text) > inputs["max_chars_per_chunk"]:
|
||||
@@ -184,4 +186,4 @@ class BatchProcessor:
|
||||
"""Apply crash protection padding to short texts."""
|
||||
if len(text.strip()) < min_length:
|
||||
return padding_template.format(seg=text)
|
||||
return text
|
||||
return text
|
||||
|
||||
@@ -43,19 +43,30 @@ OFFICIAL_23LANG_MODELS = {
|
||||
"required_files": {
|
||||
"v1": [
|
||||
"t3_23lang.safetensors", # Multilingual T3 model v1
|
||||
"s3gen.pt", # S3Gen model (same as English)
|
||||
"ve.pt", # Voice encoder (same as English)
|
||||
"mtl_tokenizer.json", # Multilingual tokenizer
|
||||
"conds.pt" # Conditioning (optional)
|
||||
"s3gen.pt", # S3Gen model (same as English)
|
||||
"ve.pt", # Voice encoder (same as English)
|
||||
"mtl_tokenizer.json", # Multilingual tokenizer
|
||||
"Cangjie5_TC.json", # Chinese Cangjie mapping
|
||||
"conds.pt" # Conditioning (optional)
|
||||
],
|
||||
"v2": [
|
||||
"t3_mtl23ls_v2.safetensors", # Multilingual T3 model v2 with enhanced tokenization
|
||||
"s3gen.pt", # S3Gen model (same as English)
|
||||
"ve.pt", # Voice encoder (same as English)
|
||||
"grapheme_mtl_merged_expanded_v1.json", # Enhanced grapheme/phoneme mappings with special tokens
|
||||
"mtl_tokenizer.json", # Multilingual tokenizer (may be updated for v2)
|
||||
"conds.pt" # Conditioning (optional)
|
||||
]
|
||||
"v2": [
|
||||
"t3_mtl23ls_v2.safetensors", # Multilingual T3 model v2 with enhanced tokenization
|
||||
"s3gen.pt", # S3Gen model (same as English)
|
||||
"ve.pt", # Voice encoder (same as English)
|
||||
"grapheme_mtl_merged_expanded_v1.json", # Enhanced grapheme/phoneme mappings with special tokens
|
||||
"mtl_tokenizer.json", # Multilingual tokenizer (may be updated for v2)
|
||||
"Cangjie5_TC.json", # Chinese Cangjie mapping
|
||||
"conds.pt" # Conditioning (optional)
|
||||
],
|
||||
"v3": [
|
||||
"t3_mtl23ls_v3.safetensors", # Latest official multilingual T3 model
|
||||
"s3gen.pt", # Official V3 API continues to use shared S3Gen
|
||||
"ve.pt", # Shared voice encoder
|
||||
"grapheme_mtl_merged_expanded_v1.json",
|
||||
"mtl_tokenizer.json",
|
||||
"Cangjie5_TC.json",
|
||||
"conds.pt"
|
||||
]
|
||||
},
|
||||
"multilingual": True
|
||||
},
|
||||
@@ -80,6 +91,26 @@ OFFICIAL_23LANG_MODELS = {
|
||||
},
|
||||
"multilingual": False, # Primarily Vietnamese-focused
|
||||
"community_finetune": True
|
||||
},
|
||||
# Egyptian Arabic community finetune
|
||||
"Egyptian Arabic (oddadmix)": {
|
||||
"repo": "oddadmix/chatterbox-egyptian-v0",
|
||||
"format": "as-is",
|
||||
"description": "Egyptian Arabic-optimized ChatterBox finetune",
|
||||
"languages": [
|
||||
"ar", # Arabic
|
||||
],
|
||||
"required_files": {
|
||||
"v2": [ # Based on v2 architecture
|
||||
"t3_mtl23ls_v2.safetensors",
|
||||
"s3gen.safetensors",
|
||||
"ve.safetensors",
|
||||
"mtl_tokenizer.json",
|
||||
"conds.pt"
|
||||
]
|
||||
},
|
||||
"multilingual": False,
|
||||
"community_finetune": True
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +299,11 @@ def get_model_requirements(model_name: str, model_version: str = "v1") -> List[s
|
||||
"conds.pt" # Optional but expected
|
||||
]
|
||||
|
||||
def validate_model_completeness(model_path: str, model_name: str, model_version: str = "v1") -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate if a model has all required components.
|
||||
Returns (is_complete, missing_files)
|
||||
"""
|
||||
def validate_model_completeness(model_path: str, model_name: str, model_version: str = "v1") -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate if a model has all required components.
|
||||
Returns (is_complete, missing_files)
|
||||
"""
|
||||
if not os.path.exists(model_path):
|
||||
return False, ["Model directory not found"]
|
||||
|
||||
@@ -280,15 +311,23 @@ def validate_model_completeness(model_path: str, model_name: str, model_version:
|
||||
missing_files = []
|
||||
optional_files = ["conds.pt", "grapheme_mtl_merged_expanded_v1.json"] # These are optional
|
||||
|
||||
existing_files = os.listdir(model_path)
|
||||
for required_file in required_files:
|
||||
# Check for exact file name
|
||||
if required_file not in existing_files:
|
||||
# Only consider it missing if it's not optional
|
||||
if required_file not in optional_files:
|
||||
missing_files.append(required_file)
|
||||
|
||||
return len(missing_files) == 0, missing_files
|
||||
existing_files = os.listdir(model_path)
|
||||
for required_file in required_files:
|
||||
file_present = required_file in existing_files
|
||||
|
||||
# Official 23-lang loaders accept either .pt or .safetensors for these shared components.
|
||||
if not file_present and required_file in ["s3gen.pt", "ve.pt"]:
|
||||
alt_name = required_file.replace(".pt", ".safetensors")
|
||||
file_present = alt_name in existing_files
|
||||
|
||||
# Community v2 tokenizer variants may provide either expanded tokenizer filename.
|
||||
if not file_present and required_file == "grapheme_mtl_merged_expanded_v1.json":
|
||||
file_present = "tokenizer_vi_expanded.json" in existing_files
|
||||
|
||||
if not file_present and required_file not in optional_files:
|
||||
missing_files.append(required_file)
|
||||
|
||||
return len(missing_files) == 0, missing_files
|
||||
|
||||
def supports_voice_conversion(model_name: str) -> bool:
|
||||
"""
|
||||
@@ -308,4 +347,4 @@ def get_chatterbox_models():
|
||||
|
||||
def get_available_languages():
|
||||
"""Legacy compatibility - redirect to supported languages"""
|
||||
return get_supported_language_names()
|
||||
return get_supported_language_names()
|
||||
|
||||
@@ -235,6 +235,9 @@ class T3(nn.Module):
|
||||
length_penalty=1.0,
|
||||
repetition_penalty=1.2,
|
||||
cfg_weight=0.5,
|
||||
# TTS Audio Suite patch: V3 follows upstream by disabling the legacy
|
||||
# multilingual alignment analyzer while V1/V2 retain existing behavior.
|
||||
use_alignment_analyzer=True,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@@ -267,7 +270,7 @@ class T3(nn.Module):
|
||||
if not self.compiled:
|
||||
# Default to None for English models, only create for multilingual
|
||||
alignment_stream_analyzer = None
|
||||
if self.hp.is_multilingual:
|
||||
if self.hp.is_multilingual and use_alignment_analyzer:
|
||||
alignment_stream_analyzer = AlignmentStreamAnalyzer(
|
||||
self.tfmr,
|
||||
None,
|
||||
@@ -331,7 +334,7 @@ class T3(nn.Module):
|
||||
inputs_embeds=inputs_embeds,
|
||||
past_key_values=None,
|
||||
use_cache=True,
|
||||
output_attentions=True,
|
||||
output_attentions=use_alignment_analyzer,
|
||||
output_hidden_states=True,
|
||||
return_dict=True,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import torch
|
||||
from pathlib import Path
|
||||
from unicodedata import category
|
||||
from tokenizers import Tokenizer
|
||||
from huggingface_hub import hf_hub_download
|
||||
from utils.text.russian_stress_support import get_russian_text_stresser
|
||||
|
||||
|
||||
# Special tokens
|
||||
@@ -55,13 +55,9 @@ class EnTokenizer:
|
||||
return txt
|
||||
|
||||
|
||||
# Model repository
|
||||
REPO_ID = "ResembleAI/chatterbox"
|
||||
|
||||
# Global instances for optional dependencies
|
||||
_kakasi = None
|
||||
_dicta = None
|
||||
_russian_stresser = None
|
||||
|
||||
|
||||
def is_kanji(c: str) -> bool:
|
||||
@@ -167,13 +163,13 @@ class ChineseCangjieConverter:
|
||||
self._init_segmenter()
|
||||
|
||||
def _load_cangjie_mapping(self, model_dir=None):
|
||||
"""Load Cangjie mapping from HuggingFace model repository."""
|
||||
"""Load the Cangjie mapping from the organized local model folder."""
|
||||
try:
|
||||
cangjie_file = hf_hub_download(
|
||||
repo_id=REPO_ID,
|
||||
filename="Cangjie5_TC.json",
|
||||
cache_dir=model_dir
|
||||
)
|
||||
# TTS Audio Suite patch: this asset is downloaded by the unified
|
||||
# downloader; tokenization must never create a hidden HF cache.
|
||||
cangjie_file = Path(model_dir) / "Cangjie5_TC.json"
|
||||
if not cangjie_file.is_file():
|
||||
raise FileNotFoundError(f"Missing local Cangjie mapping: {cangjie_file}")
|
||||
|
||||
with open(cangjie_file, "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
@@ -238,18 +234,12 @@ class ChineseCangjieConverter:
|
||||
|
||||
def add_russian_stress(text: str) -> str:
|
||||
"""Russian text normalization: adds stress marks to Russian text (v2 feature)."""
|
||||
global _russian_stresser
|
||||
|
||||
try:
|
||||
if _russian_stresser is None:
|
||||
from russian_text_stresser.text_stresser import RussianTextStresser
|
||||
_russian_stresser = RussianTextStresser()
|
||||
|
||||
return _russian_stresser.stress_text(text)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("russian_text_stresser not available - Russian stress labeling skipped")
|
||||
return text
|
||||
stresser = get_russian_text_stresser()
|
||||
if stresser is None:
|
||||
logger.warning("Russian stress support unavailable - stress labeling skipped")
|
||||
return text
|
||||
return stresser.stress_text(text)
|
||||
except Exception as e:
|
||||
logger.warning(f"Russian stress labeling failed: {e}")
|
||||
return text
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
from functools import lru_cache
|
||||
|
||||
# Smart numba compatibility for melspec processing
|
||||
try:
|
||||
from utils.compatibility import setup_numba_compatibility
|
||||
setup_numba_compatibility(quick_startup=True, verbose=False)
|
||||
except ImportError:
|
||||
# Fallback for environments without compatibility module
|
||||
import sys
|
||||
import os
|
||||
if sys.version_info >= (3, 13) and os.environ.get('NUMBA_DISABLE_JIT') != '1':
|
||||
os.environ['NUMBA_DISABLE_JIT'] = '1'
|
||||
|
||||
from scipy import signal
|
||||
import numpy as np
|
||||
import librosa
|
||||
# Use librosa fallback for Python 3.13 compatibility
|
||||
from utils.audio.librosa_fallback import safe_mel_filters, safe_stft
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def mel_basis(hp):
|
||||
assert hp.fmax <= hp.sample_rate // 2
|
||||
return librosa.filters.mel(
|
||||
return safe_mel_filters(
|
||||
sr=hp.sample_rate,
|
||||
n_fft=hp.n_fft,
|
||||
n_mels=hp.num_mels,
|
||||
@@ -65,7 +55,7 @@ def melspectrogram(wav, hp, pad=True):
|
||||
def _stft(y, hp, pad=True):
|
||||
# NOTE: after 0.8, pad mode defaults to constant, setting this to reflect for
|
||||
# historical consistency and streaming-version consistency
|
||||
return librosa.stft(
|
||||
return safe_stft(
|
||||
y,
|
||||
n_fft=hp.n_fft,
|
||||
hop_length=hp.hop_size,
|
||||
|
||||
@@ -16,11 +16,10 @@ project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from utils.audio.librosa_fallback import safe_trim
|
||||
from utils.audio.librosa_fallback import safe_resample, safe_trim
|
||||
|
||||
import numpy as np
|
||||
from numpy.lib.stride_tricks import as_strided
|
||||
import librosa
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch import nn, Tensor
|
||||
@@ -275,7 +274,7 @@ class VoiceEncoder(nn.Module):
|
||||
"""
|
||||
if sample_rate != self.hp.sample_rate:
|
||||
wavs = [
|
||||
librosa.resample(wav, orig_sr=sample_rate, target_sr=self.hp.sample_rate, res_type="kaiser_fast")
|
||||
safe_resample(wav, orig_sr=sample_rate, target_sr=self.hp.sample_rate, res_type="kaiser_fast")
|
||||
for wav in wavs
|
||||
]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ except ImportError:
|
||||
PERTH_AVAILABLE = False
|
||||
|
||||
from .models.t3 import T3
|
||||
from .models.s3tokenizer import S3_SR, drop_invalid_tokens
|
||||
from .models.s3tokenizer import S3_SR, S3_TOKEN_RATE, drop_invalid_tokens
|
||||
from .models.s3gen import S3GEN_SR, S3Gen
|
||||
from .models.tokenizers import EnTokenizer, MTLTokenizer
|
||||
from .models.voice_encoder import VoiceEncoder
|
||||
@@ -219,7 +219,8 @@ class ChatterboxOfficial23LangTTS:
|
||||
"""
|
||||
Load ChatterBox Official 23-Lang multilingual model from local directory.
|
||||
Expected files:
|
||||
- t3_23lang.safetensors (multilingual T3 model v1) OR t3_mtl23ls_v2.safetensors (v2)
|
||||
- t3_23lang.safetensors (v1), t3_mtl23ls_v2.safetensors (v2),
|
||||
or t3_mtl23ls_v3.safetensors (v3)
|
||||
- s3gen.pt (S3Gen model)
|
||||
- ve.pt (Voice encoder)
|
||||
- mtl_tokenizer.json (multilingual tokenizer)
|
||||
@@ -242,10 +243,10 @@ class ChatterboxOfficial23LangTTS:
|
||||
|
||||
# Check model completeness with version
|
||||
if model_name:
|
||||
is_complete, missing_files = validate_model_completeness(str(ckpt_dir), model_name, model_version)
|
||||
if not is_complete:
|
||||
print(f"⚠️ Missing {model_version} files: {missing_files}")
|
||||
# Continue loading anyway - some files like conds.pt are optional
|
||||
is_complete, missing_files = validate_model_completeness(str(ckpt_dir), model_name, model_version)
|
||||
if not is_complete:
|
||||
print(f"⚠️ Model folder is incomplete for {model_version}: missing {missing_files}")
|
||||
# Continue loading anyway - some files like conds.pt are optional
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
@@ -260,42 +261,54 @@ class ChatterboxOfficial23LangTTS:
|
||||
has_safetensors = ve_safetensors_path.exists()
|
||||
has_pt = ve_pt_path.exists()
|
||||
|
||||
if has_pt:
|
||||
# Use official .pt format (guaranteed compatibility)
|
||||
ve_state = torch.load(ve_pt_path, map_location=map_location, weights_only=True)
|
||||
ve.load_state_dict(ve_state)
|
||||
elif has_safetensors:
|
||||
# User manually downloaded safetensors - use with compatibility warning
|
||||
print("⚠️ Using ve.safetensors with strict=False for compatibility. Official implementation uses .pt files")
|
||||
ve_state = load_file(ve_safetensors_path, device=actual_device)
|
||||
ve.load_state_dict(ve_state, strict=False)
|
||||
if has_pt:
|
||||
# Use official .pt format (guaranteed compatibility)
|
||||
ve_state = torch.load(ve_pt_path, map_location=map_location, weights_only=True)
|
||||
ve.load_state_dict(ve_state)
|
||||
elif has_safetensors:
|
||||
# Safetensors fallback is valid even though official releases usually ship .pt here.
|
||||
print("ℹ️ Using ve.safetensors fallback (official releases usually ship ve.pt)")
|
||||
ve_state = load_file(ve_safetensors_path, device=actual_device)
|
||||
ve.load_state_dict(ve_state, strict=False)
|
||||
else:
|
||||
raise FileNotFoundError(f"Voice encoder not found: tried {ve_safetensors_path} and {ve_pt_path}")
|
||||
|
||||
ve.to(actual_device).eval()
|
||||
|
||||
# Normalize model_version for file lookups (Vietnamese (Viterbox) -> v2)
|
||||
version_for_files = "v2" if model_version == "Vietnamese (Viterbox)" else model_version
|
||||
# Normalize model_version for file lookups (Vietnamese / Egyptian -> v2)
|
||||
version_for_files = "v2" if model_version in ["Vietnamese (Viterbox)", "Egyptian Arabic (oddadmix)"] else model_version
|
||||
|
||||
# Load version-specific multilingual tokenizer FIRST to get vocab size
|
||||
print("📦 Loading multilingual tokenizer...")
|
||||
tokenizer_path = None
|
||||
|
||||
if version_for_files == "v2":
|
||||
# Try v2 enhanced tokenizer first
|
||||
if version_for_files in ("v2", "v3"):
|
||||
# V2 and V3 use the expanded multilingual tokenizer.
|
||||
candidate_path = ckpt_dir / "grapheme_mtl_merged_expanded_v1.json"
|
||||
if candidate_path.exists():
|
||||
tokenizer_path = candidate_path
|
||||
else:
|
||||
# Try Vietnamese-expanded tokenizer (community finetunes)
|
||||
candidate_path = ckpt_dir / "tokenizer_vi_expanded.json"
|
||||
if candidate_path.exists():
|
||||
print("🌐 Using Vietnamese-expanded tokenizer")
|
||||
tokenizer_path = candidate_path
|
||||
else:
|
||||
# Fallback to v1 tokenizer
|
||||
print("⚠️ v2 tokenizer not found, falling back to v1 tokenizer")
|
||||
tokenizer_path = ckpt_dir / "mtl_tokenizer.json"
|
||||
# FALLBACK: Check official folder for v2 tokenizer (community fine-tunes often omit it)
|
||||
# Use absolute path reconstruction to find the official folder
|
||||
try:
|
||||
official_dir = os.path.join(os.path.dirname(str(ckpt_dir)), "Official 23-Lang")
|
||||
official_candidate = Path(official_dir) / "grapheme_mtl_merged_expanded_v1.json"
|
||||
if official_candidate.exists():
|
||||
print("🌐 Using official v2 enhanced tokenizer fallback")
|
||||
tokenizer_path = official_candidate
|
||||
except:
|
||||
pass
|
||||
|
||||
if not tokenizer_path:
|
||||
# Try Vietnamese-expanded tokenizer (community finetunes)
|
||||
candidate_path = ckpt_dir / "tokenizer_vi_expanded.json"
|
||||
if candidate_path.exists():
|
||||
print("🌐 Using Vietnamese-expanded tokenizer")
|
||||
tokenizer_path = candidate_path
|
||||
else:
|
||||
# Fallback to v1 tokenizer
|
||||
print("⚠️ v2 enhanced tokenizer not found, falling back to mtl_tokenizer.json")
|
||||
tokenizer_path = ckpt_dir / "mtl_tokenizer.json"
|
||||
else:
|
||||
# v1 model - try standard tokenizer
|
||||
tokenizer_path = ckpt_dir / "mtl_tokenizer.json"
|
||||
@@ -315,22 +328,26 @@ class ChatterboxOfficial23LangTTS:
|
||||
|
||||
# Support multiple T3 filename patterns:
|
||||
# - Official v1: t3_23lang.safetensors
|
||||
# - Official v2: t3_mtl23ls_v2.safetensors
|
||||
# - Official v2/v3: t3_mtl23ls_v2.safetensors / t3_mtl23ls_v3.safetensors
|
||||
# - Vietnamese Viterbox: t3_ml24ls_v2.safetensors
|
||||
# - Egyptian Arabic: t3_mtl23ls_v2.safetensors
|
||||
# - Future variants: any t3_*.safetensors
|
||||
t3_path = None
|
||||
if version_for_files == "v2":
|
||||
# Try specific v2 patterns first
|
||||
for pattern in ["t3_mtl23ls_v2.safetensors", "t3_ml24ls_v2.safetensors"]:
|
||||
if version_for_files in ("v2", "v3"):
|
||||
patterns = (
|
||||
["t3_mtl23ls_v3.safetensors"]
|
||||
if version_for_files == "v3"
|
||||
else ["t3_mtl23ls_v2.safetensors", "t3_ml24ls_v2.safetensors"]
|
||||
)
|
||||
for pattern in patterns:
|
||||
candidate = ckpt_dir / pattern
|
||||
if candidate.exists():
|
||||
t3_path = candidate
|
||||
break
|
||||
|
||||
# Fallback: find any t3_*_v2.safetensors file
|
||||
if not t3_path:
|
||||
import glob
|
||||
matches = list(ckpt_dir.glob("t3_*_v2.safetensors"))
|
||||
# Fallback stays version-specific so V2 and V3 cannot be mixed.
|
||||
if not t3_path:
|
||||
matches = list(ckpt_dir.glob(f"t3_*_{version_for_files}.safetensors"))
|
||||
if matches:
|
||||
t3_path = matches[0]
|
||||
else:
|
||||
@@ -362,15 +379,15 @@ class ChatterboxOfficial23LangTTS:
|
||||
has_safetensors = s3gen_safetensors_path.exists()
|
||||
has_pt = s3gen_pt_path.exists()
|
||||
|
||||
if has_pt:
|
||||
# Use official .pt format (guaranteed compatibility)
|
||||
s3gen_state = torch.load(s3gen_pt_path, map_location=map_location, weights_only=True)
|
||||
s3gen.load_state_dict(s3gen_state)
|
||||
elif has_safetensors:
|
||||
# User manually downloaded safetensors - use with compatibility warning
|
||||
print("⚠️ Using s3gen.safetensors with strict=False for compatibility. Official implementation uses .pt files")
|
||||
s3gen_state = load_file(s3gen_safetensors_path, device=actual_device)
|
||||
s3gen.load_state_dict(s3gen_state, strict=False)
|
||||
if has_pt:
|
||||
# Use official .pt format (guaranteed compatibility)
|
||||
s3gen_state = torch.load(s3gen_pt_path, map_location=map_location, weights_only=True)
|
||||
s3gen.load_state_dict(s3gen_state)
|
||||
elif has_safetensors:
|
||||
# Safetensors fallback is valid even though official releases usually ship .pt here.
|
||||
print("ℹ️ Using s3gen.safetensors fallback (official releases usually ship s3gen.pt)")
|
||||
s3gen_state = load_file(s3gen_safetensors_path, device=actual_device)
|
||||
s3gen.load_state_dict(s3gen_state, strict=False)
|
||||
else:
|
||||
raise FileNotFoundError(f"S3Gen model not found: tried {s3gen_safetensors_path} and {s3gen_pt_path}")
|
||||
|
||||
@@ -492,7 +509,7 @@ class ChatterboxOfficial23LangTTS:
|
||||
Args:
|
||||
device: Device to load model on
|
||||
model_name: Model to load (defaults to "ChatterBox Official 23-Lang")
|
||||
model_version: Model version - "v1" or "v2" (defaults to "v2")
|
||||
model_version: Model version - "v1", "v2", or "v3"
|
||||
"""
|
||||
# Get model configuration
|
||||
model_config = get_model_config(model_name)
|
||||
@@ -533,8 +550,8 @@ class ChatterboxOfficial23LangTTS:
|
||||
model_format = model_config.get("format", "pt")
|
||||
|
||||
# Normalize model_version for file lookups
|
||||
# "Vietnamese (Viterbox)" is treated as v2 for file requirements
|
||||
version_for_files = "v2" if model_version == "Vietnamese (Viterbox)" else model_version
|
||||
# "Vietnamese (Viterbox)" and "Egyptian Arabic (oddadmix)" are treated as v2 for file requirements
|
||||
version_for_files = "v2" if model_version in ["Vietnamese (Viterbox)", "Egyptian Arabic (oddadmix)"] else model_version
|
||||
|
||||
# Silent loading unless errors occur
|
||||
|
||||
@@ -676,9 +693,10 @@ class ChatterboxOfficial23LangTTS:
|
||||
max_new_tokens=1000, # TODO: use the value in config
|
||||
temperature=temperature,
|
||||
cfg_weight=cfg_weight,
|
||||
repetition_penalty=repetition_penalty,
|
||||
min_p=min_p,
|
||||
top_p=top_p,
|
||||
repetition_penalty=repetition_penalty,
|
||||
min_p=min_p,
|
||||
top_p=top_p,
|
||||
use_alignment_analyzer=self.model_version != "v3",
|
||||
)
|
||||
# Extract only the conditional batch.
|
||||
speech_tokens = speech_tokens[0]
|
||||
@@ -687,12 +705,20 @@ class ChatterboxOfficial23LangTTS:
|
||||
speech_tokens = drop_invalid_tokens(speech_tokens)
|
||||
speech_tokens = speech_tokens.to(self.device)
|
||||
|
||||
wav, _ = self.s3gen.inference(
|
||||
speech_tokens=speech_tokens,
|
||||
ref_dict=self.conds.gen,
|
||||
)
|
||||
wav = wav.squeeze(0).detach().cpu().numpy()
|
||||
if self.enable_watermarking:
|
||||
wav, _ = self.s3gen.inference(
|
||||
speech_tokens=speech_tokens,
|
||||
ref_dict=self.conds.gen,
|
||||
)
|
||||
wav = wav.squeeze(0).detach().cpu().numpy()
|
||||
|
||||
if self.model_version == "v3":
|
||||
# TTS Audio Suite patch: match official V3 by dropping the
|
||||
# final degraded pre-EOS speech-token artifact.
|
||||
token_count = int(speech_tokens.shape[-1])
|
||||
clean_token_count = max(1, token_count - 1)
|
||||
wav = wav[: clean_token_count * (S3GEN_SR // S3_TOKEN_RATE)]
|
||||
|
||||
if self.enable_watermarking:
|
||||
self._init_watermarker_if_needed()
|
||||
if self.watermarker is not None:
|
||||
watermarked_wav = self.watermarker.apply_watermark(wav, sample_rate=self.sr)
|
||||
|
||||
@@ -28,6 +28,7 @@ import tempfile
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
from typing import Optional, List, Dict, Any, Generator
|
||||
import comfy.model_management as model_management
|
||||
|
||||
from utils.models.unified_model_interface import unified_model_interface
|
||||
from utils.models.factory_config import ModelLoadConfig
|
||||
@@ -59,6 +60,12 @@ class CosyVoiceEngine:
|
||||
|
||||
# Generation modes
|
||||
MODES = ["zero_shot", "instruct", "cross_lingual"]
|
||||
|
||||
@staticmethod
|
||||
def _check_interrupt():
|
||||
"""Raise immediately when ComfyUI interrupt was requested."""
|
||||
if model_management.interrupt_processing:
|
||||
raise InterruptedError("CosyVoice3 generation interrupted by user")
|
||||
|
||||
def __init__(self, model_dir: str = "Fun-CosyVoice3-0.5B-RL", device: str = "auto",
|
||||
use_fp16: bool = False, load_trt: bool = False, load_vllm: bool = False):
|
||||
@@ -190,36 +197,42 @@ class CosyVoiceEngine:
|
||||
self._cosyvoice = unified_model_interface.load_model(self._model_config)
|
||||
|
||||
def _ensure_device_loaded(self):
|
||||
"""Check and reload model if it was offloaded to CPU."""
|
||||
from utils.device import resolve_torch_device
|
||||
target_device = resolve_torch_device("auto")
|
||||
|
||||
"""
|
||||
Check and reload model if it was offloaded to CPU.
|
||||
|
||||
Delegates to the ComfyUI wrapper's model_load() method for proper device management.
|
||||
"""
|
||||
if self._cosyvoice is None:
|
||||
return
|
||||
|
||||
# Check if model was offloaded - look for a model component
|
||||
model_component = None
|
||||
if hasattr(self._cosyvoice, 'model') and hasattr(self._cosyvoice.model, 'llm'):
|
||||
model_component = self._cosyvoice.model.llm
|
||||
elif hasattr(self._cosyvoice, 'llm'):
|
||||
model_component = self._cosyvoice.llm
|
||||
|
||||
if model_component is not None and hasattr(model_component, 'parameters'):
|
||||
try:
|
||||
first_param = next(model_component.parameters())
|
||||
current_device = str(first_param.device)
|
||||
|
||||
if current_device != target_device:
|
||||
# Use unified model manager for device movement
|
||||
try:
|
||||
from utils.models.comfyui_model_wrapper.model_manager import tts_model_manager
|
||||
if not tts_model_manager.ensure_device("cosyvoice", target_device):
|
||||
# Fallback to direct movement
|
||||
self.to(target_device)
|
||||
except Exception:
|
||||
self.to(target_device)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
from utils.device import resolve_torch_device
|
||||
target_device = resolve_torch_device("auto")
|
||||
|
||||
# The unified interface returns a ComfyUIModelWrapper, not the raw model
|
||||
# Check if wrapper indicates model is not on GPU
|
||||
if hasattr(self._cosyvoice, '_is_loaded_on_gpu') and not self._cosyvoice._is_loaded_on_gpu:
|
||||
# Model was offloaded, use wrapper's model_load to properly reload
|
||||
if hasattr(self._cosyvoice, 'model_load'):
|
||||
self._cosyvoice.model_load(target_device)
|
||||
return
|
||||
|
||||
# Also check actual component devices as a safety check
|
||||
if hasattr(self._cosyvoice, 'model'):
|
||||
cosyvoice_model = self._cosyvoice.model
|
||||
|
||||
# Check llm component device
|
||||
if hasattr(cosyvoice_model, 'llm') and hasattr(cosyvoice_model.llm, 'parameters'):
|
||||
try:
|
||||
first_param = next(cosyvoice_model.llm.parameters())
|
||||
current_device = first_param.device
|
||||
|
||||
# Normalize device comparison (cuda:0 == cuda)
|
||||
if current_device.type != torch.device(target_device).type:
|
||||
# Device mismatch detected, trigger reload
|
||||
if hasattr(self._cosyvoice, 'model_load'):
|
||||
self._cosyvoice.model_load(target_device)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
def generate_zero_shot(
|
||||
self,
|
||||
@@ -287,6 +300,7 @@ class CosyVoiceEngine:
|
||||
speed=speed,
|
||||
text_frontend=text_frontend
|
||||
)):
|
||||
self._check_interrupt()
|
||||
audio_chunk = output['tts_speech']
|
||||
audio_chunks.append(audio_chunk)
|
||||
|
||||
@@ -379,6 +393,7 @@ class CosyVoiceEngine:
|
||||
speed=speed,
|
||||
text_frontend=text_frontend
|
||||
)):
|
||||
self._check_interrupt()
|
||||
audio_chunk = output['tts_speech']
|
||||
audio_chunks.append(audio_chunk)
|
||||
|
||||
@@ -465,6 +480,7 @@ class CosyVoiceEngine:
|
||||
speed=speed,
|
||||
text_frontend=text_frontend
|
||||
)):
|
||||
self._check_interrupt()
|
||||
audio_chunk = output['tts_speech']
|
||||
audio_chunks.append(audio_chunk)
|
||||
|
||||
@@ -603,6 +619,7 @@ class CosyVoiceEngine:
|
||||
stream=stream,
|
||||
speed=speed
|
||||
):
|
||||
self._check_interrupt()
|
||||
audio_chunks.append(output['tts_speech'])
|
||||
|
||||
# Concatenate all chunks
|
||||
@@ -617,28 +634,14 @@ class CosyVoiceEngine:
|
||||
"""
|
||||
Move all model components to the specified device.
|
||||
|
||||
Critical for ComfyUI model management - ensures all components move together
|
||||
when models are detached to CPU and later reloaded to CUDA.
|
||||
Delegates to the CosyVoiceHandler via the ComfyUI wrapper for proper
|
||||
component-level device management.
|
||||
"""
|
||||
self.device = device
|
||||
|
||||
# Move the underlying CosyVoice model if loaded
|
||||
if self._cosyvoice is not None:
|
||||
# CosyVoice has model, frontend components
|
||||
if hasattr(self._cosyvoice, 'model') and hasattr(self._cosyvoice.model, 'to'):
|
||||
try:
|
||||
self._cosyvoice.model = self._cosyvoice.model.to(device)
|
||||
except ValueError as e:
|
||||
if "is not supported for" in str(e) and ("8-bit" in str(e) or "4-bit" in str(e)):
|
||||
if not self._quantized_move_warning_shown:
|
||||
print(f"⚠️ Skipping device move for quantized model: {e}")
|
||||
self._quantized_move_warning_shown = True
|
||||
else:
|
||||
raise
|
||||
|
||||
# Update device attribute
|
||||
if hasattr(self._cosyvoice, 'device'):
|
||||
self._cosyvoice.device = torch.device(device) if isinstance(device, str) else device
|
||||
# Delegate to the ComfyUI wrapper's model_load which uses CosyVoiceHandler
|
||||
if self._cosyvoice is not None and hasattr(self._cosyvoice, 'model_load'):
|
||||
self._cosyvoice.model_load(device)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
@@ -270,7 +270,6 @@ class CosyVoice3(CosyVoice2):
|
||||
# PATCH: Added llm_filename parameter to support model variants (llm.pt vs llm.rl.pt)
|
||||
def __init__(self, model_dir, load_trt=False, load_vllm=False, fp16=False, trt_concurrent=1, llm_filename='llm.pt'):
|
||||
self.model_dir = model_dir
|
||||
self.fp16 = fp16
|
||||
if not os.path.exists(model_dir):
|
||||
model_dir = snapshot_download(model_dir)
|
||||
hyper_yaml_path = '{}/cosyvoice3.yaml'.format(model_dir)
|
||||
@@ -289,6 +288,15 @@ class CosyVoice3(CosyVoice2):
|
||||
if torch.cuda.is_available() is False and (load_trt is True or fp16 is True):
|
||||
load_trt, fp16 = False, False
|
||||
logging.warning('no cuda device, set load_trt/fp16 to False')
|
||||
# TTS Audio Suite patch: ROCm cannot use CosyVoice3's FP16 flow/vocoder
|
||||
# path reliably; CosyVoice3Model applies BF16 only to the bundled Qwen LLM.
|
||||
elif torch.version.hip:
|
||||
if fp16:
|
||||
logging.warning('ROCm detected: CosyVoice3 disables FP16 for its flow/vocoder and uses BF16 only for the Qwen LLM.')
|
||||
if load_trt:
|
||||
logging.warning('ROCm detected: TensorRT is unavailable, disabling CosyVoice3 TensorRT loading.')
|
||||
load_trt, fp16 = False, False
|
||||
self.fp16 = fp16
|
||||
self.model = CosyVoice3Model(configs['llm'], configs['flow'], configs['hift'], fp16)
|
||||
# PATCH: Use llm_filename parameter instead of hardcoded 'llm.pt'
|
||||
self.model.load('{}/{}'.format(model_dir, llm_filename),
|
||||
|
||||
@@ -38,6 +38,7 @@ class CosyVoiceModel:
|
||||
self.flow = flow
|
||||
self.hift = hift
|
||||
self.fp16 = fp16
|
||||
self._rocm_bf16_llm_autocast = False
|
||||
self.token_min_hop_len = 2 * self.flow.input_frame_rate
|
||||
self.token_max_hop_len = 4 * self.flow.input_frame_rate
|
||||
self.token_overlap_len = 20
|
||||
@@ -57,6 +58,7 @@ class CosyVoiceModel:
|
||||
# dict used to store session related variable
|
||||
self.tts_speech_token_dict = {}
|
||||
self.llm_end_dict = {}
|
||||
self.llm_error_dict = {}
|
||||
self.mel_overlap_dict = {}
|
||||
self.flow_cache_dict = {}
|
||||
self.hift_cache_dict = {}
|
||||
@@ -111,29 +113,61 @@ class CosyVoiceModel:
|
||||
input_names = ["x", "mask", "mu", "t", "spks", "cond"]
|
||||
return {'min_shape': min_shape, 'opt_shape': opt_shape, 'max_shape': max_shape, 'input_names': input_names}
|
||||
|
||||
# TTS Audio Suite patch: ROCm needs BF16 for the bundled Qwen LLM while
|
||||
# CosyVoice's flow model and vocoder remain in FP32 to prevent silent audio.
|
||||
def _llm_autocast_context(self):
|
||||
if hasattr(self.llm, 'vllm'):
|
||||
return nullcontext()
|
||||
if self._rocm_bf16_llm_autocast:
|
||||
return torch.amp.autocast('cuda', dtype=torch.bfloat16)
|
||||
if self.fp16:
|
||||
return torch.amp.autocast('cuda', dtype=torch.float16)
|
||||
return nullcontext()
|
||||
|
||||
# TTS Audio Suite patch: forward bundled LLM worker failures to the caller
|
||||
# instead of trying to decode an empty token sequence.
|
||||
def _raise_if_llm_failed(self, uuid):
|
||||
error = self.llm_error_dict.pop(uuid, None)
|
||||
if error is None:
|
||||
return
|
||||
with self.lock:
|
||||
self.tts_speech_token_dict.pop(uuid, None)
|
||||
self.llm_end_dict.pop(uuid, None)
|
||||
self.hift_cache_dict.pop(uuid, None)
|
||||
if hasattr(self, 'mel_overlap_dict'):
|
||||
self.mel_overlap_dict.pop(uuid, None)
|
||||
if hasattr(self, 'flow_cache_dict'):
|
||||
self.flow_cache_dict.pop(uuid, None)
|
||||
raise RuntimeError(f'CosyVoice LLM inference failed: {error}') from error
|
||||
|
||||
def llm_job(self, text, prompt_text, llm_prompt_speech_token, llm_embedding, uuid, progress_callback=None):
|
||||
with self.llm_context, torch.cuda.amp.autocast(self.fp16 is True and hasattr(self.llm, 'vllm') is False):
|
||||
if isinstance(text, Generator):
|
||||
assert (self.__class__.__name__ != 'CosyVoiceModel') and not hasattr(self.llm, 'vllm'), 'streaming input text is only implemented for CosyVoice2/3 and do not support vllm!'
|
||||
for i in self.llm.inference_bistream(text=text,
|
||||
prompt_text=prompt_text.to(self.device),
|
||||
prompt_text_len=torch.tensor([prompt_text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_speech_token=llm_prompt_speech_token.to(self.device),
|
||||
prompt_speech_token_len=torch.tensor([llm_prompt_speech_token.shape[1]], dtype=torch.int32).to(self.device),
|
||||
embedding=llm_embedding.to(self.device)):
|
||||
self.tts_speech_token_dict[uuid].append(i)
|
||||
else:
|
||||
for i in self.llm.inference(text=text.to(self.device),
|
||||
text_len=torch.tensor([text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_text=prompt_text.to(self.device),
|
||||
prompt_text_len=torch.tensor([prompt_text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_speech_token=llm_prompt_speech_token.to(self.device),
|
||||
prompt_speech_token_len=torch.tensor([llm_prompt_speech_token.shape[1]], dtype=torch.int32).to(self.device),
|
||||
embedding=llm_embedding.to(self.device),
|
||||
uuid=uuid,
|
||||
progress_callback=progress_callback):
|
||||
self.tts_speech_token_dict[uuid].append(i)
|
||||
self.llm_end_dict[uuid] = True
|
||||
# TTS Audio Suite patch: capture worker failures for _raise_if_llm_failed().
|
||||
try:
|
||||
with self.llm_context, self._llm_autocast_context():
|
||||
if isinstance(text, Generator):
|
||||
assert (self.__class__.__name__ != 'CosyVoiceModel') and not hasattr(self.llm, 'vllm'), 'streaming input text is only implemented for CosyVoice2/3 and do not support vllm!'
|
||||
for i in self.llm.inference_bistream(text=text,
|
||||
prompt_text=prompt_text.to(self.device),
|
||||
prompt_text_len=torch.tensor([prompt_text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_speech_token=llm_prompt_speech_token.to(self.device),
|
||||
prompt_speech_token_len=torch.tensor([llm_prompt_speech_token.shape[1]], dtype=torch.int32).to(self.device),
|
||||
embedding=llm_embedding.to(self.device)):
|
||||
self.tts_speech_token_dict[uuid].append(i)
|
||||
else:
|
||||
for i in self.llm.inference(text=text.to(self.device),
|
||||
text_len=torch.tensor([text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_text=prompt_text.to(self.device),
|
||||
prompt_text_len=torch.tensor([prompt_text.shape[1]], dtype=torch.int32).to(self.device),
|
||||
prompt_speech_token=llm_prompt_speech_token.to(self.device),
|
||||
prompt_speech_token_len=torch.tensor([llm_prompt_speech_token.shape[1]], dtype=torch.int32).to(self.device),
|
||||
embedding=llm_embedding.to(self.device),
|
||||
uuid=uuid,
|
||||
progress_callback=progress_callback):
|
||||
self.tts_speech_token_dict[uuid].append(i)
|
||||
except Exception as error:
|
||||
self.llm_error_dict[uuid] = error
|
||||
finally:
|
||||
self.llm_end_dict[uuid] = True
|
||||
|
||||
def vc_job(self, source_speech_token, uuid):
|
||||
self.tts_speech_token_dict[uuid] = source_speech_token.flatten().tolist()
|
||||
@@ -188,6 +222,7 @@ class CosyVoiceModel:
|
||||
this_uuid = str(uuid.uuid1())
|
||||
with self.lock:
|
||||
self.tts_speech_token_dict[this_uuid], self.llm_end_dict[this_uuid] = [], False
|
||||
self.llm_error_dict[this_uuid] = None
|
||||
self.hift_cache_dict[this_uuid] = None
|
||||
self.mel_overlap_dict[this_uuid] = torch.zeros(1, 80, 0)
|
||||
self.flow_cache_dict[this_uuid] = torch.zeros(1, 80, 0, 2)
|
||||
@@ -217,6 +252,7 @@ class CosyVoiceModel:
|
||||
if self.llm_end_dict[this_uuid] is True and len(self.tts_speech_token_dict[this_uuid]) < token_hop_len + self.token_overlap_len:
|
||||
break
|
||||
p.join()
|
||||
self._raise_if_llm_failed(this_uuid)
|
||||
# deal with remain tokens, make sure inference remain token len equals token_hop_len when cache_speech is not None
|
||||
this_tts_speech_token = torch.tensor(self.tts_speech_token_dict[this_uuid]).unsqueeze(dim=0)
|
||||
this_tts_speech = self.token2wav(token=this_tts_speech_token,
|
||||
@@ -229,6 +265,7 @@ class CosyVoiceModel:
|
||||
else:
|
||||
# deal with all tokens
|
||||
p.join()
|
||||
self._raise_if_llm_failed(this_uuid)
|
||||
this_tts_speech_token = torch.tensor(self.tts_speech_token_dict[this_uuid]).unsqueeze(dim=0)
|
||||
this_tts_speech = self.token2wav(token=this_tts_speech_token,
|
||||
prompt_token=flow_prompt_speech_token,
|
||||
@@ -241,6 +278,7 @@ class CosyVoiceModel:
|
||||
with self.lock:
|
||||
self.tts_speech_token_dict.pop(this_uuid)
|
||||
self.llm_end_dict.pop(this_uuid)
|
||||
self.llm_error_dict.pop(this_uuid, None)
|
||||
self.mel_overlap_dict.pop(this_uuid)
|
||||
self.hift_cache_dict.pop(this_uuid)
|
||||
self.flow_cache_dict.pop(this_uuid)
|
||||
@@ -274,6 +312,7 @@ class CosyVoice2Model(CosyVoiceModel):
|
||||
# dict used to store session related variable
|
||||
self.tts_speech_token_dict = {}
|
||||
self.llm_end_dict = {}
|
||||
self.llm_error_dict = {}
|
||||
self.hift_cache_dict = {}
|
||||
|
||||
def load_jit(self, flow_encoder_model):
|
||||
@@ -336,6 +375,7 @@ class CosyVoice2Model(CosyVoiceModel):
|
||||
this_uuid = str(uuid.uuid1())
|
||||
with self.lock:
|
||||
self.tts_speech_token_dict[this_uuid], self.llm_end_dict[this_uuid] = [], False
|
||||
self.llm_error_dict[this_uuid] = None
|
||||
self.hift_cache_dict[this_uuid] = None
|
||||
if source_speech_token.shape[1] == 0:
|
||||
p = threading.Thread(target=self.llm_job, args=(text, prompt_text, llm_prompt_speech_token, llm_embedding, this_uuid, progress_callback))
|
||||
@@ -363,6 +403,7 @@ class CosyVoice2Model(CosyVoiceModel):
|
||||
if self.llm_end_dict[this_uuid] is True and len(self.tts_speech_token_dict[this_uuid]) - token_offset < this_token_hop_len + self.flow.pre_lookahead_len:
|
||||
break
|
||||
p.join()
|
||||
self._raise_if_llm_failed(this_uuid)
|
||||
# deal with remain tokens, make sure inference remain token len equals token_hop_len when cache_speech is not None
|
||||
this_tts_speech_token = torch.tensor(self.tts_speech_token_dict[this_uuid]).unsqueeze(dim=0)
|
||||
this_tts_speech = self.token2wav(token=this_tts_speech_token,
|
||||
@@ -376,6 +417,7 @@ class CosyVoice2Model(CosyVoiceModel):
|
||||
else:
|
||||
# deal with all tokens
|
||||
p.join()
|
||||
self._raise_if_llm_failed(this_uuid)
|
||||
this_tts_speech_token = torch.tensor(self.tts_speech_token_dict[this_uuid]).unsqueeze(dim=0)
|
||||
this_tts_speech = self.token2wav(token=this_tts_speech_token,
|
||||
prompt_token=flow_prompt_speech_token,
|
||||
@@ -389,6 +431,7 @@ class CosyVoice2Model(CosyVoiceModel):
|
||||
with self.lock:
|
||||
self.tts_speech_token_dict.pop(this_uuid)
|
||||
self.llm_end_dict.pop(this_uuid)
|
||||
self.llm_error_dict.pop(this_uuid, None)
|
||||
self.hift_cache_dict.pop(this_uuid)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
@@ -406,7 +449,9 @@ class CosyVoice3Model(CosyVoice2Model):
|
||||
self.llm = llm
|
||||
self.flow = flow
|
||||
self.hift = hift
|
||||
self.fp16 = fp16
|
||||
# TTS Audio Suite patch: enable BF16 LLM autocast on ROCm only.
|
||||
self._rocm_bf16_llm_autocast = torch.cuda.is_available() and bool(torch.version.hip)
|
||||
self.fp16 = fp16 and not self._rocm_bf16_llm_autocast
|
||||
# NOTE must matching training static_chunk_size
|
||||
self.token_hop_len = 25
|
||||
# rtf and decoding related
|
||||
@@ -415,6 +460,7 @@ class CosyVoice3Model(CosyVoice2Model):
|
||||
# dict used to store session related variable
|
||||
self.tts_speech_token_dict = {}
|
||||
self.llm_end_dict = {}
|
||||
self.llm_error_dict = {}
|
||||
self.hift_cache_dict = {}
|
||||
|
||||
def token2wav(self, token, prompt_token, prompt_feat, embedding, token_offset, uuid, stream=False, finalize=False, speed=1.0):
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Dots TTS engine integration."""
|
||||
|
||||
from .dots_tts_engine import DotsTTSEngine
|
||||
from .dots_tts_downloader import DotsTTSDownloader
|
||||
|
||||
__all__ = ["DotsTTSEngine", "DotsTTSDownloader"]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Dots TTS model downloader.
|
||||
|
||||
Downloads official Rednote dots.tts checkpoints into
|
||||
ComfyUI/models/TTS/dots_tts/ instead of hidden Hugging Face cache folders.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
|
||||
import folder_paths
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
from utils.hf_download_logging import quiet_hf_download_logs
|
||||
from utils.models.extra_paths import get_all_tts_model_paths, get_preferred_download_path
|
||||
|
||||
|
||||
class DotsTTSDownloader:
|
||||
"""Resolve and download official dots.tts model folders."""
|
||||
|
||||
MODELS: Dict[str, Dict[str, str]] = {
|
||||
"dots.tts-base": {
|
||||
"repo_id": "rednote-hilab/dots.tts-base",
|
||||
"description": "Official Dots TTS base checkpoint",
|
||||
},
|
||||
"dots.tts-soar": {
|
||||
"repo_id": "rednote-hilab/dots.tts-soar",
|
||||
"description": "Official Dots TTS SOAR checkpoint",
|
||||
},
|
||||
"dots.tts-mf": {
|
||||
"repo_id": "rednote-hilab/dots.tts-mf",
|
||||
"description": "Official Dots TTS MeanFlow checkpoint",
|
||||
},
|
||||
}
|
||||
|
||||
REQUIRED_FILES = [
|
||||
"added_tokens.json",
|
||||
"chat_template.jinja",
|
||||
"config.json",
|
||||
"latent_stats.pt",
|
||||
"llm_config.json",
|
||||
"merges.txt",
|
||||
"model.safetensors",
|
||||
"speaker_encoder.safetensors",
|
||||
"special_tokens_map.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"vocab.json",
|
||||
"vocoder.safetensors",
|
||||
]
|
||||
|
||||
def __init__(self, base_path: Optional[str] = None):
|
||||
if base_path is None:
|
||||
try:
|
||||
self.base_path = get_preferred_download_path(model_type="TTS", engine_name="dots_tts")
|
||||
except Exception:
|
||||
self.base_path = os.path.join(folder_paths.models_dir, "TTS", "dots_tts")
|
||||
else:
|
||||
self.base_path = base_path
|
||||
os.makedirs(self.base_path, exist_ok=True)
|
||||
|
||||
def resolve_model_path(self, model_identifier: str) -> str:
|
||||
"""Resolve local: names, absolute paths, or known official model identifiers."""
|
||||
if not model_identifier:
|
||||
model_identifier = "dots.tts-soar"
|
||||
|
||||
if os.path.isabs(model_identifier) and os.path.exists(model_identifier):
|
||||
return model_identifier
|
||||
|
||||
if os.path.isdir(model_identifier):
|
||||
return model_identifier
|
||||
|
||||
if model_identifier.startswith("local:"):
|
||||
local_name = model_identifier[6:]
|
||||
for base_path in get_all_tts_model_paths("TTS"):
|
||||
for folder_name in ("dots_tts", "DOTS", ""):
|
||||
candidate = os.path.join(base_path, folder_name, local_name) if folder_name else os.path.join(base_path, local_name)
|
||||
if os.path.exists(candidate):
|
||||
print(f"📁 Using local Dots TTS model: {candidate}")
|
||||
return candidate
|
||||
raise FileNotFoundError(f"Local Dots TTS model not found: {local_name}")
|
||||
|
||||
if model_identifier in self.MODELS:
|
||||
return self.get_model_path(model_identifier)
|
||||
|
||||
raise FileNotFoundError(f"Unknown Dots TTS model identifier: {model_identifier}")
|
||||
|
||||
def get_model_path(self, model_name: str) -> str:
|
||||
model_dir = os.path.join(self.base_path, model_name)
|
||||
if not os.path.exists(model_dir) or not self._is_model_complete(model_dir):
|
||||
return self.download_model(model_name, force=os.path.exists(model_dir))
|
||||
return model_dir
|
||||
|
||||
def download_model(self, model_name: str, force: bool = False) -> str:
|
||||
if model_name not in self.MODELS:
|
||||
raise ValueError(f"Unknown Dots TTS model: {model_name}")
|
||||
|
||||
model_info = self.MODELS[model_name]
|
||||
repo_id = model_info["repo_id"]
|
||||
model_dir = os.path.join(self.base_path, model_name)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print("📦 Dots TTS Model Download")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Model: {model_name}")
|
||||
print(f"Description: {model_info['description']}")
|
||||
print(f"Repository: {repo_id}")
|
||||
print(f"Target: {model_dir}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
try:
|
||||
with quiet_hf_download_logs():
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=model_dir,
|
||||
local_dir_use_symlinks=False,
|
||||
resume_download=True,
|
||||
force_download=force,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to download {model_name} from {repo_id}: {e}") from e
|
||||
|
||||
if not self._is_model_complete(model_dir):
|
||||
raise RuntimeError(f"Downloaded Dots TTS model is incomplete: {model_dir}")
|
||||
|
||||
print(f"✅ Dots TTS model ready: {model_dir}")
|
||||
return model_dir
|
||||
|
||||
def _is_model_complete(self, model_dir: str) -> bool:
|
||||
if not os.path.isdir(model_dir):
|
||||
return False
|
||||
|
||||
missing = [
|
||||
rel_path
|
||||
for rel_path in self.REQUIRED_FILES
|
||||
if not os.path.exists(os.path.join(model_dir, rel_path))
|
||||
]
|
||||
if missing:
|
||||
print(f"❌ Dots TTS model incomplete. Missing {len(missing)} file(s):")
|
||||
for rel_path in missing:
|
||||
print(f" - {rel_path}")
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
Dots TTS engine wrapper.
|
||||
|
||||
Wraps the official dots.tts runtime with ComfyUI-friendly model lifecycle hooks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import importlib
|
||||
import logging
|
||||
import time
|
||||
import warnings
|
||||
import gc
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import torch
|
||||
|
||||
current_dir = os.path.dirname(__file__)
|
||||
engines_dir = os.path.dirname(current_dir)
|
||||
project_root = os.path.dirname(engines_dir)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from engines.dots_tts.dots_tts_downloader import DotsTTSDownloader
|
||||
from engines.dots_tts.progress_callback import DotsTTSProgressTracker
|
||||
from utils.device import resolve_torch_device
|
||||
|
||||
|
||||
class _SuppressDotsModelTypeWarning(logging.Filter):
|
||||
"""Drop the upstream bogus model-type warning while keeping real ones."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
return True
|
||||
return "model of type `dots_tts` to instantiate a model of type" not in message
|
||||
|
||||
|
||||
class DotsTTSEngine:
|
||||
"""Thin wrapper around the official DotsTtsRuntime."""
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
_normalizer_warning_shown = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "dots.tts-soar",
|
||||
device: str = "auto",
|
||||
precision: str = "auto",
|
||||
optimize: bool = False,
|
||||
max_generate_length: int = 500,
|
||||
model_dir: Optional[str] = None,
|
||||
):
|
||||
self.model_name = model_name
|
||||
self.device = resolve_torch_device(device)
|
||||
self.precision = self._resolve_precision(precision, self.device)
|
||||
self.optimize = bool(optimize)
|
||||
self.max_generate_length = int(max_generate_length)
|
||||
self.model_dir = model_dir or DotsTTSDownloader().resolve_model_path(model_name)
|
||||
self._runtime = None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_precision(precision: str, device: str) -> str:
|
||||
normalized = str(precision or "auto").lower()
|
||||
if normalized in {"bfloat16", "float16", "float32"}:
|
||||
if device == "cpu" and normalized != "float32":
|
||||
return "float32"
|
||||
return normalized
|
||||
|
||||
if device == "cpu":
|
||||
return "float32"
|
||||
|
||||
if torch.cuda.is_available():
|
||||
major, _minor = torch.cuda.get_device_capability()
|
||||
if major >= 8:
|
||||
return "bfloat16"
|
||||
return "float16"
|
||||
|
||||
def _import_runtime(self):
|
||||
importlib.invalidate_caches()
|
||||
|
||||
nodes_dir = os.path.join(project_root, "nodes")
|
||||
blocked_paths = {
|
||||
os.path.abspath(engines_dir),
|
||||
os.path.abspath(nodes_dir),
|
||||
}
|
||||
original_sys_path = list(sys.path)
|
||||
|
||||
# ComfyUI can place the suite's `engines/` directory directly on sys.path.
|
||||
# That makes our local `engines/dots_tts` package shadow the pip-installed
|
||||
# official `dots_tts` package we actually need here.
|
||||
sys.path[:] = [
|
||||
path for path in sys.path
|
||||
if os.path.abspath(path or os.getcwd()) not in blocked_paths
|
||||
]
|
||||
|
||||
stale_modules = []
|
||||
for module_name, module in list(sys.modules.items()):
|
||||
if not (module_name == "dots_tts" or module_name.startswith("dots_tts.")):
|
||||
continue
|
||||
module_file = getattr(module, "__file__", "") or ""
|
||||
if module_file and os.path.abspath(module_file).startswith(os.path.abspath(project_root)):
|
||||
stale_modules.append((module_name, module))
|
||||
del sys.modules[module_name]
|
||||
try:
|
||||
with self._text_normalizer_compat():
|
||||
from dots_tts.runtime import DotsTtsRuntime
|
||||
except Exception as e:
|
||||
for module_name, module in stale_modules:
|
||||
sys.modules.setdefault(module_name, module)
|
||||
raise ImportError(
|
||||
"Failed to import the official `dots_tts` package in the active ComfyUI Python "
|
||||
"environment. This is usually either a missing install or an import-path "
|
||||
f"collision. Active Python: {sys.executable}"
|
||||
) from e
|
||||
finally:
|
||||
sys.path[:] = original_sys_path
|
||||
return DotsTtsRuntime
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def _suppress_noisy_runtime_logs():
|
||||
"""Mute verbose Dots chatter while keeping actionable Transformers warnings."""
|
||||
dots_logger = None
|
||||
dots_logger_disabled = False
|
||||
transformers_filter = _SuppressDotsModelTypeWarning()
|
||||
transformer_loggers = []
|
||||
|
||||
try:
|
||||
from loguru import logger as dots_logger
|
||||
|
||||
dots_logger.disable("dots_tts")
|
||||
dots_logger_disabled = True
|
||||
|
||||
transformer_loggers = [
|
||||
logging.getLogger("transformers"),
|
||||
logging.getLogger("transformers.configuration_utils"),
|
||||
logging.getLogger("transformers.modeling_utils"),
|
||||
logging.getLogger("transformers.tokenization_utils_base"),
|
||||
]
|
||||
for logger in transformer_loggers:
|
||||
logger.addFilter(transformers_filter)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r".*model of type `dots_tts` to instantiate a model of type.*",
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
if dots_logger is not None and dots_logger_disabled:
|
||||
try:
|
||||
dots_logger.enable("dots_tts")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for logger in transformer_loggers:
|
||||
try:
|
||||
logger.removeFilter(transformers_filter)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def _patch_tokenizer_regex_fix():
|
||||
"""Force the upstream Dots tokenizer load to opt into the Mistral regex fix."""
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
except Exception:
|
||||
yield
|
||||
return
|
||||
|
||||
original_from_pretrained = AutoTokenizer.from_pretrained
|
||||
|
||||
def patched_from_pretrained(*args, **kwargs):
|
||||
kwargs.setdefault("fix_mistral_regex", True)
|
||||
return original_from_pretrained(*args, **kwargs)
|
||||
|
||||
AutoTokenizer.from_pretrained = patched_from_pretrained
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
AutoTokenizer.from_pretrained = original_from_pretrained
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def _text_normalizer_compat():
|
||||
"""Temporarily provide Dots' expected tn package when unavailable."""
|
||||
normalizers_available = False
|
||||
try:
|
||||
from tn.chinese.normalizer import Normalizer as _ZhNormalizer # noqa: F401
|
||||
from tn.english.normalizer import Normalizer as _EnNormalizer # noqa: F401
|
||||
normalizers_available = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if normalizers_available:
|
||||
yield
|
||||
return
|
||||
|
||||
class _NoOpNormalizer:
|
||||
def normalize(self, text: str) -> str:
|
||||
return text
|
||||
|
||||
tn_module = types.ModuleType("tn")
|
||||
chinese_module = types.ModuleType("tn.chinese")
|
||||
chinese_normalizer_module = types.ModuleType("tn.chinese.normalizer")
|
||||
english_module = types.ModuleType("tn.english")
|
||||
english_normalizer_module = types.ModuleType("tn.english.normalizer")
|
||||
|
||||
# Mark package modules as packages so nested imports work even when an
|
||||
# unrelated top-level module named `tn` is already installed.
|
||||
tn_module.__path__ = []
|
||||
chinese_module.__path__ = []
|
||||
english_module.__path__ = []
|
||||
|
||||
chinese_normalizer_module.Normalizer = _NoOpNormalizer
|
||||
english_normalizer_module.Normalizer = _NoOpNormalizer
|
||||
|
||||
chinese_module.normalizer = chinese_normalizer_module
|
||||
english_module.normalizer = english_normalizer_module
|
||||
tn_module.chinese = chinese_module
|
||||
tn_module.english = english_module
|
||||
|
||||
fallback_modules = {
|
||||
"tn": tn_module,
|
||||
"tn.chinese": chinese_module,
|
||||
"tn.chinese.normalizer": chinese_normalizer_module,
|
||||
"tn.english": english_module,
|
||||
"tn.english.normalizer": english_normalizer_module,
|
||||
}
|
||||
missing = object()
|
||||
previous_modules = {
|
||||
name: sys.modules.get(name, missing)
|
||||
for name in fallback_modules
|
||||
}
|
||||
sys.modules.update(fallback_modules)
|
||||
try:
|
||||
if not DotsTTSEngine._normalizer_warning_shown:
|
||||
print("[Dots TTS] WeTextProcessing not available; normalize_text will use a no-op fallback")
|
||||
DotsTTSEngine._normalizer_warning_shown = True
|
||||
yield
|
||||
finally:
|
||||
for name, previous in previous_modules.items():
|
||||
if previous is missing:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
def _ensure_runtime_loaded(self):
|
||||
if self._runtime is not None:
|
||||
return
|
||||
|
||||
DotsTtsRuntime = self._import_runtime()
|
||||
with self._patch_tokenizer_regex_fix(), self._suppress_noisy_runtime_logs():
|
||||
self._runtime = DotsTtsRuntime.from_pretrained(
|
||||
self.model_dir,
|
||||
precision=self.precision,
|
||||
optimize=self.optimize,
|
||||
max_generate_length=self.max_generate_length,
|
||||
)
|
||||
self._ensure_runtime_device()
|
||||
|
||||
def _current_runtime_device(self) -> Optional[str]:
|
||||
if self._runtime is None:
|
||||
return None
|
||||
try:
|
||||
first_param = next(self._runtime.model.parameters())
|
||||
return str(first_param.device)
|
||||
except Exception:
|
||||
return str(getattr(self._runtime, "device", self.device))
|
||||
|
||||
def _move_runtime_to(self, target_device: str):
|
||||
if self._runtime is None:
|
||||
return
|
||||
|
||||
device_obj = torch.device(target_device)
|
||||
runtime = self._runtime
|
||||
runtime.model = runtime.model.to(device_obj).eval()
|
||||
runtime.device = device_obj
|
||||
|
||||
def _ensure_runtime_device(self):
|
||||
if self._runtime is None:
|
||||
return
|
||||
target_device = resolve_torch_device(self.device)
|
||||
current_device = self._current_runtime_device()
|
||||
if self._devices_equivalent(current_device, target_device):
|
||||
return
|
||||
if current_device != target_device:
|
||||
print(f"🔄 Dots TTS: moving runtime from {current_device} to {target_device}")
|
||||
self._move_runtime_to(target_device)
|
||||
|
||||
@staticmethod
|
||||
def _devices_equivalent(current_device: Optional[str], target_device: Optional[str]) -> bool:
|
||||
if current_device == target_device:
|
||||
return True
|
||||
if not current_device or not target_device:
|
||||
return False
|
||||
current = str(current_device)
|
||||
target = str(target_device)
|
||||
if current.startswith("cuda") and target.startswith("cuda"):
|
||||
return current in {"cuda", "cuda:0"} and target in {"cuda", "cuda:0"}
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clear_runtime_caches(runtime: Any) -> None:
|
||||
if runtime is None:
|
||||
return
|
||||
|
||||
model = getattr(runtime, "model", None)
|
||||
if model is not None:
|
||||
try:
|
||||
if hasattr(model, "set_optimize"):
|
||||
model.set_optimize(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for attr_name in (
|
||||
"_compiled_models",
|
||||
"_prompt_feature_cache",
|
||||
"_static_generate_workspaces",
|
||||
"_fm_decode_workspaces",
|
||||
):
|
||||
try:
|
||||
cache = getattr(model, attr_name, None)
|
||||
if hasattr(cache, "clear"):
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for attr_name in (
|
||||
"_double_streaming_prompt_g_cond_cache",
|
||||
"_double_streaming_silence_audio_patch_cache",
|
||||
):
|
||||
try:
|
||||
cache = getattr(runtime, attr_name, None)
|
||||
if hasattr(cache, "clear"):
|
||||
cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def unload_runtime(self):
|
||||
"""Fully tear down the Dots runtime so Clear VRAM actually releases it."""
|
||||
runtime = self._runtime
|
||||
if runtime is None:
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if hasattr(torch.cuda, "ipc_collect"):
|
||||
try:
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
self._clear_runtime_caches(runtime)
|
||||
|
||||
model = getattr(runtime, "model", None)
|
||||
try:
|
||||
if model is not None and hasattr(model, "to"):
|
||||
model.to("cpu")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
runtime.model = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._runtime = None
|
||||
|
||||
try:
|
||||
import torch._dynamo as torch_dynamo
|
||||
|
||||
torch_dynamo.reset()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
del model
|
||||
del runtime
|
||||
gc.collect()
|
||||
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if hasattr(torch.cuda, "ipc_collect"):
|
||||
try:
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def to(self, device):
|
||||
"""ComfyUI unload/reload hook."""
|
||||
self.device = str(device) if isinstance(device, str) else str(torch.device(device))
|
||||
self._ensure_runtime_device()
|
||||
return self
|
||||
|
||||
def unload(self):
|
||||
"""Explicit unload hook used by engine-specific ComfyUI handlers."""
|
||||
self.unload_runtime()
|
||||
|
||||
def generate(
|
||||
self,
|
||||
text: str,
|
||||
prompt_audio_path: Optional[str] = None,
|
||||
prompt_text: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
num_steps: int = 10,
|
||||
guidance_scale: float = 1.2,
|
||||
speaker_scale: float = 1.5,
|
||||
normalize_text: bool = False,
|
||||
template_name: Optional[str] = "tts",
|
||||
seed: int = 42,
|
||||
) -> Dict[str, Any]:
|
||||
self._ensure_runtime_loaded()
|
||||
self._ensure_runtime_device()
|
||||
|
||||
seed_value = int(seed or 0)
|
||||
if seed_value > 0:
|
||||
torch.manual_seed(seed_value)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(seed_value)
|
||||
|
||||
chunks = []
|
||||
tracker = DotsTTSProgressTracker(
|
||||
max_generate_length=self.max_generate_length,
|
||||
sample_rate=self.SAMPLE_RATE,
|
||||
)
|
||||
start_time = time.time()
|
||||
|
||||
with self._patch_tokenizer_regex_fix(), self._suppress_noisy_runtime_logs():
|
||||
stream = self._runtime.generate_stream(
|
||||
text=text,
|
||||
prompt_audio_path=prompt_audio_path,
|
||||
prompt_text=prompt_text,
|
||||
language=language,
|
||||
template_name=template_name,
|
||||
num_steps=int(num_steps),
|
||||
guidance_scale=float(guidance_scale),
|
||||
speaker_scale=float(speaker_scale),
|
||||
normalize_text=bool(normalize_text),
|
||||
)
|
||||
for chunk in stream:
|
||||
if not isinstance(chunk, torch.Tensor):
|
||||
chunk = torch.tensor(chunk, dtype=torch.float32)
|
||||
chunk = chunk.detach().float().cpu()
|
||||
if chunk.dim() > 1:
|
||||
chunk = chunk.squeeze()
|
||||
chunks.append(chunk)
|
||||
tracker.update(chunk.shape[-1] if chunk.numel() > 0 else 0)
|
||||
|
||||
tracker.end()
|
||||
|
||||
if chunks:
|
||||
audio = torch.cat(chunks, dim=-1)
|
||||
else:
|
||||
audio = torch.zeros(0, dtype=torch.float32)
|
||||
|
||||
time_used = time.time() - start_time
|
||||
duration_seconds = audio.shape[-1] / self.SAMPLE_RATE if audio.numel() > 0 else 0.0
|
||||
rtf = time_used / duration_seconds if duration_seconds > 0 else float("inf")
|
||||
|
||||
return {
|
||||
"fid": None,
|
||||
"audio": audio,
|
||||
"sample_rate": self.SAMPLE_RATE,
|
||||
"time_used": time_used,
|
||||
"rtf": rtf,
|
||||
"profiling": None,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Language helpers for Dots TTS.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
DOTS_LANGUAGE_CODE_BY_NAME = {
|
||||
"arabic": "AR",
|
||||
"czech": "CS",
|
||||
"german": "DE",
|
||||
"greek": "EL",
|
||||
"english": "EN",
|
||||
"spanish": "ES",
|
||||
"finnish": "FI",
|
||||
"french": "FR",
|
||||
"hindi": "HI",
|
||||
"indonesian": "ID",
|
||||
"italian": "IT",
|
||||
"japanese": "JA",
|
||||
"korean": "KO",
|
||||
"dutch": "NL",
|
||||
"polish": "PL",
|
||||
"portuguese": "PT",
|
||||
"pt": "PT",
|
||||
"pt-br": "PT",
|
||||
"pt-pt": "PT",
|
||||
"po": "PT",
|
||||
"romanian": "RO",
|
||||
"russian": "RU",
|
||||
"thai": "TH",
|
||||
"turkish": "TR",
|
||||
"ukrainian": "UK",
|
||||
"uk": "UK",
|
||||
"vietnamese": "VI",
|
||||
"cantonese": "YUE",
|
||||
"yue": "YUE",
|
||||
"chinese": "ZH",
|
||||
"mandarin": "ZH",
|
||||
"zh": "ZH",
|
||||
"zh-cn": "ZH",
|
||||
"zh-tw": "ZH",
|
||||
}
|
||||
|
||||
DOTS_LANGUAGE_DISPLAY_BY_CODE = {
|
||||
"AR": "Arabic",
|
||||
"CS": "Czech",
|
||||
"DE": "German",
|
||||
"EL": "Greek",
|
||||
"EN": "English",
|
||||
"ES": "Spanish",
|
||||
"FI": "Finnish",
|
||||
"FR": "French",
|
||||
"HI": "Hindi",
|
||||
"ID": "Indonesian",
|
||||
"IT": "Italian",
|
||||
"JA": "Japanese",
|
||||
"KO": "Korean",
|
||||
"NL": "Dutch",
|
||||
"PL": "Polish",
|
||||
"PT": "Portuguese",
|
||||
"RO": "Romanian",
|
||||
"RU": "Russian",
|
||||
"TH": "Thai",
|
||||
"TR": "Turkish",
|
||||
"UK": "Ukrainian",
|
||||
"VI": "Vietnamese",
|
||||
"YUE": "Cantonese",
|
||||
"ZH": "Chinese",
|
||||
}
|
||||
|
||||
DOTS_LANGUAGE_OPTIONS = [
|
||||
"Auto",
|
||||
"None",
|
||||
"Arabic",
|
||||
"Czech",
|
||||
"German",
|
||||
"Greek",
|
||||
"English",
|
||||
"Spanish",
|
||||
"Finnish",
|
||||
"French",
|
||||
"Hindi",
|
||||
"Indonesian",
|
||||
"Italian",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Dutch",
|
||||
"Polish",
|
||||
"Portuguese",
|
||||
"Romanian",
|
||||
"Russian",
|
||||
"Thai",
|
||||
"Turkish",
|
||||
"Ukrainian",
|
||||
"Vietnamese",
|
||||
"Cantonese",
|
||||
"Chinese",
|
||||
]
|
||||
|
||||
|
||||
def normalize_dots_language(language: Optional[str]) -> Optional[str]:
|
||||
if language is None:
|
||||
return None
|
||||
|
||||
normalized = str(language).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
lowered = normalized.lower()
|
||||
if lowered in {"auto", "auto detect", "auto_detect"}:
|
||||
return "auto_detect"
|
||||
if lowered in {"none", "off", "no tag", "no language tag"}:
|
||||
return None
|
||||
|
||||
mapped = DOTS_LANGUAGE_CODE_BY_NAME.get(lowered)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
upper_value = normalized.upper()
|
||||
if upper_value in DOTS_LANGUAGE_DISPLAY_BY_CODE:
|
||||
return upper_value
|
||||
|
||||
if lowered == "yue":
|
||||
return "YUE"
|
||||
|
||||
return upper_value
|
||||
|
||||
|
||||
def format_dots_language_display(language: Optional[str]) -> str:
|
||||
normalized = normalize_dots_language(language)
|
||||
if normalized == "auto_detect":
|
||||
return "Auto"
|
||||
if normalized is None:
|
||||
return "None"
|
||||
|
||||
return DOTS_LANGUAGE_DISPLAY_BY_CODE.get(normalized, str(language).strip() or "Auto")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user