Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/FluidAudio/TTS/KokoroAne/KokoroAneError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading