Compare commits

...
Author SHA1 Message Date
diodiogod 9c40f4542b Add DramaBox LoRA training workflow
- Add the ready-to-use DramaBox LoRA training workflow and cover
- List the workflow in the README examples
- Keep DramaBox fine-tuning documentation general and capability-focused
2026-08-10 18:08:19 -03:00
diodiogod bf7d83f26e Improve DramaBox LoRA runtime reuse and documentation 2026-08-10 11:10:23 -03:00
diodiogod b09e5023b5 Add DramaBox LoRA training and adapter loading 2026-08-09 23:50:59 -03:00
diodiogod 586bd96e51 Version 5.6.5
Fix MOSS Dataset Prep workflow compatibility

Technical details:
- Restore the original serialized order of all existing optional widgets
- Append recursive_folder_scan after legacy widget values
- Prevent saved workflows from shifting validation and codec settings
- Address issue #340
2026-08-03 17:47:14 -03:00
diodiogod 127bfe32fc Version 5.6.4
Add MOSS training folder dataset import

Implementation details:
- Accept paired audio and transcript folders in MOSS Dataset Prep
- Generate deterministic cached JSONL manifests without changing existing manifest behavior
- Add optional recursive scanning and validation-folder support
- Address issue #340
2026-08-03 08:10:33 -03:00
diodiogod 2f587b22b3 Version 5.6.3
Prevent installer validation from importing engine runtimes

Technical details:
- Walk dotted module specs without importing parent packages
- Keep presence-only validation isolated from third-party startup checks
- Add regression coverage for import side effects
- Address issue #337
2026-08-01 17:46:46 -03:00
diodiogod 48608775e0 Version 5.6.2
Handle incompatible FlashAttention installs in F5-TTS

Technical details:
- Validate optional FlashAttention submodules before enabling the backend
- Keep F5-TTS on PyTorch attention when FlashAttention is partial or incompatible
- Improve the explicit FlashAttention backend error
- Address issue #334
2026-07-30 19:50:54 -03:00
diodiogod eaaacef869 Version 5.6.1
Allow installer to continue without optional audio libraries

Technical details:
- Treat PortAudio and libsamplerate checks as non-fatal warnings on Linux and macOS
- Allow Fish Audio S2 runtime installation to run in headless environments
- Correct Fedora dependency guidance
- Address issues #330 and #332
2026-07-30 17:44:51 -03:00
43 changed files with 21591 additions and 134 deletions
+45
View File
@@ -5,6 +5,51 @@ 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.6.5] - 2026-08-03
### Fixed
- Fix MOSS-TTS training settings in saved workflows
- Fix existing MOSS Dataset Prep workflows loading values into the wrong fields
- Fix invalid validation split and preparation batch size errors after updating
- Fix MOSS training tensor shape errors caused by shifted codec settings
## [5.6.4] - 2026-08-03
### Added
- Add MOSS-TTS training dataset folder support
- Add direct loading of matching audio and transcript files from a folder
- Support WAV, FLAC, MP3, OGG, and M4A training clips
- Add optional recursive scanning for datasets organized into subfolders
- Preserve existing JSONL manifest workflows
## [5.6.3] - 2026-08-01
### Changed
- Improve runtime availability checks so package startup code is not executed during installation
### Fixed
- Fix TTS Audio Suite installer validation failures
- Fix ComfyUI Desktop installation failing on supported PyTorch and TorchAudio combinations
## [5.6.2] - 2026-07-30
### Changed
- Improve F5-TTS fallback so the standard PyTorch attention backend continues working
### Fixed
- Fix F5-TTS failing to load with incomplete FlashAttention installations
- Fix F5-TTS startup crashes when optional FlashAttention components are missing
## [5.6.1] - 2026-07-30
### Fixed
- Fix Fish Audio S2 installation in headless Linux environments
- Fix missing optional audio libraries preventing Fish Audio S2 setup
- Improve Linux and macOS dependency warnings so core TTS installation continues
- Correct Fedora package installation guidance
## [5.6.0] - 2026-07-25
### Added
+19 -13
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.0
# TTS Audio Suite v5.6.5
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/diogogo)
@@ -277,6 +277,9 @@ This matters because the suite now has a clearer split:
FP8-cast transformer storage, and optional `torch.compile`
* **Generation diagnostics**: conservative near-silence detection in console
output, TTS generation information, and SRT timing reports
* **LoRA training**: official DramaBox audio-branch IC-LoRA training through
the unified training nodes, with normalized manifest/index input and managed
adapter export
**Important limitations:**
@@ -288,6 +291,8 @@ This matters because the suite now has a clearer split:
See the **[DramaBox Prompting Guide](docs/DRAMABOX_PROMPTING_GUIDE.md)** for
prompt syntax, controls, memory modes, duration behavior, and examples.
See the **[DramaBox LoRA Training Guide](docs/DRAMABOX_LORA_GUIDE.md)** for
dataset formats, training workflow, adapter loading, and CPU-safe preflight.
</details>
@@ -1268,19 +1273,19 @@ This section provides a detailed guide for installing TTS Audio Suite, covering
* Python 3.12 or higher
* **System libraries** (Linux only):
* **Optional system libraries** (Linux only):
```bash
# Ubuntu/Debian - Required for audio processing
# Ubuntu/Debian - Optional audio features
sudo apt-get install portaudio19-dev libsamplerate0-dev
# Fedora/RHEL
sudo dnf install portaudio-devel libsamplerate-devel
```
> **📋 Why needed?** `libsamplerate0-dev` provides audio resampling libraries for packages like `resampy` and `soxr`. `portaudio19-dev` enables voice recording features.
> **📋 Optional:** `libsamplerate0-dev` provides additional audio-resampling support. `portaudio19-dev` enables voice recording. Missing either package no longer blocks installation of the TTS engines.
* **macOS dependencies**:
* **Optional macOS dependencies**:
```bash
brew install portaudio
@@ -1375,17 +1380,17 @@ If you have a direct installation with a virtual environment (venv), follow thes
### Troubleshooting Dependency Issues
#### System Dependencies (Linux)
#### Optional System Dependencies (Linux)
**Our install script automatically detects missing system libraries** and will display helpful error messages like:
**Our install script automatically detects missing optional system libraries** and will display feature warnings like:
```
[!] Missing system dependencies detected!
[!] Optional system dependencies are missing
============================================================
SYSTEM DEPENDENCIES REQUIRED
OPTIONAL LINUX SYSTEM DEPENDENCIES
============================================================
• libsamplerate0-dev (for audio resampling)
• portaudio19-dev (for voice recording)
• libsamplerate0-dev (optional additional audio-resampling support)
• portaudio19-dev (optional voice recording)
Please install with:
# Ubuntu/Debian:
@@ -1394,7 +1399,7 @@ sudo apt-get install libsamplerate0-dev portaudio19-dev
# Fedora/RHEL:
sudo dnf install libsamplerate-devel portaudio-devel
============================================================
Then run this install script again.
Core TTS installation will continue; only the listed features may be unavailable.
```
#### Python Environment Issues
@@ -1527,7 +1532,7 @@ For offline/manual setup:
| Granite ASR | `ComfyUI/models/TTS/granite_asr/` | ✅ | Granite ASR models; plus adds native diarization/timestamps, optional Qwen forced aligner reused lazily for timestamps/SRT fallback |
| Echo-TTS | `ComfyUI/models/TTS/echo-tts-base/` | ✅ | ~7.1GB total (base + dac); CC-BY-NC-SA |
| Dots TTS | `ComfyUI/models/TTS/dots_tts/` | ✅ | Official base / soar / mf checkpoints with tokenizer, vocoder, speaker encoder |
| DramaBox | `ComfyUI/models/TTS/dramabox/DramaBox/` | ✅ | ~16.4GB download; fast mode roughly 24GB VRAM; experimental FP8 peaks on RTX 4090: staged ~15.1GB allocated, sequential ~11.7GB allocated / ~12.4GB reserved; conditional LTX-2 Community License |
| DramaBox | `ComfyUI/models/TTS/dramabox/DramaBox/` | ✅ | ~16.4GB download; fast mode roughly 24GB VRAM; experimental FP8, staged, and sequential options can reduce VRAM, but no minimum GPU size is guaranteed; conditional LTX-2 Community License |
| Fish Audio S2 Pro | `ComfyUI/models/TTS/fish_audio_s2_pro/` | ✅ | Official BF16 or optional community FP8 checkpoint; the official checkpoint can be quantized on load with BNB INT8/NF4; main T5 environment with process teardown for Clear VRAM; Fish Audio Research License |
| OmniVoice | `ComfyUI/models/TTS/omnivoice/` | ✅ | Official OmniVoice model. Voice cloning in this suite requires explicit reference text. |
@@ -1568,6 +1573,7 @@ Your support helps maintain and improve this project for the entire community!
| Workflow | Description | Status | Files |
| ---------------------------------------------- | ---------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **🤐 Voice Cleaning** | Audio restoration & cleanup with dual tool pipeline | ✅ **New in v4.13** | [📁 JSON](example_workflows/Voice%20Cleaning%20-%20🤐%20Noise%20or%20Vocal%20Removal%20+%20🤐%20Voice%20Fixer.json) |
| **DramaBox LoRA 🎓 Model Training** | DramaBox IC-LoRA training workflow from staged speech clips | ✅ **New** | [📁 JSON](example_workflows/DramaBox%20LoRA%20🎓%20Model%20Training.json) |
| **MOSS LoRA 🎓 Model Training** | Initial MOSS LoRA training workflow from clipped speech dataset | ✅ **New in v4.27** | [📁 JSON](example_workflows/MOSS%20LoRA%20🎓%20Model%20Training.json) |
| **RVC 🎓 Model Training** | RVC voice model training workflow | ✅ **New in v4.25** | [📁 JSON](example_workflows/RVC%20🎓%20Model%20Training.json) |
| **🎨 Step Audio EditX - Audio Editor** | Step Audio EditX audio editing with inline edit tags | ✅ **New in v4.14** | [📁 JSON](example_workflows/🎨%20Step%20Audio%20EditX%20-%20Audio%20Editor%20+%20Inline%20Edit%20Tags.json) |
+99
View File
@@ -0,0 +1,99 @@
# DramaBox LoRA training
TTS Audio Suite exposes the official DramaBox audio-branch IC-LoRA trainer
through the unified `🎓 Model Training` flow. The bundled scripts are pinned to
the same upstream DramaBox revision as the inference implementation.
See the official DramaBox
[LoRA training guide](https://github.com/resemble-ai/DramaBox#training-a-lora-on-top-of-dramabox)
for the upstream dataset format and training behavior.
## Workflow
1. Build a `⚙️ DramaBox Engine`.
2. Create the dataset either externally or entirely inside ComfyUI:
`🎞️ Training Clip Staging` → `🧾 DramaBox Dataset Rows`.
3. Connect the resulting manifest to `📦 DramaBox Dataset Prep` and keep
`dataset_type` set to `manifest`.
4. Provide at least two clips per speaker.
5. Connect the dataset to `🎛️ DramaBox Training Config` and then to `🎓 Model Training`.
6. Select the resulting adapter in the DramaBox engine, or enter its path in the
advanced LoRA override field.
The dataset node accepts:
- JSONL/JSON manifests with `audio_filepath` (or `audio_path`) and `text` (or
`transcript`)
- TSV rows with audio path and text
- the official `gemini_synthetic` and `libriheavy` index formats
Manifest rows may include `speaker`, `speaker_id`, `language`, and `duration`.
If `speaker` is omitted, rows are grouped as `speaker_1`. Duration and audio
metadata are measured without loading the waveform into the GPU. The suite
converts all accepted formats into the `~`-delimited speaker index required by
the upstream training loop. Clips are restricted to 2–20 seconds by default.
For an all-ComfyUI dataset, connect one or more `AUDIO` sources to
`🎞️ Training Clip Staging`, then enter one transcript per clip in
`🧾 DramaBox Dataset Rows`. Speaker and language lines are optional; shared
defaults are used when those lines are blank.
### Transcripts and scene descriptions
The official trainer accepts either plain spoken transcripts or the same
scene-style prompt format used for inference. For example, both of these are
valid training text:
```text
This is the spoken sentence.
A woman speaks warmly, "This is the spoken sentence."
```
Use scene descriptions only when they accurately describe the clip. Plain
transcripts remain valid and are the safer choice when no reliable style or
scene annotation is available.
## What training does
The first preprocessing pass uses Gemma and the DramaBox audio VAE to create
cached conditions and audio latents. The training process then attaches a LoRA
to the audio transformer branch. It saves periodic checkpoints and exports the
selected adapter to:
```text
ComfyUI/models/TTS/dramabox/loras/<adapter_name>/
```
The job directory, normalized index, preprocessing cache, progress file, and
logs are stored under:
```text
ComfyUI/output/tts_audio_suite_training/dramabox/
```
`continue_from` is a warm start from an existing LoRA checkpoint; it is not an
exact optimizer-state resume. Use saved checkpoints to compare quality rather
than assuming the last step is best. Optional upstream validation can be
enabled with a `val_config` YAML path, but it launches full DramaBox inference
at each save step. It requires a second GPU: set `validation_gpu` to that
physical CUDA device index. The suite rejects validation on the training GPU
instead of allowing both full model processes to compete for the same VRAM.
DramaBox LoRA inference supports normal transformer precision, `fp8_cast`, and
the optional `torch.compile` path. With normal precision the live adapter is
reversibly merged for fast inference. With FP8 storage the BF16 adapter remains
unmerged above the immutable FP8 base weights, avoiding unsafe mixed-dtype
weight fusion while retaining the main FP8 memory saving.
The base DramaBox runtime is reused when the selected adapter or LoRA strength
changes. Strength updates are applied directly to the live PEFT adapter, while
the generated-audio cache still treats adapter path, file revision, and strength
as distinct generation settings. Replacing an adapter with a different rank may
retrace compiled transformer blocks, but does not reload the base checkpoint.
## CPU-safe preflight
Training and Gemma/VAE preprocessing are GPU workloads. For development or
validation without touching CUDA, enable `dry_run` in the training config and
the dataset node's `dry_run`/`preprocess_now` controls. This writes the
normalized index and official command/config without loading DramaBox weights.
+2 -3
View File
@@ -140,9 +140,8 @@ The segment override ends at the next character tag.
VRAM. It additionally keeps the diffusion transformer in system RAM while
another major stage uses CUDA. It transfers the transformer for every
generated segment or long-form chunk and is therefore substantially slower.
With `fp8_cast`,
this measured about 11.7GB peak allocated and 12.4GB peak reserved VRAM on
an RTX 4090; leave additional headroom for ComfyUI and other loaded models.
Actual peak usage varies with the environment, generation settings, and
other loaded components; no minimum GPU size is guaranteed.
System RAM must hold the offloaded transformer (about 3.4GB with FP8 or
6.6GB without it).
- `fp8_cast` uses the official LTX FP8 transformer weight-storage policy and
+12 -5
View File
@@ -1351,7 +1351,7 @@ engines:
srt: true
vc: false
asr: false
training: false
training: true
special_features:
- "Expressive scene prompting and stage directions"
@@ -1362,6 +1362,7 @@ engines:
- "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"
model_sources:
- component: "DramaBox DiT + audio components"
@@ -1415,8 +1416,8 @@ engines:
emotion_control: { supported: true, notes: "Natural-language scene prompt and stage directions" }
native_long_form: { supported: true, notes: "Official duration-aware quote-group chunking; ~37s target / 45s cap" }
native_srt_duration_targeting: { supported: true, notes: "Subtitle duration is passed as gen_duration before the selected SRT timing mode applies final correction" }
community_finetunes: { supported: false, notes: "Not integrated" }
vram_efficient: { supported: true, notes: "Fast mode is ~24GB; experimental FP8 peaks on RTX 4090: staged ~15.1GB allocated, sequential ~11.7GB allocated / ~12.4GB reserved" }
community_finetunes: { supported: true, notes: "Official audio-branch IC-LoRA adapters can be trained and loaded" }
vram_efficient: { supported: true, notes: "Fast mode is ~24GB; experimental FP8, staged, and sequential options can reduce VRAM, but practical peak usage varies by environment and loaded components" }
speed_performance: { supported: "partial", notes: "Fast favors repeated generation; staged reloads temporary components; sequential also transfers the transformer; optional block-level torch.compile accelerates repeat denoising" }
reference_free_tts: { supported: true, notes: "Voice reference is optional" }
@@ -1983,7 +1984,7 @@ readme_model_download_table:
- engine: "DramaBox"
primary_model_path: "ComfyUI/models/TTS/dramabox/DramaBox/"
auto_download: "✅"
notes: "~16.4GB download; fast mode roughly 24GB VRAM; experimental FP8 peaks on RTX 4090: staged ~15.1GB allocated, sequential ~11.7GB allocated / ~12.4GB reserved; conditional LTX-2 Community License"
notes: "~16.4GB download; fast mode roughly 24GB VRAM; experimental FP8, staged, and sequential options can reduce VRAM, but no minimum GPU size is guaranteed; conditional LTX-2 Community License"
- engine: "Fish Audio S2 Pro"
primary_model_path: "ComfyUI/models/TTS/fish_audio_s2_pro/"
auto_download: "✅"
@@ -2223,7 +2224,7 @@ model_layouts_markdown: |
```text
ComfyUI/models/TTS/dramabox/
└── DramaBox/
├── DramaBox/
├── dramabox-dit-v1.safetensors
├── dramabox-audio-components.safetensors
├── assets/
@@ -2234,6 +2235,10 @@ model_layouts_markdown: |
├── model-00002-of-00002.safetensors
├── model.safetensors.index.json
└── tokenizer and processor files...
└── loras/
└── <adapter_name>/
├── adapter_config.json
└── adapter_model.safetensors
```
Notes:
@@ -2241,6 +2246,8 @@ model_layouts_markdown: |
- Both repositories download directly into the organized suite folder.
- Transformers is forced into local-only loading after download.
- Requires NVIDIA CUDA. Fast mode targets approximately 24GB VRAM; experimental staged/sequential modes can run with less.
- Integrated training exports LoRA adapters into `dramabox/loras/<adapter_name>/`.
- Training jobs, normalized datasets, preprocessing caches, checkpoints, and logs are stored under `ComfyUI/output/tts_audio_suite_training/dramabox/`.
- The LTX-2 Community License requires a paid license for entities with at
least USD 10 million in annual revenue.
+1 -1
View File
@@ -18,7 +18,7 @@
| **Echo-TTS** | Main | echo-tts-base + fish-s1-dac-min | ~5.3GB + ~1.8GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | CC-BY-NC-SA-4.0 | Diffusion-based (~30s best), Force Speaker KV (speaker drift control) | 1 |
| **Fish Audio S2 Pro** | Main | S2 Pro 4B / FP8 | ~10.3GB / ~8.0GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Fish Audio Research License | Free-form sub-word emotion/prosody tags, Native multi-speaker and multi-turn dialogue with dynamic speaker references, Zero-shot voice cloning, Optional per-segment custom character switching, Configurable 4K-32K native context with reduced KV-cache VRAM, Optional community FP8 weight-only checkpoint with BF16 activations, Optional on-the-fly BitsAndBytes INT8/NF4 for the official checkpoint | 80+ languages |
| **Dots TTS** | Main | dots.tts-base, dots.tts-soar, dots.tts-mf | ~6GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | Apache-2.0 | Official auto language detect / language control, SOAR and MeanFlow distilled variants | 19 |
| **DramaBox** | Main | DramaBox 3.3B | ~16.4GB | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | LTX-2 Community License | Expressive scene prompting and stage directions, Native and SRT-aware duration targeting, Official duration-aware long-form chunking with scene-prefix preservation, Optional 10-second zero-shot voice reference, CFG negative prompt with per-segment switching, Explicit generation/reference durations, CFG rescale control, and optional Perth watermark, Experimental staged and sequential strategies for lowering peak VRAM; official LTX FP8-cast storage, Optional official torch.compile path | 1 |
| **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-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 |
+3 -3
View File
@@ -9,14 +9,14 @@
| **Voice Conversion** | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| **ASR (Transcribe)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Sound Effects** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| **Training** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
| **Training** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
| **Voice Cloning** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (Base model) | ❌ | ✅ | ✅ | ✅ Reference audio plus exact transcript | ✅ | ✅ Optional reference audio; configurable 3-30 second window from the beginning of the source, default 10 seconds | ✅ | ✅ | ❌ | ⚠️ (needs training) |
| **Reference Transcript†** | **Required** | Not used | Not used | Not used | Optional | Optional | Not used | Conditional | Conditional | N/A | **Required** | Not used | **Required** | Optional | Not used | **Required** | Conditional | Not used | N/A |
| **Native Multi-Speaker** | ❌ | ❌ | ❌ | ✅ (Base only, Kugel uses fallback) | ✅ | ❌ | ❌ | ❌ | ❌ | ⚠️ (Plus variant speaker attribution / diarization) | ❌ | ❌ | ✅ Native dialogue is default; optional custom mode generates each [Character] segment independently as local speaker 0 | ❌ | ❌ | ❌ | ✅ (TTSD v1.0; 1-5 speakers) | ❌ | ❌ |
| **Emotion Control** | ❌ | ❌ | ⚠️ (v2 tags - doesn't work) | ❌ | ⚠️ (via prompt) | ✅ (native inline tags) | ✅ (8 emotions) | ⚠️ (via instruct) | ⚠️ (via instruct) | ❌ | ✅ (14 emotions) | ❌ | ✅ Free-form inline natural-language tags | ❌ | ✅ Natural-language scene prompt and stage directions | ⚠️ (voice-design instruct + inline non-verbal tags) | ✅ (MOSS-VoiceGenerator instruction-conditioned voice design) | ❌ | ❌ |
| **Native Long-form** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ Configurable 4K-32K native context; suite text chunking is bypassed | ❌ | ✅ Official duration-aware quote-group chunking; ~37s target / 45s cap | ✅ (uses upstream audio_chunk_duration / audio_chunk_threshold orchestration; bypasses suite char-based chunk splitting) | ✅ (TTSD/Delay long-form; use chunk orchestration for very long inputs) | ❌ | N/A |
| **Community Finetunes** | ✅ | ✅ | ✅ | ✅ KugelAudio, Hindi | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ (LoRA adapter inference supported; initial integrated LoRA training support added for MOSS-TTS Delay 8B) | ❌ | ✅ |
| **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 peaks on RTX 4090: staged ~15.1GB allocated, sequential ~11.7GB allocated / ~12.4GB reserved | ⚠️ (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) | ✅ |
| **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) | ❌ | ✅ |
| **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 |
+7 -1
View File
@@ -227,7 +227,7 @@ Notes:
```text
ComfyUI/models/TTS/dramabox/
└── DramaBox/
├── DramaBox/
├── dramabox-dit-v1.safetensors
├── dramabox-audio-components.safetensors
├── assets/
@@ -238,6 +238,10 @@ ComfyUI/models/TTS/dramabox/
├── model-00002-of-00002.safetensors
├── model.safetensors.index.json
└── tokenizer and processor files...
└── loras/
└── <adapter_name>/
├── adapter_config.json
└── adapter_model.safetensors
```
Notes:
@@ -245,6 +249,8 @@ Notes:
- Both repositories download directly into the organized suite folder.
- Transformers is forced into local-only loading after download.
- Requires NVIDIA CUDA. Fast mode targets approximately 24GB VRAM; experimental staged/sequential modes can run with less.
- Integrated training exports LoRA adapters into `dramabox/loras/<adapter_name>/`.
- Training jobs, normalized datasets, preprocessing caches, checkpoints, and logs are stored under `ComfyUI/output/tts_audio_suite_training/dramabox/`.
- The LTX-2 Community License requires a paid license for entities with at
least USD 10 million in annual revenue.
+19 -2
View File
@@ -23,12 +23,29 @@ It does **not** currently support:
Current ComfyUI flow:
1. `🎞️ MOSS Clip Staging`
1. `🎞️ Training Clip Staging`
2. `🧾 MOSS Dataset Rows`
3. `📦 MOSS Dataset Prep`
4. `🎛️ MOSS Training Config`
5. `🎓 Model Training`
If clips and transcripts are already prepared on disk, you can skip the first two
nodes. Set `dataset_source` on `📦 MOSS Dataset Prep` to a folder containing
same-name audio and text pairs:
```text
my_dataset/
├── clip001.wav
├── clip001.txt
├── clip002.flac
└── clip002.txt
```
Each `.txt` file must contain the transcript spoken in its matching audio file.
Folder scanning supports WAV, FLAC, MP3, OGG, and M4A. Subfolders are ignored
unless `recursive_folder_scan` is enabled. Existing JSONL manifest paths continue
to work unchanged.
## The Important Fields
### `text_lines`
@@ -229,7 +246,7 @@ If you do not have a separate validation manifest:
If you want the least confusing starting point:
- use `🎞️ MOSS Clip Staging`
- use `🎞️ Training Clip Staging`
- use `🧾 MOSS Dataset Rows`
- fill only `text_lines`
- leave `reference_clip_lines` blank
+67 -23
View File
@@ -26,11 +26,36 @@ class DramaBoxEngineAdapter:
self.audio_cache = get_audio_cache()
self._last_config: Optional[ModelLoadConfig] = None
self._load_signature = None
self._lora_signature = None
self.last_generation_status: Dict[str, Any] = {"near_silent": False}
def update_config(self, new_config: Dict[str, Any]):
self.config = new_config.copy() if new_config else {}
@staticmethod
def _lora_revision(path: Any) -> str:
"""Return a cheap cache token that changes when a managed adapter is replaced."""
value = str(path or "").strip()
if not value:
return ""
try:
candidate = os.path.abspath(os.path.expanduser(value))
if os.path.isfile(candidate):
stat = os.stat(candidate)
return f"{candidate}:{stat.st_size}:{stat.st_mtime_ns}"
if os.path.isdir(candidate):
entries = []
for item in os.listdir(candidate):
if not item.endswith(".safetensors"):
continue
item_path = os.path.join(candidate, item)
stat = os.stat(item_path)
entries.append(f"{item}:{stat.st_size}:{stat.st_mtime_ns}")
return f"{candidate}|{'|'.join(sorted(entries))}"
except OSError:
pass
return value
@classmethod
def _warn_if_near_silent(
cls,
@@ -76,6 +101,7 @@ class DramaBoxEngineAdapter:
}
def _build_load_signature(self) -> Tuple[Any, ...]:
"""Identity of the expensive base runtime, excluding live LoRA state."""
return (
self.config.get("model_name", "DramaBox"),
self.config.get("device", "auto"),
@@ -85,35 +111,50 @@ class DramaBoxEngineAdapter:
bool(self.config.get("compile_model", False)),
)
def _build_lora_signature(self) -> Tuple[Any, ...]:
path = self.config.get("lora_path", "")
return (
str(path or "").strip(),
self._lora_revision(path),
float(self.config.get("lora_strength", 1.0)),
)
def _ensure_model_loaded(self):
signature = self._build_load_signature()
if signature == self._load_signature and self._last_config is not None:
return
self._last_config = ModelLoadConfig(
engine_name="dramabox",
model_type="tts",
model_name=self.config.get("model_name", "DramaBox"),
device=self.config.get("device", "auto"),
additional_params={
"precision": self.config.get("precision", "auto"),
"memory_mode": self.config.get("memory_mode", "fast"),
"transformer_quantization": self.config.get(
"transformer_quantization", "none"
),
"compile_model": bool(self.config.get("compile_model", False)),
},
)
from utils.models.unified_model_interface import unified_model_interface
unified_model_interface.load_model(self._last_config)
self._load_signature = signature
if signature != self._load_signature or self._last_config is None:
self._last_config = ModelLoadConfig(
engine_name="dramabox",
model_type="tts",
model_name=self.config.get("model_name", "DramaBox"),
device=self.config.get("device", "auto"),
additional_params={
"precision": self.config.get("precision", "auto"),
"memory_mode": self.config.get("memory_mode", "fast"),
"transformer_quantization": self.config.get(
"transformer_quantization", "none"
),
"compile_model": bool(self.config.get("compile_model", False)),
},
)
self._load_signature = signature
self._lora_signature = None
engine = unified_model_interface.load_model(self._last_config)
lora_signature = self._build_lora_signature()
if lora_signature != self._lora_signature:
lora_path, lora_revision, lora_strength = lora_signature
engine.set_lora(
lora_path=lora_path,
strength=lora_strength,
revision=lora_revision,
)
self._lora_signature = lora_signature
return engine
def _get_engine(self):
self._ensure_model_loaded()
from utils.models.unified_model_interface import unified_model_interface
return unified_model_interface.load_model(self._last_config)
return self._ensure_model_loaded()
def _extract_voice_reference(
self, voice_ref: Optional[Dict[str, Any]]
@@ -195,6 +236,9 @@ class DramaBoxEngineAdapter:
),
memory_mode=self.config.get("memory_mode", "fast"),
compile_model=bool(self.config.get("compile_model", False)),
lora_path=self.config.get("lora_path", ""),
lora_strength=float(self.config.get("lora_strength", 1.0)),
lora_revision=self._lora_revision(self.config.get("lora_path", "")),
seed=int(seed),
character=character_name or "narrator",
)
+17
View File
@@ -28,6 +28,8 @@ class DramaBoxEngine:
memory_mode: str = "fast",
transformer_quantization: str = "none",
compile_model: bool = False,
lora_path: str = "",
lora_strength: float = 1.0,
):
self.model_name = model_name
self.device = resolve_torch_device(device)
@@ -36,6 +38,8 @@ class DramaBoxEngine:
self.memory_mode = str(memory_mode)
self.transformer_quantization = str(transformer_quantization)
self.compile_model = bool(compile_model)
self.lora_path = str(lora_path or "").strip()
self.lora_strength = float(lora_strength)
self._server = None
self._server_module = None
@@ -104,6 +108,8 @@ class DramaBoxEngine:
bnb_4bit=True,
memory_mode=self.memory_mode,
transformer_quantization=self.transformer_quantization,
lora_path=self.lora_path,
lora_strength=self.lora_strength,
)
print("✅ DramaBox runtime ready")
@@ -169,6 +175,17 @@ class DramaBoxEngine:
"sample_rate": int(sample_rate),
}
def set_lora(self, lora_path: str = "", strength: float = 1.0, revision: str = ""):
"""Update the live adapter without rebuilding the base DramaBox runtime."""
self.lora_path = str(lora_path or "").strip()
self.lora_strength = float(strength)
if self._server is not None:
self._server.configure_lora(
self.lora_path,
self.lora_strength,
revision=str(revision or ""),
)
def parameters(self) -> Iterator[torch.nn.Parameter]:
"""Expose loaded submodule parameters for ComfyUI memory accounting."""
if self._server is None:
+5
View File
@@ -0,0 +1,5 @@
"""DramaBox LoRA dataset and training integration."""
from .handler import DramaBoxTrainingHandler
__all__ = ["DramaBoxTrainingHandler"]
+458
View File
@@ -0,0 +1,458 @@
"""Dataset normalization for the official DramaBox IC-LoRA trainer.
The upstream preprocessor accepts JSONL and TSV, but the upstream training
loop builds its speaker map from ``~``-delimited index rows. This module keeps
that conversion in the suite so a manifest that is valid for preprocessing is
also valid for training.
"""
from __future__ import annotations
import csv
import hashlib
import json
import os
import re
import wave
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
import folder_paths
AUDIO_EXTENSIONS = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aac"}
PREPROCESSED_SAMPLE_PATTERN = re.compile(r"sample_(\d+)\.pt$")
def slugify(value: Any) -> str:
safe = "".join(
ch if ch.isalnum() or ch in ("-", "_") else "_"
for ch in str(value or "").strip()
)
safe = safe.strip("_")
return safe or "dramabox_lora"
def get_dramabox_training_root() -> str:
root = os.path.join(
folder_paths.get_output_directory(), "tts_audio_suite_training", "dramabox"
)
os.makedirs(root, exist_ok=True)
return root
def _resolve_source_path(value: str) -> Path:
raw = os.path.expanduser(str(value or "").strip())
if not raw:
raise ValueError("dataset_source is required")
candidates = [Path(raw)]
input_root = Path(folder_paths.get_input_directory())
candidates.extend((input_root / raw, input_root / "datasets" / raw))
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
raise FileNotFoundError(f"DramaBox dataset source not found: {value}")
def _resolve_audio_path(raw_path: Any, *, source_path: Path, audio_dir: str) -> Path:
value = os.path.expanduser(str(raw_path or "").strip())
if not value:
raise ValueError("Dataset row is missing audio_filepath/audio_path")
candidates: List[Path] = []
if os.path.isabs(value):
candidates.append(Path(value))
else:
if audio_dir:
candidates.append(Path(os.path.expanduser(audio_dir)) / value)
candidates.append(source_path.parent / value)
candidates.append(Path(value))
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
raise FileNotFoundError(f"DramaBox audio file not found: {raw_path}")
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "").replace("\x00", "")).strip()
def _speaker_value(row: Dict[str, Any], default: str = "speaker_1") -> str:
value = (
row.get("speaker")
or row.get("speaker_id")
or row.get("voice")
or row.get("character")
or default
)
return _clean_text(value).replace("~", "_") or default
def _language_value(row: Dict[str, Any]) -> str:
return _clean_text(row.get("language") or row.get("lang") or "en").replace("~", "_") or "en"
def _coerce_float(value: Any, default: float = 0.0) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
return parsed if parsed > 0 else float(default)
def _probe_audio(path: Path) -> Tuple[int, int, float]:
"""Return sample rate, frame count, and duration without loading audio."""
try:
import torchaudio
info = torchaudio.info(str(path))
sample_rate = int(getattr(info, "sample_rate", 0) or 0)
frames = int(getattr(info, "num_frames", 0) or 0)
if sample_rate > 0 and frames > 0:
return sample_rate, frames, frames / sample_rate
except Exception:
pass
if path.suffix.lower() == ".wav":
with wave.open(str(path), "rb") as handle:
sample_rate = int(handle.getframerate())
frames = int(handle.getnframes())
if sample_rate > 0 and frames > 0:
return sample_rate, frames, frames / sample_rate
raise RuntimeError(
f"Could not inspect audio duration for '{path}'. Add a positive duration "
"field to the manifest or install a Torchaudio-compatible decoder."
)
def _parse_manifest(source_path: Path, audio_dir: str) -> Iterable[Dict[str, Any]]:
text = source_path.read_text(encoding="utf-8-sig")
stripped = text.lstrip()
if stripped.startswith("["):
raw_rows = json.loads(text)
else:
raw_rows = [json.loads(line) for line in text.splitlines() if line.strip()]
for row in raw_rows:
if not isinstance(row, dict):
continue
yield {
"audio": _resolve_audio_path(
row.get("audio_filepath", row.get("audio_path", row.get("audio"))),
source_path=source_path,
audio_dir=audio_dir,
),
"text": _clean_text(row.get("text", row.get("transcript", ""))),
"duration": _coerce_float(row.get("duration")),
"sample_rate": int(_coerce_float(row.get("sample_rate"))),
"samples": int(_coerce_float(row.get("samples", row.get("num_frames")))),
"speaker": _speaker_value(row),
"language": _language_value(row),
}
def _parse_tsv(source_path: Path, audio_dir: str) -> Iterable[Dict[str, Any]]:
with source_path.open("r", encoding="utf-8-sig", newline="") as handle:
for row_number, row in enumerate(csv.reader(handle, delimiter="\t"), start=1):
if len(row) < 2:
continue
yield {
"audio": _resolve_audio_path(row[0], source_path=source_path, audio_dir=audio_dir),
"text": _clean_text(row[1]),
"duration": _coerce_float(row[2]) if len(row) > 2 else 0.0,
"sample_rate": 0,
"samples": 0,
"speaker": _clean_text(row[3]).replace("~", "_") if len(row) > 3 else "speaker_1",
"language": _clean_text(row[4]).replace("~", "_") if len(row) > 4 else "en",
"row_number": row_number,
}
def _parse_gemini(source_path: Path, audio_dir: str) -> Iterable[Dict[str, Any]]:
for line in source_path.read_text(encoding="utf-8-sig").splitlines():
parts = line.strip().split("~")
if len(parts) < 8:
continue
file_id, speaker, language = parts[:3]
sample_rate = int(_coerce_float(parts[3], 24000))
samples = int(_coerce_float(parts[4]))
duration = _coerce_float(parts[5])
text = _clean_text(parts[-1])
yield {
"audio": _resolve_audio_path(file_id, source_path=source_path, audio_dir=audio_dir),
"text": text,
"duration": duration,
"sample_rate": sample_rate,
"samples": samples,
"speaker": _clean_text(speaker).replace("~", "_") or "speaker_1",
"language": _clean_text(language).replace("~", "_") or "en",
}
def _parse_libriheavy(source_path: Path, audio_dir: str) -> Iterable[Dict[str, Any]]:
for line in source_path.read_text(encoding="utf-8-sig").splitlines():
parts = line.strip().split("~")
if len(parts) < 7:
continue
file_id, speaker, language = parts[:3]
# Format: id~speaker~lang~samples~duration_ms~phonemes~text.
sample_rate = 24000
samples = int(_coerce_float(parts[3]))
duration = _coerce_float(parts[4]) / 1000.0 if len(parts) >= 5 else 0.0
yield {
"audio": _resolve_audio_path(file_id, source_path=source_path, audio_dir=audio_dir),
"text": _clean_text(parts[-1]),
"duration": duration,
"sample_rate": sample_rate,
"samples": samples,
"speaker": _clean_text(speaker).replace("~", "_") or "speaker_1",
"language": _clean_text(language).replace("~", "_") or "en",
}
def _raw_rows(source_path: Path, dataset_type: str, audio_dir: str) -> Iterable[Dict[str, Any]]:
parsers = {
"manifest": _parse_manifest,
"tsv": _parse_tsv,
"gemini_synthetic": _parse_gemini,
"libriheavy": _parse_libriheavy,
}
try:
parser = parsers[str(dataset_type)]
except KeyError as exc:
raise ValueError(f"Unsupported DramaBox dataset type: {dataset_type}") from exc
return parser(source_path, audio_dir)
def _fingerprint(source_path: Path, *, dataset_type: str, audio_dir: str, min_duration: float, max_duration: float) -> str:
stat = source_path.stat()
raw = f"{source_path}|{stat.st_size}|{stat.st_mtime_ns}|{dataset_type}|{audio_dir}|{min_duration}|{max_duration}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _normalize_rows(
source_path: Path,
*,
dataset_type: str,
audio_dir: str,
min_duration: float,
max_duration: float,
) -> List[Dict[str, Any]]:
records: List[Dict[str, Any]] = []
for row_index, row in enumerate(_raw_rows(source_path, dataset_type, audio_dir)):
text = _clean_text(row.get("text"))
if not text:
continue
audio = Path(row["audio"]).resolve()
sample_rate = int(row.get("sample_rate") or 0)
samples = int(row.get("samples") or 0)
duration = _coerce_float(row.get("duration"))
if not sample_rate or not samples or not duration:
try:
probed_rate, probed_samples, probed_duration = _probe_audio(audio)
sample_rate = sample_rate or probed_rate
samples = samples or probed_samples
duration = duration or probed_duration
except RuntimeError:
if duration <= 0:
raise
sample_rate = sample_rate or 24000
samples = samples or max(1, round(duration * sample_rate))
if duration < float(min_duration) or duration > float(max_duration):
continue
records.append(
{
"id": f"sample_{row_index:06d}",
"audio": str(audio),
"text": text,
"duration": float(duration),
"sample_rate": int(sample_rate),
"samples": int(samples),
"speaker": _speaker_value(row),
"language": _language_value(row),
}
)
if not records:
raise ValueError(
"DramaBox dataset preparation produced no usable rows. Check the audio paths, "
"transcripts, and the min/max duration filters."
)
speaker_counts: Dict[str, int] = {}
for record in records:
speaker_counts[record["speaker"]] = speaker_counts.get(record["speaker"], 0) + 1
unusable = sorted(name for name, count in speaker_counts.items() if count < 2)
if unusable:
raise ValueError(
"DramaBox LoRA training needs at least two clips per speaker so the official "
f"trainer can choose a reference clip. Speakers with fewer than two clips: {', '.join(unusable)}."
)
return records
def _write_index(records: List[Dict[str, Any]], index_path: Path) -> None:
index_path.parent.mkdir(parents=True, exist_ok=True)
with index_path.open("w", encoding="utf-8") as handle:
for record in records:
text = str(record["text"]).replace("\r", " ").replace("\n", " ")
handle.write(
"~".join(
(
str(Path(record["audio"]).resolve()),
str(record["speaker"]),
str(record["language"]),
str(int(record["sample_rate"])),
str(int(record["samples"])),
f"{float(record['duration']):.6f}",
"_",
text,
)
)
+ "\n"
)
def _preprocessed_indices(directory: Path) -> set[int]:
indices: set[int] = set()
if not directory.is_dir():
return indices
for path in directory.glob("sample_*.pt"):
match = PREPROCESSED_SAMPLE_PATTERN.fullmatch(path.name)
if match:
indices.add(int(match.group(1)))
return indices
def validate_preprocessed_dataset(
records: List[Dict[str, Any]],
preprocessed_dir: str | Path,
*,
raise_on_missing: bool = False,
) -> bool:
"""Require matching text conditions and audio latents for every index row."""
root = Path(preprocessed_dir)
expected = set(range(len(records)))
available = _preprocessed_indices(root / "conditions") & _preprocessed_indices(
root / "audio_latents"
)
missing = sorted(expected - available)
complete = bool(expected) and not missing
if raise_on_missing and not complete:
preview = ", ".join(str(index) for index in missing[:10]) or "all"
suffix = "..." if len(missing) > 10 else ""
raise RuntimeError(
"DramaBox preprocessing did not produce matching condition/audio-latent "
f"files for {len(missing) or len(expected)} sample(s) (indices: {preview}{suffix}). "
"Fix the reported source-audio errors and run Dataset Prep again."
)
return complete
def prepare_dramabox_dataset(
shared_settings: Dict[str, Any],
*,
dataset_source: str,
model_name: str,
dataset_type: str = "manifest",
audio_dir: str = "",
min_duration: float = 2.0,
max_duration: float = 20.0,
reuse_existing: bool = True,
preprocess_now: bool = True,
dry_run: bool = False,
) -> Dict[str, Any]:
source_path = _resolve_source_path(dataset_source)
fingerprint = _fingerprint(
source_path,
dataset_type=dataset_type,
audio_dir=audio_dir,
min_duration=min_duration,
max_duration=max_duration,
)
safe_name = slugify(model_name)
dataset_root = Path(get_dramabox_training_root()) / "datasets" / f"{safe_name}_{fingerprint}"
index_path = dataset_root / "speaker_index.txt"
metadata_path = dataset_root / "dataset.json"
preprocessed_dir = dataset_root / "preprocessed"
if reuse_existing and metadata_path.is_file() and index_path.is_file():
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
records = metadata.get("records") or []
except Exception:
records = []
else:
records = []
if not records:
records = _normalize_rows(
source_path,
dataset_type=dataset_type,
audio_dir=audio_dir,
min_duration=float(min_duration),
max_duration=float(max_duration),
)
dataset_root.mkdir(parents=True, exist_ok=True)
_write_index(records, index_path)
metadata_path.write_text(
json.dumps(
{
"type": "dramabox_dataset",
"source_path": str(source_path),
"dataset_type": dataset_type,
"audio_dir": audio_dir,
"min_duration": float(min_duration),
"max_duration": float(max_duration),
"records": records,
},
indent=2,
ensure_ascii=False,
),
encoding="utf-8",
)
# Rewrite cached indexes as well so datasets prepared by older suite
# builds migrate from synthetic sample ids to resolvable audio paths.
_write_index(records, index_path)
dataset: Dict[str, Any] = {
"type": "training_dataset",
"engine_type": "dramabox",
"training_mode": "audio_lora",
"model_name": model_name,
"dataset_type": dataset_type,
"source_path": str(source_path),
"index_path": str(index_path),
"speaker_index": str(index_path),
"data_dir": [str(preprocessed_dir)],
"preprocessed_dir": str(preprocessed_dir),
"min_duration": float(min_duration),
"max_duration": float(max_duration),
"records": records,
"train_records": len(records),
"speakers": sorted({str(record["speaker"]) for record in records}),
"preprocessed": validate_preprocessed_dataset(records, preprocessed_dir),
"dry_run": bool(dry_run),
"shared_settings": dict(shared_settings or {}),
}
if preprocess_now and not dry_run and not dataset["preprocessed"]:
from .trainer import run_dramabox_preprocess
run_dramabox_preprocess(dataset, shared_settings, batch_size=8)
dataset["preprocessed"] = True
return dataset
__all__ = [
"get_dramabox_training_root",
"prepare_dramabox_dataset",
"slugify",
"validate_preprocessed_dataset",
]
+82
View File
@@ -0,0 +1,82 @@
"""DramaBox backend for the unified model-training node."""
from __future__ import annotations
from typing import Any, Dict
from engines.training.base_handler import BaseTrainingHandler
from engines.training.registry import register_training_handler
class DramaBoxTrainingHandler(BaseTrainingHandler):
engine_type = "dramabox"
artifact_type = "lora_adapter"
def _shared_settings(self, tts_engine: Any) -> Dict[str, Any]:
config = self.ensure_engine_type(tts_engine)
return {
"model_name": config.get("model_name", "DramaBox"),
"device": str(config.get("device", "auto")),
"precision": str(config.get("precision", "auto")),
}
def build_default_training_config(self, tts_engine: Any) -> Dict[str, Any]:
self._shared_settings(tts_engine)
return {
"type": "training_config",
"engine_type": "dramabox",
"training_mode": "audio_lora",
"base_model": "dev",
"steps": 10000,
"learning_rate": 1e-4,
"lr_scheduler": "cosine",
"warmup_steps": 500,
"batch_size": 1,
"grad_accum": 4,
"max_grad_norm": 1.0,
"save_every": 500,
"log_every": 10,
"seed": 42,
"lora_rank": 128,
"lora_alpha": 128,
"lora_dropout": 0.1,
"ref_ratio": 0.3,
"max_ref_tokens": 200,
"text_dropout": 0.4,
"preprocess_batch_size": 8,
"validation_config": "",
"validation_gpu": "",
"dry_run": False,
}
def prepare_dataset(self, tts_engine: Any, **kwargs) -> Dict[str, Any]:
from .dataset import prepare_dramabox_dataset
return prepare_dramabox_dataset(self._shared_settings(tts_engine), **kwargs)
def train(
self,
tts_engine: Any,
training_dataset: Dict[str, Any],
training_config: Dict[str, Any],
output_name: str = "",
resume: bool = False,
overwrite: bool = False,
continue_from: Any = None,
node_id: str = "",
) -> Dict[str, Any]:
from .trainer import run_dramabox_training_job
return run_dramabox_training_job(
shared_settings=self._shared_settings(tts_engine),
dataset_info=training_dataset,
training_config=training_config,
output_name=output_name,
resume=resume,
overwrite=overwrite,
continue_from=continue_from,
node_id=node_id,
)
register_training_handler("dramabox", DramaBoxTrainingHandler)
+687
View File
@@ -0,0 +1,687 @@
"""Process runner for the official DramaBox IC-LoRA trainer."""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, Optional
import folder_paths
from engines.dramabox.dramabox_downloader import DramaBoxDownloader
from engines.training.progress_io import write_json_progress_file
from engines.training.progress_registry import (
finalize_training_job,
register_training_job,
update_training_job,
)
from .dataset import (
get_dramabox_training_root,
slugify,
validate_preprocessed_dataset,
)
PROJECT_ROOT = Path(__file__).resolve().parents[3]
VENDOR_ROOT = PROJECT_ROOT / "engines" / "dramabox" / "vendor"
PREPROCESS_SCRIPT = VENDOR_ROOT / "src" / "preprocess.py"
TRAIN_SCRIPT = VENDOR_ROOT / "src" / "train.py"
def _write_progress(progress_file: str, *, status: str, phase: str, **updates: Any) -> None:
payload: Dict[str, Any] = {}
if progress_file and os.path.isfile(progress_file):
try:
with open(progress_file, "r", encoding="utf-8") as handle:
existing = json.load(handle)
if isinstance(existing, dict):
payload.update(existing)
except Exception:
pass
payload.update(updates)
payload["status"] = status
payload["phase"] = phase
payload["updated_at"] = datetime.now().isoformat()
if progress_file:
write_json_progress_file(progress_file, payload, default=str)
def _interrupt_requested() -> bool:
try:
import comfy.model_management as model_management
except Exception:
return False
try:
return bool(model_management.processing_interrupted())
except Exception:
return bool(getattr(model_management, "interrupt_processing", False))
def _device_environment(shared_settings: Dict[str, Any]) -> Dict[str, str]:
env = os.environ.copy()
device = str(shared_settings.get("device", "auto") or "auto").strip().lower()
if device.startswith("cpu"):
# CPU mode is explicit. This also prevents a CUDA-enabled torch build
# from silently taking the user's GPU during preprocessing.
env["CUDA_VISIBLE_DEVICES"] = ""
elif device.startswith("cuda:"):
env["CUDA_VISIBLE_DEVICES"] = device.split(":", 1)[1]
return env
def _run_process(
command: Iterable[str],
*,
cwd: Path,
env: Dict[str, str],
phase: str,
progress_file: str = "",
node_id: str = "",
total_steps: int = 0,
) -> None:
command = [str(value) for value in command]
print(f"🎓 DramaBox {phase} command: {' '.join(command)}")
process = subprocess.Popen(
command,
cwd=str(cwd),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
tail: list[str] = []
recent_loss_trace: list[Dict[str, Any]] = []
best_loss: Optional[float] = None
try:
assert process.stdout is not None
for raw_line in process.stdout:
line = raw_line.rstrip()
if line:
telemetry_match = re.fullmatch(
r"TTS_SUITE_PROGRESS\s+step=(\d+)\s+total=(\d+)", line
)
if telemetry_match is None:
print(f"[DramaBox {phase}] {line}")
tail.append(line)
del tail[:-30]
if progress_file:
match = telemetry_match or re.search(
r"(?:Step|step)\s+(\d+)(?:/(\d+))?", line
)
if match:
step = int(match.group(1))
parsed_total = int(match.group(2) or total_steps or 0)
overall_progress = (step / parsed_total) if parsed_total else 0.0
progress_updates: Dict[str, Any] = {
"step": step,
"total_steps": parsed_total,
"overall_progress": overall_progress,
"latest_log": line,
}
loss_match = re.search(
r"\bloss=([-+0-9.eE]+)", line, re.IGNORECASE
)
if loss_match:
loss_value = float(loss_match.group(1))
lr_match = re.search(
r"\blr=([-+0-9.eE]+)", line, re.IGNORECASE
)
learning_rate = (
float(lr_match.group(1)) if lr_match else None
)
recent_loss_trace.append(
{"step": step, "total_loss": loss_value}
)
recent_loss_trace = recent_loss_trace[-120:]
best_loss = (
loss_value
if best_loss is None
else min(best_loss, loss_value)
)
progress_updates.update(
latest_loss=loss_value,
best_gen_loss=best_loss,
recent_loss_trace=recent_loss_trace,
current_metrics={
"loss_gen_all": loss_value,
"loss_disc_all": 0.0,
"loss_mel": 0.0,
"loss_kl": 0.0,
"loss_fm": 0.0,
"learning_rate": learning_rate,
},
)
_write_progress(
progress_file,
status="running",
phase=phase,
**progress_updates,
)
update_training_job(
node_id,
status="running",
phase=phase,
**progress_updates,
)
elif "encoding:" in line.lower():
match = re.search(r"(\d+)\s*/\s*(\d+)", line)
if match:
step = int(match.group(1))
parsed_total = int(match.group(2))
overall_progress = step / max(parsed_total, 1)
_write_progress(
progress_file,
status="running",
phase=phase,
step=step,
total_steps=parsed_total,
overall_progress=overall_progress,
latest_log=line,
)
update_training_job(
node_id,
status="running",
phase=phase,
step=step,
total_steps=parsed_total,
overall_progress=overall_progress,
latest_log=line,
)
if _interrupt_requested():
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
raise InterruptedError(f"DramaBox {phase} interrupted by user")
return_code = process.wait()
except BaseException:
if process.poll() is None:
process.terminate()
raise
if return_code != 0:
details = "\n".join(tail[-10:])
raise RuntimeError(
f"DramaBox {phase} process failed with exit code {return_code}."
+ (f"\nLast output:\n{details}" if details else "")
)
def _resolve_model_paths(shared_settings: Dict[str, Any]) -> Dict[str, str]:
model_name = str(shared_settings.get("model_name", "DramaBox") or "DramaBox")
return DramaBoxDownloader().resolve_model_path(model_name)
def build_preprocess_command(
dataset_info: Dict[str, Any],
shared_settings: Dict[str, Any],
*,
batch_size: int = 8,
skip_existing: bool = True,
) -> list[str]:
paths = _resolve_model_paths(shared_settings)
command = [
sys.executable,
str(PREPROCESS_SCRIPT),
"--dataset-type",
"gemini_synthetic",
"--index",
str(dataset_info["index_path"]),
"--output-dir",
str(dataset_info["preprocessed_dir"]),
"--checkpoint",
paths["audio_components"],
"--audio-only-ckpt",
paths["audio_components"],
"--gemma-root",
paths["gemma_root"],
"--max-duration",
str(float(dataset_info.get("max_duration", 20.0))),
"--min-duration",
str(float(dataset_info.get("min_duration", 2.0))),
"--batch-size",
str(max(1, int(batch_size))),
]
if skip_existing:
command.append("--skip-existing")
return command
def run_dramabox_preprocess(
dataset_info: Dict[str, Any],
shared_settings: Dict[str, Any],
*,
batch_size: int = 8,
progress_file: str = "",
node_id: str = "",
) -> Dict[str, Any]:
command = build_preprocess_command(
dataset_info,
shared_settings,
batch_size=batch_size,
skip_existing=True,
)
_run_process(
command,
cwd=VENDOR_ROOT,
env=_device_environment(shared_settings),
phase="preprocess",
progress_file=progress_file,
node_id=node_id,
)
validate_preprocessed_dataset(
dataset_info.get("records") or [],
dataset_info["preprocessed_dir"],
raise_on_missing=True,
)
dataset_info["preprocessed"] = True
return dataset_info
def _resolve_validation_config(value: str) -> str:
raw = os.path.expanduser(str(value or "").strip())
if not raw:
return ""
candidates = [Path(raw)]
if not os.path.isabs(raw):
candidates.extend(
(
Path(folder_paths.get_input_directory()) / raw,
VENDOR_ROOT / raw,
)
)
for candidate in candidates:
if candidate.is_file():
return str(candidate.resolve())
raise FileNotFoundError(f"DramaBox validation config not found: {value}")
def _validation_gpu(training_device: str, requested_gpu: Any) -> str:
value = str(requested_gpu or "").strip()
if not value:
raise ValueError(
"DramaBox validation_config requires validation_gpu because official validation "
"runs a second full model process. Reserve a GPU different from the training GPU."
)
if not value.isdigit():
raise ValueError("DramaBox validation_gpu must be a non-negative CUDA device index")
device = str(training_device or "auto").strip().lower()
training_gpu = device.split(":", 1)[1] if device.startswith("cuda:") else "0"
if value == training_gpu:
raise ValueError(
f"DramaBox validation_gpu ({value}) must differ from the training GPU ({training_gpu})"
)
return value
def _resolve_continue_lora(continue_from: Any) -> str:
if continue_from is None:
return ""
if isinstance(continue_from, str):
value = os.path.abspath(os.path.expanduser(continue_from.strip()))
elif isinstance(continue_from, dict):
if str(continue_from.get("engine_type", "") or "").strip().lower() not in {"", "dramabox"}:
raise ValueError("continue_from TRAINING_ARTIFACTS must come from a DramaBox training run")
value = str(
continue_from.get("lora_path")
or continue_from.get("model_path")
or (continue_from.get("lora_adapter") or {}).get("adapter_path", "")
).strip()
value = os.path.abspath(os.path.expanduser(value)) if value else ""
else:
raise ValueError("Unsupported DramaBox continue_from input")
if not value:
return ""
if os.path.isdir(value):
candidates = sorted(Path(value).glob("lora_step_*.safetensors"))
candidates += [Path(value) / "adapter_model.safetensors"]
for candidate in reversed(candidates):
if candidate.is_file():
return str(candidate)
raise FileNotFoundError(f"No DramaBox LoRA weights found in '{value}'")
if not os.path.isfile(value):
raise FileNotFoundError(f"DramaBox LoRA checkpoint not found: {value}")
return value
def _managed_lora_root() -> Path:
try:
from utils.models.extra_paths import get_all_tts_model_paths
for base_path in get_all_tts_model_paths("TTS"):
root = Path(base_path) / "dramabox" / "loras"
root.mkdir(parents=True, exist_ok=True)
return root
except Exception:
pass
root = Path(folder_paths.models_dir) / "TTS" / "dramabox" / "loras"
root.mkdir(parents=True, exist_ok=True)
return root
def _next_managed_lora_dir(name: str, *, overwrite: bool) -> Path:
target = _managed_lora_root() / slugify(name)
if overwrite or not target.exists():
return target
counter = 2
while True:
candidate = target.parent / f"{target.name}_{counter}"
if not candidate.exists():
return candidate
counter += 1
def _latest_lora_file(output_dir: Path) -> Optional[Path]:
candidates = sorted(
output_dir.glob("lora_step_*.safetensors"),
key=lambda path: int(re.search(r"(\d+)", path.stem).group(1))
if re.search(r"(\d+)", path.stem)
else -1,
)
if candidates:
return candidates[-1]
candidate = output_dir / "adapter_model.safetensors"
return candidate if candidate.is_file() else None
def _build_train_config(
dataset_info: Dict[str, Any],
shared_settings: Dict[str, Any],
training_config: Dict[str, Any],
*,
output_dir: Path,
continue_lora: str,
resolve_paths: bool = True,
) -> Dict[str, Any]:
if shared_settings.get("model_paths"):
paths = dict(shared_settings["model_paths"])
elif resolve_paths:
paths = _resolve_model_paths(shared_settings)
else:
paths = {
"transformer": "<dramabox-transformer.safetensors>",
"audio_components": "<dramabox-audio-components.safetensors>",
}
config: Dict[str, Any] = {
"data_dir": [str(dataset_info["preprocessed_dir"])],
"speaker_index": [str(dataset_info["index_path"])],
"output_dir": str(output_dir),
"checkpoint": paths["transformer"],
"full_checkpoint": paths["audio_components"],
"base_model": str(training_config.get("base_model", "dev")),
"lora_rank": int(training_config.get("lora_rank", 128)),
"lora_alpha": int(training_config.get("lora_alpha", 128)),
"lora_dropout": float(training_config.get("lora_dropout", 0.1)),
"ref_ratio": float(training_config.get("ref_ratio", 0.3)),
"max_ref_tokens": int(training_config.get("max_ref_tokens", 200)),
"text_dropout": float(training_config.get("text_dropout", 0.4)),
"steps": int(training_config.get("steps", 10000)),
"lr": float(training_config.get("learning_rate", 1e-4)),
"lr_scheduler": str(training_config.get("lr_scheduler", "cosine")),
"warmup_steps": int(training_config.get("warmup_steps", 500)),
"batch_size": int(training_config.get("batch_size", 1)),
"grad_accum": int(training_config.get("grad_accum", 4)),
"max_grad_norm": float(training_config.get("max_grad_norm", 1.0)),
"save_every": max(1, int(training_config.get("save_every", 500))),
"log_every": int(training_config.get("log_every", 10)),
"seed": int(training_config.get("seed", 42)),
}
if continue_lora:
config["resume_lora"] = continue_lora
validation_config = _resolve_validation_config(
training_config.get("validation_config", "")
)
if validation_config:
config["val_config"] = validation_config
return config
def _accelerate_command() -> list[str]:
executable = shutil.which("accelerate")
if executable:
return [executable, "launch", "--num_processes", "1"]
return [sys.executable, "-m", "accelerate.commands.launch", "--num_processes", "1"]
def run_dramabox_training_job(
shared_settings: Dict[str, Any],
dataset_info: Dict[str, Any],
training_config: Dict[str, Any],
*,
output_name: str = "",
resume: bool = False,
overwrite: bool = False,
continue_from: Any = None,
node_id: str = "",
) -> Dict[str, Any]:
if str(dataset_info.get("engine_type", "") or "").strip().lower() != "dramabox":
raise ValueError("DramaBox training requires a DramaBox TRAINING_DATASET payload")
if str(training_config.get("training_mode", "audio_lora") or "").strip().lower() != "audio_lora":
raise ValueError("DramaBox training currently supports audio_lora mode only")
if resume:
raise RuntimeError(
"DramaBox does not support exact optimizer-state resume. Use continue_from with a saved LoRA checkpoint for a warm start."
)
if str(shared_settings.get("device", "auto") or "auto").strip().lower().startswith("cpu") and not bool(
training_config.get("dry_run", False)
):
raise RuntimeError(
"DramaBox model training requires CUDA. Use dry_run for CPU-only validation; "
"no model weights or CUDA process will be started in that mode."
)
requested_validation = str(
training_config.get("validation_config", "") or ""
).strip()
if requested_validation:
_resolve_validation_config(requested_validation)
_validation_gpu(
shared_settings.get("device", "auto"),
training_config.get("validation_gpu", ""),
)
safe_name = slugify(output_name or dataset_info.get("model_name") or "dramabox_lora")
root = Path(get_dramabox_training_root()) / "jobs"
root.mkdir(parents=True, exist_ok=True)
fingerprint = f"{safe_name}|{dataset_info.get('index_path')}|{training_config}"
job_hash = __import__("hashlib").sha256(fingerprint.encode("utf-8")).hexdigest()[:12]
job_dir = root / f"{safe_name}_{job_hash}"
if job_dir.exists() and not overwrite:
job_dir = root / f"{safe_name}_{job_hash}_{int(time.time())}"
if overwrite and job_dir.exists():
shutil.rmtree(job_dir)
job_dir.mkdir(parents=True, exist_ok=True)
train_output_dir = job_dir / "lora"
progress_file = str(job_dir / "progress.json")
managed_dir = _next_managed_lora_dir(safe_name, overwrite=overwrite)
continue_lora = _resolve_continue_lora(continue_from)
register_training_job(
node_id,
engine_type="dramabox",
progress_file=progress_file,
job_dir=str(job_dir),
model_name=safe_name,
sample_rate="48k",
total_epochs=1,
)
try:
_write_progress(
progress_file,
status="starting",
phase="setup",
engine_type="dramabox",
model_name=safe_name,
dataset_records=int(dataset_info.get("train_records", 0)),
speakers=dataset_info.get("speakers", []),
started_at=time.time(),
)
if not bool(dataset_info.get("preprocessed")):
if bool(training_config.get("dry_run", False)):
print("🧪 DramaBox dry-run: skipping GPU dataset preprocessing")
else:
_write_progress(progress_file, status="running", phase="preprocess")
run_dramabox_preprocess(
dataset_info,
shared_settings,
batch_size=int(training_config.get("preprocess_batch_size", 8)),
progress_file=progress_file,
node_id=node_id,
)
train_config = _build_train_config(
dataset_info,
shared_settings,
training_config,
output_dir=train_output_dir,
continue_lora=continue_lora,
resolve_paths=not bool(training_config.get("dry_run", False)),
)
config_path = job_dir / "training_config.yaml"
import yaml
config_path.write_text(yaml.safe_dump(train_config, sort_keys=False), encoding="utf-8")
(job_dir / "resolved_training_config.json").write_text(
json.dumps(
{
"dataset": dataset_info,
"shared_settings": shared_settings,
"training_config": training_config,
"official_config": train_config,
"continue_from": continue_lora,
},
indent=2,
ensure_ascii=False,
default=str,
),
encoding="utf-8",
)
command = [*_accelerate_command(), str(TRAIN_SCRIPT), "--config", str(config_path)]
if bool(training_config.get("dry_run", False)):
summary = (
f"DramaBox dry-run ready: {safe_name} | {dataset_info.get('train_records', 0)} rows | "
f"official command prepared without loading CUDA or model weights"
)
_write_progress(
progress_file,
status="completed",
phase="dry_run",
overall_progress=1.0,
summary=summary,
command=command,
)
finalize_training_job(node_id, status="completed", summary=summary, dry_run=True)
return {
"type": "training_artifacts",
"engine_type": "dramabox",
"training_mode": "audio_lora",
"dry_run": True,
"job_dir": str(job_dir),
"training_config": str(config_path),
"summary": summary,
"command": command,
}
_write_progress(progress_file, status="running", phase="train", total_steps=int(train_config["steps"]))
train_env = _device_environment(shared_settings)
if train_config.get("val_config"):
paths = _resolve_model_paths(shared_settings)
train_env["LTX_CHECKPOINT"] = paths["transformer"]
train_env["LTX_FULL_CHECKPOINT"] = paths["audio_components"]
train_env["GEMMA_ROOT"] = paths["gemma_root"]
train_env["TRAIN_VAL_GPU"] = _validation_gpu(
shared_settings.get("device", "auto"),
training_config.get("validation_gpu", ""),
)
_run_process(
command,
cwd=VENDOR_ROOT,
env=train_env,
phase="train",
progress_file=progress_file,
node_id=node_id,
total_steps=int(train_config["steps"]),
)
selected_lora = _latest_lora_file(train_output_dir)
if selected_lora is None:
raise RuntimeError(
f"DramaBox training exited successfully but produced no LoRA file in '{train_output_dir}'."
)
if managed_dir.exists():
shutil.rmtree(managed_dir)
managed_dir.mkdir(parents=True, exist_ok=True)
managed_lora = managed_dir / selected_lora.name
shutil.copy2(selected_lora, managed_lora)
if selected_lora.name != "adapter_model.safetensors":
shutil.copy2(selected_lora, managed_dir / "adapter_model.safetensors")
adapter_config = train_output_dir / "adapter_config.json"
if adapter_config.is_file():
shutil.copy2(adapter_config, managed_dir / adapter_config.name)
shutil.copy2(config_path, managed_dir / "training_config.yaml")
summary = (
f"DramaBox audio LoRA training complete: {safe_name} | "
f"steps={train_config['steps']} | adapter={managed_lora}"
)
_write_progress(
progress_file,
status="completed",
phase="done",
overall_progress=1.0,
output_adapter=str(managed_lora),
output_dir=str(managed_dir),
summary=summary,
)
finalize_training_job(
node_id,
status="completed",
output_adapter=str(managed_lora),
summary=summary,
)
return {
"type": "training_artifacts",
"engine_type": "dramabox",
"training_mode": "audio_lora",
"model_path": str(managed_dir),
"lora_path": str(managed_lora),
"job_dir": str(job_dir),
"summary": summary,
"lora_adapter": {
"type": "dramabox_lora",
"adapter_path": str(managed_lora),
"adapter_dir": str(managed_dir),
},
}
except InterruptedError as error:
_write_progress(progress_file, status="cancelled", phase="cancelled", error=str(error))
finalize_training_job(node_id, status="cancelled", error=str(error))
raise
except Exception as error:
_write_progress(progress_file, status="error", phase="error", error=str(error))
finalize_training_job(node_id, status="error", error=str(error))
raise
__all__ = [
"build_preprocess_command",
"run_dramabox_preprocess",
"run_dramabox_training_job",
]
+25 -2
View File
@@ -1,4 +1,4 @@
# Bundled DramaBox inference source
# Bundled DramaBox inference and training source
This directory contains the inference-critical source copied unchanged from:
@@ -15,4 +15,27 @@ The bundled-code changes are marked inline:
- `src/inference_server.py`: ComfyUI cancellation exceptions are allowed to
propagate from progress callbacks instead of being swallowed; the official
negative-prompt, FP8-cast, compile, and staged-memory controls are exposed to
the suite wrapper.
the suite wrapper; suite-managed PEFT LoRA loading is added for trained
DramaBox audio adapters.
- `src/validate.py`: validation accepts the suite's separately organized
DramaBox transformer and audio-components checkpoints.
- `src/preprocess.py`: suite-distributed pre-quantized Gemma checkpoints use
the same bitsandbytes-aware prompt-encoder loader as DramaBox inference.
- `src/train.py`: the batch collator lives at module scope so Windows
spawn-based DataLoader workers can serialize it; lightweight per-step
telemetry keeps the suite's training dashboard current between normal logs.
- `ltx2/ltx_core/text_encoders/gemma/encoders/encoder_configurator.py`:
supports both wrapped and direct SigLIP vision-tower layouts for the suite's
newer Transformers runtime.
The official training entry points are also bundled at this pin:
- `src/preprocess.py`
- `src/train.py`
- `src/validate.py`
- `configs/training_args.example.yaml`
- `configs/val_config.example.yaml`
The suite invokes these scripts through the unified training backend. Apart
from the documented compatibility patches, training behavior stays upstream;
dataset normalization, job lifecycle, and UI wiring remain suite-side.
@@ -0,0 +1,63 @@
# DramaBox IC-LoRA training config — values become the defaults for
# `accelerate launch src/train.py --config configs/training_args.example.yaml`.
# Any flag explicitly passed on the CLI overrides the YAML.
# ── Data ───────────────────────────────────────────────────────────────────
# One entry per preprocessed dataset (output dirs from src/preprocess.py).
data_dir:
- /path/to/preprocessed_dataset_a/
- /path/to/preprocessed_dataset_b/
# One index file per data_dir entry. Each line follows the format you fed to
# preprocess.py — see README "Prepare your index file".
speaker_index:
- /path/to/preprocessed_dataset_a/index.txt
- /path/to/preprocessed_dataset_b/index.txt
# Output directory for LoRA shards + logs (relative paths resolve against the
# repo root).
output_dir: tts_iclora_v1
# ── Base model ─────────────────────────────────────────────────────────────
# Train your LoRA on top of DramaBox itself (recommended) — the trimmed audio
# components are enough; no need to ship the raw LTX-2.3 base.
checkpoint: dramabox-dit-v1.safetensors
full_checkpoint: dramabox-audio-components.safetensors
base_model: dev # 'dev' = ShiftedLogitNormal sampler; 'distilled' = DistilledTimestepSampler
# ── LoRA hyperparams (rank == alpha → scale = 1.0) ─────────────────────────
lora_rank: 128
lora_alpha: 128
lora_dropout: 0.1 # ~0.1 helps regularize on small datasets
# Resume an existing LoRA — step number parsed from the filename
# (e.g. lora_step_05000.safetensors → starts at step 5000).
# resume_lora: tts_iclora_v0/lora_step_05000.safetensors
# ── Voice-cloning reference tokens ─────────────────────────────────────────
ref_ratio: 0.3 # fraction of training samples that get a ref-token tail
max_ref_tokens: 200 # cap on appended ref tokens after patchification
# CFG training: probability of zeroing the text condition (forces reliance on
# the voice ref / unconditional path).
text_dropout: 0.4
# ── Schedule ───────────────────────────────────────────────────────────────
# Cosine + 1e-4 = from-scratch fine-tune.
# Constant + 1e-5 = polish on top of an existing LoRA (use with `resume_lora`).
steps: 10000
lr: 1.0e-04
lr_scheduler: cosine
warmup_steps: 500
batch_size: 1
grad_accum: 4
max_grad_norm: 1.0
save_every: 500
log_every: 50
seed: 53
# Optional per-save-step validation pass. Generates a sample for every speaker
# in the val_config so you can A/B listen during training.
# val_config: configs/val_config.example.yaml
+25
View File
@@ -0,0 +1,25 @@
# Validation prompts run by src/validate.py at every --save-every checkpoint.
# Each entry produces one .wav under <output_dir>/val_step_<N>/<name>.wav.
#
# Fields:
# name — short tag used as the output filename
# prompt — full DramaBox-style scene prompt
# reference — (optional) absolute path to a 10+ s voice reference clip;
# omit for prompt-only generation
speakers:
- name: villain_growl
prompt: 'A shadowy villain speaks with cold menace, "You have entered my domain, mortal." He chuckles darkly, "Such arrogance will be your undoing."'
reference: /path/to/voice_refs/male_villain.wav
- name: tender_whisper
prompt: 'A woman speaks tenderly, "It has been a long day, my love." She whispers, "Close your eyes. I am right here."'
reference: /path/to/voice_refs/female_warm.wav
- name: catgirl_giggle
prompt: 'A playful girl already mid-giggle, "Hehehe, oh my gosh you should see your face!" She gasps, "Oh my, hehe, I cannot stop!"'
# No `reference:` here — pure prompt-driven generation.
- name: announcer_smug
prompt: 'A confident announcer speaks proudly, "And now, the moment you have all been waiting for." He chuckles knowingly, "Heheh."'
reference: /path/to/voice_refs/male_announcer.wav
@@ -154,22 +154,48 @@ VIDEO_ONLY_EMBEDDINGS_PROCESSOR_KEY_OPS = (
def create_and_populate(module: GemmaTextEncoder) -> GemmaTextEncoder:
model = module.model
v_model = model.model.vision_tower.vision_model
vision_tower = model.model.vision_tower
# TTS Audio Suite patch: Transformers 5 exposes SiglipVisionModel
# directly, while the upstream-pinned layout wraps it in `.vision_model`.
v_model = getattr(vision_tower, "vision_model", vision_tower)
l_model = model.model.language_model
config = model.config.text_config
dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)
base = config.rope_local_base_freq
if hasattr(config, "rope_local_base_freq"):
base = config.rope_local_base_freq
rope_type = config.rope_scaling["rope_type"]
rope_kwargs = {}
else:
# TTS Audio Suite patch: Transformers 5 migrates Gemma 3's local and
# full-attention RoPE settings into named `rope_parameters` entries.
rope_parameters = config.rope_parameters
base = rope_parameters["sliding_attention"]["rope_theta"]
rope_type = rope_parameters["full_attention"]["rope_type"]
rope_kwargs = {"layer_type": "full_attention"}
local_rope_freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.int64).to(dtype=torch.float) / dim))
inv_freqs, _ = ROPE_INIT_FUNCTIONS[config.rope_scaling["rope_type"]](config)
inv_freqs, _ = ROPE_INIT_FUNCTIONS[rope_type](config, **rope_kwargs)
positions_length = len(v_model.embeddings.position_ids[0])
position_ids = torch.arange(positions_length, dtype=torch.long, device="cpu").unsqueeze(0)
v_model.embeddings.register_buffer("position_ids", position_ids)
embed_scale = torch.tensor(model.config.text_config.hidden_size**0.5, device="cpu")
l_model.embed_tokens.register_buffer("embed_scale", embed_scale)
l_model.rotary_emb_local.register_buffer("inv_freq", local_rope_freqs)
l_model.rotary_emb.register_buffer("inv_freq", inv_freqs)
if hasattr(l_model, "rotary_emb_local"):
l_model.rotary_emb_local.register_buffer("inv_freq", local_rope_freqs)
l_model.rotary_emb.register_buffer("inv_freq", inv_freqs)
else:
# TTS Audio Suite patch: Transformers 5 consolidates both attention
# variants into one rotary module with separately named buffers.
rotary_emb = l_model.rotary_emb
rotary_emb.register_buffer("sliding_attention_inv_freq", local_rope_freqs)
rotary_emb.register_buffer(
"sliding_attention_original_inv_freq", local_rope_freqs.clone()
)
rotary_emb.register_buffer("full_attention_inv_freq", inv_freqs)
rotary_emb.register_buffer(
"full_attention_original_inv_freq", inv_freqs.clone()
)
return module
+266 -1
View File
@@ -125,7 +125,8 @@ def auto_rescale_for_cfg(cfg: float) -> float:
class TTSServer:
def __init__(self, checkpoint=None, full_checkpoint=None, gemma_root=None,
device="cuda", dtype="bf16", compile_model=True, bnb_4bit=True,
memory_mode="fast", transformer_quantization="none"):
memory_mode="fast", transformer_quantization="none",
lora_path="", lora_strength=1.0):
MODELS = APP_DIR / "models"
self.checkpoint = checkpoint or str(MODELS / "ltx-2.3-22b-dev-audio-only-v13-merged.safetensors")
self.full_checkpoint = full_checkpoint or os.environ.get(
@@ -140,6 +141,14 @@ class TTSServer:
self.bnb_4bit = bnb_4bit
self.memory_mode = str(memory_mode)
self.transformer_quantization = str(transformer_quantization)
# TTS Audio Suite patch: accept a trained DramaBox audio LoRA at
# runtime so training artifacts can be used without a second CLI.
self.lora_path = str(lora_path or "").strip()
self.lora_strength = float(lora_strength)
self._active_lora_revision = ""
self._active_lora_file = ""
self._applied_lora_strength = 0.0
self._unmerged_lora_weight_scale = 1.0
if self.memory_mode not in {"fast", "staged", "sequential"}:
raise ValueError(f"Unknown DramaBox memory mode: {self.memory_mode}")
if self.transformer_quantization not in {"none", "fp8_cast"}:
@@ -262,6 +271,8 @@ class TTSServer:
self._velocity_model = builder.build(
device=self.device, dtype=build_dtype
).to(self.device).eval()
if self.lora_path and self.lora_strength != 0.0:
self.configure_lora(self.lora_path, self.lora_strength)
n_params = sum(p.numel() for p in self._velocity_model.parameters()) / 1e9
vram_gb = sum(p.numel() * p.element_size() for p in self._velocity_model.parameters()) / 1e9
logging.info(f" Transformer: {time.time()-t0:.1f}s ({n_params:.1f}B params, {vram_gb:.1f}GB VRAM, {self.dtype})")
@@ -287,6 +298,260 @@ class TTSServer:
)
logging.info(f" AudioDecoder (warm): {time.time()-t0:.1f}s")
@staticmethod
def _resolve_lora_file(lora_path: str) -> Path:
path = Path(os.path.expanduser(str(lora_path or "").strip()))
if path.is_dir():
candidates = sorted(path.glob("lora_step_*.safetensors"))
candidates += [path / "adapter_model.safetensors"]
for candidate in reversed(candidates):
if candidate.is_file():
return candidate
if path.is_file():
return path
raise FileNotFoundError(f"DramaBox LoRA file not found: {lora_path}")
# TTS Audio Suite patch: keep PEFT state attached and reversibly merge it
# so strength changes avoid both a base reload and per-step LoRA matmuls.
@staticmethod
def _set_lora_strength(model, strength: float) -> None:
"""Re-merge the live adapter at a new strength without reloading the base."""
try:
from peft.tuners.lora.layer import LoraLayer
except ImportError as exc:
raise RuntimeError("DramaBox LoRA inference requires peft.") from exc
updated = 0
if any(
isinstance(module, LoraLayer) and bool(module.merged)
for module in model.modules()
):
model.unmerge_adapter()
for module in model.modules():
if isinstance(module, LoraLayer) and "default" in module.lora_A:
module.set_scale("default", float(strength))
updated += 1
if updated <= 0:
raise RuntimeError("DramaBox LoRA modules are missing from the live model.")
if hasattr(model, "set_adapter"):
model.set_adapter("default")
if float(strength) == 0.0:
model.disable_adapter_layers()
else:
model.enable_adapter_layers()
model.merge_adapter(adapter_names=["default"])
@staticmethod
def _set_unmerged_lora_strength(
model, strength: float, current_weight_scale: float
) -> float:
"""Scale a BF16 PEFT branch over an immutable FP8 base in place."""
try:
from peft.tuners.lora.layer import LoraLayer
except ImportError as exc:
raise RuntimeError("DramaBox LoRA inference requires peft.") from exc
if float(strength) == 0.0:
model.disable_adapter_layers()
return float(current_weight_scale)
model.enable_adapter_layers()
if hasattr(model, "set_adapter"):
model.set_adapter("default")
ratio = float(strength) / float(current_weight_scale)
updated = 0
for module in model.modules():
if isinstance(module, LoraLayer) and "default" in module.lora_A:
if ratio != 1.0:
with torch.no_grad():
module.lora_B["default"].weight.mul_(ratio)
updated += 1
if updated <= 0:
raise RuntimeError("DramaBox LoRA modules are missing from the live model.")
return float(strength)
def _prepare_unmerged_lora(self, model) -> None:
"""Keep PEFT matrices in the activation dtype used above FP8 storage."""
from peft.tuners.lora.layer import LoraLayer
for module in model.modules():
if isinstance(module, LoraLayer) and "default" in module.lora_A:
module.lora_A["default"].to(device=self.device, dtype=self.dtype)
module.lora_B["default"].to(device=self.device, dtype=self.dtype)
# TTS Audio Suite patch: replace only the live adapter modules while
# preserving the already-loaded official DramaBox transformer weights.
@classmethod
def _attach_lora(cls, model, lora_path: str, strength: float):
"""Attach or replace the official PEFT-compatible audio LoRA in place."""
try:
from peft import LoraConfig, PeftModel, get_peft_model
from safetensors.torch import load_file
except ImportError as exc:
raise RuntimeError(
"DramaBox LoRA inference requires peft and safetensors."
) from exc
lora_file = cls._resolve_lora_file(lora_path)
adapter_config_path = lora_file.parent / "adapter_config.json"
rank = 128
alpha = 128
if adapter_config_path.is_file():
try:
metadata = json.loads(adapter_config_path.read_text(encoding="utf-8"))
rank = int(metadata.get("r", rank))
alpha = int(metadata.get("lora_alpha", alpha))
except Exception as exc:
logging.warning("Could not read DramaBox LoRA adapter_config.json: %s", exc)
lora_state = load_file(str(lora_file))
# Standalone upstream checkpoints may omit adapter_config.json. Infer
# the rank from the first LoRA-A tensor so those files remain usable.
if not adapter_config_path.is_file():
for key, value in lora_state.items():
if ".lora_A." in key or key.endswith(".lora_A.weight"):
rank = int(value.shape[0])
alpha = rank
break
lora_config = LoraConfig(
r=rank,
lora_alpha=alpha,
lora_dropout=0.0,
bias="none",
target_modules=[
"audio_attn1.to_k",
"audio_attn1.to_q",
"audio_attn1.to_v",
"audio_attn1.to_out.0",
"audio_attn2.to_k",
"audio_attn2.to_q",
"audio_attn2.to_v",
"audio_attn2.to_out.0",
"audio_ff.net.0.proj",
"audio_ff.net.2",
],
)
if isinstance(model, PeftModel):
if any(
hasattr(module, "merged") and bool(module.merged)
for module in model.modules()
):
model.unmerge_adapter()
if "default" in model.peft_config:
model.delete_adapter("default")
model.add_adapter("default", lora_config)
adapted = model
else:
adapted = get_peft_model(model, lora_config)
mapped = {}
is_peft_format = any("base_model.model." in key for key in lora_state)
is_original_format = any("diffusion_model." in key for key in lora_state)
compiled_blocks = any("._orig_mod." in key for key in adapted.state_dict())
for key, value in lora_state.items():
if is_peft_format:
new_key = key
elif is_original_format:
new_key = key.replace("diffusion_model.", "base_model.model.")
else:
continue
new_key = new_key.replace(".lora_A.weight", ".lora_A.default.weight")
new_key = new_key.replace(".lora_B.weight", ".lora_B.default.weight")
if compiled_blocks and "._orig_mod." not in new_key:
# TTS Audio Suite patch: torch.compile wraps every official
# transformer block in OptimizedModule and inserts `_orig_mod`
# into its state-dict path before PEFT attaches the adapter.
new_key = re.sub(
r"(transformer_blocks\.\d+)\.",
r"\1._orig_mod.",
new_key,
count=1,
)
mapped[new_key] = value
if not mapped:
raise RuntimeError(
f"DramaBox LoRA '{lora_file}' is not in a recognized PEFT/ID-LoRA format."
)
missing, unexpected = adapted.load_state_dict(mapped, strict=False)
loaded = len(mapped) - len(unexpected)
if loaded <= 0:
raise RuntimeError(
f"DramaBox LoRA '{lora_file}' did not match the audio transformer modules."
)
logging.info(
"DramaBox LoRA loaded: %s (%d tensors, strength %.2f)",
lora_file,
loaded,
float(strength),
)
return adapted.eval(), str(lora_file.resolve())
# TTS Audio Suite patch: split mutable adapter identity from the expensive
# base-model cache identity used by the suite's ComfyUI model wrapper.
def configure_lora(self, lora_path: str, strength: float, revision: str = "") -> None:
"""Hot-swap a DramaBox adapter or update only its runtime strength."""
path = str(lora_path or "").strip()
strength = float(strength)
revision = str(revision or "")
use_unmerged_fp8 = self.transformer_quantization == "fp8_cast"
if not path:
if self._active_lora_file:
if use_unmerged_fp8:
self._unmerged_lora_weight_scale = self._set_unmerged_lora_strength(
self._velocity_model, 0.0, self._unmerged_lora_weight_scale
)
else:
self._set_lora_strength(self._velocity_model, 0.0)
logging.info("DramaBox LoRA disabled without reloading the base model")
self.lora_path = ""
self.lora_strength = strength
self._applied_lora_strength = 0.0
return
lora_file = str(self._resolve_lora_file(path).resolve())
same_adapter = (
lora_file == self._active_lora_file
and revision == self._active_lora_revision
)
if same_adapter:
if strength != self._applied_lora_strength:
if use_unmerged_fp8:
self._unmerged_lora_weight_scale = self._set_unmerged_lora_strength(
self._velocity_model,
strength,
self._unmerged_lora_weight_scale,
)
else:
self._set_lora_strength(self._velocity_model, strength)
logging.info(
"DramaBox LoRA strength updated in place: %.2f", strength
)
else:
self._velocity_model, lora_file = self._attach_lora(
self._velocity_model, path, strength
)
self._active_lora_file = lora_file
self._active_lora_revision = revision
if use_unmerged_fp8:
self._prepare_unmerged_lora(self._velocity_model)
self._unmerged_lora_weight_scale = 1.0
self._unmerged_lora_weight_scale = self._set_unmerged_lora_strength(
self._velocity_model,
strength,
self._unmerged_lora_weight_scale,
)
logging.info(
"DramaBox FP8 base: using an unmerged BF16 LoRA branch"
)
else:
self._set_lora_strength(self._velocity_model, strength)
self.lora_path = path
self.lora_strength = strength
self._applied_lora_strength = strength
def _move_velocity_model(self, target: torch.device) -> None:
"""Move the persistent DiT between CUDA and RAM for staged inference."""
target = torch.device(target)
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
Preprocess TTS datasets for LTX-2.3 audio-only LoRA fine-tuning.
Takes paired (audio, transcript) data and produces the format expected by
the LTX trainer:
.precomputed/
├── latents/sample_N.pt # Dummy video latents (minimal)
├── conditions/sample_N.pt # Text embeddings from Gemma
└── audio_latents/sample_N.pt # Audio VAE-encoded latents
Supports multiple dataset formats:
- gemini_synthetic: index.txt with ~-separated fields (id~speaker~lang~sr~samples~dur~phonemes~text)
- libriheavy: index_ft.txt with ~-separated fields (id~speaker~lang~samples~dur~phonemes~text)
- manifest: JSON/JSONL with {"audio_filepath": ..., "text": ...}
- tsv: TSV file with audio_path<TAB>text columns
Usage:
python preprocess_tts_data.py \
--dataset-type gemini_synthetic \
--index /path/to/dataset/index.txt \
--audio-dir /path/to/dataset/wavs \
--output-dir /path/to/output/tts_training_data \
--max-samples 10000 \
--max-duration 20.0 \
--min-duration 3.0
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
import torch
import torchaudio
REPO_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ltx2"))
# ltx-pipelines on path via ltx2/
MODEL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEMMA_DIR = os.environ.get("GEMMA_DIR", "gemma-3-12b-it-qat-q4_0-unquantized")
def parse_args():
p = argparse.ArgumentParser(description="Preprocess TTS data for LTX-2.3 fine-tuning")
p.add_argument("--dataset-type", required=True,
choices=["gemini_synthetic", "libriheavy", "manifest", "tsv"],
help="Dataset format type")
p.add_argument("--index", required=True, help="Path to index/manifest file")
p.add_argument("--audio-dir", default=None,
help="Base directory for audio files (if paths in index are relative)")
p.add_argument("--output-dir", required=True, help="Output directory for preprocessed data")
p.add_argument("--checkpoint", default=os.path.join(MODEL_DIR, "ltx-2.3-22b-distilled.safetensors"))
p.add_argument("--gemma-root", default=GEMMA_DIR)
p.add_argument("--max-samples", type=int, default=0, help="Max samples to process (0=all)")
p.add_argument("--max-duration", type=float, default=20.0, help="Max audio duration in seconds")
p.add_argument("--min-duration", type=float, default=2.0, help="Min audio duration in seconds")
p.add_argument("--batch-size", type=int, default=8, help="Batch size for text encoding")
p.add_argument("--skip-existing", action="store_true", help="Skip already processed samples")
p.add_argument("--audio-only-ckpt", default=None,
help="Audio-only checkpoint for VAE encoding (optional, uses full ckpt if not set)")
p.add_argument("--shard", type=int, default=0, help="Shard index (for parallel processing)")
p.add_argument("--num-shards", type=int, default=1, help="Total number of shards")
p.add_argument("--gpu", type=int, default=None, help="GPU device index to use")
return p.parse_args()
def parse_gemini_synthetic(index_path: str, audio_dir: str | None) -> list[dict]:
"""Parse gemini_synthetic format: id~speaker~lang~sr~samples~dur~phonemes~text"""
samples = []
with open(index_path) as f:
for line in f:
parts = line.strip().split("~")
if len(parts) < 7:
continue
file_id = parts[0]
text = parts[-1] # Last field is always the text
sr = int(parts[3])
n_samples = int(parts[4])
duration = n_samples / sr
# Find audio file
if audio_dir:
# Try common extensions
for ext in [".flac", ".wav", ".mp3"]:
audio_path = os.path.join(audio_dir, file_id + ext)
if os.path.exists(audio_path):
break
else:
continue
else:
audio_path = file_id
samples.append({
"id": file_id,
"audio_path": audio_path,
"text": text,
"duration": duration,
})
return samples
def parse_libriheavy(index_path: str, audio_dir: str | None) -> list[dict]:
"""Parse libriheavy format: id~speaker~lang~samples~dur~phonemes~text"""
samples = []
with open(index_path) as f:
for line in f:
parts = line.strip().split("~")
if len(parts) < 7:
continue
file_id = parts[0]
text = parts[-1]
n_samples = int(parts[3])
duration = int(parts[4]) / 1000.0 # milliseconds to seconds
if audio_dir:
for ext in [".flac", ".wav", ".mp3"]:
audio_path = os.path.join(audio_dir, file_id + ext)
if os.path.exists(audio_path):
break
else:
continue
else:
audio_path = file_id
samples.append({
"id": file_id,
"audio_path": audio_path,
"text": text,
"duration": duration,
})
return samples
def parse_manifest(index_path: str, audio_dir: str | None) -> list[dict]:
"""Parse JSON/JSONL manifest with audio_filepath and text fields."""
samples = []
with open(index_path) as f:
for line in f:
entry = json.loads(line.strip())
audio_path = entry.get("audio_filepath", entry.get("audio_path", ""))
text = entry.get("text", entry.get("transcript", ""))
duration = entry.get("duration", 0.0)
if audio_dir and not os.path.isabs(audio_path):
audio_path = os.path.join(audio_dir, audio_path)
if os.path.exists(audio_path) and text:
samples.append({
"id": Path(audio_path).stem,
"audio_path": audio_path,
"text": text,
"duration": duration,
})
return samples
def parse_tsv(index_path: str, audio_dir: str | None) -> list[dict]:
"""Parse TSV file with audio_path<TAB>text."""
samples = []
with open(index_path) as f:
for line in f:
parts = line.strip().split("\t")
if len(parts) < 2:
continue
audio_path, text = parts[0], parts[1]
if audio_dir and not os.path.isabs(audio_path):
audio_path = os.path.join(audio_dir, audio_path)
if os.path.exists(audio_path):
samples.append({
"id": Path(audio_path).stem,
"audio_path": audio_path,
"text": text,
"duration": 0.0,
})
return samples
PARSERS = {
"gemini_synthetic": parse_gemini_synthetic,
"libriheavy": parse_libriheavy,
"manifest": parse_manifest,
"tsv": parse_tsv,
}
@torch.inference_mode()
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args = parse_args()
from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
from ltx_core.types import Audio
from ltx_pipelines.utils.blocks import AudioConditioner, PromptEncoder
from ltx_pipelines.utils.media_io import decode_audio_from_file
from ltx_trainer.model_loader import load_text_encoder, load_embeddings_processor
if args.gpu is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dtype = torch.bfloat16
# Create output directories
out = Path(args.output_dir)
(out / "latents").mkdir(parents=True, exist_ok=True)
(out / "conditions").mkdir(parents=True, exist_ok=True)
(out / "audio_latents").mkdir(parents=True, exist_ok=True)
# Parse dataset
logging.info(f"Parsing {args.dataset_type} dataset from {args.index}...")
samples = PARSERS[args.dataset_type](args.index, args.audio_dir)
logging.info(f"Found {len(samples)} samples")
# Filter by duration
before = len(samples)
samples = [s for s in samples if args.min_duration <= s["duration"] <= args.max_duration]
logging.info(f"After duration filter [{args.min_duration}s, {args.max_duration}s]: {len(samples)} (dropped {before - len(samples)})")
if args.max_samples > 0:
samples = samples[:args.max_samples]
logging.info(f"Limiting to {len(samples)} samples")
# Assign global indices before sharding
for i, s in enumerate(samples):
s["global_idx"] = i
# Shard the data for parallel processing
if args.num_shards > 1:
total = len(samples)
samples = samples[args.shard::args.num_shards]
logging.info(f"Shard {args.shard}/{args.num_shards}: {len(samples)} samples (of {total} total)")
# ── Step 1: Encode text with Gemma (Blocks 1+2 only) ──
# The trainer runs Block 3 (embeddings processor/connectors) during training,
# so we only precompute Blocks 1+2 here (Gemma LLM + feature extractor).
logging.info("Loading text encoder (Gemma + feature extractor)...")
gemma_config_path = Path(args.gemma_root) / "config.json"
gemma_config = {}
if gemma_config_path.is_file():
try:
gemma_config = json.loads(gemma_config_path.read_text(encoding="utf-8"))
except Exception:
pass
prompt_encoder = None
if "quantization_config" in gemma_config:
# TTS Audio Suite patch: the suite distributes a pre-quantized BNB
# Gemma checkpoint. The official tensor builder treats its packed
# weights as dense matrices, producing thousands of shape mismatches.
# Reuse the inference loader that already understands this format.
logging.info("Loading pre-quantized Gemma through the BNB prompt encoder...")
prompt_encoder = PromptEncoder(
checkpoint_path=args.checkpoint,
gemma_root=args.gemma_root,
dtype=dtype,
device=device,
warm=True,
use_bnb_4bit=True,
audio_only=True,
)
text_encoder = prompt_encoder._warm_text_encoder
embeddings_processor = prompt_encoder._warm_embeddings_processor
text_encoder.feature_extractor = embeddings_processor.feature_extractor
prompt_encoder._warm_text_encoder = None
prompt_encoder._warm_embeddings_processor = None
else:
text_encoder = load_text_encoder(args.gemma_root, device=device, dtype=dtype)
# Load feature extractor on CPU first to save GPU memory, then move to device
logging.info("Loading feature extractor (on CPU first to save GPU memory)...")
emb_proc = load_embeddings_processor(args.checkpoint, device="cpu", dtype=dtype)
text_encoder.feature_extractor = emb_proc.feature_extractor.to(device)
del emb_proc
torch.cuda.empty_cache()
logging.info("Encoding text prompts (Blocks 1+2: Gemma + feature extractor)...")
for i, sample in enumerate(samples):
gidx = sample["global_idx"]
cond_path = out / "conditions" / f"sample_{gidx:06d}.pt"
if args.skip_existing and cond_path.exists():
continue
text = sample["text"]
# Run Blocks 1+2: Gemma LLM → feature extractor
hidden_states, attention_mask = text_encoder.encode(text)
video_feats, audio_feats = text_encoder.feature_extractor(
hidden_states, attention_mask, "left"
)
torch.save({
"video_prompt_embeds": video_feats.squeeze(0).cpu(),
"audio_prompt_embeds": audio_feats.squeeze(0).cpu() if audio_feats is not None else video_feats.squeeze(0).cpu(),
"prompt_attention_mask": attention_mask.squeeze(0).bool().cpu(),
}, cond_path)
if i % 100 == 0:
logging.info(f" Text encoding: {i}/{len(samples)}")
del text_encoder
if prompt_encoder is not None:
del embeddings_processor
del prompt_encoder
torch.cuda.empty_cache()
# ── Step 2: Encode audio with Audio VAE ──
ckpt_for_vae = args.audio_only_ckpt or args.checkpoint
logging.info(f"Loading audio VAE from {ckpt_for_vae}...")
ac = AudioConditioner(checkpoint_path=ckpt_for_vae, dtype=dtype, device=device)
logging.info("Encoding audio samples...")
for idx, sample in enumerate(samples):
gidx = sample["global_idx"]
audio_path = out / "audio_latents" / f"sample_{gidx:06d}.pt"
if args.skip_existing and audio_path.exists():
continue
try:
# Load audio
voice = decode_audio_from_file(sample["audio_path"], device, 0.0, args.max_duration)
if voice is None:
logging.warning(f" Skipping {sample['id']}: no audio")
continue
w = voice.waveform
if w.dim() == 2:
if w.shape[0] == 1:
w = w.repeat(2, 1)
w = w.unsqueeze(0)
elif w.dim() == 3 and w.shape[1] == 1:
w = w.repeat(1, 2, 1)
voice = Audio(waveform=w, sampling_rate=voice.sampling_rate)
# Encode through Audio VAE
audio_latent = ac(lambda enc: vae_encode_audio(voice, enc, None))
# Save audio latent
torch.save({
"latents": audio_latent.squeeze(0).cpu(), # [C=8, T, F=16]
"sample_rate": 16000,
}, audio_path)
except Exception as e:
logging.warning(f" Skipping {sample['id']}: {e}")
continue
if idx % 100 == 0:
logging.info(f" Audio encoding: {idx}/{len(samples)}")
del ac
torch.cuda.empty_cache()
# ── Step 3: Create dummy video latents ──
logging.info("Creating dummy video latents...")
# Minimal video: 1 frame, 64x64 = 2x2 in latent space
dummy_video = {
"latents": torch.zeros(128, 1, 2, 2),
"num_frames": 1,
"height": 2,
"width": 2,
"fps": 24.0,
}
for idx, sample in enumerate(samples):
gidx = sample["global_idx"]
latent_path = out / "latents" / f"sample_{gidx:06d}.pt"
if args.skip_existing and latent_path.exists():
continue
torch.save(dummy_video, latent_path)
# ── Summary ──
n_audio = len(list((out / "audio_latents").glob("*.pt")))
n_cond = len(list((out / "conditions").glob("*.pt")))
n_lat = len(list((out / "latents").glob("*.pt")))
logging.info(f"\nDone! Output: {args.output_dir}")
logging.info(f" audio_latents: {n_audio} files")
logging.info(f" conditions: {n_cond} files")
logging.info(f" latents: {n_lat} files")
if __name__ == "__main__":
main()
+900
View File
@@ -0,0 +1,900 @@
#!/usr/bin/env python3
"""
Audio-Only IC-LoRA Training for Voice Cloning on LTX-2.3.
Uses the IC-LoRA pattern: reference audio tokens are APPENDED to the end of
the target sequence using AudioConditionByReferenceLatent. Loss is computed
only on target tokens; reference tokens remain clean (denoise_mask=0).
This follows the official video-to-video IC-LoRA strategy closely, but adapted
for the audio-only modality path.
Usage (single GPU):
CUDA_VISIBLE_DEVICES=0 python train_audio_iclora.py --data-dir ... --speaker-index ...
Usage (multi-GPU with accelerate):
CUDA_VISIBLE_DEVICES=4,5,6,7 accelerate launch --num_processes=4 train_audio_iclora.py ...
"""
import argparse
import logging
import math
import os
import random
import shutil
import sys
import time
from collections import defaultdict
from pathlib import Path
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
REPO_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ltx2"))
# ltx-pipelines already on path via ltx2/
MODEL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Import audio conditioning item from our module
sys.path.insert(0, MODEL_DIR)
from audio_conditioning import AudioConditionByReferenceLatent
# ─── Timestep Sampling ───
class DistilledTimestepSampler:
"""Sample timesteps from the distilled sigma schedule.
The distilled model was trained to denoise at these specific sigma values.
We sample uniformly from the intervals between consecutive sigmas,
matching the distribution the model actually operates on.
"""
# Distilled 8-step sigma values (boundaries of denoising intervals)
SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0]
def __init__(self, jitter: float = 0.02):
self.jitter = jitter
def sample(self, batch_size: int, seq_length: int = None, device: torch.device = None) -> torch.Tensor:
n_intervals = len(self.SIGMAS) - 1
interval_idx = torch.randint(0, n_intervals, (batch_size,), device=device)
t = torch.rand(batch_size, device=device)
sigma_high = torch.tensor([self.SIGMAS[i] for i in interval_idx], device=device)
sigma_low = torch.tensor([self.SIGMAS[i + 1] for i in interval_idx], device=device)
sigma = sigma_low + t * (sigma_high - sigma_low)
return sigma.clamp(0.01, 0.99)
class ShiftedLogitNormalTimestepSampler:
"""Shifted logit-normal distribution, shift depends on sequence length."""
def __init__(self, std: float = 1.0, eps: float = 1e-3, uniform_prob: float = 0.1):
self.std = std
self.eps = eps
self.uniform_prob = uniform_prob
self.normal_999_percentile = 3.0902 * std
self.normal_005_percentile = -2.5758 * std
def sample(self, batch_size: int, seq_length: int, device: torch.device = None) -> torch.Tensor:
mu = self._get_shift(seq_length)
normal = torch.randn(batch_size, device=device) * self.std + mu
logitnormal = torch.sigmoid(normal)
p999 = torch.sigmoid(torch.tensor(mu + self.normal_999_percentile, device=device))
p005 = torch.sigmoid(torch.tensor(mu + self.normal_005_percentile, device=device))
stretched = (logitnormal - p005) / (p999 - p005)
stretched = torch.where(stretched >= self.eps, stretched, 2 * self.eps - stretched)
stretched = stretched.clamp(0, 1)
uniform = (1 - self.eps) * torch.rand(batch_size, device=device) + self.eps
prob = torch.rand(batch_size, device=device)
return torch.where(prob > self.uniform_prob, stretched, uniform)
@staticmethod
def _get_shift(seq_length, min_tok=1024, max_tok=4096, min_s=0.95, max_s=2.05):
m = (max_s - min_s) / (max_tok - min_tok)
return m * seq_length + (min_s - m * min_tok)
# ─── Dataset ───
def build_speaker_map(index_paths, data_dirs):
"""Map speaker → [(data_dir, sample_idx)] from index file(s).
The sample index comes from field 0 of the `~`-delimited row when it
parses as int (allows subset indexes that keep original sample numbers),
otherwise we fall back to the row's line number (legacy behaviour for
string-keyed indexes like tts_training_data_podcast).
"""
speaker_to_samples = defaultdict(list)
for index_path, data_dir in zip(index_paths, data_dirs):
with open(index_path) as f:
for line_num, line in enumerate(f):
parts = line.strip().split("~")
if len(parts) < 7:
continue
try:
idx = int(parts[0])
except ValueError:
idx = line_num
speaker_id = parts[1]
speaker_to_samples[speaker_id].append((data_dir, idx))
return {k: v for k, v in speaker_to_samples.items() if len(v) >= 2}
class IDLoRADataset(Dataset):
# Silence-latent reference loaded once, used to detect and strip any
# leading silence frames baked into the preprocessed audio_latents. The
# training loop ALREADY prepends 0-25 random silence frames, so we don't
# want accidental silence in the source data compounding on top.
_silence_ref = None
@classmethod
def _load_silence_ref(cls):
if cls._silence_ref is None:
p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "silence_latent_frame.pt")
if os.path.exists(p):
cls._silence_ref = torch.load(p, weights_only=True).float().squeeze() # [C, F]
return cls._silence_ref
def __init__(self, speaker_map):
self.samples = []
self.speaker_map = {}
for speaker, entries in speaker_map.items():
valid = []
for data_dir, idx in entries:
audio_path = Path(data_dir) / "audio_latents" / f"sample_{idx:06d}.pt"
cond_path = Path(data_dir) / "conditions" / f"sample_{idx:06d}.pt"
if audio_path.exists() and cond_path.exists():
valid.append((data_dir, idx))
if len(valid) >= 2:
self.speaker_map[speaker] = valid
for speaker, entries in self.speaker_map.items():
for entry in entries:
self.samples.append((entry, speaker))
IDLoRADataset._load_silence_ref()
def __len__(self):
return len(self.samples)
def _load_sample(self, data_dir, idx):
base = Path(data_dir)
audio = torch.load(base / "audio_latents" / f"sample_{idx:06d}.pt", weights_only=False)
# Prefer prefix-stripped text embeddings if they exist (re-encoded with
# just the quoted dialogue, dropping the "A woman says, " / "A man
# speaks with X accent, " scene-description prefix).
stripped = base / "conditions_stripped" / f"sample_{idx:06d}.pt"
cond_path = stripped if stripped.exists() else base / "conditions" / f"sample_{idx:06d}.pt"
cond = torch.load(cond_path, weights_only=False)
if isinstance(audio, dict):
audio = audio.get("audio_latent", audio.get("latent", list(audio.values())[0]))
if audio.dim() == 2:
audio = audio.unsqueeze(0)
audio_feats = cond.get("audio_prompt_embeds", cond.get("prompt_embeds"))
attn_mask = cond.get("prompt_attention_mask")
# The audio_connector has num_learnable_registers=128 and asserts the
# input sequence length is divisible by 128. Our new preprocessing
# saved trimmed conditions (dropping left-padding to save disk), which
# produces short/irregular sequence lengths. Left-pad back to the next
# multiple of 128 with zeros (matching the tokenizer's left-padding
# convention) so this assertion holds.
REG = 128
L = audio_feats.shape[0]
target_L = ((L + REG - 1) // REG) * REG
if target_L != L:
pad_len = target_L - L
pad_emb = torch.zeros(pad_len, audio_feats.shape[1],
dtype=audio_feats.dtype)
pad_mask = torch.zeros(pad_len, dtype=attn_mask.dtype)
audio_feats = torch.cat([pad_emb, audio_feats], dim=0)
attn_mask = torch.cat([pad_mask, attn_mask], dim=0)
return audio, audio_feats, attn_mask
def __getitem__(self, idx):
(data_dir, tgt_idx), speaker = self.samples[idx]
tgt_latent, audio_feats, attn_mask = self._load_sample(data_dir, tgt_idx)
# Drop the reference entirely for non-voice-cloning categories:
# - SFX samples (speaker starts with "sfx_"): descriptive sound events,
# no speaker identity to clone.
# - Song/music samples (suno dataset): prompts describe the music style,
# reference audio doesn't transfer anything useful.
# Return a zero-length ref so the model trains target-only for these.
drop_ref = speaker.startswith("sfx_") or "preprocessed_ltx_suno" in str(data_dir)
if drop_ref:
C, F_dim = tgt_latent.shape[0], tgt_latent.shape[2]
ref_latent = torch.zeros(C, 0, F_dim, dtype=tgt_latent.dtype)
else:
entries = self.speaker_map[speaker]
ref_entry = random.choice([e for e in entries if e[1] != tgt_idx])
ref_latent, _, _ = self._load_sample(*ref_entry)
return {
"tgt_latent": tgt_latent,
"ref_latent": ref_latent,
"audio_features": audio_feats,
"attention_mask": attn_mask,
}
# ─── Model building ───
def build_audio_only_model(checkpoint_path, device, dtype):
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
from ltx_core.loader.registry import DummyRegistry
from ltx_core.loader.sd_ops import SDOps
from ltx_core.model.transformer.model import LTXModel, LTXModelType
from ltx_core.model.model_protocol import ModelConfigurator
from ltx_core.model.transformer.attention import AttentionFunction
from ltx_core.model.transformer.rope import LTXRopeType
sd_ops = SDOps("AO").with_matching(prefix="model.diffusion_model.").with_replacement("model.diffusion_model.", "")
class Cfg(ModelConfigurator[LTXModel]):
@classmethod
def from_config(cls, config):
t = config.get("transformer", {})
cp = None
if not t.get("caption_proj_before_connector", False):
from ltx_core.model.transformer.text_projection import create_caption_projection
with torch.device("meta"):
cp = create_caption_projection(t, audio=True)
return LTXModel(
model_type=LTXModelType.AudioOnly,
audio_num_attention_heads=t.get("audio_num_attention_heads", 32),
audio_attention_head_dim=t.get("audio_attention_head_dim", 64),
audio_in_channels=t.get("audio_in_channels", 128),
audio_out_channels=t.get("audio_out_channels", 128),
num_layers=t.get("num_layers", 48),
audio_cross_attention_dim=t.get("audio_cross_attention_dim", 2048),
norm_eps=t.get("norm_eps", 1e-6),
attention_type=AttentionFunction(t.get("attention_type", "default")),
positional_embedding_theta=t.get("positional_embedding_theta", 10000.0),
audio_positional_embedding_max_pos=t.get("audio_positional_embedding_max_pos", [20]),
timestep_scale_multiplier=t.get("timestep_scale_multiplier", 1000),
use_middle_indices_grid=t.get("use_middle_indices_grid", True),
rope_type=LTXRopeType(t.get("rope_type", "interleaved")),
double_precision_rope=t.get("frequencies_precision", False) == "float64",
apply_gated_attention=t.get("apply_gated_attention", False),
audio_caption_projection=cp,
cross_attention_adaln=t.get("cross_attention_adaln", False),
)
builder = Builder(model_path=checkpoint_path, model_class_configurator=Cfg,
model_sd_ops=sd_ops, registry=DummyRegistry())
return builder.build(device=device, dtype=dtype)
def load_audio_connector(checkpoint_path, device, dtype):
# ltx-trainer already on path via ltx2/
from ltx_trainer.model_loader import load_embeddings_processor
emb_proc = load_embeddings_processor(checkpoint_path, device=device, dtype=dtype)
connector = emb_proc.audio_connector
del emb_proc
return connector
def apply_lora(model, rank, alpha, dropout=0.0):
from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=rank, lora_alpha=alpha, lora_dropout=dropout, bias="none",
target_modules=[
# Self-attention over audio tokens (voice-transfer pathway via ref).
"audio_attn1.to_k", "audio_attn1.to_q", "audio_attn1.to_v", "audio_attn1.to_out.0",
# Cross-attention (audio ↔ text context) NOT adapted — keep base
# model's prompt→audio behaviour intact and rely on dataset balance
# to drive expressiveness. (v15c tried this with adaLN unfreeze,
# that proved too destructive; v16 tries it adaLN-frozen.)
# FFN — non-linear capacity for style/phonetic adaptation.
"audio_ff.net.0.proj", "audio_ff.net.2",
],
)
model = get_peft_model(model, config)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
logging.info(f"LoRA: {trainable:,} trainable / {total:,} total ({100*trainable/total:.1f}%)")
return model
@torch.no_grad()
def prepare_audio_context(audio_connector, audio_features, attention_mask, device, dtype):
from ltx_core.text_encoders.gemma.embeddings_processor import convert_to_additive_mask
audio_features = audio_features.to(device=device, dtype=dtype)
attention_mask = attention_mask.to(device=device)
if audio_features.shape[0] > 1:
results = []
for i in range(audio_features.shape[0]):
feat_i = audio_features[i:i+1]
mask_i = attention_mask[i:i+1]
additive = convert_to_additive_mask(mask_i, feat_i.dtype)
enc_i, _ = audio_connector(feat_i, additive)
results.append(enc_i)
return torch.cat(results, dim=0)
additive_mask = convert_to_additive_mask(attention_mask, audio_features.dtype)
audio_encoded, _ = audio_connector(audio_features, additive_mask)
return audio_encoded
# ─── Validation ───
def _unwrap_model_safe(model):
"""Strip DDP / peft wrappers without going through accelerate.unwrap_model,
which imports deepspeed — broken in our env (torch API drift)."""
while hasattr(model, "module"):
model = model.module
return model
def run_validation(lora_path, val_config_path, output_dir, step, lora_rank=128):
"""Call validate.py in a subprocess. It loads TTSServer (the same stack
the warm server / Gradio app uses), attaches our LoRA, then iterates every
entry in val_config with the same inference settings the user tests with.
Single subprocess amortises the model-load cost across all val entries.
Forces validation onto VAL_GPU (default "0") because training already
occupies the rest. Override via TRAIN_VAL_GPU env var.
"""
import subprocess
val_dir = os.path.join(output_dir, "validation", f"step_{step:05d}")
os.makedirs(val_dir, exist_ok=True)
script = os.path.join(os.path.dirname(__file__), "validate.py")
cmd = [
sys.executable, script,
"--val-config", val_config_path,
"--output-dir", val_dir,
"--lora", lora_path,
"--lora-rank", str(lora_rank),
# Use raw estimator output (no +10% buffer) so we can hear
# whether the model needs more/less duration at current quality.
"--duration-multiplier", "1.0",
]
log_path = os.path.join(val_dir, "validate.log")
env = os.environ.copy()
# Validation needs its OWN GPU (training fills the others).
env["CUDA_VISIBLE_DEVICES"] = os.environ.get("TRAIN_VAL_GPU", "0")
try:
with open(log_path, "w") as logf:
result = subprocess.run(
cmd, stdout=logf, stderr=subprocess.STDOUT, timeout=1800, env=env,
)
if result.returncode == 0:
logging.info(f" Validation step {step}: OK → {val_dir}")
else:
logging.warning(f" Validation step {step} FAILED (see {log_path})")
except subprocess.TimeoutExpired:
logging.warning(f" Validation step {step} TIMEOUT (>30min)")
# ─── Args ───
def collate_audio_batch(batch):
"""Pad variable-length audio and track real lengths for loss masking."""
# TTS Audio Suite patch: keep this callable at module scope so Windows
# spawn-based DataLoader workers can pickle it.
max_tgt_T = max(item["tgt_latent"].shape[1] for item in batch)
max_ref_T = max(item["ref_latent"].shape[1] for item in batch)
channels = batch[0]["tgt_latent"].shape[0]
feature_dim = batch[0]["tgt_latent"].shape[2]
tgt_list, ref_list, feat_list, mask_list = [], [], [], []
tgt_lengths, ref_lengths = [], []
for item in batch:
tgt = item["tgt_latent"]
ref = item["ref_latent"]
tgt_lengths.append(tgt.shape[1])
ref_lengths.append(ref.shape[1])
if tgt.shape[1] < max_tgt_T:
pad = torch.zeros(
channels,
max_tgt_T - tgt.shape[1],
feature_dim,
dtype=tgt.dtype,
)
tgt = torch.cat([tgt, pad], dim=1)
tgt_list.append(tgt)
if ref.shape[1] < max_ref_T:
pad = torch.zeros(
channels,
max_ref_T - ref.shape[1],
feature_dim,
dtype=ref.dtype,
)
ref = torch.cat([ref, pad], dim=1)
ref_list.append(ref)
feat_list.append(item["audio_features"])
mask_list.append(item["attention_mask"])
return {
"tgt_latent": torch.stack(tgt_list),
"ref_latent": torch.stack(ref_list),
"audio_features": torch.stack(feat_list),
"attention_mask": torch.stack(mask_list),
"tgt_lengths": torch.tensor(tgt_lengths),
"ref_lengths": torch.tensor(ref_lengths),
}
def parse_args():
# First pass: pull out --config so its values can become argparse defaults.
cfg_parser = argparse.ArgumentParser(add_help=False)
cfg_parser.add_argument("--config", default=None,
help="YAML file with default values for any of the flags below. "
"Explicit CLI flags still override the YAML.")
cfg_args, remaining = cfg_parser.parse_known_args()
yaml_defaults: dict = {}
if cfg_args.config:
import yaml as _yaml
with open(cfg_args.config) as f:
yaml_defaults = _yaml.safe_load(f) or {}
# YAML keys are dashes-or-underscores → normalize to argparse dest (underscore).
yaml_defaults = {k.replace("-", "_"): v for k, v in yaml_defaults.items()}
def _yaml(name, fallback):
return yaml_defaults.get(name, fallback)
p = argparse.ArgumentParser(
parents=[cfg_parser],
description="Audio-Only IC-LoRA Training for Voice Cloning",
)
p.add_argument("--data-dir", required="data_dir" not in yaml_defaults,
nargs="+", default=_yaml("data_dir", None))
p.add_argument("--speaker-index", required="speaker_index" not in yaml_defaults,
nargs="+", default=_yaml("speaker_index", None))
p.add_argument("--output-dir", default=_yaml("output_dir", os.path.join(MODEL_DIR, "tts_iclora_v1")))
p.add_argument("--checkpoint", default=_yaml("checkpoint", os.path.join(MODEL_DIR, "dramabox-dit-v1.safetensors")))
p.add_argument("--full-checkpoint", default=_yaml("full_checkpoint", os.path.join(MODEL_DIR, "dramabox-audio-components.safetensors")))
p.add_argument("--base-model", choices=["distilled", "dev"], default=_yaml("base_model", "dev"),
help="Base model type: distilled uses DistilledTimestepSampler, dev uses ShiftedLogitNormal")
p.add_argument("--lora-rank", type=int, default=_yaml("lora_rank", 128))
p.add_argument("--lora-alpha", type=int, default=_yaml("lora_alpha", 128))
p.add_argument("--lora-dropout", type=float, default=_yaml("lora_dropout", 0.0),
help="Dropout applied to LoRA A/B matrices during training. "
"Recommended ~0.1 for small datasets to regularize.")
p.add_argument("--resume-lora", default=_yaml("resume_lora", None))
p.add_argument("--resume-step-offset", type=int, default=_yaml("resume_step_offset", None),
help="Step to add when naming saved checkpoints. If None, inferred "
"from --resume-lora filename (e.g. lora_step_10000.safetensors → 10000). "
"Set to 0 to start numbering at 0 regardless.")
p.add_argument("--ref-ratio", type=float, default=_yaml("ref_ratio", 0.3),
help="Fraction of target length to use as reference (default 0.3)")
p.add_argument("--max-ref-tokens", type=int, default=_yaml("max_ref_tokens", 200),
help="Maximum reference tokens after patchification (default 200)")
p.add_argument("--text-dropout", type=float, default=_yaml("text_dropout", 0.0),
help="Probability of dropping text conditioning (forces reliance on voice ref)")
p.add_argument("--steps", type=int, default=_yaml("steps", 30000))
p.add_argument("--lr", type=float, default=_yaml("lr", 3e-5))
p.add_argument("--lr-scheduler", choices=["cosine", "linear", "constant"], default=_yaml("lr_scheduler", "cosine"))
p.add_argument("--batch-size", type=int, default=_yaml("batch_size", 1))
p.add_argument("--grad-accum", type=int, default=_yaml("grad_accum", 4))
p.add_argument("--max-grad-norm", type=float, default=_yaml("max_grad_norm", 1.0))
p.add_argument("--save-every", type=int, default=_yaml("save_every", 1000))
p.add_argument("--log-every", type=int, default=_yaml("log_every", 50))
p.add_argument("--seed", type=int, default=_yaml("seed", 42))
p.add_argument("--warmup-steps", type=int, default=_yaml("warmup_steps", 100))
p.add_argument("--val-config", default=_yaml("val_config", None))
return p.parse_args(remaining)
# ─── Main ───
def main():
from accelerate import Accelerator
from accelerate.utils import set_seed
args = parse_args()
accelerator = Accelerator(
gradient_accumulation_steps=args.grad_accum,
mixed_precision="bf16",
)
is_main = accelerator.is_main_process
if is_main:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
else:
logging.basicConfig(level=logging.WARNING)
set_seed(args.seed)
device = accelerator.device
dtype = torch.bfloat16
os.makedirs(args.output_dir, exist_ok=True)
# Save training args
if is_main:
import yaml
args_dict = vars(args).copy()
args_dict["_meta"] = {
"world_size": accelerator.num_processes,
"dtype": str(dtype),
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"script": "train_audio_iclora.py",
"pattern": "IC-LoRA (ref appended to end)",
}
with open(os.path.join(args.output_dir, "training_args.yaml"), "w") as f:
yaml.dump(args_dict, f, default_flow_style=False, sort_keys=False)
from ltx_core.components.patchifiers import AudioPatchifier
from ltx_core.model.transformer.modality import Modality
from ltx_core.guidance.perturbations import BatchedPerturbationConfig
from ltx_core.tools import AudioLatentTools
from ltx_core.types import AudioLatentShape, LatentState
from ltx_pipelines.utils.helpers import modality_from_latent_state, timesteps_from_mask
# Build speaker map
if is_main:
logging.info("Building speaker map...")
speaker_map = build_speaker_map(args.speaker_index, args.data_dir)
if is_main:
logging.info(f"Speaker map: {len(speaker_map)} speakers, "
f"{sum(len(v) for v in speaker_map.values())} samples")
# Load model
if is_main:
logging.info("Loading audio-only model...")
model = build_audio_only_model(args.checkpoint, device, dtype)
if is_main:
logging.info("Loading audio connector...")
audio_connector = load_audio_connector(args.full_checkpoint, device, dtype)
audio_connector.eval()
for p in audio_connector.parameters():
p.requires_grad = False
if is_main:
logging.info(f"Applying LoRA (rank={args.lora_rank}, alpha={args.lora_alpha})...")
model = apply_lora(model, args.lora_rank, args.lora_alpha, args.lora_dropout)
# Resume from checkpoint
if args.resume_lora:
from safetensors.torch import load_file as st_load
if is_main:
logging.info(f"Resuming from: {args.resume_lora}")
lora_sd = st_load(args.resume_lora)
mapped = {}
for k, v in lora_sd.items():
nk = k.replace(".lora_A.weight", ".lora_A.default.weight").replace(
".lora_B.weight", ".lora_B.default.weight")
mapped[nk] = v
model.load_state_dict(mapped, strict=False)
# Determine step offset for save filenames. Without this, resuming a run
# restarts step numbering at 0 and would overwrite earlier phase-1
# checkpoints with the same save_every cadence.
if args.resume_step_offset is None:
resume_offset = 0
if args.resume_lora:
import re as _re
m = _re.search(r"lora_step_(\d+)", os.path.basename(args.resume_lora))
if m:
resume_offset = int(m.group(1))
args.resume_step_offset = resume_offset
if is_main and args.resume_step_offset:
logging.info(f"Save-step offset: +{args.resume_step_offset}")
model.train()
model.base_model.model.set_gradient_checkpointing(True)
# Dataset & DataLoader
dataset = IDLoRADataset(speaker_map)
if is_main:
logging.info(f"Dataset: {len(dataset)} samples, {len(dataset.speaker_map)} speakers")
dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, num_workers=2,
pin_memory=True, drop_last=True, collate_fn=collate_audio_batch)
# Optimizer & Scheduler
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad],
lr=args.lr, betas=(0.9, 0.999), weight_decay=0.01,
)
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR, ConstantLR
warmup = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=args.warmup_steps)
remaining = args.steps - args.warmup_steps
if args.lr_scheduler == "cosine":
# Warmup -> constant hold (20% of remaining) -> cosine decay
hold_steps = max(remaining // 5, 0)
decay_steps = max(remaining - hold_steps, 1)
hold_sched = ConstantLR(optimizer, factor=1.0, total_iters=hold_steps)
decay_sched = CosineAnnealingLR(optimizer, T_max=decay_steps, eta_min=1e-6)
scheduler = SequentialLR(
optimizer,
[warmup, hold_sched, decay_sched],
milestones=[args.warmup_steps, args.warmup_steps + hold_steps],
)
elif args.lr_scheduler == "linear":
main_sched = LinearLR(optimizer, start_factor=1.0, end_factor=0.01, total_iters=max(remaining, 1))
scheduler = SequentialLR(optimizer, [warmup, main_sched], milestones=[args.warmup_steps])
else:
main_sched = ConstantLR(optimizer, factor=1.0, total_iters=max(remaining, 1))
scheduler = SequentialLR(optimizer, [warmup, main_sched], milestones=[args.warmup_steps])
# Prepare with Accelerate — but NOT the scheduler. AcceleratedScheduler
# calls the underlying scheduler.step() `num_processes` times per sync,
# which silently scales down our warmup/cosine spans by that factor.
# We call scheduler.step() ourselves, gated on sync_gradients → exactly
# one advance per optimizer step, as the yaml spec intends.
model, optimizer, dataloader = accelerator.prepare(model, optimizer, dataloader)
patchifier = AudioPatchifier(patch_size=1)
# Select timestep sampler based on base model type
if args.base_model == "distilled":
timestep_sampler = DistilledTimestepSampler()
if is_main:
logging.info("Using DistilledTimestepSampler (matching distilled model sigmas)")
else:
timestep_sampler = ShiftedLogitNormalTimestepSampler()
if is_main:
logging.info("Using ShiftedLogitNormalTimestepSampler (dev model)")
# Training loop
if is_main:
logging.info(f"Training: {args.steps} steps, lr={args.lr}, scheduler={args.lr_scheduler}, "
f"batch={args.batch_size}, grad_accum={args.grad_accum}, "
f"world_size={accelerator.num_processes}, "
f"ref_ratio={args.ref_ratio}, max_ref_tokens={args.max_ref_tokens}")
logging.info("IC-LoRA pattern: ref tokens APPENDED to target, loss on target only")
data_iter = iter(dataloader)
step = 0
accum_loss = 0.0
best_loss = float("inf")
best_step = 0
t0 = time.time()
total_micro_steps = args.steps * args.grad_accum
for micro_step in range(total_micro_steps):
try:
batch = next(data_iter)
except StopIteration:
data_iter = iter(dataloader)
batch = next(data_iter)
is_opt_step = (micro_step + 1) % args.grad_accum == 0
if is_opt_step:
step += 1
if is_main:
# TTS Audio Suite patch: provide lightweight per-step telemetry
# to the parent process even when human logs use log_every=50.
print(
f"TTS_SUITE_PROGRESS step={step} total={args.steps}",
flush=True,
)
with accelerator.accumulate(model):
tgt_latent = batch["tgt_latent"].to(dtype=dtype) # [B, C, max_tgt_T, F]
ref_latent = batch["ref_latent"].to(dtype=dtype) # [B, C, max_ref_T, F]
tgt_lengths = batch["tgt_lengths"].to(device=device) # [B]
B = tgt_latent.shape[0]
# ── Random silence padding (0-1s) ── ltx_audio_tts baseline.
# User observed reference-audio leak at end of generations when this
# was reduced to 5 (v14) or 10 frames (v16/v17) — the model seemed
# to use the extra target budget to regurgitate ref content. Full
# 25 frames (0-1s avg 500ms) was apparently load-bearing for
# regularising the boundary and reducing hallucinations.
# Uses the real silence latent (not zeros) so the VAE decodes it as
# true silence instead of static noise.
max_pad_frames = 25 # ~1s at 25 latent frames/sec
pad_frames = random.randint(0, max_pad_frames)
if pad_frames > 0:
C, F_dim = tgt_latent.shape[1], tgt_latent.shape[3]
if not hasattr(args, '_silence_frame') or args._silence_frame is None:
_sf_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "silence_latent_frame.pt")
if os.path.exists(_sf_path):
args._silence_frame = torch.load(_sf_path, weights_only=True) # [C, 1, F]
if is_main:
logging.info(f"Loaded silence latent from {_sf_path}")
else:
args._silence_frame = False # fallback to zeros
if is_main:
logging.warning(f"silence_latent_frame.pt not found, using zeros")
if args._silence_frame is not False:
sf = args._silence_frame.to(dtype=dtype, device=device) # [C, 1, F]
silence_pad = sf.unsqueeze(0).expand(B, -1, pad_frames, -1) # [B, C, pad, F]
else:
silence_pad = torch.zeros(B, C, pad_frames, F_dim, dtype=dtype, device=device)
tgt_latent = torch.cat([silence_pad, tgt_latent], dim=2)
# Cap reference to max_ref_tokens (in latent frames, before patchification)
# After patchification, ref_T tokens = ref frames (patch_size=1)
ref_T_frames = min(ref_latent.shape[2], args.max_ref_tokens)
ref_latent = ref_latent[:, :, :ref_T_frames, :]
tgt_T_frames = tgt_latent.shape[2] # max (padded) target frames
# ── Step 1: Create target AudioLatentShape and AudioLatentTools ──
tgt_shape = AudioLatentShape(
batch=B,
channels=tgt_latent.shape[1], # 8
frames=tgt_T_frames,
mel_bins=tgt_latent.shape[3], # 16
)
audio_tools = AudioLatentTools(
patchifier=patchifier,
target_shape=tgt_shape,
)
# ── Step 2: Create initial state from target latent ──
# create_initial_state patchifies: [B, C, T, F] -> [B, T, C*F]
# Also creates denoise_mask=1 (all target tokens will be denoised)
# and computes temporal positions
state = audio_tools.create_initial_state(
device=device,
dtype=dtype,
initial_latent=tgt_latent,
)
# state.latent: [B, tgt_T, 128], state.denoise_mask: [B, tgt_T, 1]
# state.positions: [B, 1, tgt_T, 2]
tgt_T = audio_tools.target_shape.token_count() # = tgt_T_frames
# ── Step 3: Apply flow-matching noise to target BEFORE appending ref ──
# Sample sigma
total_tokens = tgt_T + ref_T_frames
sigma = timestep_sampler.sample(B, total_tokens, device=device)
sigma_exp = sigma.view(-1, 1, 1) # [B, 1, 1]
noise = torch.randn_like(state.latent) # [B, tgt_T, 128]
noisy_tgt = (1 - sigma_exp) * state.latent + sigma_exp * noise
# Replace the latent in state with the noisy version
# (clean_latent stays clean for post_process_latent pattern)
state = LatentState(
latent=noisy_tgt,
denoise_mask=state.denoise_mask,
positions=state.positions,
clean_latent=state.clean_latent,
attention_mask=state.attention_mask,
)
# ── Step 4: Append reference tokens using AudioConditionByReferenceLatent ──
# This appends ref tokens to the END with denoise_mask=0 (frozen/clean)
# Skip entirely when ref_T=0 (SFX / song samples): the model trains
# target-only for those categories since there's no voice to clone.
if ref_T_frames > 0:
ref_conditioning = AudioConditionByReferenceLatent(
latent=ref_latent,
strength=1.0, # 1.0 = ref fully clean (denoise_mask=0)
)
state = ref_conditioning.apply_to(
latent_state=state,
latent_tools=audio_tools,
)
# state.latent: [B, tgt_T + ref_T, 128]
# state.denoise_mask: [B, tgt_T + ref_T, 1]
# target tokens: 1.0 (denoise), ref tokens: 0.0 (frozen)
# state.positions: [B, 1, tgt_T + ref_T, 2]
# ── Step 5: Build loss mask for target tokens (excluding padding) ──
# loss_mask: 1 for real target tokens, 0 for padding and ref tokens
loss_mask = torch.zeros(B, tgt_T, device=device)
for b_idx in range(B):
real_len = min(tgt_lengths[b_idx].item(), tgt_T)
loss_mask[b_idx, :real_len] = 1.0
# ── Step 6: Prepare text context ──
# Text conditioning dropout: randomly zero out text context to force
# the model to rely on the voice reference for identity/style.
with torch.no_grad():
audio_context = prepare_audio_context(
audio_connector, batch["audio_features"],
batch["attention_mask"], device, dtype)
if args.text_dropout > 0 and random.random() < args.text_dropout:
audio_context = torch.zeros_like(audio_context)
# ── Step 7: Build Modality using modality_from_latent_state ──
# timesteps = sigma * denoise_mask (ref gets 0, target gets sigma)
audio_mod = modality_from_latent_state(
state=state,
context=audio_context,
sigma=sigma,
enabled=True,
)
# ── Step 8: Forward pass ──
perturbations = BatchedPerturbationConfig.empty(B)
with torch.autocast(device_type="cuda", dtype=dtype):
_, velocity_pred = model(video=None, audio=audio_mod, perturbations=perturbations)
# ── Step 9: Compute loss (IC-LoRA pattern) ──
# Target is at the FRONT (indices 0..tgt_T), ref at the END
# velocity target = noise - clean
tgt_patchified = audio_tools.patchifier.patchify(tgt_latent) # [B, tgt_T, 128]
target_velocity = noise - tgt_patchified
# Extract target portion of prediction
pred_tgt = velocity_pred[:, :tgt_T] # [B, tgt_T, 128]
# MSE loss with mask: only on real target tokens (not padding or ref)
per_token_mse = (pred_tgt - target_velocity).pow(2).mean(dim=-1) # [B, tgt_T]
loss = per_token_mse.mul(loss_mask).div(loss_mask.mean().clamp(min=1e-6)).mean()
accelerator.backward(loss)
if accelerator.sync_gradients and args.max_grad_norm > 0:
accelerator.clip_grad_norm_(model.parameters(), args.max_grad_norm)
optimizer.step()
optimizer.zero_grad()
# Only advance the LR scheduler once per OPTIMIZER step (not per
# micro-step). Mirrors AcceleratedOptimizer.step() which is
# internally gated on sync_gradients.
if accelerator.sync_gradients:
scheduler.step()
accum_loss += loss.item()
# Logging & saving on optimization steps only
if is_opt_step and step % args.log_every == 0 and is_main:
avg_loss = accum_loss / (args.log_every * args.grad_accum)
lr = optimizer.param_groups[0]["lr"]
elapsed = time.time() - t0
sps = step / elapsed if elapsed > 0 else 0
eta = (args.steps - step) / sps if sps > 0 else 0
logging.info(
f"Step {step}/{args.steps} | loss={avg_loss:.4f} | lr={lr:.2e} | "
f"tgt_T={tgt_T} ref_T={ref_T_frames} total={tgt_T + ref_T_frames} | "
f"{sps:.1f} steps/s | ETA {eta/60:.0f}min"
)
# Save best whenever loss improves — no warmup gate, so we can
# observe best checkpoints during warmup too.
if avg_loss < best_loss:
best_loss = avg_loss
old_best = os.path.join(args.output_dir, f"best_step_{best_step:05d}.safetensors")
best_step = step + args.resume_step_offset
new_best = os.path.join(args.output_dir, f"best_step_{best_step:05d}.safetensors")
unwrapped = _unwrap_model_safe(model)
unwrapped.save_pretrained(args.output_dir)
adapter = os.path.join(args.output_dir, "adapter_model.safetensors")
if os.path.exists(adapter):
shutil.copy(adapter, new_best)
if old_best != new_best and os.path.exists(old_best):
os.remove(old_best)
logging.info(f"New best: loss={best_loss:.4f} at step {best_step}")
accum_loss = 0.0
if is_opt_step and step % args.save_every == 0 and is_main:
global_step = step + args.resume_step_offset
save_path = os.path.join(args.output_dir, f"lora_step_{global_step:05d}.safetensors")
logging.info(f"Saving: {save_path}")
unwrapped = _unwrap_model_safe(model)
unwrapped.save_pretrained(args.output_dir)
adapter = os.path.join(args.output_dir, "adapter_model.safetensors")
if os.path.exists(adapter):
shutil.copy(adapter, save_path)
if args.val_config:
logging.info(f"Running validation at step {global_step}...")
model.eval()
run_validation(save_path, args.val_config, args.output_dir, global_step,
lora_rank=args.lora_rank)
model.train()
# Final save
if is_main:
unwrapped = _unwrap_model_safe(model)
unwrapped.save_pretrained(args.output_dir)
adapter = os.path.join(args.output_dir, "adapter_model.safetensors")
global_step = step + args.resume_step_offset
save_path = os.path.join(args.output_dir, f"lora_step_{global_step:05d}.safetensors")
if os.path.exists(adapter):
shutil.copy(adapter, save_path)
logging.info(f"Training complete! {step} steps in {time.time()-t0:.0f}s")
logging.info(f"Best loss: {best_loss:.4f} at step {best_step}")
if __name__ == "__main__":
main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
"""Warm validation runner — loads base dev + LoRA + all aux models ONCE,
then iterates every speaker in val_config generating each output.
Matches the same generation path as inference.py but keeps Gemma / audio VAE
/ velocity model / audio decoder resident across entries. Inference
settings default to the Gradio warm-server values (cfg=2.5, stg=1.5,
modality=1.0, rescale=0, 30 steps, fps=25) — use --inference-params to
override.
"""
import argparse
import logging
import os
import sys
import time
import traceback
import torch
import torchaudio
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODEL_DIR = REPO_DIR
sys.path.insert(0, os.path.join(REPO_DIR, "ltx2"))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
DEV_FULL_CKPT = os.environ.get(
"LTX_FULL_CHECKPOINT",
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ltx-2.3-22b-dev.safetensors"),
)
# TTS Audio Suite patch: organized DramaBox installs keep the transformer and
# audio components in separate checkpoints, unlike the upstream full checkpoint.
DRAMABOX_TRANSFORMER_CKPT = os.environ.get("LTX_CHECKPOINT", DEV_FULL_CKPT)
GEMMA_ROOT = os.environ.get(
"GEMMA_ROOT",
os.path.expanduser("~/.cache/dramabox/gemma-3-12b-it-bnb-4bit"),
)
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--val-config", required=True)
p.add_argument("--output-dir", required=True)
p.add_argument("--lora", default=None)
p.add_argument("--lora-rank", type=int, default=128)
p.add_argument("--checkpoint", default=DRAMABOX_TRANSFORMER_CKPT)
p.add_argument("--full-checkpoint", default=DEV_FULL_CKPT)
p.add_argument("--gemma-root", default=GEMMA_ROOT)
p.add_argument("--cfg-scale", type=float, default=2.5)
p.add_argument("--stg-scale", type=float, default=1.5)
p.add_argument("--rescale-scale", type=float, default=0.0)
p.add_argument("--modality-scale", type=float, default=1.0)
p.add_argument("--steps", type=int, default=30)
p.add_argument("--fps", type=float, default=25.0)
p.add_argument("--stg-block", type=int, default=29)
p.add_argument("--cfg-clamp", type=float, default=0.0)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--duration-multiplier", type=float, default=1.1)
# Match Gradio / inference_server.py DEFAULT_NEG exactly
p.add_argument("--negative-prompt", default=(
"worst quality, inconsistent, robotic, distorted, noise, static, "
"muffled, unclear, unnatural, monotone"
))
return p.parse_args()
def estimate_speech_duration(prompt: str, speed: float = 1.0) -> float:
import re
quoted = re.findall(r'"([^"]*)"', prompt) or re.findall(r"'([^']*)'", prompt)
text = " ".join(quoted) if quoted else prompt
duration = len(text) * 0.065 / max(speed, 0.1) + 1.5
return max(3.0, round(duration, 1))
class WarmValidator:
def __init__(self, checkpoint, full_checkpoint, gemma_root, lora_path=None, lora_rank=128,
device="cuda", dtype=torch.bfloat16):
from audio_conditioning import AudioConditionByReferenceLatent # noqa: F401 (imported by inference.py)
from ltx_core.components.patchifiers import AudioPatchifier
from ltx_pipelines.utils.blocks import PromptEncoder, AudioConditioner, AudioDecoder
self.device = torch.device(device)
self.dtype = dtype
self.full_checkpoint = full_checkpoint
self.gemma_root = gemma_root
self.patchifier = AudioPatchifier(patch_size=1)
logging.info("Loading PromptEncoder (Gemma + embeddings_processor)...")
t0 = time.time()
self.prompt_encoder = PromptEncoder(
checkpoint_path=full_checkpoint, gemma_root=gemma_root,
dtype=dtype, device=self.device, warm=True, audio_only=True,
)
logging.info(f" PromptEncoder ready in {time.time()-t0:.1f}s")
logging.info("Loading AudioConditioner (audio VAE encoder)...")
t0 = time.time()
self.audio_conditioner = AudioConditioner(
checkpoint_path=full_checkpoint, dtype=dtype, device=self.device, warm=True,
)
logging.info(f" AudioConditioner ready in {time.time()-t0:.1f}s")
logging.info("Loading AudioDecoder...")
t0 = time.time()
self.audio_decoder = AudioDecoder(
checkpoint_path=full_checkpoint, dtype=dtype, device=self.device, warm=True,
)
logging.info(f" AudioDecoder ready in {time.time()-t0:.1f}s")
logging.info("Building velocity model (audio-only from base dev)...")
t0 = time.time()
# TTS Audio Suite patch: build the DiT from the dedicated transformer
# checkpoint while keeping the audio connector/VAE/decoder checkpoint separate.
self.velocity_model = self._build_velocity_model(checkpoint, lora_path, lora_rank)
logging.info(f" Velocity model ready in {time.time()-t0:.1f}s "
f"({sum(p.numel() for p in self.velocity_model.parameters()) / 1e9:.1f}B params)")
def _build_velocity_model(self, checkpoint_path, lora_path, lora_rank):
from ltx_core.loader.registry import DummyRegistry
from ltx_core.loader.sd_ops import SDOps
from ltx_core.loader.single_gpu_model_builder import SingleGPUModelBuilder as Builder
from ltx_core.model.model_protocol import ModelConfigurator
from ltx_core.model.transformer.attention import AttentionFunction
from ltx_core.model.transformer.model import LTXModel, LTXModelType
from ltx_core.model.transformer.rope import LTXRopeType
sd_ops = (
SDOps("AO")
.with_matching(prefix="model.diffusion_model.")
.with_replacement("model.diffusion_model.", "")
)
class Cfg(ModelConfigurator[LTXModel]):
@classmethod
def from_config(cls, config):
t = config.get("transformer", {})
cp = None
if not t.get("caption_proj_before_connector", False):
from ltx_core.model.transformer.text_projection import create_caption_projection
with torch.device("meta"):
cp = create_caption_projection(t, audio=True)
return LTXModel(
model_type=LTXModelType.AudioOnly,
audio_num_attention_heads=t.get("audio_num_attention_heads", 32),
audio_attention_head_dim=t.get("audio_attention_head_dim", 64),
audio_in_channels=t.get("audio_in_channels", 128),
audio_out_channels=t.get("audio_out_channels", 128),
num_layers=t.get("num_layers", 48),
audio_cross_attention_dim=t.get("audio_cross_attention_dim", 2048),
norm_eps=t.get("norm_eps", 1e-6),
attention_type=AttentionFunction(t.get("attention_type", "default")),
positional_embedding_theta=10000.0,
audio_positional_embedding_max_pos=[20.0],
timestep_scale_multiplier=t.get("timestep_scale_multiplier", 1000),
use_middle_indices_grid=t.get("use_middle_indices_grid", True),
rope_type=LTXRopeType(t.get("rope_type", "interleaved")),
double_precision_rope=t.get("frequencies_precision", False) == "float64",
apply_gated_attention=t.get("apply_gated_attention", False),
audio_caption_projection=cp,
cross_attention_adaln=t.get("cross_attention_adaln", False),
)
builder = Builder(
model_path=checkpoint_path, model_class_configurator=Cfg,
model_sd_ops=sd_ops, registry=DummyRegistry(),
)
velocity = builder.build(device=self.device, dtype=self.dtype).to(self.device).eval()
if lora_path and os.path.exists(lora_path):
from peft import LoraConfig, get_peft_model
from safetensors.torch import load_file as st_load
logging.info(f"Attaching LoRA: {lora_path}")
lora_sd = st_load(lora_path)
is_peft = any("base_model.model." in k for k in lora_sd.keys())
is_iclora = any("diffusion_model." in k for k in lora_sd.keys())
cfg = LoraConfig(
r=lora_rank, lora_alpha=lora_rank, lora_dropout=0.0, bias="none",
target_modules=[
"audio_attn1.to_k", "audio_attn1.to_q",
"audio_attn1.to_v", "audio_attn1.to_out.0",
"audio_attn2.to_k", "audio_attn2.to_q",
"audio_attn2.to_v", "audio_attn2.to_out.0",
"audio_ff.net.0.proj", "audio_ff.net.2",
],
)
velocity = get_peft_model(velocity, cfg)
if is_peft:
mapped = {}
for k, v in lora_sd.items():
nk = k
if ".lora_A.weight" in k and ".lora_A.default.weight" not in k:
nk = k.replace(".lora_A.weight", ".lora_A.default.weight")
if ".lora_B.weight" in k and ".lora_B.default.weight" not in k:
nk = k.replace(".lora_B.weight", ".lora_B.default.weight")
mapped[nk] = v
_, unexpected = velocity.load_state_dict(mapped, strict=False)
logging.info(f" Loaded {len(mapped) - len(unexpected)} LoRA weights (peft)")
elif is_iclora:
audio_keys = {k: v for k, v in lora_sd.items()
if "audio_attn1" in k or "audio_attn2" in k or "audio_ff" in k}
mapped = {}
for k, v in audio_keys.items():
nk = k.replace("diffusion_model.", "base_model.model.")
nk = nk.replace(".lora_A.weight", ".lora_A.default.weight")
nk = nk.replace(".lora_B.weight", ".lora_B.default.weight")
mapped[nk] = v
_, unexpected = velocity.load_state_dict(mapped, strict=False)
logging.info(f" Loaded {len(mapped) - len(unexpected)} LoRA weights (iclora)")
velocity = velocity.merge_and_unload()
logging.info(" Merged LoRA into base weights")
return velocity
@torch.inference_mode()
def generate(self, prompt, output_path, voice_ref=None, args=None):
from audio_conditioning import AudioConditionByReferenceLatent
from ltx_core.batch_split import BatchSplitAdapter
from ltx_core.components.diffusion_steps import EulerDiffusionStep
from ltx_core.components.guiders import MultiModalGuider, MultiModalGuiderParams
from ltx_core.components.noisers import GaussianNoiser
from ltx_core.components.schedulers import LTX2Scheduler
from ltx_core.model.audio_vae import encode_audio as vae_encode_audio
from ltx_core.model.transformer.model import X0Model
from ltx_core.tools import AudioLatentTools
from ltx_core.types import Audio, AudioLatentShape, VideoPixelShape
from ltx_pipelines.utils.denoisers import GuidedDenoiser, SimpleDenoiser
from ltx_pipelines.utils.gpu_model import gpu_model
from ltx_pipelines.utils.media_io import decode_audio_from_file
from ltx_pipelines.utils.samplers import euler_denoising_loop
t_total = time.time()
# ---- Duration + shape ----
gen_dur = estimate_speech_duration(prompt) * args.duration_multiplier
raw_frames = int(round(gen_dur * args.fps)) + 1
num_frames = ((raw_frames - 1 + 4) // 8) * 8 + 1
pixel_shape = VideoPixelShape(batch=1, frames=num_frames, height=64, width=64, fps=args.fps)
tgt_shape = AudioLatentShape.from_video_pixel_shape(pixel_shape)
audio_tools = AudioLatentTools(patchifier=self.patchifier, target_shape=tgt_shape)
state = audio_tools.create_initial_state(self.device, self.dtype)
# ---- Voice reference ----
if voice_ref and os.path.exists(voice_ref):
voice = decode_audio_from_file(voice_ref, self.device, 0.0, 10.0)
if voice is not None:
w = voice.waveform
if w.dim() == 2:
if w.shape[0] == 1:
w = w.repeat(2, 1)
w = w.unsqueeze(0)
elif w.dim() == 3 and w.shape[1] == 1:
w = w.repeat(1, 2, 1)
target_samples = int(10.0 * voice.sampling_rate)
if w.shape[-1] < target_samples:
w = w.repeat(1, 1, (target_samples // w.shape[-1]) + 1)
w = w[..., :target_samples]
peak = w.abs().max()
if peak > 0:
w = w * (10 ** (-4.0 / 20) / peak)
voice = Audio(waveform=w, sampling_rate=voice.sampling_rate)
ref_latent = self.audio_conditioner(lambda enc: vae_encode_audio(voice, enc, None))
cond = AudioConditionByReferenceLatent(
latent=ref_latent.to(self.device, self.dtype), strength=1.0,
)
state = cond.apply_to(latent_state=state, latent_tools=audio_tools)
# ---- Noise ----
gen = torch.Generator(device=self.device).manual_seed(args.seed)
noiser = GaussianNoiser(generator=gen)
state = noiser(state, noise_scale=1.0)
# ---- Prompt encode ----
use_cfg = args.cfg_scale > 1.0
prompts = [prompt, args.negative_prompt] if use_cfg else [prompt]
ctx = self.prompt_encoder(prompts, streaming_prefetch_count=None)
a_ctx = ctx[0].audio_encoding
a_ctx_neg = ctx[1].audio_encoding if use_cfg else None
# ---- Denoiser ----
needs_guidance = args.cfg_scale > 1.0 or args.stg_scale > 0.0 or args.modality_scale > 1.0
if needs_guidance:
guider = MultiModalGuider(
params=MultiModalGuiderParams(
cfg_scale=args.cfg_scale, stg_scale=args.stg_scale,
stg_blocks=[args.stg_block] if args.stg_scale > 0 else [],
rescale_scale=args.rescale_scale,
modality_scale=args.modality_scale,
cfg_clamp_scale=args.cfg_clamp,
),
negative_context=a_ctx_neg,
)
denoiser = GuidedDenoiser(
v_context=None, a_context=a_ctx,
video_guider=None, audio_guider=guider,
)
else:
denoiser = SimpleDenoiser(v_context=None, a_context=a_ctx)
sigmas = LTX2Scheduler().execute(steps=args.steps, latent=state.latent).to(self.device)
# ---- Denoise ----
# NOTE: don't wrap in gpu_model() — that context manager moves the
# model back off GPU on exit, which breaks subsequent iterations of
# our warm validator. We keep the velocity model resident.
x0 = X0Model(self.velocity_model)
batched = BatchSplitAdapter(x0, max_batch_size=1)
_, audio_state = euler_denoising_loop(
sigmas=sigmas, video_state=None, audio_state=state,
stepper=EulerDiffusionStep(), transformer=batched, denoiser=denoiser,
)
audio_state = audio_tools.clear_conditioning(audio_state)
audio_state = audio_tools.unpatchify(audio_state)
decoded = self.audio_decoder(audio_state.latent)
wav = decoded.waveform
if wav.dim() == 1:
wav = wav.unsqueeze(0)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
torchaudio.save(output_path, wav.float().cpu(), decoded.sampling_rate)
logging.info(f" -> {output_path} ({wav.shape[-1]/decoded.sampling_rate:.1f}s, "
f"{time.time()-t_total:.1f}s)")
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args = parse_args()
import yaml
with open(args.val_config) as f:
val_cfg = yaml.safe_load(f)
os.makedirs(args.output_dir, exist_ok=True)
# Build validator once (models warm for all entries).
validator = WarmValidator(
checkpoint=args.checkpoint,
full_checkpoint=args.full_checkpoint,
gemma_root=args.gemma_root,
lora_path=args.lora,
lora_rank=args.lora_rank,
device="cuda" if torch.cuda.is_available() else "cpu",
dtype=torch.bfloat16,
)
n_ok = n_fail = 0
t0 = time.time()
for entry in val_cfg.get("speakers", []):
name = entry["name"]
out_path = os.path.join(args.output_dir, f"{name}.wav")
try:
validator.generate(
prompt=entry["prompt"],
output_path=out_path,
voice_ref=entry.get("reference"),
args=args,
)
n_ok += 1
logging.info(f" [{name}] OK")
except Exception as e:
n_fail += 1
logging.warning(f" [{name}] FAILED: {e}")
traceback.print_exc()
logging.info(f"Validation done: ok={n_ok} fail={n_fail} in {(time.time()-t0)/60:.1f}min "
f"at {args.output_dir}")
if __name__ == "__main__":
main()
+13 -3
View File
@@ -434,9 +434,19 @@ class Attention(nn.Module):
# Attention processor
# TTS Audio Suite patch: FlashAttention is optional and F5 defaults to the
# PyTorch backend. Treat partial/incompatible installations as unavailable
# instead of crashing module import when submodules or compiled symbols are
# missing.
FLASH_ATTN_AVAILABLE = False
if is_package_available("flash_attn"):
from flash_attn.bert_padding import pad_input, unpad_input
from flash_attn import flash_attn_varlen_func, flash_attn_func
try:
from flash_attn.bert_padding import pad_input, unpad_input
from flash_attn import flash_attn_varlen_func, flash_attn_func
FLASH_ATTN_AVAILABLE = True
except (ImportError, OSError, AttributeError):
pass
class AttnProcessor:
@@ -447,7 +457,7 @@ class AttnProcessor:
attn_mask_enabled: bool = True,
):
if attn_backend == "flash_attn":
assert is_package_available("flash_attn"), "Please install flash-attn first."
assert FLASH_ATTN_AVAILABLE, "Please install a compatible flash-attn build first."
self.pe_attn_head = pe_attn_head
self.attn_backend = attn_backend
+66
View File
@@ -27,6 +27,7 @@ FRIENDLY_VARIANT_MAP = {
}
SUPPORTED_DELAY_TRAINING_VARIANTS = {"MOSS-TTS", "MOSS-TTS-v1.5"}
MOSS_DATASET_AUDIO_EXTENSIONS = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
def slugify(value: str) -> str:
@@ -65,6 +66,71 @@ def resolve_manifest_path(dataset_source: str) -> str:
raise FileNotFoundError(f"MOSS training manifest not found: {dataset_source}")
def _resolve_dataset_source_path(dataset_source: str) -> Path:
raw = os.path.expanduser(str(dataset_source or "").strip())
if not raw:
raise ValueError("dataset_source is required")
input_dir = folder_paths.get_input_directory()
candidates = [Path(raw), Path(input_dir, raw), Path(input_dir, "datasets", raw)]
for candidate in candidates:
if candidate.is_file() or candidate.is_dir():
return candidate.resolve()
raise FileNotFoundError(f"MOSS dataset source not found: {dataset_source}")
def _build_manifest_from_audio_folder(dataset_dir: Path, recursive: bool) -> str:
iterator = dataset_dir.rglob("*") if recursive else dataset_dir.iterdir()
audio_paths = sorted(
(path for path in iterator if path.is_file() and path.suffix.lower() in MOSS_DATASET_AUDIO_EXTENSIONS),
key=lambda path: str(path.relative_to(dataset_dir)).lower(),
)
if not audio_paths:
scope = "recursively" if recursive else ""
raise ValueError(f"No supported audio files found {scope} in MOSS dataset folder: {dataset_dir}")
records: List[Dict[str, str]] = []
missing_transcripts: List[str] = []
source_paths: List[str] = []
for audio_path in audio_paths:
transcript_path = audio_path.with_suffix(".txt")
if not transcript_path.is_file():
missing_transcripts.append(str(audio_path.relative_to(dataset_dir)))
continue
transcript = transcript_path.read_text(encoding="utf-8-sig").strip()
if not transcript:
missing_transcripts.append(str(audio_path.relative_to(dataset_dir)))
continue
records.append({"audio": str(audio_path.resolve()), "text": transcript})
source_paths.extend((str(audio_path), str(transcript_path)))
if missing_transcripts:
preview = ", ".join(missing_transcripts[:10])
remainder = len(missing_transcripts) - 10
if remainder > 0:
preview += f", and {remainder} more"
raise ValueError(
"Every MOSS dataset audio file needs a non-empty .txt transcript with the same basename. "
f"Missing or empty transcripts for: {preview}"
)
source_hash = fingerprint_paths(source_paths)
manifest_dir = Path(get_moss_training_root(), "imported_manifests")
manifest_path = manifest_dir / f"{slugify(dataset_dir.name)}_{source_hash[:12]}.jsonl"
if not manifest_path.is_file():
dump_jsonl(records, manifest_path)
print(f"MOSS dataset folder imported: {dataset_dir} | {len(records)} clips")
return str(manifest_path)
def resolve_moss_dataset_source(dataset_source: str, recursive: bool = False) -> str:
"""Resolve an existing JSONL manifest or import a folder of audio/.txt pairs."""
source_path = _resolve_dataset_source_path(dataset_source)
if source_path.is_file():
return str(source_path)
return _build_manifest_from_audio_folder(source_path, recursive=bool(recursive))
def fingerprint_paths(paths: Sequence[str]) -> str:
digest = hashlib.md5()
for path in paths:
+8 -6
View File
@@ -25,6 +25,7 @@ from engines.moss_tts.training.common import (
load_jsonl,
resolve_codec_path,
resolve_delay_training_variant,
resolve_moss_dataset_source,
resolve_model_path,
split_train_val,
slugify,
@@ -215,18 +216,19 @@ def prepare_moss_training_dataset(
n_vq: int = 0,
encode_reference_audio: bool = True,
reuse_existing: bool = True,
recursive_folder_scan: bool = False,
) -> Dict[str, Any]:
# Node UI uses prep_batch_size; keep batch_size for compatibility with older callers.
effective_batch_size = int(prep_batch_size) if int(prep_batch_size or 0) > 0 else int(batch_size)
variant = resolve_delay_training_variant(shared_settings)
train_manifest_path = os.path.abspath(dataset_source)
if not os.path.isfile(train_manifest_path):
raise FileNotFoundError(f"MOSS training manifest not found: {dataset_source}")
val_manifest_path = os.path.abspath(validation_source) if str(validation_source or "").strip() else ""
if val_manifest_path and not os.path.isfile(val_manifest_path):
raise FileNotFoundError(f"MOSS validation manifest not found: {validation_source}")
train_manifest_path = resolve_moss_dataset_source(dataset_source, recursive=recursive_folder_scan)
val_manifest_path = (
resolve_moss_dataset_source(validation_source, recursive=recursive_folder_scan)
if str(validation_source or "").strip()
else ""
)
fingerprint_inputs = [train_manifest_path]
if val_manifest_path:
+1
View File
@@ -14,6 +14,7 @@ _HANDLERS: Dict[str, Type[BaseTrainingHandler]] = {}
_HANDLER_MODULES = {
"rvc": "engines.rvc.training.handler",
"moss_tts": "engines.moss_tts.training.handler",
"dramabox": "engines.dramabox.training.handler",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

+67 -38
View File
@@ -15,6 +15,7 @@ import subprocess
import sys
import os
import platform
import importlib.machinery
import importlib.util
import hashlib
import json
@@ -177,8 +178,8 @@ class TTSAudioInstaller:
else:
self.log("All requirements.txt dependencies already satisfied", "SUCCESS")
def check_system_dependencies(self):
"""Check for required system libraries and provide helpful error messages"""
def check_system_dependencies(self):
"""Check optional system libraries and provide helpful feature warnings."""
if self.is_windows:
return True # Windows packages come pre-compiled
@@ -210,8 +211,8 @@ class TTSAudioInstaller:
return False
def check_macos_dependencies(self):
"""Check for required system libraries on macOS"""
def check_macos_dependencies(self):
"""Check for optional system libraries on macOS."""
self.log("Checking macOS system dependencies...", "INFO")
missing_deps = []
@@ -230,12 +231,12 @@ class TTSAudioInstaller:
pass
if missing_deps:
self.log("Missing system dependencies detected!", "WARNING")
print("\n" + "="*60)
print("MACOS SYSTEM DEPENDENCIES REQUIRED")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
self.log("Optional system dependencies are missing", "WARNING")
print("\n" + "="*60)
print("OPTIONAL MACOS SYSTEM DEPENDENCIES")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
print("\nPlease install with Homebrew:")
deps_list = " ".join([dep for dep, _ in missing_deps])
@@ -249,15 +250,19 @@ class TTSAudioInstaller:
print("Check with: python -c \"import platform; print(platform.machine())\"")
print("Should show: arm64 (not x86_64)")
print("="*60)
print("Then run this install script again.\n")
return False
self.log("macOS system dependencies check passed", "SUCCESS")
return True
def check_linux_dependencies(self):
"""Check for required system libraries on Linux"""
print("="*60)
print("Core TTS installation will continue; only the listed features may be unavailable.\n")
self.add_install_warning(
"Optional macOS audio libraries",
"Install the listed Homebrew packages to enable every audio feature.",
)
if not missing_deps:
self.log("macOS system dependencies check passed", "SUCCESS")
return True
def check_linux_dependencies(self):
"""Check for optional system libraries on Linux."""
self.log("Checking Linux system dependencies...", "INFO")
missing_deps = []
@@ -279,26 +284,34 @@ class TTSAudioInstaller:
pass
if missing_deps:
self.log("Missing system dependencies detected!", "WARNING")
print("\n" + "="*60)
print("LINUX SYSTEM DEPENDENCIES REQUIRED")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
self.log("Optional system dependencies are missing", "WARNING")
print("\n" + "="*60)
print("OPTIONAL LINUX SYSTEM DEPENDENCIES")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
print("\nPlease install with:")
print("# Ubuntu/Debian:")
deps_list = " ".join([dep for dep, _ in missing_deps])
print(f"sudo apt-get install {deps_list}")
print("\n# Fedora/RHEL:")
fedora_deps = deps_list.replace('-dev', '-devel').replace('19', '')
print(f"sudo dnf install {fedora_deps}")
print("="*60)
print("Then run this install script again.\n")
return False
self.log("Linux system dependencies check passed", "SUCCESS")
return True
fedora_packages = {
"libsamplerate0-dev": "libsamplerate-devel",
"portaudio19-dev": "portaudio-devel",
}
fedora_deps = " ".join(fedora_packages.get(dep, dep) for dep, _ in missing_deps)
print(f"sudo dnf install {fedora_deps}")
print("="*60)
print("Core TTS installation will continue; only the listed features may be unavailable.\n")
self.add_install_warning(
"Optional Linux audio libraries",
"Install the listed system packages to enable every audio feature.",
)
if not missing_deps:
self.log("Linux system dependencies check passed", "SUCCESS")
return True
def run_pip_command(self, args: List[str], description: str, ignore_errors: bool = False) -> bool:
"""Execute pip command with error handling and Windows UTF-8 support"""
@@ -507,7 +520,25 @@ class TTSAudioInstaller:
def module_available(self, module_name: str) -> bool:
"""Check module presence without starting another Python process or importing it."""
try:
return importlib.util.find_spec(module_name) is not None
parts = module_name.split(".")
spec = importlib.util.find_spec(parts[0])
if spec is None:
return False
# util.find_spec() imports the parent when given a dotted name.
# Walk the package paths directly so presence checks stay side-effect free.
for index in range(1, len(parts)):
search_locations = spec.submodule_search_locations
if search_locations is None:
return False
qualified_name = ".".join(parts[: index + 1])
spec = importlib.machinery.PathFinder.find_spec(
qualified_name,
search_locations,
)
if spec is None:
return False
return True
except (ImportError, ModuleNotFoundError, AttributeError, ValueError):
return False
@@ -1907,10 +1938,8 @@ def main():
else:
installer.ensure_requirements_installed() # Ensure requirements.txt is installed first
# Check system dependencies (Linux only)
if not installer.check_system_dependencies():
installer.log("System dependency check failed - aborting installation", "ERROR")
sys.exit(1)
# Report optional system dependencies without blocking engine setup.
installer.check_system_dependencies()
# Install in correct order to prevent conflicts
installer.install_pytorch_with_cuda() # Install PyTorch first with proper CUDA detection
+38 -2
View File
@@ -11,7 +11,7 @@ except ImportError:
pass
# Version and constants
VERSION = "5.6.0"
VERSION = "5.6.5"
IS_DEV = False # Set to False for release builds
VERSION_DISPLAY = f"v{VERSION}" + (" (dev)" if IS_DEV else "")
SEPARATOR = "=" * 70
@@ -500,6 +500,30 @@ except Exception as e:
print(f"❌ MOSS Dataset Rows failed: {e}")
MOSS_DATASET_ROWS_AVAILABLE = False
try:
dramabox_dataset_prep_module = load_node_module("dramabox_dataset_prep_node", "training/dramabox_dataset_prep_node.py")
DramaBoxDatasetPrepNode = dramabox_dataset_prep_module.DramaBoxDatasetPrepNode
DRAMABOX_DATASET_PREP_AVAILABLE = True
except Exception as e:
print(f"❌ DramaBox Dataset Prep failed: {e}")
DRAMABOX_DATASET_PREP_AVAILABLE = False
try:
dramabox_dataset_rows_module = load_node_module("dramabox_dataset_rows_node", "training/dramabox_dataset_rows_node.py")
DramaBoxDatasetRowsNode = dramabox_dataset_rows_module.DramaBoxDatasetRowsNode
DRAMABOX_DATASET_ROWS_AVAILABLE = True
except Exception as e:
print(f"❌ DramaBox Dataset Rows failed: {e}")
DRAMABOX_DATASET_ROWS_AVAILABLE = False
try:
dramabox_training_config_module = load_node_module("dramabox_training_config_node", "training/dramabox_training_config_node.py")
DramaBoxTrainingConfigNode = dramabox_training_config_module.DramaBoxTrainingConfigNode
DRAMABOX_TRAINING_CONFIG_AVAILABLE = True
except Exception as e:
print(f"❌ DramaBox Training Config failed: {e}")
DRAMABOX_TRAINING_CONFIG_AVAILABLE = False
try:
phoneme_text_normalizer_module = load_node_module("phoneme_text_normalizer_node", "text/phoneme_text_normalizer_node.py")
PhonemeTextNormalizer = phoneme_text_normalizer_module.PhonemeTextNormalizer
@@ -839,12 +863,24 @@ if MOSS_TRAINING_CONFIG_AVAILABLE:
if MOSS_CLIP_STAGING_AVAILABLE:
NODE_CLASS_MAPPINGS["MossClipStagingNode"] = MossClipStagingNode
NODE_DISPLAY_NAME_MAPPINGS["MossClipStagingNode"] = "🎞️ MOSS Clip Staging"
NODE_DISPLAY_NAME_MAPPINGS["MossClipStagingNode"] = "🎞️ Training Clip Staging"
if MOSS_DATASET_ROWS_AVAILABLE:
NODE_CLASS_MAPPINGS["MossDatasetRowsNode"] = MossDatasetRowsNode
NODE_DISPLAY_NAME_MAPPINGS["MossDatasetRowsNode"] = "🧾 MOSS Dataset Rows"
if DRAMABOX_DATASET_PREP_AVAILABLE:
NODE_CLASS_MAPPINGS["DramaBoxDatasetPrepNode"] = DramaBoxDatasetPrepNode
NODE_DISPLAY_NAME_MAPPINGS["DramaBoxDatasetPrepNode"] = "📦 DramaBox Dataset Prep"
if DRAMABOX_DATASET_ROWS_AVAILABLE:
NODE_CLASS_MAPPINGS["DramaBoxDatasetRowsNode"] = DramaBoxDatasetRowsNode
NODE_DISPLAY_NAME_MAPPINGS["DramaBoxDatasetRowsNode"] = "🧾 DramaBox Dataset Rows"
if DRAMABOX_TRAINING_CONFIG_AVAILABLE:
NODE_CLASS_MAPPINGS["DramaBoxTrainingConfigNode"] = DramaBoxTrainingConfigNode
NODE_DISPLAY_NAME_MAPPINGS["DramaBoxTrainingConfigNode"] = "🎛️ DramaBox Training Config"
# Register text processing nodes
if PHONEME_TEXT_NORMALIZER_AVAILABLE:
NODE_CLASS_MAPPINGS["PhonemeTextNormalizer"] = PhonemeTextNormalizer
+78 -1
View File
@@ -3,6 +3,7 @@
import importlib.util
import os
import sys
from typing import List
current_dir = os.path.dirname(__file__)
nodes_dir = os.path.dirname(current_dir)
@@ -18,6 +19,7 @@ base_spec.loader.exec_module(base_module)
BaseTTSNode = base_module.BaseTTSNode
from engines.dramabox.dramabox_downloader import DramaBoxDownloader
from utils.models.extra_paths import get_all_tts_model_paths
class DramaBoxEngineNode(BaseTTSNode):
@@ -144,7 +146,8 @@ class DramaBoxEngineNode(BaseTTSNode):
"default": "none",
"tooltip": (
"Official LTX FP8 weight-storage policy for the diffusion transformer. "
"fp8_cast lowers VRAM but upcasts each linear layer during inference."
"fp8_cast lowers VRAM but upcasts each linear layer during inference. "
"DramaBox LoRAs remain as an unmerged BF16 branch over the FP8 base."
),
}),
"compile_model": ("BOOLEAN", {
@@ -154,6 +157,27 @@ class DramaBoxEngineNode(BaseTTSNode):
"slower and may reserve more VRAM; later denoising can be faster."
),
}),
"local_lora_adapter": (cls._get_ui_lora_options(), {
"default": "None",
"tooltip": (
"Optional DramaBox audio LoRA discovered under models/TTS/dramabox/loras. "
"Training outputs are copied there when a run completes."
),
}),
"lora_adapter_override": ("STRING", {
"default": "",
"tooltip": (
"Advanced local path to a DramaBox LoRA file or adapter folder. "
"If filled, this overrides the local adapter dropdown."
),
}),
"lora_strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.05,
"tooltip": "Scale applied to the trained DramaBox LoRA. 1.0 uses the adapter's trained strength; 0 disables it.",
}),
},
}
@@ -179,7 +203,11 @@ class DramaBoxEngineNode(BaseTTSNode):
memory_mode: str = "fast",
transformer_quantization: str = "none",
compile_model: bool = False,
local_lora_adapter: str = "None",
lora_adapter_override: str = "",
lora_strength: float = 1.0,
) -> tuple:
lora_path = self._resolve_lora_adapter(local_lora_adapter, lora_adapter_override)
config = {
"engine_type": "dramabox",
"model_name": model_name,
@@ -197,6 +225,8 @@ class DramaBoxEngineNode(BaseTTSNode):
"memory_mode": str(memory_mode),
"transformer_quantization": str(transformer_quantization),
"compile_model": bool(compile_model),
"lora_path": lora_path,
"lora_strength": float(lora_strength),
}
print(f"⚙️ DramaBox: {model_name} on {device} ({precision})")
print(
@@ -206,6 +236,8 @@ class DramaBoxEngineNode(BaseTTSNode):
f"watermark={watermark}, memory_mode={memory_mode}, "
f"transformer_quantization={transformer_quantization}, compile={compile_model}"
)
if lora_path:
print(f" LoRA: {lora_path} (strength={float(lora_strength):.2f})")
print(" Prompt: dialogue in quotes; expressive stage directions outside quotes")
return ({
"engine_type": "dramabox",
@@ -213,6 +245,51 @@ class DramaBoxEngineNode(BaseTTSNode):
"capabilities": ["tts"],
},)
@classmethod
def _discover_local_loras(cls) -> List[str]:
discovered: List[str] = []
seen = set()
try:
for base_path in get_all_tts_model_paths("TTS"):
root = os.path.join(base_path, "dramabox", "loras")
if not os.path.isdir(root):
continue
for name in sorted(os.listdir(root)):
candidate = os.path.join(root, name)
if os.path.isdir(candidate):
has_weights = any(
filename.endswith(".safetensors")
for filename in os.listdir(candidate)
)
else:
has_weights = os.path.isfile(candidate) and candidate.endswith(".safetensors")
if has_weights and f"local:{name}" not in seen:
seen.add(f"local:{name}")
discovered.append(f"local:{name}")
except Exception:
pass
return discovered
@classmethod
def _get_ui_lora_options(cls) -> List[str]:
return ["None"] + cls._discover_local_loras()
@classmethod
def _resolve_lora_adapter(cls, local_value: str, override: str) -> str:
manual = str(override or "").strip()
if manual:
return os.path.abspath(os.path.expanduser(manual))
selected = str(local_value or "").strip()
if not selected or selected == "None":
return ""
if selected.startswith("local:"):
name = selected.split(":", 1)[1]
for base_path in get_all_tts_model_paths("TTS"):
candidate = os.path.join(base_path, "dramabox", "loras", name)
if os.path.exists(candidate):
return candidate
return os.path.abspath(os.path.expanduser(selected))
@staticmethod
def _validate_rescale_scale(value: str):
text = str(value).strip().lower()
@@ -0,0 +1,111 @@
"""DramaBox dataset normalization and official-preprocessor node."""
import importlib.util
import os
import sys
from engines.training.registry import get_training_handler
current_dir = os.path.dirname(__file__)
nodes_dir = os.path.dirname(current_dir)
project_root = os.path.dirname(nodes_dir)
if project_root not in sys.path:
sys.path.insert(0, project_root)
base_node_path = os.path.join(nodes_dir, "base", "base_node.py")
base_spec = importlib.util.spec_from_file_location("base_node_module", base_node_path)
base_module = importlib.util.module_from_spec(base_spec)
sys.modules["base_node_module"] = base_module
base_spec.loader.exec_module(base_module)
BaseTTSNode = base_module.BaseTTSNode
class DramaBoxDatasetPrepNode(BaseTTSNode):
@classmethod
def NAME(cls):
return "📦 DramaBox Dataset Prep"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"TTS_engine": ("TTS_ENGINE", {
"tooltip": "Connect a DramaBox engine. Its selected model supplies the official transformer, audio components, and Gemma paths.",
}),
"model_name": ("STRING", {
"default": "MyDramaBoxLoRA",
"tooltip": "Name used for the prepared dataset and eventual managed LoRA adapter.",
}),
"dataset_source": ("STRING", {
"default": "",
"tooltip": "JSONL/JSON manifest, TSV, gemini_synthetic index, or libriheavy index. Manifest rows should contain audio_filepath/audio_path and text/transcript.",
}),
"dataset_type": (["manifest", "tsv", "gemini_synthetic", "libriheavy"], {
"default": "manifest",
"tooltip": "Input format. The suite converts every format into the official ~-delimited speaker index used by the trainer.",
}),
},
"optional": {
"audio_dir": ("STRING", {
"default": "",
"tooltip": "Base folder for relative audio paths. Blank resolves paths relative to the dataset file.",
}),
"min_duration": ("FLOAT", {
"default": 2.0,
"min": 0.1,
"max": 60.0,
"step": 0.1,
"tooltip": "Minimum clip duration passed to the official preprocessor.",
}),
"max_duration": ("FLOAT", {
"default": 20.0,
"min": 0.5,
"max": 120.0,
"step": 0.5,
"tooltip": "Maximum clip duration passed to the official preprocessor.",
}),
"reuse_existing": ("BOOLEAN", {
"default": True,
"tooltip": "Reuse a matching normalized index and already-preprocessed cache when available.",
}),
"preprocess_now": ("BOOLEAN", {
"default": True,
"tooltip": "Run the official Gemma/audio-VAE preprocessing now. Turn this off to prepare only the CPU-side index and let Model Training preprocess later.",
}),
"dry_run": ("BOOLEAN", {
"default": False,
"tooltip": "CPU-safe index-only mode. No model download, Gemma load, or CUDA preprocessing is started.",
}),
},
}
RETURN_TYPES = ("TRAINING_DATASET", "STRING")
RETURN_NAMES = ("training_dataset", "dataset_info")
FUNCTION = "prepare_dataset"
CATEGORY = "TTS Audio Suite/🎓 Training"
def prepare_dataset(self, TTS_engine, model_name, dataset_source, dataset_type, **kwargs):
handler = get_training_handler("dramabox")
if handler is None:
raise RuntimeError("DramaBox training backend is not available")
dataset = handler.prepare_dataset(
TTS_engine,
dataset_source=dataset_source,
model_name=model_name,
dataset_type=dataset_type,
**kwargs,
)
info = (
f"DramaBox dataset ready: {dataset['model_name']} | "
f"{dataset['train_records']} clips | "
f"{len(dataset['speakers'])} speaker(s) | "
f"preprocessed={dataset.get('preprocessed', False)}"
)
print(f"📦 {info}")
return dataset, info
NODE_CLASS_MAPPINGS = {"DramaBoxDatasetPrepNode": DramaBoxDatasetPrepNode}
NODE_DISPLAY_NAME_MAPPINGS = {
"DramaBoxDatasetPrepNode": "📦 DramaBox Dataset Prep"
}
@@ -0,0 +1,197 @@
"""Build a DramaBox training manifest from engine-neutral staged clips."""
import importlib.util
import json
import os
import sys
from typing import List
import folder_paths
current_dir = os.path.dirname(__file__)
nodes_dir = os.path.dirname(current_dir)
project_root = os.path.dirname(nodes_dir)
if project_root not in sys.path:
sys.path.insert(0, project_root)
base_node_path = os.path.join(nodes_dir, "base", "base_node.py")
base_spec = importlib.util.spec_from_file_location("base_node_module", base_node_path)
base_module = importlib.util.module_from_spec(base_spec)
sys.modules["base_node_module"] = base_module
base_spec.loader.exec_module(base_module)
BaseTTSNode = base_module.BaseTTSNode
def _required_lines(raw_text: str, expected_count: int) -> List[str]:
lines = str(raw_text or "").splitlines()
if len(lines) != expected_count:
raise ValueError(
"DramaBox transcript line count mismatch: "
f"expected {expected_count} line(s), got {len(lines)}. "
"Enter exactly one transcript per staged clip."
)
return [line.strip() for line in lines]
def _optional_lines(raw_text: str, expected_count: int, field_name: str) -> List[str]:
lines = str(raw_text or "").splitlines()
if len(lines) > expected_count:
raise ValueError(
f"DramaBox {field_name} line count mismatch: expected at most "
f"{expected_count} line(s), got {len(lines)}."
)
return [line.strip() for line in lines] + [""] * (expected_count - len(lines))
class DramaBoxDatasetRowsNode(BaseTTSNode):
@classmethod
def NAME(cls):
return "🧾 DramaBox Dataset Rows"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip_dataset": ("TRAINING_CLIP_DATASET", {
"tooltip": "Staged audio from Training Clip Staging.",
}),
"manifest_name": ("STRING", {
"default": "dramabox_train.jsonl",
"tooltip": "Output manifest filename. .jsonl is appended when missing.",
}),
"transcript_lines": ("STRING", {
"default": "Hello there, this is a training sample.\nThis is the second sample from the same speaker.",
"multiline": True,
"tooltip": "Exactly one line per staged clip, in clip order. Blank lines skip the corresponding clip.",
}),
},
"optional": {
"speaker_lines": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional speaker name per clip. Blank lines use default_speaker.",
}),
"language_lines": ("STRING", {
"default": "",
"multiline": True,
"tooltip": "Optional language code per clip. Blank lines use default_language.",
}),
"default_speaker": ("STRING", {
"default": "speaker_1",
"tooltip": "Speaker assigned when the corresponding speaker line is blank. Each DramaBox speaker needs at least two clips.",
}),
"default_language": ("STRING", {
"default": "en",
"tooltip": "Language code assigned when the corresponding language line is blank.",
}),
"output_subdir": ("STRING", {
"default": "tts_audio_suite_training/dramabox/manifests",
"tooltip": "Subdirectory inside ComfyUI input/ for the generated manifest.",
}),
"overwrite": ("BOOLEAN", {
"default": True,
"tooltip": "Overwrite an existing manifest with the same name.",
}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("manifest_path", "manifest_info")
FUNCTION = "build_rows"
CATEGORY = "TTS Audio Suite/🎓 Training"
def build_rows(
self,
clip_dataset,
manifest_name: str,
transcript_lines: str,
speaker_lines: str = "",
language_lines: str = "",
default_speaker: str = "speaker_1",
default_language: str = "en",
output_subdir: str = "",
overwrite: bool = True,
):
if not isinstance(clip_dataset, dict) or clip_dataset.get("type") not in {
"training_clip_dataset",
"moss_clip_dataset",
}:
raise ValueError("clip_dataset must come from Training Clip Staging")
clips = clip_dataset.get("clips") or []
if not clips:
raise ValueError("clip_dataset contains no clips")
clip_count = len(clips)
transcripts = _required_lines(transcript_lines, clip_count)
speakers = _optional_lines(speaker_lines, clip_count, "speaker_lines")
languages = _optional_lines(language_lines, clip_count, "language_lines")
fallback_speaker = str(default_speaker or "").strip() or "speaker_1"
fallback_language = str(default_language or "").strip() or "en"
records = []
speaker_counts = {}
skipped_rows = 0
for index, clip in enumerate(clips):
if not transcripts[index]:
skipped_rows += 1
continue
speaker = speakers[index] or fallback_speaker
language = languages[index] or fallback_language
speaker_counts[speaker] = speaker_counts.get(speaker, 0) + 1
records.append({
"audio_filepath": str(clip["audio"]),
"text": transcripts[index],
"speaker": speaker,
"language": language,
"duration": float(clip["duration_seconds"]),
"sample_rate": int(clip["sample_rate"]),
"samples": round(
float(clip["duration_seconds"]) * int(clip["sample_rate"])
),
})
if not records:
raise RuntimeError(
"DramaBox Dataset Rows produced no records. Add at least two "
"non-empty transcripts for one speaker."
)
short_speakers = sorted(
speaker for speaker, count in speaker_counts.items() if count < 2
)
if short_speakers:
raise ValueError(
"DramaBox needs at least two clips per speaker. Speakers with only "
"one staged clip: " + ", ".join(short_speakers)
)
filename = str(manifest_name or "").strip() or "dramabox_train.jsonl"
if not filename.lower().endswith(".jsonl"):
filename += ".jsonl"
input_root = folder_paths.get_input_directory()
subdir = str(output_subdir or "").strip().strip("/\\")
output_dir = os.path.join(input_root, subdir) if subdir else input_root
os.makedirs(output_dir, exist_ok=True)
manifest_path = os.path.join(output_dir, filename)
if os.path.exists(manifest_path) and not overwrite:
raise FileExistsError(f"DramaBox manifest already exists: {manifest_path}")
with open(manifest_path, "w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
info = (
f"DramaBox manifest ready: {os.path.basename(manifest_path)} | "
f"{len(records)} clips | {len(speaker_counts)} speaker(s)"
)
if skipped_rows:
info += f" | skipped {skipped_rows} blank transcript row(s)"
print(f"🧾 {info}")
return manifest_path, info
NODE_CLASS_MAPPINGS = {"DramaBoxDatasetRowsNode": DramaBoxDatasetRowsNode}
NODE_DISPLAY_NAME_MAPPINGS = {
"DramaBoxDatasetRowsNode": "🧾 DramaBox Dataset Rows"
}
@@ -0,0 +1,194 @@
"""DramaBox IC-LoRA training configuration node."""
import importlib.util
import os
import sys
current_dir = os.path.dirname(__file__)
nodes_dir = os.path.dirname(current_dir)
project_root = os.path.dirname(nodes_dir)
if project_root not in sys.path:
sys.path.insert(0, project_root)
base_node_path = os.path.join(nodes_dir, "base", "base_node.py")
base_spec = importlib.util.spec_from_file_location("base_node_module", base_node_path)
base_module = importlib.util.module_from_spec(base_spec)
sys.modules["base_node_module"] = base_module
base_spec.loader.exec_module(base_module)
BaseTTSNode = base_module.BaseTTSNode
class DramaBoxTrainingConfigNode(BaseTTSNode):
@classmethod
def NAME(cls):
return "🎛️ DramaBox Training Config"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"training_mode": (["Audio LoRA (IC-LoRA)"], {
"default": "Audio LoRA (IC-LoRA)",
"tooltip": "Official DramaBox audio-branch IC-LoRA training mode.",
}),
"base_model": (["dev", "distilled"], {
"default": "dev",
"tooltip": "Official timestep schedule. dev is the normal DramaBox fine-tuning choice; distilled is experimental.",
}),
"steps": ("INT", {
"default": 10000,
"min": 1,
"max": 1000000,
"step": 100,
"tooltip": "Optimizer steps. The upstream example uses 10,000; listen to saved checkpoints instead of assuming the final step is best.",
}),
"learning_rate": ("FLOAT", {
"default": 1e-4,
"min": 1e-8,
"max": 1.0,
"step": 1e-6,
"tooltip": "LoRA learning rate. The official example uses 1e-4 for a fresh adapter.",
}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 32,
"step": 1,
"tooltip": "Per-device batch size. Keep this at 1 unless the dataset and GPU have room.",
}),
"grad_accum": ("INT", {
"default": 4,
"min": 1,
"max": 256,
"step": 1,
"tooltip": "Gradient accumulation steps. This increases effective batch size without loading more samples at once.",
}),
"lora_rank": ("INT", {
"default": 128,
"min": 1,
"max": 512,
"step": 1,
"tooltip": "LoRA rank. The official DramaBox example uses 128.",
}),
"lora_alpha": ("INT", {
"default": 128,
"min": 1,
"max": 1024,
"step": 1,
"tooltip": "LoRA alpha. Keeping alpha equal to rank gives a 1.0 adapter scale.",
}),
"lora_dropout": ("FLOAT", {
"default": 0.1,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "LoRA dropout. The official small-dataset example uses 0.1.",
}),
},
"optional": {
"lr_scheduler": (["cosine", "linear", "constant"], {
"default": "cosine",
"tooltip": "Learning-rate schedule passed to the official trainer.",
}),
"warmup_steps": ("INT", {
"default": 500,
"min": 0,
"max": 100000,
"step": 10,
"tooltip": "Warmup steps before the selected schedule. The official example uses 500.",
}),
"max_grad_norm": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 10.0,
"step": 0.1,
"tooltip": "Gradient clipping threshold.",
}),
"ref_ratio": ("FLOAT", {
"default": 0.3,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Fraction of a training target used as the appended voice-reference tail.",
}),
"max_ref_tokens": ("INT", {
"default": 200,
"min": 0,
"max": 4096,
"step": 1,
"tooltip": "Maximum reference tokens after audio patchification.",
}),
"text_dropout": ("FLOAT", {
"default": 0.4,
"min": 0.0,
"max": 1.0,
"step": 0.05,
"tooltip": "Probability of dropping text conditioning so the adapter learns to use the reference voice path.",
}),
"save_every": ("INT", {
"default": 500,
"min": 1,
"max": 100000,
"step": 10,
"tooltip": "Checkpoint cadence. The official trainer requires a value of at least 1.",
}),
"log_every": ("INT", {
"default": 10,
"min": 1,
"max": 10000,
"step": 1,
"tooltip": "Human-readable console update cadence. The training panel receives quieter per-step updates.",
}),
"seed": ("INT", {
"default": 42,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"tooltip": "Training random seed.",
}),
"preprocess_batch_size": ("INT", {
"default": 8,
"min": 1,
"max": 64,
"step": 1,
"tooltip": "Audio/text preprocessing batch size. Lower this if preprocessing runs out of memory.",
}),
"validation_config": ("STRING", {
"default": "",
"tooltip": "Optional path to the official val_config YAML. Validation launches another full inference process at each save step and requires a separate GPU.",
}),
"validation_gpu": ("STRING", {
"default": "",
"tooltip": "Physical CUDA device index reserved for validation, for example 1. Required when validation_config is set and must differ from the training GPU.",
}),
"dry_run": ("BOOLEAN", {
"default": False,
"tooltip": "CPU-safe preflight only: writes the normalized official config and command without loading DramaBox weights or starting CUDA training.",
}),
},
}
RETURN_TYPES = ("TRAINING_CONFIG", "STRING")
RETURN_NAMES = ("training_config", "config_info")
FUNCTION = "create_config"
CATEGORY = "TTS Audio Suite/🎓 Training"
def create_config(self, **kwargs):
kwargs["training_mode"] = "audio_lora"
config = {
"type": "training_config",
"engine_type": "dramabox",
**kwargs,
}
info = (
f"DramaBox audio LoRA config: {config['base_model']} | "
f"{config['steps']} steps | batch {config['batch_size']} | "
f"rank {config['lora_rank']} | lr {config['learning_rate']}"
)
return config, info
NODE_CLASS_MAPPINGS = {"DramaBoxTrainingConfigNode": DramaBoxTrainingConfigNode}
NODE_DISPLAY_NAME_MAPPINGS = {
"DramaBoxTrainingConfigNode": "🎛️ DramaBox Training Config"
}
+17 -17
View File
@@ -1,6 +1,4 @@
"""
MOSS clip staging node for unified training workflows.
"""
"""Engine-neutral audio clip staging for training workflows."""
import os
import re
@@ -59,7 +57,7 @@ class DynamicAudioOptionalInputs(dict):
def _slugify(value: str) -> str:
safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(value).strip())
safe = safe.strip("_")
return safe or "moss_dataset"
return safe or "training_dataset"
def _iter_audio_batches(waveform):
@@ -74,7 +72,7 @@ def _iter_audio_batches(waveform):
yield clip[None, :]
return
if waveform.ndim != 3:
raise ValueError(f"Unsupported audio tensor shape for MOSS clip staging: {tuple(waveform.shape)}")
raise ValueError(f"Unsupported audio tensor shape for clip staging: {tuple(waveform.shape)}")
for clip in waveform:
if clip.ndim == 1:
yield clip[None, :]
@@ -96,17 +94,19 @@ def _write_audio_clip(audio_tensor, sample_rate: int, output_path: str):
class MossClipStagingNode(BaseTTSNode):
"""Legacy class id retained so existing MOSS workflows keep loading."""
@classmethod
def NAME(cls):
return "🎞️ MOSS Clip Staging"
return "🎞️ Training Clip Staging"
@classmethod
def INPUT_TYPES(cls):
optional_inputs = DynamicAudioOptionalInputs(
{
"output_subdir": ("STRING", {
"default": "tts_audio_suite_training/moss_tts/staged_audio",
"tooltip": "Subdirectory inside ComfyUI input/ where staged MOSS training clips will be written."
"default": "tts_audio_suite_training/staged_audio",
"tooltip": "Subdirectory inside ComfyUI input/ where reusable training clips will be written."
}),
"overwrite": ("BOOLEAN", {
"default": True,
@@ -124,14 +124,14 @@ class MossClipStagingNode(BaseTTSNode):
return {
"required": {
"dataset_name": ("STRING", {
"default": "MyMossDataset",
"tooltip": "Base name for the staged clip set."
"default": "MyTrainingDataset",
"tooltip": "Base name for the staged clip set. The output can feed engine-specific Dataset Rows nodes."
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("MOSS_CLIP_DATASET", "STRING")
RETURN_TYPES = ("TRAINING_CLIP_DATASET", "STRING")
RETURN_NAMES = ("clip_dataset", "dataset_info")
FUNCTION = "stage_clips"
CATEGORY = "TTS Audio Suite/🎓 Training"
@@ -159,7 +159,7 @@ class MossClipStagingNode(BaseTTSNode):
):
audio_inputs = self._collect_audio_inputs(opt_audio1=opt_audio1, **kwargs)
if not audio_inputs:
raise ValueError("MOSS Clip Staging requires at least one connected AUDIO input")
raise ValueError("Training Clip Staging requires at least one connected AUDIO input")
dataset_slug = _slugify(dataset_name)
input_root = folder_paths.get_input_directory()
@@ -172,7 +172,7 @@ class MossClipStagingNode(BaseTTSNode):
import shutil
shutil.rmtree(dataset_dir)
else:
raise FileExistsError(f"MOSS staged clip folder already exists: {dataset_dir}")
raise FileExistsError(f"Staged clip folder already exists: {dataset_dir}")
os.makedirs(dataset_dir, exist_ok=True)
clips: List[Dict[str, object]] = []
@@ -201,18 +201,18 @@ class MossClipStagingNode(BaseTTSNode):
})
if not clips:
raise RuntimeError("MOSS Clip Staging produced no clips")
raise RuntimeError("Training Clip Staging produced no clips")
dataset = {
"type": "moss_clip_dataset",
"type": "training_clip_dataset",
"dataset_name": dataset_name,
"dataset_dir": dataset_dir,
"clips": clips,
}
info = f"MOSS clip dataset ready: {dataset_name} | {len(clips)} clips"
info = f"Training clip dataset ready: {dataset_name} | {len(clips)} clips"
print(f"🎞️ {info}")
return dataset, info
NODE_CLASS_MAPPINGS = {"MossClipStagingNode": MossClipStagingNode}
NODE_DISPLAY_NAME_MAPPINGS = {"MossClipStagingNode": "🎞️ MOSS Clip Staging"}
NODE_DISPLAY_NAME_MAPPINGS = {"MossClipStagingNode": "🎞️ Training Clip Staging"}
+10 -3
View File
@@ -41,9 +41,9 @@ class MossDatasetPrepNode(BaseTTSNode):
"dataset_source": ("STRING", {
"default": "",
"tooltip": (
"Path to the main MOSS manifest JSONL.\n"
"This is your training set manifest: one JSON row per clip.\n"
"In the normal workflow, connect the manifest path produced by MOSS Dataset Rows here."
"Path to a MOSS manifest JSONL or a folder of paired audio and transcript files.\n"
"For folders, use matching names such as clip001.wav + clip001.txt.\n"
"In the node workflow, connect the manifest path produced by MOSS Dataset Rows here."
)
}),
},
@@ -104,6 +104,13 @@ class MossDatasetPrepNode(BaseTTSNode):
"default": True,
"tooltip": "Reuse a matching prepared dataset cache instead of re-encoding audio codes every run."
}),
"recursive_folder_scan": ("BOOLEAN", {
"default": False,
"tooltip": (
"When dataset_source or validation_source is a folder, also scan its subfolders. "
"Disabled by default; direct files in the selected folder are always scanned."
)
}),
},
}
+7 -4
View File
@@ -58,8 +58,8 @@ class MossDatasetRowsNode(BaseTTSNode):
def INPUT_TYPES(cls):
return {
"required": {
"clip_dataset": ("MOSS_CLIP_DATASET", {
"tooltip": "Staged clip dataset from MOSS Clip Staging."
"clip_dataset": ("TRAINING_CLIP_DATASET", {
"tooltip": "Staged clip dataset from Training Clip Staging."
}),
"manifest_name": ("STRING", {
"default": "moss_train.jsonl",
@@ -224,8 +224,11 @@ class MossDatasetRowsNode(BaseTTSNode):
output_subdir: str = "",
overwrite: bool = True,
):
if not isinstance(clip_dataset, dict) or clip_dataset.get("type") != "moss_clip_dataset":
raise ValueError("clip_dataset must be a MOSS_CLIP_DATASET payload from MOSS Clip Staging")
if not isinstance(clip_dataset, dict) or clip_dataset.get("type") not in {
"training_clip_dataset",
"moss_clip_dataset",
}:
raise ValueError("clip_dataset must come from Training Clip Staging")
clips = clip_dataset.get("clips") or []
if not clips:
+1 -1
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.0"
version = "5.6.5"
license = {file = "LICENSE"}
[project.urls]
@@ -18,6 +18,25 @@ from engines.dots_tts.dots_tts_engine import DotsTTSEngine
from utils.runtimes.profiles import get_runtime_profile
@pytest.mark.unit
def test_nested_module_check_does_not_import_parent(tmp_path, monkeypatch):
package = tmp_path / "explosive_parent"
package.mkdir()
(package / "__init__.py").write_text(
"raise RuntimeError('parent package must not be imported')\n",
encoding="utf-8",
)
(package / "runtime.py").write_text("", encoding="utf-8")
monkeypatch.syspath_prepend(str(tmp_path))
monkeypatch.delitem(sys.modules, "explosive_parent", raising=False)
installer = INSTALL_MODULE.TTSAudioInstaller()
assert installer.module_available("explosive_parent.runtime") is True
assert installer.module_available("explosive_parent.missing") is False
assert "explosive_parent" not in sys.modules
@pytest.mark.unit
def test_fish_source_restore_accepts_namespace_package(tmp_path, monkeypatch):
source_root = tmp_path / "source"
+3
View File
@@ -598,6 +598,9 @@ class DramaBoxCacheKeyGenerator(CacheKeyGenerator):
'transformer_quantization': params.get('transformer_quantization', 'none'),
'memory_mode': params.get('memory_mode', 'fast'),
'compile_model': bool(params.get('compile_model', False)),
'lora_path': params.get('lora_path', ''),
'lora_strength': round(float(params.get('lora_strength', 1.0)), 4),
'lora_revision': params.get('lora_revision', ''),
'seed': params.get('seed', 42),
'character': params.get('character', 'narrator'),
'engine': 'dramabox',
+2
View File
@@ -162,6 +162,8 @@ ENGINE_REGISTRY: Dict[str, EngineCapabilities] = {
multilingual_model_switching=False,
can_corrupt_on_reload=False,
fallback_languages=["English"],
supports_training=True,
training_modes=["audio_lora"],
supports_runtime_isolation=False,
),