Skip to content

Commit 88a3e51

Browse files
authored
fix: align model picker with WhisperKit Apple Silicon support (#161)
* fix: surface model load failures * fix: align model catalog with WhisperKit support
1 parent 5d59ea6 commit 88a3e51

10 files changed

Lines changed: 608 additions & 146 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 171 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -267,29 +267,7 @@ final class AppState: ObservableObject {
267267
deviceRecommendedModel = recommendation.defaultModel
268268
}
269269

270-
// Initialize available models list
271-
availableModels = ModelSize.allCases.map { size in
272-
WhisperModelInfo(
273-
size: size,
274-
filePath: modelManager.modelFolder(for: size),
275-
isDownloaded: modelManager.isModelDownloaded(size),
276-
isActive: size.rawValue == selectedModelSize,
277-
isSupported: modelManager.isModelSupported(size)
278-
)
279-
}
280-
281-
// Enforce monotonic support ordering: if a smaller model is unsupported,
282-
// all larger models must also be unsupported. This prevents contradictory
283-
// UI states like Medium="Too Large" but Large v3="Recommended".
284-
var foundUnsupported = false
285-
for i in availableModels.indices {
286-
if !availableModels[i].isSupported {
287-
foundUnsupported = true
288-
}
289-
if foundUnsupported {
290-
availableModels[i].isSupported = false
291-
}
292-
}
270+
rebuildAvailableModels()
293271

294272
// Validate that the recommended model maps to a supported ModelSize.
295273
// If the recommendation points to an unsupported model, fall back to
@@ -394,6 +372,67 @@ final class AppState: ObservableObject {
394372
checkPermissions()
395373
}
396374

375+
/// Build the model list shown in Settings and onboarding.
376+
///
377+
/// The base catalog is curated for M-series Macs, then extended with any
378+
/// exact variants WhisperKit marks supported for the current device.
379+
private func modelCatalog() -> [ModelSize] {
380+
var catalog = ModelSize.standardCatalog
381+
382+
for size in ModelSize.allCases where modelManager.isModelSupported(size) {
383+
if !catalog.contains(size) {
384+
catalog.append(size)
385+
}
386+
}
387+
388+
if let selected = ModelSize(rawValue: selectedModelSize),
389+
!catalog.contains(selected) {
390+
catalog.append(selected)
391+
}
392+
393+
return catalog
394+
}
395+
396+
/// Recreate model UI state from the latest catalog and local cache status.
397+
private func rebuildAvailableModels() {
398+
availableModels = modelCatalog().map { size in
399+
WhisperModelInfo(
400+
size: size,
401+
filePath: modelManager.modelFolder(for: size),
402+
isDownloaded: modelManager.isModelDownloaded(size),
403+
isActive: size.rawValue == selectedModelSize,
404+
isSupported: modelManager.isModelSupported(size)
405+
)
406+
}
407+
}
408+
409+
/// Resolve WhisperKit's recommended exact model variant into app metadata.
410+
private func recommendedModelSize() -> ModelSize? {
411+
guard let recommended = deviceRecommendedModel,
412+
let size = modelManager.modelSize(from: recommended),
413+
modelManager.isModelSupported(size) else {
414+
return nil
415+
}
416+
return size
417+
}
418+
419+
/// Pick a supported startup model when the stored preference is no longer valid.
420+
private func startupFallbackModel(for preferred: ModelSize) -> ModelSize {
421+
guard !modelManager.isModelSupported(preferred) else {
422+
return preferred
423+
}
424+
425+
if let downloadedSupported = availableModels.last(where: { $0.isSupported && $0.isDownloaded })?.size {
426+
return downloadedSupported
427+
}
428+
429+
if let recommended = recommendedModelSize() {
430+
return recommended
431+
}
432+
433+
return .tiny
434+
}
435+
397436
// MARK: - Permission Handling (delegated to PermissionManager)
398437

399438
func checkPermissions() { permissionManager.checkPermissions() }
@@ -577,6 +616,12 @@ final class AppState: ObservableObject {
577616
// MARK: - Model Management
578617

579618
func loadModel(_ size: ModelSize? = nil) async {
619+
let previousLoadedModelName = whisperService.loadedModelName
620+
let previousModelSize = currentModel?.size
621+
?? previousLoadedModelName.flatMap { modelManager.modelSize(from: $0) }
622+
?? ModelSize(rawValue: selectedModelSize)
623+
let hadLoadedModel = whisperService.isModelLoaded
624+
580625
let modelName: String?
581626
if let size = size {
582627
modelName = modelManager.whisperKitModelName(for: size)
@@ -629,8 +674,19 @@ final class AppState: ObservableObject {
629674
resolvedSize = targetSize
630675
} else {
631676
let loadedName = (whisperService.loadedModelName ?? "").lowercased()
632-
// Check from largest to smallest to avoid "base" matching inside "large-v3"
633-
if loadedName.contains("large") {
677+
if let loadedSize = modelManager.modelSize(from: whisperService.loadedModelName ?? "") {
678+
resolvedSize = loadedSize
679+
} else if loadedName.contains("v20240930_turbo") {
680+
resolvedSize = .largeV3LatestTurbo
681+
} else if loadedName.contains("v20240930") {
682+
resolvedSize = .largeV3Latest
683+
} else if loadedName.contains("distil") && loadedName.contains("turbo") {
684+
resolvedSize = .distilLargeV3TurboCompact
685+
} else if loadedName.contains("distil") {
686+
resolvedSize = .distilLargeV3Compact
687+
} else if loadedName.contains("large") && loadedName.contains("turbo") {
688+
resolvedSize = .largeV3Turbo
689+
} else if loadedName.contains("large") {
634690
resolvedSize = .largeV3
635691
} else if loadedName.contains("medium") {
636692
resolvedSize = .medium
@@ -667,8 +723,89 @@ final class AppState: ObservableObject {
667723
availableModels[i].isLoading = false
668724
availableModels[i].loadingStatus = "Loading…"
669725
}
670-
errorMessage = "Failed to load model: \(error.localizedDescription)"
671-
VocaLogger.error(.appState, "Failed to load model: \(error.localizedDescription)")
726+
727+
let modelDisplayName = targetSize?.displayName ?? "model"
728+
let failureMessage = "Failed to load \(modelDisplayName): \(error.localizedDescription)"
729+
showTemporaryError(failureMessage)
730+
VocaLogger.error(.appState, failureMessage)
731+
732+
await restorePreviousModelIfNeeded(
733+
afterFailedLoadFor: targetSize,
734+
previousSize: previousModelSize,
735+
previousName: previousLoadedModelName,
736+
hadLoadedModel: hadLoadedModel,
737+
originalFailureMessage: failureMessage
738+
)
739+
}
740+
}
741+
742+
/// Surface a short-lived error state for settings and menu UI.
743+
private func showTemporaryError(_ message: String) {
744+
errorMessage = message
745+
appStatus = .error
746+
747+
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
748+
if self?.appStatus == .error, self?.errorMessage == message {
749+
self?.appStatus = .idle
750+
self?.errorMessage = nil
751+
}
752+
}
753+
}
754+
755+
/// Restore the model that was active before a failed switch.
756+
private func restorePreviousModelIfNeeded(
757+
afterFailedLoadFor failedSize: ModelSize?,
758+
previousSize: ModelSize?,
759+
previousName: String?,
760+
hadLoadedModel: Bool,
761+
originalFailureMessage: String
762+
) async {
763+
guard hadLoadedModel,
764+
let previousSize,
765+
failedSize != previousSize else {
766+
clearActiveModelState()
767+
return
768+
}
769+
770+
do {
771+
VocaLogger.info(.appState, "Restoring previous model: \(previousSize.displayName)")
772+
let folderURL = modelManager.isModelDownloaded(previousSize)
773+
? modelManager.modelFolder(for: previousSize)
774+
: nil
775+
let restoreName = previousName ?? modelManager.whisperKitModelName(for: previousSize)
776+
try await whisperService.loadModel(name: restoreName, folder: folderURL)
777+
markModelActive(previousSize)
778+
VocaLogger.info(.appState, "Restored previous model: \(previousSize.displayName)")
779+
} catch {
780+
clearActiveModelState()
781+
let restoreFailure = "Previous model could not be restored: \(error.localizedDescription)"
782+
errorMessage = "\(originalFailureMessage) \(restoreFailure)"
783+
VocaLogger.error(.appState, restoreFailure)
784+
}
785+
}
786+
787+
/// Synchronize AppState's model metadata after a successful load.
788+
private func markModelActive(_ size: ModelSize) {
789+
currentModel = nil
790+
for i in availableModels.indices {
791+
let matches = availableModels[i].size == size
792+
availableModels[i].isActive = matches
793+
availableModels[i].isLoading = false
794+
availableModels[i].loadingStatus = "Loading…"
795+
if matches {
796+
availableModels[i].isDownloaded = modelManager.isModelDownloaded(size)
797+
currentModel = availableModels[i]
798+
}
799+
}
800+
}
801+
802+
/// Clear active model metadata when no model is loaded in WhisperService.
803+
private func clearActiveModelState() {
804+
currentModel = nil
805+
for i in availableModels.indices {
806+
availableModels[i].isActive = false
807+
availableModels[i].isLoading = false
808+
availableModels[i].loadingStatus = "Loading…"
672809
}
673810
}
674811

@@ -754,7 +891,13 @@ final class AppState: ObservableObject {
754891
// downloaded yet. We download it explicitly so the UI can show real
755892
// progress, rather than delegating to WhisperKit's opaque auto-select
756893
// which provides no progress callbacks and may pick a different model.
757-
var modelToLoad = ModelSize(rawValue: selectedModelSize) ?? .tiny
894+
let preferredModel = ModelSize(rawValue: selectedModelSize) ?? .tiny
895+
var modelToLoad = startupFallbackModel(for: preferredModel)
896+
if modelToLoad != preferredModel {
897+
VocaLogger.warning(.appState, "Preferred model \(preferredModel.displayName) is not supported on this device — falling back to \(modelToLoad.displayName)")
898+
selectedModelSize = modelToLoad.rawValue
899+
rebuildAvailableModels()
900+
}
758901

759902
if !modelManager.isModelDownloaded(modelToLoad) {
760903
// Try bundled model for the preferred size first

0 commit comments

Comments
 (0)