Files
transcriptor/transcriptor/ModelManager.swift
T
sttlab-tech 8bd1a6caf8 initial commit: native macOS app for transcriptor-ai
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>
2026-08-09 13:26:29 +02:00

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))
}
}