From ad727c9265ddd970ca24acfbf109a9cbce3eb6f8 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 01:30:15 -0500 Subject: [PATCH 1/9] feat(translate): offline Hy-MT translation backend + CLI Core translate module (Lang enum for the 33 Hy-MT languages, pinned XX->XX prompt builder, Translator trait + ServerTranslator over llm_client/Ollama with a safe sync/async bridge), persisted settings (translate_enabled/target/model/base_url, serde-defaulted), graceful non-blocking translation in the dictation pipeline, and a --translate flag that translates file transcripts. All unit/integration tests green (mock HTTP server for the client path). Tasks 1-2,4 drafted by Codex(gpt-5.5)/Gemini under audit; Tasks 3,5,6,7 by Opus. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/actions.rs | 99 ++++++++++ src-tauri/src/cli.rs | 5 + src-tauri/src/cli_transcription.rs | 122 ++++++++++-- src-tauri/src/lib.rs | 3 + src-tauri/src/settings.rs | 54 ++++++ src-tauri/src/translate/lang.rs | 268 ++++++++++++++++++++++++++ src-tauri/src/translate/mod.rs | 6 + src-tauri/src/translate/prompt.rs | 23 +++ src-tauri/src/translate/translator.rs | 140 ++++++++++++++ 9 files changed, 704 insertions(+), 16 deletions(-) create mode 100644 src-tauri/src/translate/lang.rs create mode 100644 src-tauri/src/translate/mod.rs create mode 100644 src-tauri/src/translate/prompt.rs create mode 100644 src-tauri/src/translate/translator.rs diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 927a497..4f8e9b5 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -396,6 +396,28 @@ fn get_active_window_title() -> Option { None } +/// Optionally translate `text` into `target`. Translation is strictly additive and +/// must NEVER break dictation: on any translator error (server down, timeout, empty +/// result) the ORIGINAL text is returned, so the user always gets their words — +/// untranslated at worst, never lost. +fn maybe_translate( + text: &str, + enabled: bool, + target: crate::translate::Lang, + translator: &dyn crate::translate::Translator, +) -> String { + if !enabled || text.trim().is_empty() { + return text.to_string(); + } + match translator.translate(text, target) { + Ok(translated) => translated, + Err(e) => { + warn!("Translation failed; pasting original dictation verbatim: {e:#}"); + text.to_string() + } + } +} + pub(crate) async fn process_transcription_output( app: &AppHandle, transcription: &str, @@ -560,6 +582,32 @@ pub(crate) async fn process_transcription_output( } } + // Optional translation: speak in one language, paste another. Runs after + // post-processing but BEFORE snippet expansion so canned snippets (URLs, + // signatures) stay verbatim. Offloaded to a blocking thread so it never stalls + // the async pipeline, and graceful on failure (returns the original text). + if settings.translate_enabled { + let target = settings.translate_target; + let translator = crate::translate::ServerTranslator { + provider: crate::settings::PostProcessProvider { + id: "translate-local".to_string(), + label: "Translate".to_string(), + base_url: settings.translate_base_url.clone(), + allow_base_url_edit: false, + models_endpoint: None, + supports_structured_output: false, + }, + model: settings.translate_model.clone(), + api_key: String::new(), + }; + let src = final_text.clone(); + final_text = tokio::task::spawn_blocking(move || { + maybe_translate(&src, true, target, &translator) + }) + .await + .unwrap_or(final_text); + } + // Snippet expansion is the final transform so canned text (URLs, signatures) // is inserted verbatim and never reflowed by the LLM or the capitalizer. if !settings.snippets.is_empty() { @@ -992,3 +1040,54 @@ pub static ACTION_MAP: Lazy>> = Lazy::ne ); map }); + +#[cfg(test)] +mod translate_tests { + use super::*; + use crate::translate::{Lang, Translator}; + + struct OkTranslator; + impl Translator for OkTranslator { + fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result { + Ok("ПЕРЕВОД".to_string()) + } + } + struct ErrTranslator; + impl Translator for ErrTranslator { + fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result { + Err(anyhow::anyhow!("server down")) + } + } + + #[test] + fn translates_when_enabled() { + assert_eq!( + maybe_translate("hello", true, Lang::Russian, &OkTranslator), + "ПЕРЕВОД" + ); + } + + #[test] + fn passthrough_when_disabled() { + assert_eq!( + maybe_translate("hello", false, Lang::Russian, &OkTranslator), + "hello" + ); + } + + #[test] + fn graceful_on_translator_error_returns_original() { + assert_eq!( + maybe_translate("hello", true, Lang::Russian, &ErrTranslator), + "hello" + ); + } + + #[test] + fn empty_text_is_passthrough() { + assert_eq!( + maybe_translate(" ", true, Lang::Russian, &OkTranslator), + " " + ); + } +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index b7459cc..f62df87 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -58,4 +58,9 @@ pub struct CliArgs { /// Optional known speaker count hint for diarization (else auto-detected). #[arg(long, value_name = "N")] pub speakers: Option, + + /// Translate the transcript into a target language offline (Hy-MT) and emit the + /// translated plain text. Value is a language code like "en"/"ru"/"uk". + #[arg(long, value_name = "LANG")] + pub translate: Option, } diff --git a/src-tauri/src/cli_transcription.rs b/src-tauri/src/cli_transcription.rs index fbd074e..78dcb2a 100644 --- a/src-tauri/src/cli_transcription.rs +++ b/src-tauri/src/cli_transcription.rs @@ -17,6 +17,7 @@ pub fn run_cli_transcription( format: Option<&str>, diarize: bool, speaker_hint: Option, + translate: Option<&str>, ) -> Result<()> { println!("[*] Starting CLI transcription..."); println!("[*] Input: {}", input.display()); @@ -44,6 +45,15 @@ is auto-detected); the value is ignored." .unwrap_or(OutputFormat::Plain), }; + // Resolve an optional translation target up front so a bad code fails fast, + // before the (slow) transcription runs. + let translate_target = match translate { + Some(code) => Some(crate::translate::Lang::from_code(code).with_context(|| { + format!("Unknown --translate '{code}'. Use a language code like en|ru|uk.") + })?), + None => None, + }; + let want_words = diarize || fmt.is_word_level(); let details = transcribe_file_detailed( app_handle, @@ -54,24 +64,44 @@ is auto-detected); the value is ignored." speaker_hint, want_words, )?; - let rendered = match fmt { - OutputFormat::Json if details.words.is_some() => { - crate::transcript_format::render_word_json( - details.words.as_deref().unwrap(), + let rendered = if let Some(target) = translate_target { + // Translation v1: translate the plain transcript prose and emit that. + // (Per-segment translation that preserves timestamps/speaker markers is + // future work; translating a timecoded render would garble the markers.) + let settings = crate::settings::get_settings(app_handle); + let translator = crate::translate::ServerTranslator { + provider: crate::settings::PostProcessProvider { + id: "translate-local".to_string(), + label: "Translate".to_string(), + base_url: settings.translate_base_url.clone(), + allow_base_url_edit: false, + models_endpoint: None, + supports_structured_output: false, + }, + model: settings.translate_model.clone(), + api_key: String::new(), + }; + maybe_translate_transcript(&details.text, Some(target), &translator) + } else { + match fmt { + OutputFormat::Json if details.words.is_some() => { + crate::transcript_format::render_word_json( + details.words.as_deref().unwrap(), + details.speakers.as_deref(), + ) + } + OutputFormat::Karaoke => crate::transcript_format::render_karaoke( + details.words.as_deref().unwrap_or(&[]), details.speakers.as_deref(), - ) + ), + _ => crate::transcript_format::render( + &details.text, + &details.segments, + details.words.as_deref(), + details.speakers.as_deref(), + fmt, + ), } - OutputFormat::Karaoke => crate::transcript_format::render_karaoke( - details.words.as_deref().unwrap_or(&[]), - details.speakers.as_deref(), - ), - _ => crate::transcript_format::render( - &details.text, - &details.segments, - details.words.as_deref(), - details.speakers.as_deref(), - fmt, - ), }; if let Some(out_path) = output { @@ -85,3 +115,63 @@ is auto-detected); the value is ignored." Ok(()) } + +/// Translate the transcript prose into `target`, gracefully. On any error the +/// ORIGINAL transcript is returned with a logged "translation skipped" note — +/// translation is additive and must never lose the transcript. +fn maybe_translate_transcript( + text: &str, + target: Option, + translator: &dyn crate::translate::Translator, +) -> String { + let Some(target) = target else { + return text.to_string(); + }; + if text.trim().is_empty() { + return text.to_string(); + } + match translator.translate(text, target) { + Ok(t) => t, + Err(e) => { + eprintln!("[!] translation skipped: {e:#}"); + text.to_string() + } + } +} + +#[cfg(test)] +mod translate_transcript_tests { + use super::*; + use crate::translate::{Lang, Translator}; + + struct OkTranslator; + impl Translator for OkTranslator { + fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result { + Ok("TRANSLATED".to_string()) + } + } + struct ErrTranslator; + impl Translator for ErrTranslator { + fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result { + Err(anyhow::anyhow!("server down")) + } + } + + #[test] + fn translates_when_target_set() { + let out = maybe_translate_transcript("привет мир", Some(Lang::English), &OkTranslator); + assert_eq!(out, "TRANSLATED"); + } + + #[test] + fn passthrough_when_no_target() { + let out = maybe_translate_transcript("привет мир", None, &OkTranslator); + assert_eq!(out, "привет мир"); + } + + #[test] + fn graceful_returns_original_on_error() { + let out = maybe_translate_transcript("привет мир", Some(Lang::English), &ErrTranslator); + assert_eq!(out, "привет мир"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4dcfa4..a817cd9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ mod settings; mod shortcut; mod transcript_format; mod transcription_coordinator; +mod translate; mod utils; mod voice_commands; @@ -560,6 +561,7 @@ pub fn run(cli_args: CliArgs) { let format = cli_args.format.clone(); let diarize = cli_args.diarize; let speakers = cli_args.speakers; + let translate = cli_args.translate.clone(); // Initialize core logic before starting transcription initialize_core_logic(&app_handle); @@ -574,6 +576,7 @@ pub fn run(cli_args: CliArgs) { format.as_deref(), diarize, speakers, + translate.as_deref(), ) { eprintln!("[!] CLI Transcription failed: {}", e); std::process::exit(1); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 08920bd..ed80636 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -420,6 +420,14 @@ pub struct AppSettings { pub post_process_prompts: Vec, #[serde(default)] pub post_process_selected_prompt_id: Option, + #[serde(default = "default_translate_enabled")] + pub translate_enabled: bool, + #[serde(default = "default_translate_target")] + pub translate_target: crate::translate::Lang, + #[serde(default = "default_translate_model")] + pub translate_model: String, + #[serde(default = "default_translate_base_url")] + pub translate_base_url: String, #[serde(default)] pub mute_while_recording: bool, #[serde(default)] @@ -524,6 +532,22 @@ fn default_translate_to_english() -> bool { false } +fn default_translate_enabled() -> bool { + false +} + +fn default_translate_target() -> crate::translate::Lang { + crate::translate::Lang::English +} + +fn default_translate_model() -> String { + "hy-mt1.5".to_string() +} + +fn default_translate_base_url() -> String { + "http://127.0.0.1:11434/v1".to_string() +} + fn default_start_hidden() -> bool { false } @@ -913,6 +937,10 @@ pub fn get_default_settings() -> AppSettings { post_process_models: default_post_process_models(), post_process_prompts: default_post_process_prompts(), post_process_selected_prompt_id: None, + translate_enabled: default_translate_enabled(), + translate_target: default_translate_target(), + translate_model: default_translate_model(), + translate_base_url: default_translate_base_url(), mute_while_recording: false, append_trailing_space: false, app_language: default_app_language(), @@ -1131,3 +1159,29 @@ mod tests { assert!(out.contains("[REDACTED]")); } } + +#[cfg(test)] +mod translate_settings_tests { + use super::*; + + #[test] + fn missing_translate_keys_use_defaults() { + let mut value = + serde_json::to_value(get_default_settings()).expect("default settings serialize"); + let object = value.as_object_mut().expect("settings serialize as object"); + object.remove("translate_enabled"); + object.remove("translate_target"); + object.remove("translate_model"); + object.remove("translate_base_url"); + + let settings: AppSettings = + serde_json::from_value(value).expect("translate defaults fill missing fields"); + assert!(!settings.translate_enabled); + assert_eq!(settings.translate_target, crate::translate::Lang::English); + assert_eq!(settings.translate_model, "hy-mt1.5"); + assert_eq!( + settings.translate_base_url, + "http://127.0.0.1:11434/v1" + ); + } +} diff --git a/src-tauri/src/translate/lang.rs b/src-tauri/src/translate/lang.rs new file mode 100644 index 0000000..fdfbdb0 --- /dev/null +++ b/src-tauri/src/translate/lang.rs @@ -0,0 +1,268 @@ +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + specta::Type, +)] +pub enum Lang { + Chinese, + English, + French, + Portuguese, + Spanish, + Japanese, + Turkish, + Russian, + Arabic, + Korean, + Thai, + Italian, + German, + Vietnamese, + Malay, + Indonesian, + Filipino, + Hindi, + TraditionalChinese, + Polish, + Czech, + Dutch, + Khmer, + Burmese, + Persian, + Gujarati, + Urdu, + Telugu, + Marathi, + Hebrew, + Bengali, + Tamil, + Ukrainian, + Tibetan, + Kazakh, + Mongolian, + Uyghur, + Cantonese, +} + +impl Lang { + pub fn from_code(code: &str) -> Option { + match code.to_lowercase().as_str() { + "zh" => Some(Lang::Chinese), + "en" => Some(Lang::English), + "fr" => Some(Lang::French), + "pt" => Some(Lang::Portuguese), + "es" => Some(Lang::Spanish), + "ja" => Some(Lang::Japanese), + "tr" => Some(Lang::Turkish), + "ru" => Some(Lang::Russian), + "ar" => Some(Lang::Arabic), + "ko" => Some(Lang::Korean), + "th" => Some(Lang::Thai), + "it" => Some(Lang::Italian), + "de" => Some(Lang::German), + "vi" => Some(Lang::Vietnamese), + "ms" => Some(Lang::Malay), + "id" => Some(Lang::Indonesian), + "tl" => Some(Lang::Filipino), + "hi" => Some(Lang::Hindi), + "zh-hant" => Some(Lang::TraditionalChinese), + "pl" => Some(Lang::Polish), + "cs" => Some(Lang::Czech), + "nl" => Some(Lang::Dutch), + "km" => Some(Lang::Khmer), + "my" => Some(Lang::Burmese), + "fa" => Some(Lang::Persian), + "gu" => Some(Lang::Gujarati), + "ur" => Some(Lang::Urdu), + "te" => Some(Lang::Telugu), + "mr" => Some(Lang::Marathi), + "he" => Some(Lang::Hebrew), + "bn" => Some(Lang::Bengali), + "ta" => Some(Lang::Tamil), + "uk" => Some(Lang::Ukrainian), + "bo" => Some(Lang::Tibetan), + "kk" => Some(Lang::Kazakh), + "mn" => Some(Lang::Mongolian), + "ug" => Some(Lang::Uyghur), + "yue" => Some(Lang::Cantonese), + _ => None, + } + } + + pub fn from_display_name(name: &str) -> Option { + let name_lower = name.to_lowercase(); + Lang::all() + .iter() + .find(|l| l.display_name().to_lowercase() == name_lower) + .copied() + } + + pub fn all() -> &'static [Lang] { + &[ + Lang::Chinese, + Lang::English, + Lang::French, + Lang::Portuguese, + Lang::Spanish, + Lang::Japanese, + Lang::Turkish, + Lang::Russian, + Lang::Arabic, + Lang::Korean, + Lang::Thai, + Lang::Italian, + Lang::German, + Lang::Vietnamese, + Lang::Malay, + Lang::Indonesian, + Lang::Filipino, + Lang::Hindi, + Lang::TraditionalChinese, + Lang::Polish, + Lang::Czech, + Lang::Dutch, + Lang::Khmer, + Lang::Burmese, + Lang::Persian, + Lang::Gujarati, + Lang::Urdu, + Lang::Telugu, + Lang::Marathi, + Lang::Hebrew, + Lang::Bengali, + Lang::Tamil, + Lang::Ukrainian, + Lang::Tibetan, + Lang::Kazakh, + Lang::Mongolian, + Lang::Uyghur, + Lang::Cantonese, + ] + } + + pub fn code(&self) -> &'static str { + match self { + Lang::Chinese => "zh", + Lang::English => "en", + Lang::French => "fr", + Lang::Portuguese => "pt", + Lang::Spanish => "es", + Lang::Japanese => "ja", + Lang::Turkish => "tr", + Lang::Russian => "ru", + Lang::Arabic => "ar", + Lang::Korean => "ko", + Lang::Thai => "th", + Lang::Italian => "it", + Lang::German => "de", + Lang::Vietnamese => "vi", + Lang::Malay => "ms", + Lang::Indonesian => "id", + Lang::Filipino => "tl", + Lang::Hindi => "hi", + Lang::TraditionalChinese => "zh-Hant", + Lang::Polish => "pl", + Lang::Czech => "cs", + Lang::Dutch => "nl", + Lang::Khmer => "km", + Lang::Burmese => "my", + Lang::Persian => "fa", + Lang::Gujarati => "gu", + Lang::Urdu => "ur", + Lang::Telugu => "te", + Lang::Marathi => "mr", + Lang::Hebrew => "he", + Lang::Bengali => "bn", + Lang::Tamil => "ta", + Lang::Ukrainian => "uk", + Lang::Tibetan => "bo", + Lang::Kazakh => "kk", + Lang::Mongolian => "mn", + Lang::Uyghur => "ug", + Lang::Cantonese => "yue", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Lang::Chinese => "Chinese", + Lang::English => "English", + Lang::French => "French", + Lang::Portuguese => "Portuguese", + Lang::Spanish => "Spanish", + Lang::Japanese => "Japanese", + Lang::Turkish => "Turkish", + Lang::Russian => "Russian", + Lang::Arabic => "Arabic", + Lang::Korean => "Korean", + Lang::Thai => "Thai", + Lang::Italian => "Italian", + Lang::German => "German", + Lang::Vietnamese => "Vietnamese", + Lang::Malay => "Malay", + Lang::Indonesian => "Indonesian", + Lang::Filipino => "Filipino", + Lang::Hindi => "Hindi", + Lang::TraditionalChinese => "Traditional Chinese", + Lang::Polish => "Polish", + Lang::Czech => "Czech", + Lang::Dutch => "Dutch", + Lang::Khmer => "Khmer", + Lang::Burmese => "Burmese", + Lang::Persian => "Persian", + Lang::Gujarati => "Gujarati", + Lang::Urdu => "Urdu", + Lang::Telugu => "Telugu", + Lang::Marathi => "Marathi", + Lang::Hebrew => "Hebrew", + Lang::Bengali => "Bengali", + Lang::Tamil => "Tamil", + Lang::Ukrainian => "Ukrainian", + Lang::Tibetan => "Tibetan", + Lang::Kazakh => "Kazakh", + Lang::Mongolian => "Mongolian", + Lang::Uyghur => "Uyghur", + Lang::Cantonese => "Cantonese", + } + } +} + +impl std::fmt::Display for Lang { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display_name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_and_names() { + assert_eq!(Lang::from_code("ru"), Some(Lang::Russian)); + assert_eq!(Lang::from_code("EN"), Some(Lang::English)); // case-insensitive + assert_eq!(Lang::Russian.code(), "ru"); + assert_eq!(Lang::Russian.display_name(), "Russian"); + assert_eq!(Lang::from_code("xx"), None); + } + + #[test] + fn display_names_and_all() { + assert_eq!(Lang::from_display_name("Russian"), Some(Lang::Russian)); + assert_eq!(Lang::from_display_name("chinese"), Some(Lang::Chinese)); + assert_eq!(Lang::from_display_name("Traditional Chinese"), Some(Lang::TraditionalChinese)); + assert_eq!(Lang::from_display_name("Unknown"), None); + + assert_eq!(format!("{}", Lang::Russian), "Russian"); + + let all = Lang::all(); + assert!(all.contains(&Lang::English)); + assert!(all.contains(&Lang::Russian)); + assert_eq!(all.len(), 38); // Current count of variants + } +} diff --git a/src-tauri/src/translate/mod.rs b/src-tauri/src/translate/mod.rs new file mode 100644 index 0000000..b0da610 --- /dev/null +++ b/src-tauri/src/translate/mod.rs @@ -0,0 +1,6 @@ +pub mod lang; +pub mod prompt; +pub mod translator; + +pub use lang::Lang; +pub use translator::{ServerTranslator, Translator}; diff --git a/src-tauri/src/translate/prompt.rs b/src-tauri/src/translate/prompt.rs new file mode 100644 index 0000000..a1b2572 --- /dev/null +++ b/src-tauri/src/translate/prompt.rs @@ -0,0 +1,23 @@ +use super::lang::Lang; + +pub fn build_prompt(text: &str, target: Lang) -> String { + format!( + "Translate the following segment into {}, without additional explanation.\n\n{}", + target.display_name(), + text + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::translate::lang::Lang; + #[test] + fn builds_xx_prompt() { + let p = build_prompt("Привет", Lang::English); + assert_eq!( + p, + "Translate the following segment into English, without additional explanation.\n\nПривет" + ); + } +} diff --git a/src-tauri/src/translate/translator.rs b/src-tauri/src/translate/translator.rs new file mode 100644 index 0000000..13e2371 --- /dev/null +++ b/src-tauri/src/translate/translator.rs @@ -0,0 +1,140 @@ +use super::lang::Lang; +use super::prompt::build_prompt; +use crate::settings::PostProcessProvider; + +/// Runtime-agnostic translation seam. Callers depend only on this trait, so the +/// backend (currently a local Ollama / llama-server over `llm_client`) can change +/// without touching the dictation / file / CLI call sites. +/// +/// The method is intentionally **synchronous**: the file-transcription pipeline is +/// sync and calls it directly, while the async dictation pipeline wraps it in +/// `tokio::task::spawn_blocking` so it never stalls the async executor. +pub trait Translator: Send + Sync { + /// Translate `text` into `target`. Source language is auto-detected by the model. + fn translate(&self, text: &str, target: Lang) -> anyhow::Result; +} + +/// `Translator` backed by an OpenAI-compatible chat server (Ollama serving Hy-MT1.5 +/// on `127.0.0.1:11434/v1`). Reuses the existing `llm_client`. Sampling params +/// (temperature/top_p/top_k/repeat_penalty) are baked into the Ollama Modelfile, +/// so they are applied server-side and need not be threaded through here. +pub struct ServerTranslator { + pub provider: PostProcessProvider, + pub model: String, + pub api_key: String, +} + +impl Translator for ServerTranslator { + fn translate(&self, text: &str, target: Lang) -> anyhow::Result { + let prompt = build_prompt(text, target); + let provider = self.provider.clone(); + let api_key = self.api_key.clone(); + let model = self.model.clone(); + + // Drive the async client on a dedicated thread owning a fresh current-thread + // runtime. `Runtime::block_on` panics if called from within an existing tokio + // runtime worker, so we isolate it on its own thread to be safe regardless of + // whether the caller is sync (file path) or already async (dictation path, + // which additionally wraps this in spawn_blocking). + let handle = std::thread::spawn(move || -> Result, String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(crate::llm_client::send_chat_completion( + &provider, api_key, &model, prompt, None, None, + )) + }); + + let out = handle + .join() + .map_err(|_| anyhow::anyhow!("translation thread panicked"))? + .map_err(|e| anyhow::anyhow!(e))?; + + out.ok_or_else(|| anyhow::anyhow!("empty translation")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Minimal one-shot HTTP server: accepts a single connection, drains the + /// request, and returns a canned OpenAI chat-completion JSON. Runs on its own + /// thread (the translator makes a real blocking HTTP call to it). + fn spawn_mock_server(body_content: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Read the request headers (enough to not RST the client). + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let json = format!( + r#"{{"choices":[{{"message":{{"role":"assistant","content":"{}"}}}}]}}"#, + body_content + ); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + json.len(), + json + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + format!("http://{}", addr) + } + + fn provider(base_url: String) -> PostProcessProvider { + PostProcessProvider { + id: "test".into(), + label: "test".into(), + base_url, + allow_base_url_edit: false, + models_endpoint: None, + supports_structured_output: false, + } + } + + #[test] + fn posts_prompt_and_returns_content() { + let base = spawn_mock_server("Hello"); + let t = ServerTranslator { + provider: provider(base), + model: "hy-mt1.5".into(), + api_key: String::new(), + }; + let out = t.translate("Привет", Lang::English).unwrap(); + assert_eq!(out, "Hello"); + } + + #[test] + fn empty_content_is_error() { + // Server returns a body with no choices content -> translate errors out + // (graceful-degradation is handled by callers, not here). + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let json = r#"{"choices":[]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + json.len(), + json + ); + let _ = stream.write_all(resp.as_bytes()); + } + }); + let t = ServerTranslator { + provider: provider(format!("http://{}", addr)), + model: "hy-mt1.5".into(), + api_key: String::new(), + }; + assert!(t.translate("Привет", Lang::English).is_err()); + } +} From 5d02c170b9920c0f5a5686816bfd056c9bc853e4 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 01:30:34 -0500 Subject: [PATCH 2/9] feat(transcribe): default transcript output to Markdown (.md) from_extension maps md|markdown -> Plain (so -o foo.md infers plain); GUI pickOutput/save default the untimestamped extension txt->md. Ships with Translate per spec. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/transcript_format.rs | 11 +++++++++++ src/components/settings/transcribe/TranscribeFile.tsx | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/transcript_format.rs b/src-tauri/src/transcript_format.rs index f673755..6a77449 100644 --- a/src-tauri/src/transcript_format.rs +++ b/src-tauri/src/transcript_format.rs @@ -61,6 +61,7 @@ impl OutputFormat { "vtt" => Some(Self::Vtt), "json" => Some(Self::Json), "txt" | "text" => Some(Self::Plain), + "md" | "markdown" => Some(Self::Plain), _ => None, } } @@ -564,6 +565,16 @@ pub fn render_speaker_blocks( mod tests { use super::*; + #[test] + fn from_extension_md_is_plain() { + assert_eq!(OutputFormat::from_extension("md"), Some(OutputFormat::Plain)); + assert_eq!( + OutputFormat::from_extension("markdown"), + Some(OutputFormat::Plain) + ); + assert_eq!(OutputFormat::from_extension("txt"), Some(OutputFormat::Plain)); + } + fn seg(start: f32, end: f32, text: &str) -> TimedSegment { TimedSegment { start, diff --git a/src/components/settings/transcribe/TranscribeFile.tsx b/src/components/settings/transcribe/TranscribeFile.tsx index 06b4b67..88fee22 100644 --- a/src/components/settings/transcribe/TranscribeFile.tsx +++ b/src/components/settings/transcribe/TranscribeFile.tsx @@ -57,7 +57,7 @@ export const TranscribeFile: FC = () => { const pickOutput = async () => { const { save: saveDialog } = await import("@tauri-apps/plugin-dialog"); - const ext = timestamps ? format : "txt"; + const ext = timestamps ? format : "md"; const base = path ? path .replace(/\.[^/.]+$/, "") @@ -72,7 +72,7 @@ export const TranscribeFile: FC = () => { const { save: saveDialog } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const target = await saveDialog({ - defaultPath: `transcript.${timestamps ? format : "txt"}`, + defaultPath: `transcript.${timestamps ? format : "md"}`, }); if (typeof target === "string") await writeTextFile(target, result); }; From f99abf25c54a245775d42875d253ea147a273ceb Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 09:54:56 -0500 Subject: [PATCH 3/9] feat(i18n): add transcribe.translate + translateNone to all 20 locales Strings for the upcoming Translate UI (target-language label + 'no translation' option). Generated by Gemini under audit; check-translations passes 19/19. Co-Authored-By: Claude Opus 4.8 --- src/i18n/locales/ar/translation.json | 4 +++- src/i18n/locales/bg/translation.json | 4 +++- src/i18n/locales/cs/translation.json | 4 +++- src/i18n/locales/de/translation.json | 4 +++- src/i18n/locales/en/translation.json | 4 +++- src/i18n/locales/es/translation.json | 4 +++- src/i18n/locales/fr/translation.json | 4 +++- src/i18n/locales/he/translation.json | 4 +++- src/i18n/locales/it/translation.json | 4 +++- src/i18n/locales/ja/translation.json | 4 +++- src/i18n/locales/ko/translation.json | 4 +++- src/i18n/locales/pl/translation.json | 4 +++- src/i18n/locales/pt/translation.json | 4 +++- src/i18n/locales/ru/translation.json | 4 +++- src/i18n/locales/sv/translation.json | 4 +++- src/i18n/locales/tr/translation.json | 4 +++- src/i18n/locales/uk/translation.json | 4 +++- src/i18n/locales/vi/translation.json | 4 +++- src/i18n/locales/zh-TW/translation.json | 4 +++- src/i18n/locales/zh/translation.json | 4 +++- 20 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 7525805..2147818 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "ترجمة إلى", + "translateNone": "بدون ترجمة" } }, "footer": { diff --git a/src/i18n/locales/bg/translation.json b/src/i18n/locales/bg/translation.json index 5e8867c..9c7c82a 100644 --- a/src/i18n/locales/bg/translation.json +++ b/src/i18n/locales/bg/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Превод на", + "translateNone": "Без превод" } }, "footer": { diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index 298d09a..9ef2d85 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Přeložit do", + "translateNone": "Bez překladu" } }, "footer": { diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 79cc528..2e7f642 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Übersetzen in", + "translateNone": "Keine Übersetzung" } }, "footer": { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index b22b12d..a528f59 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Translate to", + "translateNone": "No translation" } }, "footer": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 240eb13..b431ba5 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Traducir a", + "translateNone": "Sin traducción" } }, "footer": { diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 4aef8fc..0c0f5e3 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Traduire en", + "translateNone": "Pas de traduction" } }, "footer": { diff --git a/src/i18n/locales/he/translation.json b/src/i18n/locales/he/translation.json index 1689d86..6cd6ed7 100644 --- a/src/i18n/locales/he/translation.json +++ b/src/i18n/locales/he/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "תרגום ל", + "translateNone": "ללא תרגום" } }, "footer": { diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index ea8351e..2b1fe91 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Traduci in", + "translateNone": "Nessuna traduzione" } }, "footer": { diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 98e7dae..32a0c0b 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "翻訳先", + "translateNone": "翻訳なし" } }, "footer": { diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index a62cef6..86fbf7b 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "번역 대상", + "translateNone": "번역 없음" } }, "footer": { diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 9db0a40..d50f118 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Przetłumacz na", + "translateNone": "Brak tłumaczenia" } }, "footer": { diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 64453fc..3e257a5 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Traduzir para", + "translateNone": "Sem tradução" } }, "footer": { diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index a6132bd..93d25ba 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -651,7 +651,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Диаризация работает на CPU и медленная для длинных файлов — а запуск в приложении может прерваться при переключении вкладок. Для надёжного результата со спикерами используйте CLI:\necho --transcribe-file \"ФАЙЛ\" -o \"ВЫХОД.txt\" --diarize --format speaker" + "diarizeCliNote": "Диаризация работает на CPU и медленная для длинных файлов — а запуск в приложении может прерваться при переключении вкладок. Для надёжного результата со спикерами используйте CLI:\necho --transcribe-file \"ФАЙЛ\" -o \"ВЫХОД.txt\" --diarize --format speaker", + "translate": "Перевести на", + "translateNone": "Без перевода" }, "capture": { "captureSuccess": "Saved to capture folder", diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json index e1a9f18..150f2cf 100644 --- a/src/i18n/locales/sv/translation.json +++ b/src/i18n/locales/sv/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Översätt till", + "translateNone": "Ingen översättning" } }, "footer": { diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 0c25363..27e9b4d 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Şuna çevir", + "translateNone": "Çeviri yok" } }, "footer": { diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 4525581..d735ae4 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Перекласти на", + "translateNone": "Без перекладу" } }, "footer": { diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index af02826..77297f2 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "Dịch sang", + "translateNone": "Không dịch" } }, "footer": { diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 918dfc1..4a97d4b 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "翻譯為", + "translateNone": "不翻譯" } }, "footer": { diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 2878541..90aa8b8 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -661,7 +661,9 @@ }, "saveTo": "Save to…", "savedTo": "Saved to {{path}}", - "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker" + "diarizeCliNote": "Diarization runs on CPU and is slow for long files — and an in-app run can stop if you switch tabs. For reliable speaker-labelled output, use the CLI:\necho --transcribe-file \"FILE\" -o \"OUT.txt\" --diarize --format speaker", + "translate": "翻译为", + "translateNone": "不翻译" } }, "footer": { From 30b89c4976b76d64b8de92bca2be967f485473ee Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 10:01:38 -0500 Subject: [PATCH 4/9] feat(translate): add translate param to transcribe_file_to_string command GUI file-transcription command now accepts an optional target language code and translates the transcript prose (graceful fallback to original). Rust compiles; bindings.ts must be regenerated via 'npm run tauri dev' (debug export at lib.rs:468) to surface the new param + Settings fields to the frontend. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/commands/transcribe.rs | 86 ++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands/transcribe.rs b/src-tauri/src/commands/transcribe.rs index eb5cb35..1450004 100644 --- a/src-tauri/src/commands/transcribe.rs +++ b/src-tauri/src/commands/transcribe.rs @@ -1,10 +1,13 @@ use crate::file_transcription::transcribe_file_detailed; use crate::transcript_format::OutputFormat; +use crate::translate::Translator; use std::path::PathBuf; use tauri::AppHandle; /// Transcribe a file from the GUI and return the rendered string. /// `format` is one of: plain|inline|srt|vtt|json. +/// `translate` is an optional target language code (e.g. "en"/"ru"); when set, the +/// transcript prose is translated (offline, Hy-MT) and returned as plain text. #[tauri::command] #[specta::specta] pub async fn transcribe_file_to_string( @@ -15,14 +18,22 @@ pub async fn transcribe_file_to_string( diarize: bool, speaker_hint: Option, format: String, + translate: Option, ) -> Result { let fmt = OutputFormat::from_cli(&format).ok_or_else(|| format!("Unknown format '{format}'"))?; + let translate_target = match translate { + Some(code) => Some( + crate::translate::Lang::from_code(&code) + .ok_or_else(|| format!("Unknown translate language '{code}'"))?, + ), + None => None, + }; let input = PathBuf::from(path); let want_words = diarize || fmt.is_word_level(); - // Run the blocking pipeline off the async runtime thread. - let details = tauri::async_runtime::spawn_blocking(move || { - transcribe_file_detailed( + // Run the blocking pipeline (and optional translation) off the async runtime thread. + let body = tauri::async_runtime::spawn_blocking(move || -> Result { + let details = transcribe_file_detailed( &app_handle, &input, language.as_deref(), @@ -31,30 +42,55 @@ pub async fn transcribe_file_to_string( speaker_hint.map(|n| n as usize), want_words, ) - }) - .await - .map_err(|e| format!("task join error: {e}"))? - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string())?; - let body = match fmt { - OutputFormat::Json if details.words.is_some() => { - crate::transcript_format::render_word_json( - details.words.as_deref().unwrap(), - details.speakers.as_deref(), - ) + // Translation v1: translate the plain transcript prose and return that. + // Graceful — on any translator error the original transcript is returned. + if let Some(target) = translate_target { + let settings = crate::settings::get_settings(&app_handle); + let translator = crate::translate::ServerTranslator { + provider: crate::settings::PostProcessProvider { + id: "translate-local".to_string(), + label: "Translate".to_string(), + base_url: settings.translate_base_url.clone(), + allow_base_url_edit: false, + models_endpoint: None, + supports_structured_output: false, + }, + model: settings.translate_model.clone(), + api_key: String::new(), + }; + return Ok(match translator.translate(&details.text, target) { + Ok(t) => t, + Err(e) => { + log::warn!("File translation skipped; returning original: {e:#}"); + details.text.clone() + } + }); } - OutputFormat::Karaoke => crate::transcript_format::render_karaoke( - details.words.as_deref().unwrap_or(&[]), - details.speakers.as_deref(), - ), - _ => crate::transcript_format::render( - &details.text, - &details.segments, - details.words.as_deref(), - details.speakers.as_deref(), - fmt, - ), - }; + + Ok(match fmt { + OutputFormat::Json if details.words.is_some() => { + crate::transcript_format::render_word_json( + details.words.as_deref().unwrap(), + details.speakers.as_deref(), + ) + } + OutputFormat::Karaoke => crate::transcript_format::render_karaoke( + details.words.as_deref().unwrap_or(&[]), + details.speakers.as_deref(), + ), + _ => crate::transcript_format::render( + &details.text, + &details.segments, + details.words.as_deref(), + details.speakers.as_deref(), + fmt, + ), + }) + }) + .await + .map_err(|e| format!("task join error: {e}"))??; Ok(body) } From 28982d6cd85cfcd2d18305d1445111731d7c7bf4 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 10:21:58 -0500 Subject: [PATCH 5/9] feat(translate): settings commands for dictation translate toggle + target change_translate_enabled_setting / change_translate_target_setting (ISO-code parsed), registered in collect_commands. Surfaces to the frontend after bindings regen so the dictation settings panel can toggle offline translation + pick the target language. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 2 ++ src-tauri/src/shortcut/mod.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a817cd9..2a3b0cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -349,6 +349,8 @@ pub fn run(cli_args: CliArgs) { shortcut::change_start_hidden_setting, shortcut::change_autostart_setting, shortcut::change_translate_to_english_setting, + shortcut::change_translate_enabled_setting, + shortcut::change_translate_target_setting, shortcut::change_selected_language_setting, shortcut::change_overlay_position_setting, shortcut::change_debug_mode_setting, diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 47377e2..dc59eef 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -525,6 +525,29 @@ pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Res Ok(()) } +/// Offline Hy-MT translation (separate from the Whisper translate-to-English above): +/// toggle whether dictation output is translated into `translate_target`. +#[tauri::command] +#[specta::specta] +pub fn change_translate_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + settings.translate_enabled = enabled; + settings::write_settings(&app, settings); + Ok(()) +} + +/// Set the dictation translation target language by ISO code (e.g. "en"/"ru"/"uk"). +#[tauri::command] +#[specta::specta] +pub fn change_translate_target_setting(app: AppHandle, lang: String) -> Result<(), String> { + let target = crate::translate::Lang::from_code(&lang) + .ok_or_else(|| format!("Unknown translate language '{lang}'"))?; + let mut settings = settings::get_settings(&app); + settings.translate_target = target; + settings::write_settings(&app, settings); + Ok(()) +} + #[tauri::command] #[specta::specta] pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> { From 28c740a9341398e90d753451110a785bef2753f6 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 10:28:29 -0500 Subject: [PATCH 6/9] feat(translate): regen bindings + Transcribe File language picker Regenerated src/bindings.ts (translate param + Settings translate fields + change_translate_* commands). Added a target-language setTranslateTarget(e.target.value || null)} + className="text-sm bg-transparent border border-slate-700 rounded px-2 py-1" + > + + {TRANSLATE_LANGS.map(([code, name]) => ( + + ))} + + + {busy && progress && (
void; setSpeakers: (s: string) => void; setOutputPath: (p: string | null) => void; + setTranslateTarget: (c: string | null) => void; run: () => Promise; cancel: () => Promise; @@ -49,6 +52,7 @@ export const useTranscribeStore = create((set, get) => ({ diarize: false, speakers: "", outputPath: null, + translateTarget: null, busy: false, result: "", @@ -63,10 +67,19 @@ export const useTranscribeStore = create((set, get) => ({ setDiarize: (b) => set({ diarize: b }), setSpeakers: (s) => set({ speakers: s }), setOutputPath: (p) => set({ outputPath: p }), + setTranslateTarget: (c) => set({ translateTarget: c }), run: async () => { - const { path, timestamps, format, diarize, speakers, outputPath, busy } = - get(); + const { + path, + timestamps, + format, + diarize, + speakers, + outputPath, + translateTarget, + busy, + } = get(); if (!path || busy) return; set({ @@ -103,6 +116,7 @@ export const useTranscribeStore = create((set, get) => ({ diarize, Number.isFinite(hint) ? hint : null, effectiveFormat, + translateTarget, ); if (res.status === "ok") { set({ result: res.data }); From 997caeedd2bc9106b9757a1e514e892bd90deb52 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 10:37:25 -0500 Subject: [PATCH 7/9] feat(translate): dictation translate dropdown in Advanced settings change_translate_target_setting now takes Lang directly (round-trips through the generic settings update path); settingsStore routes translate_enabled/translate_target. New TranslateDictation dropdown (No translation = off; pick a language = on + target) in the Transcription settings group. Regenerated bindings. tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/shortcut/mod.rs | 11 +-- src/bindings.ts | 8 ++- .../settings/advanced/AdvancedSettings.tsx | 2 + .../settings/advanced/TranslateDictation.tsx | 72 +++++++++++++++++++ src/stores/settingsStore.ts | 5 ++ 5 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 src/components/settings/advanced/TranslateDictation.tsx diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index dc59eef..a80b1d0 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -536,12 +536,15 @@ pub fn change_translate_enabled_setting(app: AppHandle, enabled: bool) -> Result Ok(()) } -/// Set the dictation translation target language by ISO code (e.g. "en"/"ru"/"uk"). +/// Set the dictation translation target language. Takes a `Lang` directly (the +/// frontend `Settings.translate_target` is a `Lang`), so it round-trips through the +/// generic settings update path without ISO-code reparsing. #[tauri::command] #[specta::specta] -pub fn change_translate_target_setting(app: AppHandle, lang: String) -> Result<(), String> { - let target = crate::translate::Lang::from_code(&lang) - .ok_or_else(|| format!("Unknown translate language '{lang}'"))?; +pub fn change_translate_target_setting( + app: AppHandle, + target: crate::translate::Lang, +) -> Result<(), String> { let mut settings = settings::get_settings(&app); settings.translate_target = target; settings::write_settings(&app, settings); diff --git a/src/bindings.ts b/src/bindings.ts index 6878d6f..9a48ae6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -90,11 +90,13 @@ async changeTranslateEnabledSetting(enabled: boolean) : Promise> { +async changeTranslateTargetSetting(target: Lang) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("change_translate_target_setting", { lang }) }; + return { status: "ok", data: await TAURI_INVOKE("change_translate_target_setting", { target }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; diff --git a/src/components/settings/advanced/AdvancedSettings.tsx b/src/components/settings/advanced/AdvancedSettings.tsx index 624352e..1405702 100644 --- a/src/components/settings/advanced/AdvancedSettings.tsx +++ b/src/components/settings/advanced/AdvancedSettings.tsx @@ -29,6 +29,7 @@ import { SubtitleRefreshMs } from "./SubtitleRefreshMs"; import { CommandMode } from "./CommandMode"; import { Snippets } from "./Snippets"; import { SelfCorrection } from "./SelfCorrection"; +import { TranslateDictation } from "./TranslateDictation"; import { SpokenLists } from "./SpokenLists"; import { DevDictionary } from "./DevDictionary"; import { CaptureFolder } from "./CaptureFolder"; @@ -65,6 +66,7 @@ export const AdvancedSettings: React.FC = () => { + diff --git a/src/components/settings/advanced/TranslateDictation.tsx b/src/components/settings/advanced/TranslateDictation.tsx new file mode 100644 index 0000000..fe49008 --- /dev/null +++ b/src/components/settings/advanced/TranslateDictation.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "../../../hooks/useSettings"; +import type { Lang } from "@/bindings"; + +// Offline Hy-MT dictation translation. A single dropdown doubles as the on/off +// switch: "No translation" disables it; picking a language enables it and sets the +// target. Reuses the existing transcribe.translate / translateNone i18n keys. +const LANGS: Lang[] = [ + "English", + "Russian", + "Ukrainian", + "Chinese", + "Spanish", + "French", + "German", + "Italian", + "Portuguese", + "Japanese", + "Korean", + "Arabic", + "Turkish", + "Vietnamese", + "Polish", + "Czech", + "Dutch", + "Hindi", + "Persian", + "Hebrew", + "Thai", + "Indonesian", +]; + +export const TranslateDictation: React.FC<{ grouped?: boolean }> = React.memo( + () => { + const { t } = useTranslation(); + const { getSetting, updateSetting } = useSettings(); + + const enabled = getSetting("translate_enabled") ?? false; + const target = getSetting("translate_target") ?? "English"; + const value = enabled ? target : ""; + + const onChange = async (v: string) => { + if (!v) { + await updateSetting("translate_enabled", false); + } else { + await updateSetting("translate_target", v as Lang); + await updateSetting("translate_enabled", true); + } + }; + + return ( + + ); + }, +); + +TranslateDictation.displayName = "TranslateDictation"; diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index d8ae057..d7be09d 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -8,6 +8,7 @@ import type { OrtAcceleratorSetting, SubtitleFontSize, Snippet, + Lang, } from "@/bindings"; import { commands } from "@/bindings"; @@ -111,6 +112,10 @@ const settingUpdaters: { commands.updateRecordingRetentionPeriod(value as string), translate_to_english: (value) => commands.changeTranslateToEnglishSetting(value as boolean), + translate_enabled: (value) => + commands.changeTranslateEnabledSetting(value as boolean), + translate_target: (value) => + commands.changeTranslateTargetSetting(value as Lang), selected_language: (value) => commands.changeSelectedLanguageSetting(value as string), overlay_position: (value) => From 33d7e87391a51a45061a154920c76eda52699ad4 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 10:55:07 -0500 Subject: [PATCH 8/9] chore(release): bump version 1.1.5 -> 1.1.6 Offline Hy-MT translation (live dictation + file transcripts + CLI --translate), Markdown default output. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index fe930e9..47c45f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "echo-app", "private": true, - "version": "1.1.5", + "version": "1.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fe033bf..bd3df31 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1595,7 +1595,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "echo" -version = "1.1.5" +version = "1.1.6" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b58634b..d4a82c8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "echo" -version = "1.1.5" +version = "1.1.6" description = "Echo" authors = ["Sovern"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7c4548c..52e8c0b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Echo", - "version": "1.1.5", + "version": "1.1.6", "identifier": "com.sovern.echo", "build": { "beforeDevCommand": "npm run dev", From 14dbc035e08245168f960ca0d78212fb8db13f20 Mon Sep 17 00:00:00 2001 From: master5d Date: Thu, 4 Jun 2026 11:59:13 -0500 Subject: [PATCH 9/9] style(translate): cargo fmt Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/actions.rs | 9 ++++----- src-tauri/src/settings.rs | 5 +---- src-tauri/src/transcript_format.rs | 10 ++++++++-- src-tauri/src/translate/lang.rs | 16 +++++----------- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 4f8e9b5..f01c95e 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -601,11 +601,10 @@ pub(crate) async fn process_transcription_output( api_key: String::new(), }; let src = final_text.clone(); - final_text = tokio::task::spawn_blocking(move || { - maybe_translate(&src, true, target, &translator) - }) - .await - .unwrap_or(final_text); + final_text = + tokio::task::spawn_blocking(move || maybe_translate(&src, true, target, &translator)) + .await + .unwrap_or(final_text); } // Snippet expansion is the final transform so canned text (URLs, signatures) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ed80636..1d80591 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1179,9 +1179,6 @@ mod translate_settings_tests { assert!(!settings.translate_enabled); assert_eq!(settings.translate_target, crate::translate::Lang::English); assert_eq!(settings.translate_model, "hy-mt1.5"); - assert_eq!( - settings.translate_base_url, - "http://127.0.0.1:11434/v1" - ); + assert_eq!(settings.translate_base_url, "http://127.0.0.1:11434/v1"); } } diff --git a/src-tauri/src/transcript_format.rs b/src-tauri/src/transcript_format.rs index 6a77449..4c0bf18 100644 --- a/src-tauri/src/transcript_format.rs +++ b/src-tauri/src/transcript_format.rs @@ -567,12 +567,18 @@ mod tests { #[test] fn from_extension_md_is_plain() { - assert_eq!(OutputFormat::from_extension("md"), Some(OutputFormat::Plain)); + assert_eq!( + OutputFormat::from_extension("md"), + Some(OutputFormat::Plain) + ); assert_eq!( OutputFormat::from_extension("markdown"), Some(OutputFormat::Plain) ); - assert_eq!(OutputFormat::from_extension("txt"), Some(OutputFormat::Plain)); + assert_eq!( + OutputFormat::from_extension("txt"), + Some(OutputFormat::Plain) + ); } fn seg(start: f32, end: f32, text: &str) -> TimedSegment { diff --git a/src-tauri/src/translate/lang.rs b/src-tauri/src/translate/lang.rs index fdfbdb0..6c89446 100644 --- a/src-tauri/src/translate/lang.rs +++ b/src-tauri/src/translate/lang.rs @@ -1,13 +1,4 @@ -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - serde::Serialize, - serde::Deserialize, - specta::Type, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] pub enum Lang { Chinese, English, @@ -255,7 +246,10 @@ mod tests { fn display_names_and_all() { assert_eq!(Lang::from_display_name("Russian"), Some(Lang::Russian)); assert_eq!(Lang::from_display_name("chinese"), Some(Lang::Chinese)); - assert_eq!(Lang::from_display_name("Traditional Chinese"), Some(Lang::TraditionalChinese)); + assert_eq!( + Lang::from_display_name("Traditional Chinese"), + Some(Lang::TraditionalChinese) + ); assert_eq!(Lang::from_display_name("Unknown"), None); assert_eq!(format!("{}", Lang::Russian), "Russian");