Skip to content
This repository was archived by the owner on Apr 28, 2026. It is now read-only.

Commit 230a385

Browse files
Block meeting start on incomplete model
Wait for live transcription to start before creating a meeting. Tighten Rust and Swift model readiness checks so partial CoreML bundles are not treated as ready.
1 parent 853c126 commit 230a385

3 files changed

Lines changed: 141 additions & 134 deletions

File tree

src-tauri/src/speech_models.rs

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -298,26 +298,18 @@ pub fn mlx_runtime_is_available() -> bool {
298298

299299
pub fn model_path_is_ready(model_id: SpeechModelId, path: &Path) -> bool {
300300
match model_id {
301-
SpeechModelId::ParakeetStreaming | SpeechModelId::ParakeetBatch => required_files_present(
302-
path,
303-
&[
304-
"config.json",
305-
"vocab.json",
306-
"encoder.mlmodelc",
307-
"decoder.mlmodelc",
308-
"joint.mlmodelc",
309-
],
310-
),
311-
SpeechModelId::Omnilingual => required_files_present(
312-
path,
313-
&[
314-
"config.json",
315-
"tokenizer.model",
316-
"omnilingual-ctc-300m-int8.mlpackage",
317-
],
318-
),
301+
SpeechModelId::ParakeetStreaming | SpeechModelId::ParakeetBatch => {
302+
required_regular_files_present(path, &["config.json", "vocab.json"])
303+
&& compiled_coreml_model_ready(&path.join("encoder.mlmodelc"))
304+
&& compiled_coreml_model_ready(&path.join("decoder.mlmodelc"))
305+
&& compiled_coreml_model_ready(&path.join("joint.mlmodelc"))
306+
}
307+
SpeechModelId::Omnilingual => {
308+
required_regular_files_present(path, &["config.json", "tokenizer.model"])
309+
&& package_directory_ready(&path.join("omnilingual-ctc-300m-int8.mlpackage"))
310+
}
319311
SpeechModelId::Qwen3Small | SpeechModelId::Qwen3Large => {
320-
required_files_present(path, &["vocab.json", "merges.txt", "tokenizer_config.json"])
312+
required_regular_files_present(path, &["vocab.json", "merges.txt", "tokenizer_config.json"])
321313
&& directory_contains_extension(path, "safetensors")
322314
}
323315
}
@@ -344,10 +336,35 @@ fn read_sysctl_value(name: &str) -> Option<String> {
344336
}
345337
}
346338

347-
fn required_files_present(path: &Path, relative_paths: &[&str]) -> bool {
339+
fn required_regular_files_present(path: &Path, relative_paths: &[&str]) -> bool {
348340
relative_paths
349341
.iter()
350-
.all(|relative_path| path.join(relative_path).exists())
342+
.all(|relative_path| path.join(relative_path).is_file())
343+
}
344+
345+
fn compiled_coreml_model_ready(path: &Path) -> bool {
346+
path.is_dir()
347+
&& path.join("model.mil").is_file()
348+
&& directory_contains_regular_file(&path.join("weights"))
349+
}
350+
351+
fn package_directory_ready(path: &Path) -> bool {
352+
path.is_dir() && directory_contains_regular_file(path)
353+
}
354+
355+
fn directory_contains_regular_file(path: &Path) -> bool {
356+
let Ok(entries) = std::fs::read_dir(path) else {
357+
return false;
358+
};
359+
360+
entries.flatten().any(|entry| {
361+
let entry_path = entry.path();
362+
match entry.file_type() {
363+
Ok(file_type) if file_type.is_file() => true,
364+
Ok(file_type) if file_type.is_dir() => directory_contains_regular_file(&entry_path),
365+
_ => false,
366+
}
367+
})
351368
}
352369

353370
fn directory_contains_extension(path: &Path, extension: &str) -> bool {

src-tauri/swift-permissions/src/speech_bridge.swift

Lines changed: 89 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -69,31 +69,6 @@ private enum SpeechModelKind: String, CaseIterable {
6969
self == .parakeetStreaming
7070
}
7171

72-
var requiredRelativePaths: [String] {
73-
switch self {
74-
case .parakeetStreaming, .parakeetBatch:
75-
return [
76-
"config.json",
77-
"vocab.json",
78-
"encoder.mlmodelc",
79-
"decoder.mlmodelc",
80-
"joint.mlmodelc",
81-
]
82-
case .omnilingual:
83-
return [
84-
"config.json",
85-
"tokenizer.model",
86-
"omnilingual-ctc-300m-int8.mlpackage",
87-
]
88-
case .qwen3Small, .qwen3Large:
89-
return [
90-
"vocab.json",
91-
"merges.txt",
92-
"tokenizer_config.json",
93-
]
94-
}
95-
}
96-
9772
func cacheDirectoryURL() throws -> URL {
9873
try HuggingFaceDownloader.getCacheDirectory(for: repo)
9974
}
@@ -107,26 +82,30 @@ private enum SpeechModelKind: String, CaseIterable {
10782
return false
10883
}
10984

110-
let fileManager = FileManager.default
111-
for relativePath in requiredRelativePaths {
112-
if !fileManager.fileExists(atPath: directory.appendingPathComponent(relativePath).path) {
113-
return false
114-
}
115-
}
116-
117-
if self == .qwen3Small || self == .qwen3Large {
118-
guard let contents = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
119-
else {
120-
return false
121-
}
122-
123-
return contents.contains { $0.pathExtension == "safetensors" }
85+
switch self {
86+
case .parakeetStreaming, .parakeetBatch:
87+
return Self.regularFileExists(at: directory.appendingPathComponent("config.json"))
88+
&& Self.regularFileExists(at: directory.appendingPathComponent("vocab.json"))
89+
&& Self.compiledCoreMLModelReady(at: directory.appendingPathComponent("encoder.mlmodelc"))
90+
&& Self.compiledCoreMLModelReady(at: directory.appendingPathComponent("decoder.mlmodelc"))
91+
&& Self.compiledCoreMLModelReady(at: directory.appendingPathComponent("joint.mlmodelc"))
92+
case .omnilingual:
93+
return Self.regularFileExists(at: directory.appendingPathComponent("config.json"))
94+
&& Self.regularFileExists(at: directory.appendingPathComponent("tokenizer.model"))
95+
&& Self.directoryContainsRegularFile(
96+
at: directory.appendingPathComponent("omnilingual-ctc-300m-int8.mlpackage")
97+
)
98+
case .qwen3Small, .qwen3Large:
99+
return Self.regularFileExists(at: directory.appendingPathComponent("vocab.json"))
100+
&& Self.regularFileExists(at: directory.appendingPathComponent("merges.txt"))
101+
&& Self.regularFileExists(at: directory.appendingPathComponent("tokenizer_config.json"))
102+
&& Self.directoryContainsFile(withExtension: "safetensors", in: directory)
124103
}
125-
126-
return true
127104
}
128105

129106
func load(progressHandler: ((Double, String) -> Void)?) async throws -> LoadedSpeechModel {
107+
let offlineMode = filesReady()
108+
130109
switch self {
131110
case .parakeetStreaming:
132111
return .streaming(
@@ -139,25 +118,94 @@ private enum SpeechModelKind: String, CaseIterable {
139118
return .parakeetBatch(
140119
try await ParakeetASRModel.fromPretrained(
141120
modelId: repo,
121+
offlineMode: offlineMode,
142122
progressHandler: progressHandler
143123
)
144124
)
145125
case .omnilingual:
146126
return .omnilingual(
147127
try await OmnilingualASRModel.fromPretrained(
148128
modelId: repo,
129+
offlineMode: offlineMode,
149130
progressHandler: progressHandler
150131
)
151132
)
152133
case .qwen3Small, .qwen3Large:
153134
return .qwen3(
154135
try await Qwen3ASRModel.fromPretrained(
155136
modelId: repo,
137+
offlineMode: offlineMode,
156138
progressHandler: progressHandler
157139
)
158140
)
159141
}
160142
}
143+
144+
private static func regularFileExists(at url: URL) -> Bool {
145+
var isDirectory = ObjCBool(false)
146+
return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
147+
&& !isDirectory.boolValue
148+
}
149+
150+
private static func compiledCoreMLModelReady(at directory: URL) -> Bool {
151+
var isDirectory = ObjCBool(false)
152+
guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory),
153+
isDirectory.boolValue
154+
else {
155+
return false
156+
}
157+
158+
return regularFileExists(at: directory.appendingPathComponent("model.mil"))
159+
&& directoryContainsRegularFile(at: directory.appendingPathComponent("weights"))
160+
}
161+
162+
private static func directoryContainsFile(withExtension pathExtension: String, in directory: URL)
163+
-> Bool
164+
{
165+
guard
166+
let contents = try? FileManager.default.contentsOfDirectory(
167+
at: directory,
168+
includingPropertiesForKeys: [.isRegularFileKey]
169+
)
170+
else {
171+
return false
172+
}
173+
174+
return contents.contains { candidate in
175+
guard
176+
candidate.pathExtension == pathExtension,
177+
let values = try? candidate.resourceValues(forKeys: [.isRegularFileKey])
178+
else {
179+
return false
180+
}
181+
182+
return values.isRegularFile == true
183+
}
184+
}
185+
186+
private static func directoryContainsRegularFile(at directory: URL) -> Bool {
187+
guard
188+
let enumerator = FileManager.default.enumerator(
189+
at: directory,
190+
includingPropertiesForKeys: [.isRegularFileKey],
191+
options: [.skipsHiddenFiles]
192+
)
193+
else {
194+
return false
195+
}
196+
197+
for case let candidate as URL in enumerator {
198+
guard let values = try? candidate.resourceValues(forKeys: [.isRegularFileKey]) else {
199+
continue
200+
}
201+
202+
if values.isRegularFile == true {
203+
return true
204+
}
205+
}
206+
207+
return false
208+
}
161209
}
162210

163211
private enum LoadedSpeechModel {

src/store.ts

Lines changed: 14 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,37 +1506,6 @@ function createMeeting(meetingId = crypto.randomUUID(), audioPath = "") {
15061506
return meeting;
15071507
}
15081508

1509-
function discardMeeting(meetingId: string) {
1510-
const deletedActiveMeeting = currentMeetingIdFromHash() === meetingId;
1511-
const clearedRecordingMeeting = state.recordingMeetingId === meetingId;
1512-
1513-
clearMeetingMarkdownSync(meetingId);
1514-
1515-
state = {
1516-
...state,
1517-
meetings: state.meetings.filter((candidate) => candidate.id !== meetingId),
1518-
recordingMeetingId: clearedRecordingMeeting ? null : state.recordingMeetingId,
1519-
transcriptionRunning: clearedRecordingMeeting ? false : state.transcriptionRunning,
1520-
transcriptionStopping: clearedRecordingMeeting ? false : state.transcriptionStopping,
1521-
liveTranscriptText: clearedRecordingMeeting ? "" : state.liveTranscriptText,
1522-
liveTranscriptEntries: clearedRecordingMeeting ? [] : state.liveTranscriptEntries,
1523-
liveTranscriptionMode: clearedRecordingMeeting ? null : state.liveTranscriptionMode,
1524-
meetingNote: deletedActiveMeeting ? "" : state.meetingNote,
1525-
diarizationMeetingId:
1526-
state.diarizationMeetingId === meetingId ? null : state.diarizationMeetingId,
1527-
diarizationIndicatorMinimized:
1528-
state.diarizationMeetingId === meetingId ? false : state.diarizationIndicatorMinimized,
1529-
diarizationBannerMessage:
1530-
state.diarizationMeetingId === meetingId ? null : state.diarizationBannerMessage,
1531-
};
1532-
persistMeetings();
1533-
emit();
1534-
1535-
if (deletedActiveMeeting) {
1536-
setHashRoute("/");
1537-
}
1538-
}
1539-
15401509
function queueMeetingAutoDiarization(meetingId: string) {
15411510
const meeting = getMeeting(meetingId);
15421511
if (!meeting || !meeting.audioPath.trim() || !state.diarizationSettings?.enabled) {
@@ -1813,6 +1782,10 @@ async function refreshManagedModelDownloadState(silent = false) {
18131782
}
18141783
}
18151784

1785+
async function refreshTranscriptionSetupState() {
1786+
await Promise.all([refreshManagedModelDownloadState(true), refreshModelSettings(true)]);
1787+
}
1788+
18161789
async function refreshDiarizationSettings(silent = false) {
18171790
try {
18181791
const diarizationSettings = await invoke<DiarizationSettings>("diarization_settings_state");
@@ -1863,7 +1836,7 @@ async function refreshSettingsWindowData(silent = false) {
18631836
}
18641837

18651838
async function ensureModelReady() {
1866-
await refreshModelSettings(true);
1839+
await refreshTranscriptionSetupState();
18671840

18681841
if (!state.modelSettings) {
18691842
throw new Error("Model settings are still loading.");
@@ -2285,8 +2258,6 @@ async function startMeeting() {
22852258
return null;
22862259
}
22872260

2288-
let finishStartingInBackground = false;
2289-
22902261
patch({
22912262
startMeetingBusy: true,
22922263
permissionNote: "",
@@ -2300,50 +2271,20 @@ async function startMeeting() {
23002271
await prepareMeetingPermissions();
23012272
await stopActiveRecordingIfNeeded();
23022273
patch({ transcriptionBusy: true });
2303-
const meeting = createMeeting();
2304-
2305-
void (async () => {
2306-
try {
2307-
const snapshot = await startLiveTranscriptionSession(meeting.id);
2308-
const nextAudioPath = snapshot.audioPath.trim();
2309-
updateMeeting(meeting.id, (current) => {
2310-
if (current.audioPath === nextAudioPath) {
2311-
return current;
2312-
}
2313-
2314-
return {
2315-
...current,
2316-
audioPath: nextAudioPath,
2317-
updatedAt: current.updatedAt,
2318-
};
2319-
});
2320-
} catch (error) {
2321-
discardMeeting(meeting.id);
2322-
patch({
2323-
permissionNote: error instanceof Error ? error.message : String(error),
2324-
});
2325-
} finally {
2326-
patch({
2327-
transcriptionBusy: false,
2328-
startMeetingBusy: false,
2329-
});
2330-
}
2331-
})();
2332-
2333-
finishStartingInBackground = true;
2334-
return meeting;
2274+
const meetingId = crypto.randomUUID();
2275+
const snapshot = await startLiveTranscriptionSession(meetingId);
2276+
return createMeeting(meetingId, snapshot.audioPath.trim());
23352277
} catch (error) {
2278+
await refreshTranscriptionSetupState();
23362279
patch({
23372280
permissionNote: error instanceof Error ? error.message : String(error),
23382281
});
23392282
return null;
23402283
} finally {
2341-
if (!finishStartingInBackground) {
2342-
patch({
2343-
transcriptionBusy: false,
2344-
startMeetingBusy: false,
2345-
});
2346-
}
2284+
patch({
2285+
transcriptionBusy: false,
2286+
startMeetingBusy: false,
2287+
});
23472288
}
23482289
}
23492290

@@ -2389,6 +2330,7 @@ async function toggleMeetingStatus(meetingId: string) {
23892330
updatedAt: new Date().toISOString(),
23902331
}));
23912332
} catch (error) {
2333+
await refreshTranscriptionSetupState();
23922334
patch({
23932335
meetingNote: error instanceof Error ? error.message : String(error),
23942336
});

0 commit comments

Comments
 (0)