From 6bc0949e7eac3f7b8aca83d45dd2e9cad87cd1f6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 12:58:12 -0500 Subject: [PATCH 01/19] feat(transcription): add multilingual/language fields to ModelInfo+Settings (T8.7, M4.2) --- src-tauri/src/models.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 23dcf2d..e0d6092 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -45,6 +45,19 @@ pub struct ModelInfo { pub size_mb: u32, // approximate download size pub installed: bool, pub active: bool, + /// `false` for the `.en` (English-only) ggml variants; `true` for the + /// multilingual variants (no `.en` suffix, T8.7/FR-TRX-4/M4.2) — gates + /// whether the Settings language picker is enabled for this model. + pub multilingual: bool, +} + +/// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code +/// (as accepted by `whisper_rs::FullParams::set_language`) plus a display +/// label for the Settings dropdown. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LanguageOption { + pub code: String, + pub label: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -198,6 +211,15 @@ pub struct Settings { pub llm_advanced: serde_json::Value, pub preferred_backend: String, // auto|npu|nvidia|amd|intel|cpu pub whisper_model: String, // ModelInfo.id, e.g. "base.en-q5_1" + /// Global default transcription language (T8.7, FR-TRX-4): `None`/`"auto"` + /// lets whisper.cpp auto-detect; an ISO-639-1 code (e.g. "es") forces + /// that language. Only takes effect with a multilingual model loaded — + /// an English-only (`.en`) model always decodes English regardless of + /// this setting (see `transcription::resolve_language`). Each meeting + /// persists whatever was actually used at `Meeting.language`, so this is + /// just the default applied at the next `start_recording`. + #[serde(default)] + pub whisper_language: Option, pub low_overhead: bool, // Recording retention (ADR-0009). Default OFF. pub default_record: bool, From 37a6dd172d30c386e1cd7455b551562b14e0af97 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 12:58:52 -0500 Subject: [PATCH 02/19] feat(transcription): multilingual model catalog entries (T8.7, M4.2) --- src-tauri/src/transcription/models.rs | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src-tauri/src/transcription/models.rs b/src-tauri/src/transcription/models.rs index f1de058..f360052 100644 --- a/src-tauri/src/transcription/models.rs +++ b/src-tauri/src/transcription/models.rs @@ -12,6 +12,12 @@ struct Catalog { id: &'static str, label: &'static str, size_mb: u32, + /// `false` for the `.en` (English-only) ggml variants; `true` for the + /// multilingual variants, which whisper.cpp ships as the same filename + /// minus the `.en` infix (e.g. `ggml-base.bin` vs `ggml-base.en.bin`) — + /// same download/install machinery, just a different id/URL (T8.7, + /// FR-TRX-4, M4.2). + multilingual: bool, } const CATALOG: &[Catalog] = &[ @@ -19,21 +25,49 @@ const CATALOG: &[Catalog] = &[ id: "tiny.en-q5_1", label: "Tiny (English, quantized) — fastest, least accurate", size_mb: 32, + multilingual: false, }, Catalog { id: "base.en-q5_1", label: "Base (English, quantized) — balanced default", size_mb: 60, + multilingual: false, }, Catalog { id: "small.en-q5_1", label: "Small (English, quantized) — more accurate, slower", size_mb: 190, + multilingual: false, }, Catalog { id: "medium.en-q5_1", label: "Medium (English, quantized) — best accuracy, slowest", size_mb: 540, + multilingual: false, + }, + Catalog { + id: "tiny-q5_1", + label: "Tiny (multilingual, quantized) — fastest, least accurate", + size_mb: 32, + multilingual: true, + }, + Catalog { + id: "base-q5_1", + label: "Base (multilingual, quantized) — balanced default", + size_mb: 60, + multilingual: true, + }, + Catalog { + id: "small-q5_1", + label: "Small (multilingual, quantized) — more accurate, slower", + size_mb: 190, + multilingual: true, + }, + Catalog { + id: "medium-q5_1", + label: "Medium (multilingual, quantized) — best accuracy, slowest", + size_mb: 540, + multilingual: true, }, ]; @@ -50,10 +84,19 @@ pub fn list(active_id: &str) -> Vec { size_mb: m.size_mb, installed: whisper_model_file(m.id).exists(), active: m.id == active_id, + multilingual: m.multilingual, }) .collect() } +/// Whether `id` names a multilingual (non-`.en`) catalog model — an unknown +/// id (shouldn't happen; callers validate against the catalog first) is +/// conservatively treated as English-only rather than granting language +/// selection it can't honor. +pub fn is_multilingual(id: &str) -> bool { + CATALOG.iter().any(|m| m.id == id && m.multilingual) +} + #[derive(Debug, thiserror::Error)] pub enum ModelError { #[error("unknown model id: {0}")] @@ -147,4 +190,38 @@ mod tests { let err = remove(active, active).unwrap_err(); assert!(matches!(err, ModelError::Invalid(_))); } + + #[test] + fn catalog_has_a_multilingual_counterpart_for_every_english_only_size() { + // T8.7/M4.2: every `.en` model must have a same-size multilingual + // sibling so the Settings picker always has a language-capable + // option at whatever accuracy/speed tier the user already chose. + let en_only: Vec<_> = CATALOG.iter().filter(|m| !m.multilingual).collect(); + let multilingual: Vec<_> = CATALOG.iter().filter(|m| m.multilingual).collect(); + assert_eq!(en_only.len(), multilingual.len()); + for en in &en_only { + assert!( + multilingual.iter().any(|m| m.size_mb == en.size_mb), + "no multilingual sibling for {}", + en.id + ); + } + } + + #[test] + fn is_multilingual_matches_the_catalog_flag() { + assert!(!is_multilingual("base.en-q5_1")); + assert!(is_multilingual("base-q5_1")); + // Unknown ids are conservatively English-only (no model to check). + assert!(!is_multilingual("nonexistent-id")); + } + + #[test] + fn list_reports_multilingual_flag_per_model() { + let models = list("base.en-q5_1"); + let base_en = models.iter().find(|m| m.id == "base.en-q5_1").unwrap(); + let base_multi = models.iter().find(|m| m.id == "base-q5_1").unwrap(); + assert!(!base_en.multilingual); + assert!(base_multi.multilingual); + } } From 7f88101a79335a15bd92323b3fade48b1bc185a0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:00:48 -0500 Subject: [PATCH 03/19] feat(transcription): whisper language catalog for Settings dropdown (T8.7, M4.2) --- src-tauri/src/transcription/languages.rs | 166 +++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src-tauri/src/transcription/languages.rs diff --git a/src-tauri/src/transcription/languages.rs b/src-tauri/src/transcription/languages.rs new file mode 100644 index 0000000..e83bde8 --- /dev/null +++ b/src-tauri/src/transcription/languages.rs @@ -0,0 +1,166 @@ +//! Selectable transcription languages (T8.7, FR-TRX-4, M4.2) — the Settings +//! language dropdown shown when a multilingual model is active. +//! +//! ponytail: a fixed table, not a runtime query against whisper.cpp's +//! `whisper_lang_str`/`whisper_lang_max_id` — the ~100-language set whisper.cpp +//! ships is effectively static (OpenAI's Whisper `tokenizer.py` LANGUAGES +//! table), and hardcoding it here means the list is available to the UI even +//! before any model is loaded (no `cpu-transcription` feature dependency). + +use crate::models::LanguageOption; + +/// `(ISO-639-1 code, display label)`, exactly the codes whisper.cpp accepts +/// via `whisper_full_params.language`. +const LANGUAGES: &[(&str, &str)] = &[ + ("en", "English"), + ("zh", "Chinese"), + ("de", "German"), + ("es", "Spanish"), + ("ru", "Russian"), + ("ko", "Korean"), + ("fr", "French"), + ("ja", "Japanese"), + ("pt", "Portuguese"), + ("tr", "Turkish"), + ("pl", "Polish"), + ("ca", "Catalan"), + ("nl", "Dutch"), + ("ar", "Arabic"), + ("sv", "Swedish"), + ("it", "Italian"), + ("id", "Indonesian"), + ("hi", "Hindi"), + ("fi", "Finnish"), + ("vi", "Vietnamese"), + ("he", "Hebrew"), + ("uk", "Ukrainian"), + ("el", "Greek"), + ("ms", "Malay"), + ("cs", "Czech"), + ("ro", "Romanian"), + ("da", "Danish"), + ("hu", "Hungarian"), + ("ta", "Tamil"), + ("no", "Norwegian"), + ("th", "Thai"), + ("ur", "Urdu"), + ("hr", "Croatian"), + ("bg", "Bulgarian"), + ("lt", "Lithuanian"), + ("la", "Latin"), + ("mi", "Maori"), + ("ml", "Malayalam"), + ("cy", "Welsh"), + ("sk", "Slovak"), + ("te", "Telugu"), + ("fa", "Persian"), + ("lv", "Latvian"), + ("bn", "Bengali"), + ("sr", "Serbian"), + ("az", "Azerbaijani"), + ("sl", "Slovenian"), + ("kn", "Kannada"), + ("et", "Estonian"), + ("mk", "Macedonian"), + ("br", "Breton"), + ("eu", "Basque"), + ("is", "Icelandic"), + ("hy", "Armenian"), + ("ne", "Nepali"), + ("mn", "Mongolian"), + ("bs", "Bosnian"), + ("kk", "Kazakh"), + ("sq", "Albanian"), + ("sw", "Swahili"), + ("gl", "Galician"), + ("mr", "Marathi"), + ("pa", "Punjabi"), + ("si", "Sinhala"), + ("km", "Khmer"), + ("sn", "Shona"), + ("yo", "Yoruba"), + ("so", "Somali"), + ("af", "Afrikaans"), + ("oc", "Occitan"), + ("ka", "Georgian"), + ("be", "Belarusian"), + ("tg", "Tajik"), + ("sd", "Sindhi"), + ("gu", "Gujarati"), + ("am", "Amharic"), + ("yi", "Yiddish"), + ("lo", "Lao"), + ("uz", "Uzbek"), + ("fo", "Faroese"), + ("ht", "Haitian Creole"), + ("ps", "Pashto"), + ("tk", "Turkmen"), + ("nn", "Nynorsk"), + ("mt", "Maltese"), + ("sa", "Sanskrit"), + ("lb", "Luxembourgish"), + ("my", "Myanmar"), + ("bo", "Tibetan"), + ("tl", "Tagalog"), + ("mg", "Malagasy"), + ("as", "Assamese"), + ("tt", "Tatar"), + ("haw", "Hawaiian"), + ("ln", "Lingala"), + ("ha", "Hausa"), + ("ba", "Bashkir"), + ("jw", "Javanese"), + ("su", "Sundanese"), + ("yue", "Cantonese"), +]; + +/// The dropdown's contents (`list_whisper_languages` command) — "Auto-detect" +/// itself is not in this list; the frontend prepends it (maps to `None`/no +/// `language` argument). +pub fn list() -> Vec { + LANGUAGES + .iter() + .map(|(code, label)| LanguageOption { + code: code.to_string(), + label: label.to_string(), + }) + .collect() +} + +/// Whether `code` is a known whisper.cpp language code (case-insensitive). +/// Used to validate an explicit selection before it's forced into +/// `FullParams::set_language` — an unrecognized code is still passed through +/// to whisper.cpp (it may support codes we haven't listed), but callers use +/// this to warn rather than silently accept a typo. +pub fn is_known(code: &str) -> bool { + LANGUAGES.iter().any(|(c, _)| c.eq_ignore_ascii_case(code)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_includes_english_and_common_languages() { + let langs = list(); + assert!(langs.iter().any(|l| l.code == "en" && l.label == "English")); + assert!(langs.iter().any(|l| l.code == "es")); + assert!(langs.iter().any(|l| l.code == "fr")); + } + + #[test] + fn codes_are_unique() { + let mut codes: Vec<&str> = LANGUAGES.iter().map(|(c, _)| *c).collect(); + let before = codes.len(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(before, codes.len(), "duplicate language code in catalog"); + } + + #[test] + fn is_known_is_case_insensitive() { + assert!(is_known("en")); + assert!(is_known("ES")); + assert!(!is_known("xx-not-a-real-code")); + } +} From 496a8aa29b365937d365a140d518117b64124418 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:00:52 -0500 Subject: [PATCH 04/19] feat(transcription): wire whisper language param through Transcriber trait (T8.7, M4.2) --- src-tauri/src/transcription/mod.rs | 175 ++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs index 3de5be6..e66338c 100644 --- a/src-tauri/src/transcription/mod.rs +++ b/src-tauri/src/transcription/mod.rs @@ -9,6 +9,7 @@ use crate::models::{BackendId, TranscriptSegment}; use std::path::Path; use std::sync::mpsc::{Receiver, Sender}; +pub mod languages; pub mod models; #[derive(Debug, thiserror::Error)] @@ -29,7 +30,13 @@ pub struct AudioWindow { pub type SegmentSink = Sender; pub trait Transcriber: Send + Sync { - fn load(model: &Path, backend: BackendId) -> Result + /// `language` (T8.7, FR-TRX-4): `None` or `Some("auto")` requests + /// auto-detection; an explicit ISO-639-1 code (e.g. `"es"`) forces that + /// language. Engines that can't honor a request (an English-only model, + /// or an engine with no language selection at all, like the NPU/ONNX + /// path) resolve it against their own capability at load time rather + /// than erroring — see `resolve_language` and `effective_language`. + fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result where Self: Sized; /// Streaming: emit interim + final segments for a window (FR-TRX-2). @@ -37,6 +44,46 @@ pub trait Transcriber: Send + Sync { /// Batch: one-shot over a whole file, higher accuracy — also the crash-recovery /// path (FR-TRX-3, T2.8): re-run over the working `audio.wav` from scratch. fn transcribe_file(&self, wav: &Path) -> Result, TrxError>; + + /// The language this engine is actually configured to decode, resolved + /// against model capability at `load` time — `None` means auto-detect. + /// Default: engines with no language selection of their own (the + /// NPU/ONNX path, whose ONNX artifacts are exported English-only) always + /// decode English. + fn effective_language(&self) -> Option { + Some("en".to_string()) + } + + /// The language actually used/detected on the most recent decode, if the + /// engine surfaces one (whisper.cpp does, via `full_lang_id_from_state`). + /// `None` until at least one decode has completed, or if the engine + /// doesn't support detection reporting at all. + fn detected_language(&self) -> Option { + None + } +} + +/// Resolves a requested language against model capability: an English-only +/// (`.en`) whisper.cpp model can only ever decode English, so an explicit +/// non-English request is forced to `"en"` (with a warning) rather than +/// being silently honored into a garbage transcript — the T8.7/FR-TRX-4 +/// "no silent footgun" requirement. `None`/`"auto"` always means +/// auto-detect, regardless of model, since that's harmless either way +/// (whisper.cpp itself forces English internally for a non-multilingual +/// model even when `language` is unset). +#[cfg(feature = "cpu-transcription")] +fn resolve_language(requested: Option<&str>, multilingual: bool) -> Option { + match requested { + None => None, + Some(l) if l.eq_ignore_ascii_case("auto") => None, + Some(l) if !multilingual && !l.eq_ignore_ascii_case("en") => { + tracing::warn!( + "language '{l}' requested but the loaded model is English-only; forcing 'en'" + ); + Some("en".to_string()) + } + Some(l) => Some(l.to_string()), + } } /// whisper.cpp-backed transcriber (CPU baseline; GPU via Cargo features). @@ -44,6 +91,14 @@ pub trait Transcriber: Send + Sync { pub struct WhisperTranscriber { ctx: whisper_rs::WhisperContext, next_id: std::sync::atomic::AtomicU64, + /// What gets passed to `FullParams::set_language` on every decode — + /// resolved once at `load` (see `resolve_language`), not re-resolved per + /// window/file, since a whole recording session uses one language. + configured_language: Option, + /// The language whisper.cpp actually used on the most recent `full()` + /// call (`whisper_full_lang_id_from_state`), updated after every decode + /// so "auto" mode has something concrete to persist (T8.7, FR-TRX-4). + last_detected_language: std::sync::Mutex>, } #[cfg(feature = "cpu-transcription")] @@ -85,6 +140,9 @@ impl WhisperTranscriber { params.set_print_timestamps(false); params.set_suppress_blank(true); params.set_single_segment(single_segment); + // T8.7/FR-TRX-4: `None` here means auto-detect, matching + // `configured_language`'s resolved meaning (see `resolve_language`). + params.set_language(self.configured_language.as_deref()); if single_segment { // whisper.cpp's encoder always runs over a full, padded 30s mel @@ -100,6 +158,18 @@ impl WhisperTranscriber { .full(params, samples) .map_err(|e| TrxError::Inference(e.to_string()))?; + // T8.7/FR-TRX-4: record whatever language whisper.cpp actually used + // for this decode (explicit request or auto-detected) so "auto" mode + // has a concrete value to persist per meeting. Best-effort — an + // unrecognized lang id (or a poisoned mutex) just leaves the last + // known value in place rather than failing the transcription. + let lang_id = state.full_lang_id_from_state(); + if let Some(code) = whisper_rs::get_lang_str(lang_id) { + if let Ok(mut last) = self.last_detected_language.lock() { + *last = Some(code.to_string()); + } + } + // A forced single_segment's reported end_timestamp() reflects // whisper.cpp's internal 30s-padded mel frame, not the real window // length — confirmed even with `duration_ms` set, so don't trust it. @@ -145,16 +215,19 @@ impl Transcriber for WhisperTranscriber { /// so `use_gpu` on a CPU-only build is a harmless no-op — this stays a /// single code path either way rather than branching on which features /// were compiled in. - fn load(model: &Path, backend: BackendId) -> Result { + fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result { let params = whisper_rs::WhisperContextParameters { use_gpu: !matches!(backend, BackendId::Cpu), ..Default::default() }; let ctx = whisper_rs::WhisperContext::new_with_params(model, params) .map_err(|e| TrxError::Load(e.to_string()))?; + let configured_language = resolve_language(language, ctx.is_multilingual()); Ok(Self { ctx, next_id: std::sync::atomic::AtomicU64::new(0), + configured_language, + last_detected_language: std::sync::Mutex::new(None), }) } @@ -178,6 +251,17 @@ impl Transcriber for WhisperTranscriber { crate::audio::read_wav_mono_16k(wav).map_err(|e| TrxError::Load(e.to_string()))?; self.run_full(&samples, 0, false) } + + fn effective_language(&self) -> Option { + self.configured_language.clone() + } + + fn detected_language(&self) -> Option { + self.last_detected_language + .lock() + .ok() + .and_then(|g| g.clone()) + } } #[cfg(feature = "cpu-transcription")] @@ -315,7 +399,8 @@ mod tests { _ => BackendId::Intel, // use_gpu = true for any non-CPU backend }; let t0 = std::time::Instant::now(); - let transcriber = WhisperTranscriber::load(Path::new(&model), backend).expect("load"); + let transcriber = + WhisperTranscriber::load(Path::new(&model), backend, None).expect("load"); let load_ms = t0.elapsed().as_millis(); let t1 = std::time::Instant::now(); let segments = transcriber @@ -330,4 +415,88 @@ mod tests { eprintln!("[spike] backend={backend:?} load={load_ms}ms infer={infer_ms}ms text={text:?}"); assert!(!text.trim().is_empty(), "transcript was empty"); } + + #[test] + fn resolve_language_auto_and_none_both_mean_auto_detect() { + assert_eq!(resolve_language(None, true), None); + assert_eq!(resolve_language(Some("auto"), true), None); + assert_eq!(resolve_language(Some("AUTO"), true), None); + } + + #[test] + fn resolve_language_explicit_on_multilingual_model_passes_through() { + assert_eq!(resolve_language(Some("es"), true), Some("es".to_string())); + assert_eq!(resolve_language(Some("fr"), true), Some("fr".to_string())); + } + + #[test] + fn resolve_language_forces_english_on_english_only_model() { + // The footgun guard (T8.7/FR-TRX-4): an English-only model can't + // honor a non-English request, so it's forced to "en" rather than + // silently producing garbage. + assert_eq!(resolve_language(Some("es"), false), Some("en".to_string())); + assert_eq!(resolve_language(Some("EN"), false), Some("en".to_string())); + // Auto is still allowed on an English-only model — harmless, since + // whisper.cpp forces English internally for it either way. + assert_eq!(resolve_language(None, false), None); + assert_eq!(resolve_language(Some("auto"), false), None); + } + + /// Multilingual decode acceptance spike (opt-in, mirrors + /// `gpu_transcribes_and_times`): loads a *multilingual* model with an + /// explicit non-English `language` and confirms it decodes non-empty + /// text without being forced to English. Needs a real multilingual ggml + /// model + a non-English wav, neither of which are fetched by CI/this + /// sandbox — run manually: + /// WA_WHISPER_MODEL=…ggml-small-q5_1.bin WA_TEST_WAV=…spanish.wav \ + /// WA_TEST_LANGUAGE=es cargo test multilingual_model_decodes_requested_language \ + /// -- --ignored --nocapture + #[test] + #[ignore = "requires a multilingual whisper model + non-English wav; run manually"] + fn multilingual_model_decodes_requested_language() { + let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL"); + let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV"); + let language = std::env::var("WA_TEST_LANGUAGE").unwrap_or_else(|_| "es".to_string()); + + let transcriber = + WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, Some(&language)) + .expect("load multilingual model"); + assert_eq!(transcriber.effective_language(), Some(language.clone())); + + let segments = transcriber + .transcribe_file(Path::new(&wav)) + .expect("transcribe"); + let text = segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" "); + eprintln!("[spike] language={language} text={text:?}"); + assert!(!text.trim().is_empty(), "transcript was empty"); + // whisper.cpp reports back whichever language it actually decoded in. + assert_eq!(transcriber.detected_language().as_deref(), Some(language.as_str())); + } + + /// "Auto" acceptance spike (opt-in): confirms auto-detection actually + /// runs (no `language` forced) and surfaces a detected language after + /// decode. Same manual-only posture as the spike above. + #[test] + #[ignore = "requires a multilingual whisper model + wav; run manually"] + fn auto_mode_detects_a_language() { + let model = std::env::var("WA_WHISPER_MODEL").expect("set WA_WHISPER_MODEL"); + let wav = std::env::var("WA_TEST_WAV").expect("set WA_TEST_WAV"); + + let transcriber = WhisperTranscriber::load(Path::new(&model), BackendId::Cpu, None) + .expect("load multilingual model"); + assert_eq!(transcriber.effective_language(), None, "auto should stay unset"); + assert_eq!(transcriber.detected_language(), None, "nothing decoded yet"); + + transcriber + .transcribe_file(Path::new(&wav)) + .expect("transcribe"); + assert!( + transcriber.detected_language().is_some(), + "auto mode should report a detected language after a decode" + ); + } } From 1fb1da0946d738908007076a68fa4fcc5b9a2cd4 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:01:17 -0500 Subject: [PATCH 05/19] feat(transcription): thread language param through OnnxTranscriber (ignored on NPU/DirectML, T8.7) --- src-tauri/src/transcription/npu.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/transcription/npu.rs b/src-tauri/src/transcription/npu.rs index b8de977..253112c 100644 --- a/src-tauri/src/transcription/npu.rs +++ b/src-tauri/src/transcription/npu.rs @@ -202,7 +202,24 @@ impl Transcriber for OnnxTranscriber { /// `Npu` → OpenVINO EP; `Amd`/`Intel` → DirectML EP (the non-Vulkan GPU /// path). Any other value is rejected so the dispatcher can fall back to /// whisper.cpp rather than us guessing. - fn load(model: &Path, backend: BackendId) -> Result { + /// + /// `language` (T8.7, FR-TRX-4): the exported ONNX model's + /// `forced_decoder_ids` bakes its language in at export time — this + /// engine has no per-inference language selection to apply, unlike + /// whisper.cpp's `FullParams::set_language`. A non-English, non-auto + /// request is logged (not silently dropped) so a user picking a language + /// on the NPU/DirectML path finds out it didn't take, rather than + /// getting a quietly-wrong transcript; `effective_language`/ + /// `detected_language` fall back to the trait's English-only defaults. + fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result { + if let Some(lang) = language { + if !lang.eq_ignore_ascii_case("auto") && !lang.eq_ignore_ascii_case("en") { + tracing::warn!( + "language '{lang}' requested but the NPU/DirectML engine's ONNX model is \ + English-only (language is fixed at export time); ignoring the request" + ); + } + } // Pick the runtime bundle + encoder EP for the requested accelerator. // error_on_failure makes a failed accelerator registration LOUD (Err) // instead of a silent CPU fallback, so the dispatcher can cleanly drop @@ -465,7 +482,7 @@ mod tests { }; let wav = std::env::var("WA_NPU_TEST_WAV").expect("set WA_NPU_TEST_WAV"); let t0 = std::time::Instant::now(); - let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu) + let t = OnnxTranscriber::load(Path::new(&model_dir), BackendId::Npu, None) .expect("load NPU transcriber"); let load_ms = t0.elapsed().as_millis(); let t1 = std::time::Instant::now(); @@ -507,7 +524,8 @@ mod tests { _ => BackendId::Intel, }; let t0 = std::time::Instant::now(); - let t = OnnxTranscriber::load(Path::new(&model_dir), backend).expect("load DirectML"); + let t = + OnnxTranscriber::load(Path::new(&model_dir), backend, None).expect("load DirectML"); let load_ms = t0.elapsed().as_millis(); let t1 = std::time::Instant::now(); let segs = t.transcribe_file(Path::new(&wav)).expect("transcribe"); From 66d9eff2f984990a5368fbc2536dd4cc8a84140f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:02:14 -0500 Subject: [PATCH 06/19] feat(storage): persist requested language at meeting creation (T8.7, M4.2) --- src-tauri/src/storage/mod.rs | 68 ++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index a3e1764..2e2e075 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -42,6 +42,12 @@ pub struct NewMeeting { /// re-rendering notes.md on reprocess/resume reapplies the same /// section structure instead of losing it. pub template_id: Option, + /// Transcription language requested at recording start (T8.7, FR-TRX-4): + /// `None` means auto-detect. Recorded immediately (not just at + /// `finalize_meeting`) so a crash-recovered `recovering` meeting still + /// knows what was asked for; `finalize_meeting`'s `language` overwrites + /// this with whatever whisper.cpp actually resolved/detected. + pub language: Option, } /// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed @@ -703,8 +709,8 @@ impl Store for SqliteStore { let audio_path = folder.join("audio.wav"); let now = now_unix(); sqlx::query( - "INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, template_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?, ?)", + "INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, template_id, language, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&m.title) @@ -713,6 +719,7 @@ impl Store for SqliteStore { .bind(audio_path.display().to_string()) .bind(&m.calendar_event_id) .bind(&m.template_id) + .bind(&m.language) .bind(now) .bind(now) .execute(&self.pool) @@ -1785,6 +1792,60 @@ mod tests { } } + /// T8.7/FR-TRX-4: the language requested at `create_meeting` is visible + /// immediately (not just after `finalize_meeting`) — a crash-recovered + /// `recovering` meeting still knows what was asked for. + #[tokio::test] + async fn create_meeting_persists_the_requested_language_immediately() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let id = store + .create_meeting(NewMeeting { + title: "Reunión semanal".to_string(), + calendar_event_id: None, + template_id: None, + language: Some("es".to_string()), + }) + .await + .unwrap(); + let meeting = store.get_meeting(&id).await.unwrap(); + assert_eq!(meeting.language.as_deref(), Some("es")); + } + + /// `finalize_meeting` overwrites whatever `create_meeting` stored with + /// the language whisper.cpp actually resolved/detected (T8.7, FR-TRX-4) + /// — e.g. "auto" mode's detected result, or an English-only model's + /// forced "en". + #[tokio::test] + async fn finalize_meeting_overwrites_the_requested_language_with_the_resolved_one() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + let id = store + .create_meeting(NewMeeting { + title: "Auto-detect meeting".to_string(), + calendar_event_id: None, + template_id: None, + language: None, // requested "auto" + }) + .await + .unwrap(); + store + .finalize_meeting( + &id, + FinalizeMeeting { + segments: Vec::new(), + speakers: Vec::new(), + duration_secs: 42, + recorded: false, + language: Some("fr".to_string()), // what auto-detect resolved to + backend_used: Some("cpu".to_string()), + model_used: Some("small-q5_1".to_string()), + }, + ) + .await + .unwrap(); + let meeting = store.get_meeting(&id).await.unwrap(); + assert_eq!(meeting.language.as_deref(), Some("fr")); + } + #[tokio::test] async fn rename_meeting_updates_the_title() { let store = SqliteStore::connect_in_memory().await.unwrap(); @@ -1793,6 +1854,7 @@ mod tests { title: "Untitled meeting".to_string(), calendar_event_id: None, template_id: None, + language: None, }) .await .unwrap(); @@ -1818,6 +1880,7 @@ mod tests { title: "Untitled meeting".to_string(), calendar_event_id: None, template_id: None, + language: None, }) .await .unwrap(); @@ -1862,6 +1925,7 @@ mod tests { title: "Untitled meeting".to_string(), calendar_event_id: None, template_id: None, + language: None, }) .await .unwrap(); From 75ca3df0a00aef83b5766aace01a0c9fc523ad3c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:02:24 -0500 Subject: [PATCH 07/19] feat(core): track resolved per-meeting language on RecordingSession (T8.7, M4.2) --- src-tauri/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf5cf17..28f230b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,13 @@ pub struct RecordingSession { /// can pick a different model than `Settings.whisper_model`). pub active_backend: Arc>, pub model_id: String, + /// Resolved transcription language (T8.7, FR-TRX-4): `None` = auto. + /// Set by the transcription worker once the engine loads (to the + /// request resolved against model capability, e.g. forced "en" for an + /// English-only model) and updated after every decode to whatever was + /// actually used/detected — `stop_recording` reads the final value to + /// persist on the meeting record. + pub language: Arc>>, /// `None` when diarization models aren't installed yet (T4.7) — live /// provisional turns and the final post-stop pass are both skipped, same /// graceful-degradation treatment as a missing hardware backend (T4.3). From 7675e0467e1a655814871a31c3ba22a2a734faf9 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:05:36 -0500 Subject: [PATCH 08/19] fix(diarization): set multilingual:false for the shared ModelInfo shape (T8.7) --- src-tauri/src/diarization/models.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/diarization/models.rs b/src-tauri/src/diarization/models.rs index 3adef5c..fab4b0b 100644 --- a/src-tauri/src/diarization/models.rs +++ b/src-tauri/src/diarization/models.rs @@ -52,6 +52,9 @@ pub fn list() -> Vec { // Both models are always "active" once installed — diarization // has no interchangeable-size picker like whisper's (yet). active: true, + // Not a whisper model — the language picker (T8.7) never applies + // to diarization's segmentation/embedding pair. + multilingual: false, }) .collect() } From 34fff3f7deb2299a8b137e27a1900013975bd9a1 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:05:55 -0500 Subject: [PATCH 09/19] feat(core): register list_whisper_languages Tauri command (T8.7, M4.2) --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28f230b..fc92ae4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub fn run() { commands::list_input_devices, commands::set_preferred_backend, commands::list_models, + commands::list_whisper_languages, commands::download_npu_package, commands::download_directml_package, commands::list_diarization_models, From 7490ff5db4fd34d9980fe46b59fb5d44e1a38c1e Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:06:00 -0500 Subject: [PATCH 10/19] feat(transcription): wire language selection through recording/reprocess/recovery (T8.7, M4.2) --- src-tauri/src/commands.rs | 140 +++++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af8a465..f373b74 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -38,6 +38,12 @@ pub struct StartRecordingArgs { pub record: bool, /// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`. pub template_id: Option, + /// Per-meeting transcription language override (T8.7, FR-TRX-4): `None` + /// falls back to `Settings.whisper_language`; `None`/`"auto"` (either + /// way) requests auto-detection. Only takes effect with a multilingual + /// model — see `transcription::resolve_language`. + #[serde(default)] + pub language: Option, } // ---- File-backed settings (consent/default-retention/storage policy) ---- @@ -54,6 +60,7 @@ fn default_settings() -> Settings { llm_advanced: serde_json::Value::Null, preferred_backend: "auto".into(), whisper_model: crate::paths::DEFAULT_WHISPER_MODEL.to_string(), + whisper_language: None, // auto-detect by default (T8.7, FR-TRX-4) low_overhead: false, default_record: false, consent_acknowledged: false, @@ -129,6 +136,7 @@ fn backend_for(settings: &Settings) -> BackendId { fn load_transcriber( backend: BackendId, whisper_model: &Path, + language: Option<&str>, ) -> Result<(Box, BackendId), crate::transcription::TrxError> { use crate::hardware::{resolve_accel, AccelPath}; @@ -146,7 +154,7 @@ fn load_transcriber( use crate::transcription::{onnx_models, OnnxTranscriber}; if onnx_models::is_installed(onnx_models::DEFAULT_ONNX_MODEL) { let dir = onnx_models::model_dir(onnx_models::DEFAULT_ONNX_MODEL); - match OnnxTranscriber::load(&dir, backend) { + match OnnxTranscriber::load(&dir, backend, language) { Ok(t) => return Ok((Box::new(t), backend)), Err(e) => tracing::warn!("ONNX engine load failed ({e}); falling back to CPU"), } @@ -164,17 +172,29 @@ fn load_transcriber( AccelPath::WhisperCuda | AccelPath::WhisperVulkan => backend, _ => BackendId::Cpu, }; - match WhisperTranscriber::load(whisper_model, whisper_backend) { + match WhisperTranscriber::load(whisper_model, whisper_backend, language) { Ok(t) => Ok((Box::new(t), whisper_backend)), Err(e) if whisper_backend != BackendId::Cpu => { tracing::warn!("backend {whisper_backend:?} failed to load ({e}); falling back to CPU"); - WhisperTranscriber::load(whisper_model, BackendId::Cpu) + WhisperTranscriber::load(whisper_model, BackendId::Cpu, language) .map(|t| (Box::new(t) as Box, BackendId::Cpu)) } Err(e) => Err(e), } } +/// Normalizes a requested language string the same way whisper.cpp itself +/// treats it: an empty string or the literal `"auto"` both mean "no explicit +/// language" — matching `FullParams::set_language`'s own `None`/`Some("auto")` +/// equivalence (T8.7, FR-TRX-4) — so callers can pass either spelling through +/// from settings/args without duplicating this check everywhere. +fn normalize_language(language: Option<&str>) -> Option<&str> { + match language { + Some(l) if !l.is_empty() && !l.eq_ignore_ascii_case("auto") => Some(l), + _ => None, + } +} + fn now_unix() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -276,6 +296,14 @@ pub async fn start_recording( ), )); } + // T8.7/FR-TRX-4: a per-meeting override wins over the Settings default; + // both spellings of "no explicit language" collapse to `None` here. + let language: Option = normalize_language( + args.language + .as_deref() + .or(settings.whisper_language.as_deref()), + ) + .map(|s| s.to_string()); let meeting_id = state .store @@ -285,6 +313,7 @@ pub async fn start_recording( .unwrap_or_else(|| "Untitled meeting".to_string()), calendar_event_id: args.calendar_event_id, template_id: args.template_id, + language: language.clone(), }) .await .map_err(|e| WaError::new("storage", e.to_string()))?; @@ -376,6 +405,14 @@ pub async fn start_recording( let segments_for_worker = segments.clone(); let active_backend: Arc> = Arc::new(StdMutex::new(backend)); let active_backend_for_worker = active_backend.clone(); + // T8.7/FR-TRX-4: starts at the requested value, then the worker refines + // it — first to what the loaded engine actually resolved (e.g. forced + // "en" for an English-only model), then to whatever was last + // used/detected once streaming stops. `stop_recording` reads the final + // value after joining this thread. + let language_state: Arc>> = Arc::new(StdMutex::new(language.clone())); + let language_state_for_worker = language_state.clone(); + let language_for_worker = language.clone(); let app_for_worker = app.clone(); let meeting_id_for_worker = meeting_id.clone(); let transcription_worker = std::thread::Builder::new() @@ -385,13 +422,14 @@ pub async fn start_recording( // fall-through (T3.4/T3.5, FR-HW-4): a GPU/NPU load failure (driver // issue, OOM, missing model) drops to CPU rather than losing the // meeting's transcript entirely. - let (transcriber, used) = match load_transcriber(backend, &model_path) { - Ok(pair) => pair, - Err(e) => { - tracing::error!("failed to load any transcriber: {e}"); - return; - } - }; + let (transcriber, used) = + match load_transcriber(backend, &model_path, language_for_worker.as_deref()) { + Ok(pair) => pair, + Err(e) => { + tracing::error!("failed to load any transcriber: {e}"); + return; + } + }; if used != backend { if let Ok(mut b) = active_backend_for_worker.lock() { *b = used; @@ -404,6 +442,9 @@ pub async fn start_recording( }), ); } + if let Ok(mut lang) = language_state_for_worker.lock() { + *lang = transcriber.effective_language(); + } run_streaming_worker(transcriber.as_ref(), frame_rx, |segment| { if let Ok(mut buf) = segments_for_worker.lock() { buf.push(segment.clone()); @@ -413,6 +454,14 @@ pub async fn start_recording( serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }), ); }); + // Prefer whatever the engine actually used/detected on its last + // decode over the load-time resolution above — meaningful for + // "auto" mode, where the real answer only exists after decoding. + if let Some(detected) = transcriber.detected_language() { + if let Ok(mut lang) = language_state_for_worker.lock() { + *lang = Some(detected); + } + } }) .map_err(|e| WaError::new("transcription", e.to_string()))?; @@ -500,6 +549,7 @@ pub async fn start_recording( segments, active_backend, model_id, + language: language_state, diarizer, speaker_names, }); @@ -586,6 +636,10 @@ pub async fn stop_recording( .map(|b| b.as_str().to_string()) .unwrap_or_else(|_| BackendId::Cpu.as_str().to_string()); + // T8.7/FR-TRX-4: whatever the transcription worker last resolved — + // explicit request, English-only forcing, or auto-detected result. + let language = session.language.lock().ok().and_then(|g| g.clone()); + // T2.10: persist transcript.json (via finalize_meeting) and notes.md // *before* touching the working WAV, so a crash here still leaves a // recoverable, regenerable meeting. @@ -598,7 +652,7 @@ pub async fn stop_recording( speakers: speakers.clone(), duration_secs: (summary.duration_ms / 1000) as i64, recorded: session.retention, - language: None, + language, backend_used: Some(backend_used), model_used: Some(session.model_id.clone()), }, @@ -1338,6 +1392,14 @@ pub async fn list_models() -> WaResult> { Ok(model_catalog::list(&model_id_for(&settings))) } +/// The Settings language dropdown's contents (T8.7, FR-TRX-4) — every +/// whisper.cpp-recognized ISO-639-1 code. "Auto-detect" isn't in this list; +/// the frontend prepends it (maps to omitting `language` at recording start). +#[tauri::command] +pub async fn list_whisper_languages() -> WaResult> { + Ok(crate::transcription::languages::list()) +} + /// The fixed segmentation+embedding pair (T4.7, FR-MODEL-1) — a separate /// command rather than folding into `list_models` because they're a fixed /// installable pair, not an interchangeable-size catalog like whisper's. @@ -1400,6 +1462,10 @@ pub async fn reprocess_transcript( state: State<'_, AppState>, meeting_id: MeetingId, model: String, + // T8.7/FR-TRX-4: `None` reuses whatever language the meeting was + // already recorded/reprocessed with, so re-transcribing with a bigger + // model doesn't silently drop a prior language selection. + language: Option, ) -> WaResult<()> { let model_path = whisper_model_file(&model); if !model_path.exists() { @@ -1416,24 +1482,33 @@ pub async fn reprocess_transcript( )); } + let meeting = state + .store + .get_meeting(&meeting_id) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + let settings = load_settings(); let backend = backend_for(&settings); - let segments = tauri::async_runtime::spawn_blocking({ + let requested_language = + normalize_language(language.as_deref().or(meeting.language.as_deref())) + .map(|s| s.to_string()); + let (segments, resolved_language) = tauri::async_runtime::spawn_blocking({ let wav_path = wav_path.clone(); move || { - let (transcriber, _used) = load_transcriber(backend, &model_path)?; - transcriber.transcribe_file(&wav_path) + let (transcriber, _used) = + load_transcriber(backend, &model_path, requested_language.as_deref())?; + let segments = transcriber.transcribe_file(&wav_path)?; + let resolved = transcriber + .detected_language() + .or_else(|| transcriber.effective_language()); + Ok::<_, crate::transcription::TrxError>((segments, resolved)) } }) .await .map_err(|e| WaError::new("transcription", e.to_string()))? .map_err(|e| WaError::new("transcription", e.to_string()))?; - let meeting = state - .store - .get_meeting(&meeting_id) - .await - .map_err(|e| WaError::new("storage", e.to_string()))?; let duration_secs = segments .last() .map(|s| (s.end_ms / 1000) as i64) @@ -1452,7 +1527,7 @@ pub async fn reprocess_transcript( speakers: meeting.speakers.clone(), duration_secs, recorded: meeting.recorded, - language: meeting.language, + language: resolved_language, backend_used: Some(backend.as_str().to_string()), model_used: Some(model), }, @@ -1504,15 +1579,31 @@ pub async fn resume_transcription( )); } + // T8.7/FR-TRX-4: `create_meeting` persisted the originally requested + // language immediately (not just at finalize), so a crash-recovered + // meeting can still honor it here rather than silently reverting to + // auto-detect. + let requested_language = state + .store + .get_meeting(&meeting_id) + .await + .ok() + .and_then(|m| m.language); + // Note: deliberately doesn't emit `recording://state` — that event drives // the main header's single live-session Record/Stop toggle, and reusing // it here would make the header show a phantom "Stop" button for a // recovery run that isn't a `RecordingSession` at all. The "recovering" // badge in the meetings list is this operation's own progress signal. - let segments = tauri::async_runtime::spawn_blocking(move || { - WhisperTranscriber::load(&model_path, BackendId::Cpu) - .and_then(|transcriber| transcriber.transcribe_file(&wav_path)) + let (segments, resolved_language) = tauri::async_runtime::spawn_blocking(move || { + let transcriber = + WhisperTranscriber::load(&model_path, BackendId::Cpu, requested_language.as_deref())?; + let segments = transcriber.transcribe_file(&wav_path)?; + let resolved = transcriber + .detected_language() + .or_else(|| transcriber.effective_language()); + Ok::<_, crate::transcription::TrxError>((segments, resolved)) }) .await .map_err(|e| WaError::new("transcription", e.to_string()))? @@ -1539,7 +1630,7 @@ pub async fn resume_transcription( // The working WAV surviving a crash is the only signal we have // left about intent; keep it rather than silently discard it. recorded: true, - language: None, + language: resolved_language, backend_used: Some(BackendId::Cpu.as_str().to_string()), model_used: Some(model_id), }, @@ -3950,6 +4041,7 @@ mod tests { title: "Test meeting".to_string(), calendar_event_id: None, template_id: None, + language: None, }) .await .unwrap(); From 4c00496c092445d17d56054b9fbddc34d3185a77 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:08:13 -0500 Subject: [PATCH 11/19] feat(ui): typed API surface for whisper language selection (T8.7, M4.2) --- src/lib/api.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 3b1eb68..6a3d732 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -69,6 +69,16 @@ export interface ModelInfo { size_mb: number; installed: boolean; active: boolean; + /** `false` for `.en` (English-only) ggml variants; `true` for multilingual + * ones — gates the Settings language picker (T8.7, FR-TRX-4, M4.2). */ + multilingual: boolean; +} + +// One selectable transcription language (T8.7, FR-TRX-4) — ISO-639-1 code +// as accepted by whisper.cpp, plus a display label. +export interface LanguageOption { + code: string; + label: string; } export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error"; @@ -152,6 +162,9 @@ export interface Meeting { duration_secs: number | null; status: MeetingStatus; recorded: boolean; + // Transcription language actually used/selected for this meeting (T8.7, + // FR-TRX-4) — an ISO-639-1 code, or null if never resolved (e.g. no audio + // was ever decoded). language: string | null; backend_used: string | null; model_used: string | null; @@ -284,6 +297,10 @@ export interface AppSettings { } | null; preferred_backend: string; whisper_model: string; + /** Default transcription language (T8.7, FR-TRX-4): `null` = auto-detect, + * an ISO-639-1 code forces that language. Only takes effect with a + * multilingual model — see `ModelInfo.multilingual`. */ + whisper_language: string | null; low_overhead: boolean; default_record: boolean; consent_acknowledged: boolean; @@ -345,14 +362,17 @@ export interface McpStatus { // ---- Commands ---- export const api = { // `record` controls audio RETENTION (default false / off — ADR-0009). + // `language` (T8.7, FR-TRX-4): omit/undefined falls back to + // Settings.whisper_language; "auto" or omitted both mean auto-detect. startRecording: ( meetingTitle?: string, calendarEventId?: string, record = false, templateId?: string, + language?: string, ) => invoke("start_recording", { - args: { meetingTitle, calendarEventId, record, templateId }, + args: { meetingTitle, calendarEventId, record, templateId, language }, }), listNoteTemplates: () => invoke("list_note_templates"), stopRecording: (meetingId: MeetingId) => invoke("stop_recording", { meetingId }), @@ -377,11 +397,16 @@ export const api = { downloadNpuPackage: () => invoke("download_npu_package"), downloadDirectmlPackage: () => invoke("download_directml_package"), listModels: () => invoke("list_models"), + // T8.7/FR-TRX-4: static catalog of whisper.cpp-recognized language codes + // for the Settings dropdown; "Auto-detect" is a frontend-only addition. + listWhisperLanguages: () => invoke("list_whisper_languages"), downloadModel: (id: string, kind: "whisper" = "whisper") => invoke("download_model", { args: { kind, id } }), removeModel: (id: string) => invoke("remove_model", { id }), - reprocessTranscript: (meetingId: MeetingId, model: string) => - invoke("reprocess_transcript", { meetingId, model }), + // `language` (T8.7): omit/undefined reuses whatever language the meeting + // already had rather than resetting it to auto. + reprocessTranscript: (meetingId: MeetingId, model: string, language?: string) => + invoke("reprocess_transcript", { meetingId, model, language }), resumeTranscription: (meetingId: MeetingId) => invoke("resume_transcription", { meetingId }), listMeetings: (filter?: MeetingFilter) => From 489271964fbde783f679d999d00d1807de3ad8ea Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:08:59 -0500 Subject: [PATCH 12/19] feat(ui): load language catalog + setWhisperLanguage in settings store (T8.7, M4.2) --- src/lib/stores/settings.svelte.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index cbb7d4c..a3809f4 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -14,6 +14,7 @@ import { type HardwareStatus, type LlmStatus, type ModelInfo, + type LanguageOption, type PrivacySelfCheck, type McpStatus, type McpAccessEntry, @@ -27,6 +28,7 @@ const DEFAULT_SETTINGS: AppSettings = { llm_model: "llama3", preferred_backend: "auto", whisper_model: "base.en-q5_1", + whisper_language: null, // auto-detect by default (T8.7, FR-TRX-4) low_overhead: false, default_record: false, // recording OFF by default (ADR-0009) consent_acknowledged: false, @@ -58,6 +60,8 @@ class SettingsStore { // Hardware + model management (Phase 3, T3.6/T3.7). hardware = $state(null); models = $state([]); + // Transcription language catalog for the Settings dropdown (T8.7, FR-TRX-4). + languages = $state([]); audioDevices = $state([]); inputDevices = $state([]); downloadProgress = $state>({}); @@ -96,6 +100,7 @@ class SettingsStore { await this.loadAudioDevices(); await this.loadInputDevices(); await this.loadModels(); + await this.loadLanguages(); await this.loadPrivacy(); await this.loadLlmStatus(); await this.loadMcpStatus(); @@ -193,6 +198,21 @@ class SettingsStore { } } + async loadLanguages() { + try { + this.languages = await api.listWhisperLanguages(); + } catch { + this.languages = []; + } + } + + /** `null` = auto-detect (T8.7, FR-TRX-4). Only meaningful when the active + * model is multilingual — the Settings UI disables/hides this control + * otherwise, and the backend forces "en" regardless if it's set anyway. */ + async setWhisperLanguage(code: string | null) { + await this.patch({ whisper_language: code }); + } + async loadPrivacy() { try { this.privacy = await api.privacySelfCheck(); From dc969df79215fceeb3c03991d804e17362c9ab21 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:09:21 -0500 Subject: [PATCH 13/19] feat(ui): pass configured whisper_language when starting a recording (T8.7, M4.2) --- src/lib/stores/recording.svelte.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/recording.svelte.ts b/src/lib/stores/recording.svelte.ts index 3a189ef..3e25070 100644 --- a/src/lib/stores/recording.svelte.ts +++ b/src/lib/stores/recording.svelte.ts @@ -2,6 +2,7 @@ // Subscribes to recording/transcript events and exposes reactive state. import { api, events, type TranscriptSegment, type MeetingId } from "../api"; +import { settings } from "./settings.svelte"; class RecordingStore { meetingId = $state(null); @@ -52,7 +53,10 @@ class RecordingStore { this.segments = []; this.retention = record; this.deviceNotice = null; - this.meetingId = await api.startRecording(title, undefined, record, templateId); + // T8.7/FR-TRX-4: whatever language is currently configured in Settings + // becomes this meeting's requested language, persisted on its record. + const language = settings.settings.whisper_language ?? undefined; + this.meetingId = await api.startRecording(title, undefined, record, templateId, language); this.state = "recording"; } From 2cdae9b20d89e1ad133f64ef60a602eb019690e2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:10:06 -0500 Subject: [PATCH 14/19] docs(api): document whisper language selection contract surface (T8.7, M4.2) --- docs/04-api-contracts.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index 68a43a3..b5d2634 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -15,7 +15,11 @@ each command returns `Result` where `WaError` carries a `kind` (mach // `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is // deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting. // templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below. -start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string }): MeetingId +// language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code +// (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted. +// Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only +// model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs). +start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef pause_recording(input: { meetingId: MeetingId }): void resume_recording(input: { meetingId: MeetingId }): void @@ -27,11 +31,17 @@ hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: stri set_preferred_backend(input: { backend: BackendId | "auto" }): void // ---- Transcription / models ---- -reprocess_transcript(input: { meetingId: MeetingId; model: string }): void // batch mode (FR-TRX-3) +// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it. +reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3) +// ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only) +// ggml variants, true for the multilingual ones; gates the Settings language picker. list_models(): ModelInfo[] list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1) download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs +// Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown +// (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list. +list_whisper_languages(): { code: string; label: string }[] // ---- Speakers ---- rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void @@ -106,6 +116,9 @@ run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "cop create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2 // ---- Settings ---- +// Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default +// transcription language applied at the next start_recording; null = auto-detect. Mirrors +// this doc's settings.json `transcription.language` (03-data-model.md). get_settings(): Settings update_settings(input: Partial): Settings // Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2). From e6e995efe07ce3dbfb5692755104e32f8f6ee033 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:11:36 -0500 Subject: [PATCH 15/19] feat(ui): Transcription Language picker in Settings > Hardware (T8.7, M4.2) --- src/lib/views/Settings.svelte | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 1015ba2..6d0a80e 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -796,6 +796,42 @@ {/each} + +

Transcription Language

+ {@const activeModel = settings.models.find((m) => m.active)} + {#if activeModel?.multilingual} + +

+ Applies to the next recording. The language actually used is shown on each meeting + afterward. +

+ {:else} + +

+ {activeModel + ? `"${activeModel.label}" is English-only.` + : "No model selected."} Switch to a multilingual model above to choose a language. +

+ {/if} {:else if section === "storage"}
@@ -1775,6 +1811,12 @@ border-radius: 5px; padding: 0.35rem; } + input:disabled, + select:disabled, + textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + } .artifacts { border: 1px solid var(--border); border-radius: 6px; From 7bff39271c29a551bd528a5d38f16f05e5de129d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:12:46 -0500 Subject: [PATCH 16/19] feat(ui): thread optional language override through meetings.reprocess (T8.7, M4.2) --- src/lib/stores/meetings.svelte.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/stores/meetings.svelte.ts b/src/lib/stores/meetings.svelte.ts index e2ea25d..06c099b 100644 --- a/src/lib/stores/meetings.svelte.ts +++ b/src/lib/stores/meetings.svelte.ts @@ -218,9 +218,11 @@ class MeetingsStore { if (this.selectedId === id) await this.select(id); } - /** Batch re-transcribe with a different (typically larger) model (T3.8). */ - async reprocess(id: MeetingId, model: string) { - await api.reprocessTranscript(id, model); + /** Batch re-transcribe with a different (typically larger) model (T3.8). + * `language` (T8.7, FR-TRX-4): omitted reuses the meeting's current + * language rather than resetting it to auto. */ + async reprocess(id: MeetingId, model: string, language?: string) { + await api.reprocessTranscript(id, model, language); await this.load(); if (this.selectedId === id) await this.select(id); } From 353d582e96613be6bd028ba666bff953d283dbff Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Tue, 7 Jul 2026 13:12:51 -0500 Subject: [PATCH 17/19] feat(ui): show meeting language badge + reprocess language picker (T8.7, M4.2) --- src/lib/views/TranscriptNotes.svelte | 43 ++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index 68fdc09..f44744f 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -29,6 +29,14 @@ return speakers.find((s) => s.label === label)?.display_name ?? label; } + // T8.7/FR-TRX-4: the meeting view shows the language actually used, not + // just the raw ISO code — falls back to the code itself if it's not in + // the (curated) catalog, and to "auto-detecting…" before any is known. + function languageLabel(code: string | null): string { + if (!code) return "auto-detecting…"; + return settings.languages.find((l) => l.code === code)?.label ?? code; + } + let notesText = $state(""); // Notes is a single pane: raw markdown ("Editor") or the rendered result // ("Preview"), toggled by one button whose label flips to the other mode. @@ -130,13 +138,16 @@ } let reprocessModel = $state(""); + // T8.7/FR-TRX-4: "" reuses the meeting's current language (backend default + // when `language` is omitted) rather than resetting it to auto. + let reprocessLanguage = $state(""); let reprocessing = $state(false); async function reprocess() { const m = meetings.selected; if (!m || !reprocessModel) return; reprocessing = true; try { - await meetings.reprocess(m.id, reprocessModel); + await meetings.reprocess(m.id, reprocessModel, reprocessLanguage || undefined); } finally { reprocessing = false; } @@ -154,8 +165,13 @@ />
-

+

+

{#if m.recorded && settings.models.some((mo) => mo.installed)} + {@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
+ {#if reprocessModelInfo?.multilingual} + + {/if}