Merge branch 'm4-2-work' into feature_chore_bug_005

This commit is contained in:
iamdoubz committed 2026-07-07 13:33:14 -05:00
commit 4e33e7d84c
17 files changed
+811 -43

No files matched your search

+15 -2
View File
@@ -15,7 +15,11 @@ each command returns `Result<T, WaError>` 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>): Settings
// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2).
+116 -24
View File
@@ -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<String>,
/// 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<String>,
}
// ---- 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<dyn Transcriber>, 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<dyn Transcriber>, 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<String> = 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<StdMutex<BackendId>> = 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<StdMutex<Option<String>>> = 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<Vec<ModelInfo>> {
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<Vec<LanguageOption>> {
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<String>,
) -> 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();
+3
View File
@@ -52,6 +52,9 @@ pub fn list() -> Vec<ModelInfo> {
// 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()
}
+8
View File
@@ -63,6 +63,13 @@ pub struct RecordingSession {
/// can pick a different model than `Settings.whisper_model`).
pub active_backend: Arc<StdMutex<models::BackendId>>,
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<StdMutex<Option<String>>>,
/// `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).
@@ -211,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,
+22
View File
@@ -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<String>,
pub low_overhead: bool,
// Recording retention (ADR-0009). Default OFF.
pub default_record: bool,
+66 -2
View File
@@ -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<String>,
/// 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<String>,
}
/// `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();
+166
View File
@@ -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<LanguageOption> {
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"));
}
}
+172 -3
View File
@@ -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<TranscriptSegment>;
pub trait Transcriber: Send + Sync {
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError>
/// `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<Self, TrxError>
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<Vec<TranscriptSegment>, 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<String> {
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<String> {
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<String> {
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<String>,
/// 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<Option<String>>,
}
#[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<Self, TrxError> {
fn load(model: &Path, backend: BackendId, language: Option<&str>) -> Result<Self, TrxError> {
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<String> {
self.configured_language.clone()
}
fn detected_language(&self) -> Option<String> {
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::<Vec<_>>()
.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"
);
}
}
+77
View File
@@ -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<ModelInfo> {
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);
}
}
+21 -3
View File
@@ -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<Self, TrxError> {
///
/// `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<Self, TrxError> {
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");
@@ -54,6 +54,7 @@ async fn attach_meeting_to_event_and_map_speaker_to_participant_round_trip() {
title: "Test meeting".to_string(),
calendar_event_id: None,
template_id: None,
language: None,
})
.await
.unwrap();
+28 -3
View File
@@ -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<MeetingId>("start_recording", {
args: { meetingTitle, calendarEventId, record, templateId },
args: { meetingTitle, calendarEventId, record, templateId, language },
}),
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
@@ -377,11 +397,16 @@ export const api = {
downloadNpuPackage: () => invoke<void>("download_npu_package"),
downloadDirectmlPackage: () => invoke<void>("download_directml_package"),
listModels: () => invoke<ModelInfo[]>("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<LanguageOption[]>("list_whisper_languages"),
downloadModel: (id: string, kind: "whisper" = "whisper") =>
invoke<void>("download_model", { args: { kind, id } }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
reprocessTranscript: (meetingId: MeetingId, model: string) =>
invoke<void>("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<void>("reprocess_transcript", { meetingId, model, language }),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (filter?: MeetingFilter) =>
+5 -3
View File
@@ -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);
}
+5 -1
View File
@@ -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<MeetingId | null>(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";
}
+20
View File
@@ -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<HardwareStatus | null>(null);
models = $state<ModelInfo[]>([]);
// Transcription language catalog for the Settings dropdown (T8.7, FR-TRX-4).
languages = $state<LanguageOption[]>([]);
audioDevices = $state<AudioDeviceInfo[]>([]);
inputDevices = $state<AudioDeviceInfo[]>([]);
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
@@ -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();
+45
View File
@@ -47,6 +47,10 @@
"recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about"
>("recording");
// The currently active whisper model (T8.7, FR-TRX-4) — gates the
// language picker below (multilingual model required).
let activeModel = $derived(settings.models.find((m) => m.active));
// ---- About (version + build commit + source) ----
const SOURCE_URL = "https://git.dou.bet/iamdoubz/WhispAssist";
let appInfo = $state<{ version: string; commit: string } | null>(null);
@@ -796,6 +800,41 @@
</li>
{/each}
</ul>
<h4>Transcription Language</h4>
{#if activeModel?.multilingual}
<label
>Language
<select
value={settings.settings.whisper_language ?? ""}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
settings.setWhisperLanguage(v || null);
}}
>
<option value="">Auto-detect</option>
{#each settings.languages as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
</label>
<p class="muted">
Applies to the next recording. The language actually used is shown on each meeting
afterward.
</p>
{:else}
<label
>Language
<select disabled aria-describedby="language-disabled-hint">
<option>English only</option>
</select>
</label>
<p class="muted" id="language-disabled-hint">
{activeModel
? `"${activeModel.label}" is English-only.`
: "No model selected."} Switch to a multilingual model above to choose a language.
</p>
{/if}
</section>
{:else if section === "storage"}
<section>
@@ -1775,6 +1814,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;
+41 -2
View File
@@ -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 @@
/>
<div class="split">
<div class="pane transcript">
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
<h4>
<MessageSquareText size={14} aria-hidden="true" /> Transcript
<span class="badge lang" title="Transcription language">{languageLabel(m.language)}</span
>
</h4>
{#if m.recorded && settings.models.some((mo) => mo.installed)}
{@const reprocessModelInfo = settings.models.find((mo) => mo.id === reprocessModel)}
<div class="reprocess">
<select bind:value={reprocessModel}>
<option value="">Re-transcribe with…</option>
@@ -163,6 +179,15 @@
<option value={mo.id}>{mo.label}</option>
{/each}
</select>
{#if reprocessModelInfo?.multilingual}
<select bind:value={reprocessLanguage} aria-label="Reprocess language">
<option value="">Keep current language</option>
<option value="auto">Auto-detect</option>
{#each settings.languages as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
{/if}
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
{reprocessing ? "Re-transcribing…" : "Go"}
@@ -328,6 +353,20 @@
letter-spacing: 0.04em;
color: var(--muted);
}
/* Transcription language (T8.7, FR-TRX-4) — a quiet pill, not a status
color, since "which language" isn't a good/bad state to flag. */
.badge.lang {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.1rem 0.45rem;
}
.reprocess {
display: flex;
gap: 0.4rem;