feat: sync force-quit Flow teardown and polish macOS local ASR UX

End Live Activities and release the audio session synchronously on
applicationWillTerminate, and continue macOS local-model install,
onboarding, and settings polish on this branch.
This commit is contained in:
Rocky
2026-07-09 17:13:07 +08:00
parent 200265fbd6
commit dcb66a9849
34 changed files with 1936 additions and 340 deletions
@@ -79,4 +79,13 @@ public struct LocalASRCapabilities: Sendable, Equatable {
supportsStreaming: false,
hotwordReloadCost: .none
)
/// FunASR Paraformer (Sherpa offline) no project hotword API.
public static let sherpaParaformer = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
maxPromptCharacters: 0,
supportsStreaming: false,
hotwordReloadCost: .none
)
}
@@ -9,19 +9,41 @@ public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
case mlx
case sherpaQwen3
case sherpaSenseVoice
case sherpaParaformer
case appleSpeech
}
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
case manual
case archive
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
case repository
case runtime
}
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
public let remotePath: String
public let localPath: String
public let sizeBytes: Int?
}
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
public let type: String
public let priority: Int
/// Full URL for a single archive download.
public let url: String
/// Base URL template for repository installs; must contain `{path}`.
public let baseURL: String?
public let files: [LocalASRDownloadFile]?
public var isRepository: Bool {
guard let files, !files.isEmpty else { return false }
return baseURL?.contains("{path}") == true
}
public var isArchive: Bool {
!url.isEmpty && !isRepository
}
}
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
@@ -30,6 +52,7 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable {
public var decoder: String?
public var tokenizer: String?
public var senseVoiceModel: String?
public var paraformerModel: String?
public var tokens: String?
}
@@ -91,6 +114,8 @@ public enum LocalASRModelCatalog {
return .sherpaQwen3
case .sherpaSenseVoice:
return .sherpaSenseVoice
case .sherpaParaformer:
return .sherpaParaformer
case .appleSpeech:
return .appleSpeech
}
@@ -117,6 +142,42 @@ public enum LocalASRModelCatalog {
#endif
}
/// Region-aware ordering for local ASR model download mirrors.
public enum LocalASRDownloadSourceSorter {
/// `true` when the system region is mainland China (`CN`).
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
region?.identifier == "CN"
}
/// Lower rank = tried earlier. CN: ModelScope HuggingFace GitHub; elsewhere: HF GitHub ModelScope.
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
switch type.lowercased() {
case "modelscope":
return chinaFirst ? 0 : 2
case "huggingface":
return chinaFirst ? 1 : 0
case "github":
return 1
default:
return 3
}
}
public static func sorted(
_ sources: [LocalASRDownloadSource],
region: Locale.Region? = Locale.current.region
) -> [LocalASRDownloadSource] {
let chinaFirst = isChinaMainland(region: region)
return sources.sorted { lhs, rhs in
let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst)
let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst)
if leftRank != rightRank { return leftRank < rightRank }
return lhs.priority < rhs.priority
}
}
}
public enum LocalASRModelCatalogError: Error, LocalizedError {
case missingBundledCatalog
case modelNotFound(String)