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
)}
+
+
{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");