@@ -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