feat(tts): expose Kokoro CoreML compute units through the binding - #21
Open
drakulavich wants to merge 10 commits into
Open
feat(tts): expose Kokoro CoreML compute units through the binding#21drakulavich wants to merge 10 commits into
drakulavich wants to merge 10 commits into
Conversation
FluidAudio's KokoroAneManager takes a `computeUnits:` argument, but the
bridge constructed it positionally with only `variant:`/`defaultVoice:`,
so Rust callers were stuck on `KokoroAneComputeUnits.default` — which
pins the Albert, PostAlbert, Alignment and Vocoder stages to the Neural
Engine.
That is fine on real Apple Silicon and fatal without an ANE. On a
virtualised macOS guest (a GitHub-hosted `macos-14` runner is one) the
ANE is not exposed, and CoreML refuses to prepare exactly those stages:
predictionFailed(stage: "vocoder", underlying: Error Domain=com.apple.CoreML
Code=0 "Failed to prepare the model for predictions. ML program was
KokoroVocoder and the function name was main."
NSUnderlyingError=... "E5RT: Output rank has changed after reshaping
espresso network for blob = anchor_classic_cpu (11)")
FluidAudio documents the escape hatch in `Documentation/TTS/KokoroAne.md`
(`KokoroAneManager(computeUnits: .cpuAndGpu)`), so this only plumbs it:
- `fluidaudio_initialize_kokoro` gains a `compute_units` C string, parsed
with FluidAudio's own `TtsComputeUnitPreset(cliValue:)`. NULL/empty
keeps the empirical mapping; an unrecognised value fails init rather
than synthesising on units the caller did not ask for.
- New `KokoroComputeUnits` enum + `init_kokoro_with_compute_units`.
`init_kokoro` keeps its signature and delegates on `Default`, so no
existing caller changes.
- `examples/kokoro.rs` takes the preset as an optional 4th argument.
Verified on an M2 (macOS 26.5.2) against a staged ANE bundle: all four
presets synthesize `am_michael` to 24 kHz mono, ~2.75 s, RMS 3552-4038.
Worth knowing for the CPU+GPU path: CoreML writes E5RT "Data-dependent
shapes were disabled" diagnostics to *stdout* and falls back internally
— synthesis still succeeds, but callers streaming WAV bytes to stdout
must silence fd 1 around the bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU2vgxhrf56CN8YDqbg3q1
…the preset test overclaiming Both fixes come from review of FluidInference#21. The compute-units docs said an ANE-less host makes *init* fail with "Failed to prepare the model for predictions". It does not. In FluidAudio 0.14.8 `KokoroAneManager.initialize` only runs `store.loadIfNeeded()`, the G2P assets and the voice packs — it never issues a prediction, and `KokoroAneError.predictionFailed` is thrown from exactly one place, `KokoroAneSynthesizer.predict` wrapping `model.prediction(from:)`. So the CoreML model loads with its ANE configuration and the *first synthesize* fails. That matches the log in the PR body, which is synthesize-time. The old wording sends anyone debugging a CI host looking at the wrong call. Reworded in both the Rust doc comment and the Swift bridge's. `compute_units_round_trip_through_cli_spellings` claimed `as_str` "must stay inside what FluidAudio's parser accepts", but only round-tripped `as_str` through this crate's own `FromStr` — self-consistent by construction, and blind to a rename that drifts away from the Swift side. It now asserts the literal kebab-case spellings (verified against TtsComputeUnitPreset.swift at the pinned 0.14.8) and says plainly that the Swift parser cannot be executed from here, so the table is manual sync. The alias test likewise now covers every alias `init?(cliValue:)` accepts rather than two of them, and asserts the rejection error names the bad value and the accepted spellings. No behaviour change: comments, test bodies, and one rustfmt wrap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
…ning the old one Converges this branch with the int-selector implementation carried on the fork, keeping the better half of each. From this branch: the preset crosses the boundary as FluidAudio's own kebab-case `cliValue` string, parsed by `TtsComputeUnitPreset(cliValue:)`. The fork's `Int32` selector had to restate the mapping in Swift, and its `default:` arm silently downgraded an out-of-range selector to `.default` — the caller asks for cpu-only, gets the ANE, and finds out at synthesize time. An unknown string still fails init loudly. From the fork: the shape. `fluidaudio_initialize_kokoro` goes back to three parameters and `fluidaudio_initialize_kokoro_with_compute_units` carries the preset, so the existing C symbol's arity is untouched and anything linking the staticlib's entry points from an older build keeps working. Widening the original symbol, as this branch did, broke that for no gain. Both @_cdecls share one Swift body, so there is still a single parse and a single failure contract. `init_kokoro` correspondingly calls the three-arg path directly rather than delegating through the preset one — the default case now touches none of the new code. Verified on M2: `default` and `cpu-and-gpu` synthesize through the new symbol (114044 WAV bytes each), `init_kokoro` through the restored three-arg symbol (104444 bytes), and `nm` shows both C symbols exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
Temporary job, to be deleted once the answer is recorded on the PR. The permanent job runs on macos-15, where the Neural Engine works, so it cannot exercise the case this feature exists for. The ANE-less failure has only ever been observed downstream and inferred here; "Not verified here" in the PR description says as much. This job runs the same synthesis on macos-14 under both presets and reports what happens, including whether the host exposes an ANE at all. It measures rather than asserts: `default` failing is the hypothesis under test, so it does not fail the build — only a failing `cpu-and-gpu` does, since that is the escape hatch itself not working. All four outcome quadrants write a verdict to the step summary, so "not reproduced" is a reportable result and not a silent pass. The example is built in its own step so a Swift 6 / Xcode 16 toolchain gap on the macos-14 image is distinguishable from a compute-unit failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
drakulavich
added a commit
to drakulavich/fluidaudio-rs
that referenced
this pull request
Aug 5, 2026
…ream PR e4c24a9 shipped compute-unit selection here as an `Int32` selector while PR FluidInference#21 proposes the same feature upstream as FluidAudio's own kebab-case `cliValue` string. Two implementations of one feature would collide the moment FluidInference#21 lands and the fork syncs, so this converges on the upstream shape now rather than resolving it in a merge. The string is the better half on its own merits: `TtsComputeUnitPreset(cliValue:)` does the parsing, so the spellings live in FluidAudio and cannot drift. The selector had to restate them in Swift, and its `default:` arm silently downgraded an out-of-range value to `.default` — the caller asks for cpu-only, gets the ANE, and finds out at synthesize time, if at all. An unknown string now fails in Rust before the FFI call, naming both the bad value and the accepted spellings. The symbol shape this branch already had is kept: `fluidaudio_initialize_kokoro` stays at three parameters and the preset rides on `fluidaudio_initialize_kokoro_with_compute_units`. Both @_cdecls now share one Swift body. `kokoroComputeUnits(for:)` is gone. Also corrects the doc claim inherited from e4c24a9 that an ANE-less host fails at init. `KokoroAneManager.initialize` only loads models — it issues no prediction — and `predictionFailed` is thrown solely from `KokoroAneSynthesizer.predict`. Load succeeds; the first synthesize fails. `examples/kokoro.rs` gains the optional 4th argument, so the API this branch has had since e4c24a9 is finally reachable from the example. The new init diagnostic goes to stderr, since the example streams WAV to stdout. Verified on M2: `default`, `cpu-and-gpu` and `cpu-only` each synthesize 88844 WAV bytes, an unknown preset exits 1 with the parse error, and `nm` shows both C symbols exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
The first run of the probe never reached synthesis: the image has no plain `Xcode_16.app`, so the guard fell through to the warning branch and the build ran on the image default — Swift 5.10, which cannot build FluidAudio 0.14.8 (Swift tools 6.0). It does ship Xcode_16.1 and Xcode_16.2. Glob for 16.x and take the newest instead of testing one hardcoded name. The separate build step did its job here: the failure was legible as a toolchain gap rather than looking like a compute-unit result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
drakulavich
added a commit
to drakulavich/fluidaudio-rs
that referenced
this pull request
Aug 5, 2026
…the preset test overclaiming Both fixes come from review of FluidInference#21. The compute-units docs said an ANE-less host makes *init* fail with "Failed to prepare the model for predictions". It does not. In FluidAudio 0.14.8 `KokoroAneManager.initialize` only runs `store.loadIfNeeded()`, the G2P assets and the voice packs — it never issues a prediction, and `KokoroAneError.predictionFailed` is thrown from exactly one place, `KokoroAneSynthesizer.predict` wrapping `model.prediction(from:)`. So the CoreML model loads with its ANE configuration and the *first synthesize* fails. That matches the log in the PR body, which is synthesize-time. The old wording sends anyone debugging a CI host looking at the wrong call. Reworded in both the Rust doc comment and the Swift bridge's. `compute_units_round_trip_through_cli_spellings` claimed `as_str` "must stay inside what FluidAudio's parser accepts", but only round-tripped `as_str` through this crate's own `FromStr` — self-consistent by construction, and blind to a rename that drifts away from the Swift side. It now asserts the literal kebab-case spellings (verified against TtsComputeUnitPreset.swift at the pinned 0.14.8) and says plainly that the Swift parser cannot be executed from here, so the table is manual sync. The alias test likewise now covers every alias `init?(cliValue:)` accepts rather than two of them, and asserts the rejection error names the bad value and the accepted spellings. No behaviour change: comments, test bodies, and one rustfmt wrap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
The run reached synthesis and `cpu-and-gpu` failed — the outcome that matters most — but every diagnostic went to GITHUB_STEP_SUMMARY, which `gh run view --log` does not expose. The failure was legible as "exit 1" and nothing else. Dump both stderr files to stdout as well, so the CoreML error is in the log where it can actually be read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
… a result
The macos-14 run reached synthesis and reported that `cpu-and-gpu` failed.
That was my bug, not a finding:
Kokoro init error: invalidResponse(description: "am_michael voice pack",
statusCode: 404)
Both presets died there, at init, before any prediction. `am_michael` is not
in the hosted bundle — examples/kokoro.rs says so in its own header, that
English currently hosts only `af_heart`. It works on a dev machine because
the bundle is staged locally, which is exactly the kind of difference a
hosted runner exists to catch. Switched to `af_heart`.
The worse half is that the job blamed the escape hatch for it. A download
failure aborts init identically under every preset, so it can never be
evidence about compute units, yet the verdict logic only looked at exit
codes and reported "the escape hatch does not rescue this host" — a false
negative against the very claim the job was built to test.
So the probe now detects that shape explicitly: both presets failing with
the same download-level error is reported as **inconclusive**, naming the
error, and says plainly that it proves nothing about the ANE either way. It
still fails the build, because a probe that measured nothing is broken — but
it no longer launders a broken probe into a result.
Verified the branch both ways against the real stderr: the 404 pair is
classified inconclusive; a vocoder `predictionFailed` on `default` with
`cpu-and-gpu` succeeding still reads as premise-confirmed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
With a voice that exists, the probe finally measured something real on
macos-14 — and the motivating case reproduced exactly:
default: predictionFailed(stage: "vocoder", Code=0
"Failed to prepare the model for predictions ... KokoroVocoder"
E5RT: Output rank has changed after reshaping espresso network
for blob = anchor_classic_cpu (11))
That is the error from the PR description, now observed in CI rather than
inferred from a downstream report. But `cpu-and-gpu` failed too, differently:
cpu-and-gpu: predictionFailed(stage: "vocoder", Code=1
"Invalid shape for output feature 'anchor'" ... must be of
rank 1, instead got a multi-array value of rank 2)
So the premise holds and that particular escape hatch does not clear it.
What the probe could not say is whether *any* setting does, because it only
ever tried one of the three. `cpu-only` — the one that avoids the GPU path
these shape errors come from — was never run.
Now all four presets run and the verdict distinguishes "the hatch works,
here is which preset" from "the hatch rescues nothing on this host". Only
the latter fails the build; `default` failing on its own is the hypothesis,
not a regression.
Host info now tees to stdout as well. Like the earlier stderr fix, it was
written only to the step summary, which `gh run view` cannot read — so the
ANE-visibility data this job exists to collect was unreachable.
All four verdict branches exercised locally against mocked exits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
The job was temporary and marked as such. It has produced its result, which
is recorded in the PR description with a link to the run:
- the premise is confirmed — on `Apple M1 (Virtual)` / macOS 14.8.7 the
ANE-pinned default fails at the vocoder with exactly the error this PR
was opened for, observed rather than inferred;
- the lever is not sufficient there — `cpu-and-gpu` and `cpu-only` clear
that failure and hit a second one, `feature 'anchor' must be of rank 1,
instead got a multi-array value of rank 2`.
The second failure is upstream, not this binding, and is filed as
FluidInference/FluidAudio#836.
Keeping the job would leave a permanently red check on every PR while
telling us nothing new: it would keep re-measuring a known result, and the
thing that would have to change for it to go green lives in FluidAudio. The
evidence stays reachable through the run link and the issue.
ci.yml is now byte-identical to main again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
…e boundary Two LOW findings from the review, both about the diff saying more than it means to. The PR text claims unrelated `cargo fmt` rewrites were excluded, but two were not: `qwen3_streaming_start` and `qwen3_streaming_feed` had been reflowed in passing. They are back to main's formatting, so the diff is now only this subject. main is not rustfmt-clean there, and matching it means inheriting that — which is the intended trade, since the alternative is unrelated churn. The second is that an init failure reaches Rust as a bare `-1`: a missing voice pack and an ANE that cannot prepare the vocoder are indistinguishable from the caller's side, and the text that would tell them apart goes to stderr from Swift. Threading a last-error string across the boundary is the real fix, but that is a wider change than this PR's subject and would touch the other init entry points to be consistent. Documented instead, with the two errors actually observed on CI so the reader knows what to go looking for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D
Member
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
KokoroAneManageralready takes acomputeUnits:argument, but the bridge constructs it with onlyvariant:/defaultVoice:(FluidAudioBridge.swift:168), and the Rust entry point isinit_kokoro(&self, default_voice, lang). So every Rust caller is pinned toKokoroAneComputeUnits.default, which puts Albert / PostAlbert / Alignment / Vocoder on the Neural Engine.That is the right default on real Apple Silicon and fatal without an ANE. On a virtualised macOS guest — a GitHub-hosted
macos-14runner is one — the ANE is not exposed, and CoreML refuses to prepare exactly those stages.Worth being precise about when that surfaces, because it is not at init.
KokoroAneManager.initializeonly runsstore.loadIfNeeded(), the G2P assets and the voice packs — it never issues a prediction — andKokoroAneError.predictionFailedis thrown from exactly one place,KokoroAneSynthesizer.predictwrappingmodel.prediction(from:). So the model loads happily with its ANE configuration and the first synthesize is what fails:Not an asset problem: the same binary and voice synthesize fine on an M2 with the same staged bundle. The ASR path is unaffected because
FluidAudioBridge.swift:401setscfg.computeUnits = .allfor ASR, leaving CoreML free to pick CPU/GPU. Kokoro has no equivalent freedom from Rust.Documentation/TTS/KokoroAne.mdalready documents the escape hatch (KokoroAneManager(computeUnits: .cpuAndGpu)) — this PR just plumbs it.Change
fluidaudio_initialize_kokorogains acompute_unitsC string, parsed with FluidAudio's ownTtsComputeUnitPreset(cliValue:)so spellings stay in one place. NULL/empty keeps the empirical mapping; an unrecognised value fails init rather than silently synthesizing on units the caller did not ask for.KokoroComputeUnitsenum (Default/AllAne/CpuAndGpu/CpuOnly) withFromStr+as_str, andinit_kokoro_with_compute_units.init_kokorokeeps its signature and delegates onDefault— no existing caller changes.examples/kokoro.rstakes the preset as an optional 4th argument.Verification
M2, macOS 26.5.2, staged ANE bundle,
am_michael, same sentence through all four presets:defaultall-anecpu-and-gpucpu-onlycargo test --libpasses, including two new tests. They cover what a Rust unit test honestly can: thatas_stremits the literal kebab-case spellings transcribed fromTtsComputeUnitPreset.swiftat the pinned 0.14.8, that every aliasinit?(cliValue:)accepts also parses here, and that an unknown preset is rejected with an error naming both the bad value and the accepted spellings. The Swift parser itself cannot be executed from this crate — there is no Swift test target — so that table is manual sync, and the test says so rather than implying it is enforced. The literal assertion was mutation-checked: renamingas_str'scpu-and-gpuoutput fails the test.One finding worth recording for other callers: on
cpu-and-gpuCoreML writes E5RT"Data-dependent shapes were disabled"diagnostics to stdout (436 bytes in the run above) and falls back internally — synthesis still succeeds, but anything streaming WAV bytes to stdout has to silence fd 1 around the bridge, as the example's callers already do.Measured on the runner: premise confirmed, lever not sufficient
That measurement has now been taken, by the temporary
macos-14job in this branch (run). The host reportsApple M1 (Virtual), macOS 14.8.7 — the virtualised guest this PR is about.All four presets fail, at the vocoder, in two clean groups:
defaultFailed to prepare the model for predictions … KokoroVocoder,E5RT: Output rank has changed after reshaping espresso network for blob = anchor_classic_cpu (11)all-anecpu-and-gpuInvalid shape for output feature 'anchor'…must be of rank 1, instead got a multi-array value of rank 2cpu-onlyTwo things follow, and they pull in opposite directions.
The premise is confirmed. The failure this PR was opened for is now observed in CI rather than inferred from a downstream report, and it is exactly the quoted error. It also lands where the docs in this PR now say it does: init succeeds, the first synthesize fails. The split is explanatory rather than coincidental — the two ANE-targeting presets share one failure, the two non-ANE presets share a different one, so the compute-unit setting demonstrably changes what CoreML does.
The lever is not sufficient on this host. Moving the vocoder off the ANE clears the prepare failure and then hits a second, independent bug: the vocoder returns rank 2 where the model description demands rank 1.
cpu-onlyfails identically tocpu-and-gpu, so this is not GPU-specific. Plausibly the same data-dependent-shape issue behind the E5RT"Data-dependent shapes were disabled"diagnostics noted above — which an M2 recovers from and this virtual M1 does not — but that link is not established here.So compute-unit selection is necessary but not sufficient for a virtualised host. The API is still the right thing to expose — a caller cannot even attempt the workaround without it, and it is the documented escape hatch — but it does not on its own make Kokoro work on a GitHub-hosted
macos-14runner, and this PR should not be read as claiming it does.The second failure is upstream, not this binding — filed as FluidInference/FluidAudio#836. Worth noting there:
Package.swiftdeclares.macOS(.v14)and KokoroAne carries no@availablegate (Qwen3 does, atmacOS 15), yet every upstream workflow runs onmacos-15— so the declared floor is not exercised anywhere.The probe job that produced this has been removed again;
ci.ymlis byte-identical tomain. Keeping it would have left a permanently red check that re-measures a known result, when the thing that must change lives in FluidAudio. The evidence stays in the run linked above.Pre-existing, untouched
cargo test --docfails oninit_qwen3_streaming's example (undeclaredcapture_audio_chunk) andcargo clippy --all-targetsflagsneedless_borrows_for_generic_argsinbuild.rs. Both reproduce on unmodifiedmain; left alone to keep this diff to one subject.cargo fmtalso rewrites several unrelated files onmain— those reverts are excluded here too.Refs drakulavich/kesha-voice-kit#678
🤖 Generated with Claude Code
https://claude.ai/code/session_01UU2vgxhrf56CN8YDqbg3q1