Kokoro-82M neural TTS registered as real macOS system voices (Spoken
Content / Speak Selection / VoiceOver) via an Audio Unit speech synthesis
extension (AVSpeechSynthesisProviderAudioUnit, macOS 13+ API; we target
macOS 15 because KokoroSwift requires it). Everything runs locally on Apple
Silicon via MLX. Two voices wired up: af_heart ("Kokoro Heart", US female,
top-rated) and am_michael ("Kokoro Michael", US male).
Builds, signs (ad-hoc), registers, and synthesizes — verified working on
Xcode 26.3 / macOS 26 / Apple Silicon. Both voices produce correct 24 kHz
neural audio through the real system speech path (AVSpeechSynthesizer →
extension → KokoroSwift/MLX), confirmed by capturing the PCM via
AVSpeechSynthesizer.write (24000 Hz, non-silent). Getting from the original
scaffold to a green build took one code fix, a dependency-graph re-pin, and a
one-line fork of KokoroSwift — see Resolved build issues below.
project.yml— XcodeGen manifest, two targets:KokoroVoice(SwiftUI host app) andKokoroVoiceExtension(app extension, embedded). The.xcodeprojis generated, not committed.App/— host app: registers the extension by being launched; verification UI (lists registered Kokoro voices viaAVSpeechSynthesisVoice.speechVoices(), speaks test text throughAVSpeechSynthesizer).Extension/— the actual synthesizer:KokoroAudioUnit.swift—AVSpeechSynthesisProviderAudioUnitsubclass. Whole-utterance synthesis insynthesizeSpeechRequest, playback viainternalRenderBlock(bocoup-sample pattern), 24kHz mono float32.VoiceManifest.swift— declarative voice list; add entries to ship more voices (model is shared; each voice = one ~0.5MB embedding in voices.npz).SSML.swift— regex SSML→plain-text (KokoroSwift takes plain text).AudioUnitFactory.swift—NSExtensionPrincipalClass.
Resources/— empty until you run./download_models.sh(fetcheskokoro-v1_0.safetensors~600MB +voices.npz~15MB, both from the KokoroTestApp repo, guaranteed compatible with the pinned library).
./download_models.sh # once
xcodegen # regenerate after any project.yml change
open KokoroVoice.xcodeproj # set signing Team on BOTH targetsRun the KokoroVoice scheme → app window → "Refresh" should show 2 voices →
"Speak" (first synthesis loads the model, takes seconds). Then System
Settings → Accessibility → Spoken Content → System voice → Kokoro Heart.
Debugging: extension logs are prefixed KokoroVoice: (Console.app or
log stream --predicate 'eventMessage CONTAINS "KokoroVoice"').
Extension registration check: pluginkit -m | grep -i kokoro.
From mlalma/KokoroTestApp
TestAppModel.swift (authoritative usage of KokoroSwift 1.0.8):
KokoroTTS(modelPath: URL)— init (ag2p:param exists with default.misaki)try engine.generateAudio(voice: MLXArray, language: .enUS | .enGB, text: String)returns tuple([Float], tokenArray?); tokens carrytext,start_ts,end_ts(word-highlighting material)- Sample rate constant:
KokoroTTS.Constants.samplingRate(24000) — the AU hardcodes 24_000; consider referencing the constant once it compiles - Voices:
NpyzReader.read(fileFromPath:)(fromMLXUtilsLibrary) →[String: MLXArray], keys like"af_heart.npy"; npz ships 28 English voices - Kokoro naming:
a*/b*= US/UK English,f/m= gender; pick.enUSvs.enGBby prefix
From bocoup/apple-custom-speech-synthesizer (working AU speech extension sample):
- Extension Info.plist:
NSExtensionPointIdentifier: com.apple.AudioUnit,AudioComponentsentry withtype: ausp, 4-charsubtype/manufacturer(ours:kkro/Vicn),tags: [Speech Synthesizer],sandboxSafe: true - Render pattern: fill output from a pre-synthesized
AVAudioPCMBuffer, setactionFlags = .offlineUnitRenderAction_Completewhen exhausted
What actually broke on Xcode 26.3 and how it's fixed. All fixes are persistent
(in project.yml, Extension/, or the KokoroSwift fork) — nothing lives only in
DerivedData.
KokoroTTS.Languagedoesn't exist — the enum is top-level in the module, so it'sKokoroSwift.Language, not nested. Fixed inKokoroAudioUnit.swift.- KokoroSwift's manifest under-declares
MLXFast— its sourcesimport MLXFastbut the target only depended on MLX/MLXNN/MLXRandom/MLXFFT. Implicit-module builds tolerate the transitive import via MLXNN; Xcode 16+/26 explicit-module builds fail withno such module 'MLXFast'. Fixed via a one-line fork —vicnaum/kokoro-ios @ mlxfast-fix(commit pinned byrevisionin project.yml).SWIFT_ENABLE_EXPLICIT_MODULES: NOis also set as a belt-and- suspenders against other under-declared transitive imports. - Dependency graph re-pinned to KokoroSwift's tested
Package.resolved— the oldMLXUtilsLibrary: branch: mainresolved to commit66f7cd5("removed BenchmarkTimer"), which KokoroSwift still calls →cannot find 'BenchmarkTimer'. project.yml now pins MLXUtilsLibrary0.0.6and mlx-swift0.29.1(exact), the combination KokoroSwift 1.0.10 was built against. Do not float these. - Metal Toolchain is a separate download in Xcode 26 — MLX compiles
.metalshaders; first build fails withcannot execute tool 'metal'. One-time fix:xcodebuild -downloadComponent MetalToolchain(~700 MB). - CLI signing — automatic signing needs an Apple account
xcodebuildcan reach, and it can't ("No Account for Team"). For local runs, build ad-hoc (see recipe). For a distributable signed build, set your Team in Xcode and build there. TheNSExtension/auspAudioComponent dict (weak-spot we worried about) renders correctly from project.yml — no change needed.
Non-obvious runtime fact: the system prepends the extension bundle id to each
voice identifier, so the registered id is
com.vicnaum.kokorovoice.extension.com.vicnaum.kokorovoice.af_heart, not the bare
com.vicnaum.kokorovoice.af_heart. The host app's hasPrefix filter still matches
(the prefix is contained), but AVSpeechSynthesisVoice(identifier:) on the bare
id silently falls back to the default system voice — pick the voice object out of
speechVoices() instead of constructing it from the short id.
Non-obvious runtime fact #2 — Spoken Content needs the app in /Applications:
AVSpeechSynthesizer (apps, incl. our host app's Speak button) loads the extension
from anywhere, but the sandboxed Spoken Content daemons (AXVisualSupportAgent →
MauiAUSP) cannot load our ausp/kkro/Vicn audio unit out of
~/Library/Developer/Xcode/DerivedData. The tell (unified log): findNext ausp/kkro/Vicn -> ausp/kona/appl then CoreSynthesizer … retryFallbackVoice →
you hear a robotic Apple voice. Fix: copy the built .app to /Applications,
lsregister -f it, and killall AXVisualSupportAgent MauiAUSP (or log out/in).
brew install xcodegen
xcodebuild -downloadComponent MetalToolchain # once, Xcode 26+
./download_models.sh # once
xcodegen
xcodebuild -project KokoroVoice.xcodeproj -scheme KokoroVoice -configuration Debug \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY="-" CODE_SIGN_STYLE=Manual CODE_SIGNING_REQUIRED=YES \
CODE_SIGNING_ALLOWED=YES DEVELOPMENT_TEAM="" PROVISIONING_PROFILE_SPECIFIER="" \
SWIFT_ENABLE_EXPLICIT_MODULES=NO build
open <DerivedData>/.../Build/Products/Debug/KokoroVoice.app # registers extension
pluginkit -m | grep -i kokoro # confirm registrationFirst build compiles all of MLX from source (~10 min); later builds are fast. First synthesis loads the 312 MB model in the extension (a few seconds).
Shipping to another Mac needs a paid Apple Developer Program account (team
HAVQAJTX35; the JP93BJRSQ8 cert is the free Personal Team and can't notarize).
Four non-obvious things, all now baked into project.yml:
- Embed the dynamic frameworks.
KokoroSwift(andMLXUtilsLibrary) aretype: .dynamic; MLX/Cmlx/Numerics build dynamic alongside them. App extensions can't carry frameworks, so the host app embeds them (KokoroVoicetarget depends onpackage: KokoroSwiftwithembed: true— its closure pulls the other four; listing them too → "duplicate tasks"). The extension finds them via its@executable_path/../../../../Frameworksrpath. Without this the extension crashes at launch (dyld: Library not loaded: @rpath/KokoroSwift.framework). Debug builds masked this by loading the framework from DerivedData — it would never have run on another Mac. - Release strips
get-task-allow.configs.ReleasesetsCODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO; otherwise the notary rejects the build ("The executable requests the com.apple.security.get-task-allow entitlement"). Debug keeps it (debuggable). - Hardened runtime is fine for MLX — no JIT entitlement needed; Metal
compiles shaders out-of-process. (
voices.npztriggers a benign notary warning — it's a zip of npy arrays, no executables.) - After swapping the
.appin/Applicationsseveral times,speechVoices()can return 0 (stale AudioComponent cache) —killall coreaudiodrefreshes it.
Recipe (one-time: create a "Developer ID Application" cert + a notary keychain
profile: xcrun notarytool store-credentials KokoroNotary --apple-id <id> --team-id HAVQAJTX35):
xcodebuild -project KokoroVoice.xcodeproj -scheme KokoroVoice -configuration Release \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY="Developer ID Application: VIKTAR NAUMIK (HAVQAJTX35)" \
CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=HAVQAJTX35 PROVISIONING_PROFILE_SPECIFIER="" \
ENABLE_HARDENED_RUNTIME=YES OTHER_CODE_SIGN_FLAGS="--timestamp" \
SWIFT_ENABLE_EXPLICIT_MODULES=NO build
ditto -c -k --keepParent <Release>/KokoroVoice.app /tmp/k.zip
xcrun notarytool submit /tmp/k.zip --keychain-profile KokoroNotary --wait # -> Accepted
xcrun stapler staple <Release>/KokoroVoice.app
spctl -a -vvv -t exec <Release>/KokoroVoice.app # -> accepted / Notarized Developer ID
# package: stapled app + /Applications symlink into a UDZO dmg, then
# codesign --timestamp the dmg, notarytool submit it, stapler staple it.Target Mac requirement: Apple Silicon, macOS 15+ (Kokoro/MLX).
Done (v1.1.0): streaming / sentence-chunked synthesis (Kokoro is non-
autoregressive with a hard 510-token limit, so whole-text synthesis threw
tooManyTokens on long selections — now chunked, hybrid first-sentence start,
oversized chunks re-split via the tooManyTokens catch); text normalization
in SSML.swift (symbols → words, numbers, line-breaks → sentence pauses,
verified with the PhonemeDump dev tool).
Done (v1.2.0): word highlighting — emitWordMarkers builds
AVSpeechSynthesisMarker(.word) from MToken.start_ts + a cursor-search that
maps each token back to its range in the original SSML, handed to the AU's
speechSynthesisOutputMetadataBlock (byteSampleOffset = frame × 4 for float32).
Verified emitting correctly via AVSpeechSynthesizer's willSpeakRange delegate
(13/13 words, right ranges). Caveat: rendering is host-dependent — apps that
implement willSpeakRange highlight; macOS Spoken Content highlighting of
third-party voices is a longstanding, inconsistent Apple bug (not our code).
Done (v1.3.0): (a) rate slider — SSML.speedMultiplier parses
<prosody rate="PERCENT"> and threads it to generateAudio(speed:); the host
injects a stray invisible char into the rate value (Float("12.5") → nil), so
the value is digit/dot-filtered. Clamped to [0.6, 1.4] — below ~0.6× Kokoro
drags/slurs, above ~1.4× it drops words (the slider's raw 12.5–400% extremes are
unusable). (b) parentheses → pauses — Misaki speaks bracketed content but
drops the bracket chars with no pause, gluing the aside onto its neighbors;
normalizeSymbols now turns ()[]{} into commas (verified via PhonemeDump).
(c) streaming smoothness on slower Macs — live playback underran on a slower
machine (synthesis fell behind playback → silence gaps/stutter, machine-
dependent; fine on a fast Mac). Fix: the live render block now primes
(buffers livePrimeChunks = 2 chunks before starting, re-buffers after any
underrun → one clean pause instead of a stutter), and maxCharBudget dropped
400 → 160 so each chunk's synthesis finishes well within the previous chunk's
playback. Trade-off: a slightly longer delay before the first word. Live priming
can't be verified from this (fast) Mac — needs testing on the slower machine; if
first-word latency is too high or it falls back to the robotic voice, lower
livePrimeChunks to 1.
- More voices: add entries to
VoiceManifest.swift(tryaf_bella,bf_emma— British voices exercise the.enGBpath). Wanted with an in-app preview UX (preview one sentence per voice). - Longer paragraph pauses: insert silence chunks between paragraphs in the AU.
- Maybe: swap in a future emotive model (MisoTTS-8B is too big for an extension; Kokoro is the right size for this architecture).
Dev tool — PhonemeDump (Tools/, type: tool in project.yml): runs
SSML.swift + Misaki's real G2P and prints raw → normalized → phonemes, so you
can debug pronunciation and add normalization rules without listening. Build the
PhonemeDump scheme, then run it from the build products dir with
DYLD_FRAMEWORK_PATH=<Build/Products/Debug>:<…/PackageFrameworks> (it links the
dynamic MLX/Misaki frameworks). --raw skips normalization.
- Don't commit model files or the generated
.xcodeproj(see.gitignore). - Voice identifiers:
com.vicnaum.kokorovoice.<npz key without .npy>— the app's status UI filters on this prefix; keep them in sync.