From 03914473e7ec49d74287c83c8a65f172362485aa Mon Sep 17 00:00:00 2001 From: Anton Yakutovich Date: Wed, 5 Aug 2026 10:07:13 +0300 Subject: [PATCH 01/10] feat(tts): expose Kokoro CoreML compute units through the binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UU2vgxhrf56CN8YDqbg3q1 --- examples/kokoro.rs | 17 +++++- src/ffi/bridge.rs | 23 +++++-- src/lib.rs | 114 +++++++++++++++++++++++++++++++++-- swift/FluidAudioBridge.swift | 15 ++++- swift/Kokoro_ffi.swift | 24 +++++++- 5 files changed, 176 insertions(+), 17 deletions(-) diff --git a/examples/kokoro.rs b/examples/kokoro.rs index 7de730d..3d01ae1 100644 --- a/examples/kokoro.rs +++ b/examples/kokoro.rs @@ -1,12 +1,18 @@ //! Example: synthesize TTS via FluidAudio Kokoro. //! Usage: cargo run --example kokoro --features tts -- "Hello world" af_heart en-us > out.wav //! cargo run --example kokoro --features tts -- "你好" zf_001 zh > out.wav +//! cargo run --example kokoro --features tts -- "Hi" af_heart en-us cpu-and-gpu > out.wav //! `lang` selects the KokoroAne variant (`zh` → Mandarin, else English). +//! The optional 4th arg picks CoreML compute units (`default`, `all-ane`, +//! `cpu-and-gpu`, `cpu-only`). Hosts without a usable Neural Engine — a +//! virtualised macOS guest, e.g. a GitHub-hosted `macos-14` runner — need +//! `cpu-and-gpu`; the default mapping pins four stages to the ANE and fails +//! there with "Failed to prepare the model for predictions". //! `af_heart` (English) and `zf_001` (Mandarin) are the built-in default voices. //! Note: English currently hosts only `af_heart`; Mandarin hosts ~100 voices (`zf_*`, `zm_*`, …) //! that download on demand from HuggingFace on first use. An id absent from the hosted bundle //! fails to load — or pre-stage it as `.bin` in the model cache. -use fluidaudio_rs::FluidAudio; +use fluidaudio_rs::{FluidAudio, KokoroComputeUnits}; use std::io::Write; fn main() -> Result<(), Box> { @@ -14,10 +20,15 @@ fn main() -> Result<(), Box> { let text = args.get(1).map(String::as_str).unwrap_or("Hello world"); let voice = args.get(2).map(String::as_str).unwrap_or("af_heart"); let lang = args.get(3).map(String::as_str).unwrap_or("en-us"); + let compute_units: KokoroComputeUnits = + args.get(4).map_or(Ok(Default::default()), |s| s.parse())?; let audio = FluidAudio::new()?; - eprintln!("Initializing Kokoro (downloads model on first run)..."); - audio.init_kokoro(voice, lang)?; + eprintln!( + "Initializing Kokoro on {} compute units (downloads model on first run)...", + compute_units.as_str() + ); + audio.init_kokoro_with_compute_units(voice, lang, compute_units)?; eprintln!("Kokoro available: {}", audio.is_kokoro_available()); let wav = audio.synthesize_kokoro(text, voice, 1.0)?; diff --git a/src/ffi/bridge.rs b/src/ffi/bridge.rs index c73da21..dba1e95 100644 --- a/src/ffi/bridge.rs +++ b/src/ffi/bridge.rs @@ -187,6 +187,7 @@ extern "C" { bridge: *mut std::ffi::c_void, default_voice: *const i8, lang: *const i8, + compute_units: *const i8, ) -> i32; fn fluidaudio_kokoro_synthesize( bridge: *mut std::ffi::c_void, @@ -249,15 +250,29 @@ impl FluidAudioBridge { } } - pub fn initialize_kokoro(&self, default_voice: &str, lang: &str) -> Result<(), String> { + pub fn initialize_kokoro( + &self, + default_voice: &str, + lang: &str, + compute_units: &str, + ) -> Result<(), String> { let c_voice = CString::new(default_voice).map_err(|_| "Invalid voice")?; let c_lang = CString::new(lang).map_err(|_| "Invalid lang")?; - let result = - unsafe { fluidaudio_initialize_kokoro(self.ptr, c_voice.as_ptr(), c_lang.as_ptr()) }; + let c_units = CString::new(compute_units).map_err(|_| "Invalid compute units")?; + let result = unsafe { + fluidaudio_initialize_kokoro( + self.ptr, + c_voice.as_ptr(), + c_lang.as_ptr(), + c_units.as_ptr(), + ) + }; if result == 0 { Ok(()) } else { - Err("Failed to initialize Kokoro".to_string()) + Err(format!( + "Failed to initialize Kokoro (compute units: {compute_units})" + )) } } diff --git a/src/lib.rs b/src/lib.rs index 4cde3b5..8cb2776 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,55 @@ impl From for FluidAudioError { } } +/// CoreML compute-unit preset for the Kokoro TTS pipeline. +/// +/// Mirrors FluidAudio's `TtsComputeUnitPreset`; [`Self::as_str`] emits the same +/// kebab-case spellings its `init?(cliValue:)` parser accepts, so the value +/// round-trips across the FFI boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum KokoroComputeUnits { + /// FluidAudio's empirical per-stage mapping (Albert / PostAlbert / + /// Alignment / Vocoder on the Neural Engine). + #[default] + Default, + /// Every stage on `.cpuAndNeuralEngine`. + AllAne, + /// Every stage on `.cpuAndGPU` — skips the ANE entirely. + CpuAndGpu, + /// Every stage on `.cpuOnly`. + CpuOnly, +} + +impl KokoroComputeUnits { + /// Canonical kebab-case name, as accepted by FluidAudio's + /// `TtsComputeUnitPreset(cliValue:)`. + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::AllAne => "all-ane", + Self::CpuAndGpu => "cpu-and-gpu", + Self::CpuOnly => "cpu-only", + } + } +} + +impl std::str::FromStr for KokoroComputeUnits { + type Err = FluidAudioError; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "default" => Ok(Self::Default), + "all-ane" | "ane" | "neural-engine" => Ok(Self::AllAne), + "cpu-and-gpu" | "cpuandgpu" | "gpu" => Ok(Self::CpuAndGpu), + "cpu-only" | "cpu" | "cpuonly" => Ok(Self::CpuOnly), + other => Err(FluidAudioError::BridgeError(format!( + "unknown Kokoro compute-units preset '{other}' \ + (expected: default, all-ane, cpu-and-gpu, cpu-only)" + ))), + } + } +} + /// Main FluidAudio interface for Rust /// /// Provides access to ASR and VAD functionality. @@ -445,9 +494,32 @@ impl FluidAudio { /// `lang` selects the KokoroAne variant in the Swift bridge (`zh` → Mandarin, /// everything else → English). Downloads the variant's model on first run /// (FluidAudio-managed cache). + /// + /// Uses [`KokoroComputeUnits::Default`]; see + /// [`init_kokoro_with_compute_units`](Self::init_kokoro_with_compute_units) + /// when the host has no usable Neural Engine. pub fn init_kokoro(&self, default_voice: &str, lang: &str) -> Result<(), FluidAudioError> { + self.init_kokoro_with_compute_units(default_voice, lang, KokoroComputeUnits::Default) + } + + /// Initialize Kokoro TTS on an explicit CoreML compute-unit preset. + /// + /// FluidAudio's default per-stage mapping pins the Albert, PostAlbert, + /// Alignment and Vocoder stages to the Neural Engine. Where no ANE is + /// exposed — notably a virtualised macOS guest, such as a GitHub-hosted + /// `macos-14` runner — CoreML cannot prepare those stages and init fails + /// with "Failed to prepare the model for predictions". Passing + /// [`KokoroComputeUnits::CpuAndGpu`] (or `CpuOnly`) keeps synthesis working + /// there, and doubles as the debugging baseline FluidAudio's `KokoroAne.md` + /// recommends for artefact investigations. + pub fn init_kokoro_with_compute_units( + &self, + default_voice: &str, + lang: &str, + compute_units: KokoroComputeUnits, + ) -> Result<(), FluidAudioError> { self.bridge - .initialize_kokoro(default_voice, lang) + .initialize_kokoro(default_voice, lang, compute_units.as_str()) .map_err(FluidAudioError::from) } @@ -669,7 +741,12 @@ impl FluidAudio { max_audio_seconds: f64, ) -> Result<(), FluidAudioError> { self.bridge - .qwen3_streaming_start(language, min_audio_seconds, chunk_seconds, max_audio_seconds) + .qwen3_streaming_start( + language, + min_audio_seconds, + chunk_seconds, + max_audio_seconds, + ) .map_err(FluidAudioError::from) } @@ -683,10 +760,7 @@ impl FluidAudio { /// /// Call this repeatedly as audio chunks become available. The engine will return /// partial transcripts according to the configuration set in `qwen3_streaming_start`. - pub fn qwen3_streaming_feed( - &self, - samples: &[f32], - ) -> Result, FluidAudioError> { + pub fn qwen3_streaming_feed(&self, samples: &[f32]) -> Result, FluidAudioError> { self.bridge .qwen3_streaming_feed(samples) .map_err(FluidAudioError::from) @@ -804,4 +878,32 @@ mod tests { // For now, just test the types exist let _ = FluidAudioError::NotInitialized("test".to_string()); } + + #[test] + fn compute_units_round_trip_through_cli_spellings() { + // `as_str` must stay inside what FluidAudio's `TtsComputeUnitPreset` + // parser accepts — the FFI passes the string, not the enum. + for units in [ + KokoroComputeUnits::Default, + KokoroComputeUnits::AllAne, + KokoroComputeUnits::CpuAndGpu, + KokoroComputeUnits::CpuOnly, + ] { + assert_eq!(units.as_str().parse::().unwrap(), units); + } + assert_eq!(KokoroComputeUnits::default(), KokoroComputeUnits::Default); + } + + #[test] + fn compute_units_accepts_aliases_and_rejects_junk() { + assert_eq!( + "CPU-Only".parse::().unwrap(), + KokoroComputeUnits::CpuOnly + ); + assert_eq!( + "gpu".parse::().unwrap(), + KokoroComputeUnits::CpuAndGpu + ); + assert!("tpu".parse::().is_err()); + } } diff --git a/swift/FluidAudioBridge.swift b/swift/FluidAudioBridge.swift index a88e23d..2a30ada 100644 --- a/swift/FluidAudioBridge.swift +++ b/swift/FluidAudioBridge.swift @@ -156,7 +156,15 @@ class FluidAudioBridgeInternal { } } - func initializeKokoro(defaultVoice: String, lang: String) throws { + /// `computeUnits` maps onto `KokoroAneComputeUnits`. `.default` keeps + /// FluidAudio's empirical per-stage mapping, which pins Albert / PostAlbert / + /// Alignment / Vocoder to the Neural Engine. Callers running where no ANE is + /// exposed — a virtualised macOS guest, e.g. a GitHub-hosted `macos-14` + /// runner — must pass `.cpuAndGpu` or `.cpuOnly`, or CoreML fails to prepare + /// those stages ("Failed to prepare the model for predictions"). + func initializeKokoro( + defaultVoice: String, lang: String, computeUnits: TtsComputeUnitPreset = .default + ) throws { let semaphore = DispatchSemaphore(value: 0) var initError: Error? @@ -165,7 +173,10 @@ class FluidAudioBridgeInternal { // `KokoroAneManager` feeds `speed` as a real model input tensor, // so `--rate` applies correctly (unlike the prior `voiceSpeed:` path). let variant = Self.kokoroVariant(for: lang) - let manager = KokoroAneManager(variant: variant, defaultVoice: defaultVoice) + let manager = KokoroAneManager( + variant: variant, + defaultVoice: defaultVoice, + computeUnits: KokoroAneComputeUnits(preset: computeUnits)) try await manager.initialize(preloadVoices: [defaultVoice]) self.kokoroManager = manager } catch { diff --git a/swift/Kokoro_ffi.swift b/swift/Kokoro_ffi.swift index 41b2e01..91564e1 100644 --- a/swift/Kokoro_ffi.swift +++ b/swift/Kokoro_ffi.swift @@ -1,3 +1,4 @@ +import FluidAudio import Foundation // MARK: - Kokoro TTS C FFI @@ -13,18 +14,37 @@ private func kokoroLog(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } +/// `computeUnits` is the kebab-case preset name `TtsComputeUnitPreset(cliValue:)` +/// accepts (`default`, `all-ane`, `cpu-and-gpu`, `cpu-only`). NULL or empty keeps +/// the backend's empirical per-stage mapping. An unrecognised value is a caller +/// bug, so it fails rather than silently synthesising on the wrong units. @_cdecl("fluidaudio_initialize_kokoro") public func fluidaudio_initialize_kokoro( _ ptr: UnsafeMutableRawPointer?, _ defaultVoice: UnsafePointer?, - _ lang: UnsafePointer? + _ lang: UnsafePointer?, + _ computeUnits: UnsafePointer? ) -> Int32 { guard let ptr = ptr else { return -1 } let bridge = Unmanaged.fromOpaque(ptr).takeUnretainedValue() let voice = defaultVoice.map { String(cString: $0) } ?? "af_heart" let langString = lang.map { String(cString: $0) } ?? "" + let unitsString = computeUnits.map { String(cString: $0) } ?? "" + + let preset: TtsComputeUnitPreset + if unitsString.isEmpty { + preset = .default + } else if let parsed = TtsComputeUnitPreset(cliValue: unitsString) { + preset = parsed + } else { + kokoroLog( + "Kokoro init error: unknown compute-units preset '\(unitsString)' " + + "(expected one of: \(TtsComputeUnitPreset.allCases.map(\.cliValue).joined(separator: ", ")))") + return -1 + } + do { - try bridge.initializeKokoro(defaultVoice: voice, lang: langString) + try bridge.initializeKokoro(defaultVoice: voice, lang: langString, computeUnits: preset) return 0 } catch { kokoroLog("Kokoro init error: \(error)") From 0dab7333d96782e350c9546de933ff66c366655d Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:09:26 +0300 Subject: [PATCH 02/10] docs(tts): correct where the ANE failure actually surfaces, and stop the preset test overclaiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fixes come from review of #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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- src/lib.rs | 80 ++++++++++++++++++++++++++++-------- swift/FluidAudioBridge.swift | 7 +++- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8cb2776..db361f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -507,11 +507,15 @@ impl FluidAudio { /// FluidAudio's default per-stage mapping pins the Albert, PostAlbert, /// Alignment and Vocoder stages to the Neural Engine. Where no ANE is /// exposed — notably a virtualised macOS guest, such as a GitHub-hosted - /// `macos-14` runner — CoreML cannot prepare those stages and init fails - /// with "Failed to prepare the model for predictions". Passing - /// [`KokoroComputeUnits::CpuAndGpu`] (or `CpuOnly`) keeps synthesis working - /// there, and doubles as the debugging baseline FluidAudio's `KokoroAne.md` - /// recommends for artefact investigations. + /// `macos-14` runner — CoreML defers the failure past model load: this call + /// succeeds, and the *first* [`synthesize_kokoro`](Self::synthesize_kokoro) + /// then fails with `predictionFailed(stage: "vocoder", ...)` wrapping + /// "Failed to prepare the model for predictions". `initialize` only + /// downloads and loads the mlmodelcs, so there is no prediction at init to + /// surface the problem earlier. Passing [`KokoroComputeUnits::CpuAndGpu`] + /// (or `CpuOnly`) keeps synthesis working there, and doubles as the + /// debugging baseline FluidAudio's `KokoroAne.md` recommends for artefact + /// investigations. pub fn init_kokoro_with_compute_units( &self, default_voice: &str, @@ -880,30 +884,70 @@ mod tests { } #[test] - fn compute_units_round_trip_through_cli_spellings() { - // `as_str` must stay inside what FluidAudio's `TtsComputeUnitPreset` - // parser accepts — the FFI passes the string, not the enum. - for units in [ - KokoroComputeUnits::Default, - KokoroComputeUnits::AllAne, - KokoroComputeUnits::CpuAndGpu, - KokoroComputeUnits::CpuOnly, + fn compute_units_emit_the_spellings_fluidaudio_parses() { + // The FFI passes the string, not the enum, so `as_str` has to land on a + // case of FluidAudio's `TtsComputeUnitPreset.init?(cliValue:)`. Nothing + // in this crate can execute that Swift parser, so the expectations below + // are transcribed from it and must be re-checked when the pinned + // FluidAudio version moves (Package.swift, currently 0.14.8 — + // Sources/FluidAudio/TTS/Shared/TtsComputeUnitPreset.swift). Asserting + // the literals — not just a `FromStr` round-trip — is what makes a + // rename of `as_str`'s output fail here instead of at runtime on the + // Swift side. + for (units, cli_value) in [ + (KokoroComputeUnits::Default, "default"), + (KokoroComputeUnits::AllAne, "all-ane"), + (KokoroComputeUnits::CpuAndGpu, "cpu-and-gpu"), + (KokoroComputeUnits::CpuOnly, "cpu-only"), ] { - assert_eq!(units.as_str().parse::().unwrap(), units); + assert_eq!(units.as_str(), cli_value); + assert_eq!(cli_value.parse::().unwrap(), units); } assert_eq!(KokoroComputeUnits::default(), KokoroComputeUnits::Default); } #[test] fn compute_units_accepts_aliases_and_rejects_junk() { + // Every alias FluidAudio 0.14.8's `init?(cliValue:)` accepts, so a + // caller that learned a spelling from FluidAudio's own `--compute-units` + // flag is not rejected here before the string ever reaches Swift. Same + // manual-sync caveat as the test above. + for (spelling, expected) in [ + ("default", KokoroComputeUnits::Default), + ("all-ane", KokoroComputeUnits::AllAne), + ("ane", KokoroComputeUnits::AllAne), + ("neural-engine", KokoroComputeUnits::AllAne), + ("cpu-and-gpu", KokoroComputeUnits::CpuAndGpu), + ("cpuandgpu", KokoroComputeUnits::CpuAndGpu), + ("gpu", KokoroComputeUnits::CpuAndGpu), + ("cpu-only", KokoroComputeUnits::CpuOnly), + ("cpu", KokoroComputeUnits::CpuOnly), + ("cpuonly", KokoroComputeUnits::CpuOnly), + ] { + assert_eq!( + spelling.parse::().unwrap(), + expected, + "alias {spelling:?} should parse" + ); + } + + // Swift lowercases before matching; so must we, or a mixed-case value + // would fail on this side and never reach the parser that accepts it. assert_eq!( "CPU-Only".parse::().unwrap(), KokoroComputeUnits::CpuOnly ); - assert_eq!( - "gpu".parse::().unwrap(), - KokoroComputeUnits::CpuAndGpu + + // Unknown presets fail in Rust, before the FFI call — the error names + // the canonical spellings rather than surfacing a bare `-1` from Swift. + let err = "tpu".parse::().unwrap_err().to_string(); + assert!( + err.contains("tpu"), + "error should quote the bad value: {err}" + ); + assert!( + err.contains("cpu-and-gpu"), + "error should list the accepted spellings: {err}" ); - assert!("tpu".parse::().is_err()); } } diff --git a/swift/FluidAudioBridge.swift b/swift/FluidAudioBridge.swift index 2a30ada..d7ba533 100644 --- a/swift/FluidAudioBridge.swift +++ b/swift/FluidAudioBridge.swift @@ -160,8 +160,11 @@ class FluidAudioBridgeInternal { /// FluidAudio's empirical per-stage mapping, which pins Albert / PostAlbert / /// Alignment / Vocoder to the Neural Engine. Callers running where no ANE is /// exposed — a virtualised macOS guest, e.g. a GitHub-hosted `macos-14` - /// runner — must pass `.cpuAndGpu` or `.cpuOnly`, or CoreML fails to prepare - /// those stages ("Failed to prepare the model for predictions"). + /// runner — must pass `.cpuAndGpu` or `.cpuOnly`. Note the failure does not + /// land here: `KokoroAneManager.initialize` only downloads and loads the + /// mlmodelcs, so this call succeeds and `synthesizeKokoro` then throws + /// `predictionFailed(stage: "vocoder", ...)` wrapping "Failed to prepare the + /// model for predictions" on the first prediction. func initializeKokoro( defaultVoice: String, lang: String, computeUnits: TtsComputeUnitPreset = .default ) throws { From b63f4dda296b4b9a5c06b288ee278dd961ebe4b1 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:26:28 +0300 Subject: [PATCH 03/10] refactor(tts): put the preset behind its own C symbol instead of widening the old one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- src/ffi/bridge.rs | 21 +++++++++++++++++++-- src/lib.rs | 6 ++++-- swift/Kokoro_ffi.swift | 37 +++++++++++++++++++++++++++++++------ 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/ffi/bridge.rs b/src/ffi/bridge.rs index dba1e95..2e945e7 100644 --- a/src/ffi/bridge.rs +++ b/src/ffi/bridge.rs @@ -187,6 +187,11 @@ extern "C" { bridge: *mut std::ffi::c_void, default_voice: *const i8, lang: *const i8, + ) -> i32; + fn fluidaudio_initialize_kokoro_with_compute_units( + bridge: *mut std::ffi::c_void, + default_voice: *const i8, + lang: *const i8, compute_units: *const i8, ) -> i32; fn fluidaudio_kokoro_synthesize( @@ -250,7 +255,19 @@ impl FluidAudioBridge { } } - pub fn initialize_kokoro( + pub fn initialize_kokoro(&self, default_voice: &str, lang: &str) -> Result<(), String> { + let c_voice = CString::new(default_voice).map_err(|_| "Invalid voice")?; + let c_lang = CString::new(lang).map_err(|_| "Invalid lang")?; + let result = + unsafe { fluidaudio_initialize_kokoro(self.ptr, c_voice.as_ptr(), c_lang.as_ptr()) }; + if result == 0 { + Ok(()) + } else { + Err("Failed to initialize Kokoro".to_string()) + } + } + + pub fn initialize_kokoro_with_compute_units( &self, default_voice: &str, lang: &str, @@ -260,7 +277,7 @@ impl FluidAudioBridge { let c_lang = CString::new(lang).map_err(|_| "Invalid lang")?; let c_units = CString::new(compute_units).map_err(|_| "Invalid compute units")?; let result = unsafe { - fluidaudio_initialize_kokoro( + fluidaudio_initialize_kokoro_with_compute_units( self.ptr, c_voice.as_ptr(), c_lang.as_ptr(), diff --git a/src/lib.rs b/src/lib.rs index db361f8..fb7b430 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -499,7 +499,9 @@ impl FluidAudio { /// [`init_kokoro_with_compute_units`](Self::init_kokoro_with_compute_units) /// when the host has no usable Neural Engine. pub fn init_kokoro(&self, default_voice: &str, lang: &str) -> Result<(), FluidAudioError> { - self.init_kokoro_with_compute_units(default_voice, lang, KokoroComputeUnits::Default) + self.bridge + .initialize_kokoro(default_voice, lang) + .map_err(FluidAudioError::from) } /// Initialize Kokoro TTS on an explicit CoreML compute-unit preset. @@ -523,7 +525,7 @@ impl FluidAudio { compute_units: KokoroComputeUnits, ) -> Result<(), FluidAudioError> { self.bridge - .initialize_kokoro(default_voice, lang, compute_units.as_str()) + .initialize_kokoro_with_compute_units(default_voice, lang, compute_units.as_str()) .map_err(FluidAudioError::from) } diff --git a/swift/Kokoro_ffi.swift b/swift/Kokoro_ffi.swift index 91564e1..3dc26c7 100644 --- a/swift/Kokoro_ffi.swift +++ b/swift/Kokoro_ffi.swift @@ -14,12 +14,12 @@ private func kokoroLog(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } -/// `computeUnits` is the kebab-case preset name `TtsComputeUnitPreset(cliValue:)` -/// accepts (`default`, `all-ane`, `cpu-and-gpu`, `cpu-only`). NULL or empty keeps -/// the backend's empirical per-stage mapping. An unrecognised value is a caller -/// bug, so it fails rather than silently synthesising on the wrong units. -@_cdecl("fluidaudio_initialize_kokoro") -public func fluidaudio_initialize_kokoro( +/// Shared body of both init entry points. `computeUnits` is the kebab-case preset +/// name `TtsComputeUnitPreset(cliValue:)` accepts (`default`, `all-ane`, +/// `cpu-and-gpu`, `cpu-only`). NULL or empty keeps the backend's empirical +/// per-stage mapping. An unrecognised value is a caller bug, so it fails rather +/// than silently synthesising on units the caller did not ask for. +private func initializeKokoro( _ ptr: UnsafeMutableRawPointer?, _ defaultVoice: UnsafePointer?, _ lang: UnsafePointer?, @@ -52,6 +52,31 @@ public func fluidaudio_initialize_kokoro( } } +/// Initialize on FluidAudio's empirical per-stage compute-unit mapping. +/// +/// Kept at three parameters so the existing C symbol's arity is unchanged — +/// anything linking these entry points from an older build keeps working. +@_cdecl("fluidaudio_initialize_kokoro") +public func fluidaudio_initialize_kokoro( + _ ptr: UnsafeMutableRawPointer?, + _ defaultVoice: UnsafePointer?, + _ lang: UnsafePointer? +) -> Int32 { + initializeKokoro(ptr, defaultVoice, lang, nil) +} + +/// Same, but pins every pipeline stage to an explicit preset. See +/// `initializeKokoro` for the accepted spellings and the failure contract. +@_cdecl("fluidaudio_initialize_kokoro_with_compute_units") +public func fluidaudio_initialize_kokoro_with_compute_units( + _ ptr: UnsafeMutableRawPointer?, + _ defaultVoice: UnsafePointer?, + _ lang: UnsafePointer?, + _ computeUnits: UnsafePointer? +) -> Int32 { + initializeKokoro(ptr, defaultVoice, lang, computeUnits) +} + /// Synthesize `text` with `voice` at `speed`; returns a complete WAV byte buffer /// (24 kHz mono 16-bit PCM (i16), peak-normalized) via `outBytes`/`outLen`. The caller owns the buffer and must /// free it with `fluidaudio_kokoro_free_bytes`. From 84559e3b4f0e8fd03669fb4a17bf74fd666597b6 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:28:06 +0300 Subject: [PATCH 04/10] ci: prove the ANE claim on macos-14 instead of asserting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 115 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e5806..f5f7b74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,118 @@ jobs: - name: Run library unit tests run: cargo test --lib + + # --------------------------------------------------------------------------- + # TEMPORARY — delete once the result is recorded in PR #21. + # + # The compute-unit escape hatch exists because FluidAudio's default mapping + # pins four Kokoro stages (Albert / PostAlbert / Alignment / Vocoder) to the + # Neural Engine, which is *claimed* to be unusable on a virtualised macOS + # guest. The job above runs on macos-15, where the ANE works, so it cannot + # test that claim at all — the motivating case has never been reproduced in + # CI, only inferred from a downstream failure. + # + # This job runs the same synthesis on macos-14 under both presets and reports + # what actually happens. It measures rather than asserts: `default` failing is + # the hypothesis under test, so it does not fail the build. `cpu-and-gpu` + # failing does, because that is the escape hatch not working. + # --------------------------------------------------------------------------- + ane-escape-hatch-proof: + name: "TEMP: Kokoro compute units on macos-14" + runs-on: macos-14 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Record host and Neural Engine visibility + run: | + { + echo "## Host" + echo '```' + sw_vers || true + uname -m || true + sysctl -n machdep.cpu.brand_string 2>/dev/null || true + echo "--- IORegistry entries matching ANE ---" + ioreg -l 2>/dev/null | grep -ci "ane" || echo "no matches" + echo "--- AppleNeuralEngine service ---" + ioreg -c AppleARMIODevice -d 1 2>/dev/null | grep -i "ane" | head -5 || echo "none" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Select Xcode 16 + run: | + ls /Applications | grep -i "^Xcode" || true + if [ -d /Applications/Xcode_16.app ]; then + sudo xcode-select -s /Applications/Xcode_16.app + else + echo "::warning::Xcode 16 not found on this image; FluidAudio needs Swift 6." + fi + swift --version + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: macos14-probe-cargo-${{ hashFiles('**/Cargo.lock', 'Package.resolved') }} + restore-keys: | + macos14-probe-cargo- + + # Built as its own step so a toolchain problem is distinguishable from an + # ANE problem: if this fails the job proves nothing about compute units. + - name: Build the example + run: cargo build --example kokoro --features tts + + - name: Synthesize under each preset + run: | + set +e + run_preset() { + cargo run -q --example kokoro --features tts -- \ + "Compute unit probe on a hosted runner" am_michael en-us "$1" \ + > "/tmp/kokoro-$1.wav" 2> "/tmp/kokoro-$1.err" + echo $? + } + default_exit=$(run_preset default) + cpu_gpu_exit=$(run_preset cpu-and-gpu) + + bytes() { grep -o 'WAV bytes: [0-9]*' "/tmp/kokoro-$1.err" | tail -1 | grep -o '[0-9]*'; } + default_bytes=$(bytes default) + cpu_gpu_bytes=$(bytes cpu-and-gpu) + + { + echo "## Result" + echo + echo "| preset | exit | WAV bytes |" + echo "|---|---|---|" + echo "| \`default\` | $default_exit | ${default_bytes:-—} |" + echo "| \`cpu-and-gpu\` | $cpu_gpu_exit | ${cpu_gpu_bytes:-—} |" + echo + echo "### Verdict" + if [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then + echo "Premise **confirmed**: the ANE-pinned default fails here and \`cpu-and-gpu\` works." + elif [ "$default_exit" -eq 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then + echo "Premise **not reproduced**: the default mapping synthesizes fine on this runner." + echo "The escape hatch is still useful, but macos-14 is not evidence for needing it." + elif [ "$cpu_gpu_exit" -ne 0 ]; then + echo "\`cpu-and-gpu\` **failed** — the escape hatch does not rescue this host." + fi + echo + echo "
default stderr" + echo; echo '```'; tail -40 /tmp/kokoro-default.err || true; echo '```' + echo "
" + echo "
cpu-and-gpu stderr" + echo; echo '```'; tail -40 /tmp/kokoro-cpu-and-gpu.err || true; echo '```' + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + + # `default` failing is the hypothesis; only a broken escape hatch is a + # build failure. + if [ "$cpu_gpu_exit" -ne 0 ]; then + echo "::error::cpu-and-gpu synthesis failed (exit $cpu_gpu_exit)" + exit 1 + fi From 9b922edbbd8fba98b7d01078abf1746e9b5c6a00 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:34:10 +0300 Subject: [PATCH 05/10] ci: pick the newest Xcode 16 actually installed on the macos-14 image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f7b74..15774b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,13 +82,19 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - - name: Select Xcode 16 + # The macos-14 image has no plain `Xcode_16.app` — it ships 15.x plus + # Xcode_16.1/16.2 — and its *default* toolchain is Swift 5.10, which + # cannot build FluidAudio 0.14.8 (Swift tools 6.0). Pick the newest 16.x + # actually present rather than guessing a filename. + - name: Select the newest Xcode 16 run: | ls /Applications | grep -i "^Xcode" || true - if [ -d /Applications/Xcode_16.app ]; then - sudo xcode-select -s /Applications/Xcode_16.app + xcode=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) + if [ -n "$xcode" ]; then + echo "Selecting $xcode" + sudo xcode-select -s "$xcode" else - echo "::warning::Xcode 16 not found on this image; FluidAudio needs Swift 6." + echo "::warning::No Xcode 16.x on this image; FluidAudio needs Swift 6." fi swift --version From f97a946d919bba9dd56d76b76e4a0e8ca87816ce Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:47:47 +0300 Subject: [PATCH 06/10] ci: echo the probe's stderr to the log, not only the step summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15774b5..2f2666f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,14 @@ jobs: default_bytes=$(bytes default) cpu_gpu_bytes=$(bytes cpu-and-gpu) + # Also to stdout: the step summary is not reachable from `gh run view`, + # so on failure the diagnosis has to be in the log itself. + for preset in default cpu-and-gpu; do + echo "===== $preset stderr =====" + cat "/tmp/kokoro-$preset.err" || true + done + echo "===== exits: default=$default_exit cpu-and-gpu=$cpu_gpu_exit =====" + { echo "## Result" echo From 73aea427420468ddb9112a0359c7d6b278515cbe Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:52:37 +0300 Subject: [PATCH 07/10] ci: probe with a voice that actually exists, and refuse to call a 404 a result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f2666f..6291115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,9 +120,12 @@ jobs: - name: Synthesize under each preset run: | set +e + # af_heart, not am_michael: it is the only English voice in the hosted + # bundle. A dev machine with a pre-staged bundle hides this; a clean + # runner gets a 404 and the probe measures nothing. run_preset() { cargo run -q --example kokoro --features tts -- \ - "Compute unit probe on a hosted runner" am_michael en-us "$1" \ + "Compute unit probe on a hosted runner" af_heart en-us "$1" \ > "/tmp/kokoro-$1.wav" 2> "/tmp/kokoro-$1.err" echo $? } @@ -141,6 +144,21 @@ jobs: done echo "===== exits: default=$default_exit cpu-and-gpu=$cpu_gpu_exit =====" + # A model/voice download failure aborts init before any prediction, so + # it hits every preset identically and proves nothing about compute + # units. Calling that "the escape hatch failed" would be a false + # negative — the first run of this probe did exactly that, on a 404. + setup_failure="" + if [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -ne 0 ]; then + for pat in "statusCode" "invalidResponse" "downloadFailed" "URLError"; do + if grep -q "$pat" /tmp/kokoro-default.err 2>/dev/null \ + && grep -q "$pat" /tmp/kokoro-cpu-and-gpu.err 2>/dev/null; then + setup_failure=$(grep -h -m1 "$pat" /tmp/kokoro-default.err | tr -d '`') + break + fi + done + fi + { echo "## Result" echo @@ -150,7 +168,11 @@ jobs: echo "| \`cpu-and-gpu\` | $cpu_gpu_exit | ${cpu_gpu_bytes:-—} |" echo echo "### Verdict" - if [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then + if [ -n "$setup_failure" ]; then + echo "**Inconclusive** — the run never reached a compute-unit decision." + echo "Both presets failed the same way before any prediction (" + echo "\`$setup_failure\`), so this says nothing about the ANE either way." + elif [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then echo "Premise **confirmed**: the ANE-pinned default fails here and \`cpu-and-gpu\` works." elif [ "$default_exit" -eq 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then echo "Premise **not reproduced**: the default mapping synthesizes fine on this runner." @@ -168,7 +190,12 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" # `default` failing is the hypothesis; only a broken escape hatch is a - # build failure. + # build failure. A setup failure fails too, but says so — it is a + # broken probe, not a result. + if [ -n "$setup_failure" ]; then + echo "::error::probe never reached a compute-unit decision: $setup_failure" + exit 1 + fi if [ "$cpu_gpu_exit" -ne 0 ]; then echo "::error::cpu-and-gpu synthesis failed (exit $cpu_gpu_exit)" exit 1 From 625976b3897d23cd8070bb2c034e20ed258505f3 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 15:57:58 +0300 Subject: [PATCH 08/10] ci: probe every preset, since testing one cannot answer the question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 116 ++++++++++++++++++++++----------------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6291115..2737144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,10 +55,11 @@ jobs: # test that claim at all — the motivating case has never been reproduced in # CI, only inferred from a downstream failure. # - # This job runs the same synthesis on macos-14 under both presets and reports - # what actually happens. It measures rather than asserts: `default` failing is - # the hypothesis under test, so it does not fail the build. `cpu-and-gpu` - # failing does, because that is the escape hatch not working. + # This job runs the same synthesis on macos-14 under all four presets and + # reports what actually happens. It measures rather than asserts: `default` + # failing is the hypothesis under test, so it does not fail the build. The + # build fails only when no preset at all synthesizes — an escape hatch that + # rescues nothing — or when the probe never reached a compute-unit decision. # --------------------------------------------------------------------------- ane-escape-hatch-proof: name: "TEMP: Kokoro compute units on macos-14" @@ -69,6 +70,8 @@ jobs: - name: Record host and Neural Engine visibility run: | + # tee, not >>: the step summary is not reachable from `gh run view`, + # so anything only written there is invisible when diagnosing a run. { echo "## Host" echo '```' @@ -80,7 +83,7 @@ jobs: echo "--- AppleNeuralEngine service ---" ioreg -c AppleARMIODevice -d 1 2>/dev/null | grep -i "ane" | head -5 || echo "none" echo '```' - } >> "$GITHUB_STEP_SUMMARY" + } | tee -a "$GITHUB_STEP_SUMMARY" # The macos-14 image has no plain `Xcode_16.app` — it ships 15.x plus # Xcode_16.1/16.2 — and its *default* toolchain is Swift 5.10, which @@ -120,83 +123,94 @@ jobs: - name: Synthesize under each preset run: | set +e + # All four, not just default vs cpu-and-gpu. The escape hatch has three + # non-default settings and testing one of them cannot show whether the + # hatch works — the first run of this probe tested only `cpu-and-gpu`, + # found it broken here, and could say nothing about `cpu-only`. + PRESETS="default all-ane cpu-and-gpu cpu-only" + # af_heart, not am_michael: it is the only English voice in the hosted # bundle. A dev machine with a pre-staged bundle hides this; a clean # runner gets a 404 and the probe measures nothing. - run_preset() { + for preset in $PRESETS; do cargo run -q --example kokoro --features tts -- \ - "Compute unit probe on a hosted runner" af_heart en-us "$1" \ - > "/tmp/kokoro-$1.wav" 2> "/tmp/kokoro-$1.err" - echo $? - } - default_exit=$(run_preset default) - cpu_gpu_exit=$(run_preset cpu-and-gpu) - - bytes() { grep -o 'WAV bytes: [0-9]*' "/tmp/kokoro-$1.err" | tail -1 | grep -o '[0-9]*'; } - default_bytes=$(bytes default) - cpu_gpu_bytes=$(bytes cpu-and-gpu) - - # Also to stdout: the step summary is not reachable from `gh run view`, - # so on failure the diagnosis has to be in the log itself. - for preset in default cpu-and-gpu; do - echo "===== $preset stderr =====" + "Compute unit probe on a hosted runner" af_heart en-us "$preset" \ + > "/tmp/kokoro-$preset.wav" 2> "/tmp/kokoro-$preset.err" + echo $? > "/tmp/kokoro-$preset.exit" + done + + exit_of() { cat "/tmp/kokoro-$1.exit"; } + bytes_of() { grep -o 'WAV bytes: [0-9]*' "/tmp/kokoro-$1.err" | tail -1 | grep -o '[0-9]*'; } + + for preset in $PRESETS; do + echo "===== $preset (exit $(exit_of "$preset")) =====" cat "/tmp/kokoro-$preset.err" || true done - echo "===== exits: default=$default_exit cpu-and-gpu=$cpu_gpu_exit =====" # A model/voice download failure aborts init before any prediction, so # it hits every preset identically and proves nothing about compute # units. Calling that "the escape hatch failed" would be a false - # negative — the first run of this probe did exactly that, on a 404. + # negative — an earlier run of this probe did exactly that, on a 404. setup_failure="" - if [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -ne 0 ]; then + all_failed=1 + for preset in $PRESETS; do + [ "$(exit_of "$preset")" -eq 0 ] && all_failed=0 + done + if [ "$all_failed" -eq 1 ]; then for pat in "statusCode" "invalidResponse" "downloadFailed" "URLError"; do - if grep -q "$pat" /tmp/kokoro-default.err 2>/dev/null \ - && grep -q "$pat" /tmp/kokoro-cpu-and-gpu.err 2>/dev/null; then + hits=0 + for preset in $PRESETS; do + grep -q "$pat" "/tmp/kokoro-$preset.err" 2>/dev/null && hits=$((hits + 1)) + done + if [ "$hits" -eq 4 ]; then setup_failure=$(grep -h -m1 "$pat" /tmp/kokoro-default.err | tr -d '`') break fi done fi + # Which non-default presets actually synthesized. + working="" + for preset in all-ane cpu-and-gpu cpu-only; do + [ "$(exit_of "$preset")" -eq 0 ] && working="$working $preset" + done + working=$(echo $working) + { echo "## Result" echo echo "| preset | exit | WAV bytes |" echo "|---|---|---|" - echo "| \`default\` | $default_exit | ${default_bytes:-—} |" - echo "| \`cpu-and-gpu\` | $cpu_gpu_exit | ${cpu_gpu_bytes:-—} |" + for preset in $PRESETS; do + echo "| \`$preset\` | $(exit_of "$preset") | $(bytes_of "$preset" || echo '—') |" + done echo echo "### Verdict" if [ -n "$setup_failure" ]; then echo "**Inconclusive** — the run never reached a compute-unit decision." - echo "Both presets failed the same way before any prediction (" - echo "\`$setup_failure\`), so this says nothing about the ANE either way." - elif [ "$default_exit" -ne 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then - echo "Premise **confirmed**: the ANE-pinned default fails here and \`cpu-and-gpu\` works." - elif [ "$default_exit" -eq 0 ] && [ "$cpu_gpu_exit" -eq 0 ]; then - echo "Premise **not reproduced**: the default mapping synthesizes fine on this runner." - echo "The escape hatch is still useful, but macos-14 is not evidence for needing it." - elif [ "$cpu_gpu_exit" -ne 0 ]; then - echo "\`cpu-and-gpu\` **failed** — the escape hatch does not rescue this host." + echo "Every preset failed the same way before any prediction" + echo "(\`$setup_failure\`), so this says nothing about the ANE either way." + elif [ "$(exit_of default)" -eq 0 ]; then + echo "Premise **not reproduced**: the default mapping synthesizes fine on this" + echo "runner, so macos-14 is not evidence for needing the escape hatch." + elif [ -n "$working" ]; then + echo "Premise **confirmed**, and the escape hatch works: the ANE-pinned default" + echo "fails here while these synthesize — \`$working\`." + else + echo "Premise **confirmed**, but the escape hatch **does not rescue this host**:" + echo "the default fails *and* so does every alternative preset. The lever is" + echo "real, but it is not sufficient here — see the per-preset errors above." fi - echo - echo "
default stderr" - echo; echo '```'; tail -40 /tmp/kokoro-default.err || true; echo '```' - echo "
" - echo "
cpu-and-gpu stderr" - echo; echo '```'; tail -40 /tmp/kokoro-cpu-and-gpu.err || true; echo '```' - echo "
" - } >> "$GITHUB_STEP_SUMMARY" - - # `default` failing is the hypothesis; only a broken escape hatch is a - # build failure. A setup failure fails too, but says so — it is a - # broken probe, not a result. + } | tee -a "$GITHUB_STEP_SUMMARY" + + # `default` failing is the hypothesis, not a build failure. A probe that + # measured nothing is broken. An escape hatch that rescues nothing is a + # finding worth failing on. if [ -n "$setup_failure" ]; then echo "::error::probe never reached a compute-unit decision: $setup_failure" exit 1 fi - if [ "$cpu_gpu_exit" -ne 0 ]; then - echo "::error::cpu-and-gpu synthesis failed (exit $cpu_gpu_exit)" + if [ "$(exit_of default)" -ne 0 ] && [ -z "$working" ]; then + echo "::error::no compute-unit preset synthesizes on this host" exit 1 fi From df9e5b8cfafe9568021af4fa35cc69797aa1e934 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 16:15:11 +0300 Subject: [PATCH 09/10] ci: drop the macos-14 probe now that it has answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- .github/workflows/ci.yml | 170 --------------------------------------- 1 file changed, 170 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2737144..63e5806 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,173 +44,3 @@ jobs: - name: Run library unit tests run: cargo test --lib - - # --------------------------------------------------------------------------- - # TEMPORARY — delete once the result is recorded in PR #21. - # - # The compute-unit escape hatch exists because FluidAudio's default mapping - # pins four Kokoro stages (Albert / PostAlbert / Alignment / Vocoder) to the - # Neural Engine, which is *claimed* to be unusable on a virtualised macOS - # guest. The job above runs on macos-15, where the ANE works, so it cannot - # test that claim at all — the motivating case has never been reproduced in - # CI, only inferred from a downstream failure. - # - # This job runs the same synthesis on macos-14 under all four presets and - # reports what actually happens. It measures rather than asserts: `default` - # failing is the hypothesis under test, so it does not fail the build. The - # build fails only when no preset at all synthesizes — an escape hatch that - # rescues nothing — or when the probe never reached a compute-unit decision. - # --------------------------------------------------------------------------- - ane-escape-hatch-proof: - name: "TEMP: Kokoro compute units on macos-14" - runs-on: macos-14 - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - - name: Record host and Neural Engine visibility - run: | - # tee, not >>: the step summary is not reachable from `gh run view`, - # so anything only written there is invisible when diagnosing a run. - { - echo "## Host" - echo '```' - sw_vers || true - uname -m || true - sysctl -n machdep.cpu.brand_string 2>/dev/null || true - echo "--- IORegistry entries matching ANE ---" - ioreg -l 2>/dev/null | grep -ci "ane" || echo "no matches" - echo "--- AppleNeuralEngine service ---" - ioreg -c AppleARMIODevice -d 1 2>/dev/null | grep -i "ane" | head -5 || echo "none" - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" - - # The macos-14 image has no plain `Xcode_16.app` — it ships 15.x plus - # Xcode_16.1/16.2 — and its *default* toolchain is Swift 5.10, which - # cannot build FluidAudio 0.14.8 (Swift tools 6.0). Pick the newest 16.x - # actually present rather than guessing a filename. - - name: Select the newest Xcode 16 - run: | - ls /Applications | grep -i "^Xcode" || true - xcode=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1) - if [ -n "$xcode" ]; then - echo "Selecting $xcode" - sudo xcode-select -s "$xcode" - else - echo "::warning::No Xcode 16.x on this image; FluidAudio needs Swift 6." - fi - swift --version - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry and target - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: macos14-probe-cargo-${{ hashFiles('**/Cargo.lock', 'Package.resolved') }} - restore-keys: | - macos14-probe-cargo- - - # Built as its own step so a toolchain problem is distinguishable from an - # ANE problem: if this fails the job proves nothing about compute units. - - name: Build the example - run: cargo build --example kokoro --features tts - - - name: Synthesize under each preset - run: | - set +e - # All four, not just default vs cpu-and-gpu. The escape hatch has three - # non-default settings and testing one of them cannot show whether the - # hatch works — the first run of this probe tested only `cpu-and-gpu`, - # found it broken here, and could say nothing about `cpu-only`. - PRESETS="default all-ane cpu-and-gpu cpu-only" - - # af_heart, not am_michael: it is the only English voice in the hosted - # bundle. A dev machine with a pre-staged bundle hides this; a clean - # runner gets a 404 and the probe measures nothing. - for preset in $PRESETS; do - cargo run -q --example kokoro --features tts -- \ - "Compute unit probe on a hosted runner" af_heart en-us "$preset" \ - > "/tmp/kokoro-$preset.wav" 2> "/tmp/kokoro-$preset.err" - echo $? > "/tmp/kokoro-$preset.exit" - done - - exit_of() { cat "/tmp/kokoro-$1.exit"; } - bytes_of() { grep -o 'WAV bytes: [0-9]*' "/tmp/kokoro-$1.err" | tail -1 | grep -o '[0-9]*'; } - - for preset in $PRESETS; do - echo "===== $preset (exit $(exit_of "$preset")) =====" - cat "/tmp/kokoro-$preset.err" || true - done - - # A model/voice download failure aborts init before any prediction, so - # it hits every preset identically and proves nothing about compute - # units. Calling that "the escape hatch failed" would be a false - # negative — an earlier run of this probe did exactly that, on a 404. - setup_failure="" - all_failed=1 - for preset in $PRESETS; do - [ "$(exit_of "$preset")" -eq 0 ] && all_failed=0 - done - if [ "$all_failed" -eq 1 ]; then - for pat in "statusCode" "invalidResponse" "downloadFailed" "URLError"; do - hits=0 - for preset in $PRESETS; do - grep -q "$pat" "/tmp/kokoro-$preset.err" 2>/dev/null && hits=$((hits + 1)) - done - if [ "$hits" -eq 4 ]; then - setup_failure=$(grep -h -m1 "$pat" /tmp/kokoro-default.err | tr -d '`') - break - fi - done - fi - - # Which non-default presets actually synthesized. - working="" - for preset in all-ane cpu-and-gpu cpu-only; do - [ "$(exit_of "$preset")" -eq 0 ] && working="$working $preset" - done - working=$(echo $working) - - { - echo "## Result" - echo - echo "| preset | exit | WAV bytes |" - echo "|---|---|---|" - for preset in $PRESETS; do - echo "| \`$preset\` | $(exit_of "$preset") | $(bytes_of "$preset" || echo '—') |" - done - echo - echo "### Verdict" - if [ -n "$setup_failure" ]; then - echo "**Inconclusive** — the run never reached a compute-unit decision." - echo "Every preset failed the same way before any prediction" - echo "(\`$setup_failure\`), so this says nothing about the ANE either way." - elif [ "$(exit_of default)" -eq 0 ]; then - echo "Premise **not reproduced**: the default mapping synthesizes fine on this" - echo "runner, so macos-14 is not evidence for needing the escape hatch." - elif [ -n "$working" ]; then - echo "Premise **confirmed**, and the escape hatch works: the ANE-pinned default" - echo "fails here while these synthesize — \`$working\`." - else - echo "Premise **confirmed**, but the escape hatch **does not rescue this host**:" - echo "the default fails *and* so does every alternative preset. The lever is" - echo "real, but it is not sufficient here — see the per-preset errors above." - fi - } | tee -a "$GITHUB_STEP_SUMMARY" - - # `default` failing is the hypothesis, not a build failure. A probe that - # measured nothing is broken. An escape hatch that rescues nothing is a - # finding worth failing on. - if [ -n "$setup_failure" ]; then - echo "::error::probe never reached a compute-unit decision: $setup_failure" - exit 1 - fi - if [ "$(exit_of default)" -ne 0 ] && [ -z "$working" ]; then - echo "::error::no compute-unit preset synthesizes on this host" - exit 1 - fi From ec2a2d75781946d278f77626dd13b26c7694b6f7 Mon Sep 17 00:00:00 2001 From: drakulavich Date: Wed, 5 Aug 2026 20:31:52 +0300 Subject: [PATCH 10/10] refactor(tts): drop the stray rustfmt hunks, document what dies at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01MKfMMVQbSEgEca4nMYey4D --- src/ffi/bridge.rs | 15 +++++++++++++++ src/lib.rs | 12 +++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/ffi/bridge.rs b/src/ffi/bridge.rs index 2e945e7..628017d 100644 --- a/src/ffi/bridge.rs +++ b/src/ffi/bridge.rs @@ -267,6 +267,21 @@ impl FluidAudioBridge { } } + /// The C entry point reports only success or `-1`, so the returned error + /// names the preset and nothing else. The actionable text — the CoreML + /// domain and code, or the download status for a missing voice pack — is + /// written to **stderr** by the Swift side, not carried back across the + /// boundary. A caller diagnosing a failure has to read it there. Two real + /// examples of what only stderr shows: + /// + /// ```text + /// Kokoro init error: invalidResponse(description: "am_michael voice pack", statusCode: 404) + /// Kokoro synthesize error: predictionFailed(stage: "vocoder", ... "Failed to prepare + /// the model for predictions. ML program was KokoroVocoder ...") + /// ``` + /// + /// Both are indistinguishable from here — a missing voice and an + /// unusable Neural Engine arrive as the same `-1`. pub fn initialize_kokoro_with_compute_units( &self, default_voice: &str, diff --git a/src/lib.rs b/src/lib.rs index fb7b430..5cdb1c3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -747,12 +747,7 @@ impl FluidAudio { max_audio_seconds: f64, ) -> Result<(), FluidAudioError> { self.bridge - .qwen3_streaming_start( - language, - min_audio_seconds, - chunk_seconds, - max_audio_seconds, - ) + .qwen3_streaming_start(language, min_audio_seconds, chunk_seconds, max_audio_seconds) .map_err(FluidAudioError::from) } @@ -766,7 +761,10 @@ impl FluidAudio { /// /// Call this repeatedly as audio chunks become available. The engine will return /// partial transcripts according to the configuration set in `qwen3_streaming_start`. - pub fn qwen3_streaming_feed(&self, samples: &[f32]) -> Result, FluidAudioError> { + pub fn qwen3_streaming_feed( + &self, + samples: &[f32], + ) -> Result, FluidAudioError> { self.bridge .qwen3_streaming_feed(samples) .map_err(FluidAudioError::from)