Skip to content

Add NeuTTS-2E CoreML conversion (Qwen3 LM + NeuCodec decoder) - #79

Open
Alex-Wengg wants to merge 5 commits into
mainfrom
feat/neutts-2e-coreml
Open

Add NeuTTS-2E CoreML conversion (Qwen3 LM + NeuCodec decoder)#79
Alex-Wengg wants to merge 5 commits into
mainfrom
feat/neutts-2e-coreml

Conversation

@Alex-Wengg

Copy link
Copy Markdown
Member

Model

neuphonic/neutts-2e — emotional English TTS (7 emotions × 4 fixed speakers): Qwen3ForCausalLM 236M backbone (28L, hidden 512, 12Q/4KV, head_dim 128, vocab 217232 with tied embeddings) emitting NeuCodec FSQ speech tokens, decoded to 24 kHz by the NeuCodec decoder (FSQ dequant → ResNet/transformer vocos backbone → ISTFT head).

Published artifacts: FluidInference/neutts-2e-coreml (6 mlpackages + speaker refs + NeuTTS Open License v1.0).

What's here

models/tts/neutts-2e/coreml/: self-contained uv project — gen-pytorch-ref.py (seeded PyTorch reference), convert-lm.py (prefill + pass-through decode macOS14 + StateType stateful decode macOS15, --max-len variants), convert-codec.py (flexible-length codec via RangeDim), compress-lm.py (int8, size-only), inference.py (end-to-end CoreML synthesis, --stream windowed vocoding, --teacher-force parity replay), src/ wrappers.

Validation

  • LM wrappers vs HF fp32: max|Δ| ≤ 3.1e-5, argmax match; CoreML fp16 vs torch: argmax match; stateful ≡ pass-through bit-identical.
  • Codec: wrapper 123.9 dB SNR vs neucodec.decode_code (fp32); CoreML fp16 40–47 dB across lengths.
  • Teacher-forced replay of 301-token PyTorch reference: 98.7% top-50 containment; final audio 41.5 dB SNR vs PyTorch waveform.
  • Perf (M5 Pro): decode 7.0 ms/tok (M=1024) / 9.1 (M=2048) on GPU, codec 12.7–27× RT on ANE, batch ≈2× RT, streaming TTFA ≈554 ms with 0% ASR round-trip WER (parakeet-tdt-v3), including a 23 s paragraph.

Deviations / findings (documented in README)

  • torch pinned <2.8 (newer torch traces shape unpacking/repeat_interleave/shape[-1]//2 into aten::Int nodes coremltools 9.0 cannot fold).
  • fp16 compute pass corrupts integer arithmetic >2048 → FSQ dequant is a [65536, 8] embedding gather.
  • Upstream bs_roformer5 misuses torchtune RoPE on [b, h, t, d] → rotation by head index, constant over time; replicated (this is also what keeps the codec length axis flexible).
  • Measured dead-ends: int8 = zero decode speedup (dispatch-latency-bound); pure-fp16 RMSNorm overflows; in-model topk is net slower and hits a CoreML kernel bug (topk over >2^17-wide intermediates returns indices mod 131072).
  • ANE rejects the LM decode graph (ANECCompile -14), matching the Qwen3-0.6B finding.

Converts neuphonic/neutts-2e (emotional English TTS, Qwen3 236M backbone)
and the NeuCodec FSQ decoder to CoreML:

- LM-Prefill (macOS14), LM-Decode pass-through-KV (macOS14) and stateful
  StateType-KV (macOS15) mlpackages, fp16 with fp32 norm/softmax ops.
  Tied embedding/LM-head shared through one parameter so const-dedup
  stores the [217232, 512] vocab matrix once per package.
- NeuCodec decoder with flexible code length (RangeDim). FSQ dequant is a
  precomputed [65536, 8] embedding gather (fp16 pass corrupts integer
  arithmetic above 2048). Replicates the upstream bs_roformer5 RoPE quirk
  (torchtune rope applied to [b, h, t, d] rotates by head index, constant
  over time), which is also what keeps the length axis flexible. Vocos
  same-padding ISTFT reimplemented as IDFT 1x1 convs + ConvTranspose1d
  overlap-add.
- gen-pytorch-ref.py (seeded PyTorch reference), inference.py (full CoreML
  synthesis + teacher-forced parity replay).

Parity: LM CoreML fp16 max-abs-diff 0.024/0.081 vs torch fp32 with argmax
match; stateful decode bit-identical to pass-through. Codec CoreML SNR
40-47 dB vs PyTorch across lengths. Teacher-forced replay keeps 98.7% of
reference tokens inside the CoreML top-50 support; final audio 41.5 dB
vs the PyTorch waveform. Sampled decode 13 ms/token (~77 tok/s vs 50
needed for real-time), codec 2.7-9.6x RT on M5 Pro.

torch pinned <2.8: newer torch traces shape unpacking, repeat_interleave
and shape[-1]//2 into aten::Int/floor_divide nodes coremltools 9.0 cannot
fold; wrappers use static ints + expand/reshape GQA for the same reason.
Same-sentence benchmark vs FluidAudio engines (fluidaudiocli --metrics,
parakeet round-trip WER, M5 Pro): Supertonic-3 44x / KokoroAne 6.3x /
PocketTTS 4.8x / NeuTTS-2E 1.06x RTFx; NeuTTS-2E and KokoroAne both 0% WER.
Also records the discovery that the hosted StyleTTS2 sized BERT/diffusion
mlmodelc bundles are missing model.mil (long texts fail with corruptedModel).
- inference.py --stream: upstream 25-frame windowed overlap-add vocoding
  over the flexible-length CoreML codec, ref codes as initial lookback.
  TTFA ~650 ms at every text length, inter-chunk ~340 ms (500 ms budget),
  0% WER (identical transcription to batch).
- Compute-unit sweep: codec is ~2x faster on CPU_AND_NE (22-27x RT) than
  GPU despite RangeDim — corrected the docstring claim; LM decode best on
  GPU (9.2 ms/tok warm; CPU_AND_NE breaks the coremltools state API;
  CPU_ONLY still real-time). inference.py now defaults LM=ALL, codec=NE.
- Multi-length benchmark matrix (5/14/53 words x 3 runs) vs KokoroAne,
  PocketTTS, Supertonic-3, StyleTTS2; NeuTTS-2E batch 1.3-1.7x RT,
  stream 1.4-1.5x RT, 0% WER on medium/long incl. the 23s paragraph.
- StyleTTS2 corruptedModel root cause: FluidAudio downloader drops
  model.mil from sized mlmodelc bundles (HF assets are complete); cache
  patched via curl for the benchmark.
convert-lm.py --max-len 1024 pair: stateful decode 7.0 ms/tok pure
(143 tok/s) vs 9.1 at M=2048; end-to-end batch ~2.0x RT, streaming TTFA
554 ms / inter-chunk 303 ms, verbatim ASR. inference.py caps generation
at the decode state capacity; compress-lm.py added (int8 halves disk,
no speed change).

Measured dead-ends recorded in README: int8 no speedup (decode is
dispatch-latency-bound, not bandwidth-bound); pure-fp16 RMSNorm
overflows (0/50 top-50 overlap); in-model top-k head is net slower and
hits a CoreML kernel bug (topk over >2^17-wide intermediate returns
indices mod 131072, values correct, fp16+fp32, chunking doesn't help;
op is correct on a standalone input-fed model). ANE rejects the decode
graph (ANECCompile -14), matching the Qwen3-0.6B finding.
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.

1 participant