diff --git a/Documentation/Diarization/GettingStarted.md b/Documentation/Diarization/GettingStarted.md index df62409d1..73eeef4a9 100644 --- a/Documentation/Diarization/GettingStarted.md +++ b/Documentation/Diarization/GettingStarted.md @@ -191,6 +191,8 @@ let diarizer = DiarizerManager(config: config) > Requires macOS 14 / iOS 17 or later. The offline stack uses native C++ clustering and AsyncStream coordination that are unavailable on older OS releases. +> **Known OS issue — macOS 14 can crash in BNNS.** macOS 14 (all patch levels through at least 14.8.7) carries an Apple bug that crashes Core ML predictions on the BNNS CPU path (`EXC_BAD_ACCESS` in `libBNNS`, `BNNSGraphContextExecute_v2` → `_platform_memmove`). Offline diarization is the component most exposed because its FBank model always runs on CPU. Nothing at the library or app level avoids it — serialized pipelines, single-model runs, and every compute-unit routing all crash ([#878](https://github.com/FluidInference/FluidAudio/issues/878), 1200/1200 reproduction on macOS 14 CI runners; also [#661](https://github.com/FluidInference/FluidAudio/issues/661)). On machines without a Neural Engine (VMs, CI runners) the crash is deterministic; on Apple Silicon it is intermittent, striking when predictions fall back from the ANE to BNNS. Apple fixed it in macOS 15 — the only remedy is updating the OS. `OfflineDiarizerManager` logs a warning when initialized on an affected build. (This is a distinct bug from the macOS/iOS 26.4–26.5 BNNS crash documented for Kokoro TTS in [KokoroAne.md](../TTS/KokoroAne.md).) + When you need full parity with the pyannote/Core ML exporter (powerset segmentation + VBx clustering), use `OfflineDiarizerManager`. It orchestrates segmentation, soft mask interpolation, WeSpeaker embedding extraction, PLDA/VBx clustering, and timeline reconstruction in one place: ```swift diff --git a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift index 17f04669b..f8cc9b912 100644 --- a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift +++ b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerManager.swift @@ -17,10 +17,35 @@ public final class OfflineDiarizerManager { } public func initialize(models: OfflineDiarizerModels) { + if Self.isBnnsCrashProneOS(ProcessInfo.processInfo.operatingSystemVersion) { + logger.warning( + "macOS 14 has a known Apple BNNS bug that can crash offline " + + "diarization (EXC_BAD_ACCESS in libBNNS) regardless of compute-unit " + + "routing or serialization. Fixed in macOS 15. " + + "See https://github.com/FluidInference/FluidAudio/issues/878") + } self.models = models logger.info("Offline diarizer models initialized") } + #if os(macOS) + private static let runningOnMacOS = true + #else + private static let runningOnMacOS = false + #endif + + /// macOS 14 carries an Apple BNNS bug that crashes Core ML predictions on + /// the BNNS CPU path (`BNNSGraphContextExecute_v2` → `_platform_memmove`, + /// #661/#878). Deterministic on machines without an ANE, intermittent on + /// Apple Silicon when predictions fall back from the ANE. No usage pattern + /// avoids it; Apple fixed it in macOS 15. iOS is unflagged — no reproduction + /// has been reported on the iOS 17 line. + static func isBnnsCrashProneOS( + _ version: OperatingSystemVersion, onMacOS: Bool = runningOnMacOS + ) -> Bool { + onMacOS && version.majorVersion == 14 + } + /// Ensure offline diarizer models are available, downloading and compiling them when needed. /// - Parameters: /// - directory: Custom cache directory. Defaults to `OfflineDiarizerModels.defaultModelsDirectory()`. diff --git a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift index ad9dd38bd..51efa24c7 100644 --- a/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift +++ b/Sources/FluidAudio/Diarizer/Offline/Core/OfflineDiarizerModels.swift @@ -111,6 +111,8 @@ public struct OfflineDiarizerModels: Sendable { throw OfflineDiarizationError.modelNotLoaded(ModelNames.OfflineDiarizer.pldaRho) } + // CPU is fastest for FBank, but on macOS 14 this pins every FBank + // prediction to the crash-prone BNNS path (#878). let fbankComputeUnits: MLComputeUnits = .cpuOnly let fbankModels = try await ModelHub.loadModels( .diarizer, diff --git a/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift b/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift new file mode 100644 index 000000000..0b32dec9d --- /dev/null +++ b/Tests/FluidAudioTests/Diarizer/Offline/OfflineDiarizerOsAdvisoryTests.swift @@ -0,0 +1,32 @@ +import XCTest + +@testable import FluidAudio + +final class OfflineDiarizerOsAdvisoryTests: XCTestCase { + + private func version(_ major: Int, _ minor: Int, _ patch: Int = 0) -> OperatingSystemVersion { + OperatingSystemVersion(majorVersion: major, minorVersion: minor, patchVersion: patch) + } + + // #878: the entire macOS 14 line crashes in libBNNS regardless of + // patch level (1200/1200 on 14.8.7 CI runners). + func testMacOS14LineIsFlagged() { + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 0), onMacOS: true)) + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 6, 1), onMacOS: true)) + XCTAssertTrue(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 8, 7), onMacOS: true)) + } + + func testFixedAndUnaffectedMacOSLinesAreNotFlagged() { + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(13, 6), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(15, 0), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(15, 7, 7), onMacOS: true)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(26, 5, 2), onMacOS: true)) + } + + // No reproduction reported on iOS; only macOS is flagged. + func testIOSIsNotFlagged() { + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(14, 0), onMacOS: false)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(17, 0), onMacOS: false)) + XCTAssertFalse(OfflineDiarizerManager.isBnnsCrashProneOS(version(18, 0), onMacOS: false)) + } +}