Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "echo-app",
"private": true,
"version": "1.1.5",
"version": "1.1.6",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "echo"
version = "1.1.5"
version = "1.1.6"
description = "Echo"
authors = ["Sovern"]
edition = "2021"
Expand Down
98 changes: 98 additions & 0 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,28 @@ fn get_active_window_title() -> Option<String> {
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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -992,3 +1039,54 @@ pub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = 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<String> {
Ok("ПЕРЕВОД".to_string())
}
}
struct ErrTranslator;
impl Translator for ErrTranslator {
fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result<String> {
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),
" "
);
}
}
5 changes: 5 additions & 0 deletions src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,

/// 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<String>,
}
122 changes: 106 additions & 16 deletions src-tauri/src/cli_transcription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub fn run_cli_transcription(
format: Option<&str>,
diarize: bool,
speaker_hint: Option<usize>,
translate: Option<&str>,
) -> Result<()> {
println!("[*] Starting CLI transcription...");
println!("[*] Input: {}", input.display());
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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<crate::translate::Lang>,
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<String> {
Ok("TRANSLATED".to_string())
}
}
struct ErrTranslator;
impl Translator for ErrTranslator {
fn translate(&self, _text: &str, _target: Lang) -> anyhow::Result<String> {
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, "привет мир");
}
}
Loading
Loading