From b944acd048543c87120649077ca79de454a064a6 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Thu, 23 Jul 2026 18:27:11 -0400 Subject: [PATCH] fix(tts/kokoro-ane): throw instead of trapping on non-finite PostAlbert durations On iOS 27 betas the new E5 CoreML runtime mis-executes the dynamic-shape Kokoro stages ("MIL program has non-constant (dynamic) shapes ... E5 function will be produced with unknown input shapes") and PostAlbert returns NaN for its 'duration' output. The pred_dur conversion then hit Int32(Float.nan), a Swift runtime trap that crashed the host app (issue #738, report from iOS 27 public beta). Extract the rounding into predictedDurations(from:), which now: - throws KokoroAneError.nonFiniteModelOutput on NaN/inf instead of trapping, so host apps get a catchable error naming the likely cause - clamps finite values to [1, maxAcousticFrames] so huge garbage values cannot trap Int32() either; absurd totals still surface via the existing acousticFramesExceedCap check Behavior for well-formed outputs is unchanged (durations are small positive floats, far below the cap). Adds pure-function unit tests for rounding, clamping, and the NaN/inf throw path (no models required). --- .../TTS/KokoroAne/KokoroAneError.swift | 6 +++ .../Pipeline/KokoroAneSynthesizer.swift | 21 +++++++-- .../KokoroAne/KokoroAneSynthesizerTests.swift | 45 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Sources/FluidAudio/TTS/KokoroAne/KokoroAneError.swift b/Sources/FluidAudio/TTS/KokoroAne/KokoroAneError.swift index d441b06df..350864ef2 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/KokoroAneError.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/KokoroAneError.swift @@ -13,6 +13,7 @@ public enum KokoroAneError: Error, LocalizedError { case acousticFramesExceedCap(have: Int, cap: Int) case predictionFailed(stage: String, underlying: Error) case unexpectedOutputShape(stage: String, expected: String, got: String) + case nonFiniteModelOutput(stage: String, output: String) case audioConversionFailed(String) public var errorDescription: String? { @@ -39,6 +40,11 @@ public enum KokoroAneError: Error, LocalizedError { return "KokoroAne stage '\(stage)' failed: \(err.localizedDescription)" case .unexpectedOutputShape(let stage, let expected, let got): return "KokoroAne stage '\(stage)' returned unexpected shape (expected \(expected), got \(got))." + case .nonFiniteModelOutput(let stage, let output): + return + "KokoroAne stage '\(stage)' returned non-finite values in '\(output)'. " + + "The CoreML runtime mis-executed the model on this OS (seen on iOS 27 betas, " + + "see FluidAudio issue #738)." case .audioConversionFailed(let detail): return "KokoroAne audio conversion failed: \(detail)" } diff --git a/Sources/FluidAudio/TTS/KokoroAne/Pipeline/KokoroAneSynthesizer.swift b/Sources/FluidAudio/TTS/KokoroAne/Pipeline/KokoroAneSynthesizer.swift index 3c8e407db..02b72a760 100644 --- a/Sources/FluidAudio/TTS/KokoroAne/Pipeline/KokoroAneSynthesizer.swift +++ b/Sources/FluidAudio/TTS/KokoroAne/Pipeline/KokoroAneSynthesizer.swift @@ -70,10 +70,7 @@ public struct KokoroAneSynthesizer { // duration → pred_dur (int32, rounded, clamped ≥ 1) let duration = try outputArray(postOut, key: "duration", stage: .postAlbert) let durFloats = KokoroAneArrays.readFloats(duration) - let predDur = durFloats.map { d -> Int32 in - let r = Int32(Float(d).rounded()) - return max(r, 1) - } + let predDur = try predictedDurations(from: durFloats) let tA = predDur.reduce(0) { $0 + Int($1) } if tA > KokoroAneConstants.maxAcousticFrames { throw KokoroAneError.acousticFramesExceedCap( @@ -164,6 +161,22 @@ public struct KokoroAneSynthesizer { // MARK: - Helpers + /// Round PostAlbert `duration` floats to per-token frame counts, clamped + /// to [1, maxAcousticFrames]. Throws instead of trapping when the model + /// emits NaN/±inf — broken CoreML runtimes can return non-finite outputs + /// (iOS 27 betas mis-execute the dynamic-shape stages, issue #738), and + /// `Int32(Float)` on those values is a fatal error in the host app. + static func predictedDurations(from durFloats: [Float]) throws -> [Int32] { + guard durFloats.allSatisfy({ $0.isFinite }) else { + throw KokoroAneError.nonFiniteModelOutput( + stage: KokoroAneStage.postAlbert.rawValue, output: "duration") + } + let cap = Float(KokoroAneConstants.maxAcousticFrames) + return durFloats.map { d in + Int32(min(max(d.rounded(), 1), cap)) + } + } + private static func predict( stage: KokoroAneStage, model: MLModel, diff --git a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneSynthesizerTests.swift b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneSynthesizerTests.swift index ec88f908e..6d224bd47 100644 --- a/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneSynthesizerTests.swift +++ b/Tests/FluidAudioTests/TTS/KokoroAne/KokoroAneSynthesizerTests.swift @@ -3,6 +3,51 @@ import XCTest @testable import FluidAudio +/// Lightweight tests for the pure duration-rounding helper (no models needed). +final class KokoroAnePredictedDurationTests: XCTestCase { + + func testRoundsAndClampsToMinimumOne() throws { + let out = try KokoroAneSynthesizer.predictedDurations( + from: [0.2, 0.5, 1.4, 2.5, 7.9, -3.0]) + // .rounded() is half-away-from-zero: 2.5 → 3. + XCTAssertEqual(out, [1, 1, 1, 3, 8, 1]) + } + + func testNaNThrowsInsteadOfTrapping() { + // iOS 27 betas mis-execute the dynamic-shape PostAlbert stage and + // return NaN durations (#738); Int32(NaN) would crash the host app. + XCTAssertThrowsError( + try KokoroAneSynthesizer.predictedDurations(from: [1.0, .nan, 2.0]) + ) { error in + guard case KokoroAneError.nonFiniteModelOutput(let stage, let output) = error else { + return XCTFail("Expected nonFiniteModelOutput, got \(error)") + } + XCTAssertEqual(stage, KokoroAneStage.postAlbert.rawValue) + XCTAssertEqual(output, "duration") + } + } + + func testInfinityThrows() { + XCTAssertThrowsError( + try KokoroAneSynthesizer.predictedDurations(from: [.infinity])) + XCTAssertThrowsError( + try KokoroAneSynthesizer.predictedDurations(from: [-.infinity])) + } + + func testHugeFiniteValueClampsWithoutTrapping() throws { + // Garbage runtimes can also return huge finite values; Int32(1e30) + // would trap. Clamped to the frame cap, the downstream T_a check + // then throws acousticFramesExceedCap. + let out = try KokoroAneSynthesizer.predictedDurations(from: [1e30, 3.0]) + XCTAssertEqual(out[0], Int32(KokoroAneConstants.maxAcousticFrames)) + XCTAssertEqual(out[1], 3) + } + + func testEmptyInput() throws { + XCTAssertEqual(try KokoroAneSynthesizer.predictedDurations(from: []), []) + } +} + /// Heavy E2E tests gated by env var (require all 7 mlmodelc + voice + vocab /// in cache). Skipped on CI by default. final class KokoroAneSynthesizerTests: XCTestCase {