Skip to content

feat(tts/kokoro-v1.1-zh): CoreML conversion + atan2 phase noise fix#50

Merged
Alex-Wengg merged 10 commits into
mainfrom
feat/kokoro-v1.1-zh-coreml
May 4, 2026
Merged

feat(tts/kokoro-v1.1-zh): CoreML conversion + atan2 phase noise fix#50
Alex-Wengg merged 10 commits into
mainfrom
feat/kokoro-v1.1-zh-coreml

Conversation

@Alex-Wengg

Copy link
Copy Markdown
Member

Summary

  • Adds the seven-stage CoreML conversion pipeline for Kokoro-82M-v1.1-zh (Mandarin) — ALBERT, PostAlbert, Alignment, Prosody, Noise, Vocoder, Tail.
  • Fixes the audible high-frequency noise that survived every weight-quantisation, compute-precision, and dispatch-mode change tried in rounds 1–2 of the investigation. Root cause was a missing torch.atan2 PyTorch/ONNX-divergence correction in CoreMLForwardSTFT.
  • Ships before/after/reference WAVs in docs/audio/ for direct A/B listening on GitHub.

Root cause

CoreMLForwardSTFT.transform (convert-coreml.py:432-456) was missing the same atan2 correction the upstream kokoro/custom_stft.py:135-138 already documents:

input PyTorch atan2 CoreML MIL atan2
(imag = 0, real < 0) 0

For real-valued STFT input the DC bin's imag is exactly 0 and the Nyquist bin's imag sits at the fp32 noise floor (~1e-15). Whenever real < 0 at those bins, the converted CoreML graph emitted phase = 0 instead of +π. The π/2π offset propagated through noise_convs[0] (a strided conv that mixes 11 frequency bins) into all 256 channels of x_source_0, surfacing as broad-spectrum noise above 10 kHz — present even in a pure-fp32 build on cpuOnly.

The fix

Two-stage clip-then-correct in CoreMLForwardSTFT.transform:

eps = 1e-5
imag_clipped = torch.where(imag_out.abs() < eps,
                           torch.zeros_like(imag_out), imag_out)
magnitude = torch.sqrt(real_out ** 2 + imag_clipped ** 2 + 1e-14)
phase = torch.atan2(imag_clipped, real_out)
correction_mask = (imag_clipped == 0) & (real_out < 0)
phase = torch.where(correction_mask,
                    torch.full_like(phase, math.pi), phase)

eps = 1e-5 catches both the exact-zero (DC) and computational-zero (Nyquist) cases without affecting any legitimate spectral imag value (≥ 1e-3 for typical audio). The where() patches the PyTorch branch.

Verification

KokoroNoise conversion fidelity (PyTorch trace vs CoreML, deterministic)

metric before fix after fix
x_source_0 rel-rms 0.397 0.057 (7×)
x_source_0 corr 0.911 0.998
x_source_1 rel-rms 0.070 0.002
x_source_1 corr 0.996 1.000
STFT phase max|diff| 6.283 (2π) 0.000

End-to-end audio HF (≥10 kHz) band power

zm_009 before fix after fix
CoreML output −81.31 dB −83.91 dB
PyTorch reference −83.81 dB −83.90 dB
Δ(CoreML − PyTorch) +2.50 dB −0.01 dB
HF residual (coreml − pt) −86.55 dB −91.18 dB

zf_001 Δ(CoreML − PyTorch): +0.11 dB (no regression).

Reference audio (download from docs/audio/)

All 24 kHz mono float32. The zm_009 triplet is the same prompt (你好世界,今天天气很好。) so the residual is the noise-stage delta.

Investigation rounds

The full story is in report.md:

  • Round 1 — ruled out weight precision (int8 → fp32), compute precision (FLOAT16 → FLOAT32), dispatch (ANE/GPU/CPU), voice timbre offset, and the cos-Snake1D identity rewrite.
  • Round 2 — localised noise to KokoroNoise via a five-tier stage-swap chain (per_stage_diff.py); misdiagnosed the cause as sin-of-large-args inside CoreMLSineGenV2 because the substage probe conflated rand_ini randomness with graph drift.
  • Round 3 — conversion-fidelity diff (probe_noise_fidelity.py) immediately exposed phase max|diff| = 2π, pointing at atan2. A wrap-before-sin attempt actually made x_source_0 worse (rel 0.025 vs 0.0009) before the right fix landed.

Diagnostic scripts (all self-contained, in models/tts/kokoro-v1.1-zh/coreml/)

script what it does
tail_compare.py three-way iSTFT comparison (torch.istft, CustomSTFT, CoreML Tail) on the same x_pre
vocoder_xpre_diff.py Vocoder x_pre PT-vs-CoreML diff
per_stage_diff.py five-tier stage-swap chain (full PT → … → full CoreML) with HF-band tracking
probe_noise.py CU sweep + per-channel divergence on the noise stage
probe_noise_fidelity.py conversion fidelity of CoreMLFullNoiseModel (the script that found the atan2 bug)
probe_sinegen_isolated.py six standalone CoreML models that disprove the Round 2 sin-precision hypothesis

Test plan

  • convert-coreml.py --stages noise --no-palettize noise produces a valid KokoroNoise.mlpackage.
  • probe_noise_fidelity.py reports x_source_0 rel-rms ≤ 0.06, x_source_1 ≤ 0.003.
  • per_stage_diff.py on zm_009 shows audio_D HF within 0.01 dB of audio_A.
  • per_stage_diff.py on zf_001 shows ≤ 0.15 dB HF delta (no regression on the female voice).
  • inference.py produces a 24 kHz WAV indistinguishable in HF band from the PyTorch reference.
  • Listening test on a Mac/iPhone in the deployment app (FluidAudio) to confirm the noise is perceptually gone.

🤖 Generated with Claude Code

Alex-Wengg and others added 7 commits May 3, 2026 17:28
Adds the seven-stage CoreML conversion pipeline for Kokoro-82M-v1.1-zh
(Mandarin: ALBERT, PostAlbert, Alignment, Prosody, Noise, Vocoder, Tail)
together with the diagnostic story and fix for the audible high-frequency
noise that survived every weight-quantisation, compute-precision, and
dispatch-mode change tried in rounds 1–2 of the investigation.

## Root cause

`CoreMLForwardSTFT.transform` (convert-coreml.py:432-456) was missing the
atan2 PyTorch/ONNX-divergence correction documented in
kokoro/custom_stft.py:135-138.

  - PyTorch:  atan2(0, real<0) = +π
  - CoreML:   atan2(0, real<0) =  0

For real-valued STFT input the DC bin's imag is exactly 0 and the Nyquist
bin's imag sits at the fp32 noise floor (~1e-15). Whenever real<0 at
those bins, the converted CoreML graph produced phase=0 instead of +π.
The π/2π offset propagated through `noise_convs[0]` (a strided conv that
mixes 11 frequency bins) into all 256 channels of `x_source_0`,
appearing as broad-spectrum noise above 10 kHz.

## Fix

Two-stage clip-then-correct in `CoreMLForwardSTFT.transform`:

  eps = 1e-5
  imag_clipped = where(|imag| < eps, 0, imag)
  phase = atan2(imag_clipped, real)
  phase = where((imag_clipped == 0) & (real < 0), +π, phase)

The clip catches both the exact-zero (DC) and computational-zero
(Nyquist) cases without affecting any legitimate spectral imag value
(≥ 1e-3 for typical audio). The where() patches the PyTorch branch.

## Verification

KokoroNoise conversion fidelity (PyTorch trace vs CoreML, deterministic):

  metric                          before fix    after fix
  x_source_0 rel-rms              0.397         0.057       (7×)
  x_source_0 corr                 0.911         0.998
  x_source_1 rel-rms              0.070         0.002
  x_source_1 corr                 0.996         1.000
  STFT phase max|diff|            6.283 (2π)    0.000

End-to-end audio HF (≥10 kHz) band power:

  zm_009                          before fix    after fix
  CoreML output                   −81.31 dB     −83.91 dB
  PyTorch reference               −83.81 dB     −83.90 dB
  Δ(CoreML − PyTorch)             +2.50 dB      −0.01 dB
  HF residual (coreml − pt)       −86.55 dB     −91.18 dB

  zf_001 Δ(CoreML − PyTorch):     +0.11 dB

Reference WAVs (3.5 s @ 24 kHz, mono float32) ship in
docs/audio/ for direct A/B listening on GitHub.

## Investigation rounds

Round 1 (TRIALS.md, report.md): ruled out weight precision, compute
precision, dispatch mode, voice timbre offset, and the cos-Snake1D
identity rewrite. ~6 weeks of dead ends.

Round 2 (report.md): localised noise to KokoroNoise via three-tier
swap (per_stage_diff.py); misdiagnosed the cause as sin-of-large-args
inside CoreMLSineGenV2 because the Round 2 substage probe conflated
rand_ini randomness with graph drift.

Round 3 (report.md): conversion-fidelity diff (probe_noise_fidelity.py)
immediately exposed phase max|diff|=2π, pointing at atan2. The wrap-
before-sin fix attempted first actually made x_source_0 worse; the real
fix was the atan2 correction.

## Diagnostic scripts

  tail_compare.py            three-way iSTFT comparison (torch.istft,
                             CustomSTFT, CoreML Tail) on the same x_pre
  vocoder_xpre_diff.py       Vocoder x_pre PT-vs-CoreML diff
  per_stage_diff.py          five-tier stage-swap chain (full PT → ... →
                             full CoreML) with HF-band tracking
  probe_noise.py             CU sweep + per-channel divergence on the
                             noise stage
  probe_noise_fidelity.py    conversion fidelity of CoreMLFullNoiseModel
                             (the script that found the atan2 bug)
  probe_sinegen_isolated.py  six standalone CoreML models that disprove
                             the Round 2 sin-precision hypothesis

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the full background-noise investigation chronologically — six
weeks of dead ends followed by the two-line fix.

  Trial 6  — Round 1: precision & quantization sweep (6 sub-trials, all
             eliminated). Side findings on zm_009 timbre offset.
  Trial 7  — Round 2: Snake1D cos rewrite (algebraic + fp16 cancellation
             both ruled out).
  Trial 8  — Round 3: iSTFT/Tail comparison. Tail bit-equivalent to
             torch.istft; Vocoder bit-equivalent to PyTorch; localised
             noise to KokoroNoise via 5-tier stage swap.
  Trial 9  — Round 3 wrong turn: phase wrapping inside CoreMLSineGenV2.
             Made things 27x worse. probe_sinegen_isolated.py shows
             CoreML's sin handles 22 000-rad arguments fine.
  Trial 10 — Round 3 root cause: torch.atan2 returns 0 for (imag=0,
             real<0) where PyTorch returns +π. Hits DC + Nyquist STFT
             bins on every real-valued input.
  Trial 11 — The fix: clip near-zero imag, apply +π correction.
             eps iteration record (1e-9 → 1e-5).
  Trial 12 — Verification: conversion fidelity, HF band power matched
             to PyTorch within 0.01 dB on zm_009 and 0.11 dB on zf_001.

Existing TRIALS.md (Trials 0-5, pre-conversion) preserved verbatim;
new content appended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the rebuilt KokoroNoise.{mlpackage,mlmodelc} into build/ANE-zh/ so
reviewers can verify the fix without re-running convert-coreml.py. Both
bundles use --no-palettize noise (pure fp32 weights) and contain the
atan2 phase-correction at convert-coreml.py:432-456.

Force-added through both layers of .gitignore (root *.mlmodelc/*.mlpackage
and models/tts/kokoro-v1.1-zh/coreml/.gitignore build/) per request — the
~35 MB cost is accepted to keep the verifiable artifact attached to the
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous build (commit f8fca2c) was --no-palettize for diagnostic A/B
parity. This re-export uses the default int8 kmeans palettization (matching
all six other CoreML stages on the HF repo), so the bundle is now suitable
for direct upload to FluidInference/kokoro-82m-coreml/ANE-zh/.

Verification — fix unchanged by palettization:

  zm_009  HF (>=10 kHz) audio band power
    PyTorch reference   : -83.91 dB
    CoreML (palettized) : -83.90 dB     delta = +0.01 dB

  zf_001  HF (>=10 kHz) audio band power
    PyTorch reference   : -71.78 dB
    CoreML (palettized) : -71.53 dB     delta = +0.25 dB

  KokoroNoise per-source rel-rms vs PyTorch reference (both with rand_ini)
    x_source_0          : 0.279  (was 0.279 in fp32 build)
    x_source_1          : 0.019  (was 0.019 in fp32 build)

  HF noise-stage residual (audio_C - audio_D)
    palettized          : -95.00 dB    (was -94.91 dB in fp32 build)

Round 1 Trial 6.1 already established that int8 palettization does not
reintroduce noise (the atan2 fix is independent of weight precision). This
re-export confirms it: same numbers as the fp32 build, within 0.01 dB.

Replaced files:
  build/ANE-zh/KokoroNoise.mlpackage   (weights 17.2 MB -> 4.4 MB)
  build/ANE-zh/KokoroNoise.mlmodelc    (weights 17.2 MB -> 4.4 MB)
  build/ANE-zh/KokoroNoise-fixed.zip   (33.6 MB -> 8.5 MB)
  docs/audio/after_fix_zm009.wav       (regenerated with palettized model)
  docs/audio/after_fix_zf001.wav       (regenerated with palettized model)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Alex-Wengg

Copy link
Copy Markdown
Member Author

@devin-ai-integration please take a look

Alex-Wengg added 3 commits May 3, 2026 21:20
Removed after the consolidated upload to
FluidInference/kokoro-82m-coreml/ANE-zh on HF. The build/ tree is
regenerable from convert-coreml.py and shouldn't have been tracked.
Removed the 4 reference wavs under docs/audio/ and the matching
"Reference audio" subsection in TRIALS.md. The before/after listening
samples were one-shot artefacts for the noise-fix PR; the dB tables in
the same section retain the quantitative evidence.
Moved all 13 tracked Python scripts (convert, inference, benchmark,
compare, 7 noise-fix probes, audio compare helper) from the coreml/
root into coreml/scripts/. Updated README.md, TRIALS.md, report.md,
and the two docs/ files to point at the new locations. The
laishere-coreml/convert-coreml.py reference in TRIALS.md is preserved
(different path, not affected by the move).

Note: scripts/probe_noise_fidelity.py:27 still uses a CWD-relative
spec_from_file_location("convert_coreml", "convert-coreml.py"); now
requires running from scripts/ instead of coreml/. Caller-side fix.

@beta-devin-ai-integration beta-devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review (Beta)

@Alex-Wengg Alex-Wengg merged commit 53e21db into main May 4, 2026
@Alex-Wengg Alex-Wengg deleted the feat/kokoro-v1.1-zh-coreml branch May 4, 2026 02:02
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