Skip to content

Commit 0db8367

Browse files
committed
chore: async model switching and cleanup
1 parent 18ea957 commit 0db8367

File tree

5 files changed

+62
-42
lines changed

5 files changed

+62
-42
lines changed

FreeWispr/Sources/FreeWispr/AppState.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class AppState: ObservableObject {
2020

2121
private var isSetUp = false
2222
private var textCorrector: Any?
23+
private var pendingModelSelection: ModelSize?
2324

2425
let hotkeyManager = HotkeyManager()
2526
let audioRecorder = AudioRecorder()
@@ -51,7 +52,7 @@ class AppState: ObservableObject {
5152
// Load model
5253
do {
5354
let modelPath = modelManager.localModelPath(for: selectedModel)
54-
try transcriber.loadModel(at: modelPath)
55+
try await transcriber.loadModel(at: modelPath)
5556
statusMessage = "Ready"
5657
} catch {
5758
statusMessage = "Failed to load model: \(error.localizedDescription)"
@@ -153,30 +154,46 @@ class AppState: ObservableObject {
153154
isTranscribing = false
154155
}
155156

156-
func switchModel(to model: ModelSize) async {
157-
guard !isSwitchingModel else { return }
157+
func switchModel(to targetModel: ModelSize) async {
158+
if isSwitchingModel {
159+
pendingModelSelection = targetModel
160+
return
161+
}
162+
158163
isSwitchingModel = true
159-
defer { isSwitchingModel = false }
164+
let previousModel = selectedModel
165+
if previousModel != targetModel {
166+
selectedModel = targetModel
167+
}
168+
defer {
169+
isSwitchingModel = false
170+
if let pending = pendingModelSelection {
171+
pendingModelSelection = nil
172+
if pending != selectedModel {
173+
Task { await self.switchModel(to: pending) }
174+
}
175+
}
176+
}
160177

161178
transcriber.unloadModel()
162179

163-
if !modelManager.isModelDownloaded(model) || !modelManager.isCoreMLDownloaded(model) {
164-
statusMessage = "Downloading \(model.displayName)..."
180+
if !modelManager.isModelDownloaded(targetModel) || !modelManager.isCoreMLDownloaded(targetModel) {
181+
statusMessage = "Downloading \(targetModel.displayName)..."
165182
do {
166-
try await modelManager.downloadModel(model)
183+
try await modelManager.downloadModel(targetModel)
167184
} catch {
168185
statusMessage = "Download failed: \(error.localizedDescription)"
169-
// Revert UI selection to the model that is still loaded (none now — stay on previous)
186+
selectedModel = previousModel
170187
return
171188
}
172189
}
173190

174191
do {
175-
try transcriber.loadModel(at: modelManager.localModelPath(for: model))
176-
selectedModel = model // Commit only after successful load
192+
try await transcriber.loadModel(at: modelManager.localModelPath(for: targetModel))
177193
statusMessage = "Ready"
178194
} catch {
179195
statusMessage = "Failed to load model: \(error.localizedDescription)"
196+
selectedModel = previousModel
180197
}
181198
}
182199

FreeWispr/Sources/FreeWispr/FreeWisprApp.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ struct MenuBarView: View {
9898
Picker("", selection: Binding(
9999
get: { appState.selectedModel },
100100
set: { newValue in
101-
guard hasAppeared, !appState.isSwitchingModel else { return }
101+
guard hasAppeared else { return }
102102
Task { await appState.switchModel(to: newValue) }
103103
}
104104
)) {
@@ -107,12 +107,6 @@ struct MenuBarView: View {
107107
}
108108
}
109109
.frame(width: 160)
110-
.disabled(appState.isSwitchingModel)
111-
}
112-
113-
if appState.modelManager.isDownloading {
114-
ProgressView(value: appState.modelManager.downloadProgress)
115-
.progressViewStyle(.linear)
116110
}
117111

118112
// AI Cleanup (macOS 26+ / Apple Intelligence)

FreeWispr/Sources/FreeWispr/ModelManager.swift

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,23 +101,28 @@ class ModelManager: ObservableObject {
101101
}
102102
let (tempZip, _) = try await URLSession.shared.download(from: coreMLDownloadURL(for: model), delegate: delegate)
103103

104-
// Unzip the Core ML model into the models directory
105104
let unzipDir = modelsDirectory
106-
let process = Process()
107-
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
108-
process.arguments = ["-o", tempZip.path, "-d", unzipDir.path]
109-
try process.run()
110-
process.waitUntilExit()
111-
try? FileManager.default.removeItem(at: tempZip)
112-
113-
if process.terminationStatus != 0 {
114-
try? FileManager.default.removeItem(at: localCoreMLPath(for: model))
115-
throw ModelDownloadError.unzipFailed(process.terminationStatus)
116-
}
105+
let coreMLPath = localCoreMLPath(for: model)
106+
let tempZipPath = tempZip.path
117107

118-
guard FileManager.default.fileExists(atPath: localCoreMLPath(for: model).path) else {
119-
throw ModelDownloadError.outputMissing(localCoreMLPath(for: model).path)
120-
}
108+
try await Task.detached(priority: .utility) {
109+
let process = Process()
110+
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
111+
process.arguments = ["-o", tempZipPath, "-d", unzipDir.path]
112+
try process.run()
113+
process.waitUntilExit()
114+
115+
try? FileManager.default.removeItem(at: URL(fileURLWithPath: tempZipPath))
116+
117+
if process.terminationStatus != 0 {
118+
try? FileManager.default.removeItem(at: coreMLPath)
119+
throw ModelDownloadError.unzipFailed(process.terminationStatus)
120+
}
121+
122+
guard FileManager.default.fileExists(atPath: coreMLPath.path) else {
123+
throw ModelDownloadError.outputMissing(coreMLPath.path)
124+
}
125+
}.value
121126
}
122127

123128
downloadProgress = 1.0

FreeWispr/Sources/FreeWispr/WhisperTranscriber.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,19 @@ class WhisperTranscriber: ObservableObject {
1616
private var transcriptionCount = 0
1717
private let refreshInterval = 50
1818

19-
func loadModel(at path: URL) throws {
20-
let params = WhisperParams(strategy: .greedy)
21-
params.language = .english // Skip auto-detection
22-
params.print_progress = false // Silence progress spam
23-
params.print_realtime = false
24-
params.print_timestamps = false
25-
params.single_segment = true // Treat as one segment — faster
19+
func loadModel(at path: URL) async throws {
20+
let newContext = await Task.detached(priority: .userInitiated) { () -> Whisper in
21+
let params = WhisperParams(strategy: .greedy)
22+
params.language = .english // Skip auto-detection
23+
params.print_progress = false // Silence progress spam
24+
params.print_realtime = false
25+
params.print_timestamps = false
26+
params.single_segment = true // Treat as one segment — faster
2627

27-
whisper = Whisper(fromFileURL: path, withParams: params)
28+
return Whisper(fromFileURL: path, withParams: params)
29+
}.value
30+
31+
whisper = newContext
2832
modelPath = path
2933
isModelLoaded = true
3034
transcriptionCount = 0
@@ -55,7 +59,7 @@ class WhisperTranscriber: ObservableObject {
5559
transcriptionCount += 1
5660
if transcriptionCount >= refreshInterval, let path = modelPath {
5761
do {
58-
try loadModel(at: path)
62+
try await loadModel(at: path)
5963
} catch {
6064
// Reload failed — continue with existing context.
6165
}

FreeWispr/Tests/BenchmarkTests/PipelineBenchmark.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ final class PipelineBenchmark: XCTestCase {
107107
print()
108108

109109
let transcriber = WhisperTranscriber()
110-
try transcriber.loadModel(at: modelPath)
110+
try await transcriber.loadModel(at: modelPath)
111111

112112
var correctorAvailable = false
113113
#if canImport(FoundationModels)

0 commit comments

Comments
 (0)