Skip to content

Add Irodori-TTS v4 support - #881

Merged
Lazarus-931 merged 7 commits into
Blaizzy:mainfrom
yoshphys:feature/irodori-tts-v4-pr
Aug 12, 2026
Merged

Add Irodori-TTS v4 support#881
Lazarus-931 merged 7 commits into
Blaizzy:mainfrom
yoshphys:feature/irodori-tts-v4-pr

Conversation

@yoshphys

Copy link
Copy Markdown
Contributor

Adds support for Irodori-TTS v4-Small, and fixes several divergences from the reference implementation that were found while validating it.

v4 support

v4-Small unifies the previous base and VoiceDesign models into one checkpoint. Compared to v3 the architecture change is narrow: the two scratch-trained text/caption encoders are replaced by a single pretrained ModernBERT-ja-310m backbone feeding separate projectors, and speaker_patch_size goes from 1 to 4. The DiT, adaLN, speaker encoder and duration predictor are unchanged.

  • New modernbert.py: MLX port of ModernBERT — fused Wqkv, GeGLU MLP, alternating global / sliding-window attention with per-layer-type RoPE theta, and layer 0's identity attn_norm.
  • PretrainedTextBackbone + PretrainedConditionProjector, used when text_encoder_type is "pretrained". v1–v3 keep the scratch encoders.
  • The tokenizer bundled with the converted weights is preferred over the upstream repo, so inference needs no extra download.
  • Multi-clip reference audio: clips are encoded separately and concatenated, then trimmed to the checkpoint's ref_max_seconds (120s for v4, 30s before). This matches how v4 was trained.

Converted models: mlx-community/Irodori-TTS-v4-Small-fp16 and -8bit.

Fixes that also change v1–v3 behaviour

Validating v4 against the reference implementation surfaced pre-existing bugs. normalize_text had drifted in ways that change what the model is asked to read, so existing checkpoints will sound different (closer to the reference):

  • Sentence-final and were stripped. Upstream keeps them, and they carry prosody — dropping the shortened predicted durations and clipped endings.
  • ASCII spaces (U+0020) were removed, so "hello world" became "helloworld". Only the ideographic space U+3000 should go.
  • .. / ... were not folded to .
  • NFKC normalization was missing, leaving or untouched.
  • Outer-bracket stripping ran once and ignored nesting depth, so 「前半」と「後半」 lost its outer quotes.

Also fixed, affecting every caption-conditioned checkpoint (v2 VD, v3 VD, v4):

  • An absent caption still tokenizes to a BOS token and its mask was left set, so the DiT and duration predictor saw a one-token caption instead of none. Upstream zeroes the mask.
  • The trailing-silence trim was applied unconditionally, so a silence point of 0 would have produced zero-length audio.
  • use_cfg ignored has_caption_cfg, and has_caption_cfg did not check that the caption mask is non-empty.

And one v4-only bug: PretrainedConditionProjector coerced its input to self.projector.weight.dtype. After nn.quantize that weight is packed uint32, so activations were truncated to integers — a no-op in fp16, but it put the 8-bit projector output 79% off.

Verification

Each stage was compared against the reference PyTorch implementation on the real v4 checkpoint:

Stage Agreement
ModernBERT encoder (fp32, random weights, vs transformers) 4.8e-7
normalize_text (3035 strings incl. 3000 fuzz) exact
Tokenization (text/caption, truncation, padding) exact
Duration features (14-dim) exact
Duration prediction exact (0.0000%)
text_state (backbone + projector + norm) 0.09%
DiT forward (v_pred) 0.46%

The two non-exact rows are fp16-vs-fp32 weight error.

Known model behaviour (not a port issue)

With a caption but no reference audio, v4 roughly doubles the predicted duration for short texts (under ~7 tokens) and reads the sentence twice to fill the window. Running the reference PyTorch sampler produces the same frame counts and the same repetition, so this is upstream model behaviour. It is documented in the model README along with the workarounds (pass reference audio, or set duration_scale / seconds).

Tests

79 Irodori tests pass (13 new for v4, 4 for caption/tail-trim handling, 8 for normalization including a differential check against a local upstream checkout when present). Full test_models.py: 441 passed, 1 pre-existing unrelated failure (TestSparkTTSModel::test_init).

🤖 Generated with Claude Code

@lucasnewman

Copy link
Copy Markdown
Collaborator

@yoshphys Very nicely done, thank you! Can you sign your commits and force push so we can clear the merge requirements?

yoshphys and others added 7 commits August 11, 2026 08:29
Irodori-TTS v4-Small replaces the two scratch-trained text/caption
encoders with a single pretrained ModernBERT-ja-310m backbone feeding
separate projectors, and raises speaker_patch_size to 4.

- Port ModernBERT to MLX (modernbert.py): fused Wqkv, GeGLU MLP,
  alternating global/sliding-window attention with per-layer-type RoPE
  theta, and layer 0's identity attn_norm. Verified against
  transformers on random weights to 5e-7.
- Add PretrainedTextBackbone + PretrainedConditionProjector and route
  text/caption encoding through them when text_encoder_type is
  "pretrained"; scratch encoders remain the default for v1-v3.
- Load the tokenizer bundled with the converted weights when present,
  so inference no longer depends on the upstream tokenizer repo.
- Support multi-clip reference audio: clips are encoded separately and
  concatenated, then trimmed to the checkpoint's ref_max_seconds (120s
  for v4, 30s before).
- Size the no-reference placeholder latent to speaker_patch_size, which
  otherwise patches down to an empty sequence at v4's patch size 4.

The sliding-window mask keeps its diagonal open so padding queries far
from any real token cannot produce an all-masked softmax row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nn.quantize turns the projection into a QuantizedLinear whose weight is
packed uint32, so aligning the backbone activations to
self.projector.weight.dtype truncated them to integers. This was a no-op
in fp16, so only quantized v4 builds were affected: the text/caption
projector output landed 79% off and generation was audibly broken.

Dropping the cast brings the 8-bit projector back to 0.7% of fp16, and
text_state to 0.7% of the fp32 PyTorch reference. Adds a regression test
that quantizes a small v4 model and checks the projector output scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two divergences from the reference implementation, both affecting
caption-conditioned checkpoints (v2 VoiceDesign, v3 VoiceDesign, v4):

- An absent caption still tokenizes to a BOS token, and its mask was
  left set, so the DiT and the duration predictor saw a one-token
  caption instead of no caption at all. Upstream zeroes the caption mask
  when the caption text is empty. For v4 voice cloning without a caption
  this shortened the predicted duration by ~4% (79 vs 82 frames) and cut
  the tail of the utterance.
- The trailing-silence trim was applied unconditionally, so a silence
  point of 0 would have produced zero-length audio. Upstream only
  applies the trim when the flattening point is positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
normalize_text had drifted from Irodori-TTS/irodori_tts/text_normalization.py
in ways that changed what the model is actually asked to read. All of these
predate v4 and affect every version:

- Sentence-final 。 and 、 were stripped. Upstream keeps them, and they carry
  prosody: dropping the 。 shortened predicted durations and clipped endings.
- ASCII spaces (U+0020) were removed, so "hello world" became "helloworld".
  Only the ideographic space U+3000 should go.
- ".." / "..." were not folded to "…".
- NFKC normalization was missing; only fullwidth alnum and halfwidth kana were
  approximated by hand, leaving ㈱ or Ⅲ untouched.
- Outer-bracket stripping ran once and ignored nesting depth, so
  「前半」と「後半」 lost its outer quotes and ((x)) only lost one level.

The caption is now stripped (not normalized), matching upstream, and the
reported token count uses the mask instead of the padded width.

Adds a differential test that runs both implementations over a fuzz corpus
when an upstream checkout is present, plus explicit cases for each behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- use_cfg ignored has_caption_cfg, so a caption-only checkpoint run with
  cfg_scale_text=0 would have skipped guidance entirely.
- has_caption_cfg did not check that the caption mask is non-empty. With
  an empty caption the caption-uncond bundle is identical to the
  conditional one, so its guidance term is zero: computing it just cost
  an extra batch slot per step.

Also documents that v4 over-predicts duration for short caption-only
prompts and repeats the utterance to fill the window. That is upstream
model behaviour: the reference PyTorch implementation predicts the same
frame counts (verified to 0.0000%) and repeats identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The normalization differential test looked for the reference repository
at a single hard-coded location. Honour IRODORI_TTS_UPSTREAM first so it
can run wherever the checkout lives, and keep skipping when absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yoshphys
yoshphys force-pushed the feature/irodori-tts-v4-pr branch from e9cc914 to e5dc2a7 Compare August 10, 2026 23:46
@Lazarus-931
Lazarus-931 merged commit 76f8363 into Blaizzy:main Aug 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants