Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ final class AppState: ObservableObject {
modelName = nil // Let WhisperKit auto-select
}

// Mark the model as loading
if let size = size, let idx = availableModels.firstIndex(where: { $0.size == size }) {
availableModels[idx].isLoading = true
}

do {
// If model is downloaded locally, use the local folder
let folder = size.flatMap { modelManager.modelFolder(for: $0) }
Expand All @@ -304,11 +309,16 @@ final class AppState: ObservableObject {
? availableModels[i].size == size
: loadedName.contains(availableModels[i].size.rawValue)
availableModels[i].isActive = matches
availableModels[i].isLoading = false
if matches {
currentModel = availableModels[i]
}
}
} catch {
// Clear loading state on error
if let size = size, let idx = availableModels.firstIndex(where: { $0.size == size }) {
availableModels[idx].isLoading = false
}
errorMessage = "Failed to load model: \(error.localizedDescription)"
}
}
Expand All @@ -327,15 +337,25 @@ final class AppState: ObservableObject {
}
}

availableModels[index].isDownloaded = true
availableModels[index].downloadProgress = nil
availableModels[index].filePath = modelManager.modelFolder(for: size)
// Refresh all model statuses to ensure previously downloaded models are preserved
refreshModelStatuses()
} catch {
availableModels[index].downloadProgress = nil
errorMessage = "Download failed: \(error.localizedDescription)"
}
}

/// Refresh the download status of all models
/// This ensures that all previously downloaded models are detected and marked correctly
private func refreshModelStatuses() {
for i in availableModels.indices {
let size = availableModels[i].size
availableModels[i].isDownloaded = modelManager.isModelDownloaded(size)
availableModels[i].downloadProgress = nil
availableModels[i].filePath = modelManager.modelFolder(for: size)
}
}

// MARK: - Startup

func performStartup() async {
Expand Down
3 changes: 3 additions & 0 deletions Sources/VocaMac/Models/WhisperModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ struct WhisperModelInfo: Identifiable {
/// Download progress (0.0 to 1.0), nil when not downloading
var downloadProgress: Double?

/// Whether this model is currently being loaded into memory
var isLoading: Bool = false

var id: String { size.id }

/// Human-readable status description
Expand Down
16 changes: 14 additions & 2 deletions Sources/VocaMac/Services/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,23 @@ final class ModelManager {
config.prewarm = false
config.load = false // Don't load into memory, just download

// Report progress
// Report initial progress
onProgress(0.1)

let _ = try await WhisperKit(config)
// Simulate progress while downloading, since WhisperKit doesn't expose granular progress
let progressTask = Task {
var currentProgress = 0.1
while currentProgress < 0.95 {
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 second intervals
currentProgress += Double.random(in: 0.05...0.15)
onProgress(min(currentProgress, 0.95))
}
}

let _ = try await WhisperKit(config)

// Cancel progress simulation and report completion
progressTask.cancel()
onProgress(1.0)
print("[ModelManager] Model '\(whisperKitModelName(for: size))' downloaded successfully")
} catch {
Expand Down
57 changes: 55 additions & 2 deletions Sources/VocaMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ struct PermissionRow: View {

struct ModelSettingsTab: View {
@EnvironmentObject var appState: AppState
@StateObject private var processMonitor = ProcessMonitor()

var body: some View {
ScrollView {
Expand All @@ -237,6 +238,7 @@ struct ModelSettingsTab: View {
SystemInfoPill(icon: "memorychip", label: "RAM", value: "\(capabilities.physicalMemoryGB) GB")
SystemInfoPill(icon: "bolt.fill", label: "Metal", value: capabilities.supportsMetalAcceleration ? "Yes" : "No")
}
.frame(maxWidth: .infinity, alignment: .leading)

if let recommended = appState.deviceRecommendedModel {
HStack {
Expand All @@ -262,6 +264,44 @@ struct ModelSettingsTab: View {
}
}

// Resource usage
GroupBox {
VStack(alignment: .leading, spacing: 6) {
Label("Resource Usage", systemImage: "gauge.with.dots.needle.bottom.50percent")
.font(.headline)
.padding(.bottom, 4)

HStack(spacing: 24) {
SystemInfoPill(
icon: "cpu",
label: "CPU",
value: String(format: "%.1f%%", processMonitor.cpuUsage)
)
SystemInfoPill(
icon: "memorychip",
label: "Memory",
value: processMonitor.memoryMB >= 1024
? String(format: "%.1f GB", processMonitor.memoryMB / 1024)
: String(format: "%.0f MB", processMonitor.memoryMB)
)
SystemInfoPill(
icon: "chart.line.uptrend.xyaxis",
label: "Peak",
value: processMonitor.memoryPeakMB >= 1024
? String(format: "%.1f GB", processMonitor.memoryPeakMB / 1024)
: String(format: "%.0f MB", processMonitor.memoryPeakMB)
)
SystemInfoPill(
icon: "arrow.triangle.branch",
label: "Threads",
value: "\(processMonitor.threadCount)"
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(4)
}

// Currently active model
if let current = appState.currentModel {
GroupBox {
Expand Down Expand Up @@ -336,6 +376,7 @@ struct SystemInfoPill: View {
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
}
}

Expand Down Expand Up @@ -394,7 +435,7 @@ struct ModelRow: View {

Spacer()

// Download progress
// Download progress or loading indicator
if let progress = model.downloadProgress {
VStack(spacing: 2) {
ProgressView(value: progress)
Expand All @@ -404,6 +445,15 @@ struct ModelRow: View {
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if model.isLoading {
VStack(spacing: 2) {
ProgressView()
.frame(width: 60)
.controlSize(.small)
Text("Loading...")
.font(.caption2)
.foregroundStyle(.secondary)
}
}

// Action button
Expand All @@ -415,13 +465,16 @@ struct ModelRow: View {
Text("Too Large")
.font(.caption)
.foregroundStyle(.secondary)
} else if model.isLoading || model.downloadProgress != nil {
// Show nothing - progress indicator handles the feedback
EmptyView()
} else if model.isDownloaded {
Button("Load") {
Task { await appState.loadModel(model.size) }
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
} else if model.downloadProgress == nil {
} else {
Button("Download & Load") {
Task {
await appState.downloadModel(model.size)
Expand Down