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/src/actions.rs b/src-tauri/src/actions.rs
index 927a497..f01c95e 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,31 @@ 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 +1039,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/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)
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e4dcfa4..2a3b0cd 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;
@@ -348,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,
@@ -560,6 +563,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 +578,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..1d80591 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,26 @@ 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/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs
index 47377e2..a80b1d0 100644
--- a/src-tauri/src/shortcut/mod.rs
+++ b/src-tauri/src/shortcut/mod.rs
@@ -525,6 +525,32 @@ 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. 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,
+ target: crate::translate::Lang,
+) -> Result<(), String> {
+ 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> {
diff --git a/src-tauri/src/transcript_format.rs b/src-tauri/src/transcript_format.rs
index f673755..4c0bf18 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,22 @@ 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-tauri/src/translate/lang.rs b/src-tauri/src/translate/lang.rs
new file mode 100644
index 0000000..6c89446
--- /dev/null
+++ b/src-tauri/src/translate/lang.rs
@@ -0,0 +1,262 @@
+#[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 && (
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) =>
diff --git a/src/stores/transcribeStore.ts b/src/stores/transcribeStore.ts
index dad6bff..4368975 100644
--- a/src/stores/transcribeStore.ts
+++ b/src/stores/transcribeStore.ts
@@ -20,6 +20,8 @@ interface TranscribeState {
diarize: boolean;
speakers: string;
outputPath: string | null;
+ // Optional offline-translation target (ISO code like "en"/"ru"); null = no translation.
+ translateTarget: string | null;
// Run state (persist so a long transcription stays visible after navigating
// away and back — the component used to hold this locally and lost it on
@@ -37,6 +39,7 @@ interface TranscribeState {
setDiarize: (b: boolean) => 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 });