From 17277cf6ce6afe538a98a2a7b6db4cd17adfbc57 Mon Sep 17 00:00:00 2001 From: Fero Date: Sun, 22 Mar 2026 08:43:02 +0100 Subject: [PATCH] Save recordings before transcription (#1024) * save recordings before transcription * impart cj preferences * cleanup * re-transcribe anything * format * fix tests * format * ui cleanup * format * cleanup * bunch of fixes, mainly for vm * put comments back * update transcription failure text * alert -> toast * spawn_blocking --------- Co-authored-by: CJ Pais --- src-tauri/src/actions.rs | 227 +++++++++----- src-tauri/src/audio_toolkit/audio/mod.rs | 2 +- src-tauri/src/audio_toolkit/audio/utils.rs | 28 +- src-tauri/src/audio_toolkit/mod.rs | 4 +- src-tauri/src/commands/history.rs | 53 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/managers/history.rs | 287 ++++++++++++------ src-tauri/src/managers/transcription.rs | 7 + src-tauri/src/tray.rs | 18 +- src/bindings.ts | 12 +- .../settings/history/HistorySettings.tsx | 130 ++++++-- src/i18n/locales/ar/translation.json | 6 +- src/i18n/locales/cs/translation.json | 6 +- src/i18n/locales/de/translation.json | 6 +- src/i18n/locales/en/translation.json | 6 +- src/i18n/locales/es/translation.json | 6 +- src/i18n/locales/fr/translation.json | 6 +- src/i18n/locales/it/translation.json | 6 +- src/i18n/locales/ja/translation.json | 6 +- src/i18n/locales/ko/translation.json | 6 +- src/i18n/locales/pl/translation.json | 6 +- src/i18n/locales/pt/translation.json | 6 +- src/i18n/locales/ru/translation.json | 6 +- src/i18n/locales/tr/translation.json | 6 +- src/i18n/locales/uk/translation.json | 6 +- src/i18n/locales/vi/translation.json | 6 +- src/i18n/locales/zh-TW/translation.json | 6 +- src/i18n/locales/zh/translation.json | 6 +- 28 files changed, 644 insertions(+), 227 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 510b3d7..76d12df 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -315,6 +315,52 @@ async fn maybe_convert_chinese_variant( } } +pub(crate) struct ProcessedTranscription { + pub final_text: String, + pub post_processed_text: Option, + pub post_process_prompt: Option, +} + +pub(crate) async fn process_transcription_output( + app: &AppHandle, + transcription: &str, + post_process: bool, +) -> ProcessedTranscription { + let settings = get_settings(app); + let mut final_text = transcription.to_string(); + let mut post_processed_text: Option = None; + let mut post_process_prompt: Option = None; + + if let Some(converted_text) = maybe_convert_chinese_variant(&settings, transcription).await { + final_text = converted_text; + } + + if post_process { + if let Some(processed_text) = post_process_transcription(&settings, &final_text).await { + post_processed_text = Some(processed_text.clone()); + final_text = processed_text; + + if let Some(prompt_id) = &settings.post_process_selected_prompt_id { + if let Some(prompt) = settings + .post_process_prompts + .iter() + .find(|prompt| &prompt.id == prompt_id) + { + post_process_prompt = Some(prompt.prompt.clone()); + } + } + } + } else if final_text != transcription { + post_processed_text = Some(final_text.clone()); + } + + ProcessedTranscription { + final_text, + post_processed_text, + post_process_prompt, + } +} + impl ShortcutAction for TranscribeAction { fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { let start_time = Instant::now(); @@ -435,7 +481,6 @@ impl ShortcutAction for TranscribeAction { tauri::async_runtime::spawn(async move { let _guard = FinishGuard(ah.clone()); - let binding_id = binding_id.clone(); // Clone for the inner async task debug!( "Starting async transcription task for binding: {}", binding_id @@ -449,104 +494,120 @@ impl ShortcutAction for TranscribeAction { samples.len() ); - let transcription_time = Instant::now(); - let samples_clone = samples.clone(); // Clone for history saving - match tm.transcribe(samples) { - Ok(transcription) => { - debug!( - "Transcription completed in {:?}: '{}'", - transcription_time.elapsed(), - transcription - ); - if !transcription.is_empty() { - let settings = get_settings(&ah); - let mut final_text = transcription.clone(); - let mut post_processed_text: Option = None; - let mut post_process_prompt: Option = None; + if samples.is_empty() { + debug!("Recording produced no audio samples; skipping persistence"); + utils::hide_recording_overlay(&ah); + change_tray_icon(&ah, TrayIconState::Idle); + } else { + // Save WAV concurrently with transcription + let sample_count = samples.len(); + let file_name = format!("handy-{}.wav", chrono::Utc::now().timestamp()); + let wav_path = hm.recordings_dir().join(&file_name); + let wav_path_for_verify = wav_path.clone(); + let samples_for_wav = samples.clone(); + let wav_handle = tauri::async_runtime::spawn_blocking(move || { + crate::audio_toolkit::save_wav_file(&wav_path, &samples_for_wav) + }); - // First, check if Chinese variant conversion is needed - if let Some(converted_text) = - maybe_convert_chinese_variant(&settings, &transcription).await - { - final_text = converted_text; + // Transcribe concurrently with WAV save + let transcription_time = Instant::now(); + let transcription_result = tm.transcribe(samples); + + // Await WAV save and verify + let wav_saved = match wav_handle.await { + Ok(Ok(())) => { + match crate::audio_toolkit::verify_wav_file( + &wav_path_for_verify, + sample_count, + ) { + Ok(()) => true, + Err(e) => { + error!("WAV verification failed: {}", e); + false + } } + } + Ok(Err(e)) => { + error!("Failed to save WAV file: {}", e); + false + } + Err(e) => { + error!("WAV save task panicked: {}", e); + false + } + }; + + match transcription_result { + Ok(transcription) => { + debug!( + "Transcription completed in {:?}: '{}'", + transcription_time.elapsed(), + transcription + ); - // Then apply LLM post-processing if this is the post-process hotkey - // Uses final_text which may already have Chinese conversion applied if post_process { show_processing_overlay(&ah); } - let processed = if post_process { - post_process_transcription(&settings, &final_text).await - } else { - None - }; - if let Some(processed_text) = processed { - post_processed_text = Some(processed_text.clone()); - final_text = processed_text; + let processed = + process_transcription_output(&ah, &transcription, post_process) + .await; - // Get the prompt that was used - if let Some(prompt_id) = &settings.post_process_selected_prompt_id { - if let Some(prompt) = settings - .post_process_prompts - .iter() - .find(|p| &p.id == prompt_id) - { - post_process_prompt = Some(prompt.prompt.clone()); - } + // Save to history if WAV was saved + if wav_saved { + if let Err(err) = hm.save_entry( + file_name, + transcription, + post_process, + processed.post_processed_text.clone(), + processed.post_process_prompt.clone(), + ) { + error!("Failed to save history entry: {}", err); } - } else if final_text != transcription { - // Chinese conversion was applied but no LLM post-processing - post_processed_text = Some(final_text.clone()); } - // Save to history with post-processed text and prompt - let hm_clone = Arc::clone(&hm); - let transcription_for_history = transcription.clone(); - tauri::async_runtime::spawn(async move { - if let Err(e) = hm_clone - .save_transcription( - samples_clone, - transcription_for_history, - post_processed_text, - post_process_prompt, - ) - .await - { - error!("Failed to save transcription to history: {}", e); - } - }); - - // Paste the final text (either processed or original) - let ah_clone = ah.clone(); - let paste_time = Instant::now(); - ah.run_on_main_thread(move || { - match utils::paste(final_text, ah_clone.clone()) { - Ok(()) => debug!( - "Text pasted successfully in {:?}", - paste_time.elapsed() - ), - Err(e) => error!("Failed to paste transcription: {}", e), - } - // Hide the overlay after transcription is complete - utils::hide_recording_overlay(&ah_clone); - change_tray_icon(&ah_clone, TrayIconState::Idle); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); + if processed.final_text.is_empty() { utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); - }); - } else { + } else { + let ah_clone = ah.clone(); + let paste_time = Instant::now(); + let final_text = processed.final_text; + ah.run_on_main_thread(move || { + match utils::paste(final_text, ah_clone.clone()) { + Ok(()) => debug!( + "Text pasted successfully in {:?}", + paste_time.elapsed() + ), + Err(e) => error!("Failed to paste transcription: {}", e), + } + utils::hide_recording_overlay(&ah_clone); + change_tray_icon(&ah_clone, TrayIconState::Idle); + }) + .unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + utils::hide_recording_overlay(&ah); + change_tray_icon(&ah, TrayIconState::Idle); + }); + } + } + Err(err) => { + debug!("Global Shortcut Transcription error: {}", err); + // Save entry with empty text so user can retry + if wav_saved { + if let Err(save_err) = hm.save_entry( + file_name, + String::new(), + post_process, + None, + None, + ) { + error!("Failed to save failed history entry: {}", save_err); + } + } utils::hide_recording_overlay(&ah); change_tray_icon(&ah, TrayIconState::Idle); } } - Err(err) => { - debug!("Global Shortcut Transcription error: {}", err); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); - } } } else { debug!("No samples retrieved from recording stop"); diff --git a/src-tauri/src/audio_toolkit/audio/mod.rs b/src-tauri/src/audio_toolkit/audio/mod.rs index fcc1003..a25360a 100644 --- a/src-tauri/src/audio_toolkit/audio/mod.rs +++ b/src-tauri/src/audio_toolkit/audio/mod.rs @@ -8,5 +8,5 @@ mod visualizer; pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo}; pub use recorder::{is_microphone_access_denied, AudioRecorder}; pub use resampler::FrameResampler; -pub use utils::save_wav_file; +pub use utils::{read_wav_samples, save_wav_file, verify_wav_file}; pub use visualizer::AudioVisualiser; diff --git a/src-tauri/src/audio_toolkit/audio/utils.rs b/src-tauri/src/audio_toolkit/audio/utils.rs index cec8be7..1f62cf5 100644 --- a/src-tauri/src/audio_toolkit/audio/utils.rs +++ b/src-tauri/src/audio_toolkit/audio/utils.rs @@ -1,10 +1,34 @@ use anyhow::Result; -use hound::{WavSpec, WavWriter}; +use hound::{WavReader, WavSpec, WavWriter}; use log::debug; use std::path::Path; +/// Read a WAV file and return normalised f32 samples. +pub fn read_wav_samples>(file_path: P) -> Result> { + let reader = WavReader::open(file_path.as_ref())?; + let samples = reader + .into_samples::() + .map(|s| s.map(|v| v as f32 / i16::MAX as f32)) + .collect::, _>>()?; + Ok(samples) +} + +/// Verify a WAV file by reading it back and checking the sample count. +pub fn verify_wav_file>(file_path: P, expected_samples: usize) -> Result<()> { + let reader = WavReader::open(file_path.as_ref())?; + let actual_samples = reader.len() as usize; + if actual_samples != expected_samples { + anyhow::bail!( + "WAV sample count mismatch: expected {}, got {}", + expected_samples, + actual_samples + ); + } + Ok(()) +} + /// Save audio samples as a WAV file -pub async fn save_wav_file>(file_path: P, samples: &[f32]) -> Result<()> { +pub fn save_wav_file>(file_path: P, samples: &[f32]) -> Result<()> { let spec = WavSpec { channels: 1, sample_rate: 16000, diff --git a/src-tauri/src/audio_toolkit/mod.rs b/src-tauri/src/audio_toolkit/mod.rs index 2e51fc5..a94f27c 100644 --- a/src-tauri/src/audio_toolkit/mod.rs +++ b/src-tauri/src/audio_toolkit/mod.rs @@ -5,8 +5,8 @@ pub mod utils; pub mod vad; pub use audio::{ - is_microphone_access_denied, list_input_devices, list_output_devices, save_wav_file, - AudioRecorder, CpalDeviceInfo, + is_microphone_access_denied, list_input_devices, list_output_devices, read_wav_samples, + save_wav_file, verify_wav_file, AudioRecorder, CpalDeviceInfo, }; pub use text::{apply_custom_words, filter_transcription_output}; pub use utils::get_cpal_host; diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index cf6c13c..3792f6f 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -1,4 +1,8 @@ -use crate::managers::history::{HistoryManager, PaginatedHistory}; +use crate::actions::process_transcription_output; +use crate::managers::{ + history::{HistoryManager, PaginatedHistory}, + transcription::TranscriptionManager, +}; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -55,6 +59,53 @@ pub async fn delete_history_entry( .map_err(|e| e.to_string()) } +#[tauri::command] +#[specta::specta] +pub async fn retry_history_entry_transcription( + app: AppHandle, + history_manager: State<'_, Arc>, + transcription_manager: State<'_, Arc>, + id: i64, +) -> Result<(), String> { + let entry = history_manager + .get_entry_by_id(id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("History entry {} not found", id))?; + + let audio_path = history_manager.get_audio_file_path(&entry.file_name); + let samples = crate::audio_toolkit::read_wav_samples(&audio_path) + .map_err(|e| format!("Failed to load audio: {}", e))?; + + if samples.is_empty() { + return Err("Recording has no audio samples".to_string()); + } + + transcription_manager.initiate_model_load(); + + let tm = Arc::clone(&transcription_manager); + let transcription = tauri::async_runtime::spawn_blocking(move || tm.transcribe(samples)) + .await + .map_err(|e| format!("Transcription task panicked: {}", e))? + .map_err(|e| e.to_string())?; + + if transcription.is_empty() { + return Err("Recording contains no speech".to_string()); + } + + let processed = + process_transcription_output(&app, &transcription, entry.post_process_requested).await; + history_manager + .update_transcription( + id, + transcription, + processed.post_processed_text, + processed.post_process_prompt, + ) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + #[tauri::command] #[specta::specta] pub async fn update_history_limit( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f49d165..a7e0728 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -418,6 +418,7 @@ pub fn run(cli_args: CliArgs) { commands::history::toggle_history_entry_saved, commands::history::get_audio_file_path, commands::history::delete_history_entry, + commands::history::retry_history_entry_transcription, commands::history::update_history_limit, commands::history::update_recording_retention_period, helpers::clamshell::is_laptop, diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 2c6d6ed..8e1de92 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::{DateTime, Local, Utc}; use log::{debug, error, info}; use rusqlite::{params, Connection, OptionalExtension}; @@ -10,8 +10,6 @@ use std::path::PathBuf; use tauri::AppHandle; use tauri_specta::Event; -use crate::audio_toolkit::save_wav_file; - /// Database migrations for transcription history. /// Each migration is applied in order. The library tracks which migrations /// have been applied using SQLite's user_version pragma. @@ -32,6 +30,7 @@ static MIGRATIONS: &[M] = &[ ), M::up("ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;"), M::up("ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;"), + M::up("ALTER TABLE transcription_history ADD COLUMN post_process_requested BOOLEAN NOT NULL DEFAULT 0;"), ]; #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -45,6 +44,8 @@ pub struct PaginatedHistory { pub enum HistoryUpdatePayload { #[serde(rename = "added")] Added { entry: HistoryEntry }, + #[serde(rename = "updated")] + Updated { entry: HistoryEntry }, #[serde(rename = "deleted")] Deleted { id: i64 }, #[serde(rename = "toggled")] @@ -61,6 +62,7 @@ pub struct HistoryEntry { pub transcription_text: String, pub post_processed_text: Option, pub post_process_prompt: Option, + pub post_process_requested: bool, } pub struct HistoryManager { @@ -194,38 +196,63 @@ impl HistoryManager { Ok(Connection::open(&self.db_path)?) } - /// Save a transcription to history (both database and WAV file) - pub async fn save_transcription( + fn map_history_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(HistoryEntry { + id: row.get("id")?, + file_name: row.get("file_name")?, + timestamp: row.get("timestamp")?, + saved: row.get("saved")?, + title: row.get("title")?, + transcription_text: row.get("transcription_text")?, + post_processed_text: row.get("post_processed_text")?, + post_process_prompt: row.get("post_process_prompt")?, + post_process_requested: row.get("post_process_requested")?, + }) + } + + pub fn recordings_dir(&self) -> &std::path::Path { + &self.recordings_dir + } + + /// Save a new history entry to the database. + /// The WAV file should already have been written to the recordings directory. + pub fn save_entry( &self, - audio_samples: Vec, + file_name: String, transcription_text: String, + post_process_requested: bool, post_processed_text: Option, post_process_prompt: Option, - ) -> Result<()> { + ) -> Result { let timestamp = Utc::now().timestamp(); - let file_name = format!("handy-{}.wav", timestamp); let title = self.format_timestamp_title(timestamp); - // Save WAV file - let file_path = self.recordings_dir.join(&file_name); - save_wav_file(file_path, &audio_samples).await?; - - // Save to database - let id = self.save_to_database( - file_name.clone(), - timestamp, - title.clone(), - transcription_text.clone(), - post_processed_text.clone(), - post_process_prompt.clone(), + let conn = self.get_connection()?; + conn.execute( + "INSERT INTO transcription_history ( + file_name, + timestamp, + saved, + title, + transcription_text, + post_processed_text, + post_process_prompt, + post_process_requested + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + &file_name, + timestamp, + false, + &title, + &transcription_text, + &post_processed_text, + &post_process_prompt, + post_process_requested, + ], )?; - // Clean up old entries - self.cleanup_old_entries()?; - - // Emit history updated event with the full entry let entry = HistoryEntry { - id, + id: conn.last_insert_rowid(), file_name, timestamp, saved: false, @@ -233,32 +260,71 @@ impl HistoryManager { transcription_text, post_processed_text, post_process_prompt, + post_process_requested, }; - if let Err(e) = (HistoryUpdatePayload::Added { entry }).emit(&self.app_handle) { + + debug!("Saved history entry with id {}", entry.id); + + self.cleanup_old_entries()?; + + // Emit typed event for real-time frontend updates + if let Err(e) = (HistoryUpdatePayload::Added { + entry: entry.clone(), + }) + .emit(&self.app_handle) + { error!("Failed to emit history-updated event: {}", e); } - Ok(()) + Ok(entry) } - fn save_to_database( + /// Update an existing history entry with new transcription results (used by retry). + pub fn update_transcription( &self, - file_name: String, - timestamp: i64, - title: String, + id: i64, transcription_text: String, post_processed_text: Option, post_process_prompt: Option, - ) -> Result { + ) -> Result { let conn = self.get_connection()?; - conn.execute( - "INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt], + let updated = conn.execute( + "UPDATE transcription_history + SET transcription_text = ?1, + post_processed_text = ?2, + post_process_prompt = ?3 + WHERE id = ?4", + params![ + transcription_text, + post_processed_text, + post_process_prompt, + id + ], )?; - let id = conn.last_insert_rowid(); - debug!("Saved transcription to database with id {}", id); - Ok(id) + if updated == 0 { + return Err(anyhow!("History entry {} not found", id)); + } + + let entry = conn + .query_row( + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + FROM transcription_history WHERE id = ?1", + params![id], + Self::map_history_entry, + )?; + + debug!("Updated transcription for history entry {}", id); + + if let Err(e) = (HistoryUpdatePayload::Updated { + entry: entry.clone(), + }) + .emit(&self.app_handle) + { + error!("Failed to emit history-updated event: {}", e); + } + + Ok(entry) } pub fn cleanup_old_entries(&self) -> Result<()> { @@ -389,55 +455,42 @@ impl HistoryManager { let conn = self.get_connection()?; let limit = limit.map(|l| l.min(100)); - let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result { - Ok(HistoryEntry { - id: row.get("id")?, - file_name: row.get("file_name")?, - timestamp: row.get("timestamp")?, - saved: row.get("saved")?, - title: row.get("title")?, - transcription_text: row.get("transcription_text")?, - post_processed_text: row.get("post_processed_text")?, - post_process_prompt: row.get("post_process_prompt")?, - }) - }; - let mut entries: Vec = match (cursor, limit) { (Some(cursor_id), Some(lim)) => { let fetch_count = (lim + 1) as i64; let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested FROM transcription_history WHERE id < ?1 ORDER BY id DESC LIMIT ?2", )?; let result = stmt - .query_map(params![cursor_id, fetch_count], row_mapper)? + .query_map(params![cursor_id, fetch_count], Self::map_history_entry)? .collect::, _>>()?; result } (None, Some(lim)) => { let fetch_count = (lim + 1) as i64; let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested FROM transcription_history ORDER BY id DESC LIMIT ?1", )?; let result = stmt - .query_map(params![fetch_count], row_mapper)? + .query_map(params![fetch_count], Self::map_history_entry)? .collect::, _>>()?; result } (_, None) => { let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested FROM transcription_history ORDER BY id DESC", )?; let result = stmt - .query_map([], row_mapper)? + .query_map([], Self::map_history_entry)? .collect::, _>>()?; result } @@ -451,34 +504,53 @@ impl HistoryManager { Ok(PaginatedHistory { entries, has_more }) } - pub fn get_latest_entry(&self) -> Result> { - let conn = self.get_connection()?; - Self::get_latest_entry_with_conn(&conn) - } - + #[cfg(test)] fn get_latest_entry_with_conn(conn: &Connection) -> Result> { let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt + "SELECT + id, + file_name, + timestamp, + saved, + title, + transcription_text, + post_processed_text, + post_process_prompt, + post_process_requested FROM transcription_history ORDER BY timestamp DESC LIMIT 1", )?; - let entry = stmt - .query_row([], |row| { - Ok(HistoryEntry { - id: row.get("id")?, - file_name: row.get("file_name")?, - timestamp: row.get("timestamp")?, - saved: row.get("saved")?, - title: row.get("title")?, - transcription_text: row.get("transcription_text")?, - post_processed_text: row.get("post_processed_text")?, - post_process_prompt: row.get("post_process_prompt")?, - }) - }) - .optional()?; + let entry = stmt.query_row([], Self::map_history_entry).optional()?; + Ok(entry) + } + /// Get the latest entry with non-empty transcription text. + pub fn get_latest_completed_entry(&self) -> Result> { + let conn = self.get_connection()?; + Self::get_latest_completed_entry_with_conn(&conn) + } + + fn get_latest_completed_entry_with_conn(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT + id, + file_name, + timestamp, + saved, + title, + transcription_text, + post_processed_text, + post_process_prompt, + post_process_requested + FROM transcription_history + WHERE transcription_text != '' + ORDER BY timestamp DESC + LIMIT 1", + )?; + + let entry = stmt.query_row([], Self::map_history_entry).optional()?; Ok(entry) } @@ -516,24 +588,21 @@ impl HistoryManager { pub async fn get_entry_by_id(&self, id: i64) -> Result> { let conn = self.get_connection()?; let mut stmt = conn.prepare( - "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt - FROM transcription_history WHERE id = ?1", + "SELECT + id, + file_name, + timestamp, + saved, + title, + transcription_text, + post_processed_text, + post_process_prompt, + post_process_requested + FROM transcription_history + WHERE id = ?1", )?; - let entry = stmt - .query_row([id], |row| { - Ok(HistoryEntry { - id: row.get("id")?, - file_name: row.get("file_name")?, - timestamp: row.get("timestamp")?, - saved: row.get("saved")?, - title: row.get("title")?, - transcription_text: row.get("transcription_text")?, - post_processed_text: row.get("post_processed_text")?, - post_process_prompt: row.get("post_process_prompt")?, - }) - }) - .optional()?; + let entry = stmt.query_row([id], Self::map_history_entry).optional()?; Ok(entry) } @@ -596,7 +665,8 @@ mod tests { title TEXT NOT NULL, transcription_text TEXT NOT NULL, post_processed_text TEXT, - post_process_prompt TEXT + post_process_prompt TEXT, + post_process_requested BOOLEAN NOT NULL DEFAULT 0 );", ) .expect("create transcription_history table"); @@ -605,8 +675,16 @@ mod tests { fn insert_entry(conn: &Connection, timestamp: i64, text: &str, post_processed: Option<&str>) { conn.execute( - "INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO transcription_history ( + file_name, + timestamp, + saved, + title, + transcription_text, + post_processed_text, + post_process_prompt, + post_process_requested + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ format!("handy-{}.wav", timestamp), timestamp, @@ -614,7 +692,8 @@ mod tests { format!("Recording {}", timestamp), text, post_processed, - Option::::None + Option::::None, + false, ], ) .expect("insert history entry"); @@ -641,4 +720,18 @@ mod tests { assert_eq!(entry.transcription_text, "second"); assert_eq!(entry.post_processed_text.as_deref(), Some("processed")); } + + #[test] + fn get_latest_completed_entry_skips_empty_entries() { + let conn = setup_conn(); + insert_entry(&conn, 100, "completed", None); + insert_entry(&conn, 200, "", None); + + let entry = HistoryManager::get_latest_completed_entry_with_conn(&conn) + .expect("fetch latest completed entry") + .expect("completed entry exists"); + + assert_eq!(entry.timestamp, 100); + assert_eq!(entry.transcription_text, "completed"); + } } diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 4273ed7..ae995dc 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -428,6 +428,13 @@ impl TranscriptionManager { } pub fn transcribe(&self, audio: Vec) -> Result { + #[cfg(debug_assertions)] + if std::env::var("HANDY_FORCE_TRANSCRIPTION_FAILURE").is_ok() { + return Err(anyhow::anyhow!( + "Simulated transcription failure (HANDY_FORCE_TRANSCRIPTION_FAILURE)" + )); + } + // Update last activity timestamp self.touch_activity(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index f74cb40..39cfcb0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -232,19 +232,28 @@ pub fn set_tray_visibility(app: &AppHandle, visible: bool) { pub fn copy_last_transcript(app: &AppHandle) { let history_manager = app.state::>(); - let entry = match history_manager.get_latest_entry() { + let entry = match history_manager.get_latest_completed_entry() { Ok(Some(entry)) => entry, Ok(None) => { - warn!("No transcription history entries available for tray copy."); + warn!("No completed transcription history entries available for tray copy."); return; } Err(err) => { - error!("Failed to fetch last transcription entry: {}", err); + error!( + "Failed to fetch last completed transcription entry: {}", + err + ); return; } }; - if let Err(err) = app.clipboard().write_text(last_transcript_text(&entry)) { + let text = last_transcript_text(&entry); + if text.trim().is_empty() { + warn!("Last completed transcription is empty; skipping tray copy."); + return; + } + + if let Err(err) = app.clipboard().write_text(text) { error!("Failed to copy last transcript to clipboard: {}", err); return; } @@ -267,6 +276,7 @@ mod tests { transcription_text: transcription.to_string(), post_processed_text: post_processed.map(|text| text.to_string()), post_process_prompt: None, + post_process_requested: false, } } diff --git a/src/bindings.ts b/src/bindings.ts index b7d607a..21f7d6a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -753,6 +753,14 @@ async deleteHistoryEntry(id: number) : Promise> { else return { status: "error", error: e as any }; } }, +async retryHistoryEntryTranscription(id: number) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("retry_history_entry_transcription", { id }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async updateHistoryLimit(limit: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_history_limit", { limit }) }; @@ -808,8 +816,8 @@ export type BindingResponse = { success: boolean; binding: ShortcutBinding | nul export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type CustomSounds = { start: boolean; stop: boolean } export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" -export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null } -export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number } +export type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null; post_process_requested: boolean } +export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "updated"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number } /** * Result of changing keyboard implementation */ diff --git a/src/components/settings/history/HistorySettings.tsx b/src/components/settings/history/HistorySettings.tsx index 909164f..6170693 100644 --- a/src/components/settings/history/HistorySettings.tsx +++ b/src/components/settings/history/HistorySettings.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { readFile } from "@tauri-apps/plugin-fs"; -import { Check, Copy, FolderOpen, Star, Trash2 } from "lucide-react"; +import { Check, Copy, FolderOpen, RotateCcw, Star, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { commands, events, @@ -14,6 +15,27 @@ import { formatDateTime } from "@/utils/dateFormat"; import { AudioPlayer } from "../../ui/AudioPlayer"; import { Button } from "../../ui/Button"; +const IconButton: React.FC<{ + onClick: () => void; + title: string; + disabled?: boolean; + active?: boolean; + children: React.ReactNode; +}> = ({ onClick, title, disabled, active, children }) => ( + +); + const PAGE_SIZE = 30; interface OpenRecordingsButtonProps { @@ -114,6 +136,10 @@ export const HistorySettings: React.FC = () => { const payload: HistoryUpdatePayload = event.payload; if (payload.action === "added") { setEntries((prev) => [payload.entry, ...prev]); + } else if (payload.action === "updated") { + setEntries((prev) => + prev.map((e) => (e.id === payload.entry.id ? payload.entry : e)), + ); } // "deleted" and "toggled" are handled by optimistic updates only, // so we intentionally ignore them here to avoid double-mutation. @@ -190,6 +216,13 @@ export const HistorySettings: React.FC = () => { } }; + const retryHistoryEntry = async (id: number) => { + const result = await commands.retryHistoryEntryTranscription(id); + if (result.status !== "ok") { + throw new Error(String(result.error)); + } + }; + const openRecordingsFolder = async () => { try { const result = await commands.openRecordingsFolder(); @@ -227,6 +260,7 @@ export const HistorySettings: React.FC = () => { onCopyText={() => copyToClipboard(entry.transcription_text)} getAudioUrl={getAudioUrl} deleteAudio={deleteAudioEntry} + retryTranscription={retryHistoryEntry} /> ))} @@ -264,6 +298,7 @@ interface HistoryEntryProps { onCopyText: () => void; getAudioUrl: (fileName: string) => Promise; deleteAudio: (id: number) => Promise; + retryTranscription: (id: number) => Promise; } const HistoryEntryComponent: React.FC = ({ @@ -272,9 +307,13 @@ const HistoryEntryComponent: React.FC = ({ onCopyText, getAudioUrl, deleteAudio, + retryTranscription, }) => { const { t, i18n } = useTranslation(); const [showCopied, setShowCopied] = useState(false); + const [retrying, setRetrying] = useState(false); + + const hasTranscription = entry.transcription_text.trim().length > 0; const handleLoadAudio = useCallback( () => getAudioUrl(entry.file_name), @@ -282,6 +321,10 @@ const HistoryEntryComponent: React.FC = ({ ); const handleCopyText = () => { + if (!hasTranscription) { + return; + } + onCopyText(); setShowCopied(true); setTimeout(() => setShowCopied(false), 2000); @@ -292,7 +335,19 @@ const HistoryEntryComponent: React.FC = ({ await deleteAudio(entry.id); } catch (error) { console.error("Failed to delete entry:", error); - alert(t("settings.history.deleteError")); + toast.error(t("settings.history.deleteError")); + } + }; + + const handleRetranscribe = async () => { + try { + setRetrying(true); + await retryTranscription(entry.id); + } catch (error) { + console.error("Failed to re-transcribe:", error); + toast.error(t("settings.history.retranscribeError")); + } finally { + setRetrying(false); } }; @@ -302,10 +357,10 @@ const HistoryEntryComponent: React.FC = ({

{formattedDate}

-
- - - +
-

- {entry.transcription_text} + +

+ {retrying && ( + + )} + {retrying + ? t("settings.history.transcribing") + : hasTranscription + ? entry.transcription_text + : t("settings.history.transcriptionFailed")}

+
); diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 604da86..a228f1d 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -396,7 +396,11 @@ "save": "حفظ التفريغ", "unsave": "إزالة من المحفوظات", "delete": "حذف الإدخال", - "deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى" + "deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى", + "retranscribe": "إعادة النسخ", + "retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.", + "transcribing": "جارٍ النسخ...", + "transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة." }, "debug": { "title": "تصحيح الأخطاء", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index d560fc6..877ccdb 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -418,7 +418,11 @@ "save": "Uložit přepis", "unsave": "Odebrat z uložených", "delete": "Smazat záznam", - "deleteError": "Nepodařilo se smazat záznam. Zkuste to prosím znovu." + "deleteError": "Nepodařilo se smazat záznam. Zkuste to prosím znovu.", + "retranscribe": "Přepsat znovu", + "retranscribeError": "Opětovný přepis se nezdařil. Zkuste to prosím znovu.", + "transcribing": "Přepisuji...", + "transcriptionFailed": "Přepis selhal. Můžete zkusit přepis znovu pomocí ikony opakování." }, "debug": { "title": "Ladění", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 8133fa4..d476002 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -418,7 +418,11 @@ "save": "Transkription speichern", "unsave": "Aus Gespeicherten entfernen", "delete": "Eintrag löschen", - "deleteError": "Eintrag konnte nicht gelöscht werden. Bitte versuche es erneut." + "deleteError": "Eintrag konnte nicht gelöscht werden. Bitte versuche es erneut.", + "retranscribe": "Erneut transkribieren", + "retranscribeError": "Erneute Transkription fehlgeschlagen. Bitte versuche es erneut.", + "transcribing": "Transkribiere...", + "transcriptionFailed": "Transkription fehlgeschlagen. Du kannst über das Wiederholen-Symbol erneut transkribieren." }, "debug": { "title": "Debug", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 9a065b9..f7cf056 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -418,7 +418,11 @@ "save": "Save transcription", "unsave": "Remove from saved", "delete": "Delete entry", - "deleteError": "Failed to delete entry. Please try again." + "deleteError": "Failed to delete entry. Please try again.", + "retranscribe": "Re-transcribe", + "retranscribeError": "Failed to re-transcribe. Please try again.", + "transcribing": "Transcribing...", + "transcriptionFailed": "Transcription failed. You can re-transcribe using the retry icon." }, "debug": { "title": "Debug", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index ab6e8e9..fb3644f 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -418,7 +418,11 @@ "save": "Guardar transcripción", "unsave": "Eliminar de guardados", "delete": "Eliminar entrada", - "deleteError": "Error al eliminar la entrada. Por favor, intenta de nuevo." + "deleteError": "Error al eliminar la entrada. Por favor, intenta de nuevo.", + "retranscribe": "Re-transcribir", + "retranscribeError": "No se pudo re-transcribir. Por favor, inténtalo de nuevo.", + "transcribing": "Transcribiendo...", + "transcriptionFailed": "La transcripción falló. Puedes re-transcribir usando el ícono de reintentar." }, "debug": { "title": "Depuración", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 29f82cc..0deb8e2 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -418,7 +418,11 @@ "save": "Enregistrer la transcription", "unsave": "Retirer des favoris", "delete": "Supprimer l'entrée", - "deleteError": "Échec de la suppression de l'entrée. Veuillez réessayer." + "deleteError": "Échec de la suppression de l'entrée. Veuillez réessayer.", + "retranscribe": "Re-transcrire", + "retranscribeError": "Impossible de re-transcrire. Veuillez réessayer.", + "transcribing": "Transcription en cours...", + "transcriptionFailed": "La transcription a échoué. Vous pouvez re-transcrire en utilisant l'icône de réessai." }, "debug": { "title": "Débogage", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index b3784a0..c7cf6a8 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -418,7 +418,11 @@ "save": "Salva la trascrizione", "unsave": "Rimuovi dai salvataggi", "delete": "Elimina elemento", - "deleteError": "Errore nell'eliminazione dell'elemento. Riprova." + "deleteError": "Errore nell'eliminazione dell'elemento. Riprova.", + "retranscribe": "Ri-trascrivere", + "retranscribeError": "Impossibile ri-trascrivere. Riprova.", + "transcribing": "Trascrizione in corso...", + "transcriptionFailed": "Trascrizione fallita. Puoi ri-trascrivere usando l'icona di ripetizione." }, "debug": { "title": "Debug", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index c6d0850..5fdc8c3 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -418,7 +418,11 @@ "save": "文字起こしを保存", "unsave": "保存から削除", "delete": "エントリーを削除", - "deleteError": "エントリーの削除に失敗しました。もう一度お試しください。" + "deleteError": "エントリーの削除に失敗しました。もう一度お試しください。", + "retranscribe": "再文字起こし", + "retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。", + "transcribing": "文字起こし中...", + "transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。" }, "debug": { "title": "デバッグ", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index 66f451b..e1c8ccb 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -418,7 +418,11 @@ "save": "변환된 텍스트 저장", "unsave": "저장에서 제거", "delete": "항목 삭제", - "deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요." + "deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요.", + "retranscribe": "다시 전사", + "retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.", + "transcribing": "전사 중...", + "transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다." }, "debug": { "title": "디버그", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 67de524..36c2c96 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -418,7 +418,11 @@ "save": "Zapisz transkrypcję", "unsave": "Usuń z zapisanych", "delete": "Usuń wpis", - "deleteError": "Nie udało się usunąć wpisu. Spróbuj ponownie." + "deleteError": "Nie udało się usunąć wpisu. Spróbuj ponownie.", + "retranscribe": "Transkrybuj ponownie", + "retranscribeError": "Nie udało się ponownie transkrybować. Spróbuj ponownie.", + "transcribing": "Transkrybuję...", + "transcriptionFailed": "Transkrypcja nie powiodła się. Możesz transkrybować ponownie za pomocą ikony ponowienia." }, "debug": { "title": "Debugowanie", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index f77c7f5..3d62a17 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -418,7 +418,11 @@ "save": "Salvar transcrição", "unsave": "Remover dos salvos", "delete": "Excluir entrada", - "deleteError": "Falha ao excluir entrada. Por favor, tente novamente." + "deleteError": "Falha ao excluir entrada. Por favor, tente novamente.", + "retranscribe": "Re-transcrever", + "retranscribeError": "Falha ao re-transcrever. Por favor, tente novamente.", + "transcribing": "Transcrevendo...", + "transcriptionFailed": "A transcrição falhou. Você pode re-transcrever usando o ícone de tentar novamente." }, "debug": { "title": "Depuração", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index 941a200..cb88f67 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -418,7 +418,11 @@ "save": "Сохранить транскрипцию", "unsave": "Удалить из сохраненных", "delete": "Удалить запись", - "deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз." + "deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз.", + "retranscribe": "Перетранскрибировать", + "retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.", + "transcribing": "Транскрибирование...", + "transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора." }, "debug": { "title": "Отлаживать", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index d0918cb..4574b6c 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -418,7 +418,11 @@ "save": "Transkripsiyonu kaydet", "unsave": "Kaydedilenlerden kaldır", "delete": "Kaydı sil", - "deleteError": "Kayıt silinemedi. Lütfen tekrar deneyin." + "deleteError": "Kayıt silinemedi. Lütfen tekrar deneyin.", + "retranscribe": "Yeniden yazıya dök", + "retranscribeError": "Yeniden yazıya dökme başarısız oldu. Lütfen tekrar deneyin.", + "transcribing": "Yazıya döküyor...", + "transcriptionFailed": "Transkripsiyon başarısız oldu. Yeniden deneme simgesini kullanarak tekrar yazıya dökebilirsiniz." }, "debug": { "title": "Hata Ayıklama", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 903cf6a..1aa0eb0 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -418,7 +418,11 @@ "save": "Зберегти транскрипцію", "unsave": "Видалити зі збережених", "delete": "Видалити запис", - "deleteError": "Не вдалося видалити запис. Спробуйте ще раз." + "deleteError": "Не вдалося видалити запис. Спробуйте ще раз.", + "retranscribe": "Перетранскрибувати", + "retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.", + "transcribing": "Транскрибування...", + "transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору." }, "debug": { "title": "Дебаг", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index b16a15c..e406b44 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -418,7 +418,11 @@ "save": "Lưu bản ghi", "unsave": "Xóa khỏi đã lưu", "delete": "Xóa mục", - "deleteError": "Không thể xóa mục. Vui lòng thử lại." + "deleteError": "Không thể xóa mục. Vui lòng thử lại.", + "retranscribe": "Phiên âm lại", + "retranscribeError": "Không thể phiên âm lại. Vui lòng thử lại.", + "transcribing": "Đang phiên âm...", + "transcriptionFailed": "Phiên âm thất bại. Bạn có thể phiên âm lại bằng biểu tượng thử lại." }, "debug": { "title": "Gỡ lỗi", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 5ce9a8e..6f27da2 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -418,7 +418,11 @@ "save": "儲存轉錄", "unsave": "從已儲存中移除", "delete": "刪除條目", - "deleteError": "刪除條目失敗,請重試" + "deleteError": "刪除條目失敗,請重試", + "retranscribe": "重新轉錄", + "retranscribeError": "重新轉錄失敗,請重試。", + "transcribing": "轉錄中...", + "transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。" }, "debug": { "title": "偵錯", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index f517e61..de6efc8 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -418,7 +418,11 @@ "save": "保存转录", "unsave": "从已保存中移除", "delete": "删除条目", - "deleteError": "删除条目失败,请重试。" + "deleteError": "删除条目失败,请重试。", + "retranscribe": "重新转录", + "retranscribeError": "重新转录失败。请重试。", + "transcribing": "转录中...", + "transcriptionFailed": "转录失败。您可以使用重试图标重新转录。" }, "debug": { "title": "调试",