8bd1a6caf8
SwiftUI app, the one-click way to use the local transcription pipeline — installs and runs with no separate manual steps. The Xcode build phase (scripts/embed-binaries.sh) builds audiotee (git submodule, pinned to a commit on our own Gitea fork) and transcriptor-ai + qwen_asr (sibling checkout, which itself vendors qwen-asr as a submodule), then embeds all three binaries in the app bundle. The AI model is the one thing still fetched at runtime, via ModelManager.swift, since it's multi-GB and doesn't belong baked into a build. Also implements: model download with real progress UI, capture source selection (system/mic/both), a Settings window for the transcript save location and a glossary file (biases ASR toward proper nouns/product names via qwen_asr's --prompt), and a live chat-bubble transcript view. See CLAUDE.md for the full architecture, the reasoning behind each decision, and a fairly long list of real bugs found via actual testing (not just code review) — TCC permission escalation through a subprocess tree, SwiftUI Form/Grid layout quirks, @State vs @AppStorage persistence, a prompt-leakage bug in the glossary feature, and more. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
177 lines
6.7 KiB
Swift
177 lines
6.7 KiB
Swift
//
|
|
// ModelManager.swift
|
|
// transcriptor
|
|
//
|
|
// Downloads and locates qwen-asr model files — the one thing "one-click
|
|
// install" deliberately leaves at runtime (see CLAUDE.md, "Goal": models
|
|
// are multi-GB and don't belong baked into the app bundle, same reasoning
|
|
// Ollama/LM Studio don't ship them inside their own app either).
|
|
//
|
|
// File list and HuggingFace URL pattern mirror
|
|
// transcriptor-ai/third_party/qwen-asr/download_model.sh exactly — keep in
|
|
// sync by hand if that script's file list ever changes.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum ModelChoice: String, CaseIterable, Identifiable {
|
|
case small
|
|
case large
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .small: "Qwen3-ASR 0.6B (faster, lower quality)"
|
|
case .large: "Qwen3-ASR 1.7B (recommended)"
|
|
}
|
|
}
|
|
|
|
fileprivate var modelID: String {
|
|
switch self {
|
|
case .small: "Qwen/Qwen3-ASR-0.6B"
|
|
case .large: "Qwen/Qwen3-ASR-1.7B"
|
|
}
|
|
}
|
|
|
|
var directoryName: String {
|
|
switch self {
|
|
case .small: "qwen3-asr-0.6b"
|
|
case .large: "qwen3-asr-1.7b"
|
|
}
|
|
}
|
|
|
|
fileprivate var files: [String] {
|
|
switch self {
|
|
case .small:
|
|
["config.json", "generation_config.json", "model.safetensors", "vocab.json", "merges.txt"]
|
|
case .large:
|
|
[
|
|
"config.json", "generation_config.json", "model.safetensors.index.json",
|
|
"model-00001-of-00002.safetensors", "model-00002-of-00002.safetensors",
|
|
"vocab.json", "merges.txt",
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ModelManagerError: Error {
|
|
case invalidDownloadedFile
|
|
}
|
|
|
|
@Observable
|
|
final class ModelManager: NSObject {
|
|
private(set) var isDownloading = false
|
|
private(set) var statusText = ""
|
|
private(set) var fileProgress: Double = 0 // 0...1, current file only
|
|
private(set) var currentFileIndex = 0
|
|
private(set) var totalFiles = 0
|
|
|
|
private struct DownloadContext {
|
|
let destination: URL
|
|
let completion: (Result<Void, Error>) -> Void
|
|
}
|
|
private var contexts: [Int: DownloadContext] = [:]
|
|
// delegateQueue: .main so delegate callbacks land on the main actor
|
|
// directly — this class's @Observable properties are read by SwiftUI,
|
|
// which needs that.
|
|
// @Observable's macro doesn't support `lazy var` (init-accessor
|
|
// synthesis conflict) — this is an internal implementation detail
|
|
// anyway, not UI-relevant state, so excluding it from observation
|
|
// tracking is the right fix, not a workaround.
|
|
@ObservationIgnored
|
|
private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
|
|
|
|
static var modelsRootDirectory: URL {
|
|
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
return appSupport.appendingPathComponent("eu.sttlab.transcriptor/models", isDirectory: true)
|
|
}
|
|
|
|
func localDirectory(for choice: ModelChoice) -> URL {
|
|
Self.modelsRootDirectory.appendingPathComponent(choice.directoryName, isDirectory: true)
|
|
}
|
|
|
|
func isDownloaded(_ choice: ModelChoice) -> Bool {
|
|
let dir = localDirectory(for: choice)
|
|
return choice.files.allSatisfy {
|
|
FileManager.default.fileExists(atPath: dir.appendingPathComponent($0).path)
|
|
}
|
|
}
|
|
|
|
/// Downloads whatever files are missing (already-present files are left
|
|
/// alone, so an interrupted download resumes rather than restarting)
|
|
/// and returns the model's local directory.
|
|
func ensureModel(_ choice: ModelChoice) async throws -> URL {
|
|
let dir = localDirectory(for: choice)
|
|
if isDownloaded(choice) { return dir }
|
|
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
isDownloading = true
|
|
defer { isDownloading = false }
|
|
|
|
totalFiles = choice.files.count
|
|
for (index, file) in choice.files.enumerated() {
|
|
currentFileIndex = index + 1
|
|
let destination = dir.appendingPathComponent(file)
|
|
if FileManager.default.fileExists(atPath: destination.path) {
|
|
continue
|
|
}
|
|
statusText = "Downloading \(file) (\(currentFileIndex)/\(totalFiles))..."
|
|
fileProgress = 0
|
|
let url = URL(string: "https://huggingface.co/\(choice.modelID)/resolve/main/\(file)")!
|
|
try await downloadFile(from: url, to: destination)
|
|
}
|
|
statusText = "Model ready."
|
|
return dir
|
|
}
|
|
|
|
private func downloadFile(from url: URL, to destination: URL) async throws {
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
let task = session.downloadTask(with: url)
|
|
contexts[task.taskIdentifier] = DownloadContext(destination: destination) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
task.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ModelManager: URLSessionDownloadDelegate {
|
|
func urlSession(
|
|
_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
|
didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64
|
|
) {
|
|
guard totalBytesExpectedToWrite > 0 else { return }
|
|
fileProgress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
|
|
) {
|
|
// The file at `location` only exists for the duration of this
|
|
// callback — the move must happen synchronously here, not be
|
|
// deferred to later (e.g. inside the completion closure), or the
|
|
// system may have already deleted it.
|
|
guard let context = contexts.removeValue(forKey: downloadTask.taskIdentifier) else { return }
|
|
do {
|
|
if FileManager.default.fileExists(atPath: context.destination.path) {
|
|
try FileManager.default.removeItem(at: context.destination)
|
|
}
|
|
try FileManager.default.moveItem(at: location, to: context.destination)
|
|
context.completion(.success(()))
|
|
} catch {
|
|
context.completion(.failure(error))
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
// A successful download is already resolved in
|
|
// didFinishDownloadingTo (which removes the context) — this only
|
|
// has work to do for a task that failed before reaching that point
|
|
// (network error, etc).
|
|
guard let error, let context = contexts.removeValue(forKey: task.taskIdentifier) else { return }
|
|
context.completion(.failure(error))
|
|
}
|
|
}
|