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 <cj@cjpais.com>
This commit is contained in:
Fero 2026-03-22 08:43:02 +01:00 committed by GitHub
parent 4a4f5ebc9a
commit 17277cf6ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 644 additions and 227 deletions

View file

@ -315,6 +315,52 @@ async fn maybe_convert_chinese_variant(
}
}
pub(crate) struct ProcessedTranscription {
pub final_text: String,
pub post_processed_text: Option<String>,
pub post_process_prompt: Option<String>,
}
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<String> = None;
let mut post_process_prompt: Option<String> = 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<String> = None;
let mut post_process_prompt: Option<String> = 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");

View file

@ -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;

View file

@ -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<P: AsRef<Path>>(file_path: P) -> Result<Vec<f32>> {
let reader = WavReader::open(file_path.as_ref())?;
let samples = reader
.into_samples::<i16>()
.map(|s| s.map(|v| v as f32 / i16::MAX as f32))
.collect::<Result<Vec<f32>, _>>()?;
Ok(samples)
}
/// Verify a WAV file by reading it back and checking the sample count.
pub fn verify_wav_file<P: AsRef<Path>>(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<P: AsRef<Path>>(file_path: P, samples: &[f32]) -> Result<()> {
pub fn save_wav_file<P: AsRef<Path>>(file_path: P, samples: &[f32]) -> Result<()> {
let spec = WavSpec {
channels: 1,
sample_rate: 16000,

View file

@ -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;

View file

@ -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<HistoryManager>>,
transcription_manager: State<'_, Arc<TranscriptionManager>>,
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(

View file

@ -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,

View file

@ -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<String>,
pub post_process_prompt: Option<String>,
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<HistoryEntry> {
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<f32>,
file_name: String,
transcription_text: String,
post_process_requested: bool,
post_processed_text: Option<String>,
post_process_prompt: Option<String>,
) -> Result<()> {
) -> Result<HistoryEntry> {
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<String>,
post_process_prompt: Option<String>,
) -> Result<i64> {
) -> Result<HistoryEntry> {
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<HistoryEntry> {
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<HistoryEntry> = 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::<std::result::Result<Vec<_>, _>>()?;
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::<std::result::Result<Vec<_>, _>>()?;
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::<std::result::Result<Vec<_>, _>>()?;
result
}
@ -451,34 +504,53 @@ impl HistoryManager {
Ok(PaginatedHistory { entries, has_more })
}
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
let conn = self.get_connection()?;
Self::get_latest_entry_with_conn(&conn)
}
#[cfg(test)]
fn get_latest_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {
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<Option<HistoryEntry>> {
let conn = self.get_connection()?;
Self::get_latest_completed_entry_with_conn(&conn)
}
fn get_latest_completed_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {
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<Option<HistoryEntry>> {
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::<String>::None
Option::<String>::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");
}
}

View file

@ -428,6 +428,13 @@ impl TranscriptionManager {
}
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
#[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();

View file

@ -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::<Arc<HistoryManager>>();
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,
}
}

View file

@ -753,6 +753,14 @@ async deleteHistoryEntry(id: number) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async retryHistoryEntryTranscription(id: number) : Promise<Result<null, string>> {
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<Result<null, string>> {
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
*/

View file

@ -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 }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded-md flex items-center justify-center transition-colors cursor-pointer disabled:cursor-not-allowed disabled:text-text/20 ${
active
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"
}`}
title={title}
>
{children}
</button>
);
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}
/>
))}
</div>
@ -264,6 +298,7 @@ interface HistoryEntryProps {
onCopyText: () => void;
getAudioUrl: (fileName: string) => Promise<string | null>;
deleteAudio: (id: number) => Promise<void>;
retryTranscription: (id: number) => Promise<void>;
}
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
@ -272,9 +307,13 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
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<HistoryEntryProps> = ({
);
const handleCopyText = () => {
if (!hasTranscription) {
return;
}
onCopyText();
setShowCopied(true);
setTimeout(() => setShowCopied(false), 2000);
@ -292,7 +335,19 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
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<HistoryEntryProps> = ({
<div className="px-4 py-2 pb-5 flex flex-col gap-3">
<div className="flex justify-between items-center">
<p className="text-sm font-medium">{formattedDate}</p>
<div className="flex items-center gap-1">
<button
<div className="flex items-center">
<IconButton
onClick={handleCopyText}
className="text-text/50 hover:text-logo-primary hover:border-logo-primary transition-colors cursor-pointer"
disabled={!hasTranscription || retrying}
title={t("settings.history.copyToClipboard")}
>
{showCopied ? (
@ -313,14 +368,11 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
) : (
<Copy width={16} height={16} />
)}
</button>
<button
</IconButton>
<IconButton
onClick={onToggleSaved}
className={`p-2 rounded-md transition-colors cursor-pointer ${
entry.saved
? "text-logo-primary hover:text-logo-primary/80"
: "text-text/50 hover:text-logo-primary"
}`}
disabled={retrying}
active={entry.saved}
title={
entry.saved
? t("settings.history.unsave")
@ -332,19 +384,61 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
height={16}
fill={entry.saved ? "currentColor" : "none"}
/>
</button>
<button
</IconButton>
<IconButton
onClick={handleRetranscribe}
disabled={retrying}
title={t("settings.history.retranscribe")}
>
<RotateCcw
width={16}
height={16}
style={
retrying
? { animation: "spin 1s linear infinite reverse" }
: undefined
}
/>
</IconButton>
<IconButton
onClick={handleDeleteEntry}
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
disabled={retrying}
title={t("settings.history.delete")}
>
<Trash2 width={16} height={16} />
</button>
</IconButton>
</div>
</div>
<p className="italic text-text/90 text-sm pb-2 select-text cursor-text">
{entry.transcription_text}
<p
className={`italic text-sm pb-2 ${
retrying
? ""
: hasTranscription
? "text-text/90 select-text cursor-text whitespace-pre-wrap break-words"
: "text-text/40"
}`}
style={
retrying
? { animation: "transcribe-pulse 3s ease-in-out infinite" }
: undefined
}
>
{retrying && (
<style>{`
@keyframes transcribe-pulse {
0%, 100% { color: color-mix(in srgb, var(--color-text) 40%, transparent); }
50% { color: color-mix(in srgb, var(--color-text) 90%, transparent); }
}
`}</style>
)}
{retrying
? t("settings.history.transcribing")
: hasTranscription
? entry.transcription_text
: t("settings.history.transcriptionFailed")}
</p>
<AudioPlayer onLoadRequest={handleLoadAudio} className="w-full" />
</div>
);

View file

@ -396,7 +396,11 @@
"save": "حفظ التفريغ",
"unsave": "إزالة من المحفوظات",
"delete": "حذف الإدخال",
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى"
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى",
"retranscribe": "إعادة النسخ",
"retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.",
"transcribing": "جارٍ النسخ...",
"transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة."
},
"debug": {
"title": "تصحيح الأخطاء",

View file

@ -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í",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -418,7 +418,11 @@
"save": "文字起こしを保存",
"unsave": "保存から削除",
"delete": "エントリーを削除",
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。"
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。",
"retranscribe": "再文字起こし",
"retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。",
"transcribing": "文字起こし中...",
"transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。"
},
"debug": {
"title": "デバッグ",

View file

@ -418,7 +418,11 @@
"save": "변환된 텍스트 저장",
"unsave": "저장에서 제거",
"delete": "항목 삭제",
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요."
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요.",
"retranscribe": "다시 전사",
"retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.",
"transcribing": "전사 중...",
"transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다."
},
"debug": {
"title": "디버그",

View file

@ -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",

View file

@ -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",

View file

@ -418,7 +418,11 @@
"save": "Сохранить транскрипцию",
"unsave": "Удалить из сохраненных",
"delete": "Удалить запись",
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз."
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз.",
"retranscribe": "Перетранскрибировать",
"retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.",
"transcribing": "Транскрибирование...",
"transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора."
},
"debug": {
"title": "Отлаживать",

View file

@ -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",

View file

@ -418,7 +418,11 @@
"save": "Зберегти транскрипцію",
"unsave": "Видалити зі збережених",
"delete": "Видалити запис",
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз."
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз.",
"retranscribe": "Перетранскрибувати",
"retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.",
"transcribing": "Транскрибування...",
"transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору."
},
"debug": {
"title": "Дебаг",

View file

@ -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",

View file

@ -418,7 +418,11 @@
"save": "儲存轉錄",
"unsave": "從已儲存中移除",
"delete": "刪除條目",
"deleteError": "刪除條目失敗,請重試"
"deleteError": "刪除條目失敗,請重試",
"retranscribe": "重新轉錄",
"retranscribeError": "重新轉錄失敗,請重試。",
"transcribing": "轉錄中...",
"transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。"
},
"debug": {
"title": "偵錯",

View file

@ -418,7 +418,11 @@
"save": "保存转录",
"unsave": "从已保存中移除",
"delete": "删除条目",
"deleteError": "删除条目失败,请重试。"
"deleteError": "删除条目失败,请重试。",
"retranscribe": "重新转录",
"retranscribeError": "重新转录失败。请重试。",
"transcribing": "转录中...",
"transcriptionFailed": "转录失败。您可以使用重试图标重新转录。"
},
"debug": {
"title": "调试",