Skip to content

Commit 748675e

Browse files
committed
feat(tray): unify model selection and keep tray/dashboard in sync
- Include Parakeet downloads and Soniox (when key present) in tray menu - Rebuild tray menu on model download/delete and cloud disconnect - Ensure tray↔dashboard sync via settings saves + events (model-changed, settings-changed) - Copy: point guidance to "Models" instead of "Settings" - Remove all toast action CTAs (no inline buttons in toasts) Notes: - Soniox preload is skipped; local engines unchanged - Language reset-on-select remains intentional per current policy Validation: - Typecheck OK; frontend 127/127; backend 154/154
1 parent 54d98d0 commit 748675e

37 files changed

+1087
-671
lines changed

src-tauri/src/ai/tests.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,7 @@ mod tests {
131131
let mut prompts_options = EnhancementOptions::default();
132132
prompts_options.preset = EnhancementPreset::Prompts;
133133
let prompts_prompt = build_enhancement_prompt(text, None, &prompts_options);
134-
assert!(
135-
prompts_prompt.contains("transform the cleaned text into a concise AI prompt")
136-
);
134+
assert!(prompts_prompt.contains("transform the cleaned text into a concise AI prompt"));
137135

138136
// Test Email preset
139137
let mut email_options = EnhancementOptions::default();
@@ -235,10 +233,16 @@ mod tests {
235233
prompt.contains("self-corrections"),
236234
"Should handle self-corrections"
237235
);
238-
assert!(prompt.contains("last-intent wins"), "Should use last-intent policy");
236+
assert!(
237+
prompt.contains("last-intent wins"),
238+
"Should use last-intent policy"
239+
);
239240

240241
// 2. Error correction
241-
assert!(prompt.contains("grammar, punctuation, capitalization"), "Should handle grammar and spelling");
242+
assert!(
243+
prompt.contains("grammar, punctuation, capitalization"),
244+
"Should handle grammar and spelling"
245+
);
242246

243247
// 3. Number and time formatting
244248
assert!(

src-tauri/src/commands/ai.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,7 @@ pub async fn update_enhancement_options(
478478
.save()
479479
.map_err(|e| format!("Failed to save enhancement options: {}", e))?;
480480

481-
log::info!(
482-
"Enhancement options updated: preset={:?}",
483-
options.preset
484-
);
481+
log::info!("Enhancement options updated: preset={:?}", options.preset);
485482

486483
Ok(())
487484
}

src-tauri/src/commands/audio.rs

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ async fn resolve_engine_for_model(
175175
match engine_hint.map(|e| e.to_lowercase()) {
176176
Some(ref engine) if engine == "soniox" => {
177177
if crate::secure_store::secure_has(app, "stt_api_key_soniox").unwrap_or(false) {
178-
Ok(ActiveEngineSelection::Soniox { model_name: model_name.to_string() })
178+
Ok(ActiveEngineSelection::Soniox {
179+
model_name: model_name.to_string(),
180+
})
179181
} else {
180182
Err("Soniox token not configured. Please configure it in Models.".to_string())
181183
}
@@ -216,9 +218,13 @@ async fn resolve_engine_for_model(
216218
None => {
217219
if model_name == "soniox" {
218220
if crate::secure_store::secure_has(app, "stt_api_key_soniox").unwrap_or(false) {
219-
return Ok(ActiveEngineSelection::Soniox { model_name: model_name.to_string() });
221+
return Ok(ActiveEngineSelection::Soniox {
222+
model_name: model_name.to_string(),
223+
});
220224
} else {
221-
return Err("Soniox token not configured. Please configure it in Models.".to_string());
225+
return Err(
226+
"Soniox token not configured. Please configure it in Models.".to_string(),
227+
);
222228
}
223229
}
224230
if let Some(path) = whisper_state.read().await.get_model_path(model_name) {
@@ -354,7 +360,8 @@ async fn validate_recording_requirements(app: &AppHandle) -> Result<(), String>
354360
.and_then(|v| v.as_str().map(|s| s.to_string()))
355361
.unwrap_or_else(|| "whisper".to_string());
356362
if engine == "soniox" {
357-
let has_key = crate::secure_store::secure_has(app, "stt_api_key_soniox").unwrap_or(false);
363+
let has_key =
364+
crate::secure_store::secure_has(app, "stt_api_key_soniox").unwrap_or(false);
358365
(true, has_key)
359366
} else {
360367
(false, false)
@@ -364,7 +371,8 @@ async fn validate_recording_requirements(app: &AppHandle) -> Result<(), String>
364371
}
365372
};
366373

367-
let has_models = has_whisper_models || has_parakeet_models || (is_soniox_selected && soniox_ready);
374+
let has_models =
375+
has_whisper_models || has_parakeet_models || (is_soniox_selected && soniox_ready);
368376

369377
if !has_models {
370378
log::error!("No models downloaded");
@@ -375,11 +383,15 @@ async fn validate_recording_requirements(app: &AppHandle) -> Result<(), String>
375383
"no-models-error",
376384
serde_json::json!({
377385
"title": "No Speech Recognition Models",
378-
"message": if is_soniox_selected { "Please configure your Soniox token in Settings before recording." } else { "Please download at least one model from Settings before recording." },
386+
"message": if is_soniox_selected { "Please configure your Soniox token in Models before recording." } else { "Please download at least one model from Models before recording." },
379387
"action": "open-settings"
380388
}),
381389
);
382-
return Err(if is_soniox_selected { "Soniox token missing".to_string() } else { "No speech recognition models installed. Please download a model first.".to_string() });
390+
return Err(if is_soniox_selected {
391+
"Soniox token missing".to_string()
392+
} else {
393+
"No speech recognition models installed. Please download a model first.".to_string()
394+
});
383395
}
384396

385397
// Check license status (with caching to improve performance)
@@ -1054,12 +1066,14 @@ pub async fn stop_recording(
10541066
&app,
10551067
&audio_path,
10561068
"Soniox token not configured",
1057-
"Please configure your Soniox token in Settings before recording.",
1069+
"Please configure your Soniox token in Models before recording.",
10581070
)
10591071
.await;
10601072
}
10611073

1062-
ActiveEngineSelection::Soniox { model_name: config.current_model.clone() }
1074+
ActiveEngineSelection::Soniox {
1075+
model_name: config.current_model.clone(),
1076+
}
10631077
}
10641078
_ => {
10651079
let downloaded_models = whisper_manager.read().await.get_downloaded_model_names();
@@ -1070,7 +1084,7 @@ pub async fn stop_recording(
10701084
&app,
10711085
&audio_path,
10721086
"No speech recognition models installed",
1073-
"Please download at least one speech recognition model from Settings to use VoiceTypr.",
1087+
"Please download at least one speech recognition model from Models to use VoiceTypr.",
10741088
)
10751089
.await;
10761090
}
@@ -1184,7 +1198,9 @@ pub async fn stop_recording(
11841198
let normalized_path = {
11851199
let ts = chrono::Local::now().format("%Y%m%d_%H%M%S");
11861200
let out_path = parent_dir.join(format!("normalized_{}.wav", ts));
1187-
if let Err(e) = crate::ffmpeg::normalize_streaming(&app, &audio_path, &out_path).await {
1201+
if let Err(e) =
1202+
crate::ffmpeg::normalize_streaming(&app, &audio_path, &out_path).await
1203+
{
11881204
log::error!("Audio normalization (ffmpeg) failed: {}", e);
11891205
update_recording_state(
11901206
&app,
@@ -1277,7 +1293,6 @@ pub async fn stop_recording(
12771293
config.ai_enabled
12781294
);
12791295

1280-
12811296
let language = if config.language.is_empty() {
12821297
None
12831298
} else {
@@ -1431,7 +1446,13 @@ pub async fn stop_recording(
14311446
}
14321447
}
14331448
ActiveEngineSelection::Soniox { .. } => {
1434-
match soniox_transcribe_async(&app_for_task, &audio_path_clone, language_for_task.as_deref()).await {
1449+
match soniox_transcribe_async(
1450+
&app_for_task,
1451+
&audio_path_clone,
1452+
language_for_task.as_deref(),
1453+
)
1454+
.await
1455+
{
14351456
Ok(text) => Ok(text),
14361457
Err(e) => Err(e),
14371458
}
@@ -1807,19 +1828,34 @@ pub async fn save_transcription(app: AppHandle, text: String, model: String) ->
18071828
if let Some(value) = store.get(&key) {
18081829
match &latest {
18091830
Some((ts, _)) => {
1810-
if key > *ts { latest = Some((key.to_string(), value)); }
1831+
if key > *ts {
1832+
latest = Some((key.to_string(), value));
1833+
}
18111834
}
1812-
None => latest = Some((key.to_string(), value))
1835+
None => latest = Some((key.to_string(), value)),
18131836
}
18141837
}
18151838
}
18161839

18171840
if let Some((ts, v)) = latest {
1818-
let same_text = v.get("text").and_then(|x| x.as_str()).map(|s| s == text).unwrap_or(false);
1819-
let same_model = v.get("model").and_then(|x| x.as_str()).map(|s| s == model).unwrap_or(false);
1841+
let same_text = v
1842+
.get("text")
1843+
.and_then(|x| x.as_str())
1844+
.map(|s| s == text)
1845+
.unwrap_or(false);
1846+
let same_model = v
1847+
.get("model")
1848+
.and_then(|x| x.as_str())
1849+
.map(|s| s == model)
1850+
.unwrap_or(false);
18201851
let within_window = chrono::DateTime::parse_from_rfc3339(&ts)
18211852
.ok()
1822-
.and_then(|t| t.with_timezone(&chrono::Utc).signed_duration_since(chrono::Utc::now()).num_seconds().checked_abs())
1853+
.and_then(|t| {
1854+
t.with_timezone(&chrono::Utc)
1855+
.signed_duration_since(chrono::Utc::now())
1856+
.num_seconds()
1857+
.checked_abs()
1858+
})
18231859
.map(|secs| secs <= 2)
18241860
.unwrap_or(false);
18251861
if same_text && same_model && within_window {
@@ -2145,9 +2181,13 @@ pub async fn transcribe_audio(
21452181
}
21462182

21472183
// Soniox async transcription via v1 Files + Transcriptions flow
2148-
async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Option<&str>) -> Result<String, String> {
2149-
use tokio::fs;
2184+
async fn soniox_transcribe_async(
2185+
app: &AppHandle,
2186+
wav_path: &Path,
2187+
language: Option<&str>,
2188+
) -> Result<String, String> {
21502189
use reqwest::multipart::{Form, Part};
2190+
use tokio::fs;
21512191

21522192
let key = crate::secure_store::secure_get(app, "stt_api_key_soniox")?
21532193
.ok_or_else(|| "Soniox API key not set".to_string())?;
@@ -2160,10 +2200,14 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
21602200
let base = "https://api.soniox.com/v1";
21612201

21622202
// 1) Upload file -> file_id
2163-
let filename = wav_path.file_name().and_then(|s| s.to_str()).unwrap_or("audio.wav");
2203+
let filename = wav_path
2204+
.file_name()
2205+
.and_then(|s| s.to_str())
2206+
.unwrap_or("audio.wav");
21642207
let file_part = Part::bytes(wav_bytes)
21652208
.file_name(filename.to_string())
2166-
.mime_str("audio/wav").map_err(|e| e.to_string())?;
2209+
.mime_str("audio/wav")
2210+
.map_err(|e| e.to_string())?;
21672211
let form = Form::new().part("file", file_part);
21682212

21692213
let upload_url = format!("{}/files", base);
@@ -2181,14 +2225,20 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
21812225
return Err(format!("Soniox upload failed: HTTP {}: {}", code, snippet));
21822226
}
21832227
let upload_json: serde_json::Value = upload_resp.json().await.map_err(|e| e.to_string())?;
2184-
let file_id = upload_json.get("id").and_then(|v| v.as_str()).ok_or("Missing file_id")?.to_string();
2228+
let file_id = upload_json
2229+
.get("id")
2230+
.and_then(|v| v.as_str())
2231+
.ok_or("Missing file_id")?
2232+
.to_string();
21852233

21862234
// 2) Create transcription -> transcription_id
21872235
let mut payload = serde_json::json!({
21882236
"model": "stt-async-preview",
21892237
"file_id": file_id,
21902238
});
2191-
if let Some(lang) = language { payload["language_hints"] = serde_json::json!([lang]); }
2239+
if let Some(lang) = language {
2240+
payload["language_hints"] = serde_json::json!([lang]);
2241+
}
21922242

21932243
let create_url = format!("{}/transcriptions", base);
21942244
let create_resp = client
@@ -2203,10 +2253,17 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
22032253
let code = create_resp.status();
22042254
let body = create_resp.text().await.unwrap_or_default();
22052255
let snippet: String = body.chars().take(300).collect();
2206-
return Err(format!("Soniox create transcription failed: HTTP {}: {}", code, snippet));
2256+
return Err(format!(
2257+
"Soniox create transcription failed: HTTP {}: {}",
2258+
code, snippet
2259+
));
22072260
}
22082261
let create_json: serde_json::Value = create_resp.json().await.map_err(|e| e.to_string())?;
2209-
let transcription_id = create_json.get("id").and_then(|v| v.as_str()).ok_or("Missing transcription id")?.to_string();
2262+
let transcription_id = create_json
2263+
.get("id")
2264+
.and_then(|v| v.as_str())
2265+
.ok_or("Missing transcription id")?
2266+
.to_string();
22102267

22112268
// 3) Poll status
22122269
let status_url = format!("{}/transcriptions/{}", base, transcription_id);
@@ -2230,11 +2287,16 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
22302287
match status {
22312288
"completed" => break,
22322289
"error" => {
2233-
let msg = json.get("error_message").and_then(|v| v.as_str()).unwrap_or("Job failed");
2290+
let msg = json
2291+
.get("error_message")
2292+
.and_then(|v| v.as_str())
2293+
.unwrap_or("Job failed");
22342294
return Err(format!("Soniox job failed: {}", msg));
22352295
}
22362296
_ => {
2237-
if started.elapsed() > timeout { return Err("Soniox transcription timed out".to_string()); }
2297+
if started.elapsed() > timeout {
2298+
return Err("Soniox transcription timed out".to_string());
2299+
}
22382300
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
22392301
}
22402302
}
@@ -2252,7 +2314,10 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
22522314
let code = resp.status();
22532315
let body = resp.text().await.unwrap_or_default();
22542316
let snippet: String = body.chars().take(200).collect();
2255-
return Err(format!("Soniox transcript failed: HTTP {}: {}", code, snippet));
2317+
return Err(format!(
2318+
"Soniox transcript failed: HTTP {}: {}",
2319+
code, snippet
2320+
));
22562321
}
22572322
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
22582323

@@ -2265,11 +2330,17 @@ async fn soniox_transcribe_async(app: &AppHandle, wav_path: &Path, language: Opt
22652330
let mut first = true;
22662331
for t in tokens {
22672332
if let Some(txt) = t.get("text").and_then(|v| v.as_str()) {
2268-
if !first { out.push(' '); } else { first = false; }
2333+
if !first {
2334+
out.push(' ');
2335+
} else {
2336+
first = false;
2337+
}
22692338
out.push_str(txt);
22702339
}
22712340
}
2272-
if !out.is_empty() { return Ok(out); }
2341+
if !out.is_empty() {
2342+
return Ok(out);
2343+
}
22732344
}
22742345
Err("Soniox transcript format not recognized".to_string())
22752346
}

src-tauri/src/commands/license.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use crate::license::{api_client::LicenseApiClient, device, keychain, LicenseState, LicenseStatus};
2+
use crate::simple_cache::{self as scache, SetItemOptions};
23
use crate::AppState;
34
use chrono::{DateTime, Duration, Utc};
45
use serde::{Deserialize, Serialize};
56
use std::panic::{RefUnwindSafe, UnwindSafe};
67
use std::time::Instant;
78
use tauri::{AppHandle, Manager};
8-
use crate::simple_cache::{self as scache, SetItemOptions};
99

1010
/// Cached license status to avoid repeated API calls
1111
/// Cache is valid for 6 hours to balance freshness with performance
@@ -102,7 +102,10 @@ fn is_within_grace_period(app: &AppHandle) -> Option<i64> {
102102
// Check if grace period timestamp exists (regardless of whether it's valid)
103103
fn has_grace_period_timestamp(app: &AppHandle) -> bool {
104104
// let cache = app.cache();
105-
scache::get(app, LAST_VALIDATION_KEY).ok().flatten().is_some()
105+
scache::get(app, LAST_VALIDATION_KEY)
106+
.ok()
107+
.flatten()
108+
.is_some()
106109
}
107110

108111
// Check if we're within the trial grace period

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub mod model;
1111
pub mod permissions;
1212
pub mod reset;
1313
pub mod settings;
14+
pub mod stt;
1415
pub mod text;
1516
pub mod utils;
1617
pub mod window;
17-
pub mod stt;

0 commit comments

Comments
 (0)