diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 1155efef..0fccaae9 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; + 7CAA000A2F99000000000001 /* NemotronCacheRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAA000B2F99000000000002 /* NemotronCacheRecoveryTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -36,6 +37,7 @@ 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; + 7CAA000B2F99000000000002 /* NemotronCacheRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NemotronCacheRecoveryTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -107,6 +109,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, + 7CAA000B2F99000000000002 /* NemotronCacheRecoveryTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -262,6 +265,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, + 7CAA000A2F99000000000001 /* NemotronCacheRecoveryTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/NemotronProvider.swift b/Sources/Fluid/Services/NemotronProvider.swift index 64e07da3..af3f3e02 100644 --- a/Sources/Fluid/Services/NemotronProvider.swift +++ b/Sources/Fluid/Services/NemotronProvider.swift @@ -74,6 +74,12 @@ final class NemotronProvider: TranscriptionProvider { .appendingPathComponent(self.folderHint, isDirectory: true) } + static var compiledModelCacheDirectory: URL? { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("FluidAudio", isDirectory: true) + .appendingPathComponent("CompiledNemotronModels", isDirectory: true) + } + func modelsExistOnDisk() -> Bool { guard let dir = self.cacheDirectory else { return false } return Self.artifactsAreComplete(at: dir) @@ -174,7 +180,16 @@ final class NemotronProvider: TranscriptionProvider { "Nemotron: ANE model load failed; retrying with cpuAndGPU fallback [error=\(error.localizedDescription)]", source: "Nemotron" ) - manager = try await self.loadManager(modelDirectory: dir, computeUnits: .cpuAndGPU) + self.clearCompiledModelCache() + do { + manager = try await self.loadManager(modelDirectory: dir, computeUnits: .cpuAndGPU) + } catch { + let compiledCachePath = Self.compiledModelCacheDirectory?.path ?? "~/Library/Caches/FluidAudio/CompiledNemotronModels" + throw Self.makeError( + "Nemotron model failed to load even after clearing the compiled CoreML cache at \(compiledCachePath). If this continues, manually delete ~/Library/Caches/FluidAudio/CompiledNemotronModels and try again.", + underlyingError: error + ) + } } try await self.applySelectedLanguage(to: manager) try Task.checkCancellation() @@ -337,6 +352,26 @@ final class NemotronProvider: TranscriptionProvider { self.activeLanguageCode = nil } + private func clearCompiledModelCache() { + Self.clearCompiledModelCache(at: Self.compiledModelCacheDirectory) + } + + static func clearCompiledModelCache(at directory: URL?) { + guard let directory, FileManager.default.fileExists(atPath: directory.path) else { return } + DebugLogger.shared.warning( + "Nemotron: clearing compiled CoreML cache at \(directory.path)", + source: "Nemotron" + ) + do { + try FileManager.default.removeItem(at: directory) + } catch { + DebugLogger.shared.warning( + "Nemotron: failed to clear compiled CoreML cache at \(directory.path): \(error.localizedDescription)", + source: "Nemotron" + ) + } + } + private func transcribeBatched(_ samples: [Float]) async throws -> ASRTranscriptionResult { guard samples.isEmpty == false else { return ASRTranscriptionResult(text: "", confidence: 0) } @@ -597,7 +632,7 @@ final class NemotronProvider: TranscriptionProvider { return nil } - private static func shouldRetryWithoutNeuralEngine(_ error: Error) -> Bool { + static func shouldRetryWithoutNeuralEngine(_ error: Error) -> Bool { if self.hasNeuralEngineRetryCode(error as NSError) { return true } @@ -636,8 +671,12 @@ final class NemotronProvider: TranscriptionProvider { } } - private static func makeError(_ description: String) -> NSError { - NSError(domain: "NemotronProvider", code: -1, userInfo: [NSLocalizedDescriptionKey: description]) + private static func makeError(_ description: String, underlyingError: Error? = nil) -> NSError { + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: description] + if let underlyingError { + userInfo[NSUnderlyingErrorKey] = underlyingError + } + return NSError(domain: "NemotronProvider", code: -1, userInfo: userInfo) } } #else diff --git a/Tests/FluidDictationIntegrationTests/NemotronCacheRecoveryTests.swift b/Tests/FluidDictationIntegrationTests/NemotronCacheRecoveryTests.swift new file mode 100644 index 00000000..76bbe559 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/NemotronCacheRecoveryTests.swift @@ -0,0 +1,76 @@ +@testable import FluidVoice_Debug +import XCTest + +#if arch(arm64) +@MainActor +@available(macOS 14.0, *) +final class NemotronCacheRecoveryTests: XCTestCase { + + func testShouldRetryWithoutNeuralEngine_matchesNestedErrorCodeMinus14() { + let underlying = NSError(domain: "CoreML", code: -14) + let error = NSError( + domain: "FluidVoiceTests", + code: 1, + userInfo: [NSUnderlyingErrorKey: underlying] + ) + + XCTAssertTrue(NemotronProvider.shouldRetryWithoutNeuralEngine(error)) + } + + func testShouldRetryWithoutNeuralEngine_matchesCoreMLExecutionPlanDescription() { + let error = NSError( + domain: "CoreML", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Failed to build the model execution plan with error code: -14.", + ] + ) + + XCTAssertTrue(NemotronProvider.shouldRetryWithoutNeuralEngine(error)) + } + + func testShouldRetryWithoutNeuralEngine_ignoresUnrelatedErrors() { + let error = NSError( + domain: NSCocoaErrorDomain, + code: -999, + userInfo: [NSLocalizedDescriptionKey: "The operation was cancelled."] + ) + + XCTAssertFalse(NemotronProvider.shouldRetryWithoutNeuralEngine(error)) + } + + func testCompiledModelCacheDirectoryUsesFluidAudioCompiledNemotronCache() throws { + let cachesDirectory = try XCTUnwrap( + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ).standardizedFileURL + let compiledCacheDirectory = try XCTUnwrap(NemotronProvider.compiledModelCacheDirectory) + .standardizedFileURL + + XCTAssertTrue(compiledCacheDirectory.path.hasPrefix(cachesDirectory.path + "/")) + XCTAssertEqual( + Array(compiledCacheDirectory.pathComponents.suffix(2)), + ["FluidAudio", "CompiledNemotronModels"] + ) + } + + func testClearCompiledModelCacheRemovesDirectoryAndIgnoresMissingDirectory() throws { + let tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("NemotronCacheRecoveryTests-\(UUID().uuidString)", isDirectory: true) + let compiledCacheDirectory = tempRoot + .appendingPathComponent("FluidAudio", isDirectory: true) + .appendingPathComponent("CompiledNemotronModels", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: tempRoot) + } + + try FileManager.default.createDirectory(at: compiledCacheDirectory, withIntermediateDirectories: true) + try Data("cache".utf8).write(to: compiledCacheDirectory.appendingPathComponent("marker.txt")) + + NemotronProvider.clearCompiledModelCache(at: compiledCacheDirectory) + + XCTAssertFalse(FileManager.default.fileExists(atPath: compiledCacheDirectory.path)) + NemotronProvider.clearCompiledModelCache(at: compiledCacheDirectory) + XCTAssertFalse(FileManager.default.fileExists(atPath: compiledCacheDirectory.path)) + } +} +#endif