Skip to content
Open
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
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -36,6 +37,7 @@
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
7CAA000B2F99000000000002 /* NemotronCacheRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NemotronCacheRecoveryTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -107,6 +109,7 @@
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
7CAA000B2F99000000000002 /* NemotronCacheRecoveryTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
47 changes: 43 additions & 4 deletions Sources/Fluid/Services/NemotronProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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