Skip to content

Commit d9542e9

Browse files
committed
feat: implement downloadable models and enhance model management UI
1 parent c857552 commit d9542e9

3 files changed

Lines changed: 295 additions & 63 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Foundation
2+
3+
struct DownloadableModel: Identifiable {
4+
let id = UUID()
5+
let name: String
6+
var isDownloaded: Bool
7+
let url: URL
8+
let size: Int
9+
var speedRate: Int
10+
var accuracyRate: Int
11+
var downloadProgress: Double = 0.0
12+
13+
var sizeString: String {
14+
let formatter = ByteCountFormatter()
15+
formatter.allowedUnits = [.useMB, .useGB]
16+
formatter.countStyle = .file
17+
formatter.includesUnit = true
18+
formatter.isAdaptive = true
19+
return formatter.string(fromByteCount: Int64(size) * 1000000)
20+
}
21+
22+
init(name: String, isDownloaded: Bool, url: URL, size: Int, speedRate: Int, accuracyRate: Int) {
23+
self.name = name
24+
self.isDownloaded = isDownloaded
25+
self.url = url
26+
self.size = size
27+
self.speedRate = speedRate
28+
self.accuracyRate = accuracyRate
29+
}
30+
}
31+
32+
let availableModels = [
33+
DownloadableModel(
34+
name: "Turbo V3 large",
35+
isDownloaded: false,
36+
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin?download=true")!,
37+
size: 1624,
38+
speedRate: 60,
39+
accuracyRate: 100
40+
),
41+
DownloadableModel(
42+
name: "Turbo V3 medium",
43+
isDownloaded: false,
44+
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin?download=true")!,
45+
size: 874,
46+
speedRate: 70,
47+
accuracyRate: 70
48+
),
49+
DownloadableModel(
50+
name: "Turbo V3 small",
51+
isDownloaded: false,
52+
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin?download=true")!,
53+
size: 574,
54+
speedRate: 100,
55+
accuracyRate: 60
56+
)
57+
]

FreeWhisper/Onboarding/OnboardingView.swift

Lines changed: 4 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import Foundation
99
import SwiftUI
1010

11+
// Import WhisperModels
12+
import Foundation
13+
1114
class OnboardingViewModel: ObservableObject {
1215
@Published var selectedLanguage: String {
1316
didSet {
@@ -455,7 +458,7 @@ struct PermissionsStepView: View {
455458

456459
// Microphone Permission
457460
PermissionRowView(
458-
icon: Aline Wright"mic.fill",
461+
icon: "mic.fill",
459462
title: "Microphone",
460463
description: "For recording audio to transcribe",
461464
isGranted: permissionsManager.isMicrophonePermissionGranted,
@@ -997,64 +1000,6 @@ struct OnboardingSecondaryButtonStyle: ButtonStyle {
9971000
}
9981001
}
9991002

1000-
// MARK: - Existing Models (keeping the same data structure)
1001-
1002-
struct DownloadableModel: Identifiable {
1003-
let id = UUID()
1004-
let name: String
1005-
var isDownloaded: Bool
1006-
let url: URL
1007-
let size: Int
1008-
var speedRate: Int
1009-
var accuracyRate: Int
1010-
var downloadProgress: Double = 0.0
1011-
1012-
var sizeString: String {
1013-
let formatter = ByteCountFormatter()
1014-
formatter.allowedUnits = [.useMB, .useGB]
1015-
formatter.countStyle = .file
1016-
formatter.includesUnit = true
1017-
formatter.isAdaptive = true
1018-
return formatter.string(fromByteCount: Int64(size) * 1000000)
1019-
}
1020-
1021-
init(name: String, isDownloaded: Bool, url: URL, size: Int, speedRate: Int, accuracyRate: Int) {
1022-
self.name = name
1023-
self.isDownloaded = isDownloaded
1024-
self.url = url
1025-
self.size = size
1026-
self.speedRate = speedRate
1027-
self.accuracyRate = accuracyRate
1028-
}
1029-
}
1030-
1031-
let availableModels = [
1032-
DownloadableModel(
1033-
name: "Turbo V3 large",
1034-
isDownloaded: false,
1035-
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin?download=true")!,
1036-
size: 1624,
1037-
speedRate: 60,
1038-
accuracyRate: 100
1039-
),
1040-
DownloadableModel(
1041-
name: "Turbo V3 medium",
1042-
isDownloaded: false,
1043-
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin?download=true")!,
1044-
size: 874,
1045-
speedRate: 70,
1046-
accuracyRate: 70
1047-
),
1048-
DownloadableModel(
1049-
name: "Turbo V3 small",
1050-
isDownloaded: false,
1051-
url: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin?download=true")!,
1052-
size: 574,
1053-
speedRate: 100,
1054-
accuracyRate: 60
1055-
)
1056-
]
1057-
10581003
#Preview {
10591004
OnboardingView()
10601005
}

FreeWhisper/Settings/ModelContent.swift

Lines changed: 234 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22

33
struct ModelContent: View {
44
@ObservedObject var viewModel: SettingsViewModel
5+
@State private var showModelDownloadSheet = false
56

67
var body: some View {
78
VStack(spacing: 20) {
@@ -46,13 +47,242 @@ struct ModelContent: View {
4647
icon: "arrow.down.circle.fill"
4748
) {
4849
VStack(alignment: .leading, spacing: 12) {
49-
Text("Add GGML format models to the folder above, then restart the app.")
50+
Text("Choose from pre-configured models or add your own GGML format models.")
5051
.font(.system(size: 13))
5152
.foregroundColor(.secondary)
5253

53-
Link("Browse Available Models", destination: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/tree/main")!)
54-
.font(.system(size: 13, weight: .medium))
55-
.foregroundColor(.accentColor)
54+
HStack {
55+
Button("Download Models") {
56+
showModelDownloadSheet = true
57+
}
58+
.buttonStyle(GlassButtonStyle())
59+
60+
Link("Browse HuggingFace", destination: URL(string: "https://huggingface.co/ggerganov/whisper.cpp/tree/main")!)
61+
.font(.system(size: 13, weight: .medium))
62+
.foregroundColor(.accentColor)
63+
}
64+
}
65+
}
66+
}
67+
.sheet(isPresented: $showModelDownloadSheet) {
68+
ModelDownloadView(onDismiss: {
69+
showModelDownloadSheet = false
70+
viewModel.loadAvailableModels() // Refresh models after download
71+
})
72+
.frame(width: 600, height: 500)
73+
}
74+
}
75+
}
76+
77+
struct ModelDownloadView: View {
78+
let onDismiss: () -> Void
79+
@StateObject private var viewModel = ModelDownloadViewModel()
80+
81+
var body: some View {
82+
VStack(spacing: 20) {
83+
// Header
84+
HStack {
85+
VStack(alignment: .leading, spacing: 4) {
86+
Text("Download Whisper Models")
87+
.font(.title2)
88+
.fontWeight(.bold)
89+
90+
Text("Select a model to download")
91+
.font(.subheadline)
92+
.foregroundColor(.secondary)
93+
}
94+
95+
Spacer()
96+
97+
Button("Done") {
98+
onDismiss()
99+
}
100+
.buttonStyle(GlassButtonStyle())
101+
}
102+
.padding(.horizontal)
103+
.padding(.top)
104+
105+
// Model List
106+
ScrollView {
107+
VStack(spacing: 12) {
108+
ForEach($viewModel.models) { $model in
109+
ModelDownloadCard(model: $model, viewModel: viewModel)
110+
}
111+
}
112+
.padding()
113+
}
114+
}
115+
.background(Color(.windowBackgroundColor))
116+
}
117+
}
118+
119+
struct ModelDownloadCard: View {
120+
@Binding var model: DownloadableModel
121+
@ObservedObject var viewModel: ModelDownloadViewModel
122+
@State private var isHovered = false
123+
124+
var body: some View {
125+
VStack(spacing: 12) {
126+
HStack(spacing: 16) {
127+
// Model info
128+
VStack(alignment: .leading, spacing: 4) {
129+
HStack(spacing: 8) {
130+
Text(model.name)
131+
.font(.system(size: 16, weight: .semibold))
132+
.foregroundColor(.primary)
133+
134+
if model.name == "Turbo V3 large" {
135+
Text("RECOMMENDED")
136+
.font(.system(size: 9, weight: .bold))
137+
.foregroundColor(.black)
138+
.padding(.horizontal, 6)
139+
.padding(.vertical, 2)
140+
.background(
141+
Capsule()
142+
.fill(Color.mint)
143+
)
144+
}
145+
146+
Spacer()
147+
}
148+
149+
Text(model.sizeString)
150+
.font(.system(size: 13))
151+
.foregroundColor(.secondary)
152+
}
153+
154+
// Status indicator
155+
Group {
156+
if model.isDownloaded {
157+
Image(systemName: "checkmark.circle.fill")
158+
.font(.system(size: 20))
159+
.foregroundColor(.mint)
160+
} else if model.downloadProgress > 0 && model.downloadProgress < 1 {
161+
ZStack {
162+
Circle()
163+
.stroke(Color.gray.opacity(0.3), lineWidth: 2)
164+
.frame(width: 20, height: 20)
165+
166+
Circle()
167+
.trim(from: 0, to: model.downloadProgress)
168+
.stroke(Color.mint, lineWidth: 2)
169+
.frame(width: 20, height: 20)
170+
.rotationEffect(.degrees(-90))
171+
}
172+
} else {
173+
Button(action: {
174+
viewModel.downloadModel(model)
175+
}) {
176+
Image(systemName: "arrow.down.circle")
177+
.font(.system(size: 20))
178+
.foregroundColor(.accentColor)
179+
}
180+
.buttonStyle(.plain)
181+
.disabled(viewModel.isDownloadingAny)
182+
}
183+
}
184+
}
185+
186+
// Performance bars
187+
HStack(spacing: 24) {
188+
PerformanceBar(title: "Accuracy", value: model.accuracyRate, color: .mint)
189+
PerformanceBar(title: "Speed", value: model.speedRate, color: .orange)
190+
}
191+
}
192+
.padding(16)
193+
.background(
194+
RoundedRectangle(cornerRadius: 12)
195+
.fill(Color(.controlBackgroundColor).opacity(0.6))
196+
.overlay(
197+
RoundedRectangle(cornerRadius: 12)
198+
.stroke(Color.gray.opacity(0.15), lineWidth: 1)
199+
)
200+
)
201+
.scaleEffect(isHovered ? 1.02 : 1.0)
202+
.animation(.easeInOut(duration: 0.2), value: isHovered)
203+
.onHover { hovering in
204+
isHovered = hovering
205+
}
206+
}
207+
}
208+
209+
struct PerformanceBar: View {
210+
let title: String
211+
let value: Int
212+
let color: Color
213+
214+
var body: some View {
215+
VStack(spacing: 6) {
216+
Text(title)
217+
.font(.system(size: 10, weight: .medium))
218+
.foregroundColor(.secondary)
219+
220+
ZStack(alignment: .leading) {
221+
RoundedRectangle(cornerRadius: 2)
222+
.fill(Color.gray.opacity(0.2))
223+
.frame(width: 50, height: 3)
224+
225+
RoundedRectangle(cornerRadius: 2)
226+
.fill(color)
227+
.frame(width: 50 * Double(value) / 100, height: 3)
228+
}
229+
230+
Text("\(value)%")
231+
.font(.system(size: 9, weight: .medium, design: .monospaced))
232+
.foregroundColor(.secondary)
233+
}
234+
}
235+
}
236+
237+
class ModelDownloadViewModel: ObservableObject {
238+
@Published var models: [DownloadableModel] = []
239+
@Published var isDownloadingAny: Bool = false
240+
241+
private let modelManager = WhisperModelManager.shared
242+
243+
init() {
244+
initializeModels()
245+
}
246+
247+
private func initializeModels() {
248+
// Initialize models with their actual download status
249+
models = availableModels.map { model in
250+
var updatedModel = model
251+
updatedModel.isDownloaded = modelManager.isModelDownloaded(name: model.url.lastPathComponent)
252+
return updatedModel
253+
}
254+
}
255+
256+
func downloadModel(_ model: DownloadableModel) {
257+
guard !model.isDownloaded && !isDownloadingAny else { return }
258+
259+
isDownloadingAny = true
260+
261+
// Find the index of the model we're downloading
262+
guard let modelIndex = models.firstIndex(where: { $0.id == model.id }) else {
263+
isDownloadingAny = false
264+
return
265+
}
266+
267+
Task {
268+
do {
269+
// Start the download with progress updates
270+
let filename = model.url.lastPathComponent
271+
272+
try await modelManager.downloadModel(url: model.url, name: filename) { [weak self] progress in
273+
DispatchQueue.main.async {
274+
self?.models[modelIndex].downloadProgress = progress
275+
if progress >= 1.0 {
276+
self?.models[modelIndex].isDownloaded = true
277+
self?.isDownloadingAny = false
278+
}
279+
}
280+
}
281+
} catch {
282+
print("Failed to download model: \(error)")
283+
DispatchQueue.main.async {
284+
self.models[modelIndex].downloadProgress = 0
285+
self.isDownloadingAny = false
56286
}
57287
}
58288
}

0 commit comments

Comments
 (0)