A Better Kokoro Voice for iPhone: 5, 10, and 15 Second Core ML Exports

I’ve had fun experimenting with text-to-speech AI. I’m vibe-coding an iOS app that sends messages to/from my laptop at home via Telegram, and I wanted to have a voice mode for it. I wanted a mode with the mic always on, and then when my laptop replied on telegram, it would read out the response to me with voice. I started with the built in system voices, but quickly wanted something much nicer. I’ve been spoiled with the very good Claude apps in voice mode.

I quickly got something up and running with Kokoro, but also found it not quite what I wanted. I spent the next 2 days playing with Claude to customize the models and their behavior, and I think we came out with something nice! It’s even my first deployment on Hugging Face!

Lots of adjustments, lots of back and forth with Claude, lots of A/B testing and diagnosing small oddities in the resulting audio. Below I’ve had Claude write up a summary of what we did. Maybe it’ll be useful for you (or your bot!), but I wanted some sort of record for my Claude if I ever come back to tweak again.

The rest of this post was written with AI, to document the process we used, the steps we took, the results, and the final Hugging Face and GitHub URLs. “We” means Adam and the AI together, and “I” means the AI.


Winston is a small iOS and macOS app Adam uses to talk to bots in Telegram. Its voice mode transcribes what Adam says on the device, sends it to the bot, and reads the bot’s reply aloud. Adam can talk over a reply to cut it off, and replies are spoken a sentence at a time, so the first sentence plays while the rest render. The target phone is an iPhone 16e.

This post covers how Winston’s voice went from Apple’s system voices to Kokoro-82M, and then to our own Core ML export of Kokoro. The export fixes a warble in the longer-window models, keeps a working speed input, and comes in 5, 10, and 15 second versions.

I did most of the hands-on work, as two Claude Code agents: one in the Winston app and one in Adam’s fork of speech-swift, passing notes to each other. Adam listened, made the calls, and tried it on the phone.

The results

  • Hugging Face: milestonemade/Kokoro-82M-CoreML-Masked, with four models: kokoro_masked_5s (fp32), and kokoro_masked_5s_fp16, kokoro_masked_10s_fp16, and kokoro_masked_15s_fp16.
  • GitHub: the export recipe and the research notes, on the agent/kokoro-model-adjustments branch of Adam’s speech-swift fork.
  • 5 s, fp32: in a blind A/B against soniqo’s kokoro_5s, the model speech-swift ships, Adam preferred ours in 9 of 16 pairs and called 7 a tie, and kokoro_5s won none.
  • 5 s, fp16: preferred in 12 of 16 blind pairs, tied in 3, and lost 1. On the CPU it’s the fastest of the bunch: about 200 ms per pass, against 260 ms for kokoro_5s.
  • Short replies: “Yes.” sounded like “ess” until two settings outside the model were fixed: the voice style for each utterance length, and Kokoro’s start and end token.
  • 10 s and 15 s: the same recipe, both with the speed input. Durations match PyTorch exactly, and the 15 s model renders every test text, even a three-sentence paragraph, in one pass. On long sentences, blind, the 15 s model beat our 5 s model 6 of 6 times and soniqo’s kokoro_5s 6 of 6 times. The 10 s model beat our 5 s model 5 of 6 times.

Across the 32 blind pairs at 5 s, ours was preferred or tied in all but one. Across the 18 long-sentence pairs, the longer windows won 17.

Stage one: the system voices

Voice mode first spoke with Apple’s system voices through AVSpeechSynthesizer. It renders with write(_:toBufferCallback:) into the app’s own audio engine, which echo cancellation and barge-in need. By default it picks the best installed voice for the system language: Premium, then Enhanced, then the default quality. Settings has a voice picker, a speed, a pitch, and a spoken sample.

Two surprises along the way:

  • pitchMultiplier is ignored for the Siri natural voices when rendering with write. Nora’s output was bit-identical at a pitch of 0.5 and 2.0. Winston now shifts pitch after synthesis with an AVAudioUnitTimePitch node, for every voice.
  • AVSpeechSynthesisVoice.speechVoices() blocks the main thread for about 27 ms each call. The voice picker called it once per menu item, which beachballed the Mac for over a second. The list is now cached per language.

In Adam’s words, the system voices are “fine, but not great.” Their bonus is that they can read much longer text in one go, and they were very easy to implement. But the Kokoro models have much better tone and intonation, so “if I can get a custom model that sounds way better, that’s a win.”

Stage two: Kokoro-82M through speech-swift

Kokoro-82M is an 82 million parameter text-to-speech model (StyleTTS 2 with an iSTFTNet vocoder). It’s Apache-2.0, speaks at 24 kHz, and has 54 voices. Winston offers the 27 American and British English ones.

To run it on the device, Winston uses soniqo’s speech-swift, a Core ML port. Its KokoroTTSModel loads kokoro_5s from aufklarer/Kokoro-82M-CoreML: one end-to-end graph that runs the whole pipeline (BERT, duration, alignment, prosody, and vocoder), text tokens in and audio out, with 128 token slots, a 5 second audio window, and a speed input. Winston uses Adam’s fork, pinned to a commit.

The integration needed more than expected:

  • Download on first use. The model is about 335 MB. It downloads into Application Support, excluded from backup, with a marker file so later launches load offline. Without the marker, the loader asks Hugging Face first and, with no network, retries for about two minutes.
  • No GPU. Loading with the default .all compute units aborts the process inside MPSGraph on macOS 27 (“invalid axis (122) for input of rank 2”), and it can’t be caught. Winston loads with .cpuAndNeuralEngine.
  • Numbers. Kokoro’s English phonemizer has no digits, so “3.5”, “$20”, “10:30” and “2026” all came out as the same unknown sound. Winston writes numbers out as words first.
  • Acronyms. Kokoro read “API” as “ay” and “PR” as “parister”. Winston spells acronyms out letter by letter unless its dictionary knows them as words, and there’s a user pronunciation list.
  • Intel Macs. The universal Mac build failed because KokoroTTS read float16 outputs through Swift’s Float16, which doesn’t exist on x86_64 macOS. The fork reads them through vImage instead.
  • Memory. The model is freed when a system voice is chosen, and deleted after a day unused.

The “8 words” problem

Kokoro paused every 8 to 10 words in long sentences, even with no comma. The cause was the 5 second window. At Kokoro’s speaking rate, 5 seconds holds only about 56 phoneme tokens, about 8 words. So KokoroTTS cut sentences into 56-token pieces and spoke each one as a whole utterance: the pitch fell at the end of each piece, followed by 670 to 915 ms of silence.

The first fix, in the fork, cuts long sentences into even pieces before words that start a phrase (“and”, “because”, “which”), and trims the silence at cuts inside a sentence. That helped, but the tone of voice still reset at every cut. The real fix is a bigger window, so a whole sentence renders in one pass.

Here’s one sentence through all three stages: “I checked the build server this morning, and both jobs finished without errors, so the new release is ready whenever you want to ship it.” The first two clips are soniqo’s kokoro_5s.

  • Upstream speech-swift (31d676c, the first version Winston used): 3 pieces, cut at the two commas. Each piece is spoken as a whole utterance with its own lead-in and tail, so the cuts leave pauses of 765 ms and 710 ms. 9.03 s in all.
  • The fork’s first fix (a8d60fe): 4 pieces, with pauses of 160 ms at the commas and 20 ms before “whenever”. That’s about 1.1 s less silence, at 8.12 s, but the pitch still resets at every cut.
  • Our 15 s model: the whole sentence in one pass.
Upstream speech-swift
First fix
Ours 15 s, fp16, CPU

Trying a bigger window

To see what else was out there, I researched Core ML ports of Kokoro with bigger windows and wrote up a brief. (The brief blamed the 128-token input, which turned out to be wrong. The real limit is the 5 second output window: at Kokoro’s speaking rate, a 15 second window holds about 200 tokens.)

The candidates:

  • FluidAudio’s end-to-end graphs: kokoro_21_5s (124 tokens, 7.3 s), kokoro_21_10s (168 tokens, 10.5 s), and kokoro_21_15s (249 tokens, 15.5 s).
  • FluidAudio’s 7-stage pipeline, which handles any length and has a speed input, but their own docs list open crashes on iOS 26.6 and 27.
  • mattmireles/kokoro-coreml, a multi-stage pipeline split across the Neural Engine, GPU, and CPU, with buckets up to 30 s, and the only one with iPhone measurements.
  • toddkrabach/Kokoro-82M-CoreML, kokoro-ios on MLX, and sherpa-onnx.

FluidAudio’s 15 s graph was the easiest to try: its inputs and outputs match speech-swift’s graph, except it has no speed input. Their export script hard-codes speed = 1.

To load it, I changed the fork so fromPretrained(modelFile:) loads any end-to-end bundle by file name. It reads the token slots, the audio window, and whether there’s a speed input from the graph itself, and sizes the chunks to fit the window. It also picks each chunk’s voice row by the chunk’s length.

On the Mac, a 200-token sentence rendered in one pass, and a speech-recognition round trip got every word. Each 15 s pass costs about the same however short the text, because the decoder always renders the whole window:

Text 5 s graph 15 s graph
“Yes.” (7 tokens) 380 ms 1,200 ms
60 tokens 770 ms (2 chunks) 1,210 ms
200 tokens 1,570 ms (4 chunks) 1,240 ms

Winston gained a model picker in Settings (soniqo’s kokoro_5s, and FluidAudio’s 5 s, 10 s, and 15 s), and a long-press “Speak” item on any message, to hear the same text again after switching models.

The warble

On the iPhone, Adam found the longer sentences had better intonation, but the FluidAudio voices were “a little warbly”, while soniqo’s kokoro_5s sounded “really nice”.

Here’s the build-server sentence through each model. The 5 s models cut it into pieces, so the intonation seesaws. FluidAudio’s models warble. Our own 15 s model, built later in this post, fixes both:

Model Window Listen
soniqo kokoro_5s 5 s
FluidAudio kokoro_21_5s 5 s
FluidAudio kokoro_21_15s 15 s
Ours, fp16 on the CPU 15 s

To track it down, we rendered the same two texts in a matrix of configurations, and built a pitch tracker that measures jitter: the mean frame-to-frame change in pitch, in cents. Jitter on the short text was about 18 for kokoro_5s and 33 for FluidAudio’s 15 s graph. Every FluidAudio configuration still sounded off:

  • random phases on or off
  • the CPU alone, or the CPU and Neural Engine
  • start and end token ids of 0 instead of 1 and 2
  • the averaged voice, or the voice row matched to the text’s length
  • FluidAudio’s 5 s graph instead of the 15 s

The cause was in the graphs’ Core ML programs. soniqo’s kokoro_5s runs the backward half of each bidirectional LSTM over the valid length only, including the frame-level pitch LSTM. FluidAudio’s six bidirectional LSTMs read the padding as well: 249 token slots and 621 frames, which for a short reply is about 90% padding, all of it feeding the duration and pitch predictors.

The symptom matches. soniqo’s notes say their graphs from before the packed-LSTM fix “spoke ~1.5x faster” (speech-core#160). FluidAudio spoke the short sentence 1.49× faster than kokoro_5s, and the long one, which has less padding, 1.19× faster.

No runtime option fixes this. The graph has to be re-exported with packed or masked LSTMs, so kokoro_5s went back to being Winston’s default.

Finding an exporter

The obvious move was to run soniqo’s exporter at 10 and 15 seconds. It isn’t public. It lives in soniqo’s private speech-models repo: a speech-swift commit in October removed paths like speech-models/models/<model>/export/convert.py, and the repo itself returns a 404. We searched all 46 branches, 29 tags, and 436 pull requests of speech-swift, speech-core’s history, every Hugging Face commit of the aufklarer and soniqo Kokoro repos, and the Wayback Machine. All that turned up was an older 3-stage exporter, deleted the day before kokoro_5s was uploaded, and an unmerged FluidAudio-style exporter in PR #128, with unpacked LSTMs.

The best public alternative is elizaOS’s export_e2e_coreml.py (MIT). It runs against mattmireles/kokoro-coreml (Apache-2.0), and exports one fused graph with the same inputs and outputs as kokoro_5s:

  • All six bidirectional LSTMs are masked: they hold their state on padded steps, which equals packing for right-padded input.
  • The AdaIN statistics are taken over the valid frames only.
  • It keeps real speed and random_phases inputs.
  • The window length, token count, and precision are flags.

Building our own export

We exported at 5 seconds first. At the same window as kokoro_5s (128 token slots, 120,000 samples), the chunking, the voice files, and the post-processing are identical on both sides of an A/B, so only the graph differs.

The setup: Python 3.12, torch 2.5.0, coremltools 8.3.0, eliza at b2e30700, mattmireles’ kokoro-coreml at 2a75c780 (the June commit eliza was written against; later commits changed AdaIN1d’s signature), and hexgrad’s kokoro-v1_0.pth weights.

With coremltools 8.3 on macOS 27, the exporter needed fixes. Each one was found by comparing Core ML against torch stage by stage:

  1. The sine source converted to all zeros. coremltools converts (f0 / sr) % 1 wrongly, which silently zeroes the whole harmonic source; the audio’s spectral correlation with torch was 0.66. x - floor(x) computes the same value and converts correctly.
  2. The masked AdaIN’s frame mask came out one sample short. It’s stretched with F.interpolate(mode="nearest"), which converts to an integer-scale upsample: 24,000 samples instead of 24,001, and the conversion fails. A constant-index index_select reproduces PyTorch’s indices exactly.
  3. Loading for the GPU aborts. ct.convert(compute_units=ALL) loads the converted model for the GPU, which hits the same MPSGraph crash. Loading for CPU_AND_NE avoids it.
  4. eliza’s STFT patch, applied to mattmireles’ custom_stft.py.

Parity checks:

  • The masked torch model predicts exactly the same durations as upstream Kokoro.
  • The Core ML fp32 graph matches torch with a waveform correlation of 0.998.

The 5 second results

Durations matched kokoro_5s within one 25 ms frame per chunk, and so did the pitch: short-text jitter was 21, against 18 for kokoro_5s and 33 for FluidAudio.

For the blind test, each of 8 texts was rendered by both graphs in two pairs (with the start and end token ids speech-swift sends, and with 0), the files were shuffled under neutral names, and Adam picked a winner in each pair. Ours was preferred in 9 of 16 pairs and tied in 7, and kokoro_5s won none.

fp16

The goal of fp16 was the Neural Engine, which only runs fp16. Getting there took three tries:

  1. fp16 everywhere except the phase path broke the text side: NaN durations on the CPU, and wrong ones on the Neural Engine. Its first Neural Engine compile also took about 110 minutes.
  2. fp16 in the decoder and vocoder only, with everything up to the decoder input (BERT, every LSTM, the duration and pitch predictors) in fp32, and the phase path and STFT bins kept fp32 too. The vocoder then went NaN: the masked AdaIN summed over 24,001 samples, which overflows fp16.
  3. The same split, with fp16-safe AdaIN. The statistics became means over the valid fraction instead of sums divided by a count, with the variance taken on (x - mean) / 16. It’s the same math, kept inside fp16’s range. This one matched torch with a spectral correlation of 0.9956, with the same durations.

Speed per 5 s pass, in the same run on the Mac:

Model ms per pass
soniqo kokoro_5s ~260
Ours, fp32 ~270
Ours, fp16, .cpuOnly ~200
Ours, fp16, .cpuAndNeuralEngine ~995, after a ~70 minute first compile

On the CPU, the fp16 graph also opens every pass with a faint burst of noise. That turned out to matter, and it’s fixed now: see the static at the start of each pass.

The second blind test put fp16 against kokoro_5s, 8 texts each:

Ours preferred Tied kokoro_5s preferred
fp16, CPU 6 1 1
fp16, Neural Engine 6 2 0

The one loss was “Yes.”, the shortest clip, on the CPU. One likely cause is that start-of-pass burst: in “Yes.” the speech starts about 30 ms in, so the burst runs straight into it. The Neural Engine build has no burst, and it tied the same pair. Short replies had two bigger problems, which we found later: see the “Yes.” fix.

Where it runs

The surprise: nothing runs on the Neural Engine. Core ML’s MLComputePlan shows soniqo’s kokoro_5s runs 100% on the CPU, even when loaded with .cpuAndNeuralEngine. Only 10 of its 8,732 operations support the Neural Engine at all: the graph is fp32, and the Neural Engine runs fp16. Our fp32 export is the same. Winston now logs this compute plan for every model it loads, so the phone can tell us where it places them.

Our fp16 build can use the Neural Engine, and it sounds good there. But the parts that have to stay fp32 (the text side, the phase path, and the STFT) force constant handoffs between the CPU and the Neural Engine, which cost more than they save. So the fp16 model loads with .cpuOnly. Real Neural Engine use needs a different graph structure, like mattmireles’ split pipeline, not just a precision flag.

10 and 15 seconds

With the 5 second export at least as good as kokoro_5s, the same recipe ran at longer windows. Both longer models take the speed input.

5 s 10 s 15 s
Token slots 128 168 249
Output samples 120,000 240,000 360,000
Chunk budget 56 tokens 125 tokens 194 tokens

Every one matches PyTorch’s durations exactly, with no NaN. The fp32 graphs match torch with a waveform correlation of 0.998 at both 10 s and 15 s. The fp16 graphs reach a spectral correlation of 0.997 (10 s) and 0.9985 (15 s).

Speed of one pass on the Mac’s CPU, measured in Python:

fp32 fp16
10 s 829 ms 580 ms
15 s 1,630 ms 888 ms

Through speech-swift, the fp16 graphs take about 0.6 s per pass at 10 s, and 1.0 to 1.4 s at 15 s. As with FluidAudio’s graphs, the cost is set by the window: a one-word reply costs as much as a full one.

The weights barely change with the window: about 330 MB at fp32, and about 235 MB at fp16.

The 15 s model rendered every test text in one piece, including the three-sentence paragraph. The 10 s model split the long sentence and the paragraph into 2.

Long-sentence blind test

For the blind test, Adam listened to 6 sentences of 8 to 11 seconds each, all fp16 on the CPU, with the sides shuffled:

Pair Longer window won
Ours 15 s against ours 5 s 6 of 6 (3 “way better”)
Ours 15 s against soniqo kokoro_5s 6 of 6 (2 “way better”)
Ours 10 s against ours 5 s 5 of 6

Adam’s picks came down to intonation and seams. The chunked 5 s renders sounded stitched together, and on one sentence a 5 s seam cut off the end of a word. On one sentence, Adam rated the 15 s render the best of all six files and soniqo’s kokoro_5s the worst.

Two things to know:

  • Static in the 10 s renders. 5 of the 6 had faint static between the pieces. Since fixed: see below.
  • Waiting for the first word. The total render time is about the same: about 1.2 s for a long sentence with the 5 s model, in pieces, against 1.1 to 1.8 s for the 15 s model. But the 15 s model plays nothing until the whole sentence is done, while the 5 s model starts playing its first piece after about 0.3 s.

The static at the start of each pass

The static came from the fp16 graphs themselves. On the CPU, the 5 s and 10 s fp16 graphs open every pass with a faint burst of noise: about 12 ms at -39 to -50 dBFS on the 5 s graph, and about 27 ms at -42 to -60 dBFS on the 10 s graph. Every chunk is a new pass, so on a long sentence the burst repeats at every seam and sounds like static between the pieces. The fp32 graphs, soniqo’s kokoro_5s, FluidAudio’s graphs, the 15 s fp16 graph, and fp16 on the Neural Engine all start in silence, at -85 dBFS or quieter.

The fix is in speech-swift, and the models are unchanged. Kokoro gives every token at least one 25 ms frame, so the start token always fills the first 25 ms of a pass, and nothing is spoken there. If those 25 ms are quieter than -30 dBFS, speech-swift zeroes them and fades in over the next 5 ms. On graphs without the burst, it only clears silence. After the fix, the 5 s fp16 lead-in sits at about -70 to -74 dBFS, and the 10 s at about -99 dBFS.

A first attempt looked for a quiet burst followed by 20 ms of digital silence. That caught the seams, but missed short replies like “Yes.”, where speech follows the burst right away.

The fix had a side effect too. The seam trimmer had been counting the burst as sound, so fp16 renders with several pieces ran longer than fp32: the paragraph took 11.88 s instead of 11.35 s, and the build-server sentence 8.74 s instead of 8.12 s. Now every 5 s fp16 render is the same length as fp32.

Adam compared before and after, and found the fixed renders much better. The clips here are the 10 s model, fp16 on the CPU, where the static is easiest to hear.

The build-server sentence, in 2 pieces:

Ours 10 s, before
Ours 10 s, after

The three-sentence paragraph, in 2 pieces:

Ours 10 s, before
Ours 10 s, after

“Yes.”, where the burst runs straight into the word. Adam can’t hear a difference between these two, but the waveform analysis shows the static at the start of the “before” render:

Ours 10 s, before
Ours 10 s, after

The long-sentence blind test is written up in section 5 of the research notes.

Renders of the comparison texts from the 10 s and 15 s models are in the listening examples below. The build-server sentence, the commas sentence, the question, and the three-sentence paragraph are the best ones for comparing the 10 s and 15 s models against the 5 s ones. On the paragraph, Adam hears better intonation from the 10 s and 15 s models than from the 5 s.

Short replies: the “Yes.” fix

Next, we compared our models against mattmireles’ split pipeline, the one that runs on the Neural Engine. On long sentences, our 15 s model won 6 of 6. But on 8 short texts, Adam preferred mattmireles 5 times: “Hello there…”, the paragraph, the question, “Sure, I can do that.”, and “Yes.”. Our 5 s fp16 model won 2, and 1 tied.

“Yes.”, before any of the fixes below:

Ours Before (0.85 s)
mattmireles (1.35 s)

It looked like their export was simply better. It turned out to be two settings around the model, not the model itself.

One voice style for every length

Kokoro’s voice style depends on how long the utterance is. Each voice has 510 rows: row n is the style for an utterance of n phonemes, and upstream Kokoro picks the row that matches. The voice files we used, from aufklarer’s repo, held a single vector: exactly the mean of the 510 rows. The mean is close to the right row for long sentences, and far from it for short ones:

Utterance length Similarity of the mean to the right row (cosine)
120 phonemes 0.999
60 phonemes 0.94
20 phonemes 0.74
5 phonemes (“Yes.”) 0.49

That matches the pattern: we won every long sentence and lost the short replies. With the right rows, built from hexgrad’s voice tensors, short replies speak at upstream Kokoro’s pace. On the 15 s model, “Hello there, how are you today?” went from 2.52 s to 1.95 s, and long sentences didn’t change.

In a second blind test with the rows, mattmireles won 3, ours won 2, and 3 tied. Closer, but “Yes.” still lost: Adam said ours sounded like “ess”.

The start and end tokens

Every utterance is wrapped in a start and an end token. Upstream Kokoro uses token 0 at both ends, and speech-swift used 1 and 2. The model’s predicted durations for “Yes.”:

Start and end tokens Lead-in “y” Total
1 and 2 75–125 ms 50–75 ms 0.85–0.98 s
0 and 0 425–475 ms 50–75 ms 1.20–1.35 s

The “y” isn’t shorter with 1 and 2, but it starts from near silence, and the glide barely sounds, so “Yes.” comes out as “ess”. With 0, the model gets a long lead-in and starts the word cleanly. With the rows and token 0, our renders came out exactly as long as mattmireles’: “Yes.” 1.35 s, “Sure, I can do that.” 1.80 s, and “Hello there…” 2.17 s. Same model, same settings: mattmireles’ edge on short replies was these two settings.

In a third blind test, rows with token 0 against rows with 1 and 2, token 0 won 3 (“Yes.”, the numbers, and the question), 1 and 2 won 1 (the paragraph, “barely”), and 4 tied.

A shorter lead-in

Token 0 adds about 400 ms of silence before a short reply, and in a voice app, that’s a slower response. So speech-swift now cuts the lead-in before the first piece’s speech to 100 ms. The model still gets its long lead-in and its clean start, and the listener doesn’t wait through it. “Yes.” went from 1.35 s to 1.06 s, and every test reply now starts speaking at exactly 100 ms. The cut applies to every graph, so soniqo’s kokoro_5s also starts about 150 ms sooner.

What shipped

  • The voices on Hugging Face now carry all 510 rows (Hub commit d8b3a084), at about 2.7 MB per voice. The mean is still there, so older code keeps working.
  • speech-swift gained a boundaryTokenId setting, 0 by default for the kokoro_masked_* models, and the 100 ms lead-in cut (eb3608f and bd6905c).
  • The details are in the research notes on the split pipeline.

The usual caveats apply: one listener, and small sets of 8 short texts per test. The numbers text isn’t a fair comparison either, because speech-swift reads raw digits, and Winston writes them out as words before speaking.

“Yes.” at every step, our 5 s fp16 model on the CPU:

Step Length Listen
Ours Before: the mean voice, tokens 1 and 2 0.85 s
Ours, the right row, tokens 1 and 2: the “ess” 0.97 s
Ours, the right row, token 0: a clean start, after a long lead-in 1.35 s
Ours Shipped: the right row, token 0, a 100 ms lead-in 1.06 s
mattmireles 1.35 s

The fix isn’t only for “Yes.”. The voice row feeds the model’s prosody predictor, which sets the pace and pitch of every word. With the mean, every short reply spoke with a style closer to a long sentence’s, and the right row changes both the pace and the intonation of every short reply. “Hello there, how are you today?” went from 2.52 s to 1.98 s, and the question from 4.38 s to 3.50 s. With the right rows and token 0, “Hello there…” matched mattmireles’ length exactly, and the 100 ms lead-in cut makes ours about 0.2 s shorter. Long sentences, where the mean was already close to the right row, didn’t change.

“Hello there, how are you today?”:

Ours Before (2.52 s)
Ours Shipped (1.98 s)
mattmireles (2.18 s)

“Do you want me to move the meeting to Thursday afternoon instead?”:

Ours Before (4.38 s)
Ours Shipped (3.50 s)
mattmireles (3.75 s)

Listening examples

Every clip is the af_heart voice at speed 1, 24 kHz. Each phrase has the same six models. The top row is soniqo kokoro_5s, the model speech-swift ships, from aufklarer/Kokoro-82M-CoreML, FluidAudio’s 5 s graph, and mattmireles’ split pipeline with its 15 s bucket. The bottom row is our 5 s, 10 s, and 15 s models, fp16 on the CPU.

  • Ours: as shipped. These are the published models from Hugging Face, with the static fix and the “Yes.” fix: per-length voice rows, start and end token 0, and the 100 ms lead-in. These aren’t the exact files from the blind tests. Where a text fits in one pass, all three of our models give the same length, because they’re the same network with different windows.
  • FluidAudio 5 s: kokoro_21_5s, on the Neural Engine, with tokens 1 and 2. Its 7.3 s window holds 88 tokens, so the longer texts render in 2 pieces.
  • mattmireles 15 s: mattmireles’ split pipeline with its own front end and per-length voice rows, start and end token 0, and the 15 s bucket for every piece, on the CPU and Neural Engine. Its front end caps each piece at 128 tokens and cuts at clause or sentence boundaries, so the build-server sentence and the paragraph still render in 3 pieces.

“Sure, I can do that.”, from the warble listening set:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“Sure, I can do that.”, from the blind A/B set (in the blind test, Adam preferred our 5 s fp32 model over kokoro_5s):

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“I checked the build server this morning, and both jobs finished without errors, so the new release is ready whenever you want to ship it.”, from the warble listening set:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

The same sentence, from the fp16 blind A/B set:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“Bring the charger, the blue folder, two pens, and, if you have room, the spare headphones.” Here kokoro_5s and ours use start and end tokens of 0, and FluidAudio’s use 1 and 2. In the blind test, Adam called our 5 s fp32 model way better than kokoro_5s:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“Do you want me to move the meeting to Thursday afternoon instead?” Here kokoro_5s and ours use start and end tokens of 0, and FluidAudio’s use 1 and 2. In the blind test, Adam called our 5 s fp32 model way better than kokoro_5s:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“The meeting starts at nine tomorrow morning. Please bring the budget report and the travel plans for next month. We will finish with a short review of the new product launch.” In the blind test, Adam called our 5 s fp16 model way better than kokoro_5s. Listening afterward, Adam hears better intonation from the 10 s and 15 s models than from the 5 s. The 15 s model renders the whole paragraph in one pass, the 10 s in 2, FluidAudio 5 s in 3, and mattmireles in 3, one per sentence:

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

More examples

“Hello there, how are you today?” (in the blind test, Adam called our 5 s fp32 model way better than kokoro_5s):

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“Your flight leaves at 7:45 tomorrow morning from gate 23, and boarding starts thirty minutes earlier.” (in the blind test, Adam called our 5 s fp16 model way better than kokoro_5s):

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

“Yes.” (the one blind pair kokoro_5s won, against our 5 s fp16 model before the static fix):

soniqo kokoro_5s
FluidAudio 5 s
mattmireles 15 s
Ours 5 s, fp16, CPU
Ours 10 s, fp16, CPU
Ours 15 s, fp16, CPU

How the graphs compare

FluidAudio kokoro_21_15s / _5s soniqo kokoro_5s Ours
Bidirectional LSTMs read the padding (the warble) packed: backward over the valid length masked: state held on padding (equivalent)
AdaIN statistics over the whole padded window — over valid frames only, fp16-safe
Speed input none yes yes
Phase one fp32 cumsum over the window 1000-sample segmented cumsum, wrapped upstream’s frame-rate cumsum, fp32
Window / token slots 15.5 s / 249, 7.3 s / 124 5 s / 128 5 s / 128, 10 s / 168, 15 s / 249
Where it runs effectively the CPU 100% CPU fp32 on the CPU, or fp16 decoder on the CPU, ~25% faster
Blind result warbly in every configuration the reference 5 s: preferred or tied in all but 1 of 32 pairs; 15 s: won 12 of 12 on long sentences
Exporter FluidInference’s v21.py private public: eliza + mattmireles + our patches

The recipe

The export recipe is committed in the fork, with eliza’s exporter, our changes as a patch, and the diagnostic scripts. The steps:

  1. Set up a Python 3.12 environment with pinned versions, and fetch the sources at fixed commits, plus hexgrad’s weights.
  2. Check torch parity first. The masked model must predict exactly upstream’s durations, or nothing after this measures the right thing.
  3. Convert, then check Core ML parity on the CPU with parity_coreml.py. For fp16, save with --skip-load, since the first Neural Engine compile can take hours. If it diverges, diag_coreml.py, diag_sinegen.py, and diag_decoder.py show where.
  4. Compile with xcrun coremlcompiler compile, and load it in speech-swift with fromPretrained(modelFile:cacheDir:offlineMode: true), from a folder that also holds aufklarer’s vocabulary, G2P, and voice files.
  5. Render a listening set with E2EKokoroListeningSetTests, which prints render time, pitch jitter and wobble, and voiced frames.
  6. Shuffle the pairs under neutral names with make_blind_set.py, listen blind, then unblind.
  7. Check where it runs with E2EKokoroComputePlanTests, which reads MLComputePlan.
python export_e2e_coreml.py --stage torch --seconds 5 --tokens 128
python export_e2e_coreml.py --stage full --seconds 5 --tokens 128 --precision fp32 --out out/fp32
python parity_coreml.py export_e2e_coreml.py out/fp32/kokoro_5s.mlpackage
xcrun coremlcompiler compile out/fp32/kokoro_5s.mlpackage out/fp32/

# fp16 decoder, with the text side, phase path, and STFT in fp32:
KOKORO_FP32_AXES=9,11 python export_e2e_coreml.py --stage full --seconds 5 --tokens 128 \
    --precision fp16dec --skip-load --out out/fp16dec
python parity_coreml.py export_e2e_coreml.py out/fp16dec/kokoro_5s.mlpackage 128 200 cpu

Using the models

The models are public on Hugging Face at milestonemade/Kokoro-82M-CoreML-Masked:

File Window / token slots Precision Size
kokoro_masked_5s 5 s / 128 fp32 327 MB
kokoro_masked_5s_fp16 5 s / 128 fp16 decoder 233 MB
kokoro_masked_10s_fp16 10 s / 168 fp16 decoder 234 MB
kokoro_masked_15s_fp16 15 s / 249 fp16 decoder 236 MB

All four take the speed input. The repo also holds the vocabulary, G2P, and dictionary files from aufklarer’s repo, and the 54 voices with all 510 per-length style rows, so it’s a complete bundle. With these models, speech-swift uses start and end token 0 by default, as described in the “Yes.” fix. An export/ folder has the patched exporter, the patches, and the parity script. The model card covers the blind results and the advice to run fp16 with .cpuOnly. For other runtimes, it also explains the 25 ms start-of-pass rule from the static fix.

With Adam’s speech-swift fork, loading one is a single call:

let model = try await KokoroTTSModel.fromPretrained(
    modelId: "milestonemade/Kokoro-82M-CoreML-Masked",
    modelFile: "kokoro_masked_5s_fp16",
    computeUnits: .cpuOnly)

Each of the four was checked the same way: downloaded fresh from the Hub with fromPretrained, loaded, and asked to speak a sentence.

What’s still open

  • The wait for the first word with the 15 s model, which renders a whole sentence before any of it plays.
  • Measurements on the iPhone 16e: speed, battery, heat, and memory. So far, the phone has only been tested by ear.
  • Running on the Neural Engine likely needs a different graph structure, like mattmireles’ split pipeline, and soniqo-style phase wrapping.