Compare commits

...
Author SHA1 Message Date
diodiogod 5a28a46010 Add audio.cpp multi-task support 2026-08-13 21:41:16 -03:00
diodiogod 211b192f4a Improve audio.cpp console feedback 2026-08-12 18:04:27 -03:00
diodiogod 4a99f15851 Add audio.cpp TTS engine integration 2026-08-12 11:50:10 -03:00
diodiogod 08e8e8c884 Version 5.8.1
Add MOSS community full-checkpoint support

Implementation details:
- Add the LAION MOSS-TTS v1.5 Voice Acting 8B community model to the engine dropdown and downloader
- Discover compatible local MOSS full checkpoints and resolve architecture from config.json
- Enable the LAION Delay checkpoint in the existing LoRA training pipeline
- Reject unsupported local MOSS layouts instead of assuming Local Transformer
- Address issue #340
2026-08-11 23:42:44 -03:00
diodiogod 46c324c1ce Version 5.8.0
Release IndexTTS 2.5 model-version support

Implementation details:
- Add the official multilingual 2.5 backend and pinned model snapshot
- Add explicit language, duration-factor, and text-normalization controls
- Preserve IndexTTS 2.0 compatibility and emotion-control features
- Fix Text and SRT processor invalidation when switching model versions
- Separate generated-audio cache entries by model and 2.5 parameters
2026-08-11 23:08:51 -03:00
diodiogod 2bc4c2a18d Add IndexTTS 2.5 support 2026-08-11 23:08:35 -03:00
diodiogod 923f7b3c32 Version 5.7.0
Release DramaBox LoRA training support

Implementation details:
- Add DramaBox dataset preparation, training configuration, and integrated trainer nodes
- Add official audio-branch preprocessing and IC-LoRA training support
- Add DramaBox LoRA adapter loading with runtime reuse and cache-aware strength changes
- Generalize shared training clip staging and dataset-row handling
- Add DramaBox training documentation and example workflow
2026-08-10 18:08:59 -03:00
diodiogod 95223f4800 Merge DramaBox LoRA training support 2026-08-10 18:08:47 -03:00
118 changed files with 20137 additions and 357 deletions
+38
View File
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.8.1] - 2026-08-11
### Added
- Add MOSS-TTS community voice-acting model support
- Add the clearly labeled LAION Voice Acting 8B community model with automatic download
- Add compatible local full-checkpoint discovery from the MOSS model folder
- Support experimental LoRA training with the LAION community checkpoint
### Changed
- Improve errors for unsupported local MOSS model layouts
## [5.8.0] - 2026-08-11
### Added
- Add IndexTTS 2.5 as a new version of the existing IndexTTS engine
- Add Chinese, English, Japanese, Spanish, and Arabic generation
- Add explicit per-segment language switching for IndexTTS 2.5
- Add official duration-factor and text-normalization controls
- Keep IndexTTS 2.0 available for workflows that prefer its voice resemblance
### Fixed
- Fix stale audio or models when switching between IndexTTS 2.0 and 2.5
## [5.7.0] - 2026-08-10
### Added
- Add integrated DramaBox LoRA model training
- Add dataset preparation and training controls for DramaBox voice adapters
- Add live training progress and loss reporting in the Model Training panel
- Add DramaBox LoRA loading and adjustable adapter strength for inference
- Add a ready-to-use DramaBox LoRA training workflow and guide
### Changed
- Improve shared speech-clip dataset staging for model training
## [5.6.5] - 2026-08-03
### Fixed
+1 -1
View File
@@ -36,7 +36,7 @@ The project code is MIT. Model weights carry their own licenses:
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 bilibili Model Use License Conditional
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
+14 -4
View File
@@ -7,7 +7,7 @@
[![Dynamic TOML Badge][version-shield]][version-url]
[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/diogogo)
# TTS Audio Suite v5.6.5
# TTS Audio Suite v5.8.1
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/diogogo)
@@ -33,7 +33,7 @@ Subtitle workflows are still a core focus: the suite can transcribe to SRT, rebu
| **VibeVoice** | 🇺🇸​🇨🇳​🇩🇪​🇪🇸​🇫🇷​🇮🇹 +21 | 5.4GB / 18GB | 90-min long-form, Native 4-speaker (Base models) |
| **Higgs Audio 2** | 🇺🇸​🇨🇳​🇩🇪​🇪🇸​🇰🇷 | ~9GB | 3 multi-speaker, CUDA graphs (55+ tokens/sec) |
| **Higgs Audio v3** | 🌐 100+ languages | ~8GB | Native inline emotion/style/prosody/SFX tags |
| **IndexTTS-2** | 🇺🇸​🇨🇳​🇯🇵 | ~4.7GB | Emotion Control: 8 vectors, Text as reference |
| **IndexTTS 2 / 2.5** | 🇺🇸​🇨🇳​🇪🇸​🇯🇵​🇸🇦 | ~4.7GB / ~5.49GB | Emotion Control: 8 vectors, Text as reference |
| **CosyVoice3** | 🇺🇸​🇨🇳​🇯🇵​🇰🇷 | ~5.4GB | Paralinguistic tags |
| **Qwen3-TTS** | 🇺🇸​🇨🇳​🇩🇪​🇪🇸​🇫🇷​🇮🇹 +4 | ~3-6GB | Voice design, ASR (Automatic Speech Recognition) |
| **Granite ASR** | 🇺🇸​🇩🇪​🇪🇸​🇫🇷​🇯🇵​🇵🇹 | ~4.6GB | Native speaker attribution / diarization (plus model variant), Native word-level timestamps (plus model variant) |
@@ -762,7 +762,7 @@ Both versions fully support character switching, language switching, and pause t
</details>
<details>
<summary><h3>IndexTTS-2 With Emotion Control</h3></summary>
<summary><h3>IndexTTS 2 / 2.5 With Emotion Control</h3></summary>
**NEW in v4.9.0**: Revolutionary IndexTTS-2 engine with advanced emotion control and dual-source emotion blending!
@@ -772,7 +772,12 @@ Both versions fully support character switching, language switching, and pause t
* **Character Voices Integration**: Use Character Voices `opt_narrator` on `emotion_audio`, including per-character `[Character:emotion_ref]` references
* **8-Emotion Vector Control**: Manual precision control over Happy, Angry, Sad, Surprised, Afraid, Disgusted, Calm, and Melancholic emotions
* **Character Tag Emotions**: Per-character audio emotion control using `[Character:emotion_ref]` syntax, blendable with vector/text emotion
* **Emotion Alpha Control**: Fine-tune emotion intensity from 0.0 (neutral) to 2.0 (maximum dramatic expression)
* **Emotion Alpha Control**: Fine-tune emotion conditioning from 0.0 to the official 1.0 maximum
* **IndexTTS-2.5 Multilingual Generation**: Explicit Chinese, English, Japanese, Spanish, and Arabic selection
* **Official 2.5 Duration Factor**: `duration_factor` scales the internal semantic feature sequence (`0.5` shorter/faster, `1.0` unchanged, `2.0` longer/slower). It is not natural prosody or exact-duration planning, does not apply to 2.0, and is not used by SRT native-duration targeting
* **Pronunciation Overrides**: Preserve official `<word|pronunciation>` annotations through suite text processing
> **2.0 versus 2.5:** Treat 2.5 as a multilingual/efficiency alternative, not an automatic voice-cloning quality upgrade. In our manual listening, legacy 2.0 preserved speaker resemblance better when transferring a strong emotion from a different reference voice; 2.5 may still be preferable for Japanese, Spanish, Arabic, or cross-lingual generation. Strong external emotion settings can reduce perceived speaker identity, so compare both models for the target voice.
**Key Features:**
@@ -985,11 +990,15 @@ Use the built-in OmniVoice preset in **📐 Visual Tag Builder** for the canonic
* **1.7B**: `MOSS-TTS-Local-Transformer`
* **v1.5 8B**: `MOSS-TTS-v1.5` — 31 languages and more stable cloning
* **Voice Acting 8B (Community - LAION)**: optional third-party full v1.5 fine-tune for expressive delivery; selecting it downloads `laion/moss-tts-v1.5-8b-voice-acting`
* **v1 8B**: `MOSS-TTS`
* **Native 8B Dialogue**: `MOSS-TTSD-v1.0`
* **Voice Designer 1.7B**: `MOSS-VoiceGenerator` — select it in the MOSS engine for Voice Designer
* **Shared Codec**: `MOSS-Audio-Tokenizer`
Compatible community full checkpoints can also be placed in `models/TTS/moss_tts/<model-name>/`.
They are listed as `local:<model-name>` and classified from `config.json`; unsupported layouts fail explicitly.
**Supported Native Input Forms (TTSD):**
* `[Character]` tags
@@ -1031,6 +1040,7 @@ Per-segment overrides are supported with `[]` parameter syntax for whole-segment
* **Initial MOSS LoRA training support is now integrated** through the unified `🎓 Model Training` flow.
* Current scope is **MOSS-TTS 8B (Delay) LoRA training** with local adapter export into `models/TTS/moss_tts/loras/`.
* The LAION Voice Acting 8B community checkpoint is accepted by the same training path because it uses the v1.5 Delay architecture, but full inference/training validation is pending community feedback.
* Dataset-building UX is still early and will need refinement, but the end-to-end workflow is functional.
</details>
+2
View File
@@ -355,6 +355,8 @@ def setup_api_routes():
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):
+27 -8
View File
@@ -687,9 +687,9 @@ engines:
reference_free_tts: { supported: true, notes: "(zero-shot)" }
- id: indextts-2
name: IndexTTS-2
models: "IndexTTS-2"
size: "~4.7GB"
name: IndexTTS 2 / 2.5
models: "IndexTTS-2, IndexTTS-2.5"
size: "~4.7GB / ~5.49GB"
license: "bilibili Model Use License"
commercial: "conditional"
@@ -704,6 +704,8 @@ engines:
- "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"
model_sources:
- component: "IndexTTS-2"
@@ -712,6 +714,12 @@ engines:
size: "Multiple files"
auto_download: true
notes: "Main TTS engine"
- component: "IndexTTS-2.5"
source_name: "IndexTeam/IndexTTS-2.5"
source_url: "https://huggingface.co/IndexTeam/IndexTTS-2.5"
size: "~5.49GB"
auto_download: true
notes: "Multilingual backend with bundled codec and official feature-duration scaling"
- component: "w2v-bert-2.0"
source_name: "facebook/w2v-bert-2.0"
source_url: "https://huggingface.co/facebook/w2v-bert-2.0"
@@ -728,16 +736,16 @@ engines:
en: { supported: true, flag: "🇺🇸", notes: "" }
zh: { supported: true, flag: "🇨🇳", notes: "" }
de: { supported: false, flag: "🇩🇪", notes: "" }
es: { supported: false, flag: "🇪🇸", notes: "" }
es: { supported: true, flag: "🇪🇸", notes: "IndexTTS-2.5" }
fr: { supported: false, flag: "🇫🇷", notes: "" }
it: { supported: false, flag: "🇮🇹", notes: "" }
ja: { supported: true, flag: "🇯🇵", notes: "?" }
ja: { supported: true, flag: "🇯🇵", notes: "IndexTTS-2.5" }
ko: { supported: false, flag: "🇰🇷", notes: "" }
ru: { supported: false, flag: "🇷🇺", notes: "" }
pt: { supported: false, flag: "🇧🇷", notes: "" }
pl: { supported: false, flag: "🇵🇱", notes: "" }
hi: { supported: false, flag: "🇮🇳", notes: "" }
ar: { supported: false, flag: "��", notes: "" }
ar: { supported: true, flag: "🇸🇦", notes: "IndexTTS-2.5" }
tr: { supported: false, flag: "🇹🇷", notes: "" }
th: { supported: false, flag: "🇹🇭", notes: "" }
no: { supported: false, flag: "🇳🇴", notes: "" }
@@ -1496,7 +1504,7 @@ engines:
- id: moss-tts
name: MOSS-TTS
models: "Local 1.7B, Delay 8B v1.5/1.0, VoiceGenerator 1.7B, SoundEffect 8B v1, TTSD 8B"
models: "Local 1.7B, Delay 8B v1.5/1.0, LAION Voice Acting 8B community fine-tune, VoiceGenerator 1.7B, SoundEffect 8B v1, TTSD 8B"
size: "~8.5GB tokenizer + ~6.1GB/17GB/18GB model"
license: "Apache-2.0"
commercial: true
@@ -1514,6 +1522,8 @@ engines:
- "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"
@@ -1539,6 +1549,12 @@ engines:
size: "~17GB"
auto_download: true
notes: "Current official 8B delay model with 31 languages and more stable voice cloning"
- component: "MOSS-TTS v1.5 Voice Acting 8B (Community - LAION)"
source_name: "laion/moss-tts-v1.5-8b-voice-acting"
source_url: "https://huggingface.co/laion/moss-tts-v1.5-8b-voice-acting"
size: "~17GB"
auto_download: true
notes: "Third-party full MOSS-TTS v1.5 fine-tune for expressive voice acting; not an official OpenMOSS model"
- component: "MOSS-VoiceGenerator"
source_name: "OpenMOSS-Team/MOSS-VoiceGenerator"
source_url: "https://huggingface.co/OpenMOSS-Team/MOSS-VoiceGenerator"
@@ -1601,7 +1617,7 @@ engines:
asr_transcribe: { supported: false, notes: "" }
emotion_control: { supported: true, notes: "(MOSS-VoiceGenerator instruction-conditioned voice design)" }
native_long_form: { supported: true, notes: "(TTSD/Delay long-form; use chunk orchestration for very long inputs)" }
community_finetunes: { supported: true, notes: "(LoRA adapter inference supported; initial integrated LoRA training support added for MOSS-TTS Delay 8B)" }
community_finetunes: { supported: true, notes: "(Compatible full local checkpoints, LAION Voice Acting 8B auto-download, and LoRA adapter inference/training supported)" }
vram_efficient: { supported: "partial", notes: "(Local 1.7B smaller; tokenizer is large)" }
speed_performance: { supported: true, notes: "Fast with CUDA/FlashAttention" }
reference_free_tts: { supported: true, notes: "(direct TTS and prompt-only generation)" }
@@ -2292,6 +2308,7 @@ model_layouts_markdown: |
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/
@@ -2308,6 +2325,8 @@ model_layouts_markdown: |
- `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.
+2 -2
View File
@@ -10,7 +10,7 @@
| **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** | Main | IndexTTS-2 | ~4.7GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | bilibili Model Use License | Emotion Control: 8 vectors, Text as reference, Audio as reference | 3 |
| **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 |
@@ -20,7 +20,7 @@
| **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, 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, 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-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 |
+2 -2
View File
@@ -2,7 +2,7 @@
## Feature Comparison Matrix
| Feature | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | Higgs Audio v3 | IndexTTS-2 | CosyVoice3 | Qwen3-TTS | Granite ASR | Step Audio EditX | Echo-TTS | Fish Audio S2 Pro | Dots TTS | DramaBox | OmniVoice | MOSS-TTS | MOSS-SoundEffect v2 | RVC |
| 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** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -15,7 +15,7 @@
| **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 | ❌ | ✅ (LoRA adapter inference supported; initial integrated LoRA training support added for MOSS-TTS Delay 8B) | ❌ | ✅ |
| **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 |
+4 -4
View File
@@ -2,21 +2,21 @@
## Language Support by Engine
| Language | Code | F5-TTS | ChatterBox | ChatterBox 23L | VibeVoice | Higgs Audio 2 | Higgs Audio v3 | IndexTTS-2 | CosyVoice3 | Qwen3-TTS | Granite ASR | Step Audio EditX | Echo-TTS | Fish Audio S2 Pro | Dots TTS | DramaBox | OmniVoice | MOSS-TTS | MOSS-SoundEffect v2 | RVC |
| 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) | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇪🇸 **Spanish** | ES | ✅ | ❌ | ✅ | ✅ (Kugel) | ✅ | ✅ | ✅ IndexTTS-2.5 | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇫🇷 **French** | FR | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇮🇹 **Italian** | IT | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇯🇵 **Japanese** | JA | ✅ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ✅ ? | ✅ | ✅ | ✅ (4.1-2b supports Japanese; plus variant does not) | ✅ | ❌ | ✅ Tier 1 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇯🇵 **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) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇪🇬 **Arabic** | AR | ❌ | ❌ | ✅ (Egyptian) | ✅ (Kugel) | ❌ | ✅ | ✅ IndexTTS-2.5 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Tier 2 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇹🇷 **Turkish** | TR | ❌ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
| 🇹🇭 **Thai** | TH | ✅ | ❌ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ (v1.5) | ❌ | ✅ |
| 🇳🇴 **Norwegian** | NO | ❌ | ✅ | ✅ | ✅ (Kugel) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
+3 -1
View File
@@ -65,11 +65,12 @@ Use this as the canonical list of model repositories/links for offline setup.
|---|---|---|---|---|
| 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
## 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 |
@@ -149,6 +150,7 @@ Use this as the canonical list of model repositories/links for offline setup.
| 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 |
+3
View File
@@ -295,6 +295,7 @@ Notes:
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/
@@ -311,6 +312,8 @@ 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.
+3
View File
@@ -9,10 +9,13 @@ Use this if `🧾 MOSS Dataset Rows` feels unclear.
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
+7 -2
View File
@@ -135,13 +135,18 @@ See the [Sound Effects Guide](SOUND_EFFECTS_GUIDE.md) for pauses, crossfades, lo
| `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-2.0 | Shared audio/vector/text emotion intensity |
| `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:
@@ -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)
+486
View File
@@ -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"]
+372
View File
@@ -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
+111
View File
@@ -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"]
+73 -31
View File
@@ -113,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:
@@ -139,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:
@@ -155,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]
@@ -232,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
)
@@ -298,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
@@ -380,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()
@@ -407,7 +441,10 @@ class IndexTTSAdapter:
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
@@ -433,9 +470,9 @@ 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
@@ -448,9 +485,9 @@ class IndexTTSAdapter:
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
@@ -474,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:
"""
@@ -593,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
+71 -18
View File
@@ -13,7 +13,7 @@ from utils.models.factory_config import ModelLoadConfig
from utils.models.extra_paths import find_model_in_paths, get_preferred_download_path, get_all_tts_model_paths
class IndexTTSEngine:
class IndexTTSEngine:
"""
IndexTTS-2 Engine wrapper for TTS Audio Suite integration.
@@ -25,7 +25,14 @@ class IndexTTSEngine:
- High-quality emotional expression
"""
EMOTION_LABELS = ["happy", "angry", "sad", "afraid", "disgusted", "melancholic", "surprised", "calm"]
EMOTION_LABELS = ["happy", "angry", "sad", "afraid", "disgusted", "melancholic", "surprised", "calm"]
LANGUAGE_CODES = {
"zh": "ZH", "zh-cn": "ZH", "chinese": "ZH", "mandarin": "ZH",
"en": "EN", "en-us": "EN", "en-gb": "EN", "english": "EN",
"ja": "JA", "jp": "JA", "japanese": "JA",
"es": "ES", "spanish": "ES",
"ar": "AR", "arabic": "AR",
}
def __init__(self, model_dir: str = "IndexTTS-2", device: str = "auto",
use_fp16: bool = True, use_cuda_kernel: Optional[bool] = None,
@@ -44,8 +51,10 @@ class IndexTTSEngine:
use_accel: Enable GPT2 acceleration with FlashAttention
low_vram: Enable Low VRAM mode (sequential offloading)
"""
# Resolve model directory using extra_model_paths
self.model_dir = self._find_model_directory(model_dir)
# Resolve model directory using extra_model_paths
self.model_dir = self._find_model_directory(model_dir)
self.model_name = os.path.basename(self.model_dir.rstrip("/\\")) or str(model_dir)
self.model_version = "2.5" if os.path.isfile(os.path.join(self.model_dir, "codec.pth")) or "2.5" in self.model_name else "2"
self.device = self._resolve_device(device)
self.use_fp16 = use_fp16 and self.device != "cpu"
@@ -134,10 +143,10 @@ class IndexTTSEngine:
return
# Create model configuration
self._model_config = ModelLoadConfig(
self._model_config = ModelLoadConfig(
engine_name="index_tts",
model_type="tts",
model_name="IndexTTS-2",
model_name=self.model_name,
device=self.device,
model_path=self.model_dir,
additional_params={
@@ -146,7 +155,8 @@ class IndexTTSEngine:
"use_deepspeed": self.use_deepspeed,
"use_torch_compile": self.use_torch_compile,
"use_accel": self.use_accel,
"low_vram": self.low_vram
"low_vram": self.low_vram,
"model_version": self.model_version,
}
)
@@ -177,8 +187,11 @@ class IndexTTSEngine:
length_penalty: float = 0.0,
num_beams: int = 3,
repetition_penalty: float = 10.0,
max_mel_tokens: int = 1500,
**kwargs
max_mel_tokens: int = 1500,
language: str = "EN",
duration_factor: float = 1.0,
text_normalization: bool = True,
**kwargs
) -> torch.Tensor:
"""
Generate speech using IndexTTS-2.
@@ -201,7 +214,10 @@ class IndexTTSEngine:
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 (0.5-2.0)
text_normalization: Enable upstream multilingual text normalization
Returns:
Generated audio as torch.Tensor with shape [1, samples]
@@ -335,9 +351,8 @@ class IndexTTSEngine:
if unsupported_keys:
print(f"⚠️ Filtering unsupported kwargs: {unsupported_keys}")
# Call IndexTTS-2 inference
result = self._tts_engine.infer(
spk_audio_prompt=speaker_audio,
infer_kwargs = dict(
spk_audio_prompt=speaker_audio,
text=text,
output_path=None,
emo_audio_prompt=emotion_audio,
@@ -356,11 +371,49 @@ class IndexTTSEngine:
length_penalty=length_penalty,
num_beams=num_beams,
repetition_penalty=repetition_penalty,
max_mel_tokens=max_mel_tokens,
**supported_kwargs
)
# Get audio tensor directly from infer result
max_mel_tokens=max_mel_tokens,
**supported_kwargs
)
if self.model_version == "2.5":
language_key = str(language or "EN").strip().lower()
language_code = self.LANGUAGE_CODES.get(language_key, str(language or "EN").upper())
if language_code not in {"ZH", "EN", "JA", "ES", "AR"}:
raise ValueError(
f"Unsupported IndexTTS-2.5 language '{language}'. "
"Choose Chinese, English, Japanese, Spanish, or Arabic."
)
duration_factor = float(duration_factor)
if not 0.5 <= duration_factor <= 2.0:
raise ValueError("IndexTTS-2.5 duration_factor must be between 0.5 and 2.0")
infer_kwargs.update(
lang=language_code,
duration_factor=duration_factor,
text_normalization=bool(text_normalization),
)
# Call the selected IndexTTS backend.
result = self._tts_engine.infer(**infer_kwargs)
if supported_kwargs.get("stream_return", False):
# TTS Audio Suite patch: Normalize native streamed int16 chunks
# instead of trying to unpack the generator as a final WAV tuple.
def normalized_stream():
for chunk in result:
if not isinstance(chunk, torch.Tensor):
continue
chunk = chunk.detach().cpu()
if chunk.dtype == torch.int16:
chunk = chunk.float() / 32767.0
else:
chunk = chunk.float()
if chunk.dim() == 1:
chunk = chunk.unsqueeze(0)
elif chunk.dim() > 2:
chunk = chunk.reshape(-1, chunk.shape[-1]).mean(dim=0, keepdim=True)
yield chunk
return normalized_stream()
# Get audio tensor directly from infer result
# infer() with output_path=None returns a tuple (sampling_rate, wav_data)
# where wav_data is a numpy array of shape (samples, channels) in int16 format
sampling_rate, wav_data = result
+40 -11
View File
@@ -57,7 +57,7 @@ class IndexTTSDownloader:
],
"description": "CampPlus speaker embedding model for IndexTTS-2"
},
"IndexTTS-2": {
"IndexTTS-2": {
"repo_id": "IndexTeam/IndexTTS-2",
"files": [
"config.yaml",
@@ -80,8 +80,36 @@ class IndexTTSDownloader:
"qwen0.6bemo4-merge/tokenizer_config.json",
"qwen0.6bemo4-merge/vocab.json"
],
"description": "IndexTTS-2 main model with emotion control"
}
"description": "IndexTTS-2 main model with emotion control"
},
"IndexTTS-2.5": {
"repo_id": "IndexTeam/IndexTTS-2.5",
# TTS Audio Suite patch: Pin the audited release snapshot because
# the upstream repository is changing rapidly immediately post-release.
"revision": "ba2480d9f7f629eb18f6acaebb357679d9ba88a4",
"files": [
"config.yaml",
"codec.pth",
"feat1.pt",
"feat2.pt",
"gpt.pth",
"s2mel.pth",
"multilingual_zh_ja_yue_char_del.tiktoken",
"wav2vec2bert_stats.pt",
"qwen0.6bemo4-merge/Modelfile",
"qwen0.6bemo4-merge/added_tokens.json",
"qwen0.6bemo4-merge/chat_template.jinja",
"qwen0.6bemo4-merge/config.json",
"qwen0.6bemo4-merge/generation_config.json",
"qwen0.6bemo4-merge/merges.txt",
"qwen0.6bemo4-merge/model.safetensors",
"qwen0.6bemo4-merge/special_tokens_map.json",
"qwen0.6bemo4-merge/tokenizer.json",
"qwen0.6bemo4-merge/tokenizer_config.json",
"qwen0.6bemo4-merge/vocab.json",
],
"description": "IndexTTS-2.5 multilingual model with official duration-factor scaling and emotion control",
}
}
def __init__(self, base_path: Optional[str] = None):
@@ -152,13 +180,14 @@ class IndexTTSDownloader:
})
# Download model files using unified downloader
result_path = self.downloader.download_huggingface_model(
repo_id=model_info["repo_id"],
model_name=model_name,
files=file_list,
engine_type="IndexTTS",
**kwargs
)
result_path = self.downloader.download_huggingface_model(
repo_id=model_info["repo_id"],
model_name=model_name,
files=file_list,
engine_type="IndexTTS",
revision=model_info.get("revision"),
**kwargs
)
if not result_path:
raise RuntimeError("HuggingFace download failed")
@@ -291,4 +320,4 @@ def download_index_tts_model(model_name: str = "IndexTTS-2",
def is_index_tts_available(model_name: str = "IndexTTS-2") -> bool:
"""Check if IndexTTS-2 model is available locally."""
return index_tts_downloader.is_model_available(model_name)
return index_tts_downloader.is_model_available(model_name)
@@ -0,0 +1 @@
# TTS Audio Suite patch: Package marker for the bundled official IndexTTS 2.5 semantic codec.
@@ -0,0 +1 @@
# TTS Audio Suite patch: Package marker for the bundled IndexTTS 2.5 codec quantizers.
@@ -0,0 +1,14 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from indextts.codec.amphion_codec.quantize.factorized_vector_quantize import (
FactorizedVectorQuantize,
)
from indextts.codec.amphion_codec.quantize.vector_quantize import VectorQuantize
from indextts.codec.amphion_codec.quantize.lookup_free_quantize import LookupFreeQuantize
from indextts.codec.amphion_codec.quantize.residual_vq import ResidualVQ
@@ -0,0 +1,153 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from torch.nn.utils import weight_norm
def WNConv1d(*args, **kwargs):
return weight_norm(nn.Conv1d(*args, **kwargs))
def WNConvTranspose1d(*args, **kwargs):
return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
class FactorizedVectorQuantize(nn.Module):
def __init__(
self,
input_dim,
codebook_size,
codebook_dim,
commitment=0.005,
codebook_loss_weight=1.0,
use_l2_normlize=True,
):
super().__init__()
self.input_dim = input_dim
self.codebook_size = codebook_size
self.codebook_dim = codebook_dim
self.commitment = commitment
self.codebook_loss_weight = codebook_loss_weight
self.use_l2_normlize = use_l2_normlize
if self.input_dim != self.codebook_dim:
self.in_project = WNConv1d(self.input_dim, self.codebook_dim, kernel_size=1)
self.out_project = WNConv1d(
self.codebook_dim, self.input_dim, kernel_size=1
)
else:
self.in_project = nn.Identity()
self.out_project = nn.Identity()
self.codebook = nn.Embedding(self.codebook_size, self.codebook_dim)
def forward(self, z):
"""
Parameters
----------
z: torch.Tensor[B x D x T]
Returns
-------
z_q: torch.Tensor[B x D x T]
Quantized continuous representation of input
commit_loss: Tensor[B]
Commitment loss to train encoder to predict vectors closer to codebook entries
codebook_loss: Tensor[B]
Codebook loss to update the codebook
indices: torch.Tensor[B x T]
Codebook indices (quantized discrete representation of input)
z_e: torch.Tensor[B x D x T]
Projected latents (continuous representation of input before quantization)
"""
# Factorized codes project input into low-dimensional space if self.input_dim != self.codebook_dim
z_e = self.in_project(z)
z_q, indices = self.decode_latents(z_e)
# Compute commitment loss and codebook loss
if self.training:
commit_loss = (
F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2])
* self.commitment
)
codebook_loss = (
F.mse_loss(z_q, z_e.detach(), reduction="none").mean([1, 2])
* self.codebook_loss_weight
)
else:
commit_loss = torch.zeros(z.shape[0], device=z.device)
codebook_loss = torch.zeros(z.shape[0], device=z.device)
z_q = z_e + (z_q - z_e).detach()
z_q = self.out_project(z_q)
return z_q, commit_loss, codebook_loss, indices, z_e
def embed_code(self, embed_id):
return F.embedding(embed_id, self.codebook.weight)
def decode_code(self, embed_id):
return self.embed_code(embed_id).transpose(1, 2)
def decode_latents(self, latents):
encodings = rearrange(latents, "b d t -> (b t) d")
codebook = self.codebook.weight
# L2 normalize encodings and codebook
if self.use_l2_normlize:
encodings = F.normalize(encodings)
codebook = F.normalize(codebook)
# Compute euclidean distance between encodings and codebook,
# if use_l2_normlize is True, the distance is equal to cosine distance
dist = (
encodings.pow(2).sum(1, keepdim=True)
- 2 * encodings @ codebook.t()
+ codebook.pow(2).sum(1, keepdim=True).t()
)
indices = rearrange((-dist).max(1)[1], "(b t) -> b t", b=latents.size(0))
z_q = self.decode_code(indices)
return z_q, indices
def vq2emb(self, vq, out_proj=True):
emb = self.decode_code(vq)
if out_proj:
emb = self.out_project(emb)
return emb
def latent2dist(self, latents):
encodings = rearrange(latents, "b d t -> (b t) d")
codebook = self.codebook.weight
# L2 normalize encodings and codebook
if self.use_l2_normlize:
encodings = F.normalize(encodings)
codebook = F.normalize(codebook)
# Compute euclidean distance between encodings and codebook,
# if use_l2_normlize is True, the distance is equal to cosine distance
dist = (
encodings.pow(2).sum(1, keepdim=True)
- 2 * encodings @ codebook.t()
+ codebook.pow(2).sum(1, keepdim=True).t()
) # (b*t, k)
indices = rearrange((-dist).max(1)[1], "(b t) -> b t", b=latents.size(0))
dist = rearrange(dist, "(b t) k -> b t k", b=latents.size(0))
z_q = self.decode_code(indices)
return -dist, indices, z_q
@@ -0,0 +1,80 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from torch.nn.utils import weight_norm
def WNConv1d(*args, **kwargs):
return weight_norm(nn.Conv1d(*args, **kwargs))
def WNConvTranspose1d(*args, **kwargs):
return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
class LookupFreeQuantize(nn.Module):
def __init__(
self,
input_dim,
codebook_size,
codebook_dim,
):
super().__init__()
self.input_dim = input_dim
self.codebook_size = codebook_size
self.codebook_dim = codebook_dim
assert 2**codebook_dim == codebook_size
if self.input_dim != self.codebook_dim:
self.in_project = WNConv1d(self.input_dim, self.codebook_dim, kernel_size=1)
self.out_project = WNConv1d(
self.codebook_dim, self.input_dim, kernel_size=1
)
else:
self.in_project = nn.Identity()
self.out_project = nn.Identity()
def forward(self, z):
z_e = self.in_project(z)
z_e = F.sigmoid(z_e)
z_q = z_e + (torch.round(z_e) - z_e).detach()
z_q = self.out_project(z_q)
commit_loss = torch.zeros(z.shape[0], device=z.device)
codebook_loss = torch.zeros(z.shape[0], device=z.device)
bits = (
2
** torch.arange(self.codebook_dim, device=z.device)
.unsqueeze(0)
.unsqueeze(-1)
.long()
) # (1, d, 1)
indices = (torch.round(z_e.clone().detach()).long() * bits).sum(1).long()
return z_q, commit_loss, codebook_loss, indices, z_e
def vq2emb(self, vq, out_proj=True):
emb = torch.zeros(
vq.shape[0], self.codebook_dim, vq.shape[-1], device=vq.device
) # (B, d, T)
for i in range(self.codebook_dim):
emb[:, i, :] = (vq % 2).float()
vq = vq // 2
if out_proj:
emb = self.out_project(emb)
return emb
@@ -0,0 +1,180 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from typing import Union
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from torch.nn.utils import weight_norm
from indextts.codec.amphion_codec.quantize.factorized_vector_quantize import (
FactorizedVectorQuantize,
)
from indextts.codec.amphion_codec.quantize.vector_quantize import VectorQuantize
from indextts.codec.amphion_codec.quantize.lookup_free_quantize import LookupFreeQuantize
class ResidualVQ(nn.Module):
"""
Introduced in SoundStream: An end2end neural audio codec
https://arxiv.org/abs/2107.03312
"""
def __init__(
self,
input_dim: int = 256,
num_quantizers: int = 8,
codebook_size: int = 1024,
codebook_dim: int = 256,
quantizer_type: str = "vq", # "vq" or "fvq" or "lfq"
quantizer_dropout: float = 0.5,
**kwargs,
):
super().__init__()
self.input_dim = input_dim
self.num_quantizers = num_quantizers
self.codebook_size = codebook_size
self.codebook_dim = codebook_dim
self.quantizer_type = quantizer_type
self.quantizer_dropout = quantizer_dropout
if quantizer_type == "vq":
VQ = VectorQuantize
elif quantizer_type == "fvq":
VQ = FactorizedVectorQuantize
elif quantizer_type == "lfq":
VQ = LookupFreeQuantize
else:
raise ValueError(f"Unknown quantizer type {quantizer_type}")
self.quantizers = nn.ModuleList(
[
VQ(
input_dim=input_dim,
codebook_size=codebook_size,
codebook_dim=codebook_dim,
**kwargs,
)
for _ in range(num_quantizers)
]
)
def forward(self, z, n_quantizers: int = None):
"""
Parameters
----------
z : Tensor[B x D x T]
n_quantizers : int, optional
No. of quantizers to use
(n_quantizers < self.n_codebooks ex: for quantizer dropout)
Note: if `self.quantizer_dropout` is True, this argument is ignored
when in training mode, and a random number of quantizers is used.
Returns
-------
"quantized_out" : Tensor[B x D x T]
Quantized continuous representation of input
"all_indices" : Tensor[N x B x T]
Codebook indices for each codebook
(quantized discrete representation of input)
"all_commit_losses" : Tensor[N]
"all_codebook_losses" : Tensor[N]
"all_quantized" : Tensor[N x B x D x T]
"""
quantized_out = 0.0
residual = z
all_commit_losses = []
all_codebook_losses = []
all_indices = []
all_quantized = []
if n_quantizers is None:
n_quantizers = self.num_quantizers
if self.training:
n_quantizers = torch.ones((z.shape[0],)) * self.num_quantizers + 1
dropout = torch.randint(1, self.num_quantizers + 1, (z.shape[0],))
n_dropout = int(z.shape[0] * self.quantizer_dropout)
n_quantizers[:n_dropout] = dropout[:n_dropout]
n_quantizers = n_quantizers.to(z.device)
for i, quantizer in enumerate(self.quantizers):
if self.training is False and i >= n_quantizers:
break
z_q_i, commit_loss_i, codebook_loss_i, indices_i, z_e_i = quantizer(
residual
)
# Create mask to apply quantizer dropout
mask = (
torch.full((z.shape[0],), fill_value=i, device=z.device) < n_quantizers
)
quantized_out = quantized_out + z_q_i * mask[:, None, None]
residual = residual - z_q_i
commit_loss_i = (commit_loss_i * mask).mean()
codebook_loss_i = (codebook_loss_i * mask).mean()
all_commit_losses.append(commit_loss_i)
all_codebook_losses.append(codebook_loss_i)
all_indices.append(indices_i)
all_quantized.append(z_q_i)
all_commit_losses, all_codebook_losses, all_indices, all_quantized = map(
torch.stack,
(all_commit_losses, all_codebook_losses, all_indices, all_quantized),
)
return (
quantized_out,
all_indices,
all_commit_losses,
all_codebook_losses,
all_quantized,
)
def vq2emb(self, vq, n_quantizers=None):
quantized_out = 0.0
if n_quantizers is None:
n_quantizers = self.num_quantizers
for idx, quantizer in enumerate(self.quantizers):
if idx >= n_quantizers:
break
quantized_out += quantizer.vq2emb(vq[idx])
return quantized_out
def latent2dist(self, z, n_quantizers=None):
quantized_out = 0.0
residual = z
all_dists = []
all_indices = []
if n_quantizers is None:
n_quantizers = self.num_quantizers
for i, quantizer in enumerate(self.quantizers):
if self.training is False and i >= n_quantizers:
break
dist_i, indices_i, z_q_i = quantizer.latent2dist(residual)
all_dists.append(dist_i)
all_indices.append(indices_i)
quantized_out = quantized_out + z_q_i
residual = residual - z_q_i
all_dists = torch.stack(all_dists)
all_indices = torch.stack(all_indices)
return all_dists, all_indices
@@ -0,0 +1,404 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange, repeat
from torch.nn.utils import weight_norm
def WNConv1d(*args, **kwargs):
return weight_norm(nn.Conv1d(*args, **kwargs))
def WNConvTranspose1d(*args, **kwargs):
return weight_norm(nn.ConvTranspose1d(*args, **kwargs))
def l2norm(t):
return F.normalize(t, p=2, dim=-1)
def ema_inplace(moving_avg, new, decay):
moving_avg.data.mul_(decay).add_(new, alpha=(1 - decay))
def laplace_smoothing(x, n_categories, eps=1e-5):
return (x + eps) / (x.sum() + n_categories * eps)
def sample_vectors(samples, num):
num_samples, device = samples.shape[0], samples.device
if num_samples >= num:
indices = torch.randperm(num_samples, device=device)[:num]
else:
indices = torch.randint(0, num_samples, (num,), device=device)
return samples[indices]
def kmeans(samples, num_clusters, num_iters=10, use_cosine_sim=False):
dim, dtype, device = samples.shape[-1], samples.dtype, samples.device
means = sample_vectors(samples, num_clusters)
for _ in range(num_iters):
if use_cosine_sim:
dists = samples @ means.t()
else:
diffs = rearrange(samples, "n d -> n () d") - rearrange(
means, "c d -> () c d"
)
dists = -(diffs**2).sum(dim=-1)
buckets = dists.max(dim=-1).indices
bins = torch.bincount(buckets, minlength=num_clusters)
zero_mask = bins == 0
bins_min_clamped = bins.masked_fill(zero_mask, 1)
new_means = buckets.new_zeros(num_clusters, dim, dtype=dtype)
new_means.scatter_add_(0, repeat(buckets, "n -> n d", d=dim), samples)
new_means = new_means / bins_min_clamped[..., None]
if use_cosine_sim:
new_means = l2norm(new_means)
means = torch.where(zero_mask[..., None], means, new_means)
return means, bins
class EuclideanCodebook(nn.Module):
def __init__(
self,
dim,
codebook_size,
kmeans_init=False,
kmeans_iters=10,
decay=0.8,
eps=1e-5,
threshold_ema_dead_code=2,
weight_init=False,
):
super().__init__()
self.decay = decay
init_fn = torch.randn if not weight_init else torch.zeros
embed = init_fn(codebook_size, dim)
if weight_init:
nn.init.uniform_(embed, -1 / codebook_size, 1 / codebook_size)
self.codebook_size = codebook_size
self.kmeans_iters = kmeans_iters
self.eps = eps
self.threshold_ema_dead_code = threshold_ema_dead_code
self.register_buffer(
"initted", torch.Tensor([not kmeans_init])
) # if kmeans_init is True, then initted is False; otherwise, initted is True
self.register_buffer("cluster_size", torch.zeros(codebook_size))
self.register_buffer("embed", embed)
self.register_buffer("embed_avg", embed.clone())
def init_embed_(self, data):
embed, cluster_size = kmeans(data, self.codebook_size, self.kmeans_iters)
self.embed.data.copy_(embed)
self.embed_avg.data.copy_(embed)
self.cluster_size.data.copy_(cluster_size)
self.initted.data.copy_(torch.Tensor([True]))
def replace(self, samples, mask):
modified_codebook = torch.where(
mask[..., None], sample_vectors(samples, self.codebook_size), self.embed
)
self.embed.data.copy_(modified_codebook)
def expire_codes_(self, batch_samples):
if self.threshold_ema_dead_code == 0:
return
expired_codes = self.cluster_size < self.threshold_ema_dead_code
if not torch.any(expired_codes):
return
batch_samples = rearrange(batch_samples, "... d -> (...) d")
self.replace(batch_samples, mask=expired_codes)
def forward(self, x):
shape, dtype = x.shape, x.dtype
flatten = rearrange(x, "... d -> (...) d")
embed = self.embed.t() # (codebook_size, dim) -> (dim, codebook_size)
if not self.initted:
self.init_embed_(flatten)
dist = -(
flatten.pow(2).sum(1, keepdim=True)
- 2 * flatten @ embed
+ embed.pow(2).sum(0, keepdim=True)
)
embed_ind = dist.max(dim=-1).indices
embed_onehot = F.one_hot(embed_ind, self.codebook_size).type(dtype)
embed_ind = embed_ind.view(*shape[:-1])
quantize = F.embedding(embed_ind, self.embed)
if self.training:
ema_inplace(self.cluster_size, embed_onehot.sum(0), self.decay)
embed_sum = (
flatten.t() @ embed_onehot
) # (dim, ...) @ (..., codebook_size) -> (dim, codebook_size)
ema_inplace(self.embed_avg, embed_sum.t(), self.decay)
cluster_size = (
laplace_smoothing(self.cluster_size, self.codebook_size, self.eps)
* self.cluster_size.sum()
)
embed_normalized = self.embed_avg / cluster_size.unsqueeze(1)
self.embed.data.copy_(embed_normalized)
self.expire_codes_(x)
return quantize, embed_ind
def vq2emb(self, vq):
quantize = F.embedding(vq, self.embed)
return quantize
def latent2dist(self, x):
shape, dtype = x.shape, x.dtype
flatten = rearrange(x, "... d -> (...) d")
embed = self.embed.t() # (codebook_size, dim) -> (dim, codebook_size)
if not self.initted:
self.init_embed_(flatten)
dist = -(
flatten.pow(2).sum(1, keepdim=True)
- 2 * flatten @ embed
+ embed.pow(2).sum(0, keepdim=True)
)
embed_ind = dist.max(dim=-1).indices
embed_ind = embed_ind.view(*shape[:-1])
quantize = F.embedding(embed_ind, self.embed)
dist = dist.view(*shape[:-1], -1)
return dist, embed_ind, quantize
class SimpleCodebook(nn.Module):
def __init__(
self,
dim,
codebook_size,
use_l2_normlize=False,
):
super().__init__()
self.dim = dim
self.codebook_size = codebook_size
self.use_l2_normlize = use_l2_normlize
self.embed = nn.Embedding(self.codebook_size, self.dim)
def forward(self, x):
shape, dtype = x.shape, x.dtype
flatten = rearrange(x, "... d -> (...) d")
embed = self.embed.weight.t() # (codebook_size, dim) -> (dim, codebook_size)
if self.use_l2_normlize:
flatten = F.normalize(flatten)
embed = F.normalize(embed)
dist = -(
flatten.pow(2).sum(1, keepdim=True)
- 2 * flatten @ embed
+ embed.pow(2).sum(0, keepdim=True)
)
embed_ind = dist.max(dim=-1).indices
embed_ind = embed_ind.view(*shape[:-1])
quantize = F.embedding(embed_ind, self.embed)
return quantize, embed_ind
def vq2emb(self, vq):
quantize = F.embedding(vq, self.embed.weight)
return quantize
def latent2dist(self, x):
shape, dtype = x.shape, x.dtype
flatten = rearrange(x, "... d -> (...) d")
embed = self.embed.weight.t() # (codebook_size, dim) -> (dim, codebook_size)
if self.use_l2_normlize:
flatten = F.normalize(flatten)
embed = F.normalize(embed)
dist = -(
flatten.pow(2).sum(1, keepdim=True)
- 2 * flatten @ embed
+ embed.pow(2).sum(0, keepdim=True)
)
embed_ind = dist.max(dim=-1).indices
embed_ind = embed_ind.view(*shape[:-1])
quantize = F.embedding(embed_ind, self.embed)
dist = dist.view(*shape[:-1], -1)
return dist, embed_ind, quantize
class VectorQuantize(nn.Module):
"""Vector quantization and factorized vecotor quantization implementation
Args:
input_dim (int): Dimension of input.
codebook_size (int): Codebook size.
codebook_dim (int): Codebook dimension. We suggest use codebook_dim = input_dim
if use codebook_type == "euclidean", otherwise, if you want to use
factorized vector quantization, use codebook_dim as small number (e.g. 8 or 32).
commitment (float): Weight for commitment loss.
use_l2_normlize (bool): Whether to use l2 normlized codes for factorized vecotor quantization,
we suggest use it as True if you want to use factorized vector quantization
kmeans_init (bool): Whether to use kmeans to initialize the codebooks.
kmeans_iters (int): Number of iterations used for kmeans initialization.
decay (float): Decay for exponential moving average over the codebooks.
epsilon (float): Epsilon value for numerical stability.
threshold_ema_dead_code (int): Threshold for dead code expiration. Replace any codes
that have an exponential moving average cluster size less than the specified threshold with
randomly selected vector from the current batch.
"""
def __init__(
self,
input_dim,
codebook_size,
codebook_dim,
commitment=0.005,
codebook_loss_weight=1.0,
use_l2_normlize=False,
codebook_type="euclidean", # "euclidean" or "simple"
kmeans_init=False,
kmeans_iters=10,
decay=0.8,
eps=1e-5,
threshold_ema_dead_code=2,
weight_init=False,
):
super().__init__()
self.input_dim = input_dim
self.codebook_size = codebook_size
self.codebook_dim = codebook_dim
self.commitment = commitment
self.codebook_loss_weight = codebook_loss_weight
self.use_l2_normlize = use_l2_normlize
self.codebook_type = codebook_type
self.kmeans_init = kmeans_init
self.kmeans_iters = kmeans_iters
self.decay = decay
self.eps = eps
self.threshold_ema_dead_code = threshold_ema_dead_code
self.weight_init = weight_init
if self.input_dim != self.codebook_dim:
self.in_project = WNConv1d(self.input_dim, self.codebook_dim, kernel_size=1)
self.out_project = WNConv1d(
self.codebook_dim, self.input_dim, kernel_size=1
)
else:
self.in_project = nn.Identity()
self.out_project = nn.Identity()
if self.codebook_type == "euclidean":
self.codebook = EuclideanCodebook(
self.codebook_dim,
codebook_size=self.codebook_size,
kmeans_init=self.kmeans_init,
kmeans_iters=self.kmeans_iters,
decay=self.decay,
eps=self.eps,
threshold_ema_dead_code=self.threshold_ema_dead_code,
weight_init=self.weight_init,
)
elif self.codebook_type == "simple":
self.codebook = SimpleCodebook(
self.codebook_dim,
codebook_size=self.codebook_size,
use_l2_normlize=self.use_l2_normlize,
)
else:
raise NotImplementedError(
f"codebook_type {self.codebook_type} is not implemented!"
)
def forward(self, z):
"""
Parameters
----------
z: torch.Tensor[B x D x T]
Returns
-------
z_q: torch.Tensor[B x D x T]
Quantized continuous representation of input
commit_loss: Tensor[B]
Commitment loss to train encoder to predict vectors closer to codebook entries
codebook_loss: Tensor[B]
Codebook loss to update the codebook
indices: torch.Tensor[B x T]
Codebook indices (quantized discrete representation of input)
z_e: torch.Tensor[B x D x T]
Projected latents (continuous representation of input before quantization)
"""
# Factorized codes project input into low-dimensional space if self.input_dim != self.codebook_dim
z_e = self.in_project(z)
z_q, indices = self.decode_latents(z_e)
# Compute commitment loss and codebook loss
if self.training:
commit_loss = (
F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2])
* self.commitment
)
codebook_loss = (
F.mse_loss(z_q, z_e.detach(), reduction="none").mean([1, 2])
* self.codebook_loss_weight
)
else:
commit_loss = torch.zeros(z.shape[0], device=z.device)
codebook_loss = torch.zeros(z.shape[0], device=z.device)
z_q = z_e + (z_q - z_e).detach()
z_q = self.out_project(z_q)
return z_q, commit_loss, codebook_loss, indices, z_e
def decode_latents(self, latents):
encodings = rearrange(latents, "b d t -> b t d")
z_q, indices = self.codebook(encodings)
z_q = z_q.transpose(1, 2)
return z_q, indices
def vq2emb(self, vq, out_proj=True):
emb = self.codebook.vq2emb(vq)
emb = emb.transpose(1, 2)
if out_proj:
emb = self.out_project(emb)
return emb
def latent2dist(self, latents):
latents = rearrange(latents, "b d t -> b t d")
dist, embed_ind, quantize = self.codebook.latent2dist(latents)
return dist, embed_ind, quantize.transpose(1, 2)
@@ -0,0 +1 @@
# TTS Audio Suite patch: Package marker for the bundled IndexTTS 2.5 Vocos codec.
@@ -0,0 +1,853 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from typing import Optional, Tuple
import numpy as np
import scipy
import torch
from torch import nn, view_as_real, view_as_complex
from torch import nn
from torch.nn.utils import weight_norm, remove_weight_norm
from torchaudio.functional.functional import _hz_to_mel, _mel_to_hz
def safe_log(x: torch.Tensor, clip_val: float = 1e-7) -> torch.Tensor:
"""
Computes the element-wise logarithm of the input tensor with clipping to avoid near-zero values.
Args:
x (Tensor): Input tensor.
clip_val (float, optional): Minimum value to clip the input tensor. Defaults to 1e-7.
Returns:
Tensor: Element-wise logarithm of the input tensor with clipping applied.
"""
return torch.log(torch.clip(x, min=clip_val))
def symlog(x: torch.Tensor) -> torch.Tensor:
return torch.sign(x) * torch.log1p(x.abs())
def symexp(x: torch.Tensor) -> torch.Tensor:
return torch.sign(x) * (torch.exp(x.abs()) - 1)
class STFT(nn.Module):
def __init__(
self,
n_fft: int,
hop_length: int,
win_length: int,
center=True,
):
super().__init__()
self.center = center
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
window = torch.hann_window(win_length)
self.register_buffer("window", window)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (B, T * hop_length)
if not self.center:
pad = self.win_length - self.hop_length
x = torch.nn.functional.pad(x, (pad // 2, pad // 2), mode="reflect")
stft_spec = torch.stft(
x,
self.n_fft,
hop_length=self.hop_length,
win_length=self.win_length,
window=self.window,
center=self.center,
return_complex=False,
) # (B, n_fft // 2 + 1, T, 2)
rea = stft_spec[:, :, :, 0] # (B, n_fft // 2 + 1, T, 2)
imag = stft_spec[:, :, :, 1] # (B, n_fft // 2 + 1, T, 2)
log_mag = torch.log(
torch.abs(torch.sqrt(torch.pow(rea, 2) + torch.pow(imag, 2))) + 1e-5
) # (B, n_fft // 2 + 1, T)
phase = torch.atan2(imag, rea) # (B, n_fft // 2 + 1, T)
return log_mag, phase
class ISTFT(nn.Module):
"""
Custom implementation of ISTFT since torch.istft doesn't allow custom padding (other than `center=True`) with
windowing. This is because the NOLA (Nonzero Overlap Add) check fails at the edges.
See issue: https://github.com/pytorch/pytorch/issues/62323
Specifically, in the context of neural vocoding we are interested in "same" padding analogous to CNNs.
The NOLA constraint is met as we trim padded samples anyway.
Args:
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames.
win_length (int): The size of window frame and STFT filter.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(
self, n_fft: int, hop_length: int, win_length: int, padding: str = "same"
):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.n_fft = n_fft
self.hop_length = hop_length
self.win_length = win_length
window = torch.hann_window(win_length)
self.register_buffer("window", window)
def forward(self, spec: torch.Tensor) -> torch.Tensor:
"""
Compute the Inverse Short Time Fourier Transform (ISTFT) of a complex spectrogram.
Args:
spec (Tensor): Input complex spectrogram of shape (B, N, T), where B is the batch size,
N is the number of frequency bins, and T is the number of time frames.
Returns:
Tensor: Reconstructed time-domain signal of shape (B, L), where L is the length of the output signal.
"""
if self.padding == "center":
# Fallback to pytorch native implementation
return torch.istft(
spec,
self.n_fft,
self.hop_length,
self.win_length,
self.window,
center=True,
)
elif self.padding == "same":
pad = (self.win_length - self.hop_length) // 2
else:
raise ValueError("Padding must be 'center' or 'same'.")
assert spec.dim() == 3, "Expected a 3D tensor as input"
B, N, T = spec.shape
# Inverse FFT
ifft = torch.fft.irfft(spec, self.n_fft, dim=1, norm="backward")
ifft = ifft * self.window[None, :, None]
# Overlap and Add
output_size = (T - 1) * self.hop_length + self.win_length
y = torch.nn.functional.fold(
ifft,
output_size=(1, output_size),
kernel_size=(1, self.win_length),
stride=(1, self.hop_length),
)[:, 0, 0, pad:-pad]
# Window envelope
window_sq = self.window.square().expand(1, T, -1).transpose(1, 2)
window_envelope = torch.nn.functional.fold(
window_sq,
output_size=(1, output_size),
kernel_size=(1, self.win_length),
stride=(1, self.hop_length),
).squeeze()[pad:-pad]
# Normalize
assert (window_envelope > 1e-11).all()
y = y / window_envelope
return y
class MDCT(nn.Module):
"""
Modified Discrete Cosine Transform (MDCT) module.
Args:
frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, frame_len: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.frame_len = frame_len
N = frame_len // 2
n0 = (N + 1) / 2
window = torch.from_numpy(scipy.signal.cosine(frame_len)).float()
self.register_buffer("window", window)
pre_twiddle = torch.exp(-1j * torch.pi * torch.arange(frame_len) / frame_len)
post_twiddle = torch.exp(-1j * torch.pi * n0 * (torch.arange(N) + 0.5) / N)
# view_as_real: NCCL Backend does not support ComplexFloat data type
# https://github.com/pytorch/pytorch/issues/71613
self.register_buffer("pre_twiddle", view_as_real(pre_twiddle))
self.register_buffer("post_twiddle", view_as_real(post_twiddle))
def forward(self, audio: torch.Tensor) -> torch.Tensor:
"""
Apply the Modified Discrete Cosine Transform (MDCT) to the input audio.
Args:
audio (Tensor): Input audio waveform of shape (B, T), where B is the batch size
and T is the length of the audio.
Returns:
Tensor: MDCT coefficients of shape (B, L, N), where L is the number of output frames
and N is the number of frequency bins.
"""
if self.padding == "center":
audio = torch.nn.functional.pad(
audio, (self.frame_len // 2, self.frame_len // 2)
)
elif self.padding == "same":
# hop_length is 1/2 frame_len
audio = torch.nn.functional.pad(
audio, (self.frame_len // 4, self.frame_len // 4)
)
else:
raise ValueError("Padding must be 'center' or 'same'.")
x = audio.unfold(-1, self.frame_len, self.frame_len // 2)
N = self.frame_len // 2
x = x * self.window.expand(x.shape)
X = torch.fft.fft(
x * view_as_complex(self.pre_twiddle).expand(x.shape), dim=-1
)[..., :N]
res = X * view_as_complex(self.post_twiddle).expand(X.shape) * np.sqrt(1 / N)
return torch.real(res) * np.sqrt(2)
class IMDCT(nn.Module):
"""
Inverse Modified Discrete Cosine Transform (IMDCT) module.
Args:
frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, frame_len: int, padding: str = "same"):
super().__init__()
if padding not in ["center", "same"]:
raise ValueError("Padding must be 'center' or 'same'.")
self.padding = padding
self.frame_len = frame_len
N = frame_len // 2
n0 = (N + 1) / 2
window = torch.from_numpy(scipy.signal.cosine(frame_len)).float()
self.register_buffer("window", window)
pre_twiddle = torch.exp(1j * torch.pi * n0 * torch.arange(N * 2) / N)
post_twiddle = torch.exp(1j * torch.pi * (torch.arange(N * 2) + n0) / (N * 2))
self.register_buffer("pre_twiddle", view_as_real(pre_twiddle))
self.register_buffer("post_twiddle", view_as_real(post_twiddle))
def forward(self, X: torch.Tensor) -> torch.Tensor:
"""
Apply the Inverse Modified Discrete Cosine Transform (IMDCT) to the input MDCT coefficients.
Args:
X (Tensor): Input MDCT coefficients of shape (B, L, N), where B is the batch size,
L is the number of frames, and N is the number of frequency bins.
Returns:
Tensor: Reconstructed audio waveform of shape (B, T), where T is the length of the audio.
"""
B, L, N = X.shape
Y = torch.zeros((B, L, N * 2), dtype=X.dtype, device=X.device)
Y[..., :N] = X
Y[..., N:] = -1 * torch.conj(torch.flip(X, dims=(-1,)))
y = torch.fft.ifft(
Y * view_as_complex(self.pre_twiddle).expand(Y.shape), dim=-1
)
y = (
torch.real(y * view_as_complex(self.post_twiddle).expand(y.shape))
* np.sqrt(N)
* np.sqrt(2)
)
result = y * self.window.expand(y.shape)
output_size = (1, (L + 1) * N)
audio = torch.nn.functional.fold(
result.transpose(1, 2),
output_size=output_size,
kernel_size=(1, self.frame_len),
stride=(1, self.frame_len // 2),
)[:, 0, 0, :]
if self.padding == "center":
pad = self.frame_len // 2
elif self.padding == "same":
pad = self.frame_len // 4
else:
raise ValueError("Padding must be 'center' or 'same'.")
audio = audio[:, pad:-pad]
return audio
class FourierHead(nn.Module):
"""Base class for inverse fourier modules."""
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
raise NotImplementedError("Subclasses must implement the forward method.")
class ISTFTHead(FourierHead):
"""
ISTFT Head module for predicting STFT complex coefficients.
Args:
dim (int): Hidden dimension of the model.
n_fft (int): Size of Fourier transform.
hop_length (int): The distance between neighboring sliding window frames, which should align with
the resolution of the input features.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
"""
def __init__(self, dim: int, n_fft: int, hop_length: int, padding: str = "same"):
super().__init__()
out_dim = n_fft + 2
self.out = torch.nn.Linear(dim, out_dim)
self.istft = ISTFT(
n_fft=n_fft, hop_length=hop_length, win_length=n_fft, padding=padding
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the ISTFTHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x).transpose(1, 2)
mag, p = x.chunk(2, dim=1)
mag = torch.exp(mag)
mag = torch.clip(
mag, max=1e2
) # safeguard to prevent excessively large magnitudes
# wrapping happens here. These two lines produce real and imaginary value
x = torch.cos(p)
y = torch.sin(p)
# recalculating phase here does not produce anything new
# only costs time
# phase = torch.atan2(y, x)
# S = mag * torch.exp(phase * 1j)
# better directly produce the complex value
S = mag * (x + 1j * y)
audio = self.istft(S)
return audio
class IMDCTSymExpHead(FourierHead):
"""
IMDCT Head module for predicting MDCT coefficients with symmetric exponential function
Args:
dim (int): Hidden dimension of the model.
mdct_frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
sample_rate (int, optional): The sample rate of the audio. If provided, the last layer will be initialized
based on perceptual scaling. Defaults to None.
clip_audio (bool, optional): Whether to clip the audio output within the range of [-1.0, 1.0]. Defaults to False.
"""
def __init__(
self,
dim: int,
mdct_frame_len: int,
padding: str = "same",
sample_rate: Optional[int] = None,
clip_audio: bool = False,
):
super().__init__()
out_dim = mdct_frame_len // 2
self.out = nn.Linear(dim, out_dim)
self.imdct = IMDCT(frame_len=mdct_frame_len, padding=padding)
self.clip_audio = clip_audio
if sample_rate is not None:
# optionally init the last layer following mel-scale
m_max = _hz_to_mel(sample_rate // 2)
m_pts = torch.linspace(0, m_max, out_dim)
f_pts = _mel_to_hz(m_pts)
scale = 1 - (f_pts / f_pts.max())
with torch.no_grad():
self.out.weight.mul_(scale.view(-1, 1))
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the IMDCTSymExpHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x)
x = symexp(x)
x = torch.clip(
x, min=-1e2, max=1e2
) # safeguard to prevent excessively large magnitudes
audio = self.imdct(x)
if self.clip_audio:
audio = torch.clip(x, min=-1.0, max=1.0)
return audio
class IMDCTCosHead(FourierHead):
"""
IMDCT Head module for predicting MDCT coefficients with parametrizing MDCT = exp(m) · cos(p)
Args:
dim (int): Hidden dimension of the model.
mdct_frame_len (int): Length of the MDCT frame.
padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same".
clip_audio (bool, optional): Whether to clip the audio output within the range of [-1.0, 1.0]. Defaults to False.
"""
def __init__(
self,
dim: int,
mdct_frame_len: int,
padding: str = "same",
clip_audio: bool = False,
):
super().__init__()
self.clip_audio = clip_audio
self.out = nn.Linear(dim, mdct_frame_len)
self.imdct = IMDCT(frame_len=mdct_frame_len, padding=padding)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Forward pass of the IMDCTCosHead module.
Args:
x (Tensor): Input tensor of shape (B, L, H), where B is the batch size,
L is the sequence length, and H denotes the model dimension.
Returns:
Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal.
"""
x = self.out(x)
m, p = x.chunk(2, dim=2)
m = torch.exp(m).clip(
max=1e2
) # safeguard to prevent excessively large magnitudes
audio = self.imdct(m * torch.cos(p))
if self.clip_audio:
audio = torch.clip(x, min=-1.0, max=1.0)
return audio
class ConvNeXtBlock(nn.Module):
"""ConvNeXt Block adapted from https://github.com/facebookresearch/ConvNeXt to 1D audio signal.
Args:
dim (int): Number of input channels.
intermediate_dim (int): Dimensionality of the intermediate layer.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
adanorm_num_embeddings (int, optional): Number of embeddings for AdaLayerNorm.
None means non-conditional LayerNorm. Defaults to None.
"""
def __init__(
self,
dim: int,
intermediate_dim: int,
layer_scale_init_value: float,
adanorm_num_embeddings: Optional[int] = None,
):
super().__init__()
self.dwconv = nn.Conv1d(
dim, dim, kernel_size=7, padding=3, groups=dim
) # depthwise conv
self.adanorm = adanorm_num_embeddings is not None
if adanorm_num_embeddings:
self.norm = AdaLayerNorm(adanorm_num_embeddings, dim, eps=1e-6)
else:
self.norm = nn.LayerNorm(dim, eps=1e-6)
self.pwconv1 = nn.Linear(
dim, intermediate_dim
) # pointwise/1x1 convs, implemented with linear layers
self.act = nn.GELU()
self.pwconv2 = nn.Linear(intermediate_dim, dim)
self.gamma = (
nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True)
if layer_scale_init_value > 0
else None
)
def forward(
self, x: torch.Tensor, cond_embedding_id: Optional[torch.Tensor] = None
) -> torch.Tensor:
residual = x
x = self.dwconv(x)
x = x.transpose(1, 2) # (B, C, T) -> (B, T, C)
if self.adanorm:
assert cond_embedding_id is not None
x = self.norm(x, cond_embedding_id)
else:
x = self.norm(x)
x = self.pwconv1(x)
x = self.act(x)
x = self.pwconv2(x)
if self.gamma is not None:
x = self.gamma * x
x = x.transpose(1, 2) # (B, T, C) -> (B, C, T)
x = residual + x
return x
class AdaLayerNorm(nn.Module):
"""
Adaptive Layer Normalization module with learnable embeddings per `num_embeddings` classes
Args:
num_embeddings (int): Number of embeddings.
embedding_dim (int): Dimension of the embeddings.
"""
def __init__(self, num_embeddings: int, embedding_dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.dim = embedding_dim
self.scale = nn.Embedding(
num_embeddings=num_embeddings, embedding_dim=embedding_dim
)
self.shift = nn.Embedding(
num_embeddings=num_embeddings, embedding_dim=embedding_dim
)
torch.nn.init.ones_(self.scale.weight)
torch.nn.init.zeros_(self.shift.weight)
def forward(self, x: torch.Tensor, cond_embedding_id: torch.Tensor) -> torch.Tensor:
scale = self.scale(cond_embedding_id)
shift = self.shift(cond_embedding_id)
x = nn.functional.layer_norm(x, (self.dim,), eps=self.eps)
x = x * scale + shift
return x
class ResBlock1(nn.Module):
"""
ResBlock adapted from HiFi-GAN V1 (https://github.com/jik876/hifi-gan) with dilated 1D convolutions,
but without upsampling layers.
Args:
dim (int): Number of input channels.
kernel_size (int, optional): Size of the convolutional kernel. Defaults to 3.
dilation (tuple[int], optional): Dilation factors for the dilated convolutions.
Defaults to (1, 3, 5).
lrelu_slope (float, optional): Negative slope of the LeakyReLU activation function.
Defaults to 0.1.
layer_scale_init_value (float, optional): Initial value for the layer scale. None means no scaling.
Defaults to None.
"""
def __init__(
self,
dim: int,
kernel_size: int = 3,
dilation: Tuple[int, int, int] = (1, 3, 5),
lrelu_slope: float = 0.1,
layer_scale_init_value: Optional[float] = None,
):
super().__init__()
self.lrelu_slope = lrelu_slope
self.convs1 = nn.ModuleList(
[
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[0],
padding=self.get_padding(kernel_size, dilation[0]),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[1],
padding=self.get_padding(kernel_size, dilation[1]),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=dilation[2],
padding=self.get_padding(kernel_size, dilation[2]),
)
),
]
)
self.convs2 = nn.ModuleList(
[
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
weight_norm(
nn.Conv1d(
dim,
dim,
kernel_size,
1,
dilation=1,
padding=self.get_padding(kernel_size, 1),
)
),
]
)
self.gamma = nn.ParameterList(
[
(
nn.Parameter(
layer_scale_init_value * torch.ones(dim, 1), requires_grad=True
)
if layer_scale_init_value is not None
else None
),
(
nn.Parameter(
layer_scale_init_value * torch.ones(dim, 1), requires_grad=True
)
if layer_scale_init_value is not None
else None
),
(
nn.Parameter(
layer_scale_init_value * torch.ones(dim, 1), requires_grad=True
)
if layer_scale_init_value is not None
else None
),
]
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
for c1, c2, gamma in zip(self.convs1, self.convs2, self.gamma):
xt = torch.nn.functional.leaky_relu(x, negative_slope=self.lrelu_slope)
xt = c1(xt)
xt = torch.nn.functional.leaky_relu(xt, negative_slope=self.lrelu_slope)
xt = c2(xt)
if gamma is not None:
xt = gamma * xt
x = xt + x
return x
def remove_weight_norm(self):
for l in self.convs1:
remove_weight_norm(l)
for l in self.convs2:
remove_weight_norm(l)
@staticmethod
def get_padding(kernel_size: int, dilation: int = 1) -> int:
return int((kernel_size * dilation - dilation) / 2)
class Backbone(nn.Module):
"""Base class for the generator's backbone. It preserves the same temporal resolution across all layers."""
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
"""
Args:
x (Tensor): Input tensor of shape (B, C, L), where B is the batch size,
C denotes output features, and L is the sequence length.
Returns:
Tensor: Output of shape (B, L, H), where B is the batch size, L is the sequence length,
and H denotes the model dimension.
"""
raise NotImplementedError("Subclasses must implement the forward method.")
class VocosBackbone(Backbone):
"""
Vocos backbone module built with ConvNeXt blocks. Supports additional conditioning with Adaptive Layer Normalization
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
intermediate_dim (int): Intermediate dimension used in ConvNeXtBlock.
num_layers (int): Number of ConvNeXtBlock layers.
layer_scale_init_value (float, optional): Initial value for layer scaling. Defaults to `1 / num_layers`.
adanorm_num_embeddings (int, optional): Number of embeddings for AdaLayerNorm.
None means non-conditional model. Defaults to None.
"""
def __init__(
self,
input_channels: int,
dim: int,
intermediate_dim: int,
num_layers: int,
layer_scale_init_value: Optional[float] = None,
adanorm_num_embeddings: Optional[int] = None,
):
super().__init__()
self.input_channels = input_channels
self.embed = nn.Conv1d(input_channels, dim, kernel_size=7, padding=3)
self.adanorm = adanorm_num_embeddings is not None
if adanorm_num_embeddings:
self.norm = AdaLayerNorm(adanorm_num_embeddings, dim, eps=1e-6)
else:
self.norm = nn.LayerNorm(dim, eps=1e-6)
layer_scale_init_value = layer_scale_init_value or 1 / num_layers
self.convnext = nn.ModuleList(
[
ConvNeXtBlock(
dim=dim,
intermediate_dim=intermediate_dim,
layer_scale_init_value=layer_scale_init_value,
adanorm_num_embeddings=adanorm_num_embeddings,
)
for _ in range(num_layers)
]
)
self.final_layer_norm = nn.LayerNorm(dim, eps=1e-6)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, (nn.Conv1d, nn.Linear)):
nn.init.trunc_normal_(m.weight, std=0.02)
nn.init.constant_(m.bias, 0)
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
bandwidth_id = kwargs.get("bandwidth_id", None)
x = self.embed(x)
if self.adanorm:
assert bandwidth_id is not None
x = self.norm(x.transpose(1, 2), cond_embedding_id=bandwidth_id)
else:
x = self.norm(x.transpose(1, 2))
x = x.transpose(1, 2)
for conv_block in self.convnext:
x = conv_block(x, cond_embedding_id=bandwidth_id)
x = self.final_layer_norm(x.transpose(1, 2))
return x
class VocosResNetBackbone(Backbone):
"""
Vocos backbone module built with ResBlocks.
Args:
input_channels (int): Number of input features channels.
dim (int): Hidden dimension of the model.
num_blocks (int): Number of ResBlock1 blocks.
layer_scale_init_value (float, optional): Initial value for layer scaling. Defaults to None.
"""
def __init__(
self,
input_channels,
dim,
num_blocks,
layer_scale_init_value=None,
):
super().__init__()
self.input_channels = input_channels
self.embed = weight_norm(
nn.Conv1d(input_channels, dim, kernel_size=3, padding=1)
)
layer_scale_init_value = layer_scale_init_value or 1 / num_blocks / 3
self.resnet = nn.Sequential(
*[
ResBlock1(dim=dim, layer_scale_init_value=layer_scale_init_value)
for _ in range(num_blocks)
]
)
def forward(self, x: torch.Tensor, **kwargs) -> torch.Tensor:
x = self.embed(x)
x = self.resnet(x)
x = x.transpose(1, 2)
return x
class Vocos(nn.Module):
def __init__(
self,
input_channels: int = 256,
dim: int = 384,
intermediate_dim: int = 1152,
num_layers: int = 8,
adanorm_num_embeddings: int = 4,
n_fft: int = 800,
hop_size: int = 200,
padding: str = "same",
):
super().__init__()
self.backbone = VocosBackbone(
input_channels=input_channels,
dim=dim,
intermediate_dim=intermediate_dim,
num_layers=num_layers,
adanorm_num_embeddings=adanorm_num_embeddings,
)
self.head = ISTFTHead(dim, n_fft, hop_size, padding)
def forward(self, x):
x = self.backbone(x)
x = self.head(x)
return x[:, None, :]
@@ -0,0 +1,10 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
from indextts.utils.maskgct.models.codec.kmeans.repcodec_model import RepCodec
def build_semantic_codec(cfg):
semantic_codec = RepCodec(cfg=cfg)
semantic_codec.eval()
return semantic_codec
+260
View File
@@ -0,0 +1,260 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
# Copyright (c) 2024 Amphion.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import logging
import os
import torch
import torch.nn as nn
from torch.nn import functional as F
logger = logging.getLogger(__name__)
from indextts.codec.amphion_codec.quantize import ResidualVQ
from indextts.codec.kmeans.vocos import VocosBackbone
def init_weights(m):
if isinstance(m, nn.Conv1d):
nn.init.trunc_normal_(m.weight, std=0.02)
nn.init.constant_(m.bias, 0)
if isinstance(m, nn.Linear):
nn.init.trunc_normal_(m.weight, std=0.02)
nn.init.constant_(m.bias, 0)
class EnhancedCodec(nn.Module):
def __init__(
self,
codebook_size=8192,
hidden_size=1024,
codebook_dim=8,
vocos_dim=384,
vocos_intermediate_dim=2048,
vocos_num_layers=12,
num_quantizers=1,
downsample_scale=2,
cfg=None,
):
super().__init__()
codebook_size = (
cfg.codebook_size
if cfg is not None and hasattr(cfg, "codebook_size")
else codebook_size
)
codebook_dim = (
cfg.codebook_dim
if cfg is not None and hasattr(cfg, "codebook_dim")
else codebook_dim
)
hidden_size = (
cfg.hidden_size
if cfg is not None and hasattr(cfg, "hidden_size")
else hidden_size
)
vocos_dim = (
cfg.vocos_dim
if cfg is not None and hasattr(cfg, "vocos_dim")
else vocos_dim
)
vocos_intermediate_dim = (
cfg.vocos_intermediate_dim
if cfg is not None and hasattr(cfg, "vocos_intermediate_dim")
else vocos_intermediate_dim
)
vocos_num_layers = (
cfg.vocos_num_layers
if cfg is not None and hasattr(cfg, "vocos_num_layers")
else vocos_num_layers
)
num_quantizers = (
cfg.num_quantizers
if cfg is not None and hasattr(cfg, "num_quantizers")
else num_quantizers
)
downsample_scale = (
cfg.downsample_scale
if cfg is not None and hasattr(cfg, "downsample_scale")
else downsample_scale
)
self.codebook_size = codebook_size
self.codebook_dim = codebook_dim
self.hidden_size = hidden_size
self.vocos_dim = vocos_dim
self.vocos_intermediate_dim = vocos_intermediate_dim
self.vocos_num_layers = vocos_num_layers
self.num_quantizers = num_quantizers
self.downsample_scale = downsample_scale
if self.downsample_scale != None and self.downsample_scale > 1:
self.down = nn.Conv1d(
self.hidden_size, self.hidden_size, kernel_size=3, stride=2, padding=1
)
self.up = nn.Conv1d(
self.hidden_size, self.hidden_size, kernel_size=3, stride=1, padding=1
)
self.encoder = nn.Sequential(
VocosBackbone(
input_channels=self.hidden_size,
dim=self.vocos_dim,
intermediate_dim=self.vocos_intermediate_dim,
num_layers=self.vocos_num_layers,
adanorm_num_embeddings=None,
),
nn.Linear(self.vocos_dim, self.hidden_size),
)
self.decoder = nn.Sequential(
VocosBackbone(
input_channels=self.hidden_size,
dim=self.vocos_dim,
intermediate_dim=self.vocos_intermediate_dim,
num_layers=self.vocos_num_layers,
adanorm_num_embeddings=None,
),
nn.Linear(self.vocos_dim, self.hidden_size),
)
self.quantizer = ResidualVQ(
input_dim=hidden_size,
num_quantizers=num_quantizers,
codebook_size=codebook_size,
codebook_dim=codebook_dim,
quantizer_type="fvq",
quantizer_dropout=0.0,
commitment=0.15,
codebook_loss_weight=1.0,
use_l2_normlize=True,
)
self.reset_parameters()
def forward(self, x):
# downsample
feat = x
length = x.size(1)
if length % 2 != 0:
# 去掉最后一帧
x = x[:, :-1, :]
feat = feat[:, :-1, :] # 关键:同步裁剪feat
if self.downsample_scale != None and self.downsample_scale > 1:
x = x.transpose(1, 2)
x = self.down(x)
x = F.gelu(x)
x = x.transpose(1, 2)
x = self.encoder(x.transpose(1, 2)).transpose(1, 2)
(
quantized_out,
all_indices,
all_commit_losses,
all_codebook_losses,
_,
) = self.quantizer(x)
# while 1:
# pass
# decoder
x = self.decoder(quantized_out)
x_rec = x
# up
if self.downsample_scale != None and self.downsample_scale > 1:
x = x.transpose(1, 2)
x = F.interpolate(x, scale_factor=2, mode="nearest")
x_rec = self.up(x).transpose(1, 2)
codebook_loss = (all_codebook_losses + all_commit_losses).mean()
all_indices = all_indices
reconstruction_loss = F.mse_loss(x_rec, feat)
return x_rec, codebook_loss, all_indices, reconstruction_loss
def quantize(self, x):
if self.downsample_scale != None and self.downsample_scale > 1:
x = x.transpose(1, 2)
x = self.down(x)
x = F.gelu(x)
x = x.transpose(1, 2)
x = self.encoder(x.transpose(1, 2)).transpose(1, 2)
(
quantized_out,
all_indices,
all_commit_losses,
all_codebook_losses,
_,
) = self.quantizer(x)
if all_indices.shape[0] == 1:
return all_indices.squeeze(0), quantized_out.transpose(1, 2)
return all_indices, quantized_out.transpose(1, 2)
def reset_parameters(self):
self.apply(init_weights)
def decode(self, codes):
"""
通过 codes 恢复quantized_out
Args:
codes: Tensor[N x B x T] or Tensor[B x T] (当N=1时)
量化的索引
Returns:
quantized_out: Tensor[B x D x T]
重建的量化输出
"""
# 处理单个量化器的情况
if codes.dim() == 2:
codes = codes.unsqueeze(0) # [B, T] -> [1, B, T]
# 使用quantizer的vq2emb方法恢复量化输出
quantized_out = self.quantizer.vq2emb(codes)
x = self.decoder(quantized_out)
# 如果有下采样操作,则进行上采样
if self.downsample_scale != None and self.downsample_scale > 1:
x = x.transpose(1, 2)
x = F.interpolate(x, scale_factor=2, mode="nearest")
x_rec = self.up(x).transpose(1, 2)
return x_rec
def load_checkpoint(self, checkpoint_path):
"""Load model weights from a checkpoint file."""
assert os.path.isfile(checkpoint_path), f"Checkpoint not found: {checkpoint_path}"
checkpoint_dict = torch.load(checkpoint_path, map_location='cpu')
saved_state_dict = checkpoint_dict['model']
state_dict = self.state_dict()
new_state_dict = {}
for k, v in state_dict.items():
if k in saved_state_dict and saved_state_dict[k].shape == v.shape:
new_state_dict[k] = saved_state_dict[k]
else:
logger.warning("%s is not in the checkpoint or shape mismatch", k)
new_state_dict[k] = v
self.load_state_dict(new_state_dict)
logger.info("Loaded codec checkpoint '%s'", checkpoint_path)
if __name__ == "__main__":
repcodec = EnhancedCodec(vocos_dim=1024, downsample_scale=2)
print(repcodec)
print(sum(p.numel() for p in repcodec.parameters()) / 1e6)
x = torch.randn(5, 10, 1024)
x_rec, codebook_loss, all_indices = repcodec(x)
print(x_rec.shape, codebook_loss, all_indices.shape)
vq_id, emb = repcodec.quantize(x)
print(vq_id.shape, emb.shape)
+85 -31
View File
@@ -21,6 +21,7 @@ from indextts.gpt.conformer_encoder import ConformerEncoder
from indextts.gpt.perceiver import PerceiverResampler
from indextts.utils.arch_util import AttentionBlock
from indextts.utils.typical_sampling import TypicalLogitsWarper
from indextts.utils.tokenizer import LANGUAGE_DICT
def null_position_embeddings(range, dim):
@@ -314,7 +315,8 @@ class UnifiedVoice(nn.Module):
start_text_token=0, stop_text_token=1, number_mel_codes=8194, start_mel_token=8192, stop_mel_token=8193,
train_solo_embeddings=False, use_mel_codes_as_input=True,
checkpointing=True, types=1,
condition_num_latent=32, condition_type="perceiver", condition_module=None, emo_condition_module=None, use_accel=False):
condition_num_latent=32, condition_type="perceiver", condition_module=None, emo_condition_module=None, use_accel=False,
spk_cond_mode="conformer"):
"""
Args:
layers: Number of layers in transformer stack.
@@ -353,23 +355,31 @@ class UnifiedVoice(nn.Module):
self.cond_num = condition_num_latent
self.cond_mask_pad = nn.ConstantPad1d((self.cond_num, 0), True)
self.emo_cond_mask_pad = nn.ConstantPad1d((1, 0), True)
if condition_type == "perceiver":
self.conditioning_encoder = ConditioningEncoder(1024, model_dim, num_attn_heads=heads)
self.perceiver_encoder = PerceiverResampler(model_dim, dim_context=model_dim, num_latents=self.cond_num)
elif condition_type == "conformer_perceiver" or condition_type == "conformer_encoder":
self.conditioning_encoder = ConformerEncoder(input_size=1024,
output_size=condition_module['output_size'],
linear_units=condition_module['linear_units'],
attention_heads=condition_module['attention_heads'],
num_blocks=condition_module['num_blocks'],
input_layer=condition_module['input_layer'])
if condition_type == "conformer_perceiver":
self.perceiver_encoder = PerceiverResampler(model_dim, dim_context=condition_module['output_size'],
ff_mult=condition_module['perceiver_mult'],
heads=condition_module['attention_heads'],
num_latents=self.cond_num)
# TTS Audio Suite patch: Keep one Transformers-5-compatible GPT implementation for
# both IndexTTS-2 and 2.5 while selecting their different speaker conditioning.
self.spk_cond_mode = spk_cond_mode
if spk_cond_mode == "campplus":
self.spk_emb_proj = nn.Linear(192, model_dim)
else:
self.conditioning_encoder = ConditioningEncoder(1024, model_dim, num_attn_heads=heads, mean=True)
if condition_type == "perceiver":
self.conditioning_encoder = ConditioningEncoder(1024, model_dim, num_attn_heads=heads)
self.perceiver_encoder = PerceiverResampler(model_dim, dim_context=model_dim, num_latents=self.cond_num)
elif condition_type == "conformer_perceiver" or condition_type == "conformer_encoder":
self.conditioning_encoder = ConformerEncoder(input_size=1024,
output_size=condition_module['output_size'],
linear_units=condition_module['linear_units'],
attention_heads=condition_module['attention_heads'],
num_blocks=condition_module['num_blocks'],
input_layer=condition_module['input_layer'])
if condition_type == "conformer_perceiver":
self.perceiver_encoder = PerceiverResampler(model_dim, dim_context=condition_module['output_size'],
ff_mult=condition_module['perceiver_mult'],
heads=condition_module['attention_heads'],
num_latents=self.cond_num)
else:
self.conditioning_encoder = ConditioningEncoder(1024, model_dim, num_attn_heads=heads, mean=True)
self.speed_emb = nn.Embedding(2, model_dim)
self.speed_emb.weight.data.normal_(mean=0.0, std=0.0)
self.emo_conditioning_encoder = ConformerEncoder(input_size=1024,
output_size=emo_condition_module['output_size'],
@@ -385,6 +395,8 @@ class UnifiedVoice(nn.Module):
self.text_embedding = nn.Embedding(self.number_text_tokens * types + 1, model_dim)
if spk_cond_mode == "campplus":
self.lang_embedding = nn.Embedding(len(LANGUAGE_DICT) + 1, model_dim)
self.emo_layer = nn.Linear(model_dim, model_dim)
self.emovec_layer = nn.Linear(1024, model_dim)
@@ -406,9 +418,6 @@ class UnifiedVoice(nn.Module):
self.text_head = nn.Linear(model_dim, self.number_text_tokens * types + 1)
self.mel_head = nn.Linear(model_dim, self.number_mel_codes)
self.speed_emb = nn.Embedding(2, model_dim)
self.speed_emb.weight.data.normal_(mean=0.0, std=0.0)
# Initialize the embeddings per the GPT-2 scheme
embeddings = [self.text_embedding]
if use_mel_codes_as_input:
@@ -622,7 +631,12 @@ class UnifiedVoice(nn.Module):
"""
if do_spk_cond:
speech_conditioning_latent = self.get_conditioning(speech_conditioning_latent.transpose(1,2), cond_mel_lengths)
if self.spk_cond_mode == "campplus":
speech_conditioning_latent = self.spk_emb_proj(speech_conditioning_latent)
if speech_conditioning_latent.ndim != 3:
speech_conditioning_latent = speech_conditioning_latent.unsqueeze(1)
else:
speech_conditioning_latent = self.get_conditioning(speech_conditioning_latent.transpose(1,2), cond_mel_lengths)
else:
speech_conditioning_latent = speech_conditioning_latent
@@ -637,9 +651,16 @@ class UnifiedVoice(nn.Module):
mel_codes = self.set_mel_padding(mel_codes, mel_codes_lengths)
mel_codes = F.pad(mel_codes, (0, 1), value=self.stop_mel_token)
duration_emb = self.speed_emb(torch.zeros_like(use_speed))
duration_emb_half = self.speed_emb(torch.ones_like(use_speed))
conds = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)), 1)
if self.spk_cond_mode == "campplus":
padding = torch.zeros(
speech_conditioning_latent.size(0), 2, speech_conditioning_latent.size(2),
device=speech_conditioning_latent.device,
)
conds = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), padding), 1)
else:
duration_emb = self.speed_emb(torch.zeros_like(use_speed))
duration_emb_half = self.speed_emb(torch.ones_like(use_speed))
conds = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)), 1)
text_inputs, text_targets = self.build_aligned_inputs_and_targets(text_inputs, self.start_text_token, self.stop_text_token)
text_emb = self.text_embedding(text_inputs) + self.text_pos_embedding(text_inputs)
mel_codes, mel_targets = self.build_aligned_inputs_and_targets(mel_codes, self.start_mel_token, self.stop_mel_token)
@@ -654,6 +675,7 @@ class UnifiedVoice(nn.Module):
self,
conditional_latents: torch.Tensor,
text_inputs: torch.Tensor,
langs: torch.Tensor = None,
):
"""
@@ -681,6 +703,8 @@ class UnifiedVoice(nn.Module):
text_input = F.pad(text_input, (0, 1), value=self.stop_text_token)
text_input_pos = torch.arange(0, text_input.size(-1), device=device)
text_emb = self.text_embedding(text_input) + self.text_pos_embedding.emb(text_input_pos)
if langs is not None and self.spk_cond_mode == "campplus":
text_emb += self.lang_embedding(langs[i])
# concatenate [conditional latents][text embeddings]
conds_text_emb = [
conditional_latents.squeeze(0) if single_cond else conditional_latents[i],
@@ -715,7 +739,10 @@ class UnifiedVoice(nn.Module):
fake_inputs[:, -1] = self.start_mel_token
return fake_inputs, batched_mel_emb, attention_mask
def inference_speech(self, speech_condition, text_inputs, emo_speech_condition=None, cond_lengths=None, emo_cond_lengths=None, emo_vec=None, use_speed=False, input_tokens=None, num_return_sequences=1,
def inference_speech(self, speech_condition, text_inputs, langs=None,
emo_speech_condition=None, cond_lengths=None, emo_cond_lengths=None, emo_vec=None,
use_speed=False, campplus_embedding=None, wav=None,
input_tokens=None, num_return_sequences=1,
max_generate_length=None, typical_sampling=False, typical_mass=.9, **hf_generate_kwargs):
"""
Args:
@@ -736,7 +763,27 @@ class UnifiedVoice(nn.Module):
if emo_cond_lengths is None:
emo_cond_lengths = torch.tensor([emo_speech_condition.shape[-1]], device=speech_condition.device)
speech_conditioning_latent = self.get_conditioning(speech_condition.transpose(1,2), cond_lengths)
if self.spk_cond_mode == "campplus":
if campplus_embedding is not None:
speech_conditioning_latent = campplus_embedding
elif wav is not None:
if not hasattr(self, 'sv_pipeline'):
from modelscope.pipelines import pipeline
self.sv_pipeline = pipeline(
task='speaker-verification',
model='iic/speech_campplus_sv_zh-cn_16k-common',
device='cpu',
)
speech_conditioning_latent = torch.tensor(
self.sv_pipeline([wav], output_emb=True)['embs']
).to(text_inputs.device)
else:
raise ValueError("campplus mode requires campplus_embedding or wav")
speech_conditioning_latent = self.spk_emb_proj(speech_conditioning_latent)
if speech_conditioning_latent.ndim != 3:
speech_conditioning_latent = speech_conditioning_latent.unsqueeze(0)
else:
speech_conditioning_latent = self.get_conditioning(speech_condition.transpose(1,2), cond_lengths)
if emo_vec is None:
print('compute emo vec')
emo_vec = self.get_emo_conditioning(emo_speech_condition.transpose(1,2), emo_cond_lengths)
@@ -745,11 +792,18 @@ class UnifiedVoice(nn.Module):
else:
print('Use the specified emotion vector')
tmp = torch.zeros(text_inputs.size(0)).to(text_inputs.device)
duration_emb = self.speed_emb(torch.zeros_like(tmp).long())
duration_emb_half = self.speed_emb(torch.ones_like(tmp).long())
conds_latent = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)), 1)
input_ids, inputs_embeds, attention_mask = self.prepare_gpt_inputs(conds_latent, text_inputs)
if self.spk_cond_mode == "campplus":
padding = torch.zeros(
speech_conditioning_latent.size(0), 2, speech_conditioning_latent.size(2),
device=speech_conditioning_latent.device,
)
conds_latent = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), padding), 1)
else:
tmp = torch.zeros(text_inputs.size(0)).to(text_inputs.device)
duration_emb = self.speed_emb(torch.zeros_like(tmp).long())
duration_emb_half = self.speed_emb(torch.ones_like(tmp).long())
conds_latent = torch.cat((speech_conditioning_latent + emo_vec.unsqueeze(1), duration_emb_half.unsqueeze(1), duration_emb.unsqueeze(1)), 1)
input_ids, inputs_embeds, attention_mask = self.prepare_gpt_inputs(conds_latent, text_inputs, langs)
self.inference_model.store_mel_emb(inputs_embeds)
if input_tokens is None:
inputs = input_ids
+2 -1
View File
@@ -882,7 +882,8 @@ class IndexTTS2:
cond_lengths=torch.tensor([spk_cond_emb.shape[-1]], device=text_tokens.device),
emo_cond_lengths=torch.tensor([emo_cond_emb.shape[-1]], device=text_tokens.device),
emo_vec=emovec,
do_sample=True,
# TTS Audio Suite patch: Honor the engine node's sampling control.
do_sample=do_sample,
top_p=top_p,
top_k=top_k,
temperature=temperature,
File diff suppressed because it is too large Load Diff
+267 -101
View File
@@ -1,4 +1,6 @@
# TTS Audio Suite patch: Updated to the shared official IndexTTS 2/2.5 text frontend; dependency fallbacks are retained below for ComfyUI.
# -*- coding: utf-8 -*-
from functools import lru_cache
import os
import traceback
import re
@@ -9,7 +11,7 @@ from sentencepiece import SentencePieceProcessor
class TextNormalizer:
def __init__(self):
def __init__(self, enable_glossary=False):
self.zh_normalizer = None
self.en_normalizer = None
self.char_rep_map = {
@@ -53,13 +55,25 @@ class TextNormalizer:
"$": ".",
**self.char_rep_map,
}
def _create_dummy_normalizer(self):
"""Create a dummy normalizer that returns text unchanged"""
class DummyNormalizer:
def normalize(self, text):
return text
return DummyNormalizer()
self.clean_pattern = re.compile("|".join(re.escape(p) for p in self.char_rep_map.keys()))
self.enable_glossary = enable_glossary
# 术语词汇表:用户可自定义专业术语的读法
# 格式: {"原始术语": {"en": "英文读法", "zh": "中文读法"}}
# "M.2": {"en": "M dot two", "zh": "M 二"},
# "PCIe 5.0": {"en": "PCIE five", "zh": "PCIE 五点零"},
# "PCIe 4.0": {"en": "PCIE four", "zh": "PCIE 四点零"},
# "AHCI": "A H C I",
# "TTS": "T T S",
# "Inc.": {"en": "Ink"},
# ".json": {"en": " dot Jay-Son", "zh": "点 Jay-Son"},
# "C++": {"en": "C plus plus", "zh": "C 加加"},
# "C#": "C sharp"
# self.term_glossary = {
# "C++": {"en": "C plus plus", "zh": "C 加加"},
# "C#": "C sharp",
# "CMake": "C Make",
# }
self.term_glossary = dict()
def match_email(self, email):
# 正则表达式匹配邮箱格式:数字英文@数字英文.英文
@@ -78,6 +92,14 @@ class TextNormalizer:
例如:克里斯托弗·诺兰,约瑟夫·高登-莱维特
"""
TECH_TERM_PATTERN = r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+"
"""
匹配技术术语,格式:字母开头+(字母或数字)*+(-字母或数字)+
例如:GPT-5-nano, F5-TTS, Fish-Speech, GPT-5, CosyVoice-2
必须以字母开头,避免匹配纯数字(如电话号码 135-4567-8900)
用于保护连字符结构,防止中文normalizer将连字符解析为减号(如"负五减")
"""
# 匹配常见英语缩写 's,仅用于替换为 is,不匹配所有 's
ENGLISH_CONTRACTION_PATTERN = r"(what|where|who|which|how|t?here|it|s?he|that|this)'s"
@@ -98,106 +120,109 @@ class TextNormalizer:
import platform
if self.zh_normalizer is not None and self.en_normalizer is not None:
return
if platform.system() != "Linux": # Mac and Windows
normalizer_class = None
try:
from WeTextProcessing import Normalizer
normalizer_class = Normalizer
print("Using WeTextProcessing for text normalization")
except ImportError:
try:
from wetext import Normalizer # Fallback for older installations
normalizer_class = Normalizer
print("Using wetext for text normalization (fallback)")
except ImportError:
print("Warning: No text normalization package available (WeTextProcessing/wetext)")
print("IndexTTS-2 will use basic text processing - may affect quality for Chinese text")
# Create dummy normalizers that return text unchanged
self.zh_normalizer = self._create_dummy_normalizer()
self.en_normalizer = self._create_dummy_normalizer()
return
if normalizer_class:
self.zh_normalizer = normalizer_class(remove_erhua=False, lang="zh", operator="tn")
self.en_normalizer = normalizer_class(lang="en", operator="tn")
else: # Linux systems
try:
# Try WeTextProcessing first (same as Windows/Mac)
from WeTextProcessing import Normalizer
print("Using WeTextProcessing for text normalization")
try:
if platform.system() != "Linux": # Mac and Windows
from wetext import Normalizer
self.zh_normalizer = Normalizer(remove_erhua=False, lang="zh", operator="tn")
self.en_normalizer = Normalizer(lang="en", operator="tn")
except ImportError:
try:
# Try direct tn imports (WeTextProcessing's internal modules)
from tn.chinese.normalizer import Normalizer as NormalizerZh
from tn.english.normalizer import Normalizer as NormalizerEn
print("Using WeTextProcessing internal tn modules for text normalization")
# use new cache dir for build tagger rules with disable remove_interjections and remove_erhua
cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tagger_cache")
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
with open(os.path.join(cache_dir, ".gitignore"), "w") as f:
f.write("*\n")
self.zh_normalizer = NormalizerZh(
cache_dir=cache_dir, remove_interjections=False, remove_erhua=False, overwrite_cache=False
)
self.en_normalizer = NormalizerEn(overwrite_cache=False)
except ImportError:
try:
# Fallback to wetext if available
from wetext import Normalizer
print("Using wetext for text normalization (fallback)")
self.zh_normalizer = Normalizer(remove_erhua=False, lang="zh", operator="tn")
self.en_normalizer = Normalizer(lang="en", operator="tn")
except ImportError:
print("Warning: No text normalization package available on Linux")
print("IndexTTS-2 will use basic text processing - may affect quality for Chinese text")
# Create dummy normalizers that return text unchanged
self.zh_normalizer = self._create_dummy_normalizer()
self.en_normalizer = self._create_dummy_normalizer()
else:
from tn.chinese.normalizer import Normalizer as NormalizerZh
from tn.english.normalizer import Normalizer as NormalizerEn
cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tagger_cache")
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
with open(os.path.join(cache_dir, ".gitignore"), "w") as f:
f.write("*\n")
self.zh_normalizer = NormalizerZh(
cache_dir=cache_dir, remove_interjections=False, remove_erhua=False, overwrite_cache=False
)
self.en_normalizer = NormalizerEn(overwrite_cache=False)
except ImportError as exc:
# TTS Audio Suite patch: Text normalization is optional in ComfyUI;
# retain basic punctuation cleanup instead of making TTS unavailable.
print(f"⚠️ IndexTTS text normalizer unavailable ({exc}); using basic normalization")
self.zh_normalizer = False
self.en_normalizer = False
G2P_PRONUNCIATION_ANNOTATION_PATTERN = re.compile(r'<([^|>\n]+)\|([^>\n]+)>')
def _protect_pronunciation_annotations(self, text: str):
"""
在 normalize 之前调用:将 <字|读音> 标注替换为纯字母占位符,
防止 normalizer 把标注内的数字/符号展开(如 XING2 -> XING二)。
返回 (替换后文本, 占位符字典)。
"""
placeholders = {}
def _idx_to_alpha(n):
s = ''
while True:
s = chr(ord('a') + n % 26) + s
n = n // 26 - 1
if n < 0:
break
return s
def _replacer(m):
tag = _idx_to_alpha(len(placeholders))
key = f'PRONPLACEHOLDER{tag}PRONPLACEHOLDER'
placeholders[key] = m.group(0)
return key
text = self.G2P_PRONUNCIATION_ANNOTATION_PATTERN.sub(_replacer, text)
return text, placeholders
@staticmethod
def _restore_pronunciation_annotations(text: str, placeholders: dict) -> str:
"""在 normalize 之后调用:将占位符还原为原始 <字|读音> 标注。"""
for key, val in placeholders.items():
text = text.replace(key, val)
return text
def normalize(self, text: str) -> str:
if not self.zh_normalizer or not self.en_normalizer:
print("Warning: text normalizer is not initialized - using basic text processing")
# Apply basic character replacements and return
pattern = re.compile("|".join(re.escape(p) for p in self.char_rep_map.keys()))
return pattern.sub(lambda x: self.char_rep_map[x.group()], text)
# Check if we have functional normalizers or dummy ones
is_dummy_normalizer = hasattr(self.zh_normalizer, '__class__') and self.zh_normalizer.__class__.__name__ == 'DummyNormalizer'
if is_dummy_normalizer:
# Use basic text processing only
print("Using basic text processing (no advanced normalization available)")
pattern = re.compile("|".join(re.escape(p) for p in self.char_rep_map.keys()))
result = pattern.sub(lambda x: self.char_rep_map[x.group()], text)
elif self.use_chinese(text):
return self.clean_pattern.sub(lambda x: self.char_rep_map[x.group()], text)
# 保护 G2P 发音标注 <word|pronunciation>,防止被 normalizer 破坏
text, _pron_placeholders = self._protect_pronunciation_annotations(text)
if self.use_chinese(text):
text = re.sub(TextNormalizer.ENGLISH_CONTRACTION_PATTERN, r"\1 is", text, flags=re.IGNORECASE)
replaced_text, pinyin_list = self.save_pinyin_tones(text.rstrip())
# 应用术语词汇表(优先级最高,在所有保护之前)
if self.enable_glossary:
text = self.apply_glossary_terms(text, lang="zh")
# 保护技术术语(如 GPT-5-nano)避免被中文normalizer错误处理
replaced_text, tech_list = self.save_tech_terms(text.rstrip())
replaced_text, pinyin_list = self.save_pinyin_tones(replaced_text)
replaced_text, original_name_list = self.save_names(replaced_text)
try:
result = self.zh_normalizer.normalize(replaced_text)
except Exception:
result = replaced_text # Fallback to original text instead of empty string
print("Warning: Chinese text normalization failed, using original text")
result = ""
print(traceback.format_exc())
# 恢复人名
result = self.restore_names(result, original_name_list)
# 恢复拼音声调
result = self.restore_pinyin_tones(result, pinyin_list)
# 恢复技术术语
result = self.restore_tech_terms(result, tech_list)
pattern = re.compile("|".join(re.escape(p) for p in self.zh_char_rep_map.keys()))
result = pattern.sub(lambda x: self.zh_char_rep_map[x.group()], result)
else:
try:
text = re.sub(TextNormalizer.ENGLISH_CONTRACTION_PATTERN, r"\1 is", text, flags=re.IGNORECASE)
result = self.en_normalizer.normalize(text)
# 应用术语词汇表(优先级最高,在所有保护之前)
if self.enable_glossary:
text = self.apply_glossary_terms(text, lang="en")
# 保护技术术语(如 GPT-5-Nano)避免被英文normalizer错误处理
replaced_text, tech_list = self.save_tech_terms(text)
result = self.en_normalizer.normalize(replaced_text)
# 恢复技术术语
result = self.restore_tech_terms(result, tech_list)
except Exception:
result = text # Fallback to original text instead of empty string
print("Warning: English text normalization failed, using original text")
result = text
print(traceback.format_exc())
pattern = re.compile("|".join(re.escape(p) for p in self.char_rep_map.keys()))
result = pattern.sub(lambda x: self.char_rep_map[x.group()], result)
# 恢复 G2P 发音标注
result = self._restore_pronunciation_annotations(result, _pron_placeholders)
return result
def correct_pinyin(self, pinyin: str):
@@ -247,6 +272,133 @@ class TextNormalizer:
transformed_text = transformed_text.replace(f"<n_{number}>", name)
return transformed_text
def save_tech_terms(self, original_text):
"""
保护技术术语中的连字符,防止被中文normalizer解析为减号
策略:将术语中的连字符替换为特殊占位符<H>,数字仍可被正常处理
例如:GPT-5-nano -> GPT<H>5<H>nano,然后 5 被转换为 五
最终恢复为:GPT-五-nano
"""
tech_pattern = re.compile(TextNormalizer.TECH_TERM_PATTERN)
original_tech_list = tech_pattern.findall(original_text)
if len(original_tech_list) == 0:
return (original_text, None)
# 去重并按长度降序排列(避免短匹配先替换导致问题)
original_tech_list = sorted(set(original_tech_list), key=len, reverse=True)
transformed_text = original_text
# 将术语中的连字符替换为占位符 <H>
for term in original_tech_list:
# 将 GPT-5-nano 替换为 GPT<H>5<H>nano
protected_term = term.replace("-", "<H>")
transformed_text = transformed_text.replace(term, protected_term)
return transformed_text, original_tech_list
def restore_tech_terms(self, normalized_text, original_tech_list):
"""
恢复技术术语中的连字符
将占位符 <H> 恢复为连字符 -
同时清理 normalizer 可能在占位符周围添加的多余空格
"""
if not original_tech_list or len(original_tech_list) == 0:
return normalized_text
# 清理 <H> 周围可能的空格,然后恢复为连字符
# 处理模式: " <H> " -> "-", " <H>" -> "-", "<H> " -> "-", "<H>" -> "-"
transformed_text = re.sub(r'\s*<H>\s*', '-', normalized_text)
return transformed_text
def apply_glossary_terms(self, text, lang="zh"):
"""
应用术语词汇表,将专业术语替换为对应语言的读法
Args:
text: 待处理文本
lang: 语言类型 "zh" 或 "en"
Returns:
处理后的文本
Example:
"M.2 NVMe SSD" -> (zh) "M 二 NVMe SSD"
"M.2 NVMe SSD" -> (en) "M dot two NVMe SSD"
"""
if not self.term_glossary:
return text
# 按术语长度降序排列,避免短术语先匹配导致长术语无法匹配
# 例如:"PCIe 5.0" 应该在 "PCIe" 之前匹配
sorted_terms = sorted(self.term_glossary.keys(), key=len, reverse=True)
@lru_cache(maxsize=42)
def get_term_pattern(term: str):
return re.compile(re.escape(term), re.IGNORECASE)
transformed_text = text
for term in sorted_terms:
term_value = self.term_glossary[term]
if isinstance(term_value, dict):
replacement = term_value.get(lang, term_value.get(lang, term))
else:
replacement = term_value
# 使用正则进行大小写不敏感的替换
pattern = get_term_pattern(term)
transformed_text = pattern.sub(replacement, transformed_text)
return transformed_text
def load_glossary(self, glossary_dict):
"""
加载外部术语词汇表
Args:
glossary_dict: 术语词典,格式为 {"术语": {"en": "英文读法", "zh": "中文读法"}}
Example:
normalizer.load_glossary({
"M.2": {"en": "M dot two", "zh": "M 二"},
"PCIe": {"en": "PCIE", "zh": "PCIE"}
})
"""
if glossary_dict and isinstance(glossary_dict, dict):
self.term_glossary.update(glossary_dict)
def load_glossary_from_yaml(self, glossary_path):
"""
从 YAML 文件加载术语词汇表
Args:
glossary_path: YAML 文件路径
Example:
normalizer.load_glossary_from_yaml("checkpoints/glossary.yaml")
YAML 文件格式:
M.2:
en: M dot two
zh: M 二
NVMe: N-V-M-E # 中英文相同读法
"""
if glossary_path and os.path.exists(glossary_path):
import yaml
with open(glossary_path, 'r', encoding='utf-8') as f:
external_glossary = yaml.safe_load(f)
if external_glossary and isinstance(external_glossary, dict):
self.term_glossary = external_glossary
return True
return False
def save_glossary_to_yaml(self, glossary_path):
"""
保存术语词汇表到 YAML 文件
Args:
glossary_path: YAML 文件路径
"""
import yaml
with open(glossary_path, 'w', encoding='utf-8') as f:
yaml.dump(self.term_glossary, f, allow_unicode=True, default_flow_style=False)
def save_pinyin_tones(self, original_text):
"""
替换拼音声调为占位符 <pinyin_a>, <pinyin_b>, ...
@@ -402,7 +554,10 @@ class TextTokenizer:
@staticmethod
def split_segments_by_token(
tokenized_str: List[str], split_tokens: List[str], max_text_tokens_per_segment: int
tokenized_str: List[str],
split_tokens: List[str],
max_text_tokens_per_segment: int,
quick_streaming_tokens: int = 0
) -> List[List[str]]:
"""
将tokenize后的结果按特定token进一步分割
@@ -417,7 +572,17 @@ class TextTokenizer:
token = tokenized_str[i]
current_segment.append(token)
current_segment_tokens_len += 1
if current_segment_tokens_len <= max_text_tokens_per_segment:
if not ("," in split_tokens or "▁," in split_tokens ) and ("," in current_segment or "▁," in current_segment):
# 如果当前tokens中有,,则按,分割
sub_segments = TextTokenizer.split_segments_by_token(
current_segment, [",", "▁,"], max_text_tokens_per_segment=max_text_tokens_per_segment, quick_streaming_tokens = quick_streaming_tokens
)
elif "-" not in split_tokens and "-" in current_segment:
# 没有,,则按-分割
sub_segments = TextTokenizer.split_segments_by_token(
current_segment, ["-"], max_text_tokens_per_segment=max_text_tokens_per_segment, quick_streaming_tokens = quick_streaming_tokens
)
elif current_segment_tokens_len <= max_text_tokens_per_segment:
if token in split_tokens and current_segment_tokens_len > 2:
if i < len(tokenized_str) - 1:
if tokenized_str[i + 1] in ["'", "▁'"]:
@@ -429,16 +594,6 @@ class TextTokenizer:
current_segment_tokens_len = 0
continue
# 如果当前tokens的长度超过最大限制
if not ("," in split_tokens or "▁," in split_tokens ) and ("," in current_segment or "▁," in current_segment):
# 如果当前tokens中有,,则按,分割
sub_segments = TextTokenizer.split_segments_by_token(
current_segment, [",", "▁,"], max_text_tokens_per_segment=max_text_tokens_per_segment
)
elif "-" not in split_tokens and "-" in current_segment:
# 没有,,则按-分割
sub_segments = TextTokenizer.split_segments_by_token(
current_segment, ["-"], max_text_tokens_per_segment=max_text_tokens_per_segment
)
else:
# 按照长度分割
sub_segments = []
@@ -459,14 +614,19 @@ class TextTokenizer:
if current_segment_tokens_len > 0:
assert current_segment_tokens_len <= max_text_tokens_per_segment
segments.append(current_segment)
# 如果相邻的句子加起来长度小于最大限制,则合并
# 如果相邻的句子加起来长度小于最大限制,且此前token总数超过quick_streaming_tokens,则合并
merged_segments = []
total_token = 0
for segment in segments:
total_token += len(segment)
if len(segment) == 0:
continue
if len(merged_segments) == 0:
merged_segments.append(segment)
elif len(merged_segments[-1]) + len(segment) <= max_text_tokens_per_segment:
elif len(merged_segments[-1]) + len(segment) <= max_text_tokens_per_segment and total_token > quick_streaming_tokens:
merged_segments[-1] = merged_segments[-1] + segment
# 或小于最大长度限制的一半,则合并
elif len(merged_segments[-1]) + len(segment) <= max_text_tokens_per_segment / 2:
merged_segments[-1] = merged_segments[-1] + segment
else:
merged_segments.append(segment)
@@ -481,16 +641,16 @@ class TextTokenizer:
"▁?",
"▁...", # ellipsis
]
def split_segments(self, tokenized: List[str], max_text_tokens_per_segment=120) -> List[List[str]]:
def split_segments(self, tokenized: List[str], max_text_tokens_per_segment=120, quick_streaming_tokens = 0) -> List[List[str]]:
return TextTokenizer.split_segments_by_token(
tokenized, self.punctuation_marks_tokens, max_text_tokens_per_segment=max_text_tokens_per_segment
tokenized, self.punctuation_marks_tokens, max_text_tokens_per_segment=max_text_tokens_per_segment, quick_streaming_tokens = quick_streaming_tokens
)
if __name__ == "__main__":
# 测试程序
text_normalizer = TextNormalizer()
text_normalizer = TextNormalizer(enable_glossary=True)
cases = [
"IndexTTS 正式发布1.0版本了,效果666",
@@ -525,12 +685,18 @@ if __name__ == "__main__":
"babala2是什么?", # babala二是什么?
"用beta1测试", # 用beta一测试
"have you ever been to beta2?", # have you ever been to beta two?
"such as XTTS, CosyVoice2, Fish-Speech, and F5-TTS", # such as xtts,cosyvoice two,fish-speech,and f five-tts
"where's the money?", # where is the money?
"who's there?", # who is there?
"which's the best?", # which is the best?
"how's it going?", # how is it going?
"今天是个好日子 it's a good day", # 今天是个好日子 it is a good day
# 术语
"such as XTTS, CosyVoice2, Fish-Speech, and F5-TTS", # such as xtts,cosyvoice two,fish-speech,and f five-tts
"GPT-5-Nano is the smallest and fastest variant in the GPT-5 model family.", # GPT-five-Nano is the smallest and fastest variant in the GPT-five model family
"GPT-5-Nano 是 GPT-5 模型家族中最小且速度最快的变体", # GPT-五-Nano 是 GPT-五 系统中最小且速度最快的变体
"2025/09/08 IndexTTS-2 全球发布", # 二零二五年九月八日 IndexTTS-二全球发布
"Here are some highly-rated M.2 NVMe SSDs: Samsung 9100 PRO PCIe 5.0 SSD M.2, $139.99", # Here are some highly-rated M dot two NVMe SSD's, Samsung nine thousand one hundred PRO PCIE five SSD M dot two . one hundred and thirty nine dollars and ninety nine cents
"we dive deep into the showdown between DisplayPort 1.4 and HDMI 2.1 to determine which is the best choice for gaming enthusiasts",
# 人名
"约瑟夫·高登-莱维特(Joseph Gordon-Levitt is an American actor)",
"蒂莫西·唐纳德·库克(英文名:Timothy Donald Cook),通称蒂姆·库克(Tim Cook),美国商业经理、工业工程师和工业开发商,现任苹果公司首席执行官。",
+127
View File
@@ -0,0 +1,127 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
import re
import random
class JapaneseG2PProcessor:
"""
日语文本分词 + 平假名化处理器。
依赖 fugashi + unidic-lite(或系统 MeCab)。
安装: pip install fugashi unidic-lite
"""
def __init__(self, g2p_ratio=0.2):
self.g2p_ratio = g2p_ratio
self._init_tagger()
def _init_tagger(self):
try:
import fugashi
self.tagger = fugashi.Tagger()
self.backend = 'fugashi'
except ImportError:
try:
import MeCab
self.tagger = MeCab.Tagger('-Ochasen')
self.backend = 'mecab'
except ImportError:
raise ImportError(
"请安装 fugashi: pip install fugashi unidic-lite,"
"或安装系统 MeCab 后 pip install mecab-python3"
)
def tokenize(self, text: str) -> list:
"""
日语分词,返回 [(surface, reading_katakana), ...] 列表。
reading 为片假名读音;若无法获取则等于 surface。
"""
tokens = []
if self.backend == 'fugashi':
for token in self.tagger(text):
surface = token.surface
try:
reading = token.feature.kana
if not reading or reading == '*':
reading = surface
except AttributeError:
feat = token.feature.split(',')
reading = feat[7] if len(feat) > 7 and feat[7] != '*' else surface
tokens.append((surface, reading))
else:
for line in self.tagger.parse(text).splitlines():
if line in ('EOS', ''):
continue
parts = line.split('\t')
if len(parts) >= 2:
surface = parts[0]
reading = parts[1] if parts[1] != '*' else parts[0]
tokens.append((surface, reading))
return tokens
@staticmethod
def kata2hira(text: str) -> str:
"""片假名 → 平假名(ァ-ン → ぁ-ん)"""
return ''.join(
chr(ord(ch) - 0x60) if 0x30A1 <= ord(ch) <= 0x30F6 else ch
for ch in text
)
@staticmethod
def _has_kanji(text: str) -> bool:
"""判断字符串是否含有汉字"""
return any('\u4e00' <= ch <= '\u9fff' for ch in text)
def _process_segment(self, text: str) -> str:
"""对单个无空格片段做汉字 token 的局部平假名替换。"""
tokens = self.tokenize(text)
kanji_indices = [i for i, (surface, _) in enumerate(tokens) if self._has_kanji(surface)]
num_to_replace = int(len(kanji_indices) * self.g2p_ratio)
if num_to_replace == 0 and kanji_indices and random.random() < self.g2p_ratio:
num_to_replace = 1
replace_set = set(random.sample(kanji_indices, min(num_to_replace, len(kanji_indices))))
result = []
for i, (surface, reading) in enumerate(tokens):
if i in replace_set:
hira = self.kata2hira(reading)
result.append(hira)
else:
result.append(surface)
return ' '.join(result)
def process_ja_text(self, text: str) -> str:
"""
日语文本分词后,对含汉字的 token 按 g2p_ratio 概率替换为
平假名读音,其余保留原字。
输入中原有的空格位置在输出中保留。
"""
# 按空格拆分,保留空格位置,逐段处理后拼回
parts = re.split(r'( +)', text) # 奇数位为空格,偶数位为文本段
return ''.join(
self._process_segment(p) if p.strip() else p
for p in parts
)
# ----------------------------------------------------------------------
if __name__ == '__main__':
processor = JapaneseG2PProcessor(g2p_ratio=0.5)
test_text = 'ちょうど 探しに行こうかなって 思っていたんだ。'
test_text = '足が長く見えるように、真ん中のエンブレムのところまで、伸ばした感じで、メインの骨格を作りました。'
print('原文:', test_text)
tokens = processor.tokenize(test_text)
print('分词结果:')
for surface, reading in tokens:
print(f' {surface!r:10s} → {reading!r}')
for _ in range(3):
print('增强:', processor.process_ja_text(test_text))
# processor = JapaneseG2PProcessor(g2p_ratio=0)
# f = open("./japan_label.list", 'w')
# for x in open("./japan.list", 'r').readlines():
# org = x.strip().split('|')[4]
# tar = processor.process_ja_text(org)
# f.write(f'{org},{tar}\n')
# f.close()
+238
View File
@@ -0,0 +1,238 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
#!/usr/bin/env python3
# Copyright 2026 Xiaomi Corp.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""TTS 前端文本归一化(Text Normalization)。
基于 ``nemo_text_processing`` 把数字/符号/日期/货币等 non-standard words 展开成
可朗读文本(例如 ``"25%"`` -> ``"twenty five percent"``)。
设计要点:
- **输入是上游服务语言码**(ar/zh/es/en/ja 这类 ISO 639-1 风格短码)。本模块内部
维护 ``_SERVICE_TO_NEMO`` 把它转成 NeMo 需要的语言码。也兼容上游直接传 ISO 639-3
(arb/arz/... 等)的情况——会先折回服务码再查。
- **NeMo 不是所有语言都有 TN grammar**(如日语 ja 没有)。不支持的语言直接返回
原文透传。
- **懒加载 + 缓存**:``Normalizer`` 构建 grammar 较慢(秒级),按语言缓存实例。
- **失败降级**:``nemo_text_processing`` 未安装、grammar 构建失败、或 normalize
调用抛异常时,记 warning 并返回原文,绝不中断合成。
"""
import os
import time
import logging
from typing import Dict, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 语言码映射:上游服务码 / ISO 639-3 -> NeMo TN 语言码
#
# 仅列出 NeMo 目前有 TN grammar 的语言。未列出的(如 ja 日语)会跳过归一化。
# NeMo 语言码见 nemo_text_processing.text_normalization.normalize.Normalizer(lang=...)。
# 需要扩充时,确认对应语言在你安装的 nemo 版本里确有 TN grammar 后再加。
# ---------------------------------------------------------------------------
# 服务码(ISO 639-1 风格)-> NeMo 语言码
_SERVICE_TO_NEMO: Dict[str, str] = {
"ar": "ar",
"zh": "zh",
"es": "es",
"en": "en",
# "ja": NeMo 无日语 TN grammar,故意不列入 -> 跳过归一化
}
# ISO 639-3 -> 服务码
_ISO3_TO_SERVICE: Dict[str, str] = {
"arb": "ar", # standard arabic
"arz": "ar", # egyptian arabic
"ary": "ar", # moroccan arabic
"ars": "ar", # najdi arabic
"zho": "zh",
"cmn": "zh",
"spa": "es",
"eng": "en",
"jpn": "ja",
}
def _to_nemo_lang(lang: Optional[str]) -> Optional[str]:
"""把上游语言码映射成 NeMo TN 语言码;不支持归一化则返回 None。"""
if not lang:
return None
key = lang.lower()
if key in _SERVICE_TO_NEMO:
return _SERVICE_TO_NEMO[key]
# 上游可能直接传了 ISO 639-3(如 arb / spa),先折回服务码再查
svc = _ISO3_TO_SERVICE.get(key)
if svc and svc in _SERVICE_TO_NEMO:
return _SERVICE_TO_NEMO[svc]
return None
class TextNormalizer:
"""按语言懒加载并缓存 NeMo ``Normalizer`` 的封装。
单例式使用(见模块底部 ``get_text_normalizer()``),使 grammar 只构建一次并跨调用复用。
Args:
input_case: NeMo 的大小写处理模式。``"cased"`` 保留大小写(默认,适合含专有
名词/多语种混排的文本);``"lower_cased"`` 先转小写再归一化。
"""
def __init__(self, input_case: str = "cased"):
self.input_case = input_case
# nemo_lang -> Normalizer 实例;值为 None 表示该语言不可用(已尝试过并失败)
self._cache: Dict[str, Optional[object]] = {}
def _get_normalizer(self, nemo_lang: str):
"""返回缓存的 Normalizer;首次构建,失败则缓存 None 以避免反复重试。"""
if nemo_lang in self._cache:
return self._cache[nemo_lang]
normalizer = None
try:
from nemo_text_processing.text_normalization.normalize import Normalizer
normalizer = Normalizer(input_case=self.input_case, lang=nemo_lang)
logger.info(f"nemo Normalizer(lang={nemo_lang}) initialized")
except Exception as e:
logger.warning(
f"build nemo Normalizer(lang={nemo_lang}) failed -> "
f"skip text normalization for this language: {e}"
)
normalizer = None
self._cache[nemo_lang] = normalizer
return normalizer
def normalize(self, text: Optional[str], lang: Optional[str]) -> Optional[str]:
"""对 ``text`` 做文本归一化。
语言不支持 / NeMo 不可用 / 归一化抛异常时,原样返回 ``text``(降级透传)。
Args:
text: 待归一化文本。
lang: 上游语言码(服务码或 ISO 639-3)。
Returns:
归一化后的文本;无法处理时返回原文。
"""
if not text:
return text
nemo_lang = _to_nemo_lang(lang)
if nemo_lang is None:
# 语言无关模式或 NeMo 无该语言 TN(如 ja):跳过
return text
normalizer = self._get_normalizer(nemo_lang)
if normalizer is None:
return text
try:
return normalizer.normalize(text, verbose=False)
except Exception as e:
logger.warning(
f"text normalization failed (lang={lang}->{nemo_lang}) -> "
f"use raw text: {e}"
)
return text
_DEFAULT_NORMALIZER: Optional[TextNormalizer] = None
def get_text_normalizer(input_case: str = "cased") -> TextNormalizer:
"""返回进程级共享的 ``TextNormalizer`` 单例。"""
global _DEFAULT_NORMALIZER
if _DEFAULT_NORMALIZER is None:
_DEFAULT_NORMALIZER = TextNormalizer(input_case=input_case)
return _DEFAULT_NORMALIZER
def normalize_text(text: Optional[str], lang: Optional[str]) -> Optional[str]:
"""便捷入口:用共享单例对 ``text`` 按 ``lang`` 做归一化。"""
return get_text_normalizer().normalize(text, lang)
def print_nemo_results(lang, result_dir='nemo_tn_result'):
"""读取 result_{lang}.tsv 并逐行打印 nemo_result 列。"""
result_path = os.path.join(result_dir, f'result_{lang}_front.tsv')
if not os.path.exists(result_path):
print(f"[SKIP] {result_path} not found")
return
with open(result_path, 'r', encoding='utf-8') as f:
f.readline() # skip header
for line in f:
parts = line.strip().split('\t')
if len(parts) >= 4:
print(parts[3])
def get_nemo_result_main():
target_langs = ['ja']
normalize_root = 'nemo_tn_testdata'
output_dir = 'nemo_tn_result'
os.makedirs(output_dir, exist_ok=True)
normalizer = get_text_normalizer()
for lang in target_langs:
testset = os.path.join(normalize_root, f'testset_{lang}.tsv')
if not os.path.exists(testset):
print(f"[SKIP] {testset} not found")
continue
output_path = os.path.join(output_dir, f'result_{lang}.tsv')
total, match, mismatch = 0, 0, 0
t_start = time.perf_counter()
with open(testset, 'r', encoding='utf-8') as fin, \
open(output_path, 'w', encoding='utf-8') as fout:
header = fin.readline().strip()
fout.write(f"{header}\tnemo_result\tstatus\n")
for line in fin:
line = line.strip()
if not line:
continue
parts = line.split('\t')
if len(parts) < 3:
continue
sid, original, gt = parts[0], parts[1], parts[2]
nemo_result = normalizer.normalize(original, lang)
# 去掉首尾空格后比较
nemo_result = nemo_result.strip() if nemo_result else ""
gt = gt.strip()
status = "✅" if nemo_result == gt else "❌"
total += 1
if status == "✅":
match += 1
else:
mismatch += 1
fout.write(f"{sid}\t{original}\t{gt}\t{nemo_result}\t{status}\n")
elapsed = time.perf_counter() - t_start
avg_ms = elapsed / total * 1000 if total > 0 else 0
print(f"[{lang.upper()}] total={total}, match={match}, mismatch={mismatch}, "
f"accuracy={match/total*100:.1f}%, "
f"avg={avg_ms:.1f}ms/sentence, total_time={elapsed:.2f}s -> {output_path}")
if __name__ == '__main__':
print_nemo_results('zh')
@@ -0,0 +1,450 @@
# TTS Audio Suite patch: Bundled from the official IndexTTS 2.5 runtime to avoid upstream dependency pins conflicting with ComfyUI.
import base64
import os
from functools import lru_cache
from typing import Optional
import torch
from transformers import AutoTokenizer
from whisper.tokenizer import Tokenizer
import tiktoken
LANGUAGES = {
"en": "english",
"zh": "chinese",
"de": "german",
"es": "spanish",
"ru": "russian",
"ko": "korean",
"fr": "french",
"ja": "japanese",
"pt": "portuguese",
"tr": "turkish",
"pl": "polish",
"ca": "catalan",
"nl": "dutch",
"ar": "arabic",
"sv": "swedish",
"it": "italian",
"id": "indonesian",
"hi": "hindi",
"fi": "finnish",
"vi": "vietnamese",
"he": "hebrew",
"uk": "ukrainian",
"el": "greek",
"ms": "malay",
"cs": "czech",
"ro": "romanian",
"da": "danish",
"hu": "hungarian",
"ta": "tamil",
"no": "norwegian",
"th": "thai",
"ur": "urdu",
"hr": "croatian",
"bg": "bulgarian",
"lt": "lithuanian",
"la": "latin",
"mi": "maori",
"ml": "malayalam",
"cy": "welsh",
"sk": "slovak",
"te": "telugu",
"fa": "persian",
"lv": "latvian",
"bn": "bengali",
"sr": "serbian",
"az": "azerbaijani",
"sl": "slovenian",
"kn": "kannada",
"et": "estonian",
"mk": "macedonian",
"br": "breton",
"eu": "basque",
"is": "icelandic",
"hy": "armenian",
"ne": "nepali",
"mn": "mongolian",
"bs": "bosnian",
"kk": "kazakh",
"sq": "albanian",
"sw": "swahili",
"gl": "galician",
"mr": "marathi",
"pa": "punjabi",
"si": "sinhala",
"km": "khmer",
"sn": "shona",
"yo": "yoruba",
"so": "somali",
"af": "afrikaans",
"oc": "occitan",
"ka": "georgian",
"be": "belarusian",
"tg": "tajik",
"sd": "sindhi",
"gu": "gujarati",
"am": "amharic",
"yi": "yiddish",
"lo": "lao",
"uz": "uzbek",
"fo": "faroese",
"ht": "haitian creole",
"ps": "pashto",
"tk": "turkmen",
"nn": "nynorsk",
"mt": "maltese",
"sa": "sanskrit",
"lb": "luxembourgish",
"my": "myanmar",
"bo": "tibetan",
"tl": "tagalog",
"mg": "malagasy",
"as": "assamese",
"tt": "tatar",
"haw": "hawaiian",
"ln": "lingala",
"ha": "hausa",
"ba": "bashkir",
"jw": "javanese",
"su": "sundanese",
"yue": "cantonese",
"minnan": "minnan",
"wuyu": "wuyu",
"dialect": "dialect",
"zh/en": "zh/en",
"en/zh": "en/zh",
"common": "common",
}
# 增加 LANGUAGE_DICT 用于映射
LANGUAGE_DICT = {lang: index for index, lang in enumerate(LANGUAGES.keys())}
# language code lookup by name, with a few language aliases
TO_LANGUAGE_CODE = {
**{language: code for code, language in LANGUAGES.items()},
"burmese": "my",
"valencian": "ca",
"flemish": "nl",
"haitian": "ht",
"letzeburgesch": "lb",
"pushto": "ps",
"panjabi": "pa",
"moldavian": "ro",
"moldovan": "ro",
"sinhalese": "si",
"castilian": "es",
"mandarin": "zh",
}
AUDIO_EVENT = {
"ASR": "ASR",
"AED": "AED",
"SER": "SER",
"Speech": "Speech",
"/Speech": "/Speech",
"BGM": "BGM",
"/BGM": "/BGM",
"Laughter": "Laughter",
"/Laughter": "/Laughter",
"Applause": "Applause",
"/Applause": "/Applause",
}
EMOTION = {
"HAPPY": "HAPPY",
"SAD": "SAD",
"ANGRY": "ANGRY",
"NEUTRAL": "NEUTRAL",
}
TTS_Vocal_Token = {
"TTS/B": "TTS/B",
"TTS/O": "TTS/O",
"TTS/Q": "TTS/Q",
"TTS/A": "TTS/A",
"TTS/CO": "TTS/CO",
"TTS/CL": "TTS/CL",
"TTS/H": "TTS/H",
**{f"TTS/SP{i:02d}": f"TTS/SP{i:02d}" for i in range(1, 14)}
}
def lang_to_token(lang):
lang = lang.lower()
if lang not in LANGUAGE_DICT:
lang = "common"
return LANGUAGE_DICT[lang]
@lru_cache(maxsize=None)
def get_encoding(name: str = "gpt2", num_languages: int = 99, model_dir: str = "checkpoints"):
vocab_path = os.path.join(model_dir, f'{name}.tiktoken')
ranks = {
base64.b64decode(token): int(rank)
for token, rank in (line.split() for line in open(vocab_path) if line)
}
n_vocab = len(ranks)
special_tokens = {}
specials = [
"<|endoftext|>",
"<|startoftranscript|>",
*[f"<|{lang}|>" for lang in list(LANGUAGES.keys())[:num_languages]],
*[f"<|{audio_event}|>" for audio_event in list(AUDIO_EVENT.keys())],
*[f"<|{emotion}|>" for emotion in list(EMOTION.keys())],
"<|translate|>",
"<|transcribe|>",
"<|startoflm|>",
"<|startofprev|>",
"<|nospeech|>",
"<|notimestamps|>",
*[f"<|SPECIAL_TOKEN_{i}|>" for i in range(1, 31)], # register special tokens for ASR
*[f"<|{tts}|>" for tts in list(TTS_Vocal_Token.keys())], # register special tokens for TTS
*[f"<|{i * 0.02:.2f}|>" for i in range(1501)],
]
for token in specials:
special_tokens[token] = n_vocab
n_vocab += 1
return tiktoken.Encoding(
name=os.path.basename(vocab_path),
explicit_n_vocab=n_vocab,
pat_str=r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""",
mergeable_ranks=ranks,
special_tokens=special_tokens,
)
class WhisperTokenizer(Tokenizer):
"""
Whisper tokenizer 没有提供 tokenize, convert_tokens_to_ids, convert_ids_to_tokens 函数
如果使用 encode 将 str 转为 list[int] 再单独 decode 每个 token 会丢失上下文信息,对于像日语单个字符可能需要多个token来表示
所以无法单纯使用 token_list = [tokenizer.decode([token_id]) for token_id in token_ids] 去做 token->index 的转换
因此这里添加了 3 个函数来做这件事
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def tokenize(self, text, max_token_comb=4):
"""
将输入文本根据token切分开转为list[str]
通过智能组合token避免出现不完整的乱码字符
"""
token_ids = self.encode(text, allowed_special="all")
# 分组合并token以获得有意义的字符
tokens = []
i = 0
while i < len(token_ids):
# 从当前位置开始,尝试不同长度的组合
best_token = None
best_length = 0
# 尝试1到4个token的组合(根据需要可以调整这个范围)
for length in range(1, min(max_token_comb+1, len(token_ids) - i + 1)):
try:
candidate_ids = token_ids[i:i+length]
candidate_token = self.decode(candidate_ids)
# 检查是否是有效token(没有乱码)
if "\ufffd" not in candidate_token and candidate_token.strip():
best_token = candidate_token
best_length = length
break # 找到第一个有效的就停止
except:
continue
# 如果找到了有效token
if best_token is not None:
tokens.append(best_token)
i += best_length
else:
# 如果没有找到,就使用单个token(即使可能有乱码)
try:
single_token = self.decode([token_ids[i]])
tokens.append(single_token)
except:
tokens.append("<UNK>")
i += 1
return tokens
@lru_cache(maxsize=None)
def get_tokenizer(
multilingual: bool,
*,
num_languages: int = 99,
language: Optional[str] = None,
task: Optional[str] = None, # Literal["transcribe", "translate", None]
model_dir: str = "checkpoints",
) -> Tokenizer:
if language is not None:
language = language.lower()
if language not in LANGUAGES:
if language in TO_LANGUAGE_CODE:
language = TO_LANGUAGE_CODE[language]
else:
raise ValueError(f"Unsupported language: {language}")
if multilingual:
encoding_name = "multilingual_zh_ja_yue_char_del"
language = language or "en"
task = task or "transcribe"
else:
encoding_name = "gpt2"
language = None
task = None
encoding = get_encoding(name=encoding_name, num_languages=num_languages, model_dir=model_dir)
return WhisperTokenizer(
encoding=encoding, num_languages=num_languages, language=language, task=task
)
class QwenTokenizer():
def __init__(self, token_path, skip_special_tokens=True):
super().__init__()
# NOTE: non-chat model, all these special tokens keep randomly initialized.
special_tokens = {
'eos_token': '<|endoftext|>',
'pad_token': '<|endoftext|>',
'additional_special_tokens': [
'<|im_start|>', '<|im_end|>', '<|endofprompt|>',
'[breath]', '<strong>', '</strong>', '[noise]',
'[laughter]', '[cough]', '[clucking]', '[accent]',
'[quick_breath]',
"<laughter>", "</laughter>",
"[hissing]", "[sigh]", "[vocalized-noise]",
"[lipsmack]", "[mn]"
],
'nonverbalspeech38k_speech_tokens': [
'[snore]', '[throatclearing]', '[crying]',
'[sniff]', '[laughing]', '[coughing]',
'[gasp]', '[yawn]', '<B>', '</B>'
]
}
self.special_tokens = special_tokens
self.tokenizer = AutoTokenizer.from_pretrained(token_path)
self.tokenizer.add_special_tokens(special_tokens)
self.skip_special_tokens = skip_special_tokens
def encode(self, text, **kwargs):
tokens = self.tokenizer([text], return_tensors="pt")
tokens = tokens["input_ids"][0].cpu().tolist()
return tokens
def decode(self, tokens):
tokens = torch.tensor(tokens, dtype=torch.int64)
text = self.tokenizer.batch_decode([tokens], skip_special_tokens=self.skip_special_tokens)[0]
return text
@lru_cache(maxsize=None)
def get_qwen_tokenizer(
token_path: str,
skip_special_tokens: bool
) -> QwenTokenizer:
return QwenTokenizer(token_path=token_path, skip_special_tokens=skip_special_tokens)
if __name__ == "__main__":
text_list = [
"IndexTTS 正式发布1.0版本了,效果666",
"晕XUAN4是一种GAN3觉",
"我爱你!",
"I love you!",
"“我爱你”的英语是“I love you”",
"2.5平方电线",
"共465篇,约315万字",
"2002年的第一场雪,下在了2003年",
"速度是10km/h",
"现在是北京时间2025年01月11日 20:00",
"他这条裤子是2012年买的,花了200块钱",
"电话:135-4567-8900",
"1键3连",
"他这条视频点赞3000+,评论1000+,收藏500+",
"这是1024元的手机,你要吗?",
"受不liao3你了",
"“衣裳”不读衣chang2,而是读衣shang5",
"最zhong4要的是:不要chong2蹈覆辙",
"不zuo1死就不会死",
"See you at 8:00 AM",
"8:00 AM 开会",
"Couting down 3, 2, 1, go!",
"数到3就开始:1、2、3",
"This sales for 2.5% off, only $12.5.",
"5G网络是4G网络的升级版,2G网络是3G网络的前身",
"苹果于2030/1/2发布新 iPhone 2X 系列手机,最低售价仅 ¥12999",
"这酒...里...有毒...",
# 异常case
"只有,,,才是最好的",
"babala2是什么?", # babala二是什么?
"用beta1测试", # 用beta一测试
"have you ever been to beta2?", # have you ever been to beta two?
"such as XTTS, CosyVoice2, Fish-Speech, and F5-TTS", # such as xtts,cosyvoice two,fish-speech,and f five-tts
"where's the money?", # where is the money?
"who's there?", # who is there?
"which's the best?", # which is the best?
"how's it going?", # how is it going?
"今天是个好日子 it's a good day", # 今天是个好日子 it is a good day
# 人名
"约瑟夫·高登-莱维特(Joseph Gordon-Levitt is an American actor)",
"蒂莫西·唐纳德·库克(英文名:Timothy Donald Cook),通称蒂姆·库克(Tim Cook),美国商业经理、工业工程师和工业开发商,现任苹果公司首席执行官。",
# 长句子
"《盗梦空间》是由美国华纳兄弟影片公司出品的电影,由克里斯托弗·诺兰执导并编剧,莱昂纳多·迪卡普里奥、玛丽昂·歌迪亚、约瑟夫·高登-莱维特、艾利奥特·佩吉、汤姆·哈迪等联袂主演,2010年7月16日在美国上映,2010年9月1日在中国内地上映,2020年8月28日在中国内地重映。影片剧情游走于梦境与现实之间,被定义为“发生在意识结构内的当代动作科幻片”,讲述了由莱昂纳多·迪卡普里奥扮演的造梦师,带领特工团队进入他人梦境,从他人的潜意识中盗取机密,并重塑他人梦境的故事。",
"清晨拉开窗帘,阳光洒在窗台的Bloomixy花艺礼盒上——薰衣草香薰蜡烛唤醒嗅觉,永生花束折射出晨露般光泽。设计师将“自然绽放美学”融入每个细节:手工陶瓷花瓶可作首饰收纳,香薰精油含依兰依兰舒缓配方。限量款附赠《365天插花灵感手册》,让每个平凡日子都有花开仪式感。\n宴会厅灯光暗下的刹那,Glimmeria星月系列耳坠开始发光——瑞士冷珐琅工艺让蓝宝石如银河流动,钛合金骨架仅3.2g无负重感。设计师秘密:内置微型重力感应器,随步伐产生0.01mm振幅,打造“行走的星光”。七夕限定礼盒含星座定制铭牌,让爱意如星辰永恒闪耀。",
"电影1:“黑暗骑士”(演员:克里斯蒂安·贝尔、希斯·莱杰;导演:克里斯托弗·诺兰);电影2:“盗梦空间”(演员:莱昂纳多·迪卡普里奥;导演:克里斯托弗·诺兰);电影3:“钢琴家”(演员:艾德里安·布洛迪;导演:罗曼·波兰斯基);电影4:“泰坦尼克号”(演员:莱昂纳多·迪卡普里奥;导演:詹姆斯·卡梅隆);电影5:“阿凡达”(演员:萨姆·沃辛顿;导演:詹姆斯·卡梅隆);电影6:“南方公园:大电影”(演员:马特·斯通、托马斯·艾恩格瑞;导演:特雷·帕克)",
"そうですね、ほんと1年前、まあコロナだったので家のリビングからあの話して、すごい緊張してしまって、もう手が冷たくなったのを今でも覚えてるんですけど、新潟にいるメンバーが",
"また、青少年健全育成などに功績がある、市内の団体を表彰する団体省令の推薦も合わせて受け付けています",
"たねん、おんてきであるは、しかがどのにこうさんして、 しゅくんにたいしてゆみをひくとゆうことは。",
"実は昨年、11kgの減量にも成功していたという。",
]
from indextts.utils.common import tokenize_by_CJK_char
tokenizer = get_tokenizer(multilingual=True)
success_count = 0
error_count = 0
for raw_text in text_list:
# print(f"raw text: {text}")
text = tokenize_by_CJK_char(raw_text)
# print(f"cleaned text: {text}")
text_ja = f'<|ja|> {text}'
# 验证 tokenize 函数
tokens = tokenizer.tokenize(text_ja)
ret1 = text_ja == "".join(tokens)
print(f"tokens: {tokens}")
# print(text_ja == "".join(tokens))
# print(f"text_ja: {text_ja}")
# print("tokens: ", "".join(tokens))
# # 验证 convert_tokens_to_ids 和 convert_ids_to_tokens 函数
# ids = tokenizer.encode(text_ja, allowed_special="all")
# ids_to_tokens = tokenizer.convert_ids_to_tokens(ids)
# tokens_to_ids = tokenizer.convert_tokens_to_ids(ids_to_tokens)
# ret2 = ids == tokens_to_ids
# print(f"raw_ids : {ids}")
# print(f"tokens_to_ids: {tokens_to_ids}")
# print(ids == tokens_to_ids)
# if ret1 and ret2:
if ret1:
print("Success:", raw_text)
success_count += 1
else:
print("Error:", raw_text)
error_count += 1
print(f"Total Success: {success_count}, Total Error: {error_count}")
+17
View File
@@ -39,6 +39,23 @@ MOSS_MODEL_SPECS = {
"model-00003-of-00004.safetensors", "model-00004-of-00004.safetensors",
],
},
"moss-tts-v1.5-8b-voice-acting": {
"repo_id": "laion/moss-tts-v1.5-8b-voice-acting",
"architecture": "delay",
"role": "tts",
"display": "MOSS-TTS v1.5 Voice Acting 8B (Community - LAION)",
"description": "Community full fine-tune of MOSS-TTS v1.5 for expressive voice acting",
"codec_model": "MOSS-Audio-Tokenizer",
"sample_rate": 24000,
"audio_temperature": 0.8,
"audio_top_p": 0.95,
"audio_top_k": 25,
"audio_repetition_penalty": 1.1,
"max_new_tokens": 4096,
"required_files": [
"config.json", "processor_config.json", "tokenizer.json", "model.safetensors",
],
},
"MOSS-TTS": {
"repo_id": "OpenMOSS-Team/MOSS-TTS",
"architecture": "delay",
+32 -3
View File
@@ -220,7 +220,11 @@ class MossTTSEngine:
if configured_name and configured_name == expected_name:
return
compatible_delay_bases = {"moss-tts", "moss-tts-v1.5"}
compatible_delay_bases = {
"moss-tts",
"moss-tts-v1.5",
"moss-tts-v1.5-8b-voice-acting",
}
if configured_name in compatible_delay_bases and expected_name in compatible_delay_bases:
print(
"⚠️ MOSS LoRA base version differs: "
@@ -244,6 +248,31 @@ class MossTTSEngine:
"Use the matching MOSS variant or a LoRA trained for this model."
)
def _resolve_model_architecture(self) -> str:
canonical = str(self.model_variant or "").removeprefix("local:")
known_architecture = self.MODEL_VARIANTS.get(canonical, {}).get("architecture")
if known_architecture:
return str(known_architecture)
config_path = os.path.join(self.model_path, "config.json")
try:
with open(config_path, "r", encoding="utf-8") as handle:
config = json.load(handle)
except Exception as e:
raise RuntimeError(
f"Cannot identify local MOSS model architecture from '{config_path}': {e}"
) from e
if config.get("local_num_layers") is not None:
return "local"
if config.get("model_type") == "moss_tts_delay" and int(config.get("n_vq", 0) or 0) == 32:
return "delay"
raise RuntimeError(
"Unsupported local MOSS model architecture. Community full checkpoints must use the "
"MOSS local-transformer layout or the 32-codebook MOSS-TTS Delay layout. "
f"Found model_type={config.get('model_type')!r}, n_vq={config.get('n_vq')!r}."
)
def _ensure_model_loaded(self) -> None:
if self._model is not None and self._processor is not None:
return
@@ -262,7 +291,7 @@ class MossTTSEngine:
if self.lora_adapter:
print(f" LoRA: {self.lora_adapter}")
architecture = self.MODEL_VARIANTS.get(self.model_variant, {}).get("architecture", "local")
architecture = self._resolve_model_architecture()
if architecture == "local":
package_base = "engines.moss_tts.impl.local_transformer"
elif architecture == "ttsd":
@@ -518,7 +547,7 @@ class MossTTSEngine:
max_new_tokens: int,
n_vq_for_inference: Optional[int] = None,
):
architecture = self.MODEL_VARIANTS.get(self.model_variant, {}).get("architecture", "local")
architecture = self._resolve_model_architecture()
if architecture == "local":
return self._model.generate(
input_ids=input_ids,
+7 -2
View File
@@ -23,10 +23,15 @@ FRIENDLY_VARIANT_MAP = {
"8B (Delay)": "MOSS-TTS",
"Recommended 8B v1.5 (Delay)": "MOSS-TTS-v1.5",
"Legacy 8B v1.0 (Delay)": "MOSS-TTS",
"Voice Acting 8B (Community - LAION)": "moss-tts-v1.5-8b-voice-acting",
"Native 8B Dialogue (MOSS-TTSD-v1.0)": "MOSS-TTSD-v1.0",
}
SUPPORTED_DELAY_TRAINING_VARIANTS = {"MOSS-TTS", "MOSS-TTS-v1.5"}
SUPPORTED_DELAY_TRAINING_VARIANTS = {
"MOSS-TTS",
"MOSS-TTS-v1.5",
"moss-tts-v1.5-8b-voice-acting",
}
MOSS_DATASET_AUDIO_EXTENSIONS = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
@@ -175,7 +180,7 @@ def resolve_delay_training_variant(config: Dict[str, Any]) -> str:
variant = resolve_variant_name(config.get("model_variant", "MOSS-TTS"))
if variant not in SUPPORTED_DELAY_TRAINING_VARIANTS:
raise RuntimeError(
"MOSS training supports the Delay 8B v1.0 and v1.5 models only. "
"MOSS training supports the Delay 8B v1.0/v1.5 models and compatible registered Delay fine-tunes only. "
f"Selected variant '{variant}' is not supported yet."
)
return variant
+36 -12
View File
@@ -76,7 +76,20 @@ class IndexTTSProcessor:
"""
self.config = engine_config
self.adapter = IndexTTSAdapter()
self.character_parser = CharacterParser()
language_defaults = {
"English": "en",
"Chinese": "zh",
"Japanese": "ja",
"Spanish": "es",
"Arabic": "ar",
}
configured_language = str(engine_config.get("language", "English"))
self.character_parser = CharacterParser(
default_language=language_defaults.get(
configured_language,
configured_language.lower(),
)
)
self.pause_processor = PauseTagProcessor()
self.sample_rate = 22050 # IndexTTS-2 native sample rate
@@ -140,10 +153,10 @@ class IndexTTSProcessor:
speaker_audio: Optional[Dict] = None,
reference_text: str = "",
seed: int = 1,
enable_chunking: bool = True,
max_chars_per_chunk: int = 400,
silence_between_chunks_ms: int = 100,
return_info: bool = False):
enable_chunking: bool = True,
max_chars_per_chunk: int = 400,
silence_between_chunks_ms: int = 100,
return_info: bool = False):
"""
Process text and generate audio with IndexTTS-2.
@@ -155,7 +168,7 @@ class IndexTTSProcessor:
enable_chunking: Whether to chunk long text (may be disabled for IndexTTS-2)
max_chars_per_chunk: Maximum characters per chunk
silence_between_chunks_ms: Silence between segments
return_info: If True, return (audio, chunk_info) tuple
return_info: If True, return (audio, chunk_info) tuple
Returns:
Generated audio tensor, or (tensor, chunk_info) if return_info=True
@@ -182,6 +195,7 @@ class IndexTTSProcessor:
# Parse character segments with emotion support and parameters
character_segment_objects = self.character_parser.parse_text_segments(text)
character_segments = [(seg.character, seg.text, seg.language, seg.emotion) for seg in character_segment_objects]
any_inline_edit_tags = False
for seg in character_segment_objects:
_, seg_edit_tags = get_edit_tags_for_segment(seg.text)
@@ -254,6 +268,7 @@ class IndexTTSProcessor:
segment_params: Optional[Dict[str, Any]] = None,
character_name: Optional[str] = None,
emotion_reference: Optional[str] = None,
segment_language: Optional[str] = None,
) -> torch.Tensor:
# Import references for nested function scope
import torchaudio as ta
@@ -385,8 +400,11 @@ class IndexTTSProcessor:
length_penalty=current_config.get('length_penalty', 0.0),
num_beams=current_config.get('num_beams', 3),
repetition_penalty=current_config.get('repetition_penalty', 10.0),
max_mel_tokens=current_config.get('max_mel_tokens', 1500),
stream_return=current_config.get('stream_return', False),
max_mel_tokens=current_config.get('max_mel_tokens', 1500),
language=language or current_config.get('language', 'English'),
duration_factor=current_config.get('duration_factor', 1.0),
text_normalization=current_config.get('text_normalization', True),
stream_return=current_config.get('stream_return', False),
more_segment_before=current_config.get('more_segment_before', 0)
)
@@ -492,8 +510,11 @@ class IndexTTSProcessor:
length_penalty=current_config.get('length_penalty', 0.0),
num_beams=current_config.get('num_beams', 3),
repetition_penalty=current_config.get('repetition_penalty', 10.0),
max_mel_tokens=current_config.get('max_mel_tokens', 1500),
stream_return=current_config.get('stream_return', False),
max_mel_tokens=current_config.get('max_mel_tokens', 1500),
language=segment_language or current_config.get('language', 'English'),
duration_factor=current_config.get('duration_factor', 1.0),
text_normalization=current_config.get('text_normalization', True),
stream_return=current_config.get('stream_return', False),
more_segment_before=current_config.get('more_segment_before', 0)
)
@@ -547,6 +568,7 @@ class IndexTTSProcessor:
seg_obj.parameters,
seg_obj.character,
seg_obj.emotion,
seg_obj.language,
)
if isinstance(segment_audio, torch.Tensor) and segment_audio.numel() > 0:
if segment_audio.dim() == 1:
@@ -581,7 +603,8 @@ class IndexTTSProcessor:
segment_params = character_segment_objects[0].parameters if character_segment_objects and character_segment_objects[0].parameters else None
character_name = character_segment_objects[0].character if character_segment_objects else None
emotion_reference = character_segment_objects[0].emotion if character_segment_objects else None
return tts_generate_func(text_content, segment_params, character_name, emotion_reference)
segment_language = character_segment_objects[0].language if character_segment_objects else None
return tts_generate_func(text_content, segment_params, character_name, emotion_reference, segment_language)
# Generate audio with pauses
if segments:
@@ -595,7 +618,8 @@ class IndexTTSProcessor:
segment_params = character_segment_objects[0].parameters if character_segment_objects and character_segment_objects[0].parameters else None
character_name = character_segment_objects[0].character if character_segment_objects else None
emotion_reference = character_segment_objects[0].emotion if character_segment_objects else None
result = tts_generate_func(text, segment_params, character_name, emotion_reference)
segment_language = character_segment_objects[0].language if character_segment_objects else None
result = tts_generate_func(text, segment_params, character_name, emotion_reference, segment_language)
# Ensure correct tensor format
if isinstance(result, torch.Tensor):
+2
View File
@@ -866,6 +866,8 @@ class TTSAudioInstaller:
"safetensors>=0.6.2", # Required by MOSS-TTS HF checkpoints
"orjson>=3.11.0", # Required by MOSS-TTS remote code
"tiktoken>=0.12.0", # Required by MOSS-TTS tokenizer
"fugashi>=1.4.0", # IndexTTS-2.5 Japanese G2P
"unidic-lite>=1.0.8", # IndexTTS-2.5 Japanese dictionary
# NOTE: opencv-python and pillow installed via install_problematic_packages() with --no-deps
# to prevent forced numpy/pillow downgrades
+15 -3
View File
@@ -11,7 +11,7 @@ except ImportError:
pass
# Version and constants
VERSION = "5.6.5"
VERSION = "5.8.1"
IS_DEV = False # Set to False for release builds
VERSION_DISPLAY = f"v{VERSION}" + (" (dev)" if IS_DEV else "")
SEPARATOR = "=" * 70
@@ -195,6 +195,14 @@ except Exception as e:
print(f"❌ Fish Audio S2 Pro Engine failed: {e}")
FISH_AUDIO_S2_ENGINE_AVAILABLE = False
try:
audio_cpp_engine_module = load_node_module("audio_cpp_engine_node", "engines/audio_cpp_engine_node.py")
AudioCppEngineNode = audio_cpp_engine_module.AudioCppEngineNode
AUDIO_CPP_ENGINE_AVAILABLE = True
except Exception as e:
print(f"❌ audio.cpp Engine failed: {e}")
AUDIO_CPP_ENGINE_AVAILABLE = False
try:
omnivoice_engine_module = load_node_module("omnivoice_engine_node", "engines/omnivoice_engine_node.py")
OmniVoiceEngineNode = omnivoice_engine_module.OmniVoiceEngineNode
@@ -224,7 +232,7 @@ try:
IndexTTSEngineNode = index_tts_engine_module.IndexTTSEngineNode
INDEX_TTS_ENGINE_AVAILABLE = True
except Exception as e:
print(f"❌ IndexTTS-2 Engine failed: {e}")
print(f"❌ IndexTTS Engine failed: {e}")
INDEX_TTS_ENGINE_AVAILABLE = False
try:
@@ -702,6 +710,10 @@ if FISH_AUDIO_S2_ENGINE_AVAILABLE:
NODE_CLASS_MAPPINGS["FishAudioS2EngineNode"] = FishAudioS2EngineNode
NODE_DISPLAY_NAME_MAPPINGS["FishAudioS2EngineNode"] = "⚙️ Fish Audio S2 Pro Engine"
if AUDIO_CPP_ENGINE_AVAILABLE:
NODE_CLASS_MAPPINGS["AudioCppEngineNode"] = AudioCppEngineNode
NODE_DISPLAY_NAME_MAPPINGS["AudioCppEngineNode"] = "⚙️ audio.cpp Multi-TTS Engine"
if OMNIVOICE_ENGINE_AVAILABLE:
NODE_CLASS_MAPPINGS["OmniVoiceEngineNode"] = OmniVoiceEngineNode
NODE_DISPLAY_NAME_MAPPINGS["OmniVoiceEngineNode"] = "⚙️ OmniVoice Engine"
@@ -716,7 +728,7 @@ if CHATTERBOX_OFFICIAL_23LANG_ENGINE_AVAILABLE:
if INDEX_TTS_ENGINE_AVAILABLE:
NODE_CLASS_MAPPINGS["IndexTTSEngineNode"] = IndexTTSEngineNode
NODE_DISPLAY_NAME_MAPPINGS["IndexTTSEngineNode"] = "⚙️ IndexTTS-2 Engine"
NODE_DISPLAY_NAME_MAPPINGS["IndexTTSEngineNode"] = "⚙️ IndexTTS 2 / 2.5 Engine"
if COSYVOICE_ENGINE_AVAILABLE:
NODE_CLASS_MAPPINGS["CosyVoiceEngineNode"] = CosyVoiceEngineNode
+16
View File
@@ -0,0 +1,16 @@
"""audio.cpp processor exports."""
from .audio_cpp_processor import AudioCPPProcessor, AudioCppProcessor
from .audio_cpp_srt_processor import (
AudioCPPSRTProcessor,
AudioCppSRTProcessor,
AudioCppSubtitleProcessor,
)
__all__ = [
"AudioCppProcessor",
"AudioCPPProcessor",
"AudioCppSRTProcessor",
"AudioCPPSRTProcessor",
"AudioCppSubtitleProcessor",
]
+412
View File
@@ -0,0 +1,412 @@
"""Text orchestration for the generic audio.cpp TTS engine."""
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
import torch
from utils.audio.chunk_combiner import ChunkCombiner
from utils.text.character_parser import character_parser
from utils.text.pause_processor import PauseTagProcessor
from utils.text.segment_parameters import ParameterValidator, apply_segment_parameters
from utils.text.step_audio_editx_special_tags import get_edit_tags_for_segment
from utils.voice.character_logging import (
format_resolved_character_block,
resolved_character_label,
)
from utils.voice.discovery import get_available_characters, get_character_mapping, voice_discovery
from utils.voice.reference import effective_voice_audio
class AudioCppProcessor:
"""Apply suite text features while accepting the runtime's response sample rate."""
_RUNTIME_KEYS = (
"connection_mode",
"server_url",
"external_server_url",
"binary_path",
"family",
"package_id",
"model_path",
"model_id",
"task",
"backend",
"device",
)
def __init__(self, adapter: Any, engine_config: Optional[Dict[str, Any]] = None):
self.adapter = adapter
self.config = dict(engine_config or {})
self._sample_rate: Optional[int] = None
@property
def sample_rate(self) -> Optional[int]:
return self._sample_rate
def update_config(self, new_config: Optional[Dict[str, Any]]) -> None:
new_value = dict(new_config or {})
old_signature = tuple(self.config.get(key) for key in self._RUNTIME_KEYS)
new_signature = tuple(new_value.get(key) for key in self._RUNTIME_KEYS)
if old_signature != new_signature:
self._sample_rate = None
self.config = new_value
self.adapter.update_config(new_value)
def reset_sample_rate(self) -> None:
"""Begin a top-level generation without retaining an old server rate."""
self._sample_rate = None
@staticmethod
def _check_interrupt() -> None:
try:
import comfy.model_management as model_management
if getattr(model_management, "interrupt_processing", False) is True:
raise InterruptedError("audio.cpp generation interrupted by user")
except ImportError:
return
def _adopt_sample_rate(self, sample_rate: Any) -> int:
try:
value = int(sample_rate)
except (TypeError, ValueError) as exc:
raise ValueError(f"audio.cpp returned invalid sample rate: {sample_rate!r}") from exc
if value <= 0:
raise ValueError(f"audio.cpp returned invalid sample rate: {value}")
if self._sample_rate is None:
self._sample_rate = value
elif self._sample_rate != value:
raise RuntimeError(
"audio.cpp returned inconsistent sample rates in one generation "
f"({self._sample_rate} Hz then {value} Hz)"
)
return value
def _setup_character_parser(self, text: str) -> None:
language = str(self.config.get("language", "auto") or "auto").strip()
fallback = "en" if language.lower() in {"", "auto", "none"} else language.lower()
character_parser.language_resolver.default_language = fallback
character_parser.default_language = fallback
tagged = []
for raw in re.findall(r"\[([^\]]+)\]", text or ""):
name = raw.split("|", 1)[0].strip()
if name and not name.lower().startswith(("pause:", "wait:", "stop:")):
tagged.append(name)
available = {str(item).lower() for item in (get_available_characters() or [])}
for alias, target in voice_discovery.get_character_aliases().items():
available.update((str(alias).lower(), str(target).lower()))
available.update(name.lower() for name in tagged)
available.add("narrator")
character_parser.set_available_characters(sorted(available))
for character, default_language in voice_discovery.get_character_language_defaults().items():
character_parser.set_character_language_default(character, default_language)
character_parser.reset_session_cache()
@staticmethod
def _should_apply_segment_language(segment: Any, base_config: Mapping[str, Any]) -> bool:
language = str(getattr(segment, "language", "") or "").strip()
if not language:
return False
if getattr(segment, "explicit_language", False):
return True
global_language = str(base_config.get("language", "auto") or "auto").strip().lower()
parser_fallback = str(character_parser.default_language or "").strip().lower()
return language.lower() != parser_fallback and language.lower() != global_language
@staticmethod
def _voice_for_character(
character: str,
voice_mapping: Mapping[str, Any],
discovered: Mapping[str, Tuple[Optional[str], Optional[str]]],
) -> Dict[str, Any]:
narrator = voice_mapping.get("narrator", {})
voice = dict(narrator) if isinstance(narrator, Mapping) else {"audio": narrator}
if character != "narrator" and character in voice_mapping:
selected = voice_mapping[character]
return dict(selected) if isinstance(selected, Mapping) else {"audio": selected}
if character != "narrator":
audio_path, reference_text = discovered.get(character, (None, None))
if audio_path:
return {"audio_path": audio_path, "reference_text": reference_text or ""}
return voice
@staticmethod
def _chunks(text: str, enabled: bool, max_chars: int) -> List[str]:
if not enabled:
return [text]
from utils.text.chunking import ImprovedChatterBoxChunker
limit = ImprovedChatterBoxChunker.validate_chunking_params(max_chars)
return ImprovedChatterBoxChunker.split_into_chunks(text, max_chars=limit)
@staticmethod
def _voice_log_note(voice_ref: Mapping[str, Any]) -> str:
if not isinstance(voice_ref, Mapping) or effective_voice_audio(voice_ref) is None:
return " [no voice reference - model default]"
reference_text = str(voice_ref.get("reference_text") or "").strip()
if reference_text:
return f" [ref text: {len(reference_text)} chars]"
return ""
@staticmethod
def _format_parameter_log(
parameters: Mapping[str, Any], current_config: Mapping[str, Any], current_seed: int
) -> str:
if not parameters:
return ""
parts = []
for key in parameters:
if key == "seed":
value = current_seed
else:
value = current_config.get(key, parameters.get(key))
if value is not None and value != "":
parts.append(f"{key}={value}")
return ", ".join(parts)
def _log_generation_text(
self,
character: str,
text: str,
voice_ref: Mapping[str, Any],
language: str,
family: str,
chunk_count: int,
parameter_log: str,
) -> None:
display_name = resolved_character_label(character, voice_ref)
voice_note = self._voice_log_note(voice_ref)
print(
f"🎭 Audio.cpp ({family}) - Generating for '{display_name}' "
f"(Language: {language}){voice_note}:"
)
if parameter_log:
print(f"🎛️ Audio.cpp params: {parameter_log}")
print(format_resolved_character_block(character, text, voice_ref))
if chunk_count > 1:
print(
f"📝 Chunking {display_name}'s text into {chunk_count} chunks "
f"(Language: {language}){voice_note}"
)
def get_character_order(self, text: str) -> List[str]:
self._setup_character_parser(text)
seen: List[str] = []
for segment in character_parser.parse_text_segments(text, engine_type="audio_cpp"):
character = segment.character or "narrator"
if character not in seen:
seen.append(character)
return seen
def process_text(
self,
text: str,
voice_mapping: Optional[Dict[str, Any]],
seed: int,
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,
apply_edit_postprocessing: bool = True,
show_text_logging: bool = True,
reset_sample_rate: bool = True,
**_: Any,
) -> List[Dict[str, Any]]:
del chunk_combination_method, silence_between_chunks_ms
if reset_sample_rate:
self.reset_sample_rate()
self._check_interrupt()
voice_mapping = dict(voice_mapping or {})
self._setup_character_parser(text)
base_config = self.config.copy()
segments = character_parser.parse_text_segments(text, engine_type="audio_cpp")
if not segments and str(text or "").strip():
segments = character_parser.parse_text_segments(
f"[narrator]{text}", engine_type="audio_cpp"
)
characters = list({segment.character for segment in segments if segment.character})
# GLM-TTS requires the transcript paired with its reference voice.
# Other pinned families accept audio-only discovery and still receive a
# transcript whenever one exists beside the character audio file.
try:
from utils.audio_cpp.capabilities import get_capability
transcript_requirement = get_capability(
str(base_config.get("family", ""))
)["reference_transcript"]
except (ImportError, KeyError, ValueError):
transcript_requirement = "none"
discovery_type = (
"audio_and_text" if transcript_requirement == "required" else "audio_only"
)
discovered = get_character_mapping(characters, engine_type=discovery_type)
configured_speakers = list(base_config.get("speaker_references") or [])
ordered_characters = []
for segment in segments:
name = segment.character or "narrator"
if name not in ordered_characters:
ordered_characters.append(name)
for index, reference in enumerate(configured_speakers, start=1):
if index < len(ordered_characters):
selected = reference if isinstance(reference, Mapping) else {"audio": reference}
voice_mapping[ordered_characters[index]] = dict(selected)
records: List[Dict[str, Any]] = []
for segment in segments:
self._check_interrupt()
segment_text = str(segment.text or "").strip()
if not segment_text:
continue
character = segment.character or "narrator"
parameters = dict(segment.parameters or {})
filtered_parameters: Dict[str, Any] = {}
current_config = base_config
current_seed = int(seed)
if parameters:
filtered_parameters = ParameterValidator.filter_parameters_for_engine(
parameters, "audio_cpp"
)
current_config = apply_segment_parameters(base_config, parameters, "audio_cpp")
current_seed = int(current_config.get("seed", seed))
if self._should_apply_segment_language(segment, base_config):
current_config = current_config.copy()
current_config["language"] = segment.language
self.adapter.update_config(current_config)
voice_ref = self._voice_for_character(character, voice_mapping, discovered)
try:
from utils.audio_cpp.capabilities import CapabilityError, validate_voice_reference
except ImportError:
validate_voice_reference = None
if validate_voice_reference is not None:
try:
validate_voice_reference(
str(base_config.get("family", "")), voice_ref, character
)
except CapabilityError:
# Preserve lightweight processor use before a concrete family
# has been selected, while enforcing every known family.
pass
def generate_fragment(content: str, edit_tags: List[Any]) -> None:
chunks = self._chunks(content, enable_chunking, max_chars_per_chunk)
if show_text_logging:
language = str(current_config.get("language", "auto") or "auto")
family = str(current_config.get("family", "unknown") or "unknown")
self._log_generation_text(
character,
content,
voice_ref,
language,
family,
len(chunks),
self._format_parameter_log(
filtered_parameters, current_config, current_seed
),
)
for chunk_index, chunk in enumerate(chunks):
self._check_interrupt()
waveform, response_rate = self.adapter.generate_single(
text=chunk,
voice_ref=voice_ref,
seed=current_seed + chunk_index,
enable_audio_cache=enable_audio_cache,
character_name=character,
)
sample_rate = self._adopt_sample_rate(response_rate)
waveform = waveform.detach().to(device="cpu", dtype=torch.float32)
if waveform.dim() == 1:
waveform = waveform.unsqueeze(0)
if waveform.dim() != 2:
raise ValueError(
f"audio.cpp waveform must be [channels, samples], got {tuple(waveform.shape)}"
)
records.append(
{
"waveform": waveform,
"sample_rate": sample_rate,
"text": chunk,
"edit_tags": edit_tags if chunk_index == 0 else [],
}
)
if PauseTagProcessor.has_pause_tags(segment_text):
pause_parts, _ = PauseTagProcessor.parse_pause_tags(segment_text)
for part_type, content in pause_parts:
if part_type == "text":
clean_text, edit_tags = get_edit_tags_for_segment(str(content))
if clean_text.strip():
generate_fragment(clean_text.strip(), edit_tags)
else:
records.append(
{
"pause_duration": float(content),
"text": f"[pause:{content}s]",
"edit_tags": [],
}
)
else:
clean_text, edit_tags = get_edit_tags_for_segment(segment_text)
if clean_text.strip():
generate_fragment(clean_text.strip(), edit_tags)
self.adapter.update_config(base_config)
if any("pause_duration" in record for record in records):
if self._sample_rate is None:
raise ValueError("audio.cpp cannot render pauses before any response sample rate is known")
for record in records:
if "pause_duration" not in record:
continue
record["waveform"] = PauseTagProcessor.create_silence_segment(
record.pop("pause_duration"), self._sample_rate, torch.device("cpu"), torch.float32
)
record["sample_rate"] = self._sample_rate
if apply_edit_postprocessing and records and any(record.get("edit_tags") for record in records):
from utils.audio.edit_post_processor import process_segments as apply_edits
records = apply_edits(records, engine_config=base_config)
for record in records:
self._adopt_sample_rate(record.get("sample_rate"))
return records
def combine_audio_segments(
self,
segments: List[Dict[str, Any]],
method: str = "auto",
silence_ms: int = 100,
original_text: str = "",
return_info: bool = False,
) -> Union[torch.Tensor, Tuple[torch.Tensor, Dict[str, Any]]]:
if not segments:
empty = torch.zeros(0, dtype=torch.float32)
return (empty, {}) if return_info else empty
rates = {self._adopt_sample_rate(segment.get("sample_rate")) for segment in segments}
if len(rates) != 1:
raise RuntimeError(f"audio.cpp segments use inconsistent sample rates: {sorted(rates)}")
sample_rate = rates.pop()
waveforms = [segment["waveform"] for segment in segments]
text_chunks = [str(segment.get("text", "")) for segment in segments]
result = ChunkCombiner.combine_chunks(
audio_segments=waveforms,
method=method,
silence_ms=int(silence_ms),
crossfade_duration=0.1,
sample_rate=sample_rate,
text_length=len(" ".join(text_chunks)),
original_text=original_text,
text_chunks=text_chunks,
return_info=return_info,
)
return result
# Compatibility with integration code that uses an all-caps acronym.
AudioCPPProcessor = AudioCppProcessor
+238
View File
@@ -0,0 +1,238 @@
"""SRT timing orchestration for audio.cpp with a response-defined sample rate."""
from __future__ import annotations
import importlib.util
import os
from typing import Any, Dict, List, Optional, Tuple
import torch
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
from utils.timing.reporting import SRTReportGenerator
def _processor_class():
"""Load by path because this project also has a top-level ``nodes.py`` module."""
path = os.path.join(os.path.dirname(__file__), "audio_cpp_processor.py")
spec = importlib.util.spec_from_file_location("audio_cpp_processor_module", path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load audio.cpp processor from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.AudioCppProcessor
def _adapter_class():
"""Load directly so unrelated optional adapters are not imported eagerly."""
path = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "engines", "adapters", "audio_cpp_adapter.py")
)
spec = importlib.util.spec_from_file_location("audio_cpp_adapter_module", path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load audio.cpp adapter from {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.AudioCppEngineAdapter
class AudioCppSRTProcessor:
"""Generate one subtitle cue at a time and assemble it on the SRT timeline."""
def __init__(self, node_instance: Any, config: Optional[Dict[str, Any]] = None):
self.node_instance = node_instance
self.config = dict(config or {})
self.adapter = _adapter_class()(self.config)
self._processor = _processor_class()(self.adapter, self.config)
success, modules, message = import_manager.import_srt_modules()
if not success or modules.get("SRTParser") is None:
raise ImportError(f"audio.cpp SRT unavailable: {message}")
self.SRTParser = modules["SRTParser"]
@property
def processor(self) -> Any:
return self._processor
@property
def sample_rate(self) -> Optional[int]:
return self.processor.sample_rate
def update_config(self, config: Optional[Dict[str, Any]]) -> None:
self.config = dict(config or {})
self.processor.update_config(self.config)
@staticmethod
def _check_interrupt(index: Optional[int] = None, total: Optional[int] = None) -> None:
try:
import comfy.model_management as model_management
if getattr(model_management, "interrupt_processing", False) is True:
location = f" at subtitle {index + 1}/{total}" if index is not None else ""
raise InterruptedError(f"audio.cpp SRT generation interrupted{location}")
except ImportError:
return
@staticmethod
def _adjustment(index: int, subtitle: Any, audio: torch.Tensor, sample_rate: int) -> Dict[str, Any]:
natural = audio.shape[-1] / sample_rate
target = float(subtitle.duration)
ratio = target / natural if natural > 0 else 1.0
return {
"index": index,
"segment_index": index,
"sequence": subtitle.sequence,
"natural_duration": natural,
"target_start": subtitle.start_time,
"target_end": subtitle.end_time,
"target_duration": target,
"start_time": subtitle.start_time,
"end_time": subtitle.end_time,
"stretch_factor": ratio,
"needs_stretching": abs(ratio - 1.0) > 0.05,
"stretch_type": "compress" if ratio < 1 else "expand" if ratio > 1 else "none",
"adjustment": natural - target,
"adjusted_start": subtitle.start_time,
"adjusted_end": subtitle.end_time,
"adjusted_duration": natural,
}
def process_srt_content(
self,
srt_content: str,
voice_mapping: Optional[Dict[str, Any]],
seed: int,
timing_mode: str,
timing_params: Optional[Dict[str, Any]],
enable_audio_cache: bool = True,
) -> Tuple[Dict[str, Any], str, str, str]:
self._check_interrupt()
subtitles = self.SRTParser().parse_srt_content(srt_content, allow_overlaps=True)
if not subtitles:
raise ValueError("audio.cpp SRT input contains no subtitles")
has_overlaps = SRTOverlapHandler.detect_overlaps(subtitles)
active_mode, switched = SRTOverlapHandler.handle_smart_natural_fallback(
timing_mode, has_overlaps, "audio.cpp SRT"
)
self.processor.reset_sample_rate()
audio_segments: List[Optional[torch.Tensor]] = []
for index, subtitle in enumerate(subtitles):
self._check_interrupt(index, len(subtitles))
text = str(subtitle.text or "").strip()
if not text:
audio_segments.append(None)
continue
records = self.processor.process_text(
text=text,
voice_mapping=voice_mapping or {},
seed=int(seed) + index,
enable_chunking=False,
enable_audio_cache=enable_audio_cache,
apply_edit_postprocessing=True,
show_text_logging=True,
reset_sample_rate=False,
)
if not records:
raise RuntimeError(f"audio.cpp produced no audio for subtitle {index + 1}")
audio = self.processor.combine_audio_segments(
records, method="auto", silence_ms=0, original_text=text
)
if audio.dim() == 1:
audio = audio.unsqueeze(0)
elif audio.dim() == 3 and audio.shape[0] == 1:
audio = audio.squeeze(0)
audio_segments.append(audio.detach().to(device="cpu", dtype=torch.float32))
sample_rate = self.processor.sample_rate
if sample_rate is None:
raise ValueError("audio.cpp could not determine a sample rate from the SRT content")
completed_segments: List[torch.Tensor] = []
for subtitle, audio in zip(subtitles, audio_segments):
if audio is None:
audio = torch.zeros(1, int(float(subtitle.duration) * sample_rate), dtype=torch.float32)
completed_segments.append(audio)
adjustments = [
self._adjustment(index, subtitle, completed_segments[index], sample_rate)
for index, subtitle in enumerate(subtitles)
]
self._check_interrupt()
final_audio, replacement, stretch_method = self._assemble(
completed_segments, subtitles, active_mode, dict(timing_params or {}), sample_rate
)
if replacement is not None:
adjustments = replacement
reporter = SRTReportGenerator()
report = reporter.generate_timing_report(
subtitles,
adjustments,
active_mode,
has_overlaps,
switched,
timing_mode if switched else None,
stretch_method,
)
adjusted_srt = reporter.generate_adjusted_srt_string(subtitles, adjustments, active_mode)
if final_audio.dim() == 1:
final_audio = final_audio.unsqueeze(0).unsqueeze(0)
elif final_audio.dim() == 2:
final_audio = final_audio.unsqueeze(0)
duration = final_audio.shape[-1] / sample_rate
mode_info = f"{active_mode} (switched from {timing_mode})" if switched else active_mode
info = (
f"Generated {duration:.1f}s audio.cpp SRT audio from {len(subtitles)} subtitles "
f"using {mode_info} mode at {sample_rate} Hz"
)
return {"waveform": final_audio, "sample_rate": sample_rate}, info, report, adjusted_srt
@staticmethod
def _assemble(
audio_segments: List[torch.Tensor],
subtitles: List[Any],
mode: str,
params: Dict[str, Any],
sample_rate: int,
):
fade = params.get("fade_for_StretchToFit", 0.01)
if mode == "stretch_to_fit":
from engines.chatterbox.audio_timing import TimedAudioAssembler
assembler = TimedAudioAssembler(sample_rate)
audio, method = assembler.assemble_timed_audio(
audio_segments,
[(item.start_time, item.end_time) for item in subtitles],
fade_duration=fade,
)
return audio, None, method
assembler = AudioAssemblyEngine(sample_rate)
if mode == "pad_with_silence":
audio = assembler.assemble_with_overlaps(audio_segments, subtitles, torch.device("cpu"))
return audio, None, None
timing = TimingEngine(sample_rate)
if mode == "concatenate":
replacements = timing.calculate_concatenation_adjustments(audio_segments, subtitles)
audio = assembler.assemble_concatenation(audio_segments, fade)
return audio, replacements, None
replacements, processed = timing.calculate_smart_timing_adjustments(
audio_segments,
subtitles,
params.get("timing_tolerance", 2.0),
params.get("max_stretch_ratio", 1.0),
params.get("min_stretch_ratio", 0.5),
torch.device("cpu"),
)
audio = assembler.assemble_smart_natural(
audio_segments, processed, replacements, subtitles, torch.device("cpu")
)
return audio, replacements, None
AudioCppSubtitleProcessor = AudioCppSRTProcessor
AudioCPPSRTProcessor = AudioCppSRTProcessor
+444
View File
@@ -0,0 +1,444 @@
"""ComfyUI configuration node for the generic audio.cpp backend."""
from __future__ import annotations
import glob
import json
import os
from typing import Any, Dict, List, Mapping, Optional
from urllib.parse import urlparse
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
def _catalog_module():
try:
from utils.audio_cpp import catalog
return catalog
except ImportError:
return None
def _fallback_specs() -> List[Dict[str, Any]]:
root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "utils", "audio_cpp", "model_specs")
)
specs = []
for path in glob.glob(os.path.join(root, "*.json")):
try:
with open(path, "r", encoding="utf-8") as handle:
value = json.load(handle)
if isinstance(value, dict) and value.get("family"):
specs.append(value)
except (OSError, json.JSONDecodeError):
continue
return specs
def _family_choices() -> List[str]:
catalog = _catalog_module()
if catalog is not None and callable(getattr(catalog, "family_choices", None)):
choices = list(catalog.family_choices())
else:
choices = [spec["family"] for spec in _fallback_specs()]
choices = sorted({str(choice) for choice in choices if str(choice).strip()})
return choices or ["qwen3_tts"]
def _package_choices() -> List[str]:
catalog = _catalog_module()
if catalog is not None and callable(getattr(catalog, "package_choices", None)):
choices = list(catalog.package_choices())
else:
choices = [
package.get("id")
for spec in _fallback_specs()
for package in spec.get("packages", [])
if isinstance(package, dict)
]
return ["auto"] + sorted({str(choice) for choice in choices if choice})
def _recommended_package(family: str) -> str:
catalog = _catalog_module()
if catalog is not None and callable(getattr(catalog, "recommended_package", None)):
value = catalog.recommended_package(family)
if value:
return str(value)
for spec in _fallback_specs():
if spec.get("family") != family:
continue
recommended = (spec.get("ui") or {}).get("recommended_package")
if recommended:
return str(recommended)
for package in spec.get("packages", []):
if package.get("default"):
return str(package["id"])
return "auto"
def _resolve_task(family: str, package_id: str, requested: str) -> str:
requested = str(requested or "auto").lower()
if requested in {"tts", "clon", "vdes", "vc", "s2s", "svc", "asr", "diar"}:
return requested
catalog = _catalog_module()
if catalog is not None and callable(getattr(catalog, "resolve_task", None)):
return str(catalog.resolve_task(family, package_id, requested="auto")).lower()
package_lower = package_id.lower()
if "voicedesign" in package_lower or "voice_design" in package_lower:
return "vdes"
if family in {"chatterbox", "confucius4_tts"}:
return "clon"
return "tts"
def _validate_package(family: str, package_id: str) -> None:
catalog = _catalog_module()
getter = getattr(catalog, "get_package", None) if catalog is not None else None
if not callable(getter) or package_id == "auto":
return
value = getter(package_id)
if value is None:
raise ValueError(f"Unknown audio.cpp package: {package_id}")
package_family = value.get("family") if isinstance(value, Mapping) else getattr(value, "family", None)
if package_family and str(package_family) != family:
raise ValueError(f"audio.cpp package '{package_id}' does not belong to family '{family}'")
class AudioCppEngineNode:
"""Describe either a managed audio.cpp runtime or an existing installation."""
@classmethod
def NAME(cls):
return "⚙️ audio.cpp Multi-TTS Engine"
@classmethod
def INPUT_TYPES(cls):
families = _family_choices()
default_family = "qwen3_tts" if "qwen3_tts" in families else families[0]
packages = _package_choices()
return {
"required": {
"connection_mode": (
["auto", "external_server", "existing_binary", "managed"],
{
"default": "auto",
"tooltip": "Auto prefers a supplied server or binary, then the suite-managed runtime.",
},
),
"family": (
families,
{
"default": default_family,
"tooltip": "audio.cpp model family. The package list and capability panel update to match this selection.",
},
),
"package_id": (
packages,
{
"default": "auto",
"tooltip": "Auto selects the pinned recommended package for the chosen family.",
},
),
"task": (
["auto", "tts", "clon", "vdes", "vc", "s2s", "svc", "asr", "diar"],
{
"default": "auto",
"tooltip": "Runtime task. Auto lets the connected unified node use the family's normal task; choose an explicit task only for advanced routing or external-server matching.",
},
),
"backend": (
["auto", "cuda", "cpu", "vulkan", "metal", "hip"],
{
"default": "auto",
"tooltip": "Native audio.cpp compute backend. Auto selects an installed CUDA runtime when available, otherwise CPU.",
},
),
"device": (
"INT",
{
"default": 0,
"min": 0,
"max": 31,
"tooltip": "Zero-based native device index. Keep 0 unless using another GPU/device.",
},
),
"threads": (
"INT",
{
"default": 4,
"min": 1,
"max": 128,
"tooltip": "Native backend/OpenMP workers. Four matches the audio.cpp CLI default; tune for your CPU.",
},
),
"language": (
"STRING",
{
"default": "auto",
"tooltip": "Language code passed to audio.cpp. Auto lets the selected model infer or use its default language.",
},
),
},
"optional": {
"server_url": (
"STRING",
{
"default": "",
"tooltip": "Required only for external_server mode, for example http://127.0.0.1:8080.",
},
),
"binary_path": (
"STRING",
{
"default": "",
"tooltip": "Optional path to an existing audiocpp_server executable. Leave blank to use the Suite-managed runtime.",
},
),
"model_path": (
"STRING",
{
"default": "",
"tooltip": "Optional existing audio.cpp model/package directory. Leave blank for discovery or managed download.",
},
),
"model_id": (
"STRING",
{
"default": "",
"tooltip": "Server model identifier. Usually leave blank; required when an external server exposes multiple models.",
},
),
"voice_id": (
"STRING",
{
"default": "",
"tooltip": "Optional built-in voice/preset ID for families such as Supertonic. Reference audio takes precedence when supported.",
},
),
"instruct": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional natural-language voice design or style instruction. Used only by families/tasks that support instructions.",
},
),
"speaker2": (any_type, {"tooltip": "Optional ordered character/Speaker 2 reference."}),
"temperature": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 5.0, "step": 0.05, "tooltip": "Sampling temperature. -1 uses the selected model/package default."}),
"top_p": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01, "tooltip": "Nucleus sampling threshold. -1 uses the model default."}),
"top_k": ("INT", {"default": -1, "min": -1, "max": 1000, "tooltip": "Top-k sampling limit. -1 uses the model default."}),
"repetition_penalty": (
"FLOAT",
{"default": -1.0, "min": -1.0, "max": 5.0, "step": 0.05, "tooltip": "Token repetition penalty. -1 uses the model default."},
),
"max_tokens": ("INT", {"default": 0, "min": 0, "max": 131072, "tooltip": "Maximum generated tokens. 0 lets the model choose its normal limit."}),
"max_steps": ("INT", {"default": 0, "min": 0, "max": 4096, "tooltip": "Maximum generation/decoder steps where supported. 0 uses the model default."}),
"num_inference_steps": ("INT", {"default": 0, "min": 0, "max": 1000, "tooltip": "Flow/diffusion inference steps where supported. 0 uses the model default."}),
"guidance_scale": (
"FLOAT",
{"default": -1.0, "min": -1.0, "max": 100.0, "step": 0.05, "tooltip": "Classifier-free guidance scale where supported. -1 uses the model default."},
),
"advanced_json": (
"STRING",
{
"default": "{}",
"multiline": True,
"tooltip": "Model-specific audio.cpp request options as a JSON object.",
},
),
"auto_download_runtime": (
"BOOLEAN",
{
"default": True,
"tooltip": "Automatically install the pinned audio.cpp runtime into Suite-managed storage when no usable runtime is found. Existing external binaries are never copied.",
},
),
"auto_download_model": (
"BOOLEAN",
{
"default": True,
"tooltip": "Automatically download the selected audio.cpp package into models/TTS/audio.cpp/models when it is not already available. Downloads use direct files, not the Hugging Face cache.",
},
),
"show_server_console": (
"BOOLEAN",
{
"default": False,
"tooltip": "Debug only: launch a visible console for a Suite-owned audio.cpp server.",
},
),
},
}
RETURN_TYPES = ("TTS_ENGINE",)
RETURN_NAMES = ("TTS_engine",)
FUNCTION = "create_engine_config"
CATEGORY = "TTS Audio Suite/⚙️ Engines"
def create_engine_config(
self,
connection_mode: str,
family: str,
package_id: str,
task: str,
backend: str,
device: int,
threads: int,
language: str,
server_url: str = "",
binary_path: str = "",
model_path: str = "",
model_id: str = "",
voice_id: str = "",
instruct: str = "",
temperature: float = -1.0,
top_p: float = -1.0,
top_k: int = -1,
repetition_penalty: float = -1.0,
max_tokens: int = 0,
max_steps: int = 0,
num_inference_steps: int = 0,
guidance_scale: float = -1.0,
advanced_json: str = "{}",
auto_download_runtime: bool = True,
auto_download_model: bool = True,
show_server_console: bool = False,
speaker_mode: str = "Custom Character Switching",
speaker2: Any = None,
**kwargs: Any,
) -> tuple:
mode = str(connection_mode).strip().lower()
if mode not in {"auto", "external_server", "existing_binary", "managed"}:
raise ValueError(f"Unsupported audio.cpp connection mode: {connection_mode}")
family = str(family).strip()
package_id = str(package_id or "auto").strip()
if not family:
raise ValueError("audio.cpp family is required")
url = str(server_url or "").strip().rstrip("/")
binary = os.path.abspath(os.path.expanduser(binary_path)) if binary_path.strip() else ""
model = os.path.abspath(os.path.expanduser(model_path)) if model_path.strip() else ""
if mode == "external_server":
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("audio.cpp external_server mode requires a valid HTTP(S) server_url")
if mode == "existing_binary":
if not binary:
raise ValueError("audio.cpp existing_binary mode requires binary_path")
if not os.path.isfile(binary):
raise FileNotFoundError(f"audio.cpp binary not found: {binary}")
try:
advanced = json.loads(advanced_json or "{}")
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid audio.cpp advanced JSON: {exc.msg}") from exc
if not isinstance(advanced, Mapping):
raise ValueError("audio.cpp advanced JSON must contain an object")
uses_existing_server = mode == "external_server"
if package_id == "auto" and not uses_existing_server:
package_id = _recommended_package(family)
if not uses_existing_server:
_validate_package(family, package_id)
resolved_task = _resolve_task(family, package_id, task)
else:
# The loaded model reported by /v1/models owns this decision.
resolved_task = str(task or "auto").lower()
config: Dict[str, Any] = {
"engine_type": "audio_cpp",
"connection_mode": mode,
"family": family,
"package_id": package_id,
"requested_task": str(task or "auto").lower(),
"task": resolved_task,
"backend": str(backend).lower(),
"device": int(device),
"threads": int(threads),
"language": str(language or "auto"),
"server_url": url,
"external_server_url": url,
"binary_path": binary,
"model_path": model,
"model_id": str(model_id or "").strip(),
"voice_id": str(voice_id or "").strip(),
"instruct": str(instruct or "").strip(),
"advanced_options": dict(advanced),
"auto_download_runtime": bool(auto_download_runtime),
"auto_download_model": bool(auto_download_model),
"show_server_console": bool(show_server_console),
"multi_speaker_mode": str(speaker_mode),
}
speakers = [speaker2] if speaker2 is not None else []
dynamic_speakers = []
for key, value in kwargs.items():
if key.startswith("speaker") and key[7:].isdigit() and value is not None:
dynamic_speakers.append((int(key[7:]), value))
speakers.extend(value for _, value in sorted(dynamic_speakers))
config["speaker_references"] = speakers
try:
from utils.audio_cpp.capabilities import get_capability
capability = get_capability(family)
maximum = int(capability["native_multi_speaker"]["max_speakers"])
if len(speakers) > max(0, maximum - 1):
raise ValueError(f"audio.cpp {family} supports at most {maximum} speakers")
if speaker_mode == "Native Multi-Speaker" and capability["native_multi_speaker"]["suite_status"] != "supported":
raise ValueError(
f"audio.cpp {family} native multi-speaker mode is not integrated; "
"use Custom Character Switching"
)
except ImportError:
pass
optional_values = {
"temperature": float(temperature),
"top_p": float(top_p),
"top_k": int(top_k),
"repetition_penalty": float(repetition_penalty),
"guidance_scale": float(guidance_scale),
}
for key, value in optional_values.items():
if value >= 0:
config[key] = value
for key, value in {
"max_tokens": int(max_tokens),
"max_steps": int(max_steps),
"num_inference_steps": int(num_inference_steps),
}.items():
if value > 0:
config[key] = value
try:
from utils.audio_cpp.capabilities import get_capability as load_capability
family_capability = load_capability(family)
suite_tasks = set(family_capability.get("suite_tasks", []))
except (ImportError, KeyError, ValueError):
suite_tasks = {"tts"}
capabilities = []
if "tts" in suite_tasks:
capabilities.append("tts")
if "asr" in suite_tasks:
capabilities.append("asr")
if "voice_conversion" in suite_tasks:
capabilities.append("voice_conversion")
if "diarization" in suite_tasks:
capabilities.append("diarization")
catalog_module = _catalog_module()
family_record = catalog_module.get_family(family) if catalog_module is not None else None
if resolved_task == "vdes" or "vdes" in getattr(family_record, "runtime_tasks", ()):
capabilities.append("voice_design")
return ({"engine_type": "audio_cpp", "config": config, "capabilities": capabilities},)
NODE_CLASS_MAPPINGS = {"AudioCppEngineNode": AudioCppEngineNode}
NODE_DISPLAY_NAME_MAPPINGS = {"AudioCppEngineNode": "⚙️ audio.cpp Multi-TTS Engine"}
+48 -26
View File
@@ -1,5 +1,5 @@
"""
IndexTTS-2 Engine Configuration Node
IndexTTS 2 / 2.5 Engine Configuration Node
Provides comprehensive configuration interface for IndexTTS-2 TTS engine with all
official parameters exposed for experimentation and fine-tuning.
@@ -58,8 +58,8 @@ class IndexTTSEngineNode(BaseTTSNode):
"""
@classmethod
def NAME(cls):
return "⚙️ IndexTTS-2 Engine"
def NAME(cls):
return "⚙️ IndexTTS 2 / 2.5 Engine"
@classmethod
def INPUT_TYPES(cls):
@@ -71,7 +71,7 @@ class IndexTTSEngineNode(BaseTTSNode):
# Model Configuration
"model_path": (model_paths, {
"default": model_paths[0] if model_paths else "IndexTTS-2",
"tooltip": "IndexTTS-2 model selection:\n• local:ModelName: Use locally installed model (respects extra_model_paths.yaml)\n• ModelName: Auto-download model if not found locally\n• Downloads respect extra_model_paths.yaml configuration"
"tooltip": "IndexTTS model version selection:\n• IndexTTS-2.5: multilingual model with official duration-factor scaling\n• IndexTTS-2: legacy emotion-disentanglement model\n• local:ModelName: use a locally installed model\n• Downloads respect extra_model_paths.yaml"
}),
"device": (["auto", "cuda", "xpu", "cpu", "mps"], {
"default": "auto",
@@ -79,9 +79,9 @@ class IndexTTSEngineNode(BaseTTSNode):
}),
# IndexTTS-2 Unique Features
"emotion_alpha": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.1,
"tooltip": "Emotion intensity control (0.0-2.0). Affects emotion control from connected emotion nodes. 1.0=full emotion, 0.5=50% blend, 0.0=neutral."
"emotion_alpha": ("FLOAT", {
"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Emotion conditioning strength (0.0-1.0). Applies to connected audio/vector/text emotion controls."
}),
"use_random": ("BOOLEAN", {
"default": False,
@@ -135,9 +135,9 @@ class IndexTTSEngineNode(BaseTTSNode):
}),
# Model Options
"use_fp16": ("BOOLEAN", {
"default": True,
"tooltip": "Use FP16 for faster inference. Disable if you encounter numerical issues."
"use_fp16": ("BOOLEAN", {
"default": True,
"tooltip": "Use reduced precision: FP16 for IndexTTS-2 and BF16 for IndexTTS-2.5. Unsupported devices fall back safely."
}),
"use_deepspeed": ("BOOLEAN", {
"default": False,
@@ -182,10 +182,23 @@ This can be connected together with the vector/text emotion input above; IndexTT
"default": 0, "min": 0, "max": 80, "step": 5,
"tooltip": "Streaming segmentation parameter. Higher values produce first audio chunk faster but may affect quality. Only used when stream_return is enabled. Recommended: 0-20."
}),
"low_vram": ("BOOLEAN", {
"default": False,
"tooltip": "Enable Low VRAM mode. Keeps models on CPU and only moves them to GPU when needed. Prevents OOM on 8GB cards but is slower."
}),
"low_vram": ("BOOLEAN", {
"default": False,
"tooltip": "Enable IndexTTS low-VRAM behavior. Legacy 2.0 uses sequential offloading; 2.5 uses more aggressive text splitting."
}),
# Appended for workflow widget-position compatibility.
"language": (["English", "Chinese", "Japanese", "Spanish", "Arabic"], {
"default": "English",
"tooltip": "IndexTTS-2.5 generation language. Character language tags override this per segment. Legacy IndexTTS-2 ignores this control."
}),
"duration_factor": ("FLOAT", {
"default": 1.0, "min": 0.5, "max": 2.0, "step": 0.01,
"tooltip": "Official IndexTTS-2.5 internal feature-duration scaling; legacy IndexTTS-2 ignores it. 0.5 is shorter/faster speech; 1.0 is unchanged; 2.0 is longer/slower. This uses nearest-neighbor scaling of semantic features, not natural prosody or exact-duration planning, and extreme values can sound stretched. It does not improve inference speed."
}),
"text_normalization": ("BOOLEAN", {
"default": True,
"tooltip": "Enable IndexTTS-2.5 multilingual text normalization and pronunciation-annotation protection."
}),
}
}
@@ -197,19 +210,20 @@ This can be connected together with the vector/text emotion input above; IndexTT
@classmethod
def _get_model_paths(cls) -> List[str]:
"""Get available IndexTTS-2 model paths following F5TTS pattern."""
paths = ["IndexTTS-2"] # Auto-download option (just model name)
paths = ["IndexTTS-2.5", "IndexTTS-2"]
try:
# Check all configured TTS model paths
all_tts_paths = get_all_tts_model_paths('TTS')
for base_path in all_tts_paths:
# Check direct path (models/TTS/IndexTTS-2)
index_direct = os.path.join(base_path, "IndexTTS-2")
if os.path.exists(os.path.join(index_direct, "config.yaml")):
local_model = "local:IndexTTS-2"
if local_model not in paths:
paths.insert(0, local_model) # Insert at beginning
# Check direct paths used by older extra_model_paths layouts.
for direct_name in ("IndexTTS-2.5", "IndexTTS-2"):
index_direct = os.path.join(base_path, direct_name)
if os.path.exists(os.path.join(index_direct, "config.yaml")):
local_model = f"local:{direct_name}"
if local_model not in paths:
paths.insert(0, local_model)
# Check organized path (models/TTS/IndexTTS/IndexTTS-2)
index_organized = os.path.join(base_path, "IndexTTS")
@@ -259,6 +273,9 @@ This can be connected together with the vector/text emotion input above; IndexTT
more_segment_before: int = 0,
low_vram: bool = False,
emotion_audio = None,
language: str = "English",
duration_factor: float = 1.0,
text_normalization: bool = True,
):
"""
Create IndexTTS-2 engine adapter with configuration.
@@ -362,11 +379,16 @@ This can be connected together with the vector/text emotion input above; IndexTT
"use_accel": use_accel,
"stream_return": stream_return,
"more_segment_before": more_segment_before,
"low_vram": low_vram,
"low_vram": low_vram,
"language": language,
"duration_factor": duration_factor,
"text_normalization": _coerce_bool_flag(text_normalization),
}
print(f"⚙️ IndexTTS-2: Configured on {device}")
print(f"⚙️ IndexTTS: Configured on {device}")
print(f" Model: {model_path}")
if "2.5" in model_path:
print(f" Language: {language} | Official feature-duration factor: {duration_factor:.2f}")
emotion_desc = f"alpha={emotion_alpha}, use_text={use_emotion_text}"
if is_dynamic_template:
emotion_desc += " (dynamic template)"
@@ -404,7 +426,7 @@ This can be connected together with the vector/text emotion input above; IndexTT
return (engine_data,)
except Exception as e:
print(f"❌ IndexTTS-2 Engine error: {e}")
print(f"❌ IndexTTS Engine error: {e}")
import traceback
traceback.print_exc()
@@ -428,6 +450,6 @@ NODE_CLASS_MAPPINGS = {
"IndexTTS Engine": IndexTTSEngineNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IndexTTS Engine": "IndexTTS-2 Engine"
NODE_DISPLAY_NAME_MAPPINGS = {
"IndexTTS Engine": "IndexTTS 2 / 2.5 Engine"
}
+8 -10
View File
@@ -45,6 +45,9 @@ class MossTTSEngineNode(BaseTTSNode):
"v1.5 8B",
"v1 8B",
]
COMMUNITY_MODEL_OPTIONS = [
"Voice Acting 8B (Community - LAION)",
]
NATIVE_MODEL_OPTION = "TTSD v1 8B"
VOICE_DESIGN_MODEL_OPTION = "Voice Design 1.7B"
SOUND_EFFECT_MODEL_OPTION = "Sound Effects v1 8B"
@@ -52,6 +55,7 @@ class MossTTSEngineNode(BaseTTSNode):
"1.7B": "MOSS-TTS-Local-Transformer",
"v1.5 8B": "MOSS-TTS-v1.5",
"v1 8B": "MOSS-TTS",
"Voice Acting 8B (Community - LAION)": "moss-tts-v1.5-8b-voice-acting",
"TTSD v1 8B": "MOSS-TTSD-v1.0",
"Voice Design 1.7B": "MOSS-VoiceGenerator",
"Sound Effects v1 8B": "MOSS-SoundEffect",
@@ -96,6 +100,7 @@ class MossTTSEngineNode(BaseTTSNode):
"1.7B: smaller local-transformer architecture.\n"
"v1.5 8B: current multilingual model.\n"
"v1 8B: original checkpoint.\n"
"Voice Acting 8B (Community - LAION): third-party full v1.5 fine-tune for expressive speech.\n"
"Voice Design 1.7B: MOSS-VoiceGenerator for Voice Designer only.\n"
"Sound Effects 8B v1: MOSS-SoundEffect for the 🌩️ Sound Effects node only.\n"
"\n"
@@ -367,20 +372,13 @@ class MossTTSEngineNode(BaseTTSNode):
@classmethod
def _get_ui_model_options(cls) -> List[str]:
values = cls._get_ui_standard_model_options() + [
values = cls._get_ui_standard_model_options() + list(cls.COMMUNITY_MODEL_OPTIONS) + [
cls.VOICE_DESIGN_MODEL_OPTION,
cls.SOUND_EFFECT_MODEL_OPTION,
cls._get_ui_native_model_option(),
]
for model_name in (
"MOSS-TTS-Local-Transformer",
"MOSS-TTS-v1.5",
"MOSS-TTS",
"MOSS-VoiceGenerator",
"MOSS-SoundEffect",
"MOSS-TTSD-v1.0",
):
local_model = cls._find_local_variant(model_name)
for model_name in cls._get_model_variants():
local_model = model_name if model_name.startswith("local:") else cls._find_local_variant(model_name)
if local_model.startswith("local:") and local_model not in values:
values.append(local_model)
return values
+22 -22
View File
@@ -37,7 +37,7 @@ from utils.voice.discovery import get_available_characters, get_character_mappin
from engines.processors.index_tts_processor import IndexTTSProcessor
class IndexTTSSRTProcessor:
class IndexTTSSRTProcessor:
"""
Complete SRT processor for IndexTTS-2 engine.
Handles full SRT workflow including timing, assembly, and reports with emotion control.
@@ -82,15 +82,15 @@ class IndexTTSSRTProcessor:
self.FFmpegTimeStretcher = modules.get("FFmpegTimeStretcher")
self.PhaseVocoderTimeStretcher = modules.get("PhaseVocoderTimeStretcher")
def update_config(self, new_config: Dict[str, Any]):
def update_config(self, new_config: Dict[str, Any]):
"""Update processor configuration with new parameters."""
self.config.update(new_config)
# Also update the IndexTTS processor's config so emotion_audio gets passed through
if hasattr(self.tts_processor, 'config'):
self.tts_processor.config.update(new_config)
# Updated processor configuration with new parameters
# Updated processor configuration with new parameters
def process_srt_content(self,
srt_content: str,
voice_mapping: Dict[str, Any],
@@ -131,11 +131,11 @@ class IndexTTSSRTProcessor:
character_parser.reset_session_cache()
character_parser.set_engine_aware_default_language("IndexTTS-2", "index_tts")
# Process subtitles and generate audio segments using existing processor
print(f"🚀 IndexTTS-2 SRT: Processing {len(subtitles)} subtitles with emotion control")
audio_segments, natural_durations, any_segment_cached = self._process_all_subtitles(
subtitles, voice_mapping, seed
# Process subtitles and generate audio segments using existing processor
print(f"🚀 IndexTTS-2 SRT: Processing {len(subtitles)} subtitles with emotion control")
audio_segments, natural_durations, any_segment_cached = self._process_all_subtitles(
subtitles, voice_mapping, seed
)
# Calculate timing adjustments
@@ -152,8 +152,8 @@ class IndexTTSSRTProcessor:
)
# Use final adjustments if returned (for smart_natural mode)
if final_adjustments is not None:
adjustments = final_adjustments
if final_adjustments is not None:
adjustments = final_adjustments
# Generate reports using existing utils
timing_report = self._generate_timing_report(
@@ -168,8 +168,8 @@ class IndexTTSSRTProcessor:
if mode_switched:
mode_info = f"{current_timing_mode} (switched from {timing_mode} due to overlaps)"
info = (f"Generated {total_duration:.1f}s IndexTTS-2 SRT-timed audio from {len(subtitles)} subtitles "
f"using {mode_info} mode ({cache_status} segments, IndexTTS-2)")
info = (f"Generated {total_duration:.1f}s IndexTTS-2 SRT-timed audio from {len(subtitles)} subtitles "
f"using {mode_info} mode ({cache_status} segments, IndexTTS-2)")
# Format final audio for ComfyUI (ensure proper 3D format: [batch, channels, samples])
if final_audio.dim() == 1:
@@ -182,10 +182,10 @@ class IndexTTSSRTProcessor:
return audio_output, info, timing_report, adjusted_srt_string
def _process_all_subtitles(self,
subtitles: List,
voice_mapping: Dict[str, Any],
seed: int) -> Tuple[List[torch.Tensor], List[float], bool]:
def _process_all_subtitles(self,
subtitles: List,
voice_mapping: Dict[str, Any],
seed: int) -> Tuple[List[torch.Tensor], List[float], bool]:
"""
Process all subtitles and generate audio segments using existing IndexTTS-2 processor.
@@ -232,10 +232,10 @@ class IndexTTSSRTProcessor:
speaker_audio=speaker_audio,
reference_text=reference_text,
seed=seed + i, # Vary seed per subtitle
enable_chunking=False, # Disable chunking for SRT segments
max_chars_per_chunk=400,
silence_between_chunks_ms=100
)
enable_chunking=False, # Disable chunking for SRT segments
max_chars_per_chunk=400,
silence_between_chunks_ms=100
)
# Ensure correct tensor format
if wav.dim() == 3:
@@ -344,4 +344,4 @@ class IndexTTSSRTProcessor:
def cleanup(self):
"""Clean up resources"""
if self.tts_processor:
self.tts_processor.cleanup()
self.tts_processor.cleanup()
+10 -3
View File
@@ -48,7 +48,7 @@ class UnifiedASRTranscribeNode(BaseChatterBoxNode):
return {
"required": {
"engine": ("TTS_ENGINE", {
"tooltip": "ASR-capable engine configuration (for example Qwen3-TTS Engine or Granite ASR Engine). This node auto-routes to the correct ASR adapter based on the engine type."
"tooltip": "ASR-capable engine configuration. Supports Qwen3-TTS ASR, Granite ASR, and audio.cpp families whose capability panel shows ASR. The unified node routes to the correct adapter and preserves available timing/speaker data."
}),
"audio": (any_typ, {
"tooltip": "Audio to transcribe. Accepts AUDIO, Character Voices output, or VideoHelper audio."
@@ -96,7 +96,7 @@ class UnifiedASRTranscribeNode(BaseChatterBoxNode):
}),
"timestamps": (["none", "word"], {
"default": "none",
"tooltip": "Timing detail for the ASR timing output:\n• none: Text only, no reusable timed words/segments\n• word: Word-level timings for timestamp-capable ASR paths\n\nUse word timings if you plan to feed this into the Text to SRT Builder.\n\nGranite note: word timestamps are native on the plus model variant when diarization is off. Other Granite timestamp paths use the separate Qwen forced aligner."
"tooltip": "Timing detail for the ASR timing output:\n• none: Text only, except native speaker turns may still carry segment timing\n• word: Request or preserve word timings when the selected ASR family supports them\n\nUse word timings for Text to SRT Builder.\n\nGranite: the plus model has native timestamps; other variants use the Qwen forced aligner.\naudio.cpp: native words/segments are preserved. Qwen3-ASR specifically needs its optional forced-aligner model for requested word timings and will otherwise continue with text only."
}),
"chunk_size": ("INT", {
"default": 30, "min": 0, "max": 600, "step": 1,
@@ -112,7 +112,7 @@ class UnifiedASRTranscribeNode(BaseChatterBoxNode):
}),
"diarization": ("BOOLEAN", {
"default": False,
"tooltip": "Speaker Diarization (Speaker Attribution):\n• True: Attribute speech to speakers if supported (for example [Speaker 1] hello)\n• False: Plain transcription without speaker turns\n\nGranite note: Native speaker attribution is supported on the 'plus' model variant. If combined with word-level timestamps, the system automatically uses the Qwen forced aligner to time-align the speakers' words."
"tooltip": "Speaker attribution:\n• True: Preserve speaker turns when the selected ASR engine returns them\n• False: Return plain transcription/timing\n\nGranite 4.1 plus and audio.cpp VibeVoice-ASR provide native speaker attribution. Other audio.cpp ASR families return a warning instead of inventing speaker labels."
}),
}
}
@@ -171,6 +171,13 @@ class UnifiedASRTranscribeNode(BaseChatterBoxNode):
engine_cfg = engine.get("config", engine)
cache_data = {
"engine_type": engine.get("engine_type"),
"family": engine_cfg.get("family"),
"package_id": engine_cfg.get("package_id"),
"model_id": engine_cfg.get("model_id"),
"model_path": engine_cfg.get("model_path"),
"connection_mode": engine_cfg.get("connection_mode"),
"server_url": engine_cfg.get("server_url"),
"advanced_options": str(engine_cfg.get("advanced_options", {})),
"model_name": engine_cfg.get("model_name"),
"model_size": engine_cfg.get("model_size"),
"device": engine_cfg.get("device"),
+84
View File
@@ -254,6 +254,28 @@ Hello! This is unified SRT TTS with character switching.
stable_params['dtype'] = config.get('dtype', 'auto')
stable_params['attention'] = config.get('attention', 'auto')
if engine_type == "audio_cpp":
for key in (
'connection_mode', 'server_url', 'server_model_id', 'model_id',
'binary_path', 'model_path', 'model_roots', 'family',
'package_id', 'task', 'backend', 'device', 'device_index',
'threads', 'model_spec_override', 'load_options',
'session_options', 'show_server_console',
):
stable_params[key] = config.get(key)
# IndexTTS 2.0 and 2.5 are distinct checkpoints/backends. Every
# load-time option must participate in the processor cache key or
# changing the engine node can silently keep the old adapter alive.
if engine_type == "index_tts":
stable_params['model_path'] = config.get('model_path', 'IndexTTS-2')
stable_params['use_fp16'] = config.get('use_fp16', True)
stable_params['use_cuda_kernel'] = config.get('use_cuda_kernel')
stable_params['use_deepspeed'] = config.get('use_deepspeed', False)
stable_params['use_torch_compile'] = config.get('use_torch_compile', False)
stable_params['use_accel'] = config.get('use_accel', False)
stable_params['low_vram'] = config.get('low_vram', False)
# For CosyVoice, include actual model identity and load options in cache key.
# RL and base variants share one folder but use different llm files, so
# model_path selection must invalidate the cached engine instance.
@@ -983,6 +1005,38 @@ Hello! This is unified SRT TTS with character switching.
}
return engine_instance
elif engine_type == "audio_cpp":
processor_path = os.path.join(nodes_dir, "audio_cpp", "audio_cpp_srt_processor.py")
processor_spec = importlib.util.spec_from_file_location(
"audio_cpp_srt_processor_module", processor_path
)
if processor_spec is None or processor_spec.loader is None:
raise ImportError(f"Cannot load audio.cpp SRT processor from {processor_path}")
processor_module = importlib.util.module_from_spec(processor_spec)
processor_spec.loader.exec_module(processor_module)
AudioCppSRTProcessor = processor_module.AudioCppSRTProcessor
class AudioCppSRTWrapper:
def __init__(self, cfg):
self.config = cfg.copy()
self.processor = AudioCppSRTProcessor(self, self.config)
def update_config(self, new_config):
self.config = new_config.copy()
self.processor.update_config(self.config)
def check_interrupt(self):
if model_management.interrupt_processing:
raise InterruptedError("audio.cpp SRT processing interrupted by user")
engine_instance = AudioCppSRTWrapper(config)
import time
self._cached_engine_instances[cache_key] = {
'instance': engine_instance,
'timestamp': time.time(),
}
return engine_instance
else:
raise ValueError(f"Unknown engine type: {engine_type}")
@@ -1134,6 +1188,11 @@ Hello! This is unified SRT TTS with character switching.
if not engine_type:
raise ValueError("TTS engine missing engine_type")
capabilities = TTS_engine.get("capabilities", [])
if capabilities and "tts" not in capabilities:
raise ValueError(
f"Engine '{engine_type}' does not support TTS/SRT. Connect it to its compatible unified node."
)
if config.get("model_role") == "voice_design":
selected_model = config.get("model_variant") or config.get("model_name") or "selected model"
@@ -1648,6 +1707,29 @@ Hello! This is unified SRT TTS with character switching.
timing_params=timing_params
)
elif engine_type == "audio_cpp":
voice_mapping = {}
if audio_tensor is not None or audio_path:
voice_mapping['narrator'] = {
'audio': audio_tensor,
'audio_path': audio_path,
'reference_text': reference_text or '',
}
timing_params = {
'fade_for_StretchToFit': fade_for_StretchToFit,
'max_stretch_ratio': max_stretch_ratio,
'min_stretch_ratio': min_stretch_ratio,
'timing_tolerance': timing_tolerance,
}
result = engine_instance.processor.process_srt_content(
srt_content=srt_content,
voice_mapping=voice_mapping,
seed=seed,
timing_mode=timing_mode,
timing_params=timing_params,
enable_audio_cache=enable_audio_cache,
)
else:
raise ValueError(f"Unknown engine type: {engine_type}")
@@ -1690,6 +1772,8 @@ Hello! This is unified SRT TTS with character switching.
or "MOSS-TTSD Native Multi-Speaker Dialogue does not support this SRT input" in msg
):
raise
if engine_type == "audio_cpp":
raise
if isinstance(e, InterruptedError):
raise
error_msg = f"❌ TTS SRT generation failed: {e}"
+116 -2
View File
@@ -250,8 +250,29 @@ Back to the main narrator voice for the conclusion.""",
stable_params['dtype'] = config.get('dtype', 'auto')
stable_params['attention'] = config.get('attention', 'auto')
# For IndexTTS-2, include low_vram in cache key since it requires model reload
if engine_type == "audio_cpp":
# audio.cpp owns a persistent native server. Everything that changes
# that server/model session belongs in the instance cache identity;
# request-time sampling controls deliberately do not.
for key in (
'connection_mode', 'server_url', 'server_model_id', 'model_id',
'binary_path', 'model_path', 'model_roots', 'family',
'package_id', 'task', 'backend', 'device', 'device_index',
'threads', 'model_spec_override', 'load_options',
'session_options', 'show_server_console',
):
stable_params[key] = config.get(key)
# IndexTTS 2.0 and 2.5 are distinct checkpoints/backends. Every
# load-time option must participate in the processor cache key or
# changing the engine node can silently keep the old adapter alive.
if engine_type == "index_tts":
stable_params['model_path'] = config.get('model_path', 'IndexTTS-2')
stable_params['use_fp16'] = config.get('use_fp16', True)
stable_params['use_cuda_kernel'] = config.get('use_cuda_kernel')
stable_params['use_deepspeed'] = config.get('use_deepspeed', False)
stable_params['use_torch_compile'] = config.get('use_torch_compile', False)
stable_params['use_accel'] = config.get('use_accel', False)
stable_params['low_vram'] = config.get('low_vram', False)
# For CosyVoice, include actual model identity and load options in cache key.
@@ -738,6 +759,48 @@ Back to the main narrator voice for the conclusion.""",
return engine_instance
elif engine_type == "audio_cpp":
adapter_path = os.path.join(project_root, "engines", "adapters", "audio_cpp_adapter.py")
adapter_spec = importlib.util.spec_from_file_location(
"audio_cpp_adapter_module", adapter_path
)
if adapter_spec is None or adapter_spec.loader is None:
raise ImportError(f"Cannot load audio.cpp adapter from {adapter_path}")
adapter_module = importlib.util.module_from_spec(adapter_spec)
adapter_spec.loader.exec_module(adapter_module)
AudioCppEngineAdapter = adapter_module.AudioCppEngineAdapter
processor_path = os.path.join(nodes_dir, "audio_cpp", "audio_cpp_processor.py")
processor_spec = importlib.util.spec_from_file_location(
"audio_cpp_processor_module", processor_path
)
if processor_spec is None or processor_spec.loader is None:
raise ImportError(f"Cannot load audio.cpp processor from {processor_path}")
processor_module = importlib.util.module_from_spec(processor_spec)
processor_spec.loader.exec_module(processor_module)
AudioCppProcessor = processor_module.AudioCppProcessor
class AudioCppWrapper:
def __init__(self, cfg):
self.config = cfg.copy()
self.adapter = AudioCppEngineAdapter(self.config)
self.processor = AudioCppProcessor(self.adapter, self.config)
def update_config(self, new_config):
self.config = new_config.copy()
self.processor.update_config(self.config)
def check_interrupt(self):
if model_management.interrupt_processing:
raise InterruptedError("audio.cpp processing interrupted by user")
engine_instance = AudioCppWrapper(config)
import time
self._cached_engine_instances[cache_key] = {
'instance': engine_instance,
'timestamp': time.time(),
}
return engine_instance
elif engine_type == "step_audio_editx":
# Create Step Audio EditX wrapper instance
class StepAudioEditXWrapper:
@@ -1002,6 +1065,11 @@ Back to the main narrator voice for the conclusion.""",
if not engine_type:
raise ValueError("TTS engine missing engine_type")
capabilities = TTS_engine.get("capabilities", [])
if capabilities and "tts" not in capabilities:
raise ValueError(
f"Engine '{engine_type}' does not support TTS. Connect it to its compatible unified node."
)
if config.get("model_role") == "voice_design":
selected_model = config.get("model_variant") or config.get("model_name") or "selected model"
@@ -2047,6 +2115,52 @@ Back to the main narrator voice for the conclusion.""",
seed=seed
)
elif engine_type == "audio_cpp":
import re
voice_mapping = {}
if audio_tensor is not None or audio_path:
voice_mapping['narrator'] = {
'audio': audio_tensor,
'audio_path': audio_path,
'reference_text': reference_text or '',
}
audio_segments = engine_instance.processor.process_text(
text=text,
voice_mapping=voice_mapping,
seed=seed,
enable_chunking=enable_chunking,
max_chars_per_chunk=max_chars_per_chunk,
enable_audio_cache=enable_audio_cache,
)
audio_result, chunk_info = engine_instance.processor.combine_audio_segments(
segments=audio_segments,
method=chunk_combination_method,
silence_ms=silence_between_chunks_ms,
original_text=text,
return_info=True,
)
sample_rate = engine_instance.processor.sample_rate
if not sample_rate:
raise RuntimeError("audio.cpp returned no sample rate")
clean_text = re.sub(r'\[.*?\]', '', text)
duration = audio_result.shape[-1] / sample_rate if audio_result.numel() else 0.0
family = config.get('family') or config.get('server_model_id') or 'external model'
base_info = (
f"Generated {duration:.1f}s audio from {len(clean_text)} characters "
f"(audio.cpp {family}, {sample_rate} Hz, narrator: {char_display})"
)
base_info += "\n🎭 Character switching, pause tags, and per-segment parameters supported"
from utils.audio.chunk_timing import ChunkTimingHelper
generation_info = ChunkTimingHelper.enhance_generation_info(
f"✅ {base_info}", chunk_info
)
result = (
AudioProcessingUtils.format_for_comfyui(audio_result, sample_rate),
generation_info,
)
else:
raise ValueError(f"Unknown engine type: {engine_type}")
@@ -2082,7 +2196,7 @@ Back to the main narrator voice for the conclusion.""",
raise
if "MOSS LoRA/base model mismatch" in str(e):
raise
if engine_type == "index_tts":
if engine_type in {"index_tts", "audio_cpp"}:
raise
if isinstance(e, InterruptedError):
raise
+75 -18
View File
@@ -51,7 +51,7 @@ GLOBAL_RVC_ITERATION_CACHE = {}
class UnifiedVoiceChangerNode(BaseVCNode):
"""
Unified Voice Changer Node - Engine-agnostic voice conversion.
Currently supports ChatterBox, prepared for future RVC and other voice conversion engines.
Routes ChatterBox, CosyVoice, RVC, and compatible audio.cpp families.
Replaces ChatterBox VC node with engine-agnostic architecture.
"""
@@ -64,7 +64,7 @@ class UnifiedVoiceChangerNode(BaseVCNode):
return {
"required": {
"TTS_engine": ("TTS_ENGINE", {
"tooltip": "TTS/VC engine configuration. Supports ChatterBox TTS Engine, CosyVoice Engine, and RVC Engine for voice conversion."
"tooltip": "Engine configuration for source-to-target voice conversion. Supports ChatterBox, CosyVoice, RVC, and audio.cpp families whose panel shows Voice conversion (Chatterbox, VeVo2, or Seed-VC)."
}),
"source_audio": (any_typ, {
"tooltip": "The original voice audio you want to convert to sound like the target voice. Accepts AUDIO input or Character Voices node output."
@@ -574,7 +574,7 @@ class UnifiedVoiceChangerNode(BaseVCNode):
}
return engine_instance
elif engine_type == "cosyvoice":
elif engine_type == "cosyvoice":
# Import and create the CosyVoice VC processor
cosyvoice_vc_path = os.path.join(nodes_dir, "cosyvoice", "cosyvoice_vc_processor.py")
cosyvoice_vc_spec = importlib.util.spec_from_file_location("cosyvoice_vc_module", cosyvoice_vc_path)
@@ -589,10 +589,21 @@ class UnifiedVoiceChangerNode(BaseVCNode):
self._cached_engine_instances[cache_key] = {
'instance': engine_instance,
'timestamp': time.time()
}
return engine_instance
elif engine_type == "f5tts":
}
return engine_instance
elif engine_type == "audio_cpp":
from engines.adapters.audio_cpp_vc_adapter import AudioCppVoiceConversionAdapter
engine_instance = AudioCppVoiceConversionAdapter(config)
import time
self._cached_engine_instances[cache_key] = {
'instance': engine_instance,
'timestamp': time.time()
}
return engine_instance
elif engine_type == "f5tts":
# F5-TTS doesn't have voice conversion capability
raise ValueError("F5-TTS engine does not support voice conversion. Use ChatterBox or CosyVoice engine for voice conversion.")
@@ -831,14 +842,22 @@ class UnifiedVoiceChangerNode(BaseVCNode):
)
converted_chunk_audio = result[0]
elif engine_type == "cosyvoice":
elif engine_type == "cosyvoice":
# CosyVoice VC processor
result = engine_instance.convert_voice(
source_audio=chunk_audio_dict,
target_audio=target_audio,
refinement_passes=refinement_passes
)
converted_chunk_audio = result[0]
converted_chunk_audio = result[0]
elif engine_type == "audio_cpp":
result = engine_instance.convert_voice(
source_audio=chunk_audio_dict,
target_audio=target_audio,
refinement_passes=refinement_passes,
)
converted_chunk_audio = result[0]
else:
raise ValueError(f"Unsupported engine type for chunking: {engine_type}")
@@ -917,8 +936,13 @@ class UnifiedVoiceChangerNode(BaseVCNode):
print(f"🔄 Voice Changer: Starting {engine_type} voice conversion")
# Validate engine supports voice conversion
if engine_type not in ["chatterbox", "chatterbox_official_23lang", "rvc", "cosyvoice"]:
raise ValueError(f"Engine '{engine_type}' does not support voice conversion. Currently supported engines: ChatterBox, ChatterBox Official 23-Lang, RVC, CosyVoice")
if engine_type not in ["chatterbox", "chatterbox_official_23lang", "rvc", "cosyvoice", "audio_cpp"]:
raise ValueError(f"Engine '{engine_type}' does not support voice conversion. Currently supported engines: ChatterBox, ChatterBox Official 23-Lang, RVC, CosyVoice, audio.cpp")
if engine_type == "audio_cpp" and "voice_conversion" not in TTS_engine.get("capabilities", []):
family = config.get("family", "selected family")
raise ValueError(
f"audio.cpp family '{family}' does not map to the Suite's source/target Voice Changer contract"
)
# Extract audio data from flexible inputs (support both AUDIO and NARRATOR_VOICE types)
processed_source_audio = self._extract_audio_from_input(source_audio, "source_audio")
@@ -1079,7 +1103,7 @@ class UnifiedVoiceChangerNode(BaseVCNode):
f"Conversion completed successfully"
)
elif engine_type == "cosyvoice":
elif engine_type == "cosyvoice":
# CosyVoice voice conversion
print(f"🔄 Voice Changer: Using CosyVoice3 for voice conversion")
@@ -1120,12 +1144,45 @@ class UnifiedVoiceChangerNode(BaseVCNode):
)
# Add unified wrapper info
conversion_info = (
f"🔄 Voice Changer (Unified) - COSYVOICE3 Engine:\n"
f"{conversion_info}"
)
else:
conversion_info = (
f"🔄 Voice Changer (Unified) - COSYVOICE3 Engine:\n"
f"{conversion_info}"
)
elif engine_type == "audio_cpp":
if len(source_chunks) > 1:
converted_waveform, output_sample_rate = self._process_chunks_with_conversion(
source_chunks,
processed_narrator_target,
engine_instance,
engine_type,
refinement_passes,
config,
source_sample_rate,
)
converted_audio = {
"waveform": converted_waveform,
"sample_rate": output_sample_rate,
}
conversion_info = (
f"Model family: {config.get('family', 'external')}\n"
f"Chunks: {len(source_chunks)} ({chunk_method}, {max_chunk_duration}s max)\n"
f"Refinement passes: {refinement_passes}\n"
f"Output sample rate: {output_sample_rate} Hz\n"
"Conversion completed successfully"
)
else:
converted_audio, conversion_info = engine_instance.convert_voice(
source_audio=processed_source_audio,
target_audio=processed_narrator_target,
refinement_passes=refinement_passes,
)
conversion_info = (
"🔄 Voice Changer (Unified) - AUDIO.CPP Engine:\n"
f"{conversion_info}"
)
else:
# Future engines will be handled here
raise ValueError(f"Engine type '{engine_type}' voice conversion not yet implemented")
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "tts_audio_suite"
description = "TTS Audio Suite - Universal multi-engine TTS extension for ComfyUI with unified architecture supporting IndexTTS-2, ChatterBox, Chatterbox Multilingual TTS (Official 23-Lang), F5-TTS, Higgs Audio 2, VibeVoice, and RVC engines. It has character voice management, SRT subtitle TTS support, and audio processing capabilities."
version = "5.6.5"
description = "TTS Audio Suite - Universal multi-engine TTS extension for ComfyUI with unified architecture supporting IndexTTS 2/2.5, ChatterBox, Chatterbox Multilingual TTS (Official 23-Lang), F5-TTS, Higgs Audio 2, VibeVoice, and RVC engines. It has character voice management, SRT subtitle TTS support, and audio processing capabilities."
version = "5.8.1"
license = {file = "LICENSE"}
[project.urls]
+2
View File
@@ -76,6 +76,8 @@ json5>=0.12.0 # JSON5 parsing for IndexTTS-2 config files
ninja>=1.11.0 # Build tool for CUDA kernel compilation (BigVGAN optimization)
sentencepiece>=0.2.1 # Text tokenization
textstat>=0.7.10 # Text statistics and readability
fugashi>=1.4.0 # IndexTTS-2.5 Japanese segmentation/G2P
unidic-lite>=1.0.8 # Dictionary data for IndexTTS-2.5 fugashi backend
punctuators # ONNX punctuation/truecase post-processing for ASR text
# Step Audio EditX engine dependencies (safe)
+66
View File
@@ -0,0 +1,66 @@
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.capabilities import (
get_package_dependencies,
get_capability,
load_capabilities,
public_capabilities,
validate_voice_reference,
)
from utils.audio_cpp.catalog import load_catalog
@pytest.mark.unit
def test_capability_overlay_covers_the_pinned_catalog():
capabilities = load_capabilities()
assert set(capabilities) == set(load_catalog().families)
assert capabilities["vibevoice"]["native_multi_speaker"] == {
"supported": True,
"max_speakers": 4,
"suite_status": "partial",
}
assert capabilities["vibevoice_asr"]["asr_features"] == {
"diarization": "native",
"timing": "native_segment",
}
assert capabilities["nemotron_asr"]["asr_features"] == {
"diarization": "none",
"timing": "native_word",
}
assert capabilities["qwen3_asr"]["asr_features"]["timing"] == "optional_forced_aligner"
assert capabilities["voxtral_realtime"]["asr_features"] == {
"diarization": "none",
"timing": "none",
}
public = public_capabilities()
assert set(public["packages"]) == set(load_catalog().packages)
assert public["packages"]["qwen3_tts_1_7b_base_q8_0"]["estimated_download_bytes"] == 2695175104
mio = public["packages"]["miotts_1_7b_q8_0"]
assert mio["dependencies"] == ["miocodec_q8_0"]
assert mio["estimated_download_bytes"] == 2496393216
assert get_package_dependencies("miotts_1_7b_q8_0")[0]["session_option"] == "miotts.codec_model_path"
@pytest.mark.unit
def test_glm_requires_audio_and_matching_transcript():
with pytest.raises(ValueError, match="requires reference audio"):
validate_voice_reference("glm_tts", {}, "Alice")
with pytest.raises(ValueError, match="requires the transcript"):
validate_voice_reference(
"glm_tts",
{"audio": {"waveform": object(), "sample_rate": 24000}},
"Alice",
)
@pytest.mark.unit
def test_optional_reference_family_accepts_default_voice():
validate_voice_reference("pocket_tts", {}, "narrator")
assert get_capability("supertonic")["built_in_voices"] is True
+76
View File
@@ -0,0 +1,76 @@
from pathlib import Path
import sys
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.catalog import (
AUDIO_CPP_RELEASE_VERSION,
CatalogError,
family_choices,
get_model_specs_dir,
load_catalog,
package_choices,
recommended_package,
resolve_task,
)
@pytest.mark.unit
def test_pinned_release_catalog_has_exact_suite_compatible_surface():
catalog = load_catalog()
assert AUDIO_CPP_RELEASE_VERSION == "0.5.1"
assert len(catalog.families) == 32
assert len(catalog.packages) == 96
assert set(family_choices()) == set(catalog.families)
assert len(package_choices()) == 96
assert set(path.name for path in get_model_specs_dir().glob("*.json")) == {
family.spec_filename for family in catalog.families.values()
}
assert "vevo2" in catalog.families
assert catalog.family("vevo2").runtime_tasks == ("tts", "vc", "s2s", "svc")
assert catalog.family("qwen3_asr").runtime_tasks == ("asr",)
assert catalog.family("seed_vc").runtime_tasks == ("vc", "svc")
@pytest.mark.unit
def test_catalog_merges_package_download_defaults_and_maps_local_paths():
catalog = load_catalog()
package = catalog.package("chatterbox_q8_0")
assert package.repo == "audio-cpp/audio.cpp-gguf"
assert package.revision == "main"
assert package.local_files == (Path("chatterbox-q8_0.gguf"),)
assert recommended_package("chatterbox") == "chatterbox_q8_0"
# Upstream release-0.5.1 uses strip_prefix="." here. It means no strip,
# not a literal directory named dot.
assert catalog.package("vietneu_tts_v3_turbo_q8_0").local_files == (Path("model.gguf"),)
@pytest.mark.unit
def test_resolve_task_uses_compiled_ids_and_specialized_package_semantics():
assert resolve_task("chatterbox", "chatterbox_q8_0", "clone") == "clon"
assert (
resolve_task("qwen3_tts", "qwen3_tts_1_7b_voicedesign_q8_0", "auto") == "vdes"
)
assert (
resolve_task("irodori_tts", "irodori_tts_600m_v3_voicedesign_f16", "auto") == "vdes"
)
assert resolve_task("qwen3_tts", "qwen3_tts_1_7b_base_q8_0", "auto") == "tts"
assert resolve_task("pocket_tts", "pocket_tts_english_q8_0", "clone") == "tts"
with pytest.raises(CatalogError, match="does not belong"):
resolve_task("chatterbox", "vevo2_q8_0", "auto")
@pytest.mark.unit
def test_every_package_maps_to_safe_relative_files():
for package in load_catalog().packages.values():
assert package.local_files
for path in package.local_files:
assert not path.is_absolute()
assert ".." not in path.parts
+103
View File
@@ -0,0 +1,103 @@
import io
from pathlib import Path
import sys
import urllib.error
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.catalog import load_catalog
from utils.audio_cpp.downloader import AudioCppDownloadError, install_package, package_download_size
from utils.audio_cpp.discovery import package_install_path
class FakeResponse(io.BytesIO):
def __init__(self, payload, content_length=None):
super().__init__(payload)
self.status = 200
self.headers = {
"Content-Length": str(len(payload) if content_length is None else content_length)
}
@pytest.mark.unit
def test_package_download_size_uses_hf_metadata_without_downloading():
package = load_catalog().package("pocket_tts_english_q8_0")
requests = []
def opener(request, timeout):
requests.append(request)
return FakeResponse(b"", content_length=123_456)
assert package_download_size(package, token="secret-token", opener=opener) == 123_456
assert requests[0].method == "HEAD"
assert requests[0].get_header("Authorization") == "Bearer secret-token"
@pytest.mark.unit
def test_direct_hf_download_uses_auth_staging_and_nested_atomic_publish(tmp_path):
package = load_catalog().package("pocket_tts_english_q8_0")
payload = b"complete-gguf"
requests = []
def opener(request, timeout):
requests.append((request, timeout))
return FakeResponse(payload)
result = install_package(package, tmp_path, token="secret-token", opener=opener)
target = package_install_path(package, tmp_path)
assert result.path == target
assert (target / package.local_files[0]).read_bytes() == payload
assert requests[0][0].get_header("Authorization") == "Bearer secret-token"
assert "huggingface.co/audio-cpp/audio.cpp-gguf/resolve/main/" in requests[0][0].full_url
assert not list(target.parent.glob("*.staging"))
@pytest.mark.unit
def test_incomplete_http_response_never_publishes_package(tmp_path):
package = load_catalog().package("chatterbox_q8_0")
def opener(request, timeout):
return FakeResponse(b"short", content_length=100)
with pytest.raises(AudioCppDownloadError, match="Incomplete download"):
install_package(package, tmp_path, opener=opener)
assert not package_install_path(package, tmp_path).exists()
@pytest.mark.unit
def test_install_preserves_sibling_precision_in_shared_target(tmp_path):
package = load_catalog().package("chatterbox_q8_0")
sibling_package = load_catalog().package("chatterbox_f16")
target = package_install_path(package, tmp_path)
sibling = package_install_path(sibling_package, tmp_path)
target.mkdir(parents=True)
sibling_file = sibling / sibling_package.local_files[0]
sibling_file.write_bytes(b"keep")
result = install_package(
package,
tmp_path,
opener=lambda request, timeout: FakeResponse(b"new"),
)
assert (result.path / package.local_files[0]).read_bytes() == b"new"
assert sibling_file.read_bytes() == b"keep"
assert not list(target.parent.glob("*.backup"))
@pytest.mark.unit
def test_hf_auth_failure_is_actionable_and_leaves_no_target(tmp_path):
package = load_catalog().package("chatterbox_q8_0")
def opener(request, timeout):
raise urllib.error.HTTPError(request.full_url, 401, "Unauthorized", {}, None)
with pytest.raises(AudioCppDownloadError, match="HF_TOKEN"):
install_package(package, tmp_path, opener=opener)
assert not package_install_path(package, tmp_path).exists()
+346
View File
@@ -0,0 +1,346 @@
"""Focused tests for the audio.cpp adapter, processors, and engine node."""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _load_module(name, relative_path):
spec = importlib.util.spec_from_file_location(name, PROJECT_ROOT / relative_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
adapter_module = _load_module("audio_cpp_adapter_test_module", "engines/adapters/audio_cpp_adapter.py")
processor_module = _load_module("audio_cpp_processor_test_module", "nodes/audio_cpp/audio_cpp_processor.py")
srt_module = _load_module("audio_cpp_srt_test_module", "nodes/audio_cpp/audio_cpp_srt_processor.py")
node_module = _load_module("audio_cpp_engine_node_test_module", "nodes/engines/audio_cpp_engine_node.py")
class _FakeSession:
def __init__(self, sample_rate=32000):
self.sample_rate = sample_rate
self.requests = []
self.owned = True
self.endpoint = ""
self.model_id = "owned-test-model"
self.family = "qwen3_tts"
self.config = {"task": "tts"}
def run(self, request):
self.requests.append(dict(request))
# Owned sessions receive a random HTTP endpoint only after the server
# starts. That transient port must not change the audio cache identity.
self.endpoint = "http://127.0.0.1:54321"
if request.get("voice_ref"):
from pathlib import Path
assert Path(request["voice_ref"]).is_absolute()
assert Path(request["voice_ref"]).is_file()
return SimpleNamespace(
waveform=torch.ones(1, self.sample_rate // 10),
sample_rate=self.sample_rate,
named_audio={},
)
def test_adapter_caches_real_sample_rate_and_cleans_reference(monkeypatch, tmp_path):
adapter_module.get_audio_cache().clear_cache()
adapter_module._CACHE_SAMPLE_RATES.clear()
session = _FakeSession(sample_rate=32000)
monkeypatch.setattr(adapter_module, "_get_session", lambda config: session)
created = []
def fake_save(waveform, sample_rate):
path = tmp_path / f"reference-{len(created)}.wav"
path.write_bytes(b"temporary")
created.append(path)
return str(path)
monkeypatch.setattr(
adapter_module.AudioProcessingUtils, "save_audio_to_temp_file", staticmethod(fake_save)
)
adapter = adapter_module.AudioCppEngineAdapter(
{"family": "qwen3_tts", "package_id": "qwen3_tts_1_7b_base_q8_0", "task": "tts"}
)
voice = {"audio": {"waveform": torch.zeros(1, 80), "sample_rate": 16000}}
first, first_rate = adapter.generate_single("hello", voice, seed=7)
second, second_rate = adapter.generate_single("hello", voice, seed=7)
assert first_rate == second_rate == 32000
assert torch.equal(first, second)
assert len(session.requests) == 1
assert session.requests[0]["seed"] == "7"
assert len(created) == 1
assert created[0].exists()
adapter.close()
assert all(not path.exists() for path in created)
class _ProcessorAdapter:
def __init__(self, sample_rate=24000):
self.sample_rate = sample_rate
self.config = {}
def update_config(self, config):
self.config = dict(config)
def generate_single(self, **kwargs):
return torch.ones(1, self.sample_rate // 10), self.sample_rate
def test_processor_materializes_leading_pause_at_response_rate(monkeypatch):
segment = SimpleNamespace(
text="[pause:0.01] hello",
character="narrator",
parameters={},
language=None,
explicit_language=False,
)
monkeypatch.setattr(processor_module.AudioCppProcessor, "_setup_character_parser", lambda self, text: None)
monkeypatch.setattr(
processor_module.character_parser,
"parse_text_segments",
lambda text, engine_type=None: [segment],
)
monkeypatch.setattr(processor_module, "get_character_mapping", lambda *args, **kwargs: {})
processor = processor_module.AudioCppProcessor(_ProcessorAdapter(32000), {"language": "auto"})
records = processor.process_text(
"ignored", {}, seed=1, enable_chunking=False, show_text_logging=False
)
assert processor.sample_rate == 32000
assert records[0]["sample_rate"] == 32000
assert records[0]["waveform"].shape == (1, 320)
assert records[1]["sample_rate"] == 32000
def test_processor_uses_glm_transcripts_and_resets_rate_between_generations(monkeypatch):
segment = SimpleNamespace(
text="hello",
character="Alice",
parameters={},
language=None,
explicit_language=False,
)
monkeypatch.setattr(
processor_module.AudioCppProcessor, "_setup_character_parser", lambda self, text: None
)
monkeypatch.setattr(
processor_module.character_parser,
"parse_text_segments",
lambda text, engine_type=None: [segment],
)
discovery_modes = []
def mapping(characters, engine_type):
discovery_modes.append(engine_type)
return {"Alice": ("alice.wav", "matching transcript")}
monkeypatch.setattr(processor_module, "get_character_mapping", mapping)
adapter = _ProcessorAdapter(24000)
processor = processor_module.AudioCppProcessor(adapter, {"family": "glm_tts"})
processor.process_text("first", {}, seed=1, enable_chunking=False, show_text_logging=False)
adapter.sample_rate = 32000
processor.process_text("second", {}, seed=1, enable_chunking=False, show_text_logging=False)
assert discovery_modes == ["audio_and_text", "audio_and_text"]
assert processor.sample_rate == 32000
def test_processor_rejects_mixed_response_rates():
processor = processor_module.AudioCppProcessor(_ProcessorAdapter(), {})
segments = [
{"waveform": torch.zeros(1, 8), "sample_rate": 24000, "text": "a"},
{"waveform": torch.zeros(1, 8), "sample_rate": 32000, "text": "b"},
]
with pytest.raises(RuntimeError, match="inconsistent sample rates"):
processor.combine_audio_segments(segments)
def test_engine_node_resolves_owned_package_task(monkeypatch):
monkeypatch.setattr(node_module, "_recommended_package", lambda family: "design-package")
monkeypatch.setattr(node_module, "_validate_package", lambda family, package: None)
monkeypatch.setattr(node_module, "_resolve_task", lambda family, package, task: "vdes")
engine = node_module.AudioCppEngineNode().create_engine_config(
"managed", "qwen3_tts", "auto", "auto", "cuda", 0, 4, "auto"
)[0]
assert engine["config"]["package_id"] == "design-package"
assert engine["config"]["task"] == "vdes"
assert engine["config"]["threads"] == 4
assert engine["capabilities"] == ["tts", "voice_design"]
def test_engine_node_keeps_external_server_task_authoritative():
engine = node_module.AudioCppEngineNode().create_engine_config(
"external_server",
"qwen3_tts",
"auto",
"auto",
"cpu",
0,
4,
"auto",
server_url="http://127.0.0.1:8080",
)[0]
assert engine["config"]["task"] == "auto"
assert engine["config"]["package_id"] == "auto"
def test_engine_node_uses_pinned_catalog_contract():
inputs = node_module.AudioCppEngineNode.INPUT_TYPES()
assert "qwen3_tts" in inputs["required"]["family"][0]
assert "qwen3_tts_1_7b_base_q8_0" in inputs["required"]["package_id"][0]
engine = node_module.AudioCppEngineNode().create_engine_config(
"managed", "qwen3_tts", "auto", "auto", "cpu", 0, 4, "auto"
)[0]
assert engine["config"]["package_id"] == "qwen3_tts_1_7b_base_q8_0"
assert engine["config"]["task"] == "tts"
def test_unified_nodes_construct_audio_cpp_processors_without_nodes_package_collision():
text_module = _load_module(
"audio_cpp_unified_text_test_module", "nodes/unified/tts_text_node.py"
)
srt_unified_module = _load_module(
"audio_cpp_unified_srt_test_module", "nodes/unified/tts_srt_node.py"
)
engine = node_module.AudioCppEngineNode().create_engine_config(
"external_server",
"pocket_tts",
"auto",
"auto",
"cpu",
0,
4,
"auto",
server_url="http://127.0.0.1:9999",
model_id="wiring-only",
)[0]
text_wrapper = text_module.UnifiedTTSTextNode()._create_proper_engine_node_instance(engine)
srt_wrapper = srt_unified_module.UnifiedTTSSRTNode()._create_proper_engine_node_instance(engine)
assert type(text_wrapper.adapter).__name__ == "AudioCppEngineAdapter"
assert type(text_wrapper.processor).__name__ == "AudioCppProcessor"
assert type(srt_wrapper.processor).__name__ == "AudioCppSRTProcessor"
def test_unified_nodes_surface_audio_cpp_runtime_errors(monkeypatch):
from utils.audio_cpp import session as session_module
text_module = _load_module(
"audio_cpp_unified_text_error_test_module", "nodes/unified/tts_text_node.py"
)
srt_unified_module = _load_module(
"audio_cpp_unified_srt_error_test_module", "nodes/unified/tts_srt_node.py"
)
engine = node_module.AudioCppEngineNode().create_engine_config(
"external_server",
"pocket_tts",
"auto",
"auto",
"cpu",
0,
4,
"auto",
server_url="http://127.0.0.1:9999",
model_id="error-only",
)[0]
def fail_session(config):
raise RuntimeError("visible audio.cpp failure")
monkeypatch.setattr(session_module, "get_audio_cpp_session", fail_session)
with pytest.raises(RuntimeError, match="visible audio.cpp failure"):
text_module.UnifiedTTSTextNode().generate_speech(
engine, "hello", "none", 1, enable_chunking=False, enable_audio_cache=False
)
with pytest.raises(RuntimeError, match="visible audio.cpp failure"):
srt_unified_module.UnifiedTTSSRTNode().generate_srt_speech(
engine,
"1\n00:00:00,000 --> 00:00:01,000\nhello",
"none",
1,
"concatenate",
enable_audio_cache=False,
)
class _Subtitle:
def __init__(self, sequence, text, start, end):
self.sequence = sequence
self.text = text
self.start_time = start
self.end_time = end
self.duration = end - start
class _SRTTextProcessor:
sample_rate = 16000
def reset_sample_rate(self):
return None
def process_text(self, **kwargs):
return [{"waveform": torch.ones(1, 8000), "sample_rate": 16000, "text": kwargs["text"]}]
def combine_audio_segments(self, records, **kwargs):
return records[0]["waveform"]
def test_srt_delays_blank_cue_until_dynamic_rate_is_known(monkeypatch):
subtitles = [_Subtitle(1, "", 0.0, 0.25), _Subtitle(2, "hello", 0.25, 0.75)]
instance = srt_module.AudioCppSRTProcessor.__new__(srt_module.AudioCppSRTProcessor)
instance.config = {}
instance._processor = _SRTTextProcessor()
instance.SRTParser = lambda: SimpleNamespace(
parse_srt_content=lambda content, allow_overlaps: subtitles
)
monkeypatch.setattr(instance, "_check_interrupt", lambda *args: None)
monkeypatch.setattr(srt_module.SRTOverlapHandler, "detect_overlaps", lambda items: False)
monkeypatch.setattr(
srt_module.SRTOverlapHandler,
"handle_smart_natural_fallback",
lambda mode, overlaps, label: (mode, False),
)
captured = {}
def fake_assemble(audio, subs, mode, params, rate):
captured["segments"] = audio
return torch.cat(audio, dim=-1), None, None
monkeypatch.setattr(instance, "_assemble", fake_assemble)
monkeypatch.setattr(
srt_module,
"SRTReportGenerator",
lambda: SimpleNamespace(
generate_timing_report=lambda *args: "report",
generate_adjusted_srt_string=lambda *args: "adjusted",
),
)
audio, _, report, adjusted = instance.process_srt_content(
"unused", {}, 0, "concatenate", {}, enable_audio_cache=False
)
assert captured["segments"][0].shape[-1] == 4000
assert audio["sample_rate"] == 16000
assert audio["waveform"].shape == (1, 1, 12000)
assert (report, adjusted) == ("report", "adjusted")
+260
View File
@@ -0,0 +1,260 @@
"""No-model tests for audio.cpp ASR and unified voice-conversion contracts."""
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
from utils.asr.types import ASRRequest
from utils.audio_cpp import session as session_module
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _load_module(name: str, relative_path: str):
spec = importlib.util.spec_from_file_location(name, PROJECT_ROOT / relative_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
asr_module = _load_module(
"audio_cpp_asr_adapter_test_module", "engines/adapters/asr_audio_cpp_adapter.py"
)
vc_module = _load_module(
"audio_cpp_vc_adapter_test_module", "engines/adapters/audio_cpp_vc_adapter.py"
)
node_module = _load_module(
"audio_cpp_multitask_node_test_module", "nodes/engines/audio_cpp_engine_node.py"
)
def _fake_save_factory(tmp_path):
paths = []
def save(_waveform, _sample_rate):
path = tmp_path / f"audio-{len(paths)}.wav"
path.write_bytes(b"wav")
paths.append(path)
return str(path)
return paths, save
@pytest.mark.unit
def test_engine_node_advertises_asr_and_vc_consumers():
asr_engine = node_module.AudioCppEngineNode().create_engine_config(
"managed", "qwen3_asr", "auto", "auto", "cpu", 0, 4, "auto"
)[0]
vc_engine = node_module.AudioCppEngineNode().create_engine_config(
"managed", "seed_vc", "auto", "auto", "cpu", 0, 4, "auto"
)[0]
assert asr_engine["config"]["task"] == "asr"
assert asr_engine["capabilities"] == ["asr"]
assert vc_engine["config"]["task"] == "vc"
assert vc_engine["capabilities"] == ["voice_conversion"]
@pytest.mark.unit
def test_asr_adapter_normalizes_words_and_speaker_turns(monkeypatch, tmp_path):
paths, fake_save = _fake_save_factory(tmp_path)
monkeypatch.setattr(
asr_module.AudioProcessingUtils, "save_audio_to_temp_file", staticmethod(fake_save)
)
class FakeSession:
task = "asr"
model_id = "vibe-asr"
def run(self, request):
assert Path(request["audio"]).is_absolute()
assert Path(request["audio"]).is_file()
return SimpleNamespace(raw={
"text": "hello world",
"language": "en",
"words": [
{"word": "hello", "start_sample": 0, "end_sample": 8000},
{"word": "world", "start_sample": 8000, "end_sample": 16000},
],
"speaker_turns": [
{
"start_sample": 0,
"end_sample": 16000,
"speaker_id": "Speaker 1",
"text": "hello world",
}
],
})
monkeypatch.setattr(session_module, "get_audio_cpp_session", lambda _config: FakeSession())
adapter = asr_module.AudioCppASREngineAdapter({
"engine_type": "audio_cpp",
"config": {"family": "vibevoice_asr", "connection_mode": "external_server"},
})
result = adapter.transcribe(ASRRequest(
audio={"waveform": torch.zeros(1, 1, 16000), "sample_rate": 16000},
timestamps="word",
diarization=True,
chunk_size=0,
))
assert result.text == "[Speaker 1] hello world"
assert result.language == "en"
assert result.segments[0].speaker == "Speaker 1"
assert [word.text for word in result.segments[0].words] == ["hello", "world"]
assert paths and not paths[0].exists()
@pytest.mark.unit
def test_asr_adapter_uses_suite_chunking_and_deduplicates_overlap(monkeypatch, tmp_path):
paths, fake_save = _fake_save_factory(tmp_path)
monkeypatch.setattr(
asr_module.AudioProcessingUtils, "save_audio_to_temp_file", staticmethod(fake_save)
)
class FakeSession:
task = "asr"
model_id = "nemotron-asr"
owned = True
def __init__(self):
self.requests = []
self.restarts = 0
self.texts = iter((
"one two three",
"three four five",
"five six seven",
))
def restart_owned_runtime(self):
self.restarts += 1
def run(self, request):
self.requests.append(request)
assert Path(request["audio"]).is_file()
return SimpleNamespace(raw={"text": next(self.texts), "language": "en"})
fake_session = FakeSession()
monkeypatch.setattr(
session_module, "get_audio_cpp_session", lambda _config: fake_session
)
adapter = asr_module.AudioCppASREngineAdapter({
"engine_type": "audio_cpp",
"config": {"family": "nemotron_asr", "connection_mode": "external_server"},
})
result = adapter.transcribe(ASRRequest(
audio={"waveform": torch.zeros(1, 1, 80), "sample_rate": 10},
chunk_size=4,
overlap=2,
))
assert result.text == "one two three four five six seven"
assert len(fake_session.requests) == 3
assert fake_session.restarts == 2
assert result.raw["timing"]["suite_chunks"] == 3
assert any("Suite-side ASR chunking" in note for note in result.raw["notes"])
assert [chunk["text"] for chunk in result.raw["chunks"]] == [
"one two three",
"three four five",
"five six seven",
]
assert [(chunk["start"], chunk["end"]) for chunk in result.raw["chunks"]] == [
(0.0, 4.0),
(2.0, 6.0),
(4.0, 8.0),
]
assert paths and all(not path.exists() for path in paths)
@pytest.mark.unit
def test_vibevoice_diarization_keeps_native_chunking(monkeypatch, tmp_path):
paths, fake_save = _fake_save_factory(tmp_path)
monkeypatch.setattr(
asr_module.AudioProcessingUtils, "save_audio_to_temp_file", staticmethod(fake_save)
)
captured = {}
class FakeSession:
task = "asr"
model_id = "vibe-asr"
def run(self, request):
captured.update(request)
return SimpleNamespace(raw={
"text": "hello",
"speaker_turns": [{
"start_sample": 0,
"end_sample": 16000,
"speaker_id": "1",
"text": "hello",
}],
})
monkeypatch.setattr(
session_module, "get_audio_cpp_session", lambda _config: FakeSession()
)
adapter = asr_module.AudioCppASREngineAdapter({
"engine_type": "audio_cpp",
"config": {"family": "vibevoice_asr", "connection_mode": "external_server"},
})
result = adapter.transcribe(ASRRequest(
audio={"waveform": torch.zeros(1, 1, 16000), "sample_rate": 16000},
diarization=True,
chunk_size=30,
overlap=2,
))
assert captured["options"]["audio_chunk_mode"] == "fixed"
assert captured["options"]["audio_chunk_seconds"] == 30
assert result.text == "[Speaker 1] hello"
assert len(paths) == 1 and not paths[0].exists()
@pytest.mark.unit
def test_vc_adapter_forces_vc_task_and_uses_source_target_audio(monkeypatch, tmp_path):
paths, fake_save = _fake_save_factory(tmp_path)
monkeypatch.setattr(
vc_module.AudioProcessingUtils, "save_audio_to_temp_file", staticmethod(fake_save)
)
captured = {}
class FakeSession:
task = "vc"
model_id = "seed-vc"
family = "seed_vc"
def run(self, request):
captured.update(request)
assert Path(request["audio"]).is_file()
assert Path(request["voice_ref"]).is_file()
return SimpleNamespace(
waveform=torch.ones(1, 2400),
sample_rate=24000,
named_audio={},
)
def fake_session(config):
assert config["requested_task"] == "vc"
assert config["task"] == "vc"
return FakeSession()
monkeypatch.setattr(session_module, "get_audio_cpp_session", fake_session)
adapter = vc_module.AudioCppVoiceConversionAdapter({
"family": "seed_vc",
"connection_mode": "managed",
})
audio = {"waveform": torch.zeros(1, 1, 1600), "sample_rate": 16000}
converted, info = adapter.convert_voice(audio, audio)
assert converted["waveform"].shape == (1, 1, 2400)
assert converted["sample_rate"] == 24000
assert captured["source_audio"] == captured["audio"]
assert captured["target_voice"] == captured["voice_ref"]
assert "Seed-VC" not in info or "seed_vc" in info
assert paths and all(not path.exists() for path in paths)
+332
View File
@@ -0,0 +1,332 @@
from pathlib import Path
from types import SimpleNamespace
import sys
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp import resolver
from utils.audio_cpp.catalog import load_catalog
from utils.audio_cpp.discovery import package_install_path
from utils.audio_cpp.runtime_installer import runtime_install_path
from utils.audio_cpp.settings import AudioCppSettings
@pytest.mark.unit
def test_auto_reuses_machine_external_server_without_loading_owned_dependencies(monkeypatch):
monkeypatch.setattr(
resolver,
"load_settings",
lambda: AudioCppSettings(
connection_mode="external",
external_server_url="HTTP://127.0.0.1:18080/",
executable_path="C:/ignored/audiocpp_server.exe",
),
)
monkeypatch.setattr(
resolver,
"_catalog_module",
lambda: (_ for _ in ()).throw(AssertionError("external mode loaded the catalog")),
)
monkeypatch.setattr(
resolver,
"_downloader_module",
lambda: (_ for _ in ()).throw(AssertionError("external mode loaded a downloader")),
)
monkeypatch.setattr(
resolver,
"_runtime_installer_module",
lambda: (_ for _ in ()).throw(AssertionError("external mode loaded an installer")),
)
result = resolver.resolve_audio_cpp_config(
{
"config": {
"connection_mode": "auto",
"family": "qwen3_tts",
"package_id": "auto",
"task": "auto",
}
}
)
assert result["connection_mode"] == "external_server"
assert result["server_url"] == "http://127.0.0.1:18080"
assert result["binary_path"] == ""
assert result["model_path"] == ""
assert "model_id" not in result
assert result["task"] == "auto"
@pytest.mark.unit
def test_owned_explicit_paths_resolve_recommended_package_task_and_cuda(monkeypatch, tmp_path):
binary = tmp_path / "audiocpp_server.exe"
binary.write_bytes(b"exe")
model = tmp_path / "Qwen-VoiceDesign"
model.mkdir()
monkeypatch.setattr(resolver, "load_settings", lambda: AudioCppSettings())
monkeypatch.setattr(resolver, "_cuda_available", lambda: True)
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "managed",
"family": "qwen3_tts",
"package_id": "qwen3_tts_1_7b_voicedesign_q8_0",
"requested_task": "auto",
"backend": "auto",
"device": "cuda:2",
"binary_path": str(binary),
"model_path": str(model),
"model_id": "My Qwen model",
}
)
assert result["connection_mode"] == "owned_process"
assert result["package_id"] == "qwen3_tts_1_7b_voicedesign_q8_0"
assert result["task"] == "vdes"
assert result["backend"] == "cuda"
assert result["device_index"] == 2
assert result["binary_path"] == str(binary.resolve())
assert result["model_path"] == str(model.resolve())
assert result["model_id"] == "My-Qwen-model"
@pytest.mark.unit
def test_owned_reuses_external_model_root_and_configured_runtime_root(monkeypatch, tmp_path):
external_models = tmp_path / "existing-audio-cpp" / "models"
managed_models = tmp_path / "suite" / "audio.cpp" / "models"
configured_runtime = tmp_path / "existing-audio-cpp" / "runtime"
settings = AudioCppSettings(
connection_mode="managed",
model_roots=(str(external_models),),
managed_model_root=str(managed_models),
runtime_root=str(configured_runtime),
runtime_backend="cpu",
)
package = load_catalog().package("chatterbox_q8_0")
installed_model = package_install_path(package, external_models)
installed_model.mkdir(parents=True)
(installed_model / package.local_files[0]).write_bytes(b"gguf")
installed_binary = runtime_install_path(configured_runtime, "cpu") / "audiocpp_server.exe"
installed_binary.parent.mkdir(parents=True)
installed_binary.write_bytes(b"exe")
monkeypatch.setattr(resolver, "load_settings", lambda: settings)
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "auto",
"family": "chatterbox",
"package_id": "auto",
"task": "auto",
"backend": "auto",
"auto_download_model": False,
"auto_download_runtime": False,
}
)
assert result["package_id"] == "chatterbox_q8_0"
assert result["task"] == "clon"
assert result["backend"] == "cpu"
assert result["model_path"] == str(installed_model.resolve())
assert result["binary_path"] == str(installed_binary.resolve())
assert not managed_models.exists()
@pytest.mark.unit
def test_missing_assets_download_only_to_managed_roots(monkeypatch, tmp_path):
external_models = tmp_path / "external" / "models"
managed_models = tmp_path / "managed" / "audio.cpp" / "models"
settings = AudioCppSettings(
connection_mode="managed",
model_roots=(str(external_models),),
managed_model_root=str(managed_models),
runtime_backend="cpu",
)
calls = {}
real_downloader = resolver._downloader_module()
real_runtime = resolver._runtime_installer_module()
def install_package(package, root, catalog, progress=None):
calls["model_root"] = Path(root)
calls["model_progress"] = progress
target = package_install_path(package, root)
target.mkdir(parents=True)
(target / package.local_files[0]).write_bytes(b"gguf")
return SimpleNamespace(path=target, bytes_downloaded=4)
def install_runtime(root, backend, progress=None):
calls["runtime_root"] = Path(root)
calls["backend"] = backend
calls["runtime_progress"] = progress
executable = runtime_install_path(root, backend) / "audiocpp_server.exe"
executable.parent.mkdir(parents=True)
executable.write_bytes(b"exe")
return SimpleNamespace(executable=executable)
monkeypatch.setattr(resolver, "load_settings", lambda: settings)
monkeypatch.setattr(
resolver,
"_downloader_module",
lambda: SimpleNamespace(install_package=install_package),
)
monkeypatch.setattr(
resolver,
"_runtime_installer_module",
lambda: SimpleNamespace(
runtime_install_path=real_runtime.runtime_install_path,
get_runtime_manifest=real_runtime.get_runtime_manifest,
install_windows_runtime=install_runtime,
),
)
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "managed",
"family": "chatterbox",
"package_id": "chatterbox_q8_0",
"backend": "cpu",
"auto_download_model": True,
"auto_download_runtime": True,
}
)
assert calls["model_root"] == managed_models
assert calls["runtime_root"] == managed_models.parent / "runtime"
assert calls["backend"] == "cpu"
assert callable(calls["model_progress"])
assert callable(calls["runtime_progress"])
assert not external_models.exists()
assert result["model_path"].startswith(str(managed_models.resolve()))
assert result["binary_path"].startswith(str((managed_models.parent / "runtime").resolve()))
@pytest.mark.unit
def test_missing_model_without_permission_has_actionable_error(monkeypatch, tmp_path):
managed_models = tmp_path / "managed" / "models"
binary = tmp_path / "audiocpp_server.exe"
binary.write_bytes(b"exe")
monkeypatch.setattr(
resolver,
"load_settings",
lambda: AudioCppSettings(managed_model_root=str(managed_models)),
)
with pytest.raises(resolver.AudioCppResolutionError, match="enable auto_download_model"):
resolver.resolve_audio_cpp_config(
{
"connection_mode": "managed",
"family": "chatterbox",
"package_id": "chatterbox_q8_0",
"backend": "cpu",
"binary_path": str(binary),
"auto_download_model": False,
}
)
@pytest.mark.unit
def test_miotts_installs_codec_dependency_and_sets_absolute_session_path(monkeypatch, tmp_path):
managed_models = tmp_path / "managed" / "models"
catalog = resolver._catalog_module().load_catalog()
miotts = catalog.package("miotts_1_7b_q8_0")
miotts_dir = package_install_path(miotts, managed_models)
for relative in miotts.local_files:
target = miotts_dir / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"miotts")
binary = tmp_path / "audiocpp_server.exe"
binary.write_bytes(b"exe")
installed = {}
def install_package(package, root, **_kwargs):
target = package_install_path(package, root)
for relative in package.local_files:
path = target / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"codec")
installed[package.id] = target
return SimpleNamespace(path=target, bytes_downloaded=5)
monkeypatch.setattr(
resolver,
"load_settings",
lambda: AudioCppSettings(managed_model_root=str(managed_models)),
)
monkeypatch.setattr(
resolver,
"_downloader_module",
lambda: SimpleNamespace(install_package=install_package),
)
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "managed",
"family": "miotts",
"package_id": "miotts_1_7b_q8_0",
"backend": "cpu",
"binary_path": str(binary),
"auto_download_model": True,
}
)
assert "miocodec_q8_0" in installed
assert result["session_options"]["miotts.codec_model_path"] == str(
installed["miocodec_q8_0"].resolve()
)
@pytest.mark.unit
def test_owned_existing_binary_allows_explicit_hip_backend(monkeypatch, tmp_path):
binary = tmp_path / "audiocpp_server.exe"
binary.write_bytes(b"exe")
model = tmp_path / "model"
model.mkdir()
monkeypatch.setattr(resolver, "load_settings", lambda: AudioCppSettings())
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "existing_binary",
"family": "chatterbox",
"package_id": "chatterbox_q8_0",
"backend": "hip",
"binary_path": str(binary),
"model_path": str(model),
}
)
assert result["backend"] == "hip"
assert result["binary_path"] == str(binary.resolve())
@pytest.mark.unit
def test_cpu_backend_reuses_installed_cuda_profile_before_downloading_duplicate(
monkeypatch, tmp_path
):
model = tmp_path / "model"
model.mkdir()
runtime_root = tmp_path / "runtime"
cuda_binary = runtime_install_path(runtime_root, "cuda") / "audiocpp_server.exe"
cuda_binary.parent.mkdir(parents=True)
cuda_binary.write_bytes(b"cuda-exe")
monkeypatch.setattr(
resolver,
"load_settings",
lambda: AudioCppSettings(runtime_root=str(runtime_root), runtime_backend="cpu"),
)
result = resolver.resolve_audio_cpp_config(
{
"connection_mode": "managed",
"family": "chatterbox",
"package_id": "chatterbox_q8_0",
"model_path": str(model),
"backend": "auto",
"auto_download_runtime": False,
}
)
assert result["backend"] == "cpu"
assert result["binary_path"] == str(cuda_binary.resolve())
@@ -0,0 +1,145 @@
import hashlib
import io
from pathlib import Path
import sys
import zipfile
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.runtime_installer import (
RuntimeAsset,
RuntimeInstallError,
RuntimeManifest,
get_runtime_manifest,
install_windows_runtime,
runtime_install_path,
)
class FakeResponse(io.BytesIO):
def __init__(self, payload):
super().__init__(payload)
self.status = 200
self.headers = {"Content-Length": str(len(payload))}
def make_zip(files):
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w") as bundle:
for name, payload in files.items():
bundle.writestr(name, payload)
return stream.getvalue()
def fake_manifest(backend, payloads, required):
assets = tuple(
RuntimeAsset(
filename=name,
url=f"https://example.test/{name}",
size=len(payload),
sha256=hashlib.sha256(payload).hexdigest(),
)
for name, payload in payloads.items()
)
return RuntimeManifest(backend=backend, assets=assets, required_files=tuple(required))
@pytest.mark.unit
def test_pinned_windows_manifests_include_verified_cuda_runtime_asset():
cpu = get_runtime_manifest("cpu")
cuda = get_runtime_manifest("cuda")
assert len(cpu.assets) == 1
assert len(cuda.assets) == 2
assert cuda.assets[1].filename == "audiocpp-windows-cuda-runtime.zip"
assert cuda.assets[1].sha256 == (
"46016655aff8f050806d81efd0fe256c15b86527935bfb3896208d4cac6b5ff8"
)
assert "cublas64_13.dll" in cuda.required_files
@pytest.mark.unit
def test_cuda_runtime_installs_two_verified_archives_atomically(tmp_path):
executable_zip = make_zip(
{"audiocpp_server.exe": b"server", "audiocpp_cli.exe": b"cli"}
)
cuda_zip = make_zip({"cublas64_13.dll": b"cublas", "cufft64_12.dll": b"cufft"})
payloads = {"runtime.zip": executable_zip, "cuda.zip": cuda_zip}
manifest = fake_manifest(
"cuda",
payloads,
("audiocpp_server.exe", "audiocpp_cli.exe", "cublas64_13.dll", "cufft64_12.dll"),
)
def opener(request, timeout):
return FakeResponse(payloads[Path(request.full_url).name])
result = install_windows_runtime(
tmp_path,
"cuda",
manifest=manifest,
platform_name="win32",
opener=opener,
)
assert result.path == runtime_install_path(tmp_path, "cuda")
assert result.executable.read_bytes() == b"server"
assert (result.path / "cublas64_13.dll").read_bytes() == b"cublas"
assert not list(result.path.parent.glob("*.staging"))
@pytest.mark.unit
def test_runtime_hash_failure_does_not_publish_or_destroy_existing_target(tmp_path):
archive = make_zip({"audiocpp_server.exe": b"new", "audiocpp_cli.exe": b"cli"})
asset = RuntimeAsset(
filename="runtime.zip",
url="https://example.test/runtime.zip",
size=len(archive),
sha256="0" * 64,
)
manifest = RuntimeManifest(
backend="cpu",
assets=(asset,),
required_files=("audiocpp_server.exe", "audiocpp_cli.exe"),
)
target = runtime_install_path(tmp_path, "cpu")
target.mkdir(parents=True)
marker = target / "old.txt"
marker.write_bytes(b"old")
with pytest.raises(RuntimeInstallError, match="SHA256 mismatch"):
install_windows_runtime(
tmp_path,
"cpu",
overwrite=True,
manifest=manifest,
platform_name="win32",
opener=lambda request, timeout: FakeResponse(archive),
)
assert marker.read_bytes() == b"old"
assert not (target / "audiocpp_server.exe").exists()
@pytest.mark.unit
def test_runtime_rejects_archive_path_traversal(tmp_path):
archive = make_zip(
{"../escape.dll": b"bad", "audiocpp_server.exe": b"server", "audiocpp_cli.exe": b"cli"}
)
manifest = fake_manifest(
"cpu", {"runtime.zip": archive}, ("audiocpp_server.exe", "audiocpp_cli.exe")
)
with pytest.raises(RuntimeInstallError, match="Unsafe path"):
install_windows_runtime(
tmp_path,
"cpu",
manifest=manifest,
platform_name="win32",
opener=lambda request, timeout: FakeResponse(archive),
)
assert not (tmp_path / "escape.dll").exists()
@@ -0,0 +1,109 @@
import json
from pathlib import Path
import sys
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.catalog import load_catalog
from utils.audio_cpp.discovery import (
find_installed_package,
package_install_path,
resolve_model,
resolve_model_roots,
)
from utils.audio_cpp.settings import AudioCppSettings, get_settings_path, load_settings, save_settings
class FakeFolderPaths:
def __init__(self, user_root, models_root, registry):
self.user_root = Path(user_root)
self.models_dir = str(models_root)
self.folder_names_and_paths = {
key: ([str(path) for path in paths], set()) for key, paths in registry.items()
}
def get_system_user_directory(self, name):
return str(self.user_root / name)
def get_folder_paths(self, name):
return list(self.folder_names_and_paths[name][0])
@pytest.mark.unit
def test_settings_live_under_comfyui_system_user_directory_and_round_trip(tmp_path):
fake = FakeFolderPaths(tmp_path / "user", tmp_path / "models", {})
expected = tmp_path / "user" / "tts_audio_suite" / "audio_cpp" / "settings.json"
settings = AudioCppSettings(
connection_mode="external",
external_server_url="http://127.0.0.1:19090",
model_roots=(str(tmp_path / "shared"),),
runtime_backend="cuda",
extras={"future_key": {"kept": True}},
)
assert get_settings_path(fake) == expected
assert save_settings(settings, folder_paths_module=fake) == expected
loaded = load_settings(folder_paths_module=fake, strict=True)
assert loaded == settings
assert json.loads(expected.read_text(encoding="utf-8"))["future_key"] == {"kept": True}
assert not list(expected.parent.glob("*.tmp"))
@pytest.mark.unit
def test_broken_settings_fail_safe_unless_strict(tmp_path):
path = tmp_path / "settings.json"
path.write_text("{broken", encoding="utf-8")
assert load_settings(path) == AudioCppSettings()
with pytest.raises(json.JSONDecodeError):
load_settings(path, strict=True)
@pytest.mark.unit
def test_model_root_precedence_deduplicates_and_keeps_managed_last(tmp_path):
explicit = tmp_path / "explicit"
configured = tmp_path / "configured"
dedicated = tmp_path / "dedicated"
tts_primary = tmp_path / "tts-primary"
tts_secondary = tmp_path / "tts-secondary"
managed = tts_primary / "audio.cpp" / "models"
fake = FakeFolderPaths(
tmp_path / "user",
tmp_path / "models",
{"audio_cpp": [dedicated], "TTS": [tts_primary, tts_secondary]},
)
settings = AudioCppSettings(
model_roots=(str(configured), str(dedicated)),
managed_model_root=str(managed),
)
assert resolve_model_roots(
[explicit, managed], settings=settings, folder_paths_module=fake
) == [
explicit,
configured,
dedicated,
tts_secondary / "audio.cpp" / "models",
managed,
]
@pytest.mark.unit
def test_discovery_prefers_existing_external_model_without_copying(tmp_path):
package = load_catalog().package("chatterbox_q8_0")
external = tmp_path / "existing-audio-cpp-models"
managed = tmp_path / "managed"
installed = package_install_path(package, external)
installed.mkdir(parents=True)
(installed / package.local_files[0]).write_bytes(b"gguf")
assert find_installed_package(package, [external, managed]) == installed
resolved = resolve_model(package.id, [external, managed])
assert resolved is not None
assert resolved.root == external
assert resolved.path == installed
assert not managed.exists()
+504
View File
@@ -0,0 +1,504 @@
import base64
import io
import json
import os
import struct
import sys
import threading
import types
import urllib.request
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import pytest
import torch
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from utils.audio_cpp.client import (
AudioCppClient,
AudioCppHTTPError,
AudioCppProtocolError,
)
from utils.audio_cpp.catalog import load_catalog
from utils.audio_cpp.discovery import package_install_path
from utils.audio_cpp.process import AudioCppServerProcess, normalize_audio_cpp_task
from utils.audio_cpp.settings import AudioCppSettings
from utils.audio_cpp import resolver as audio_cpp_resolver
from utils.audio_cpp.session import (
audio_cpp_session_statuses,
close_all_audio_cpp_sessions,
get_audio_cpp_session,
)
def _wav_bytes(sample_rate=16000, channels=1, frames=32):
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
samples = []
for index in range(frames):
value = int(16000 * ((index % 4) - 1.5) / 1.5)
samples.extend([value] * channels)
wav_file.writeframes(struct.pack(f"<{len(samples)}h", *samples))
return buffer.getvalue()
class _FakeAudioCppHandler(BaseHTTPRequestHandler):
server_version = "FakeAudioCpp/0.5.1"
def log_message(self, format, *args):
return None
def _json(self, payload, status=200):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urlsplit(self.path)
if parsed.path == "/health":
self._json({"status": "ok", "models": 1, "features": ["unload_models"]})
elif parsed.path == "/v1/models":
self.server.model_queries += 1
self._json({
"object": "list",
"data": [{
"id": self.server.model_id,
"object": "model",
"family": "pocket_tts",
"task": "tts",
"mode": "offline",
}],
})
elif parsed.path == "/v1/audio/voices":
self.server.last_voice_query = parse_qs(parsed.query)
self._json({"voices": ["alba", "cosette"]})
else:
self._json({"error": {"message": "not found", "type": "not_found"}}, 404)
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(length).decode("utf-8"))
self.server.requests.append(payload)
request = payload.get("request", {})
text = request.get("text")
if text == "http-error":
self._json(
{"error": {"message": "model is busy", "type": "server_busy"}},
503,
)
return
encoded = base64.b64encode(self.server.wav_bytes).decode("ascii")
if text == "named-only":
self._json({
"named_audio_outputs": [{
"id": "speech",
"audio": encoded,
"sample_rate": 16000,
"channels": 1,
}],
"timing": {},
})
elif text == "ambiguous":
self._json({
"named_audio_outputs": [
{"id": "left", "audio": encoded},
{"id": "right", "audio": encoded},
]
})
elif text == "transcript-only":
self._json({
"text": "hello world",
"language": "en",
"words": [
{"word": "hello", "start_sample": 0, "end_sample": 8000},
{"word": "world", "start_sample": 8000, "end_sample": 16000},
],
"timing": {"wall_ms": 1.0},
})
else:
self._json({
"audio": encoded,
"sample_rate": 16000,
"channels": 1,
"timing": {"wall_ms": 1.0},
})
@pytest.fixture
def fake_audio_cpp_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeAudioCppHandler)
server.model_id = "pocket"
server.wav_bytes = _wav_bytes()
server.requests = []
server.last_voice_query = None
server.model_queries = 0
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
@pytest.fixture(autouse=True)
def clean_audio_cpp_sessions():
close_all_audio_cpp_sessions()
yield
close_all_audio_cpp_sessions()
@pytest.mark.unit
def test_client_health_models_voices_and_primary_audio(fake_audio_cpp_server):
server, url = fake_audio_cpp_server
client = AudioCppClient(url)
assert client.health()["status"] == "ok"
assert client.models()[0]["id"] == "pocket"
assert client.voices("pocket") == ["alba", "cosette"]
assert server.last_voice_query == {"model": ["pocket"]}
assert client.supports_feature("unload_models") is True
result = client.run_task("pocket", {"text": "hello", "seed": "42"})
assert result.sample_rate == 16000
assert result.channels == 1
assert result.waveform.shape == (1, 32)
assert result.waveform.dtype == torch.float32
assert result.waveform.device.type == "cpu"
assert server.requests[-1] == {
"model": "pocket",
"request": {"text": "hello", "seed": "42"},
}
@pytest.mark.unit
def test_client_selects_sole_named_audio_and_rejects_ambiguous_output(fake_audio_cpp_server):
_, url = fake_audio_cpp_server
client = AudioCppClient(url)
result = client.run_task("pocket", {"text": "named-only"})
assert result.sample_rate == 16000
assert list(result.named_audio) == ["speech"]
assert result.waveform.data_ptr() == result.named_audio["speech"].waveform.data_ptr()
with pytest.raises(AudioCppProtocolError, match="multiple named audio"):
client.run_task("pocket", {"text": "ambiguous"})
@pytest.mark.unit
def test_client_accepts_structured_transcript_without_audio(fake_audio_cpp_server):
_, url = fake_audio_cpp_server
result = AudioCppClient(url).run_task("asr", {"text": "transcript-only"})
assert result.waveform is None
assert result.sample_rate is None
assert result.raw["text"] == "hello world"
assert len(result.raw["words"]) == 2
@pytest.mark.unit
def test_client_surfaces_structured_http_error(fake_audio_cpp_server):
_, url = fake_audio_cpp_server
client = AudioCppClient(url)
with pytest.raises(AudioCppHTTPError) as captured:
client.run_task("pocket", {"text": "http-error"})
assert captured.value.status == 503
assert captured.value.error_type == "server_busy"
assert "model is busy" in str(captured.value)
def _write_fake_server_script(path: Path) -> Path:
script = r'''
import argparse
import base64
import io
import json
import struct
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
args = parser.parse_args()
with open(args.config, "r", encoding="utf-8") as handle:
config = json.load(handle)
model_id = config["models"][0]["id"]
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(22050)
wav_file.writeframes(struct.pack("<16h", *range(16)))
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return None
def send_json(self, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
if path == "/health":
self.send_json({"status": "ok", "models": 1})
elif path == "/v1/models":
self.send_json({"object": "list", "data": [{"id": model_id}]})
elif path == "/v1/audio/voices":
self.send_json({"voices": ["managed"]})
else:
self.send_json({})
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
json.loads(self.rfile.read(length).decode("utf-8"))
self.send_json({"audio": encoded, "sample_rate": 22050, "channels": 1})
server = ThreadingHTTPServer((config["host"], config["port"]), Handler)
print("fake audio.cpp ready", flush=True)
server.serve_forever()
'''
path.write_text(script, encoding="utf-8")
return path
def _owned_config(tmp_path: Path, script: Path):
model_path = tmp_path / "model"
model_path.mkdir(exist_ok=True)
(model_path / "weights.gguf").write_bytes(b"audio-cpp-test-model")
return {
"connection_mode": "existing_binary",
"binary_path": sys.executable,
"binary_args": [str(script)],
"model_path": str(model_path),
"model_id": "managed-pocket",
"family": "pocket_tts",
"package_id": "pocket_tts_english_q8_0",
"task": "tts",
"backend": "cpu",
"startup_timeout": 5.0,
"connect_timeout": 1.0,
"request_timeout": 5.0,
"stop_timeout": 2.0,
}
@pytest.mark.unit
def test_owned_process_writes_secure_config_and_stops_exact_child(tmp_path):
assert normalize_audio_cpp_task("clone") == "clon"
assert normalize_audio_cpp_task("voice design") == "vdes"
script = _write_fake_server_script(tmp_path / "fake_audio_cpp_server.py")
runtime = AudioCppServerProcess(_owned_config(tmp_path, script)).start()
process = runtime.process
config_path = runtime.config_path
assert process is not None and process.poll() is None
assert config_path is not None and config_path.exists()
payload = json.loads(config_path.read_text(encoding="utf-8"))
assert payload["host"] == "127.0.0.1"
assert payload["cors_origins"] == ""
assert payload["log_request_body"] is False
assert payload["model_spec_override"].endswith("utils\\audio_cpp\\model_specs") or payload[
"model_spec_override"
].endswith("utils/audio_cpp/model_specs")
assert payload["models"][0]["task"] == "tts"
assert Path(payload["models"][0]["path"]).is_absolute()
assert runtime.client.voices("managed-pocket") == ["managed"]
runtime.close()
assert process.poll() is not None
assert not config_path.exists()
@pytest.mark.unit
def test_external_session_is_keyed_and_never_terminates_server(fake_audio_cpp_server):
server, url = fake_audio_cpp_server
config = {
"connection_mode": "existing_server",
"server_url": url,
"model_id": "pocket",
}
first = get_audio_cpp_session(config)
second = get_audio_cpp_session(dict(config))
assert first is second
assert first.owned is False
assert first.model_id == "pocket"
assert first.model_metadata["family"] == "pocket_tts"
assert first.task == "tts"
assert first.voices() == ["alba", "cosette"]
assert server.model_queries == 1
first.close()
with urllib.request.urlopen(f"{url}/health", timeout=1) as response:
assert json.load(response)["status"] == "ok"
@pytest.mark.unit
def test_owned_session_restarts_and_reregisters_after_exact_child_exit(tmp_path, monkeypatch):
script = _write_fake_server_script(tmp_path / "fake_audio_cpp_server.py")
fake_management = types.ModuleType("comfy.model_management")
fake_management.current_loaded_models = []
fake_management.cleanup_models = lambda: None
class LoadedModel:
def __init__(self, model):
self.model = model
fake_management.LoadedModel = LoadedModel
monkeypatch.setitem(sys.modules, "comfy.model_management", fake_management)
comfy_module = sys.modules.get("comfy")
if comfy_module is not None:
monkeypatch.setattr(comfy_module, "model_management", fake_management, raising=False)
session = get_audio_cpp_session(_owned_config(tmp_path, script))
assert session.proxy.model_size() >= len(b"audio-cpp-test-model")
first = session.run({"text": "first"})
first_runtime = session.process
first_process = first_runtime.process
assert first.sample_rate == 22050
assert len(fake_management.current_loaded_models) == 1
first_process.kill()
first_process.wait(timeout=2)
second = session.run({"text": "second"})
second_runtime = session.process
second_process = second_runtime.process
assert second.sample_rate == 22050
assert second_runtime is not first_runtime
assert second_process is not first_process
assert len(fake_management.current_loaded_models) == 1
session.restart_owned_runtime()
reset_process = session.process.process
assert second_process.poll() is not None
assert reset_process is not second_process
assert session.run({"text": "after explicit reset"}).sample_rate == 22050
assert len(fake_management.current_loaded_models) == 1
assert session.proxy.partially_unload("cpu", 1) == 0
tracked_model = fake_management.current_loaded_models[0]
session.proxy.unpatch_model("cpu")
assert reset_process.poll() is not None
assert fake_management.current_loaded_models == [tracked_model]
fake_management.current_loaded_models.pop(0)
session.run({"text": "third"})
third_process = session.process.process
assert third_process.poll() is None
assert len(fake_management.current_loaded_models) == 1
session.close()
assert third_process.poll() is not None
assert len(fake_management.current_loaded_models) == 0
@pytest.mark.unit
def test_session_makes_voice_reference_path_absolute(fake_audio_cpp_server, tmp_path, monkeypatch):
server, url = fake_audio_cpp_server
monkeypatch.chdir(tmp_path)
reference = tmp_path / "voice.wav"
reference.write_bytes(_wav_bytes())
session = get_audio_cpp_session({
"connection_mode": "existing_server",
"server_url": url,
"model_id": "pocket",
})
session.run({"text": "absolute path", "voice_ref": "voice.wav"})
sent_path = server.requests[-1]["request"]["voice_ref"]
assert Path(sent_path).is_absolute()
assert Path(sent_path) == reference
@pytest.mark.unit
def test_session_resolver_discovers_existing_model_root(tmp_path, monkeypatch):
script = _write_fake_server_script(tmp_path / "fake_audio_cpp_server.py")
external_root = tmp_path / "existing-models"
managed_root = tmp_path / "suite-managed-models"
package = load_catalog().package("pocket_tts_english_q8_0")
installed = package_install_path(package, external_root)
for relative_path in package.local_files:
target = installed / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"installed")
monkeypatch.setattr(
audio_cpp_resolver,
"load_settings",
lambda: AudioCppSettings(
connection_mode="managed",
model_roots=(str(external_root),),
managed_model_root=str(managed_root),
runtime_backend="cpu",
),
)
session = get_audio_cpp_session({
"connection_mode": "managed",
"family": "pocket_tts",
"package_id": package.id,
"task": "tts",
"backend": "cpu",
"binary_path": sys.executable,
"binary_args": [str(script)],
"startup_timeout": 5.0,
"connect_timeout": 1.0,
"request_timeout": 5.0,
})
result = session.run({"text": "resolved"})
assert result.sample_rate == 22050
assert session.config["model_path"] == str(installed.resolve())
assert not managed_root.exists()
@pytest.mark.unit
def test_external_session_selects_the_servers_sole_model(fake_audio_cpp_server):
server, url = fake_audio_cpp_server
session = get_audio_cpp_session({
"connection_mode": "external_server",
"server_url": url,
"model_id": "",
})
reused = get_audio_cpp_session({
"connection_mode": "external_server",
"server_url": url,
"model_id": "",
})
assert reused is session
assert session.model_id == "pocket"
assert session.model_metadata["family"] == "pocket_tts"
assert session.task == "tts"
assert server.model_queries == 1
before = next(item for item in audio_cpp_session_statuses() if item["model_id"] == "pocket")
assert before["state"] == "server_ready"
assert before["owned"] is False
assert before["endpoint"] == url
session.run({"text": "status"})
after = next(item for item in audio_cpp_session_statuses() if item["model_id"] == "pocket")
assert after["state"] == "model_ready"
+1
View File
@@ -11,6 +11,7 @@ _ADAPTER_MAP: Dict[str, str] = {
"qwen3_tts": "engines.adapters.asr_qwen3_adapter.Qwen3ASREngineAdapter",
"qwen3": "engines.adapters.asr_qwen3_adapter.Qwen3ASREngineAdapter",
"granite_asr": "engines.adapters.asr_granite_adapter.GraniteASREngineAdapter",
"audio_cpp": "engines.adapters.asr_audio_cpp_adapter.AudioCppASREngineAdapter",
}
+46 -3
View File
@@ -314,9 +314,14 @@ class IndexTTSCacheKeyGenerator(CacheKeyGenerator):
'repetition_penalty': params.get('repetition_penalty', 10.0),
'max_mel_tokens': params.get('max_mel_tokens', 1500),
'max_text_tokens_per_segment': params.get('max_text_tokens_per_segment', 120),
'interval_silence': params.get('interval_silence', 200),
'model_name': params.get('model_name', 'IndexTTS-2'),
'device': params.get('device', 'auto'),
'interval_silence': params.get('interval_silence', 200),
'model_name': params.get('model_name', 'IndexTTS-2'),
'model_version': params.get('model_version', '2'),
'model_path': params.get('model_path', ''),
'language': params.get('language', 'English'),
'duration_factor': round(float(params.get('duration_factor', 1.0)), 4),
'text_normalization': params.get('text_normalization', True),
'device': params.get('device', 'auto'),
'character': params.get('character', 'narrator'),
'use_torch_compile': params.get('use_torch_compile', False), # Optimization may affect output precision
'use_accel': params.get('use_accel', False), # Optimization may affect output precision
@@ -697,6 +702,43 @@ class OmniVoiceCacheKeyGenerator(CacheKeyGenerator):
return hashlib.md5(cache_string.encode()).hexdigest()
class AudioCppCacheKeyGenerator(CacheKeyGenerator):
"""Cache key generator for the generic audio.cpp server backend."""
def generate_cache_key(self, **params) -> str:
cache_data = {
'text': params.get('text', ''),
'audio_component': params.get('audio_component', ''),
'reference_text': params.get('reference_text', ''),
'family': params.get('family', ''),
'package_id': params.get('package_id', ''),
'model_path': params.get('model_path', ''),
'model_id': params.get('model_id', ''),
'task': params.get('task', ''),
'connection_mode': params.get('connection_mode', ''),
'server_url': params.get('server_url', ''),
'binary_path': params.get('binary_path', ''),
'backend': params.get('backend', ''),
'device_index': params.get('device_index', 0),
'language': params.get('language', ''),
'voice_id': params.get('voice_id', ''),
'instruct': params.get('instruct', ''),
'temperature': params.get('temperature'),
'top_p': params.get('top_p'),
'top_k': params.get('top_k'),
'repetition_penalty': params.get('repetition_penalty'),
'max_tokens': params.get('max_tokens'),
'max_steps': params.get('max_steps'),
'num_inference_steps': params.get('num_inference_steps'),
'guidance_scale': params.get('guidance_scale'),
'seed': params.get('seed', 0),
'request_options': params.get('request_options', ''),
'character': params.get('character', 'narrator'),
'engine': 'audio_cpp',
}
return hashlib.md5(str(sorted(cache_data.items())).encode()).hexdigest()
class AudioCache:
"""Unified audio cache manager for all TTS engines."""
@@ -715,6 +757,7 @@ class AudioCache:
'dots_tts': DotsTTSCacheKeyGenerator(),
'dramabox': DramaBoxCacheKeyGenerator(),
'fish_audio_s2': FishAudioS2CacheKeyGenerator(),
'audio_cpp': AudioCppCacheKeyGenerator(),
'omnivoice': OmniVoiceCacheKeyGenerator(),
'moss_tts': MossTTSCacheKeyGenerator(),
'moss_soundeffect_v2': MossSoundEffectV2CacheKeyGenerator(),
+43
View File
@@ -0,0 +1,43 @@
"""Pinned audio.cpp integration data, discovery, and installation helpers."""
from .catalog import (
AUDIO_CPP_RELEASE_COMMIT,
AUDIO_CPP_RELEASE_TAG,
AUDIO_CPP_RELEASE_VERSION,
AudioCppCatalog,
FamilyRecord,
PackageRecord,
family_choices,
get_family,
get_model_specs_dir,
get_package,
load_catalog,
package_choices,
recommended_package,
resolve_task,
)
from .settings import AudioCppSettings, get_settings_path, load_settings, save_settings
from .resolver import AudioCppResolutionError, resolve_audio_cpp_config
__all__ = [
"AUDIO_CPP_RELEASE_COMMIT",
"AUDIO_CPP_RELEASE_TAG",
"AUDIO_CPP_RELEASE_VERSION",
"AudioCppCatalog",
"AudioCppSettings",
"AudioCppResolutionError",
"FamilyRecord",
"PackageRecord",
"family_choices",
"get_family",
"get_model_specs_dir",
"get_package",
"get_settings_path",
"load_catalog",
"load_settings",
"package_choices",
"recommended_package",
"resolve_audio_cpp_config",
"resolve_task",
"save_settings",
]
+184
View File
@@ -0,0 +1,184 @@
"""Resolve Suite integration capabilities for pinned audio.cpp families."""
from __future__ import annotations
from copy import deepcopy
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Mapping
import yaml
from .catalog import AUDIO_CPP_RELEASE_TAG, PackageRecord, load_catalog
class CapabilityError(ValueError):
pass
def get_capability_path() -> Path:
return Path(__file__).resolve().with_name("integration_capabilities.yaml")
@lru_cache(maxsize=1)
def _load_overlay() -> Dict[str, Any]:
path = get_capability_path()
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise CapabilityError(f"Cannot read audio.cpp capability overlay {path}: {exc}") from exc
if raw.get("schema_version") != 1 or raw.get("release") != AUDIO_CPP_RELEASE_TAG:
raise CapabilityError("audio.cpp capability overlay release/schema does not match the catalog")
return dict(raw)
def _merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> Dict[str, Any]:
value = deepcopy(dict(base))
for key, item in override.items():
if isinstance(item, Mapping) and isinstance(value.get(key), Mapping):
value[key] = _merge(value[key], item)
else:
value[key] = deepcopy(item)
return value
@lru_cache(maxsize=1)
def load_capabilities() -> Dict[str, Dict[str, Any]]:
raw = _load_overlay()
catalog = load_catalog()
families = raw.get("families") or {}
if set(families) != set(catalog.families):
missing = sorted(set(catalog.families) - set(families))
extra = sorted(set(families) - set(catalog.families))
raise CapabilityError(f"audio.cpp capability families mismatch; missing={missing}, extra={extra}")
resolved: Dict[str, Dict[str, Any]] = {}
defaults = raw.get("defaults") or {}
for family_id, override in families.items():
family = catalog.family(family_id)
item = _merge(defaults, override or {})
item.update(
id=family.id,
display_name=family.display_name,
description=family.description,
languages=list(family.languages),
upstream_tasks=list(family.runtime_tasks),
packages=[package.id for package in family.packages],
recommended_package_id=family.recommended_package_id,
)
asr_features = item.get("asr_features") or {}
if not isinstance(asr_features, Mapping):
raise CapabilityError(
f"audio.cpp {family_id} asr_features must be an object"
)
diarization = str(asr_features.get("diarization", "none"))
timing = str(asr_features.get("timing", "none"))
if diarization not in {"none", "native"}:
raise CapabilityError(
f"audio.cpp {family_id} has invalid ASR diarization capability: {diarization}"
)
if timing not in {
"none",
"native_word",
"native_segment",
"optional_forced_aligner",
}:
raise CapabilityError(
f"audio.cpp {family_id} has invalid ASR timing capability: {timing}"
)
resolved[family_id] = item
return resolved
def get_capability(family: str) -> Dict[str, Any]:
try:
return deepcopy(load_capabilities()[str(family)])
except KeyError as exc:
raise CapabilityError(f"Unknown audio.cpp capability family: {family!r}") from exc
def public_capabilities() -> Dict[str, Any]:
catalog = load_catalog()
raw_sizes = _load_overlay().get("package_sizes") or {}
if set(raw_sizes) != set(catalog.packages):
missing = sorted(set(catalog.packages) - set(raw_sizes))
extra = sorted(set(raw_sizes) - set(catalog.packages))
raise CapabilityError(f"audio.cpp package sizes mismatch; missing={missing}, extra={extra}")
packages = {}
for package_id, package in catalog.packages.items():
size = raw_sizes[package_id]
if not isinstance(size, int) or size <= 0:
raise CapabilityError(f"Invalid estimated size for audio.cpp package {package_id!r}")
dependencies = get_package_dependencies(package_id)
dependency_bytes = sum(
int(item["estimated_download_bytes"]) for item in dependencies
)
packages[package_id] = {
"id": package.id,
"family": package.family,
"display_name": package.display_name,
"format": package.format,
"precision": package.precision,
"estimated_download_bytes": size + dependency_bytes,
"primary_download_bytes": size,
"dependencies": [item["package"].id for item in dependencies],
}
return {
"schema_version": 1,
"release": AUDIO_CPP_RELEASE_TAG,
"sizes_checked_at": "2026-08-13",
"families": load_capabilities(),
"packages": packages,
}
def get_package_dependencies(package_id: str) -> list[Dict[str, Any]]:
entries = (_load_overlay().get("package_dependencies") or {}).get(package_id, [])
dependencies = []
for entry in entries:
package = PackageRecord(
family=str(entry["family"]),
id=str(entry["id"]),
display_name=str(entry["display_name"]),
target_directory=str(entry["target_directory"]),
format=str(entry["format"]),
precision=str(entry["precision"]),
files=tuple(str(value) for value in entry["files"]),
strip_prefix=str(entry.get("strip_prefix", "")),
download={
"kind": "huggingface_snapshot",
"repo": str(entry["repo"]),
"revision": str(entry.get("revision", "main")),
"gated": False,
},
)
# Validate local mappings before the downloader touches disk.
package.local_files
dependencies.append(
{
"package": package,
"session_option": str(entry["session_option"]),
"estimated_download_bytes": int(entry["estimated_download_bytes"]),
}
)
return dependencies
def validate_voice_reference(family: str, voice_ref: Any, character: str = "narrator") -> None:
"""Enforce requirements that the frontend panel merely explains."""
from utils.voice.reference import effective_voice_audio
capability = get_capability(family)
audio_requirement = capability["reference_audio"]
has_audio = isinstance(voice_ref, Mapping) and effective_voice_audio(voice_ref) is not None
if audio_requirement in {"required", "required_per_speaker"} and not has_audio:
raise ValueError(f"audio.cpp {family} requires reference audio for '{character}'")
transcript = ""
if isinstance(voice_ref, Mapping):
transcript = str(
voice_ref.get("reference_text") or voice_ref.get("prompt_text") or voice_ref.get("text") or ""
).strip()
if capability["reference_transcript"] == "required" and not transcript:
raise ValueError(
f"audio.cpp {family} requires the transcript matching '{character}' reference audio"
)
+38
View File
@@ -0,0 +1,38 @@
"""HTTP route for the audio.cpp engine capability panel."""
from __future__ import annotations
def register_audio_cpp_capability_routes(routes, web) -> None:
@routes.get("/api/tts-audio-suite/audio-cpp-capabilities")
async def get_audio_cpp_capabilities(_request):
try:
from .capabilities import public_capabilities
return web.json_response(public_capabilities())
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
@routes.get("/api/tts-audio-suite/audio-cpp-status")
async def get_audio_cpp_status(_request):
try:
from .session import audio_cpp_session_statuses
return web.json_response({"sessions": audio_cpp_session_statuses()})
except Exception as exc:
return web.json_response({"sessions": [], "error": str(exc)}, status=500)
@routes.post("/api/tts-audio-suite/audio-cpp-stop")
async def stop_audio_cpp_session(request):
try:
from .session import stop_owned_audio_cpp_session
data = await request.json()
stopped = stop_owned_audio_cpp_session(str(data.get("session_id", "")))
if not stopped:
return web.json_response({"error": "audio.cpp session not found"}, status=404)
return web.json_response({"status": "stopped"})
except PermissionError as exc:
return web.json_response({"error": str(exc)}, status=403)
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
+368
View File
@@ -0,0 +1,368 @@
"""Pinned audio.cpp release-0.5.1 Suite-compatible model catalog.
The bundled JSON files are exact copies of the selected upstream tag's model
specifications. The executable's compiled task IDs are kept separately because
several release specs expose broader or differently-spelled task metadata.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path, PurePosixPath
from typing import Any, Dict, Iterator, Mapping, Optional, Sequence, Tuple
AUDIO_CPP_RELEASE_VERSION = "0.5.1"
AUDIO_CPP_RELEASE_TAG = "release-0.5.1"
AUDIO_CPP_RELEASE_COMMIT = "238ab6a9e321c17de8e120559f57efeedaeb1345"
MODEL_SPEC_FILENAMES: Tuple[str, ...] = (
"citrinet_asr.json",
"chatterbox.json",
"confucius4_tts.json",
"dramabox.json",
"fish_audio.json",
"fun_asr_nano.json",
"glm_tts.json",
"higgs_audio_tts.json",
"higgs_audio_stt.json",
"hviske_asr.json",
"index_tts2.json",
"inflect_v2.json",
"irodori_tts.json",
"kroko_asr.json",
"miotts.json",
"moss_tts_local.json",
"moss_tts_nano.json",
"nemotron_asr.json",
"omnivoice.json",
"outetts.json",
"parakeet_tdt.json",
"pocket_tts.json",
"qwen3_tts.json",
"qwen3_asr.json",
"seed_vc.json",
"supertonic.json",
"vevo2.json",
"vibevoice.json",
"vibevoice_asr.json",
"vietneu_tts.json",
"voxcpm2.json",
"voxtral_realtime.json",
)
# These are the task IDs actually compiled into release-0.5.1. Do not derive
# them from the broader human-facing ``tasks`` arrays in the JSON specs.
COMPILED_TASKS: Mapping[str, Tuple[str, ...]] = {
"citrinet_asr": ("asr",),
"chatterbox": ("clon", "vc"),
"confucius4_tts": ("clon",),
"dramabox": ("tts", "clon"),
"fish_audio": ("tts",),
"fun_asr_nano": ("asr",),
"glm_tts": ("tts", "clon"),
"higgs_audio_tts": ("tts",),
"higgs_audio_stt": ("asr",),
"hviske_asr": ("asr",),
"index_tts2": ("tts", "clon"),
"inflect_v2": ("tts",),
"irodori_tts": ("tts", "clon", "vdes"),
"kroko_asr": ("asr",),
"miotts": ("tts",),
"moss_tts_local": ("tts", "clon"),
"moss_tts_nano": ("tts", "clon"),
"nemotron_asr": ("asr",),
"omnivoice": ("tts",),
"outetts": ("tts", "clon"),
"parakeet_tdt": ("asr",),
"pocket_tts": ("tts",),
"qwen3_tts": ("tts", "vdes"),
"qwen3_asr": ("asr",),
"seed_vc": ("vc", "svc"),
"supertonic": ("tts",),
"vevo2": ("tts", "vc", "s2s", "svc"),
"vibevoice": ("tts",),
"vibevoice_asr": ("asr",),
"vietneu_tts": ("tts", "vdes"),
"voxcpm2": ("tts",),
"voxtral_realtime": ("asr",),
}
_TASK_ALIASES = {
"clone": "clon",
"cloning": "clon",
"voice_clone": "clon",
"voice_cloning": "clon",
"voice_design": "vdes",
"design": "vdes",
"voice_conversion": "vc",
"speech_to_speech": "s2s",
"singing_voice_conversion": "svc",
}
class CatalogError(ValueError):
"""Raised when a pinned model specification is missing or inconsistent."""
def _safe_package_relative_path(value: str, label: str) -> PurePosixPath:
normalized = value.replace("\\", "/")
path = PurePosixPath(normalized)
if (
not normalized
or path.is_absolute()
or ".." in path.parts
or (path.parts and ":" in path.parts[0])
):
raise CatalogError(f"Unsafe {label}: {value!r}")
return path
@dataclass(frozen=True)
class PackageRecord:
family: str
id: str
display_name: str
target_directory: str
format: str
precision: str
files: Tuple[str, ...]
strip_prefix: str
download: Mapping[str, Any]
default: bool = False
def local_relative_path(self, remote_path: str) -> Path:
"""Map one remote package path to its installed relative path."""
remote = _safe_package_relative_path(remote_path, "remote file path")
prefix_text = self.strip_prefix.replace("\\", "/").rstrip("/")
if prefix_text in ("", "."):
local = remote
else:
prefix = _safe_package_relative_path(prefix_text, "strip_prefix")
if remote == prefix or remote.parts[: len(prefix.parts)] != prefix.parts:
raise CatalogError(
f"Package {self.id!r} file {remote_path!r} is outside "
f"strip_prefix {self.strip_prefix!r}"
)
local = PurePosixPath(*remote.parts[len(prefix.parts) :])
if not local.parts:
raise CatalogError(f"Package {self.id!r} maps {remote_path!r} to an empty path")
return Path(*local.parts)
@property
def local_files(self) -> Tuple[Path, ...]:
return tuple(self.local_relative_path(path) for path in self.files)
@property
def repo(self) -> str:
return str(self.download.get("repo", ""))
@property
def revision(self) -> str:
return str(self.download.get("revision", "main"))
@property
def gated(self) -> bool:
return bool(self.download.get("gated", False))
@dataclass(frozen=True)
class FamilyRecord:
id: str
display_name: str
description: str
category: str
status: str
runtime_tasks: Tuple[str, ...]
languages: Tuple[str, ...]
options: Mapping[str, Any]
packages: Tuple[PackageRecord, ...]
recommended_package_id: str
spec_filename: str
raw: Mapping[str, Any]
@dataclass(frozen=True)
class AudioCppCatalog:
families: Mapping[str, FamilyRecord]
packages: Mapping[str, PackageRecord]
specs_dir: Path
release_version: str = AUDIO_CPP_RELEASE_VERSION
def iter_families(self) -> Iterator[FamilyRecord]:
return iter(self.families.values())
def iter_packages(self, family: Optional[str] = None) -> Iterator[PackageRecord]:
if family is None:
return iter(self.packages.values())
return iter(self.family(family).packages)
def family(self, family_id: str) -> FamilyRecord:
try:
return self.families[family_id]
except KeyError as exc:
raise CatalogError(f"Unknown audio.cpp family: {family_id!r}") from exc
def package(self, package_id: str) -> PackageRecord:
try:
return self.packages[package_id]
except KeyError as exc:
raise CatalogError(f"Unknown audio.cpp package: {package_id!r}") from exc
def get_model_specs_dir() -> Path:
"""Return the exact release-0.5.1 spec directory shipped with the node."""
return Path(__file__).resolve().parent / "model_specs"
def _merged_download(spec: Mapping[str, Any], package: Mapping[str, Any]) -> Dict[str, Any]:
merged = dict(spec.get("package_defaults", {}).get("download", {}))
merged.update(package.get("download", {}))
return merged
def _load_catalog(specs_dir: Path) -> AudioCppCatalog:
families: Dict[str, FamilyRecord] = {}
packages: Dict[str, PackageRecord] = {}
for filename in MODEL_SPEC_FILENAMES:
path = specs_dir / filename
if not path.is_file():
raise CatalogError(f"Missing pinned audio.cpp model spec: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CatalogError(f"Cannot read audio.cpp model spec {path}: {exc}") from exc
family_id = str(raw.get("family", ""))
if family_id not in COMPILED_TASKS:
raise CatalogError(f"Spec {filename} has unsupported release family {family_id!r}")
if family_id in families:
raise CatalogError(f"Duplicate audio.cpp family {family_id!r}")
family_packages = []
for package_data in raw.get("packages", []):
package_id = str(package_data.get("id", ""))
if not package_id or package_id in packages:
raise CatalogError(f"Missing or duplicate package ID {package_id!r} in {filename}")
package = PackageRecord(
family=family_id,
id=package_id,
display_name=str(package_data.get("display_name", package_id)),
target_directory=str(package_data.get("target_directory", "")),
format=str(package_data.get("format", "")),
precision=str(package_data.get("precision", "")),
files=tuple(str(item) for item in package_data.get("files", [])),
strip_prefix=str(package_data.get("strip_prefix", "")),
download=_merged_download(raw, package_data),
default=bool(package_data.get("default", False)),
)
_safe_package_relative_path(package.target_directory, "target_directory")
if not package.files:
raise CatalogError(f"Package {package_id!r} has no downloadable files")
# Validate prefix mappings when loading, before any filesystem mutation.
package.local_files
if package.download.get("kind") != "huggingface_snapshot" or not package.repo:
raise CatalogError(f"Package {package_id!r} has no supported download source")
packages[package_id] = package
family_packages.append(package)
recommendation = str(raw.get("ui", {}).get("recommended_package", ""))
if not recommendation:
recommendation = next((p.id for p in family_packages if p.default), "")
if recommendation not in {package.id for package in family_packages}:
raise CatalogError(
f"Family {family_id!r} recommends unknown package {recommendation!r}"
)
families[family_id] = FamilyRecord(
id=family_id,
display_name=str(raw.get("display_name", family_id)),
description=str(raw.get("description", "")),
category=str(raw.get("category", "")),
status=str(raw.get("status", "")),
runtime_tasks=COMPILED_TASKS[family_id],
languages=tuple(str(item) for item in raw.get("languages", [])),
options=dict(raw.get("options", {})),
packages=tuple(family_packages),
recommended_package_id=recommendation,
spec_filename=filename,
raw=raw,
)
if set(families) != set(COMPILED_TASKS):
missing = sorted(set(COMPILED_TASKS) - set(families))
raise CatalogError(f"Pinned audio.cpp catalog is incomplete; missing {missing}")
if len(packages) != 96:
raise CatalogError(f"Expected 96 Suite-compatible release-0.5.1 packages, found {len(packages)}")
return AudioCppCatalog(families=families, packages=packages, specs_dir=specs_dir)
@lru_cache(maxsize=1)
def _load_bundled_catalog() -> AudioCppCatalog:
return _load_catalog(get_model_specs_dir())
def load_catalog(specs_dir: Optional[Path] = None) -> AudioCppCatalog:
"""Load the bundled catalog, or validate an equivalent override directory."""
if specs_dir is None:
return _load_bundled_catalog()
return _load_catalog(Path(specs_dir).expanduser().resolve())
def family_choices() -> list[str]:
return list(load_catalog().families)
def package_choices(family: Optional[str] = None) -> list[str]:
catalog = load_catalog()
if family is None:
return list(catalog.packages)
return [package.id for package in catalog.family(family).packages]
def get_family(family: str) -> FamilyRecord:
return load_catalog().family(family)
def get_package(package_id: str) -> PackageRecord:
return load_catalog().package(package_id)
def recommended_package(family: str) -> str:
return get_family(family).recommended_package_id
def resolve_task(family: str, package_id: Optional[str], requested: str = "auto") -> str:
"""Resolve a UI task name to a release-0.5.1 compiled task ID."""
catalog = load_catalog()
family_record = catalog.family(family)
package = catalog.package(package_id) if package_id is not None else None
if package is not None and package.family != family:
raise CatalogError(f"Package {package_id!r} does not belong to family {family!r}")
normalized = str(requested or "auto").strip().lower().replace("-", "_").replace(" ", "_")
if normalized == "auto":
if package is not None and "vdes" in family_record.runtime_tasks:
package_label = f"{package.id} {package.display_name}".lower().replace("_", "")
if "voicedesign" in package_label:
return "vdes"
return family_record.runtime_tasks[0]
normalized = _TASK_ALIASES.get(normalized, normalized)
# Several release families condition cloning through a speaker reference on
# the compiled ``tts`` task instead of exposing a separate ``clon`` task.
if normalized == "clon" and "clon" not in family_record.runtime_tasks:
if "tts" in family_record.runtime_tasks:
return "tts"
if normalized not in family_record.runtime_tasks:
supported = ", ".join(family_record.runtime_tasks)
raise CatalogError(
f"Task {requested!r} is unavailable for {family!r} in audio.cpp "
f"{AUDIO_CPP_RELEASE_VERSION}; supported: {supported}"
)
return normalized
+431
View File
@@ -0,0 +1,431 @@
"""Small stdlib HTTP client for the audio.cpp server API."""
from __future__ import annotations
import array
import base64
import binascii
import io
import json
import socket
import sys
import urllib.error
import urllib.parse
import urllib.request
import wave
from dataclasses import dataclass, field
from typing import Any, Dict, Mapping, Optional
import torch
class AudioCppClientError(RuntimeError):
"""Base error for audio.cpp transport failures."""
class AudioCppConnectionError(AudioCppClientError):
"""The audio.cpp endpoint could not be reached."""
class AudioCppTimeoutError(AudioCppClientError):
"""The audio.cpp endpoint did not respond before the configured timeout."""
class AudioCppProtocolError(AudioCppClientError):
"""The server returned a response that does not match its API contract."""
class AudioCppHTTPError(AudioCppClientError):
"""Structured non-success response from audio.cpp."""
def __init__(
self,
status: int,
message: str,
*,
error_type: Optional[str] = None,
path: str = "",
response_body: str = "",
) -> None:
self.status = int(status)
self.error_type = error_type
self.path = path
self.response_body = response_body
label = f"audio.cpp HTTP {self.status}"
if error_type:
label += f" ({error_type})"
if path:
label += f" for {path}"
super().__init__(f"{label}: {message}")
@dataclass(frozen=True)
class AudioCppAudio:
"""Decoded PCM audio returned by audio.cpp."""
waveform: torch.Tensor
sample_rate: int
channels: int
@dataclass(frozen=True)
class AudioCppTaskResult:
"""Decoded result from ``POST /v1/tasks/run``."""
waveform: Optional[torch.Tensor] = None
sample_rate: Optional[int] = None
channels: Optional[int] = None
named_audio: Dict[str, AudioCppAudio] = field(default_factory=dict)
raw: Dict[str, Any] = field(default_factory=dict)
def _decode_pcm_wav(wav_bytes: bytes, *, context: str = "audio") -> AudioCppAudio:
if not isinstance(wav_bytes, (bytes, bytearray)) or not wav_bytes:
raise AudioCppProtocolError(f"audio.cpp returned empty {context} WAV data")
try:
with wave.open(io.BytesIO(bytes(wav_bytes)), "rb") as wav_file:
channels = int(wav_file.getnchannels())
sample_rate = int(wav_file.getframerate())
sample_width = int(wav_file.getsampwidth())
frame_count = int(wav_file.getnframes())
compression = wav_file.getcomptype()
frames = wav_file.readframes(frame_count)
except (EOFError, wave.Error) as exc:
raise AudioCppProtocolError(f"audio.cpp returned an invalid {context} WAV: {exc}") from exc
if compression != "NONE":
raise AudioCppProtocolError(
f"audio.cpp returned unsupported compressed {context} WAV data ({compression})"
)
if channels <= 0 or sample_rate <= 0:
raise AudioCppProtocolError(
f"audio.cpp returned invalid {context} WAV metadata "
f"(sample_rate={sample_rate}, channels={channels})"
)
if sample_width not in (1, 2, 3, 4):
raise AudioCppProtocolError(
f"audio.cpp returned unsupported {context} WAV sample width: {sample_width} bytes"
)
expected_samples = frame_count * channels
expected_bytes = expected_samples * sample_width
if len(frames) != expected_bytes:
raise AudioCppProtocolError(
f"audio.cpp returned truncated {context} WAV data "
f"({len(frames)} bytes, expected {expected_bytes})"
)
if sample_width == 1:
values = torch.tensor(list(frames), dtype=torch.float32)
values = (values - 128.0) / 128.0
elif sample_width == 2:
pcm = array.array("h")
pcm.frombytes(frames)
if sys.byteorder != "little":
pcm.byteswap()
values = torch.tensor(pcm, dtype=torch.float32) / 32768.0
elif sample_width == 3:
decoded = []
for offset in range(0, len(frames), 3):
sample = int.from_bytes(frames[offset : offset + 3], "little", signed=False)
if sample & 0x800000:
sample -= 0x1000000
decoded.append(sample)
values = torch.tensor(decoded, dtype=torch.float32) / 8388608.0
else:
pcm = array.array("i")
pcm.frombytes(frames)
if sys.byteorder != "little":
pcm.byteswap()
values = torch.tensor(pcm, dtype=torch.float32) / 2147483648.0
if expected_samples == 0:
waveform = torch.empty((channels, 0), dtype=torch.float32)
else:
waveform = values.reshape(frame_count, channels).transpose(0, 1).contiguous()
return AudioCppAudio(
waveform=waveform.cpu(),
sample_rate=sample_rate,
channels=channels,
)
def _decode_base64_wav(value: Any, *, context: str) -> AudioCppAudio:
if not isinstance(value, str) or not value:
raise AudioCppProtocolError(f"audio.cpp response field '{context}' must be base64 WAV text")
try:
wav_bytes = base64.b64decode(value, validate=True)
except (ValueError, binascii.Error) as exc:
raise AudioCppProtocolError(
f"audio.cpp response field '{context}' is not valid base64"
) from exc
return _decode_pcm_wav(wav_bytes, context=context)
class AudioCppClient:
"""Synchronous audio.cpp HTTP client using only Python's standard library."""
def __init__(
self,
base_url: str,
*,
connect_timeout: float = 5.0,
request_timeout: float = 600.0,
max_response_bytes: int = 2 * 1024 * 1024 * 1024,
) -> None:
parsed = urllib.parse.urlsplit(str(base_url).strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError(f"Invalid audio.cpp server URL: {base_url!r}")
if parsed.query or parsed.fragment:
raise ValueError("audio.cpp server URL must not contain a query or fragment")
self.base_url = urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")
)
self.connect_timeout = max(0.01, float(connect_timeout))
self.request_timeout = max(0.01, float(request_timeout))
self.max_response_bytes = max(1, int(max_response_bytes))
def _url(self, path: str) -> str:
if not path.startswith("/"):
path = "/" + path
return self.base_url + path
@staticmethod
def _error_details(body: bytes, fallback: str) -> tuple[str, Optional[str], str]:
text = body.decode("utf-8", errors="replace")
message = fallback
error_type = None
try:
payload = json.loads(text)
error = payload.get("error") if isinstance(payload, dict) else None
if isinstance(error, dict):
message = str(error.get("message") or fallback)
error_type = str(error.get("type")) if error.get("type") else None
elif error:
message = str(error)
except (TypeError, ValueError):
if text.strip():
message = text.strip()[:1000]
return message, error_type, text
@staticmethod
def _is_timeout_error(exc: BaseException) -> bool:
if isinstance(exc, (TimeoutError, socket.timeout)):
return True
if isinstance(exc, urllib.error.URLError):
return isinstance(exc.reason, (TimeoutError, socket.timeout))
return False
def _request_bytes(
self,
method: str,
path: str,
*,
payload: Optional[Mapping[str, Any]] = None,
timeout: Optional[float] = None,
) -> tuple[bytes, Mapping[str, str]]:
body = None
headers = {"Accept": "application/json"}
if payload is not None:
try:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError(f"audio.cpp request is not JSON serializable: {exc}") from exc
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
self._url(path),
data=body,
headers=headers,
method=method.upper(),
)
effective_timeout = self.request_timeout if timeout is None else max(0.01, float(timeout))
try:
with urllib.request.urlopen(request, timeout=effective_timeout) as response:
content_length = response.headers.get("Content-Length")
if content_length:
try:
if int(content_length) > self.max_response_bytes:
raise AudioCppProtocolError(
f"audio.cpp response exceeds {self.max_response_bytes} bytes"
)
except ValueError:
pass
response_body = response.read(self.max_response_bytes + 1)
if len(response_body) > self.max_response_bytes:
raise AudioCppProtocolError(
f"audio.cpp response exceeds {self.max_response_bytes} bytes"
)
return response_body, response.headers
except urllib.error.HTTPError as exc:
error_body = exc.read(65536)
message, error_type, response_text = self._error_details(error_body, str(exc.reason))
raise AudioCppHTTPError(
exc.code,
message,
error_type=error_type,
path=path,
response_body=response_text,
) from exc
except AudioCppClientError:
raise
except (urllib.error.URLError, TimeoutError, socket.timeout, OSError) as exc:
if self._is_timeout_error(exc):
raise AudioCppTimeoutError(
f"audio.cpp request to {path} timed out after {effective_timeout:.2f}s"
) from exc
reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
raise AudioCppConnectionError(
f"Could not connect to audio.cpp at {self.base_url}: {reason}"
) from exc
def _request_json(
self,
method: str,
path: str,
*,
payload: Optional[Mapping[str, Any]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
response_body, _ = self._request_bytes(method, path, payload=payload, timeout=timeout)
try:
decoded = json.loads(response_body.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise AudioCppProtocolError(
f"audio.cpp returned invalid JSON for {path}: {exc}"
) from exc
if not isinstance(decoded, dict):
raise AudioCppProtocolError(
f"audio.cpp returned {type(decoded).__name__}, expected an object for {path}"
)
return decoded
def health(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
return self._request_json(
"GET",
"/health",
timeout=self.connect_timeout if timeout is None else timeout,
)
def models(self, *, timeout: Optional[float] = None) -> list[Dict[str, Any]]:
payload = self._request_json("GET", "/v1/models", timeout=timeout)
models = payload.get("data")
if not isinstance(models, list) or any(not isinstance(item, dict) for item in models):
raise AudioCppProtocolError("audio.cpp /v1/models response is missing a valid data list")
return list(models)
def voices(self, model_id: str, *, timeout: Optional[float] = None) -> list[str]:
query = urllib.parse.urlencode({"model": str(model_id)})
payload = self._request_json("GET", f"/v1/audio/voices?{query}", timeout=timeout)
voices = payload.get("voices")
if not isinstance(voices, list) or any(not isinstance(item, str) for item in voices):
raise AudioCppProtocolError("audio.cpp voices response is missing a valid voices list")
return list(voices)
def features(self, *, timeout: Optional[float] = None) -> set[str]:
"""Read optional future feature metadata without assuming release-0.5.1 has it."""
payload = self.health(timeout=timeout)
raw = payload.get("features", payload.get("capabilities", []))
if isinstance(raw, dict):
return {str(name) for name, enabled in raw.items() if enabled}
if isinstance(raw, list):
return {str(item) for item in raw}
return set()
def supports_feature(self, name: str, *, timeout: Optional[float] = None) -> bool:
return str(name) in self.features(timeout=timeout)
def run_task(
self,
model_id: str,
request: Mapping[str, Any],
*,
timeout: Optional[float] = None,
) -> AudioCppTaskResult:
if not isinstance(request, Mapping):
raise TypeError("audio.cpp task request must be a mapping")
payload = self._request_json(
"POST",
"/v1/tasks/run",
payload={"model": str(model_id), "request": dict(request)},
timeout=timeout,
)
named_audio: Dict[str, AudioCppAudio] = {}
raw_named = payload.get("named_audio_outputs", [])
if raw_named is None:
raw_named = []
if not isinstance(raw_named, list):
raise AudioCppProtocolError("audio.cpp named_audio_outputs must be a list")
for index, item in enumerate(raw_named):
if not isinstance(item, dict):
raise AudioCppProtocolError(
f"audio.cpp named_audio_outputs[{index}] must be an object"
)
output_id = item.get("id")
if not isinstance(output_id, str) or not output_id:
raise AudioCppProtocolError(
f"audio.cpp named_audio_outputs[{index}] is missing a non-empty id"
)
if output_id in named_audio:
raise AudioCppProtocolError(f"audio.cpp returned duplicate named audio id: {output_id}")
decoded = _decode_base64_wav(
item.get("audio"), context=f"named_audio_outputs[{index}].audio"
)
self._validate_declared_audio_metadata(item, decoded, f"named_audio_outputs[{index}]")
named_audio[output_id] = decoded
primary: Optional[AudioCppAudio] = None
if "audio" in payload and payload.get("audio") is not None:
primary = _decode_base64_wav(payload.get("audio"), context="audio")
self._validate_declared_audio_metadata(payload, primary, "audio")
elif len(named_audio) == 1:
primary = next(iter(named_audio.values()))
elif len(named_audio) > 1:
raise AudioCppProtocolError(
"audio.cpp task result contains multiple named audio outputs but no primary audio"
)
elif not named_audio and not any(
key in payload for key in ("text", "segments", "speaker_turns", "words")
):
raise AudioCppProtocolError("audio.cpp task result did not contain task output")
return AudioCppTaskResult(
waveform=primary.waveform if primary is not None else None,
sample_rate=primary.sample_rate if primary is not None else None,
channels=primary.channels if primary is not None else None,
named_audio=named_audio,
raw=payload,
)
@staticmethod
def _validate_declared_audio_metadata(
payload: Mapping[str, Any],
decoded: AudioCppAudio,
context: str,
) -> None:
declared_rate = payload.get("sample_rate")
if declared_rate is not None and int(declared_rate) != decoded.sample_rate:
raise AudioCppProtocolError(
f"audio.cpp {context} sample rate metadata ({declared_rate}) "
f"does not match its WAV ({decoded.sample_rate})"
)
declared_channels = payload.get("channels")
if declared_channels is not None and int(declared_channels) != decoded.channels:
raise AudioCppProtocolError(
f"audio.cpp {context} channel metadata ({declared_channels}) "
f"does not match its WAV ({decoded.channels})"
)
__all__ = [
"AudioCppAudio",
"AudioCppClient",
"AudioCppClientError",
"AudioCppConnectionError",
"AudioCppHTTPError",
"AudioCppProtocolError",
"AudioCppTaskResult",
"AudioCppTimeoutError",
]
+187
View File
@@ -0,0 +1,187 @@
"""Resolve existing audio.cpp models without copying or cache migration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional, Sequence, Union
from .catalog import AudioCppCatalog, PackageRecord, load_catalog
from .settings import AudioCppSettings, get_settings_path, load_settings
def _import_folder_paths():
try:
import folder_paths # type: ignore
return folder_paths
except (ImportError, RuntimeError):
return None
def _registered_paths(folder_paths_module, key: str) -> list[Path]:
if folder_paths_module is None:
return []
registry = getattr(folder_paths_module, "folder_names_and_paths", {})
if key not in registry:
return []
try:
if hasattr(folder_paths_module, "get_folder_paths"):
values = folder_paths_module.get_folder_paths(key)
else:
values = registry[key][0]
except (KeyError, TypeError, ValueError):
return []
return [Path(value).expanduser() for value in values if value]
def _tts_paths(folder_paths_module) -> list[Path]:
paths: list[Path] = []
for key in ("TTS", "tts"):
paths.extend(_registered_paths(folder_paths_module, key))
if not paths and folder_paths_module is not None:
models_dir = getattr(folder_paths_module, "models_dir", None)
if models_dir:
paths.append(Path(models_dir) / "TTS")
return paths
def default_managed_model_root(
*,
settings: Optional[AudioCppSettings] = None,
folder_paths_module=None,
) -> Path:
current = settings or AudioCppSettings()
if current.managed_model_root:
return Path(current.managed_model_root).expanduser()
module = folder_paths_module if folder_paths_module is not None else _import_folder_paths()
tts_paths = _tts_paths(module)
if tts_paths:
return tts_paths[0] / "audio.cpp" / "models"
# This is only a non-ComfyUI safety fallback; it remains direct storage, not
# a Hugging Face cache.
return get_settings_path(module).parent / "models"
def _canonical(path: Path) -> str:
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
def resolve_model_roots(
explicit_roots: Optional[Iterable[Union[str, Path]]] = None,
*,
settings: Optional[AudioCppSettings] = None,
folder_paths_module=None,
) -> list[Path]:
"""Return search roots in priority order, with managed storage last."""
module = folder_paths_module if folder_paths_module is not None else _import_folder_paths()
current = settings or load_settings(folder_paths_module=module)
managed = default_managed_model_root(settings=current, folder_paths_module=module)
candidates: list[Path] = []
candidates.extend(Path(path).expanduser() for path in (explicit_roots or ()) if path)
candidates.extend(Path(path).expanduser() for path in current.model_roots if path)
candidates.extend(_registered_paths(module, "audio_cpp"))
candidates.extend(path / "audio.cpp" / "models" for path in _tts_paths(module))
managed_key = _canonical(managed)
seen: set[str] = set()
result: list[Path] = []
for candidate in candidates:
key = _canonical(candidate)
if key == managed_key or key in seen:
continue
seen.add(key)
result.append(candidate)
result.append(managed)
return result
def package_install_path(package: PackageRecord, models_root: Union[str, Path]) -> Path:
return Path(models_root) / Path(*package.target_directory.replace("\\", "/").split("/"))
def package_is_complete(package: PackageRecord, package_directory: Union[str, Path]) -> bool:
directory = Path(package_directory)
return directory.is_dir() and all(
(directory / path).is_file() and (directory / path).stat().st_size > 0
for path in package.local_files
)
def find_installed_package(
package: Union[str, PackageRecord],
roots: Optional[Sequence[Union[str, Path]]] = None,
*,
catalog: Optional[AudioCppCatalog] = None,
settings: Optional[AudioCppSettings] = None,
folder_paths_module=None,
) -> Optional[Path]:
current_catalog = catalog or load_catalog()
record = current_catalog.package(package) if isinstance(package, str) else package
search_roots = (
[Path(root) for root in roots]
if roots is not None
else resolve_model_roots(settings=settings, folder_paths_module=folder_paths_module)
)
for root in search_roots:
candidate = package_install_path(record, root)
if package_is_complete(record, candidate):
return candidate
return None
@dataclass(frozen=True)
class ResolvedModel:
package: PackageRecord
path: Path
root: Path
def resolve_model(
package: Union[str, PackageRecord],
roots: Optional[Sequence[Union[str, Path]]] = None,
*,
catalog: Optional[AudioCppCatalog] = None,
settings: Optional[AudioCppSettings] = None,
folder_paths_module=None,
) -> Optional[ResolvedModel]:
"""Resolve one catalog package and retain the root that won precedence."""
current_catalog = catalog or load_catalog()
record = current_catalog.package(package) if isinstance(package, str) else package
search_roots = (
[Path(root) for root in roots]
if roots is not None
else resolve_model_roots(settings=settings, folder_paths_module=folder_paths_module)
)
path = find_installed_package(record, search_roots, catalog=current_catalog)
if path is None:
return None
for root in search_roots:
if _canonical(package_install_path(record, root)) == _canonical(path):
return ResolvedModel(package=record, path=path, root=root)
return None
def discover_installed_packages(
roots: Optional[Sequence[Union[str, Path]]] = None,
*,
catalog: Optional[AudioCppCatalog] = None,
settings: Optional[AudioCppSettings] = None,
folder_paths_module=None,
) -> dict[str, ResolvedModel]:
current_catalog = catalog or load_catalog()
found: dict[str, ResolvedModel] = {}
for package in current_catalog.packages.values():
resolved = resolve_model(
package,
roots,
catalog=current_catalog,
settings=settings,
folder_paths_module=folder_paths_module,
)
if resolved is not None:
found[package.id] = resolved
return found
+306
View File
@@ -0,0 +1,306 @@
"""Direct, cache-free downloader for pinned audio.cpp model packages."""
from __future__ import annotations
import os
import shutil
import tempfile
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Optional, Union
from urllib.parse import quote
from .catalog import AudioCppCatalog, PackageRecord, load_catalog
from .discovery import package_install_path, package_is_complete
class AudioCppDownloadError(RuntimeError):
"""Raised when a model package cannot be downloaded safely."""
class IncompleteExistingPackageError(AudioCppDownloadError):
"""Raised when a package target exists but is not complete."""
ProgressCallback = Callable[[str, int, Optional[int]], None]
@dataclass(frozen=True)
class DownloadResult:
package: PackageRecord
path: Path
downloaded_files: tuple[Path, ...]
bytes_downloaded: int
already_present: bool = False
def resolve_hf_token(explicit_token: Optional[str] = None) -> Optional[str]:
if explicit_token:
return explicit_token.strip() or None
for name in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
value = os.environ.get(name, "").strip()
if value:
return value
try:
from huggingface_hub import get_token
value = get_token()
if value:
return value.strip()
except (ImportError, OSError, RuntimeError):
pass
token_path = Path.home() / ".cache" / "huggingface" / "token"
try:
value = token_path.read_text(encoding="utf-8").strip()
return value or None
except OSError:
return None
def huggingface_resolve_url(repo: str, revision: str, remote_path: str) -> str:
return (
"https://huggingface.co/"
f"{quote(repo, safe='/')}/resolve/{quote(revision, safe='')}/"
f"{quote(remote_path.replace(chr(92), '/'), safe='/')}"
)
def package_download_size(
package: Union[str, PackageRecord],
*,
catalog: Optional[AudioCppCatalog] = None,
token: Optional[str] = None,
timeout: int = 30,
opener=None,
) -> Optional[int]:
"""Return the declared HTTP size of every package file when available."""
current_catalog = catalog or load_catalog()
record = current_catalog.package(package) if isinstance(package, str) else package
headers = {"User-Agent": "TTS-Audio-Suite/audio.cpp-model-installer"}
hf_token = resolve_hf_token(token)
if hf_token:
headers["Authorization"] = f"Bearer {hf_token}"
open_request = opener or urllib.request.urlopen
total = 0
try:
for remote_path in record.files:
request = urllib.request.Request(
huggingface_resolve_url(record.repo, record.revision, remote_path),
headers=headers,
method="HEAD",
)
response = open_request(request, timeout=timeout)
try:
length = response.headers.get("Content-Length") if response.headers else None
if not length:
return None
total += int(length)
finally:
response.close()
except (OSError, TypeError, ValueError, urllib.error.URLError):
return None
return total or None
def download_url_to_path(
url: str,
destination: Union[str, Path],
*,
headers: Optional[Mapping[str, str]] = None,
timeout: int = 300,
opener=None,
progress: Optional[ProgressCallback] = None,
progress_label: str = "download",
) -> int:
"""Stream a URL to a new file and validate HTTP Content-Length."""
destination_path = Path(destination)
request = urllib.request.Request(url, headers=dict(headers or {}))
open_request = opener or urllib.request.urlopen
response = None
downloaded = 0
try:
response = open_request(request, timeout=timeout)
status = getattr(response, "status", None) or getattr(response, "code", None)
if status is not None and int(status) >= 400:
raise AudioCppDownloadError(f"HTTP {status} while downloading {url}")
content_length_value = response.headers.get("Content-Length") if response.headers else None
expected = int(content_length_value) if content_length_value else None
destination_path.parent.mkdir(parents=True, exist_ok=True)
with destination_path.open("xb") as handle:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
handle.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress(progress_label, downloaded, expected)
if expected is not None and downloaded != expected:
raise AudioCppDownloadError(
f"Incomplete download for {progress_label}: expected {expected} bytes, got {downloaded}"
)
return downloaded
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
raise AudioCppDownloadError(
f"Hugging Face denied {progress_label} (HTTP {exc.code}). "
"Accept any model license and configure HF_TOKEN."
) from exc
raise AudioCppDownloadError(f"HTTP {exc.code} while downloading {progress_label}") from exc
except urllib.error.URLError as exc:
raise AudioCppDownloadError(f"Network error downloading {progress_label}: {exc.reason}") from exc
except OSError as exc:
raise AudioCppDownloadError(f"Cannot write {destination_path}: {exc}") from exc
finally:
if response is not None:
try:
response.close()
except Exception:
pass
def _remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path)
def _link_or_copy(source: str, destination: str) -> str:
"""Hard-link existing model files into staging, copying only if necessary."""
try:
os.link(source, destination)
return destination
except OSError:
return shutil.copy2(source, destination)
def _merge_existing_target(target: Path, staging: Path) -> None:
"""Preserve other precision packages that share this target directory."""
if target.is_symlink() or not target.is_dir():
raise IncompleteExistingPackageError(
f"Model target cannot be safely merged because it is not a regular directory: {target}"
)
shutil.copytree(
target,
staging,
dirs_exist_ok=True,
copy_function=_link_or_copy,
symlinks=False,
)
def _publish_directory(staging: Path, target: Path, replace_existing: bool) -> None:
# PocketTTS targets are nested (for example PocketTTS-GGUF/english).
# The parent must exist before Windows can rename the staged directory.
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() and not target.is_symlink():
staging.rename(target)
return
if not replace_existing:
raise IncompleteExistingPackageError(
f"Model target already exists but is incomplete: {target}. "
"Choose overwrite explicitly or repair it manually."
)
backup = target.with_name(f".{target.name}.{uuid.uuid4().hex}.backup")
target.rename(backup)
try:
staging.rename(target)
except BaseException:
if not target.exists() and backup.exists():
backup.rename(target)
raise
_remove_path(backup)
def install_package(
package: Union[str, PackageRecord],
models_root: Union[str, Path],
*,
catalog: Optional[AudioCppCatalog] = None,
overwrite: bool = False,
token: Optional[str] = None,
timeout: int = 300,
opener=None,
progress: Optional[ProgressCallback] = None,
) -> DownloadResult:
"""Download every required package file, then atomically publish it."""
current_catalog = catalog or load_catalog()
record = current_catalog.package(package) if isinstance(package, str) else package
if record.download.get("kind") != "huggingface_snapshot":
raise AudioCppDownloadError(
f"Unsupported download kind for {record.id}: {record.download.get('kind')!r}"
)
if not record.repo:
raise AudioCppDownloadError(f"Package {record.id!r} has no Hugging Face repository")
root = Path(models_root).expanduser()
target = package_install_path(record, root)
if package_is_complete(record, target) and not overwrite:
return DownloadResult(record, target, (), 0, already_present=True)
target_exists = target.exists() or target.is_symlink()
if target_exists:
if target.is_symlink() or not target.is_dir():
raise IncompleteExistingPackageError(
f"Model target cannot be safely merged because it is not a regular directory: {target}"
)
requested_files_exist = any(
(target / path).exists() or (target / path).is_symlink()
for path in record.local_files
)
if requested_files_exist and not overwrite:
raise IncompleteExistingPackageError(
f"Model target contains an incomplete package {record.id!r}: {target}. "
"Choose overwrite explicitly or repair it manually."
)
target.parent.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.", suffix=".staging", dir=target.parent))
downloaded_files: list[Path] = []
downloaded_bytes = 0
hf_token = resolve_hf_token(token)
headers = {"User-Agent": "TTS-Audio-Suite/audio.cpp-model-installer"}
if hf_token:
headers["Authorization"] = f"Bearer {hf_token}"
try:
if target_exists:
_merge_existing_target(target, staging)
for remote_path, local_path in zip(record.files, record.local_files):
destination = staging / local_path
# The merge may have hard-linked an older copy of the requested
# precision. Remove that link before creating the replacement.
destination.unlink(missing_ok=True)
url = huggingface_resolve_url(record.repo, record.revision, remote_path)
downloaded_bytes += download_url_to_path(
url,
destination,
headers=headers,
timeout=timeout,
opener=opener,
progress=progress,
progress_label=remote_path,
)
downloaded_files.append(local_path)
if not package_is_complete(record, staging):
missing = [str(path) for path in record.local_files if not (staging / path).is_file()]
raise AudioCppDownloadError(
f"Package {record.id!r} staging validation failed; missing: {missing}"
)
_publish_directory(staging, target, replace_existing=target_exists)
return DownloadResult(
package=record,
path=target,
downloaded_files=tuple(downloaded_files),
bytes_downloaded=downloaded_bytes,
)
except BaseException:
_remove_path(staging)
raise
@@ -0,0 +1,232 @@
schema_version: 1
release: release-0.5.1
# Verified from Hugging Face file metadata on 2026-08-13. These are offline UI
# estimates; live Content-Length values remain authoritative during downloads.
package_sizes:
citrinet_asr_q8_0: 40574432
chatterbox_f16: 3744360386
chatterbox_q8_0: 2088393668
chatterbox_safetensors: 3191859618
confucius4_tts_orig: 8192757760
dramabox_q8_0: 18942803808
fish_audio_s2_pro_bf16: 10229278080
fish_audio_s2_pro_q8_0: 6317911232
fun_asr_nano_2512_f16: 1675708832
fun_asr_nano_2512_q8_0: 1045334432
fun_asr_nano_2512_safetensors: 1671205126
glm_tts_q8_0: 5143813728
higgs_audio_tts_4b_bf16: 8501587648
higgs_audio_tts_4b_q8_0: 5095354048
higgs_audio_stt_f16: 5367453248
higgs_audio_stt_q8_0: 3158310848
hviske_asr_q8_0: 2436359808
hviske_asr_safetensors: 4132293306
index_tts2_f16: 4646898304
index_tts2_orig: 8084552000
index_tts2_q8_0: 3633888608
index_tts2_safetensors: 3484956803
inflect_micro_v2_orig: 72082176
irodori_tts_500m_v3_f16: 1254813120
irodori_tts_500m_v3_q8_0: 1093739584
irodori_tts_600m_v3_voicedesign_f16: 1463787680
irodori_tts_600m_v3_voicedesign_q8_0: 1272140064
irodori_tts_v4_small_f16: 1762148352
irodori_tts_v4_small_q8_0: 1368991360
kroko_asr_community_q8_0: 167756928
miotts_1_7b_bf16: 3518532512
miotts_1_7b_orig: 3518532512
miotts_1_7b_q8_0: 2197326752
moss_tts_local_v1_5_bf16: 13367797280
moss_tts_local_v1_5_q8_0: 7512220768
moss_tts_nano_100m_bf16: 332423040
moss_tts_nano_100m_q8_0: 193337984
nemotron_asr_f16: 1277710880
nemotron_asr_q8_0: 930620256
nemotron_asr_safetensors: 2552818890
omnivoice_bf16: 1639548640
omnivoice_f16: 1639548768
omnivoice_q8_0: 1350288416
omnivoice_safetensors: 2461770336
outetts_1_0_1b_q8_0: 3029895456
parakeet_tdt_f16: 1255384320
parakeet_tdt_q8_0: 915733744
pocket_tts_english_bf16: 219096064
pocket_tts_english_q8_0: 127856704
pocket_tts_english_safetensors: 385107895
pocket_tts_german_bf16: 219096544
pocket_tts_german_q8_0: 127857184
pocket_tts_italian_bf16: 219096800
pocket_tts_italian_q8_0: 127857440
pocket_tts_portuguese_bf16: 219097728
pocket_tts_portuguese_q8_0: 127858368
pocket_tts_spanish_bf16: 219097600
pocket_tts_spanish_q8_0: 127858240
qwen3_tts_0_6b_base_safetensors: 2511651783
qwen3_tts_1_7b_base_bf16: 4203158464
qwen3_tts_1_7b_base_orig: 4544273280
qwen3_tts_1_7b_base_q8_0: 2695175104
qwen3_tts_1_7b_base_safetensors: 4539721255
qwen3_tts_1_7b_customvoice_bf16: 4179144352
qwen3_tts_1_7b_customvoice_q8_0: 2817044064
qwen3_tts_1_7b_voicedesign_bf16: 4179089248
qwen3_tts_1_7b_voicedesign_q8_0: 2816988960
qwen3_asr_0_6b_f16: 1880642016
qwen3_asr_0_6b_q8_0: 1151272416
qwen3_asr_0_6b_safetensors: 1876110856
qwen3_asr_1_7b_f16: 4087653248
qwen3_asr_1_7b_q8_0: 2473010048
qwen3_asr_1_7b_safetensors: 4087626782
seed_vc_mlx_f16: 3629186560
seed_vc_mlx_orig: 7003311936
seed_vc_mlx_q8_0: 3120809248
seed_vc_mlx_safetensors: 712430273
supertonic_3_f16: 312784196
supertonic_3_orig: 454072836
supertonic_3_q8_0: 454072836
supertonic_3_safetensors: 397279577
vevo2_f16: 5021112128
vevo2_orig: 7449615488
vevo2_q8_0: 3242124800
vibevoice_1_5b_bf16: 5420021858
vibevoice_1_5b_q8_0: 3224701538
vibevoice_asr_f16: 17361090304
vibevoice_asr_q8_0: 9858644224
vietneu_tts_v3_turbo_q8_0: 170482368
voxcpm2_bf16: 4772288288
voxcpm2_orig: 4960716192
voxcpm2_q8_0: 2955000480
voxcpm2_safetensors: 4583766759
voxtral_realtime_bf16: 8874402784
voxtral_realtime_q4_k: 3097662432
voxtral_realtime_q8_0: 5104567264
# Runtime dependencies omitted by upstream TTS package declarations. These are
# installed independently and passed to audio.cpp through absolute session paths.
package_dependencies:
miotts_1_7b_q8_0: &miotts_codec_dependency
- id: miocodec_q8_0
family: miocodec
display_name: "MioCodec 25Hz 44.1kHz v2 Q8_0 GGUF"
target_directory: "MioCodec-25Hz-44.1kHz-v2-GGUF"
format: gguf
precision: q8_0
files: ["MioCodec-25Hz-44.1kHz-v2-GGUF/miocodec-25hz-44khz-v2-q8_0.gguf"]
strip_prefix: "MioCodec-25Hz-44.1kHz-v2-GGUF"
repo: "audio-cpp/audio.cpp-gguf"
revision: main
estimated_download_bytes: 299066464
session_option: "miotts.codec_model_path"
miotts_1_7b_bf16: *miotts_codec_dependency
miotts_1_7b_orig: *miotts_codec_dependency
# Suite-owned UI and validation metadata. Download/package truth remains in
# model_specs/*.json, which are unmodified snapshots of audio.cpp.
defaults:
suite_support: supported
suite_tasks: [tts, srt, character_switching]
reference_audio: none
reference_transcript: none
built_in_voices: false
voice_design: false
inline_controls: false
native_multi_speaker: {supported: false, max_speakers: 1, suite_status: unavailable}
asr_features: {diarization: none, timing: none}
families:
citrinet_asr:
suite_tasks: [asr]
summary: "Compact English transcription using a Citrinet CTC model."
chatterbox:
reference_audio: required
suite_tasks: [tts, srt, character_switching, voice_conversion]
summary: "Voice cloning and reference-targeted voice conversion."
confucius4_tts: {reference_audio: required, summary: "Voice cloning from reference audio."}
dramabox: {reference_audio: optional, summary: "TTS and optional voice cloning."}
fish_audio: {reference_audio: optional, summary: "Reference-conditioned TTS."}
fun_asr_nano:
suite_tasks: [asr]
summary: "Multilingual speech recognition for Chinese, English, and Japanese."
glm_tts:
reference_audio: required
reference_transcript: required
summary: "Zero-shot cloning; reference audio and its transcript are required."
higgs_audio_tts:
reference_audio: optional
reference_transcript: optional
inline_controls: true
summary: "Expressive multilingual TTS, cloning, and inline style/sound controls."
higgs_audio_stt:
suite_tasks: [asr]
summary: "English transcription using the Higgs Audio v3 speech model."
hviske_asr:
suite_tasks: [asr]
summary: "Multilingual transcription across 16 languages."
index_tts2: {reference_audio: optional, summary: "TTS with optional voice cloning."}
inflect_v2: {summary: "Reference-free expressive TTS."}
irodori_tts:
reference_audio: optional
voice_design: true
summary: "TTS, optional cloning, and voice design."
kroko_asr:
suite_tasks: [asr]
asr_features: {timing: native_word}
summary: "Small multilingual community ASR model."
miotts: {reference_audio: optional, summary: "Reference-conditioned TTS."}
moss_tts_local: {reference_audio: optional, summary: "TTS with optional voice cloning."}
moss_tts_nano: {reference_audio: optional, summary: "Compact TTS with optional cloning."}
nemotron_asr:
suite_tasks: [asr]
asr_features: {timing: native_word}
summary: "Streaming-capable multilingual NVIDIA Nemotron transcription."
omnivoice:
reference_audio: optional
reference_transcript: optional
voice_design: true
summary: "Reference cloning or instruction-based voice design."
outetts:
reference_audio: optional
reference_transcript: optional
summary: "Multilingual TTS; a transcript improves reference cloning."
parakeet_tdt:
suite_tasks: [asr]
asr_features: {timing: native_word}
summary: "Fast multilingual Parakeet-TDT transcription."
pocket_tts: {reference_audio: optional, summary: "Small CPU-friendly TTS and cloning."}
qwen3_tts:
reference_audio: optional
voice_design: true
summary: "TTS with package-dependent cloning or voice design."
qwen3_asr:
suite_tasks: [asr]
asr_features: {timing: optional_forced_aligner}
summary: "Broad multilingual Qwen3 transcription; word timestamps require an optional forced aligner."
seed_vc:
suite_tasks: [voice_conversion]
reference_audio: required
summary: "Reference-targeted voice conversion through Seed-VC."
supertonic:
built_in_voices: true
summary: "Fast multilingual TTS using built-in voices."
vevo2:
reference_audio: optional
suite_tasks: [tts, srt, character_switching, voice_conversion]
summary: "TTS and reference-targeted voice conversion; S2S/SVC remain advanced upstream routes."
vibevoice:
reference_audio: required_per_speaker
native_multi_speaker: {supported: true, max_speakers: 4, suite_status: partial}
summary: "Long-form dialogue for up to four speakers. Native single-request mode is not wired yet."
vibevoice_asr:
suite_tasks: [asr, diarization]
asr_features: {diarization: native, timing: native_segment}
summary: "Multilingual transcription with native timestamped speaker turns."
vietneu_tts:
voice_design: true
summary: "Vietnamese TTS and voice design."
voxcpm2:
reference_audio: optional
voice_design: true
summary: "TTS with reference conditioning and voice design."
voxtral_realtime:
suite_tasks: [asr]
summary: "Multilingual realtime-oriented Voxtral transcription."
+287
View File
@@ -0,0 +1,287 @@
"""ComfyUI model-management proxy for suite-owned audio.cpp processes."""
from __future__ import annotations
import os
import sys
import threading
import weakref
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional
if TYPE_CHECKING:
from .session import AudioCppSession
def _first(config: Mapping[str, Any], *keys: str, default: Any = None) -> Any:
for key in keys:
if key in config and config[key] is not None:
return config[key]
return default
def _warn(message: str, exc: Optional[BaseException] = None) -> None:
text = f"WARNING: {message}"
if exc is not None:
text += f": {exc}"
encoding = getattr(sys.stderr, "encoding", None) or "ascii"
try:
text = text.encode(encoding, errors="replace").decode(encoding, errors="replace")
except LookupError:
text = text.encode("ascii", errors="replace").decode("ascii")
print(text, file=sys.stderr)
class AudioCppRuntimeProxy:
"""ComfyUI model-like resource representing one owned native server."""
def __init__(self, session: "AudioCppSession") -> None:
self._session_ref = weakref.ref(session)
self.model = self
self.processor = self
self.parent = None
self.currently_used = True
self.model_options: Dict[str, Any] = {}
self.model_keys = set()
self.offload_device = self._torch_device("cpu", 0)
backend = str(session.config.get("backend", "cuda")).lower()
device_index = self._device_index(
session.config.get("device_index", session.config.get("device", 0))
)
self.load_device = self._lifecycle_device(backend, device_index)
self.current_device = self.load_device
self._estimated_memory_size = self._memory_estimate(session.config)
self._loaded_model = None
self._model_management = None
self._registration_lock = threading.RLock()
@staticmethod
def _device_index(value: Any) -> int:
text = str(value or 0).lower()
if ":" in text:
text = text.rsplit(":", 1)[-1]
try:
return max(0, int(text))
except ValueError:
return 0
@staticmethod
def _torch_device(kind: str, index: int):
try:
import torch
return torch.device(kind, index) if kind == "cuda" else torch.device(kind)
except Exception:
return f"{kind}:{index}" if kind == "cuda" else kind
@classmethod
def _lifecycle_device(cls, backend: str, index: int):
if backend == "cpu":
return cls._torch_device("cpu", 0)
try:
import comfy.model_management as model_management
device = model_management.get_torch_device()
if device is not None:
return device
except (ImportError, AttributeError, RuntimeError):
pass
if backend == "metal":
return cls._torch_device("mps", 0)
return cls._torch_device("cuda", index)
@staticmethod
def _memory_estimate(config: Mapping[str, Any]) -> int:
explicit = _first(config, "estimated_memory_bytes", "model_memory_bytes")
if explicit is not None:
return max(1, int(explicit))
gigabytes = _first(config, "estimated_vram_gb", "model_memory_gb")
if gigabytes is not None:
return max(1, int(float(gigabytes) * 1024**3))
raw_path = _first(config, "model_path", "package_path", "gguf_path")
if raw_path:
try:
model_path = Path(
os.path.expandvars(os.path.expanduser(str(raw_path)))
).resolve()
if model_path.is_file():
return max(1, model_path.stat().st_size)
if model_path.is_dir():
total = 0
for candidate in model_path.rglob("*"):
try:
if candidate.is_file():
total += candidate.stat().st_size
except OSError:
continue
return max(1, total)
except OSError:
pass
# A zero-size entry is ignored by parts of ComfyUI's unload ordering.
return 1
def _session(self) -> Optional["AudioCppSession"]:
return self._session_ref()
def register(self) -> bool:
"""Register once with ComfyUI after an owned process becomes live."""
with self._registration_lock:
if self._loaded_model is not None:
current = getattr(self._model_management, "current_loaded_models", None)
if isinstance(current, list) and self._loaded_model in current:
return True
self._loaded_model = None
self._model_management = None
try:
import comfy.model_management as model_management
except ImportError:
return False
try:
loaded_model_type = getattr(model_management, "LoadedModel", None)
current_models = getattr(model_management, "current_loaded_models", None)
if not callable(loaded_model_type) or not isinstance(current_models, list):
return False
loaded_model = loaded_model_type(self)
loaded_model.real_model = weakref.ref(self)
cleanup = getattr(model_management, "cleanup_models", None)
loaded_model.model_finalizer = weakref.finalize(
self, cleanup if callable(cleanup) else lambda: None
)
loaded_model._tts_wrapper_ref = self
current_models.insert(0, loaded_model)
self._loaded_model = loaded_model
self._model_management = model_management
return True
except Exception as exc:
_warn("Failed to register audio.cpp runtime with ComfyUI", exc)
return False
def unregister(self) -> None:
with self._registration_lock:
loaded_model = self._loaded_model
model_management = self._model_management
self._loaded_model = None
self._model_management = None
if loaded_model is None or model_management is None:
return
current_models = getattr(model_management, "current_loaded_models", None)
if not isinstance(current_models, list):
return
try:
for candidate in list(current_models):
if candidate is loaded_model or getattr(candidate, "_tts_wrapper_ref", None) is self:
current_models.remove(candidate)
except Exception as exc:
_warn("Failed to unregister audio.cpp runtime from ComfyUI", exc)
def to(self, device):
self.current_device = device
return self
def eval(self):
return self
def model_size(self) -> int:
return self._estimated_memory_size
def loaded_size(self) -> int:
session = self._session()
return self._estimated_memory_size if session is not None and session.running else 0
def model_memory(self) -> int:
return self.model_size()
def get_ram_usage(self) -> int:
return self._estimated_memory_size
def model_offloaded_memory(self) -> int:
return max(0, self.model_size() - self.loaded_size())
def model_mmap_residency(self, free: bool = False) -> tuple[int, int]:
return 0, self._estimated_memory_size
def pinned_memory_size(self) -> int:
return 0
def lowvram_patch_counter(self) -> int:
return 0
def model_dtype(self):
try:
import torch
return torch.float32
except Exception:
return None
def current_loaded_device(self):
return self.current_device
def model_patches_models(self):
return ()
def model_patches_to(self, target) -> None:
try:
import torch
if isinstance(target, torch.device):
self.current_device = target
except Exception:
pass
def is_dynamic(self) -> bool:
return False
def is_clone(self, other) -> bool:
return other is self
def clone_has_same_weights(self, other) -> bool:
return other is self
def partially_load(self, device, extra_memory, force_patch_weights=False) -> int:
self.current_device = device
return 0
def partially_unload(self, device, memory_to_free) -> int:
# audio.cpp 0.5.1 cannot release part of a session. Claiming memory here
# would make ComfyUI believe VRAM was freed while the server still owns it.
return 0
def partially_unload_ram(self, ram_to_unload) -> int:
return 0
def patch_model(
self,
device_to=None,
lowvram_model_memory=0,
load_weights=True,
force_patch_weights=False,
):
if device_to is not None:
self.current_device = device_to
return self.model
def unpatch_model(self, device_to=None, unpatch_weights=True):
if device_to is not None:
self.current_device = device_to
session = self._session()
if session is not None:
# LoadedModel removes its list entry after this callback returns.
session._stop_owned_runtime(unregister=False)
return self.model
def model_unload(self, memory_to_free=None, unpatch_weights=True) -> bool:
self.unpatch_model(self.offload_device, unpatch_weights=unpatch_weights)
return True
def detach(self, unpatch_weights=True):
return self.unpatch_model(self.offload_device, unpatch_weights=unpatch_weights)
def cleanup(self) -> None:
session = self._session()
if session is not None:
session._stop_owned_runtime(unregister=True)
__all__ = ["AudioCppRuntimeProxy"]
+168
View File
@@ -0,0 +1,168 @@
{
"family": "chatterbox",
"display_name": "Chatterbox",
"description": "Open-source Chatterbox family for expressive TTS and voice conversion, with emotion exaggeration control, fast generation, zero-shot voice cloning, and an integrated multilingual TTS path.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone",
"vc"
],
"modes": [
"offline"
],
"languages": [
"ar",
"da",
"de",
"el",
"en",
"es",
"fi",
"fr",
"hi",
"it",
"ko",
"ms",
"nl",
"no",
"pl",
"pt",
"sv",
"sw",
"tr"
],
"capabilities": {
"clone": [
"speaker_reference"
],
"vc": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "chatterbox_q8_0",
"tags": [
"TTS",
"Clone",
"VC",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "chatterbox_q8_0",
"display_name": "Chatterbox Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Chatterbox-GGUF",
"files": [
"Chatterbox-GGUF/chatterbox-q8_0.gguf"
],
"strip_prefix": "Chatterbox-GGUF"
},
{
"id": "chatterbox_f16",
"display_name": "Chatterbox F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Chatterbox-GGUF",
"files": [
"Chatterbox-GGUF/chatterbox-f16.gguf"
],
"strip_prefix": "Chatterbox-GGUF"
},
{
"id": "chatterbox_safetensors",
"display_name": "Chatterbox Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "chatterbox",
"files": [
"ve.safetensors",
"t3_cfg.safetensors",
"s3gen.safetensors",
"tokenizer.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "ResembleAI/chatterbox"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"english_tokenizer": "model:tokenizer.json",
"multilingual_tokenizer": "model:grapheme_mtl_merged_expanded_v1.json",
"cangjie_mapping": "model:Cangjie5_TC.json",
"builtin_conditionals": "model:conds.pt"
},
"tensors": {
"voice_encoder_weights": {
"source": "weights:",
"prefix": "voice_encoder"
},
"s3gen_weights": {
"source": "weights:",
"prefix": "s3gen"
},
"t3_english_weights": {
"source": "weights:",
"prefix": "t3_english"
},
"t3_multilingual_v2_weights": {
"source": "weights:",
"prefix": "t3_multilingual_v2"
},
"t3_multilingual_v3_weights": {
"source": "weights:",
"prefix": "t3_multilingual_v3"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"english_tokenizer": "model:tokenizer.json",
"multilingual_tokenizer": "model:grapheme_mtl_merged_expanded_v1.json",
"cangjie_mapping": "model:Cangjie5_TC.json",
"builtin_conditionals": "model:conds.pt"
},
"tensors": {
"voice_encoder_weights": "model:ve.safetensors",
"s3gen_weights": "model:s3gen.safetensors",
"t3_english_weights": "model:t3_cfg.safetensors",
"t3_multilingual_v2_weights": "model:t3_mtl23ls_v2.safetensors",
"t3_multilingual_v3_weights": "model:t3_mtl23ls_v3.safetensors"
}
}
]
}
@@ -0,0 +1,84 @@
{
"family": "citrinet_asr",
"display_name": "Citrinet ASR",
"description": "NVIDIA CitriNet-family end-to-end English ASR model using a convolutional CTC architecture optimized for transcribing speech segments to text.",
"category": "asr",
"status": "supported",
"tasks": [
"asr"
],
"modes": [
"offline"
],
"languages": [
"en"
],
"capabilities": {},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "citrinet_asr_q8_0",
"tags": [
"ASR",
"GGUF"
],
"docs": [
"docs/asr.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "citrinet_asr_q8_0",
"display_name": "Citrinet ASR Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Citrinet-ASR-GGUF",
"files": [
"Citrinet-ASR-GGUF/citrinet-asr-q8_0.gguf"
],
"strip_prefix": "Citrinet-ASR-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:citrinet_256_config.json",
"tokenizer": "model:citrinet_256_tokenizer.model"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:citrinet_256_config.json",
"tokenizer": "model:citrinet_256_tokenizer.model"
},
"tensors": {
"weights": "model:citrinet_256.safetensors"
}
}
]
}
@@ -0,0 +1,330 @@
{
"schema_version": 1,
"family": "confucius4_tts",
"display_name": "Confucius4-TTS",
"description": "Confucius4-TTS is a multilingual voice-cloning TTS model packaged for audio.cpp with offline and streaming generation. It uses reference speech, language-aware text normalization, T2S semantic generation, S2A flow matching, style encoding, semantic audio features, and BigVGAN vocoding.",
"category": "tts",
"status": "experimental",
"tasks": [
"clone"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"zh",
"en",
"ja",
"ko",
"de",
"fr",
"es",
"id",
"it",
"th",
"pt",
"ru",
"ms",
"vi"
],
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"capabilities": {
"clone": [
"speaker_reference",
"long_form"
]
},
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "Target synthesis language code used by the text frontend; default zh when no request transcript language or style language is provided.",
"required": false,
"default": "zh"
},
{
"name": "temperature",
"type": "float",
"description": "T2S sampling temperature; must be positive, default 0.8.",
"required": false,
"min": 0.0,
"default": 0.8
},
{
"name": "top_p",
"type": "float",
"description": "T2S nucleus sampling probability; must be in (0, 1], default 0.8.",
"required": false,
"min": 0.0,
"max": 1.0,
"default": 0.8
},
{
"name": "top_k",
"type": "int",
"description": "T2S top-k sampling limit; must be positive, default 30.",
"required": false,
"min": 1,
"default": 30
},
{
"name": "num_beams",
"type": "int",
"description": "T2S beam count; default 3. Set 1 for single-beam sampling.",
"required": false,
"min": 1,
"default": 3
},
{
"name": "repetition_penalty",
"type": "float",
"description": "T2S repetition penalty; must be positive, default 10.0.",
"required": false,
"min": 0.0,
"default": 10.0
},
{
"name": "max_tokens",
"type": "int",
"description": "Maximum T2S semantic sequence length including prompt tokens; default 1520.",
"required": false,
"min": 1,
"default": 1520
},
{
"name": "num_inference_steps",
"type": "int",
"description": "S2A flow-matching step count; default 25.",
"required": false,
"min": 1,
"default": 25
},
{
"name": "guidance_scale",
"type": "float",
"description": "S2A classifier-free guidance scale; default 0.7.",
"required": false,
"min": 0.0,
"default": 0.7
},
{
"name": "text_chunk_size",
"type": "int",
"description": "Maximum text tokens per generated segment; default 80.",
"required": false,
"min": 1,
"default": 80
},
{
"name": "text_chunk_mode",
"type": "enum",
"description": "Framework text chunking mode; default uses the standard word-budget chunker.",
"values": [
"default",
"tag_aware",
"japanese",
"endline"
],
"required": false,
"default": "default"
},
{
"name": "cross_fade_duration_sec",
"type": "float",
"description": "Cross-fade duration between generated segments in seconds; default 0.3.",
"required": false,
"min": 0.0,
"default": 0.3
},
{
"name": "edge_fade_duration_sec",
"type": "float",
"description": "Fade duration applied at segment edges in seconds; default 0.1.",
"required": false,
"min": 0.0,
"default": 0.1
},
{
"name": "edge_pad_duration_sec",
"type": "float",
"description": "Silence padding applied at segment edges in seconds; default 0.1.",
"required": false,
"min": 0.0,
"default": 0.1
},
{
"name": "seed",
"type": "int",
"description": "Seed for T2S sampling and S2A noise initialization; default 1234.",
"required": false,
"min": 0,
"default": 1234
}
],
"session": [
{
"name": "graph_arena_mb",
"type": "int",
"description": "Reusable ggml graph arena size in MiB for Confucius stages; default 512.",
"required": false,
"min": 1,
"default": 512
},
{
"name": "weight_context_mb",
"type": "int",
"description": "Weight loading context size in MiB; default 1024.",
"required": false,
"min": 1,
"default": 1024
},
{
"name": "weight_type",
"type": "enum",
"description": "Matmul weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "conv_weight_type",
"type": "enum",
"description": "Convolution weight storage type; default native.",
"preset": "weight_type_conv",
"required": false,
"default": "native"
},
{
"name": "reference_cache_slots",
"type": "int",
"description": "Prepared reference-audio cache slots; default 1, set 0 to disable caching.",
"required": false,
"min": 0,
"default": 1
},
{
"name": "mem_saver",
"type": "bool",
"description": "Release staged graphs after request phases; default false.",
"required": false,
"default": false
}
],
"load": []
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "confucius4_tts_orig",
"display_name": "Confucius4-TTS Original-Dtype GGUF",
"default": true,
"format": "gguf",
"precision": "orig",
"target_directory": "Confucius4-TTS-GGUF",
"files": [
"Confucius4-TTS-GGUF/confucius4-tts-orig.gguf"
],
"strip_prefix": "Confucius4-TTS-GGUF"
}
],
"dependencies": [],
"ui": {
"recommended_package": "confucius4_tts_orig",
"tags": [
"TTS",
"Clone",
"GGUF",
"Stream"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:inference_config.yaml",
"tokenizer_model": "model:tokenizer.model",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"special_tokens_map": "model:special_tokens_map.json",
"w2v_preprocessor_config": "model:w2v_preprocessor_config.json",
"bigvgan_config": "model:bigvgan_config.json"
},
"tensors": {
"t2s": {
"source": "weights:",
"prefix": "t2s"
},
"s2a": {
"source": "weights:",
"prefix": "s2a"
},
"semantic_encoder": {
"source": "weights:",
"prefix": "semantic_encoder"
},
"semantic_encoder_shaw": {
"source": "weights:",
"prefix": "semantic_encoder_shaw"
},
"semantic_stats": {
"source": "weights:",
"prefix": "semantic_stats"
},
"style_encoder": {
"source": "weights:",
"prefix": "style_encoder"
},
"vocoder": {
"source": "weights:",
"prefix": "vocoder"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:inference_config.yaml",
"tokenizer_model": "model:tokenizer.model",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"special_tokens_map": "model:special_tokens_map.json",
"w2v_preprocessor_config": "model:w2v_preprocessor_config.json",
"bigvgan_config": "model:bigvgan_config.json"
},
"tensors": {
"t2s": "model:t2s.safetensors",
"s2a": "model:s2a.safetensors",
"semantic_encoder": "model:semantic_encoder.safetensors",
"semantic_encoder_shaw": "model:semantic_encoder_shaw.safetensors",
"semantic_stats": "model:semantic_stats.safetensors",
"style_encoder": "model:style_encoder.safetensors",
"vocoder": "model:vocoder.safetensors"
}
}
]
}
+229
View File
@@ -0,0 +1,229 @@
{
"schema_version": 1,
"family": "dramabox",
"display_name": "DramaBox",
"description": "DramaBox is an English expressive TTS and voice-cloning model packaged for audio.cpp as a standalone GGUF bundle. It combines Gemma text conditioning, diffusion sampling, reference-audio conditioning, long-form chunking, and 48 kHz stereo output.",
"category": "tts",
"status": "experimental",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"en"
],
"runtime": {
"tags": [
"gguf"
]
},
"capabilities": {
"tts": [
"speaker_reference",
"style_control",
"long_form"
],
"clone": [
"speaker_reference",
"style_control",
"long_form"
]
},
"options": {
"request": [
{
"name": "target_voice",
"type": "audio_path",
"description": "Reference voice WAV path for voice cloning. If omitted, DramaBox generates from text without reference-audio conditioning.",
"required": false
},
{
"name": "negative_prompt",
"type": "string",
"description": "Negative text conditioning used when guidance_scale enables classifier-free guidance; omitted uses the built-in quality prompt.",
"required": false
},
{
"name": "duration_sec",
"type": "float",
"description": "Explicit target duration in seconds; default 0 uses the prompt-duration estimator.",
"required": false,
"min": 0.0,
"default": 0.0
},
{
"name": "num_inference_steps",
"type": "int",
"description": "Diffusion sampling step count; default comes from config.json, 30 in the current package.",
"required": false,
"min": 1,
"default": 30
},
{
"name": "guidance_scale",
"type": "float",
"description": "Classifier-free guidance scale; default comes from config.json, 2.5 in the current package. Values greater than 1 enable CFG.",
"required": false,
"min": 0.0,
"default": 2.5
},
{
"name": "spatio_temporal_guidance_scale",
"type": "float",
"description": "Spatio-temporal guidance scale; default comes from config.json, 1.5 in the current package. Values greater than 0 enable STG.",
"required": false,
"min": 0.0,
"default": 1.5
},
{
"name": "duration_scale",
"type": "float",
"description": "Multiplier applied to the estimated prompt duration when duration_sec is 0; default comes from config.json, 1.1 in the current package.",
"required": false,
"min": 0.0,
"default": 1.1
},
{
"name": "reference_duration_sec",
"type": "float",
"description": "Reference voice crop/repeat duration in seconds; default comes from config.json, 10.0 in the current package.",
"required": false,
"min": 0.0,
"default": 10.0
},
{
"name": "guidance_rescale",
"type": "string",
"description": "Guidance rescale value. The default auto mode derives a rescale value from guidance_scale; a numeric string requests an explicit value.",
"required": false,
"default": "auto"
},
{
"name": "audio_chunk_threshold_sec",
"type": "float",
"description": "Estimated duration threshold that switches a request to long-form chunking; default 45.0 seconds.",
"required": false,
"min": 0.0,
"default": 45.0
},
{
"name": "audio_chunk_duration_sec",
"type": "float",
"description": "Target estimated duration for each long-form chunk; default 37.0 seconds.",
"required": false,
"min": 0.0,
"default": 37.0
},
{
"name": "cross_fade_duration_sec",
"type": "float",
"description": "Equal-power cross-fade between long-form chunks in seconds; default 0.05.",
"required": false,
"min": 0.0,
"default": 0.05
},
{
"name": "seed",
"type": "int",
"description": "Torch-compatible CUDA noise seed for diffusion sampling; default 42.",
"required": false,
"min": 0,
"default": 42
}
],
"session": [
{
"name": "perf_mode",
"type": "enum",
"description": "Attention implementation mode. Default off keeps the exact reference-query attention path; flash_attention enables the optimized path.",
"values": [
"off",
"flash_attention"
],
"required": false,
"default": "off"
},
{
"name": "mem_saver",
"type": "bool",
"description": "Release staged runtime graphs and weights immediately after each request phase to reduce peak and resident VRAM; default false keeps components cached for later reuse.",
"required": false,
"default": false
}
],
"load": []
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "dramabox_q8_0",
"display_name": "DramaBox Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "DramaBox-GGUF",
"files": [
"DramaBox-GGUF/dramabox-q8_0.gguf"
],
"strip_prefix": "DramaBox-GGUF"
}
],
"dependencies": [],
"ui": {
"recommended_package": "dramabox_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"audio_components_config": "model:audio_components_config.json",
"gemma_config": "model:gemma-3-12b-it-bnb-4bit/config.json",
"gemma_tokenizer_model": "model:gemma-3-12b-it-bnb-4bit/tokenizer.model",
"gemma_tokenizer_json": "model:gemma-3-12b-it-bnb-4bit/tokenizer.json",
"gemma_tokenizer_config": "model:gemma-3-12b-it-bnb-4bit/tokenizer_config.json"
},
"tensors": {
"dit_weights": {
"source": "weights:",
"prefix": "dit"
},
"audio_weights": {
"source": "weights:",
"prefix": "audio"
},
"gemma_weights": {
"source": "weights:",
"prefix": "gemma"
},
"silence_latent": {
"source": "weights:",
"prefix": "silence"
}
}
}
]
}
+111
View File
@@ -0,0 +1,111 @@
{
"family": "fish_audio",
"display_name": "Fish Audio S2 Pro",
"description": "Fish Audio S2 Pro text-to-speech model for expressive speech across 80+ languages, with automatic language handling, inline prosody/emotion controls, and rapid voice cloning from short reference samples.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"80+ languages"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "fish_audio_s2_pro_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "fish_audio_s2_pro_q8_0",
"display_name": "Fish Audio S2 Pro Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Fish-Audio-S2-Pro-GGUF",
"files": [
"Fish-Audio-S2-Pro-GGUF/fish-audio-s2-pro-q8_0.gguf"
],
"strip_prefix": "Fish-Audio-S2-Pro-GGUF"
},
{
"id": "fish_audio_s2_pro_bf16",
"display_name": "Fish Audio S2 Pro BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "Fish-Audio-S2-Pro-GGUF",
"files": [
"Fish-Audio-S2-Pro-GGUF/fish-audio-s2-pro-bf16.gguf"
],
"strip_prefix": "Fish-Audio-S2-Pro-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"codec_weights": {
"source": "weights:",
"prefix": "codec_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"model_weights": "model:model_audio_cpp.safetensors.index.json",
"codec_weights": "model:codec.safetensors"
}
}
]
}
@@ -0,0 +1,201 @@
{
"schema_version": 1,
"family": "fun_asr_nano",
"display_name": "Fun-ASR-Nano",
"description": "Offline multilingual speech recognition with the FunAudioLLM Fun-ASR-Nano-2512 model.",
"category": "asr",
"status": "wip",
"tasks": [
"asr"
],
"modes": [
"offline"
],
"languages": [
"auto",
"zh",
"en",
"ja"
],
"capabilities": {},
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "Recognition language, or auto to let the model infer it.",
"required": false,
"default": "auto"
},
{
"name": "enable_itn",
"type": "bool",
"description": "Enable inverse text normalization in the transcription prompt.",
"required": false,
"default": true
},
{
"name": "max_tokens",
"type": "int",
"description": "Maximum number of generated transcript tokens.",
"required": false,
"min": 1,
"default": 512
},
{
"name": "audio_chunk_mode",
"type": "enum",
"description": "Audio chunking mode: auto, fixed, or none.",
"values": [
"auto",
"fixed",
"none"
],
"required": false,
"default": "auto"
},
{
"name": "audio_chunk_seconds",
"type": "float",
"description": "Fixed chunk duration in seconds.",
"required": false,
"min": 0.001,
"default": 30
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Shared model weight storage type.",
"preset": "weight_type_full",
"required": false,
"default": "native"
}
],
"load": []
},
"runtime": {
"tags": [
"gguf",
"server",
"cuda",
"metal",
"cpu"
]
},
"packages": [
{
"id": "fun_asr_nano_2512_q8_0",
"display_name": "Fun-ASR-Nano-2512 Q8_0 GGUF",
"description": "Standalone audio.cpp GGUF built from the pinned official checkpoint; governed by the FunASR Model Open Source License Agreement v1.1.",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Fun-ASR-Nano-2512-GGUF",
"files": [
"fun-asr-nano-2512-q8_0.gguf"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "FunAudioLLM/Fun-ASR-Nano-2512-GGUF",
"revision": "ce72677f84900f0dc57f498ace253bfb3c9155b6",
"gated": false
}
},
{
"id": "fun_asr_nano_2512_f16",
"display_name": "Fun-ASR-Nano-2512 F16 GGUF",
"description": "Standalone audio.cpp GGUF built from the pinned official checkpoint; governed by the FunASR Model Open Source License Agreement v1.1.",
"format": "gguf",
"precision": "f16",
"target_directory": "Fun-ASR-Nano-2512-GGUF",
"files": [
"fun-asr-nano-2512-f16.gguf"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "FunAudioLLM/Fun-ASR-Nano-2512-GGUF",
"revision": "ce72677f84900f0dc57f498ace253bfb3c9155b6",
"gated": false
}
},
{
"id": "fun_asr_nano_2512_safetensors",
"display_name": "Fun-ASR-Nano-2512 HF Safetensors",
"description": "Official checkpoint governed by the FunASR Model Open Source License Agreement v1.1.",
"format": "safetensors",
"precision": "native",
"target_directory": "Fun-ASR-Nano-2512-hf",
"files": [
"chat_template.jinja",
"config.json",
"generation_config.json",
"model.safetensors",
"processor_config.json",
"tokenizer.json",
"tokenizer_config.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "FunAudioLLM/Fun-ASR-Nano-2512-hf",
"revision": "854d88f94205cd17d2afdb24332130d86fbe654a",
"gated": false
}
}
],
"dependencies": [],
"ui": {
"recommended_package": "fun_asr_nano_2512_q8_0",
"tags": [
"ASR",
"GGUF"
],
"docs": [
"docs/asr.md",
"docs/gguf.md"
],
"summary": "Offline Fun-ASR-Nano transcription from official safetensors or audio.cpp GGUF."
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"optional_files": {
"chat_template_jinja": "model:chat_template.jinja",
"tokenizer_config": "model:tokenizer_config.json"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"optional_files": {
"chat_template_jinja": "model:chat_template.jinja",
"tokenizer_config": "model:tokenizer_config.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
+260
View File
@@ -0,0 +1,260 @@
{
"schema_version": 1,
"family": "glm_tts",
"display_name": "GLM-TTS",
"description": "Community Chinese-English zero-shot speech synthesis and voice cloning with native Llama, Whisper-VQ, Flow/DiT, CAMPPlus, and HiFT execution.",
"category": "tts",
"status": "community",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"zh",
"en"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"options": {
"request": [
{
"name": "reference_text",
"type": "string",
"description": "Transcript matching the reference voice audio; required by GLM-TTS zero-shot synthesis.",
"required": true
},
{
"name": "max_tokens",
"type": "int",
"description": "Maximum generated speech tokens; otherwise the official 2x-to-20x text-token bounds are used.",
"required": false,
"min": 0,
"default": 0
},
{
"name": "temperature",
"type": "float",
"description": "Speech-token temperature; official default 1.0.",
"required": false,
"min": 0.0,
"default": 1.0
},
{
"name": "top_k",
"type": "int",
"description": "Speech-token top-k; official default 25.",
"required": false,
"min": 0,
"default": 25
},
{
"name": "top_p",
"type": "float",
"description": "Speech-token nucleus threshold; official default 0.8.",
"required": false,
"min": 0.0,
"max": 1.0,
"default": 0.8
},
{
"name": "seed",
"type": "int",
"description": "Speech-token, Flow-noise, and HiFT seed.",
"required": false,
"min": 0,
"default": 0
},
{
"name": "num_inference_steps",
"type": "int",
"description": "Flow Euler steps; defaults to model config, usually official default 10.",
"required": false,
"min": 1
},
{
"name": "flow_guidance_scale",
"type": "float",
"description": "Flow classifier-free guidance rate; defaults to model config, usually official default 0.7.",
"required": false,
"min": 0.0
},
{
"name": "flow_noise_path",
"type": "path",
"description": "Optional raw float32 initial Flow noise for parity tests.",
"required": false
},
{
"name": "hift_source_random_path",
"type": "path",
"description": "Optional raw float32 HiFT phase-uniform and Gaussian values for parity tests.",
"required": false
},
{
"name": "hift_prior_noise_count",
"type": "int",
"description": "Torch RNG value offset used before HiFT source generation; default 0.",
"required": false,
"min": 0,
"default": 0
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Requested component weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "mem_saver",
"type": "bool",
"description": "Release reference-only encoders after caching the voice while keeping the generation path warm; default false.",
"required": false,
"default": false
},
{
"name": "aggressive_mem_saver",
"type": "bool",
"description": "Also release Llama, Flow, and HiFT after every stage. Minimizes VRAM but reloads the generation path on every request; default false.",
"required": false,
"default": false
},
{
"name": "reference_cache_slots",
"type": "int",
"description": "Prepared reference-audio cache slots; default 1. Use 0 to disable.",
"required": false,
"min": 0,
"default": 1
},
{
"name": "llama_weight_context_mb",
"type": "int",
"description": "Llama weight metadata context in MiB; default 8192.",
"required": false,
"min": 1,
"default": 8192
},
{
"name": "constant_context_mb",
"type": "int",
"description": "Llama constant tensor context in MiB; default 256.",
"required": false,
"min": 1,
"default": 256
}
],
"load": []
},
"runtime": {
"tags": [
"gguf"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"llama_config": "model:llm/config.json",
"llama_generation_config": "model:llm/generation_config.json",
"speech_tokenizer_config": "model:speech_tokenizer/config.json",
"speech_tokenizer_preprocessor": "model:speech_tokenizer/preprocessor_config.json",
"tokenizer_config": "model:vq32k-phoneme-tokenizer/tokenizer_config.json",
"tokenizer_vocab": "model:vq32k-phoneme-tokenizer/tokenizer_vocab.json",
"tokenizer_merges": "model:vq32k-phoneme-tokenizer/tokenizer_merges.txt",
"flow_config": "model:flow/config.yaml",
"audio_cpp_config": "model:audio_cpp_config.json"
},
"tensors": {
"llama_weights": {
"source": "weights:",
"prefix": "llama_weights"
},
"speech_tokenizer_weights": {
"source": "weights:",
"prefix": "speech_tokenizer_weights"
},
"flow_weights": {
"source": "weights:",
"prefix": "flow_weights"
},
"hift_weights": {
"source": "weights:",
"prefix": "hift_weights"
},
"campplus_weights": {
"source": "weights:",
"prefix": "campplus_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"llama_config": "model:llm/config.json",
"llama_generation_config": "model:llm/generation_config.json",
"speech_tokenizer_config": "model:speech_tokenizer/config.json",
"speech_tokenizer_preprocessor": "model:speech_tokenizer/preprocessor_config.json",
"tokenizer_config": "model:vq32k-phoneme-tokenizer/tokenizer_config.json",
"tokenizer_vocab": "model:vq32k-phoneme-tokenizer/tokenizer_vocab.json",
"tokenizer_merges": "model:vq32k-phoneme-tokenizer/tokenizer_merges.txt",
"flow_config": "model:flow/config.yaml",
"audio_cpp_config": "model:audio_cpp_config.json"
},
"tensors": {
"llama_weights": "model:llm/model.safetensors.index.json",
"speech_tokenizer_weights": "model:speech_tokenizer/model.safetensors",
"flow_weights": "model:flow/model.safetensors",
"hift_weights": "model:hift/model.safetensors",
"campplus_weights": "model:frontend/campplus.safetensors"
}
}
],
"packages": [
{
"id": "glm_tts_q8_0",
"display_name": "GLM-TTS mixed Q8_0/F16 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "GLM-TTS-Q8",
"files": [
"Text to audio (TTS)/GLM-TTS_Q8.gguf"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "mirek190/audio.cpp"
}
}
],
"dependencies": [],
"ui": {
"recommended_package": "glm_tts_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/community_models/glm_tts.md",
"docs/reports/glm_tts_validation.md",
"docs/gguf.md"
]
}
}
@@ -0,0 +1,212 @@
{
"schema_version": 1,
"family": "higgs_audio_stt",
"display_name": "Higgs Audio v3 STT",
"description": "Boson AI English speech-to-text model combining a Whisper Large v3 speech encoder with a Qwen decoder for robust ASR.",
"category": "asr",
"status": "supported",
"tasks": [
"asr"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"en"
],
"capabilities": {},
"dependencies": [],
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "Transcript language code metadata; English is used when omitted.",
"required": false
},
{
"name": "max_tokens",
"type": "int",
"description": "Maximum generated transcript tokens; default 1024.",
"required": false,
"min": 1,
"default": 1024
},
{
"name": "enable_thinking",
"type": "bool",
"description": "Enable the model thinking prompt; default true.",
"required": false,
"default": true
},
{
"name": "audio_chunk_mode",
"type": "enum",
"description": "Audio chunking mode; default auto uses fixed chunks.",
"values": [
"auto",
"fixed",
"none"
],
"required": false,
"default": "auto"
},
{
"name": "audio_chunk_duration_sec",
"type": "float",
"description": "Fixed audio chunk duration in seconds; must be positive when set; default 4.",
"required": false,
"min": 0.0,
"default": 4.0
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Shared text decoder weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "audio_encoder_weight_type",
"type": "enum",
"description": "Audio encoder convolution weight storage type; default native.",
"preset": "weight_type_conv",
"required": false,
"default": "native"
},
{
"name": "text_decoder_weight_type",
"type": "enum",
"description": "Text decoder matmul weight storage type; defaults to weight_type when set, otherwise native.",
"preset": "weight_type_full",
"required": false
},
{
"name": "audio_encoder_graph_arena_mb",
"type": "int",
"description": "Audio encoder graph arena size in MiB; default 512.",
"required": false,
"min": 0,
"default": 512
},
{
"name": "text_decoder_prefill_graph_arena_mb",
"type": "int",
"description": "Text decoder prefill graph arena size in MiB; default 512.",
"required": false,
"min": 0,
"default": 512
},
{
"name": "text_decoder_decode_graph_arena_mb",
"type": "int",
"description": "Text decoder cached-step graph arena size in MiB; default 256.",
"required": false,
"min": 0,
"default": 256
},
{
"name": "text_decoder_weight_context_mb",
"type": "int",
"description": "Text decoder weight context arena size in MiB; default 4096.",
"required": false,
"min": 0,
"default": 4096
}
],
"load": []
},
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"ui": {
"recommended_package": "higgs_audio_stt_q8_0",
"tags": [
"ASR",
"GGUF",
"Stream"
],
"docs": [
"docs/asr.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "higgs_audio_stt_q8_0",
"display_name": "Higgs Audio v3 STT Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Higgs-Audio-v3-STT-GGUF",
"files": [
"Higgs-Audio-v3-STT-GGUF/higgs-audio-v3-stt-q8_0.gguf"
],
"strip_prefix": "Higgs-Audio-v3-STT-GGUF"
},
{
"id": "higgs_audio_stt_f16",
"display_name": "Higgs Audio v3 STT F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Higgs-Audio-v3-STT-GGUF",
"files": [
"Higgs-Audio-v3-STT-GGUF/higgs-audio-v3-stt-f16.gguf"
],
"strip_prefix": "Higgs-Audio-v3-STT-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"preprocessor_config": "model:preprocessor_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": ".",
"whisper": "../whisper-large-v3"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"preprocessor_config": "whisper:preprocessor_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt"
},
"tensors": {
"weights": "model:model.safetensors.index.json"
}
}
]
}
@@ -0,0 +1,105 @@
{
"family": "higgs_audio_tts",
"display_name": "Higgs Audio v3 TTS",
"description": "Boson AI conversational TTS model for expressive speech across 100+ languages, zero-shot voice cloning, and inline control over emotion, style, prosody, pauses, and sound effects.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"100+ languages"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "higgs_audio_tts_4b_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "higgs_audio_tts_4b_q8_0",
"display_name": "Higgs Audio v3 TTS 4B Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Higgs-Audio-v3-TTS-4B-GGUF",
"files": [
"Higgs-Audio-v3-TTS-4B-GGUF/higgs-audio-v3-tts-4b-q8_0.gguf"
],
"strip_prefix": "Higgs-Audio-v3-TTS-4B-GGUF"
},
{
"id": "higgs_audio_tts_4b_bf16",
"display_name": "Higgs Audio v3 TTS 4B BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "Higgs-Audio-v3-TTS-4B-GGUF",
"files": [
"Higgs-Audio-v3-TTS-4B-GGUF/higgs-audio-v3-tts-4b-bf16.gguf"
],
"strip_prefix": "Higgs-Audio-v3-TTS-4B-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"chat_template": "model:chat_template.jinja"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"chat_template": "model:chat_template.jinja"
},
"tensors": {
"weights": "model:model.safetensors.index.json"
}
}
]
}
+265
View File
@@ -0,0 +1,265 @@
{
"family": "hviske_asr",
"schema_version": 1,
"display_name": "Hviske ASR",
"description": "Danish-optimized Conformer encoder-decoder ASR model fine-tuned from the Hviske v5 family, with selectable Cohere ASR language prompts.",
"category": "asr",
"status": "supported",
"tasks": [
"asr"
],
"modes": [
"offline"
],
"languages": [
"ar",
"da",
"de",
"el",
"en",
"es",
"fr",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"vi",
"zh"
],
"capabilities": {},
"dependencies": [],
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "ASR language code; default da.",
"required": false,
"default": "da"
},
{
"name": "punctuation",
"type": "bool",
"description": "Enable or disable punctuation tokens in the decoder prompt; default true.",
"required": false,
"default": true
},
{
"name": "max_tokens",
"type": "int",
"description": "Maximum generated transcript tokens; defaults to the model config.",
"required": false,
"min": 1
},
{
"name": "num_beams",
"type": "int",
"description": "Beam-search beam count; default 1 uses greedy or sampling decode.",
"required": false,
"min": 1,
"default": 1
},
{
"name": "length_penalty",
"type": "float",
"description": "Beam-search length penalty; must be positive when set; default 1.0.",
"required": false,
"min": 0.000001,
"default": 1.0
},
{
"name": "do_sample",
"type": "bool",
"description": "Enable sampling instead of greedy decode when num_beams is 1; default false.",
"required": false,
"default": false
},
{
"name": "temperature",
"type": "float",
"description": "Decoder sampling temperature; must be positive when set; default 1.0.",
"required": false,
"min": 0.000001,
"default": 1.0
},
{
"name": "top_k",
"type": "int",
"description": "Top-k sampling limit; default 50, 0 disables top-k.",
"required": false,
"min": 0,
"default": 50
},
{
"name": "top_p",
"type": "float",
"description": "Nucleus sampling limit in (0, 1]; default 1.0.",
"required": false,
"min": 0.000001,
"max": 1.0,
"default": 1.0
},
{
"name": "seed",
"type": "int",
"description": "Decoder sampling seed; random if omitted.",
"required": false,
"min": 0
},
{
"name": "audio_chunk_mode",
"type": "enum",
"description": "Audio chunking mode; default auto uses quiet-energy splitting only when audio exceeds the model clip window.",
"values": [
"auto",
"fixed",
"quiet_energy",
"none"
],
"required": false,
"default": "auto"
},
{
"name": "audio_chunk_duration_sec",
"type": "float",
"description": "Maximum audio chunk duration in seconds; defaults to the model clip window.",
"required": false,
"min": 0.000001
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Matmul weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "conv_weight_type",
"type": "enum",
"description": "Convolution weight storage type; defaults to weight_type when set, otherwise native.",
"preset": "weight_type_conv",
"required": false
},
{
"name": "weight_context_mb",
"type": "int",
"description": "Weight descriptor context size in MiB; default 32.",
"required": false,
"min": 1,
"default": 32
},
{
"name": "encoder_graph_arena_mb",
"type": "int",
"description": "Encoder graph arena size in MiB; default 512.",
"required": false,
"min": 1,
"default": 512
},
{
"name": "decoder_prefill_graph_arena_mb",
"type": "int",
"description": "Decoder prefill graph arena size in MiB; default 512.",
"required": false,
"min": 1,
"default": 512
},
{
"name": "decoder_decode_graph_arena_mb",
"type": "int",
"description": "Decoder cached-step graph arena size in MiB; default 512.",
"required": false,
"min": 1,
"default": 512
}
],
"load": []
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "hviske_asr_q8_0",
"tags": [
"ASR",
"GGUF"
],
"docs": [
"docs/asr.md",
"docs/gguf.md"
]
},
"packages": [
{
"id": "hviske_asr_q8_0",
"display_name": "Hviske v5.3 Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Hviske-v5.3-GGUF",
"files": [
"Audio to text (ASR)/hviske-v5.3_Q8.gguf"
],
"strip_prefix": "Audio to text (ASR)",
"download": {
"kind": "huggingface_snapshot",
"repo": "mirek190/audio.cpp"
}
},
{
"id": "hviske_asr_safetensors",
"display_name": "Hviske v5.3 Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "hviske-v5.3",
"files": [
"config.json",
"generation_config.json",
"model.safetensors",
"tokenizer.model"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "syvai/hviske-v5.3"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer": "model:tokenizer.model"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer": "model:tokenizer.model"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
+199
View File
@@ -0,0 +1,199 @@
{
"family": "index_tts2",
"display_name": "IndexTTS2",
"description": "Zero-shot TTS system for Chinese and English speech synthesis with voice cloning, emotion-speaker decoupling, text or audio emotion control, and explicit duration control.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"zh",
"en"
],
"capabilities": {
"tts": [
"emotion_control"
],
"clone": [
"speaker_reference",
"emotion_control"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "index_tts2_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "index_tts2_q8_0",
"display_name": "IndexTTS2 Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "IndexTTS2-GGUF",
"files": [
"IndexTTS2-GGUF/index-tts2-q8_0.gguf"
],
"strip_prefix": "IndexTTS2-GGUF"
},
{
"id": "index_tts2_f16",
"display_name": "IndexTTS2 F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "IndexTTS2-GGUF",
"files": [
"IndexTTS2-GGUF/index-tts2-f16.gguf"
],
"strip_prefix": "IndexTTS2-GGUF"
},
{
"id": "index_tts2_orig",
"display_name": "IndexTTS2 Original-Dtype GGUF",
"format": "gguf",
"precision": "orig",
"target_directory": "IndexTTS2-GGUF",
"files": [
"IndexTTS2-GGUF/index-tts2-orig.gguf"
],
"strip_prefix": "IndexTTS2-GGUF"
},
{
"id": "index_tts2_safetensors",
"display_name": "IndexTTS2 Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "IndexTTS-2",
"files": [
"config.yaml",
"bpe.model",
"gpt.safetensors"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "mlx-community/index-tts2-mlx"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.yaml",
"bpe": "model:bpe.model",
"wav2vec2bert_config": "model:w2v-bert-2.0/config.json",
"wav2vec2bert_preprocessor_config": "model:w2v-bert-2.0/preprocessor_config.json",
"bigvgan_config": "model:bigvgan/config.json",
"qwen_emotion_config": "model:qwen0.6bemo4-merge/config.json",
"qwen_emotion_generation_config": "model:qwen0.6bemo4-merge/generation_config.json",
"qwen_emotion_tokenizer": "model:qwen0.6bemo4-merge/tokenizer.json",
"qwen_emotion_tokenizer_config": "model:qwen0.6bemo4-merge/tokenizer_config.json",
"qwen_emotion_vocab": "model:qwen0.6bemo4-merge/vocab.json",
"qwen_emotion_merges": "model:qwen0.6bemo4-merge/merges.txt"
},
"tensors": {
"gpt": {
"source": "weights:",
"prefix": "gpt"
},
"s2mel": {
"source": "weights:",
"prefix": "s2mel"
},
"speaker_matrix": {
"source": "weights:",
"prefix": "speaker_matrix"
},
"emotion_matrix": {
"source": "weights:",
"prefix": "emotion_matrix"
},
"wav2vec2bert_stats": {
"source": "weights:",
"prefix": "wav2vec2bert_stats"
},
"wav2vec2bert": {
"source": "weights:",
"prefix": "wav2vec2bert"
},
"semantic_codec": {
"source": "weights:",
"prefix": "semantic_codec"
},
"campplus": {
"source": "weights:",
"prefix": "campplus"
},
"bigvgan": {
"source": "weights:",
"prefix": "bigvgan"
},
"qwen_emotion": {
"source": "weights:",
"prefix": "qwen_emotion"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.yaml",
"bpe": "model:bpe.model",
"wav2vec2bert_config": "model:w2v-bert-2.0/config.json",
"wav2vec2bert_preprocessor_config": "model:w2v-bert-2.0/preprocessor_config.json",
"bigvgan_config": "model:bigvgan/config.json",
"qwen_emotion_config": "model:qwen0.6bemo4-merge/config.json",
"qwen_emotion_generation_config": "model:qwen0.6bemo4-merge/generation_config.json",
"qwen_emotion_tokenizer": "model:qwen0.6bemo4-merge/tokenizer.json",
"qwen_emotion_tokenizer_config": "model:qwen0.6bemo4-merge/tokenizer_config.json",
"qwen_emotion_vocab": "model:qwen0.6bemo4-merge/vocab.json",
"qwen_emotion_merges": "model:qwen0.6bemo4-merge/merges.txt"
},
"tensors": {
"gpt": "model:gpt.safetensors",
"s2mel": "model:s2mel.safetensors",
"speaker_matrix": "model:feat1.safetensors",
"emotion_matrix": "model:feat2.safetensors",
"wav2vec2bert_stats": "model:wav2vec2bert_stats.safetensors",
"wav2vec2bert": "model:w2v-bert-2.0/model.safetensors",
"semantic_codec": "model:semantic_codec_model.safetensors",
"campplus": "model:campplus.safetensors",
"bigvgan": "model:bigvgan/model.safetensors",
"qwen_emotion": "model:qwen0.6bemo4-merge/model.safetensors"
}
}
]
}
+155
View File
@@ -0,0 +1,155 @@
{
"schema_version": 1,
"family": "inflect_v2",
"display_name": "Inflect Micro v2",
"description": "Compact English VITS text-to-speech models with a native GGML inference path and an external eSpeak-ng phonemizer.",
"category": "tts",
"status": "community",
"tasks": [
"tts"
],
"modes": [
"offline"
],
"languages": [
"en"
],
"runtime": {
"tags": [
"gguf"
]
},
"capabilities": {
"tts": [
"long_form"
]
},
"options": {
"request": [
{
"name": "speaking_rate",
"type": "float",
"description": "Speech speed multiplier mapped directly to Inflect speed; default 1.0.",
"required": false,
"min": 0.5,
"max": 2.0,
"default": 1.0
},
{
"name": "variation",
"type": "float",
"description": "Latent Gaussian variation; default 0.667.",
"required": false,
"min": 0.0,
"max": 1.0,
"default": 0.667
},
{
"name": "seed",
"type": "int",
"description": "Non-negative latent noise seed; default 0.",
"required": false,
"min": 0,
"default": 0
},
{
"name": "text_chunk_mode",
"type": "enum",
"description": "Long-form text chunking mode.",
"values": [
"word_budget"
],
"required": false,
"default": "word_budget"
},
{
"name": "text_chunk_size",
"type": "int",
"description": "Maximum Unicode codepoints per long-form text chunk; default 280.",
"required": false,
"min": 1,
"default": 280
}
],
"session": [
{
"name": "espeak_library_path",
"type": "path",
"description": "Optional explicit path to the eSpeak-ng shared library.",
"required": false
},
{
"name": "espeak_data_path",
"type": "path",
"description": "Optional explicit path to the directory containing espeak-ng-data.",
"required": false
}
],
"load": []
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "inflect_micro_v2_orig",
"display_name": "Inflect Micro v2 Original-Dtype GGUF",
"default": true,
"format": "gguf",
"precision": "orig",
"target_directory": "Inflect-Micro-v2-GGUF",
"files": [
"Inflect-Micro-v2-GGUF/inflect-micro-v2-orig.gguf"
],
"strip_prefix": "Inflect-Micro-v2-GGUF"
}
],
"dependencies": [],
"ui": {
"recommended_package": "inflect_micro_v2_orig",
"tags": [
"TTS",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/community_models/inflect_v2.md",
"docs/gguf.md"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json"
},
"tensors": {
"weights": {
"source": "weights:",
"prefix": "weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
@@ -0,0 +1,406 @@
{
"schema_version": 1,
"family": "irodori_tts",
"display_name": "Irodori-TTS",
"description": "Japanese TTS model based on RF-DiT continuous audio latents, supporting zero-shot voice cloning, automatic duration prediction, multimodal voice design, and emoji-style control.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone",
"design"
],
"modes": [
"offline"
],
"languages": [
"ja"
],
"capabilities": {
"clone": [
"speaker_reference"
],
"design": [
"voice_design"
]
},
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "Text language code; Irodori-TTS supports Japanese only.",
"required": false,
"default": "ja"
},
{
"name": "caption",
"type": "string",
"description": "VoiceDesign caption describing target voice identity, style, or emotion; supported only by caption-conditioned checkpoints.",
"required": false
},
{
"name": "no_ref",
"type": "bool",
"description": "Use no-reference generation; default true unless a speaker reference is provided.",
"required": false,
"default": true
},
{
"name": "num_inference_steps",
"type": "int",
"description": "RF diffusion steps; default 40.",
"required": false,
"min": 1,
"default": 40
},
{
"name": "duration_sec",
"type": "float",
"description": "Explicit output duration in seconds; must be positive when set, otherwise predicted duration is used.",
"required": false,
"min": 0.0
},
{
"name": "duration_scale",
"type": "float",
"description": "Predicted-duration multiplier; must be positive when set; default 1.0.",
"required": false,
"min": 0.0,
"default": 1.0
},
{
"name": "text_chunk_mode",
"type": "enum",
"description": "Text chunking mode; default endline.",
"values": [
"japanese",
"endline"
],
"required": false,
"default": "endline"
},
{
"name": "text_chunk_size",
"type": "int",
"description": "Maximum characters per text chunk; default uses the model text-token window.",
"required": false,
"min": 1
},
{
"name": "min_duration_sec",
"type": "float",
"description": "Minimum generated duration in seconds; must be positive and no greater than max_duration_sec; default 0.5.",
"required": false,
"min": 0.0,
"default": 0.5
},
{
"name": "max_duration_sec",
"type": "float",
"description": "Maximum generated duration in seconds; must be at least min_duration_sec; default 30.",
"required": false,
"min": 0.0,
"default": 30.0
},
{
"name": "text_guidance_scale",
"type": "float",
"description": "Text classifier-free guidance scale; default 3.0.",
"required": false,
"min": 0.0,
"default": 3.0
},
{
"name": "speaker_guidance_scale",
"type": "float",
"description": "Speaker classifier-free guidance scale; default 5.0.",
"required": false,
"min": 0.0,
"default": 5.0
},
{
"name": "caption_guidance_scale",
"type": "float",
"description": "Caption classifier-free guidance scale; default 3.0.",
"required": false,
"min": 0.0,
"default": 3.0
},
{
"name": "guidance_scale",
"type": "float",
"description": "Override all classifier-free guidance scales when set.",
"required": false,
"min": 0.0
},
{
"name": "guidance_mode",
"type": "enum",
"description": "Classifier-free guidance combination mode; default independent.",
"values": [
"independent",
"joint",
"alternating"
],
"required": false,
"default": "independent"
},
{
"name": "guidance_min_t",
"type": "float",
"description": "Minimum diffusion timestep value where guidance is active; default 0.5.",
"required": false,
"min": 0.0,
"default": 0.5
},
{
"name": "guidance_max_t",
"type": "float",
"description": "Maximum diffusion timestep value where guidance is active; default 1.0.",
"required": false,
"min": 0.0,
"default": 1.0
},
{
"name": "seed",
"type": "int",
"description": "Generation seed for reproducible output; omitted uses a random seed.",
"required": false,
"min": 0
},
{
"name": "trim_tail",
"type": "bool",
"description": "Trim trailing silence-like samples; default true.",
"required": false,
"default": true
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Model weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "codec_weight_type",
"type": "enum",
"description": "DACVAE codec weight storage type; default native.",
"preset": "weight_type_codec_q8",
"required": false,
"default": "native"
},
{
"name": "condition_graph_arena_mb",
"type": "int",
"description": "Condition encoder graph arena size in MiB; default 256.",
"required": false,
"min": 0,
"default": 256
},
{
"name": "rf_graph_arena_mb",
"type": "int",
"description": "RF sampler graph arena size in MiB; default 768.",
"required": false,
"min": 0,
"default": 768
},
{
"name": "codec_graph_arena_mb",
"type": "int",
"description": "DACVAE codec graph arena size in MiB; default 512.",
"required": false,
"min": 0,
"default": 512
},
{
"name": "condition_weight_context_mb",
"type": "int",
"description": "Condition encoder weight context size in MiB; default 32.",
"required": false,
"min": 0,
"default": 32
},
{
"name": "rf_weight_context_mb",
"type": "int",
"description": "RF sampler weight context size in MiB; default 32.",
"required": false,
"min": 0,
"default": 32
},
{
"name": "codec_weight_context_mb",
"type": "int",
"description": "DACVAE codec weight context size in MiB; default 32.",
"required": false,
"min": 0,
"default": 32
},
{
"name": "mem_saver",
"type": "bool",
"description": "Release staged runtime graphs after request phases; default true.",
"required": false,
"default": true
},
{
"name": "reference_cache_slots",
"type": "int",
"description": "Prepared reference-speaker cache slots; default 1.",
"required": false,
"min": 0,
"default": 1
}
],
"load": []
},
"dependencies": [],
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "irodori_tts_v4_small_q8_0",
"tags": [
"TTS",
"Clone",
"Design",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "irodori_tts_v4_small_q8_0",
"display_name": "Irodori-TTS v4 Small Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Irodori-TTS-v4-Small-GGUF",
"files": [
"Irodori-TTS-v4-Small-GGUF/irodori-tts-v4-small-q8_0.gguf"
],
"strip_prefix": "Irodori-TTS-v4-Small-GGUF"
},
{
"id": "irodori_tts_v4_small_f16",
"display_name": "Irodori-TTS v4 Small F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Irodori-TTS-v4-Small-GGUF",
"files": [
"Irodori-TTS-v4-Small-GGUF/irodori-tts-v4-small-f16.gguf"
],
"strip_prefix": "Irodori-TTS-v4-Small-GGUF"
},
{
"id": "irodori_tts_600m_v3_voicedesign_q8_0",
"display_name": "Irodori-TTS 600M v3 VoiceDesign Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "Irodori-TTS-600M-v3-VoiceDesign-GGUF",
"files": [
"Irodori-TTS-600M-v3-VoiceDesign-GGUF/irodori-tts-600m-v3-voicedesign-q8_0.gguf"
],
"strip_prefix": "Irodori-TTS-600M-v3-VoiceDesign-GGUF"
},
{
"id": "irodori_tts_600m_v3_voicedesign_f16",
"display_name": "Irodori-TTS 600M v3 VoiceDesign F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Irodori-TTS-600M-v3-VoiceDesign-GGUF",
"files": [
"Irodori-TTS-600M-v3-VoiceDesign-GGUF/irodori-tts-600m-v3-voicedesign-f16.gguf"
],
"strip_prefix": "Irodori-TTS-600M-v3-VoiceDesign-GGUF"
},
{
"id": "irodori_tts_500m_v3_q8_0",
"display_name": "Irodori-TTS 500M v3 Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "Irodori-TTS-500M-v3-GGUF",
"files": [
"Irodori-TTS-500M-v3-GGUF/irodori-tts-500m-v3-q8_0.gguf"
],
"strip_prefix": "Irodori-TTS-500M-v3-GGUF"
},
{
"id": "irodori_tts_500m_v3_f16",
"display_name": "Irodori-TTS 500M v3 F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Irodori-TTS-500M-v3-GGUF",
"files": [
"Irodori-TTS-500M-v3-GGUF/irodori-tts-500m-v3-f16.gguf"
],
"strip_prefix": "Irodori-TTS-500M-v3-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"model_config": "model:model_config.json"
},
"optional_files": {
"tokenizer_json": "model:tokenizer.json",
"tokenizer_v4_json": "model:tokenizer/tokenizer.json",
"pretrained_text_config": "model:text_encoder_config.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"codec_weights": {
"source": "weights:",
"prefix": "codec_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": ".",
"tokenizer": "../llm-jp-3-150m",
"codec": "../Semantic-DACVAE-Japanese-32dim"
},
"files": {
"model_config": "model:model_config.json"
},
"optional_files": {
"tokenizer_json": "tokenizer:tokenizer.json",
"tokenizer_v4_json": "model:tokenizer/tokenizer.json",
"pretrained_text_config": "model:text_encoder_config.json"
},
"tensors": {
"model_weights": "model:model.safetensors",
"codec_weights": "codec:weights.safetensors"
}
}
]
}
+190
View File
@@ -0,0 +1,190 @@
{
"schema_version": 1,
"family": "kroko_asr",
"display_name": "Kroko Community ASR",
"description": "Native Zipformer2 RNN-T transcription for the public free Kroko Community single-language packages, with offline and stateful streaming inference, greedy and modified beam decoding, hotwords, endpoint segments, partial results, and word timestamps.",
"category": "asr",
"status": "community",
"tasks": [
"asr"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"de",
"en",
"es",
"fr",
"it",
"he",
"nl",
"pt",
"sv",
"tr"
],
"capabilities": {
"asr": [
"word_timestamps",
"partial_results",
"segments"
]
},
"options": {
"request": [
{
"name": "language",
"type": "string",
"description": "Language code matching the selected single-language Kroko package; default auto uses the package language.",
"required": false,
"default": "auto"
},
{
"name": "decoding_method",
"type": "enum",
"description": "RNN-T decoding method; greedy search remains the parity-tested default.",
"values": [
"greedy_search",
"modified_beam_search"
],
"required": false,
"default": "greedy_search"
},
{
"name": "num_beams",
"type": "int",
"description": "Maximum active hypotheses retained by modified beam search.",
"required": false,
"min": 1,
"max": 64,
"default": 4
},
{
"name": "blank_penalty",
"type": "float",
"description": "Non-negative score subtracted from the RNN-T blank logit.",
"required": false,
"min": 0.0,
"default": 0.0
},
{
"name": "hotwords",
"type": "string",
"description": "Slash- or newline-separated natural-text phrases; requires modified beam search.",
"required": false
},
{
"name": "hotwords_score",
"type": "float",
"description": "Non-negative per-token context boost for hotword phrases.",
"required": false,
"min": 0.0,
"default": 1.5
},
{
"name": "enable_endpoint",
"type": "bool",
"description": "Enable automatic endpoint speech segments.",
"required": false,
"default": false
},
{
"name": "rule1_min_trailing_silence_sec",
"type": "float",
"description": "Endpoint timeout in seconds even when no speech token was decoded.",
"required": false,
"min": 0.0,
"default": 2.4
},
{
"name": "rule2_min_trailing_silence_sec",
"type": "float",
"description": "Endpoint silence in seconds after a speech token was decoded.",
"required": false,
"min": 0.0,
"default": 1.2
},
{
"name": "rule3_min_utterance_length_sec",
"type": "float",
"description": "Maximum utterance duration in seconds before an endpoint.",
"required": false,
"min": 0.0,
"default": 20.0
}
],
"session": [],
"load": []
},
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokens": "model:tokens.txt"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"tokens": "model:tokens.txt"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
],
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "kroko_asr_community_q8_0",
"display_name": "Kroko Community ASR Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Kroko-ASR-GGUF",
"files": [
"Kroko-ASR-GGUF/kroko-en-community-64-l-q8_0.gguf"
],
"strip_prefix": "Kroko-ASR-GGUF"
}
],
"dependencies": [],
"ui": {
"recommended_package": "kroko_asr_community_q8_0",
"tags": [
"ASR",
"GGUF"
],
"docs": [
"docs/asr.md",
"docs/community_models/kroko_asr.md",
"docs/gguf.md"
]
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"family": "miotts",
"display_name": "MioTTS",
"description": "Lightweight LLM-based English and Japanese TTS family built on MioCodec, supporting low-latency speech generation and zero-shot voice cloning from short reference audio.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"en",
"ja"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "miotts_1_7b_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "miotts_1_7b_q8_0",
"display_name": "MioTTS 1.7B Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "MioTTS-1.7B-GGUF",
"files": [
"MioTTS-1.7B-GGUF/miotts-1.7b-q8_0.gguf"
],
"strip_prefix": "MioTTS-1.7B-GGUF"
},
{
"id": "miotts_1_7b_bf16",
"display_name": "MioTTS 1.7B BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "MioTTS-1.7B-GGUF",
"files": [
"MioTTS-1.7B-GGUF/miotts-1.7b-bf16.gguf"
],
"strip_prefix": "MioTTS-1.7B-GGUF"
},
{
"id": "miotts_1_7b_orig",
"display_name": "MioTTS 1.7B Original-Dtype GGUF",
"format": "gguf",
"precision": "orig",
"target_directory": "MioTTS-1.7B-GGUF",
"files": [
"MioTTS-1.7B-GGUF/miotts-1.7b-orig.gguf"
],
"strip_prefix": "MioTTS-1.7B-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt"
},
"optional_files": {
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt"
},
"optional_files": {
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
@@ -0,0 +1,149 @@
{
"family": "moss_tts_local",
"display_name": "MOSS-TTS-Local",
"description": "Flagship MOSS-TTS model for high-fidelity 31-language and code-switched speech, zero-shot voice cloning, long-form generation, and fine-grained Pinyin, phoneme, and duration control.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"ar",
"cs",
"da",
"de",
"el",
"en",
"es",
"fa",
"fi",
"fr",
"he",
"hi",
"hu",
"it",
"ja",
"ko",
"mk",
"ms",
"nl",
"pl",
"pt",
"ro",
"ru",
"sv",
"sw",
"th",
"tl",
"tr",
"vi",
"yue",
"zh"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "moss_tts_local_v1_5_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/models/moss_tts.md",
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "moss_tts_local_v1_5_q8_0",
"display_name": "MOSS-TTS-Local v1.5 Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "MOSS-TTS-Local-v1.5-GGUF",
"files": [
"MOSS-TTS-Local-v1.5-GGUF/moss-tts-local-v1.5-q8_0.gguf"
],
"strip_prefix": "MOSS-TTS-Local-v1.5-GGUF"
},
{
"id": "moss_tts_local_v1_5_bf16",
"display_name": "MOSS-TTS-Local v1.5 BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "MOSS-TTS-Local-v1.5-GGUF",
"files": [
"MOSS-TTS-Local-v1.5-GGUF/moss-tts-local-v1.5-bf16.gguf"
],
"strip_prefix": "MOSS-TTS-Local-v1.5-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_vocab": "model:vocab.json",
"tokenizer_merges": "model:merges.txt",
"audio_tokenizer_config": "model:audio_tokenizer/config.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"audio_tokenizer_weights": {
"source": "weights:",
"prefix": "audio_tokenizer_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": ".",
"audio_tokenizer": "audio_tokenizer"
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_vocab": "model:vocab.json",
"tokenizer_merges": "model:merges.txt",
"audio_tokenizer_config": "audio_tokenizer:config.json"
},
"tensors": {
"model_weights": "model:model.safetensors",
"audio_tokenizer_weights": "audio_tokenizer:model.safetensors.index.json"
}
}
]
}
@@ -0,0 +1,133 @@
{
"family": "moss_tts_nano",
"display_name": "MOSS-TTS-Nano",
"description": "Compact deployment-first MOSS-TTS model for real-time multilingual speech generation, lightweight integration, and zero-shot voice cloning.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"ar",
"cs",
"da",
"de",
"el",
"en",
"es",
"fa",
"fr",
"hu",
"it",
"ja",
"ko",
"pl",
"pt",
"ru",
"sv",
"tr",
"zh"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "moss_tts_nano_100m_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/models/moss_tts.md",
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "moss_tts_nano_100m_q8_0",
"display_name": "MOSS-TTS-Nano 100M Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "MOSS-TTS-Nano-100M-GGUF",
"files": [
"MOSS-TTS-Nano-100M-GGUF/moss-tts-nano-100m-q8_0.gguf"
],
"strip_prefix": "MOSS-TTS-Nano-100M-GGUF"
},
{
"id": "moss_tts_nano_100m_bf16",
"display_name": "MOSS-TTS-Nano 100M BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "MOSS-TTS-Nano-100M-GGUF",
"files": [
"MOSS-TTS-Nano-100M-GGUF/moss-tts-nano-100m-bf16.gguf"
],
"strip_prefix": "MOSS-TTS-Nano-100M-GGUF"
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_model": "model:tokenizer.model",
"audio_tokenizer_config": "model:audio_tokenizer/config.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"audio_tokenizer_weights": {
"source": "weights:",
"prefix": "audio_tokenizer_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": ".",
"audio_tokenizer": "audio_tokenizer"
},
"files": {
"config": "model:config.json",
"tokenizer_config": "model:tokenizer_config.json",
"tokenizer_model": "model:tokenizer.model",
"audio_tokenizer_config": "audio_tokenizer:config.json"
},
"tensors": {
"model_weights": "model:model.safetensors",
"audio_tokenizer_weights": "audio_tokenizer:model.safetensors.index.json"
}
}
]
}
@@ -0,0 +1,156 @@
{
"family": "nemotron_asr",
"display_name": "Nemotron 3.5 ASR",
"description": "NVIDIA 600M streaming ASR model for low-latency and batch transcription across 40 language-locales, with native punctuation, capitalization, automatic language detection, and configurable chunk sizes.",
"category": "asr",
"status": "supported",
"tasks": [
"asr"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"ar-AR",
"bg-BG",
"cs-CZ",
"da-DK",
"de-DE",
"el-GR",
"en-GB",
"en-US",
"es-ES",
"es-US",
"et-EE",
"fi-FI",
"fr-CA",
"fr-FR",
"he-IL",
"hi-IN",
"hr-HR",
"hu-HU",
"it-IT",
"ja-JP",
"ko-KR",
"lt-LT",
"lv-LV",
"mt-MT",
"nb-NO",
"nl-NL",
"nn-NO",
"pl-PL",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"sk-SK",
"sl-SI",
"sv-SE",
"th-TH",
"tr-TR",
"uk-UA",
"vi-VN",
"zh-CN"
],
"capabilities": {},
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"ui": {
"recommended_package": "nemotron_asr_q8_0",
"tags": [
"ASR",
"GGUF",
"Stream"
],
"docs": [
"docs/asr.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "nemotron_asr_q8_0",
"display_name": "Nemotron 3.5 ASR Streaming 0.6B Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Nemotron-3.5-ASR-Streaming-0.6B-GGUF",
"files": [
"Nemotron-3.5-ASR-Streaming-0.6B-GGUF/nemotron-3.5-asr-streaming-0.6b-q8_0.gguf"
],
"strip_prefix": "Nemotron-3.5-ASR-Streaming-0.6B-GGUF"
},
{
"id": "nemotron_asr_f16",
"display_name": "Nemotron 3.5 ASR Streaming 0.6B F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Nemotron-3.5-ASR-Streaming-0.6B-GGUF",
"files": [
"Nemotron-3.5-ASR-Streaming-0.6B-GGUF/nemotron-3.5-asr-streaming-0.6b-f16.gguf"
],
"strip_prefix": "Nemotron-3.5-ASR-Streaming-0.6B-GGUF"
},
{
"id": "nemotron_asr_safetensors",
"display_name": "Nemotron 3.5 ASR Streaming 0.6B Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "nemotron-3.5-asr-streaming-0.6b",
"files": [
"config.json",
"model.safetensors",
"processor_config.json",
"tokenizer.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "nvidia/nemotron-3.5-asr-streaming-0.6b"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
+157
View File
@@ -0,0 +1,157 @@
{
"family": "omnivoice",
"display_name": "OmniVoice",
"description": "Massively multilingual zero-shot TTS model from k2-fsa for 600+ languages, supporting short-reference voice cloning, attribute-based voice design, pronunciation controls, and nonverbal tags.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone",
"design"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"600+ languages"
],
"capabilities": {
"clone": [
"speaker_reference"
],
"design": [
"voice_design"
]
},
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"ui": {
"recommended_package": "omnivoice_q8_0",
"tags": [
"TTS",
"Clone",
"Design",
"GGUF",
"Stream"
],
"docs": [
"docs/models/omnivoice.md",
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "omnivoice_q8_0",
"display_name": "OmniVoice Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "OmniVoice-GGUF",
"files": [
"OmniVoice-GGUF/omnivoice-q8_0.gguf"
],
"strip_prefix": "OmniVoice-GGUF"
},
{
"id": "omnivoice_bf16",
"display_name": "OmniVoice BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "OmniVoice-GGUF",
"files": [
"OmniVoice-GGUF/omnivoice-bf16.gguf"
],
"strip_prefix": "OmniVoice-GGUF"
},
{
"id": "omnivoice_f16",
"display_name": "OmniVoice F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "OmniVoice-GGUF",
"files": [
"OmniVoice-GGUF/omnivoice-f16.gguf"
],
"strip_prefix": "OmniVoice-GGUF"
},
{
"id": "omnivoice_safetensors",
"display_name": "OmniVoice Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "OmniVoice",
"files": [
"config.json",
"model.safetensors",
"tokenizer.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "k2-fsa/OmniVoice"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"audio_tokenizer_config": "model:audio_tokenizer/config.json",
"audio_tokenizer_preprocessor": "model:audio_tokenizer/preprocessor_config.json"
},
"optional_files": {
"chat_template": "model:chat_template.jinja"
},
"tensors": {
"weights": {
"source": "weights:",
"prefix": "weights"
},
"audio_tokenizer_weights": {
"source": "weights:",
"prefix": "audio_tokenizer_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"tokenizer_json": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"audio_tokenizer_config": "model:audio_tokenizer/config.json",
"audio_tokenizer_preprocessor": "model:audio_tokenizer/preprocessor_config.json"
},
"optional_files": {
"chat_template": "model:chat_template.jinja"
},
"tensors": {
"weights": "model:model.safetensors",
"audio_tokenizer_weights": "model:audio_tokenizer/model.safetensors"
}
}
]
}
+300
View File
@@ -0,0 +1,300 @@
{
"schema_version": 1,
"family": "outetts",
"display_name": "Llama-OuteTTS 1.0",
"description": "Llama-based open-weight TTS model for 23-language speech synthesis with one-shot voice cloning from short reference audio and automatic word-alignment support.",
"category": "tts",
"status": "community",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"ar",
"be",
"bn",
"de",
"en",
"es",
"fa",
"fr",
"hu",
"it",
"ja",
"ka",
"ko",
"lt",
"lv",
"nl",
"pl",
"pt",
"ru",
"sw",
"ta",
"uk",
"zh"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"options": {
"request": [
{
"name": "max_tokens",
"type": "int",
"description": "Maximum generated audio tokens per chunk. When omitted, OuteTTS estimates a safe value from each chunk.",
"required": false,
"min": 1
},
{
"name": "temperature",
"type": "float",
"description": "Sampling temperature; default 0.4 for cloning, otherwise model config default.",
"required": false,
"min": 0.0
},
{
"name": "top_k",
"type": "int",
"description": "Top-k sampling; default 40 for cloning, otherwise model config default.",
"required": false,
"min": 0
},
{
"name": "top_p",
"type": "float",
"description": "Nucleus sampling in (0, 1]; default 0.9 for cloning, otherwise model config default.",
"required": false,
"min": 0.0,
"max": 1.0
},
{
"name": "min_p",
"type": "float",
"description": "Minimum probability relative to the best token; default 0.05 for cloning, otherwise model config default.",
"required": false,
"min": 0.0,
"max": 1.0
},
{
"name": "repetition_penalty",
"type": "float",
"description": "Positive windowed repetition penalty; default 1.1.",
"required": false,
"min": 0.0,
"default": 1.1
},
{
"name": "repetition_window",
"type": "int",
"description": "Recent-token penalty window; default 64.",
"required": false,
"min": 0,
"default": 64
},
{
"name": "seed",
"type": "int",
"description": "Sampling seed; cloning defaults to 4099 for native weights and 42 for quantized weights.",
"required": false,
"min": 0
},
{
"name": "reference_text",
"type": "string",
"description": "Transcript matching the reference voice audio for voice cloning.",
"required": false
},
{
"name": "reference_language",
"type": "string",
"description": "Language code used to align the reference transcript; default en.",
"required": false,
"default": "en"
},
{
"name": "text_chunk_size",
"type": "int",
"description": "Maximum UTF-8 codepoints per long-form text chunk; default 256. Chunks are split further when required by max_tokens or context budget.",
"required": false,
"min": 1,
"default": 256
},
{
"name": "text_chunk_mode",
"type": "enum",
"description": "Framework long-form text chunking mode; default word_budget.",
"preset": "text_chunk_mode_full",
"required": false,
"default": "word_budget"
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Language-model weight storage type. Quantized CUDA voice cloning is expanded to F32 in memory for generation correctness.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "llama_weight_context_mb",
"type": "int",
"description": "Language-model weight context size in MiB; default 4096.",
"required": false,
"min": 1,
"default": 4096
},
{
"name": "constant_context_mb",
"type": "int",
"description": "Language-model constant tensor context size in MiB; default 256.",
"required": false,
"min": 1,
"default": 256
},
{
"name": "dac_weight_context_mb",
"type": "int",
"description": "DAC decoder weight context size in MiB; default 1024.",
"required": false,
"min": 1,
"default": 1024
},
{
"name": "dac_graph_arena_mb",
"type": "int",
"description": "DAC decoder graph arena size in MiB; default 1536.",
"required": false,
"min": 1,
"default": 1536
},
{
"name": "aligner_path",
"type": "path",
"description": "Optional Qwen3 Forced Aligner override. Cloning automatically uses the aligner embedded in a standalone OuteTTS GGUF when present.",
"required": false
},
{
"name": "reference_cache_slots",
"type": "int",
"description": "Prepared reference-profile cache slots; default 1, set 0 to disable.",
"required": false,
"min": 0,
"default": 1
},
{
"name": "mem_saver",
"type": "bool",
"description": "Release cached-step and aligner runtime state after use; default false.",
"required": false,
"default": false
}
],
"load": []
},
"runtime": {
"tags": [
"gguf"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"special_tokens_map": "model:special_tokens_map.json",
"dac_config": "model:dac/config.json"
},
"optional_files": {
"aligner_config": "model:aligner/config.json",
"aligner_generation_config": "model:aligner/generation_config.json",
"aligner_tokenizer_config": "model:aligner/tokenizer_config.json",
"aligner_preprocessor_config": "model:aligner/preprocessor_config.json",
"aligner_processor_config": "model:aligner/processor_config.json",
"aligner_chat_template": "model:aligner/chat_template.json",
"aligner_chat_template_jinja": "model:aligner/chat_template.jinja",
"aligner_vocab": "model:aligner/vocab.json",
"aligner_merges": "model:aligner/merges.txt",
"aligner_tokenizer_json": "model:aligner/tokenizer.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"dac_weights": {
"source": "weights:",
"prefix": "dac_weights"
},
"aligner_weights": {
"source": "weights:",
"prefix": "aligner_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": ".",
"dac": "../DAC.speech.v1.0"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer": "model:tokenizer.json",
"tokenizer_config": "model:tokenizer_config.json",
"special_tokens_map": "model:special_tokens_map.json",
"dac_config": "dac:config.json"
},
"tensors": {
"model_weights": "model:model.safetensors",
"dac_weights": "dac:model.safetensors"
}
}
],
"packages": [
{
"id": "outetts_1_0_1b_q8_0",
"display_name": "Llama-OuteTTS 1.0 1B Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Llama-OuteTTS-1.0-1B_Q8",
"files": [
"Text to audio (TTS)/Llama-OuteTTS-1.0-1B_Q8.gguf"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "mirek190/audio.cpp"
}
}
],
"dependencies": [],
"ui": {
"recommended_package": "outetts_1_0_1b_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/community_models/outetts.md",
"docs/reports/outetts_validation.md",
"docs/gguf.md"
]
}
}
@@ -0,0 +1,260 @@
{
"schema_version": 1,
"family": "parakeet_tdt",
"display_name": "Parakeet-TDT 0.6B v3",
"description": "NVIDIA Parakeet-TDT 0.6B v3 FastConformer-TDT ASR covering 25 European languages with automatic language detection. Supports the upstream Transformers-compatible safetensors package and standalone audio.cpp GGUF, with offline full-context, bounded-window long-form, and buffered streaming; the checkpoint uses unlimited bidirectional attention and is not a native cache-aware streaming model.",
"category": "asr",
"status": "community",
"tasks": [
"asr"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"bg",
"cs",
"da",
"de",
"el",
"en",
"es",
"et",
"fi",
"fr",
"hr",
"hu",
"it",
"lt",
"lv",
"mt",
"nl",
"pl",
"pt",
"ro",
"ru",
"sk",
"sl",
"sv",
"uk"
],
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"capabilities": {
"asr": [
"word_timestamps",
"partial_results"
]
},
"options": {
"request": [
{
"name": "max_tokens",
"type": "int",
"description": "Maximum TDT generated tokens; 0 or omitted uses the model-derived limit.",
"required": false,
"min": 0,
"default": 0
},
{
"name": "keep_language_tags",
"type": "bool",
"description": "Keep language tag tokens in decoded text; default false.",
"required": false,
"default": false
}
],
"session": [
{
"name": "weight_type",
"type": "enum",
"description": "Shared matmul weight storage type; default native.",
"preset": "weight_type_full",
"required": false,
"default": "native"
},
{
"name": "matmul_weight_type",
"type": "enum",
"description": "Encoder and decoder matmul weight storage type; defaults to weight_type, which defaults to native. Q8_0 measured 1.79x faster on the tested CPU and changed roughly 8 percent of transcripts without moving aggregate word error rate.",
"preset": "weight_type_full",
"required": false
},
{
"name": "conv_weight_type",
"type": "enum",
"description": "Convolution weight storage type; default native.",
"preset": "weight_type_conv",
"required": false,
"default": "native"
},
{
"name": "perf_mode",
"type": "enum",
"description": "Encoder attention implementation. Default off uses the validated relative-attention path; flash_attention enables the fused implementation, which was numerically validated but slower on the tested hardware.",
"preset": "perf_mode_flash_attention",
"required": false,
"default": "off"
},
{
"name": "weight_context_mb",
"type": "int",
"description": "Weight context arena size in MiB; default 3072.",
"required": false,
"min": 1,
"default": 3072
},
{
"name": "encoder_graph_arena_mb",
"type": "int",
"description": "Encoder graph arena size in MiB; default 1024.",
"required": false,
"min": 1,
"default": 1024
},
{
"name": "decoder_graph_arena_mb",
"type": "int",
"description": "Decoder graph arena size in MiB; default 256.",
"required": false,
"min": 1,
"default": 256
},
{
"name": "audio_chunk_duration_sec",
"type": "float",
"description": "Center-region duration for buffered streaming in seconds; default 2. Fixed context windows are re-encoded rather than cache-aware.",
"required": false,
"min": 0.001,
"default": 2.0
},
{
"name": "left_context_sec",
"type": "float",
"description": "Past context included when re-encoding each buffered-streaming window in seconds; default 10.",
"required": false,
"min": 0.0,
"default": 10.0
},
{
"name": "right_context_sec",
"type": "float",
"description": "Future lookahead included when re-encoding each buffered-streaming window in seconds; default 2 and adds equivalent partial-result latency.",
"required": false,
"min": 0.0,
"default": 2.0
},
{
"name": "streaming_attention_mode",
"type": "enum",
"description": "Attention policy inside each buffered window. full_context preserves bidirectional attention over the bounded window.",
"values": [
"full_context"
],
"required": false,
"default": "full_context"
},
{
"name": "offline_mode",
"type": "enum",
"description": "Offline encoder scheduling. full_context encodes the whole utterance, long_form uses bounded overlapping windows, and auto selects long_form beyond audio_chunk_threshold_sec.",
"values": [
"full_context",
"long_form",
"auto"
],
"required": false,
"default": "full_context"
},
{
"name": "audio_chunk_threshold_sec",
"type": "float",
"description": "Duration threshold used by offline_mode=auto before switching to bounded-window long-form execution; default 30 seconds.",
"required": false,
"min": 0.001,
"default": 30.0
}
],
"load": []
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "parakeet_tdt_q8_0",
"display_name": "Parakeet-TDT 0.6B v3 Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Parakeet-TDT-0.6B-v3-GGUF",
"files": [
"Parakeet-TDT-0.6B-v3-GGUF/parakeet-tdt-0.6b-v3-q8_0.gguf"
],
"strip_prefix": "Parakeet-TDT-0.6B-v3-GGUF"
},
{
"id": "parakeet_tdt_f16",
"display_name": "Parakeet-TDT 0.6B v3 F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Parakeet-TDT-0.6B-v3-GGUF",
"files": [
"Parakeet-TDT-0.6B-v3-GGUF/parakeet-tdt-0.6b-v3-f16.gguf"
],
"strip_prefix": "Parakeet-TDT-0.6B-v3-GGUF"
}
],
"dependencies": [],
"ui": {
"recommended_package": "parakeet_tdt_q8_0",
"tags": [
"ASR",
"GGUF"
],
"docs": [
"docs/community_models/parakeet_tdt.md"
]
},
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"processor_config": "model:processor_config.json",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
+238
View File
@@ -0,0 +1,238 @@
{
"family": "pocket_tts",
"display_name": "PocketTTS",
"description": "Kyutai 100M-parameter CPU-friendly TTS package set for real-time local synthesis and small-footprint voice cloning in English, German, Italian, Portuguese, and Spanish.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone"
],
"modes": [
"offline"
],
"languages": [
"en",
"de",
"it",
"pt",
"es"
],
"capabilities": {
"clone": [
"speaker_reference"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "pocket_tts_english_q8_0",
"tags": [
"TTS",
"Clone",
"GGUF"
],
"docs": [
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "pocket_tts_english_q8_0",
"display_name": "PocketTTS English Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "PocketTTS-GGUF/english",
"files": [
"PocketTTS-GGUF/english/pocket-tts-english-q8_0.gguf"
],
"strip_prefix": "PocketTTS-GGUF/english"
},
{
"id": "pocket_tts_english_bf16",
"display_name": "PocketTTS English BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "PocketTTS-GGUF/english",
"files": [
"PocketTTS-GGUF/english/pocket-tts-english-bf16.gguf"
],
"strip_prefix": "PocketTTS-GGUF/english"
},
{
"id": "pocket_tts_german_q8_0",
"display_name": "PocketTTS German Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "PocketTTS-GGUF/german",
"files": [
"PocketTTS-GGUF/german/pocket-tts-german-q8_0.gguf"
],
"strip_prefix": "PocketTTS-GGUF/german"
},
{
"id": "pocket_tts_german_bf16",
"display_name": "PocketTTS German BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "PocketTTS-GGUF/german",
"files": [
"PocketTTS-GGUF/german/pocket-tts-german-bf16.gguf"
],
"strip_prefix": "PocketTTS-GGUF/german"
},
{
"id": "pocket_tts_italian_q8_0",
"display_name": "PocketTTS Italian Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "PocketTTS-GGUF/italian",
"files": [
"PocketTTS-GGUF/italian/pocket-tts-italian-q8_0.gguf"
],
"strip_prefix": "PocketTTS-GGUF/italian"
},
{
"id": "pocket_tts_italian_bf16",
"display_name": "PocketTTS Italian BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "PocketTTS-GGUF/italian",
"files": [
"PocketTTS-GGUF/italian/pocket-tts-italian-bf16.gguf"
],
"strip_prefix": "PocketTTS-GGUF/italian"
},
{
"id": "pocket_tts_portuguese_q8_0",
"display_name": "PocketTTS Portuguese Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "PocketTTS-GGUF/portuguese",
"files": [
"PocketTTS-GGUF/portuguese/pocket-tts-portuguese-q8_0.gguf"
],
"strip_prefix": "PocketTTS-GGUF/portuguese"
},
{
"id": "pocket_tts_portuguese_bf16",
"display_name": "PocketTTS Portuguese BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "PocketTTS-GGUF/portuguese",
"files": [
"PocketTTS-GGUF/portuguese/pocket-tts-portuguese-bf16.gguf"
],
"strip_prefix": "PocketTTS-GGUF/portuguese"
},
{
"id": "pocket_tts_spanish_q8_0",
"display_name": "PocketTTS Spanish Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "PocketTTS-GGUF/spanish",
"files": [
"PocketTTS-GGUF/spanish/pocket-tts-spanish-q8_0.gguf"
],
"strip_prefix": "PocketTTS-GGUF/spanish"
},
{
"id": "pocket_tts_spanish_bf16",
"display_name": "PocketTTS Spanish BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "PocketTTS-GGUF/spanish",
"files": [
"PocketTTS-GGUF/spanish/pocket-tts-spanish-bf16.gguf"
],
"strip_prefix": "PocketTTS-GGUF/spanish"
},
{
"id": "pocket_tts_english_safetensors",
"display_name": "PocketTTS English Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "pocket-tts",
"files": [
"languages/english/embeddings/alba.safetensors",
"languages/english/embeddings/anna.safetensors",
"languages/english/embeddings/azelma.safetensors",
"languages/english/embeddings/bill_boerst.safetensors",
"languages/english/embeddings/caro_davy.safetensors",
"languages/english/embeddings/charles.safetensors",
"languages/english/embeddings/cosette.safetensors",
"languages/english/embeddings/eponine.safetensors",
"languages/english/embeddings/estelle.safetensors",
"languages/english/embeddings/eve.safetensors",
"languages/english/embeddings/fantine.safetensors",
"languages/english/embeddings/george.safetensors",
"languages/english/embeddings/giovanni.safetensors",
"languages/english/embeddings/jane.safetensors",
"languages/english/embeddings/javert.safetensors",
"languages/english/embeddings/jean.safetensors",
"languages/english/embeddings/juergen.safetensors",
"languages/english/embeddings/lola.safetensors",
"languages/english/embeddings/marius.safetensors",
"languages/english/embeddings/mary.safetensors",
"languages/english/embeddings/michael.safetensors",
"languages/english/embeddings/paul.safetensors",
"languages/english/embeddings/peter_yearsley.safetensors",
"languages/english/embeddings/rafael.safetensors",
"languages/english/embeddings/stuart_bell.safetensors",
"languages/english/embeddings/vera.safetensors",
"languages/english/model.safetensors",
"languages/english/tokenizer.model"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "kyutai/pocket-tts"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"tokenizer": "model:tokenizer.model"
},
"optional_files": {
"config": "model:config.yaml"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"language": "languages/english"
},
"files": {
"tokenizer": "language:tokenizer.model"
},
"optional_files": {
"config": "language:config.yaml"
},
"tensors": {
"weights": "language:model.safetensors"
}
}
]
}
+214
View File
@@ -0,0 +1,214 @@
{
"family": "qwen3_asr",
"display_name": "Qwen3-ASR",
"description": "Qwen ASR model family for language identification and speech recognition across 30 languages, 22 Chinese dialects, and multiple English accents, with robustness for noisy, long-form, and singing audio.",
"category": "asr",
"status": "supported",
"tasks": [
"asr"
],
"modes": [
"offline",
"streaming"
],
"languages": [
"zh",
"en",
"yue",
"ar",
"de",
"fr",
"es",
"pt",
"id",
"it",
"ko",
"ru",
"th",
"vi",
"ja",
"tr",
"hi",
"ms",
"nl",
"sv",
"da",
"fi",
"pl",
"cs",
"fil",
"fa",
"el",
"hu",
"mk",
"ro",
"zh dialects"
],
"capabilities": {
"asr": [
"word_timestamps",
"vad_chunking",
"partial_results"
]
},
"runtime": {
"tags": [
"gguf",
"stream"
]
},
"ui": {
"recommended_package": "qwen3_asr_1_7b_q8_0",
"tags": [
"ASR",
"GGUF",
"Stream"
],
"docs": [
"docs/models/qwen3.md",
"docs/asr.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "qwen3_asr_1_7b_q8_0",
"display_name": "Qwen3-ASR 1.7B Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Qwen3-ASR-1.7B-GGUF",
"files": [
"Qwen3-ASR-1.7B-GGUF/qwen3-asr-1.7b-q8_0.gguf"
],
"strip_prefix": "Qwen3-ASR-1.7B-GGUF"
},
{
"id": "qwen3_asr_1_7b_f16",
"display_name": "Qwen3-ASR 1.7B F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Qwen3-ASR-1.7B-GGUF",
"files": [
"Qwen3-ASR-1.7B-GGUF/qwen3-asr-1.7b-f16.gguf"
],
"strip_prefix": "Qwen3-ASR-1.7B-GGUF"
},
{
"id": "qwen3_asr_0_6b_q8_0",
"display_name": "Qwen3-ASR 0.6B Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "Qwen3-ASR-0.6B-GGUF",
"files": [
"Qwen3-ASR-0.6B-GGUF/qwen3-asr-0.6b-q8_0.gguf"
],
"strip_prefix": "Qwen3-ASR-0.6B-GGUF"
},
{
"id": "qwen3_asr_0_6b_f16",
"display_name": "Qwen3-ASR 0.6B F16 GGUF",
"format": "gguf",
"precision": "f16",
"target_directory": "Qwen3-ASR-0.6B-GGUF",
"files": [
"Qwen3-ASR-0.6B-GGUF/qwen3-asr-0.6b-f16.gguf"
],
"strip_prefix": "Qwen3-ASR-0.6B-GGUF"
},
{
"id": "qwen3_asr_1_7b_safetensors",
"display_name": "Qwen3-ASR 1.7B HF Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "Qwen3-ASR-1.7B-hf",
"files": [
"config.json",
"generation_config.json",
"model.safetensors",
"processor_config.json",
"tokenizer_config.json",
"tokenizer.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "Qwen/Qwen3-ASR-1.7B-hf"
}
},
{
"id": "qwen3_asr_0_6b_safetensors",
"display_name": "Qwen3-ASR 0.6B Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "Qwen3-ASR-0.6B",
"files": [
"config.json",
"generation_config.json",
"model.safetensors",
"preprocessor_config.json",
"tokenizer_config.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "Qwen/Qwen3-ASR-0.6B"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json"
},
"optional_files": {
"preprocessor_config": "model:preprocessor_config.json",
"processor_config": "model:processor_config.json",
"chat_template": "model:chat_template.json",
"chat_template_jinja": "model:chat_template.jinja",
"vocab": "model:vocab.json",
"merges": "model:merges.txt",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "weights:"
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json"
},
"optional_files": {
"preprocessor_config": "model:preprocessor_config.json",
"processor_config": "model:processor_config.json",
"chat_template": "model:chat_template.json",
"chat_template_jinja": "model:chat_template.jinja",
"vocab": "model:vocab.json",
"merges": "model:merges.txt",
"tokenizer_json": "model:tokenizer.json"
},
"tensors": {
"weights": "model:model.safetensors"
}
}
]
}
+225
View File
@@ -0,0 +1,225 @@
{
"family": "qwen3_tts",
"display_name": "Qwen3-TTS",
"description": "Qwen TTS family for controllable 10-language speech synthesis, including 3-second voice cloning, CustomVoice instruction control over preset timbres, and VoiceDesign from natural-language descriptions.",
"category": "tts",
"status": "supported",
"tasks": [
"tts",
"clone",
"design"
],
"modes": [
"offline"
],
"languages": [
"zh",
"en",
"ja",
"ko",
"de",
"fr",
"ru",
"pt",
"es",
"it"
],
"capabilities": {
"clone": [
"speaker_reference"
],
"design": [
"voice_design"
]
},
"runtime": {
"tags": [
"gguf"
]
},
"ui": {
"recommended_package": "qwen3_tts_1_7b_base_q8_0",
"tags": [
"TTS",
"Clone",
"Design",
"GGUF"
],
"docs": [
"docs/models/qwen3.md",
"docs/tts.md",
"docs/gguf.md"
]
},
"package_defaults": {
"download": {
"kind": "huggingface_snapshot",
"repo": "audio-cpp/audio.cpp-gguf",
"revision": "main",
"gated": false
}
},
"packages": [
{
"id": "qwen3_tts_1_7b_base_q8_0",
"display_name": "Qwen3 TTS 12Hz 1.7B Base Q8_0 GGUF",
"default": true,
"format": "gguf",
"precision": "q8_0",
"target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-Base-GGUF/qwen3-tts-12hz-1.7b-base-q8_0_v2.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-Base-GGUF"
},
{
"id": "qwen3_tts_1_7b_base_bf16",
"display_name": "Qwen3 TTS 12Hz 1.7B Base BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-Base-GGUF/qwen3-tts-12hz-1.7b-base-bf16.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-Base-GGUF"
},
{
"id": "qwen3_tts_1_7b_base_orig",
"display_name": "Qwen3 TTS 12Hz 1.7B Base Original-Dtype GGUF",
"format": "gguf",
"precision": "orig",
"target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-Base-GGUF/qwen3-tts-12hz-1.7b-base-orig.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-Base-GGUF"
},
{
"id": "qwen3_tts_1_7b_customvoice_q8_0",
"display_name": "Qwen3 TTS 12Hz 1.7B CustomVoice Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF/qwen3-tts-12hz-1.7b-customvoice-q8_0.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"
},
{
"id": "qwen3_tts_1_7b_customvoice_bf16",
"display_name": "Qwen3 TTS 12Hz 1.7B CustomVoice BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF/qwen3-tts-12hz-1.7b-customvoice-bf16.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"
},
{
"id": "qwen3_tts_1_7b_voicedesign_q8_0",
"display_name": "Qwen3 TTS 12Hz 1.7B VoiceDesign Q8_0 GGUF",
"format": "gguf",
"precision": "q8_0",
"target_directory": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF/qwen3-tts-12hz-1.7b-voicedesign-q8_0.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"
},
{
"id": "qwen3_tts_1_7b_voicedesign_bf16",
"display_name": "Qwen3 TTS 12Hz 1.7B VoiceDesign BF16 GGUF",
"format": "gguf",
"precision": "bf16",
"target_directory": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF",
"files": [
"Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF/qwen3-tts-12hz-1.7b-voicedesign-bf16.gguf"
],
"strip_prefix": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"
},
{
"id": "qwen3_tts_1_7b_base_safetensors",
"display_name": "Qwen3 TTS 12Hz 1.7B Base Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "Qwen3-TTS-12Hz-1.7B-Base",
"files": [
"config.json",
"generation_config.json",
"model.safetensors",
"speech_tokenizer/config.json",
"speech_tokenizer/model.safetensors",
"tokenizer_config.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
}
},
{
"id": "qwen3_tts_0_6b_base_safetensors",
"display_name": "Qwen3 TTS 12Hz 0.6B Base Safetensors",
"format": "safetensors",
"precision": "native",
"target_directory": "Qwen3-TTS-12Hz-0.6B-Base",
"files": [
"config.json",
"generation_config.json",
"model.safetensors",
"speech_tokenizer/config.json",
"speech_tokenizer/model.safetensors",
"tokenizer_config.json"
],
"download": {
"kind": "huggingface_snapshot",
"repo": "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
}
}
],
"sources": [
{
"format": "gguf",
"roots": {
"model": ".",
"weights": "$gguf"
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt",
"speech_tokenizer_config": "model:speech_tokenizer/config.json"
},
"tensors": {
"model_weights": {
"source": "weights:",
"prefix": "model_weights"
},
"speech_tokenizer_weights": {
"source": "weights:",
"prefix": "speech_tokenizer_weights"
}
}
},
{
"format": "safetensors",
"roots": {
"model": "."
},
"files": {
"config": "model:config.json",
"generation_config": "model:generation_config.json",
"tokenizer_config": "model:tokenizer_config.json",
"vocab": "model:vocab.json",
"merges": "model:merges.txt",
"speech_tokenizer_config": "model:speech_tokenizer/config.json"
},
"tensors": {
"model_weights": "model:model.safetensors",
"speech_tokenizer_weights": "model:speech_tokenizer/model.safetensors"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More