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:
parent
4a4f5ebc9a
commit
17277cf6ce
28 changed files with 644 additions and 227 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -396,7 +396,11 @@
|
|||
"save": "حفظ التفريغ",
|
||||
"unsave": "إزالة من المحفوظات",
|
||||
"delete": "حذف الإدخال",
|
||||
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى"
|
||||
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى",
|
||||
"retranscribe": "إعادة النسخ",
|
||||
"retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.",
|
||||
"transcribing": "جارٍ النسخ...",
|
||||
"transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة."
|
||||
},
|
||||
"debug": {
|
||||
"title": "تصحيح الأخطاء",
|
||||
|
|
|
|||
|
|
@ -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í",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "文字起こしを保存",
|
||||
"unsave": "保存から削除",
|
||||
"delete": "エントリーを削除",
|
||||
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。"
|
||||
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。",
|
||||
"retranscribe": "再文字起こし",
|
||||
"retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。",
|
||||
"transcribing": "文字起こし中...",
|
||||
"transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。"
|
||||
},
|
||||
"debug": {
|
||||
"title": "デバッグ",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "변환된 텍스트 저장",
|
||||
"unsave": "저장에서 제거",
|
||||
"delete": "항목 삭제",
|
||||
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요."
|
||||
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요.",
|
||||
"retranscribe": "다시 전사",
|
||||
"retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.",
|
||||
"transcribing": "전사 중...",
|
||||
"transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다."
|
||||
},
|
||||
"debug": {
|
||||
"title": "디버그",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "Сохранить транскрипцию",
|
||||
"unsave": "Удалить из сохраненных",
|
||||
"delete": "Удалить запись",
|
||||
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз."
|
||||
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз.",
|
||||
"retranscribe": "Перетранскрибировать",
|
||||
"retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.",
|
||||
"transcribing": "Транскрибирование...",
|
||||
"transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора."
|
||||
},
|
||||
"debug": {
|
||||
"title": "Отлаживать",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "Зберегти транскрипцію",
|
||||
"unsave": "Видалити зі збережених",
|
||||
"delete": "Видалити запис",
|
||||
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз."
|
||||
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз.",
|
||||
"retranscribe": "Перетранскрибувати",
|
||||
"retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.",
|
||||
"transcribing": "Транскрибування...",
|
||||
"transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору."
|
||||
},
|
||||
"debug": {
|
||||
"title": "Дебаг",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "儲存轉錄",
|
||||
"unsave": "從已儲存中移除",
|
||||
"delete": "刪除條目",
|
||||
"deleteError": "刪除條目失敗,請重試"
|
||||
"deleteError": "刪除條目失敗,請重試",
|
||||
"retranscribe": "重新轉錄",
|
||||
"retranscribeError": "重新轉錄失敗,請重試。",
|
||||
"transcribing": "轉錄中...",
|
||||
"transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。"
|
||||
},
|
||||
"debug": {
|
||||
"title": "偵錯",
|
||||
|
|
|
|||
|
|
@ -418,7 +418,11 @@
|
|||
"save": "保存转录",
|
||||
"unsave": "从已保存中移除",
|
||||
"delete": "删除条目",
|
||||
"deleteError": "删除条目失败,请重试。"
|
||||
"deleteError": "删除条目失败,请重试。",
|
||||
"retranscribe": "重新转录",
|
||||
"retranscribeError": "重新转录失败。请重试。",
|
||||
"transcribing": "转录中...",
|
||||
"transcriptionFailed": "转录失败。您可以使用重试图标重新转录。"
|
||||
},
|
||||
"debug": {
|
||||
"title": "调试",
|
||||
|
|
|
|||
Loading…
Reference in a new issue