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 {
|
impl ShortcutAction for TranscribeAction {
|
||||||
fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
|
fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
|
|
@ -435,7 +481,6 @@ impl ShortcutAction for TranscribeAction {
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let _guard = FinishGuard(ah.clone());
|
let _guard = FinishGuard(ah.clone());
|
||||||
let binding_id = binding_id.clone(); // Clone for the inner async task
|
|
||||||
debug!(
|
debug!(
|
||||||
"Starting async transcription task for binding: {}",
|
"Starting async transcription task for binding: {}",
|
||||||
binding_id
|
binding_id
|
||||||
|
|
@ -449,104 +494,120 @@ impl ShortcutAction for TranscribeAction {
|
||||||
samples.len()
|
samples.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
let transcription_time = Instant::now();
|
if samples.is_empty() {
|
||||||
let samples_clone = samples.clone(); // Clone for history saving
|
debug!("Recording produced no audio samples; skipping persistence");
|
||||||
match tm.transcribe(samples) {
|
utils::hide_recording_overlay(&ah);
|
||||||
Ok(transcription) => {
|
change_tray_icon(&ah, TrayIconState::Idle);
|
||||||
debug!(
|
} else {
|
||||||
"Transcription completed in {:?}: '{}'",
|
// Save WAV concurrently with transcription
|
||||||
transcription_time.elapsed(),
|
let sample_count = samples.len();
|
||||||
transcription
|
let file_name = format!("handy-{}.wav", chrono::Utc::now().timestamp());
|
||||||
);
|
let wav_path = hm.recordings_dir().join(&file_name);
|
||||||
if !transcription.is_empty() {
|
let wav_path_for_verify = wav_path.clone();
|
||||||
let settings = get_settings(&ah);
|
let samples_for_wav = samples.clone();
|
||||||
let mut final_text = transcription.clone();
|
let wav_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||||
let mut post_processed_text: Option<String> = None;
|
crate::audio_toolkit::save_wav_file(&wav_path, &samples_for_wav)
|
||||||
let mut post_process_prompt: Option<String> = None;
|
});
|
||||||
|
|
||||||
// First, check if Chinese variant conversion is needed
|
// Transcribe concurrently with WAV save
|
||||||
if let Some(converted_text) =
|
let transcription_time = Instant::now();
|
||||||
maybe_convert_chinese_variant(&settings, &transcription).await
|
let transcription_result = tm.transcribe(samples);
|
||||||
{
|
|
||||||
final_text = converted_text;
|
// 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 {
|
if post_process {
|
||||||
show_processing_overlay(&ah);
|
show_processing_overlay(&ah);
|
||||||
}
|
}
|
||||||
let processed = if post_process {
|
let processed =
|
||||||
post_process_transcription(&settings, &final_text).await
|
process_transcription_output(&ah, &transcription, post_process)
|
||||||
} else {
|
.await;
|
||||||
None
|
|
||||||
};
|
|
||||||
if let Some(processed_text) = processed {
|
|
||||||
post_processed_text = Some(processed_text.clone());
|
|
||||||
final_text = processed_text;
|
|
||||||
|
|
||||||
// Get the prompt that was used
|
// Save to history if WAV was saved
|
||||||
if let Some(prompt_id) = &settings.post_process_selected_prompt_id {
|
if wav_saved {
|
||||||
if let Some(prompt) = settings
|
if let Err(err) = hm.save_entry(
|
||||||
.post_process_prompts
|
file_name,
|
||||||
.iter()
|
transcription,
|
||||||
.find(|p| &p.id == prompt_id)
|
post_process,
|
||||||
{
|
processed.post_processed_text.clone(),
|
||||||
post_process_prompt = Some(prompt.prompt.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
|
if processed.final_text.is_empty() {
|
||||||
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);
|
|
||||||
utils::hide_recording_overlay(&ah);
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
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);
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
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 {
|
} else {
|
||||||
debug!("No samples retrieved from recording stop");
|
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 device::{list_input_devices, list_output_devices, CpalDeviceInfo};
|
||||||
pub use recorder::{is_microphone_access_denied, AudioRecorder};
|
pub use recorder::{is_microphone_access_denied, AudioRecorder};
|
||||||
pub use resampler::FrameResampler;
|
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;
|
pub use visualizer::AudioVisualiser;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,34 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hound::{WavSpec, WavWriter};
|
use hound::{WavReader, WavSpec, WavWriter};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use std::path::Path;
|
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
|
/// 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 {
|
let spec = WavSpec {
|
||||||
channels: 1,
|
channels: 1,
|
||||||
sample_rate: 16000,
|
sample_rate: 16000,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ pub mod utils;
|
||||||
pub mod vad;
|
pub mod vad;
|
||||||
|
|
||||||
pub use audio::{
|
pub use audio::{
|
||||||
is_microphone_access_denied, list_input_devices, list_output_devices, save_wav_file,
|
is_microphone_access_denied, list_input_devices, list_output_devices, read_wav_samples,
|
||||||
AudioRecorder, CpalDeviceInfo,
|
save_wav_file, verify_wav_file, AudioRecorder, CpalDeviceInfo,
|
||||||
};
|
};
|
||||||
pub use text::{apply_custom_words, filter_transcription_output};
|
pub use text::{apply_custom_words, filter_transcription_output};
|
||||||
pub use utils::get_cpal_host;
|
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 std::sync::Arc;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
|
@ -55,6 +59,53 @@ pub async fn delete_history_entry(
|
||||||
.map_err(|e| e.to_string())
|
.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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn update_history_limit(
|
pub async fn update_history_limit(
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,7 @@ pub fn run(cli_args: CliArgs) {
|
||||||
commands::history::toggle_history_entry_saved,
|
commands::history::toggle_history_entry_saved,
|
||||||
commands::history::get_audio_file_path,
|
commands::history::get_audio_file_path,
|
||||||
commands::history::delete_history_entry,
|
commands::history::delete_history_entry,
|
||||||
|
commands::history::retry_history_entry_transcription,
|
||||||
commands::history::update_history_limit,
|
commands::history::update_history_limit,
|
||||||
commands::history::update_recording_retention_period,
|
commands::history::update_recording_retention_period,
|
||||||
helpers::clamshell::is_laptop,
|
helpers::clamshell::is_laptop,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use chrono::{DateTime, Local, Utc};
|
use chrono::{DateTime, Local, Utc};
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
@ -10,8 +10,6 @@ use std::path::PathBuf;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_specta::Event;
|
use tauri_specta::Event;
|
||||||
|
|
||||||
use crate::audio_toolkit::save_wav_file;
|
|
||||||
|
|
||||||
/// Database migrations for transcription history.
|
/// Database migrations for transcription history.
|
||||||
/// Each migration is applied in order. The library tracks which migrations
|
/// Each migration is applied in order. The library tracks which migrations
|
||||||
/// have been applied using SQLite's user_version pragma.
|
/// 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_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_prompt TEXT;"),
|
||||||
|
M::up("ALTER TABLE transcription_history ADD COLUMN post_process_requested BOOLEAN NOT NULL DEFAULT 0;"),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||||
|
|
@ -45,6 +44,8 @@ pub struct PaginatedHistory {
|
||||||
pub enum HistoryUpdatePayload {
|
pub enum HistoryUpdatePayload {
|
||||||
#[serde(rename = "added")]
|
#[serde(rename = "added")]
|
||||||
Added { entry: HistoryEntry },
|
Added { entry: HistoryEntry },
|
||||||
|
#[serde(rename = "updated")]
|
||||||
|
Updated { entry: HistoryEntry },
|
||||||
#[serde(rename = "deleted")]
|
#[serde(rename = "deleted")]
|
||||||
Deleted { id: i64 },
|
Deleted { id: i64 },
|
||||||
#[serde(rename = "toggled")]
|
#[serde(rename = "toggled")]
|
||||||
|
|
@ -61,6 +62,7 @@ pub struct HistoryEntry {
|
||||||
pub transcription_text: String,
|
pub transcription_text: String,
|
||||||
pub post_processed_text: Option<String>,
|
pub post_processed_text: Option<String>,
|
||||||
pub post_process_prompt: Option<String>,
|
pub post_process_prompt: Option<String>,
|
||||||
|
pub post_process_requested: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HistoryManager {
|
pub struct HistoryManager {
|
||||||
|
|
@ -194,38 +196,63 @@ impl HistoryManager {
|
||||||
Ok(Connection::open(&self.db_path)?)
|
Ok(Connection::open(&self.db_path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a transcription to history (both database and WAV file)
|
fn map_history_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
|
||||||
pub async fn save_transcription(
|
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,
|
&self,
|
||||||
audio_samples: Vec<f32>,
|
file_name: String,
|
||||||
transcription_text: String,
|
transcription_text: String,
|
||||||
|
post_process_requested: bool,
|
||||||
post_processed_text: Option<String>,
|
post_processed_text: Option<String>,
|
||||||
post_process_prompt: Option<String>,
|
post_process_prompt: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<HistoryEntry> {
|
||||||
let timestamp = Utc::now().timestamp();
|
let timestamp = Utc::now().timestamp();
|
||||||
let file_name = format!("handy-{}.wav", timestamp);
|
|
||||||
let title = self.format_timestamp_title(timestamp);
|
let title = self.format_timestamp_title(timestamp);
|
||||||
|
|
||||||
// Save WAV file
|
let conn = self.get_connection()?;
|
||||||
let file_path = self.recordings_dir.join(&file_name);
|
conn.execute(
|
||||||
save_wav_file(file_path, &audio_samples).await?;
|
"INSERT INTO transcription_history (
|
||||||
|
file_name,
|
||||||
// Save to database
|
timestamp,
|
||||||
let id = self.save_to_database(
|
saved,
|
||||||
file_name.clone(),
|
title,
|
||||||
timestamp,
|
transcription_text,
|
||||||
title.clone(),
|
post_processed_text,
|
||||||
transcription_text.clone(),
|
post_process_prompt,
|
||||||
post_processed_text.clone(),
|
post_process_requested
|
||||||
post_process_prompt.clone(),
|
) 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 {
|
let entry = HistoryEntry {
|
||||||
id,
|
id: conn.last_insert_rowid(),
|
||||||
file_name,
|
file_name,
|
||||||
timestamp,
|
timestamp,
|
||||||
saved: false,
|
saved: false,
|
||||||
|
|
@ -233,32 +260,71 @@ impl HistoryManager {
|
||||||
transcription_text,
|
transcription_text,
|
||||||
post_processed_text,
|
post_processed_text,
|
||||||
post_process_prompt,
|
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);
|
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,
|
&self,
|
||||||
file_name: String,
|
id: i64,
|
||||||
timestamp: i64,
|
|
||||||
title: String,
|
|
||||||
transcription_text: String,
|
transcription_text: String,
|
||||||
post_processed_text: Option<String>,
|
post_processed_text: Option<String>,
|
||||||
post_process_prompt: Option<String>,
|
post_process_prompt: Option<String>,
|
||||||
) -> Result<i64> {
|
) -> Result<HistoryEntry> {
|
||||||
let conn = self.get_connection()?;
|
let conn = self.get_connection()?;
|
||||||
conn.execute(
|
let updated = 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)",
|
"UPDATE transcription_history
|
||||||
params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],
|
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();
|
if updated == 0 {
|
||||||
debug!("Saved transcription to database with id {}", id);
|
return Err(anyhow!("History entry {} not found", id));
|
||||||
Ok(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<()> {
|
pub fn cleanup_old_entries(&self) -> Result<()> {
|
||||||
|
|
@ -389,55 +455,42 @@ impl HistoryManager {
|
||||||
let conn = self.get_connection()?;
|
let conn = self.get_connection()?;
|
||||||
let limit = limit.map(|l| l.min(100));
|
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) {
|
let mut entries: Vec<HistoryEntry> = match (cursor, limit) {
|
||||||
(Some(cursor_id), Some(lim)) => {
|
(Some(cursor_id), Some(lim)) => {
|
||||||
let fetch_count = (lim + 1) as i64;
|
let fetch_count = (lim + 1) as i64;
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM transcription_history
|
||||||
WHERE id < ?1
|
WHERE id < ?1
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?2",
|
LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
let result = stmt
|
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<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
(None, Some(lim)) => {
|
(None, Some(lim)) => {
|
||||||
let fetch_count = (lim + 1) as i64;
|
let fetch_count = (lim + 1) as i64;
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM transcription_history
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?1",
|
LIMIT ?1",
|
||||||
)?;
|
)?;
|
||||||
let result = stmt
|
let result = stmt
|
||||||
.query_map(params![fetch_count], row_mapper)?
|
.query_map(params![fetch_count], Self::map_history_entry)?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
(_, None) => {
|
(_, None) => {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM transcription_history
|
||||||
ORDER BY id DESC",
|
ORDER BY id DESC",
|
||||||
)?;
|
)?;
|
||||||
let result = stmt
|
let result = stmt
|
||||||
.query_map([], row_mapper)?
|
.query_map([], Self::map_history_entry)?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
@ -451,34 +504,53 @@ impl HistoryManager {
|
||||||
Ok(PaginatedHistory { entries, has_more })
|
Ok(PaginatedHistory { entries, has_more })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {
|
#[cfg(test)]
|
||||||
let conn = self.get_connection()?;
|
|
||||||
Self::get_latest_entry_with_conn(&conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_latest_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {
|
fn get_latest_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM transcription_history
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let entry = stmt
|
let entry = stmt.query_row([], Self::map_history_entry).optional()?;
|
||||||
.query_row([], |row| {
|
Ok(entry)
|
||||||
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()?;
|
|
||||||
|
|
||||||
|
/// 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)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,24 +588,21 @@ impl HistoryManager {
|
||||||
pub async fn get_entry_by_id(&self, id: i64) -> Result<Option<HistoryEntry>> {
|
pub async fn get_entry_by_id(&self, id: i64) -> Result<Option<HistoryEntry>> {
|
||||||
let conn = self.get_connection()?;
|
let conn = self.get_connection()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt
|
"SELECT
|
||||||
FROM transcription_history WHERE id = ?1",
|
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
|
let entry = stmt.query_row([id], Self::map_history_entry).optional()?;
|
||||||
.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()?;
|
|
||||||
|
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
@ -596,7 +665,8 @@ mod tests {
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
transcription_text TEXT NOT NULL,
|
transcription_text TEXT NOT NULL,
|
||||||
post_processed_text TEXT,
|
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");
|
.expect("create transcription_history table");
|
||||||
|
|
@ -605,8 +675,16 @@ mod tests {
|
||||||
|
|
||||||
fn insert_entry(conn: &Connection, timestamp: i64, text: &str, post_processed: Option<&str>) {
|
fn insert_entry(conn: &Connection, timestamp: i64, text: &str, post_processed: Option<&str>) {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt)
|
"INSERT INTO transcription_history (
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
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![
|
params![
|
||||||
format!("handy-{}.wav", timestamp),
|
format!("handy-{}.wav", timestamp),
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
@ -614,7 +692,8 @@ mod tests {
|
||||||
format!("Recording {}", timestamp),
|
format!("Recording {}", timestamp),
|
||||||
text,
|
text,
|
||||||
post_processed,
|
post_processed,
|
||||||
Option::<String>::None
|
Option::<String>::None,
|
||||||
|
false,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.expect("insert history entry");
|
.expect("insert history entry");
|
||||||
|
|
@ -641,4 +720,18 @@ mod tests {
|
||||||
assert_eq!(entry.transcription_text, "second");
|
assert_eq!(entry.transcription_text, "second");
|
||||||
assert_eq!(entry.post_processed_text.as_deref(), Some("processed"));
|
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> {
|
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
|
// Update last activity timestamp
|
||||||
self.touch_activity();
|
self.touch_activity();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,19 +232,28 @@ pub fn set_tray_visibility(app: &AppHandle, visible: bool) {
|
||||||
|
|
||||||
pub fn copy_last_transcript(app: &AppHandle) {
|
pub fn copy_last_transcript(app: &AppHandle) {
|
||||||
let history_manager = app.state::<Arc<HistoryManager>>();
|
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(Some(entry)) => entry,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
warn!("No transcription history entries available for tray copy.");
|
warn!("No completed transcription history entries available for tray copy.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to fetch last transcription entry: {}", err);
|
error!(
|
||||||
|
"Failed to fetch last completed transcription entry: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
return;
|
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);
|
error!("Failed to copy last transcript to clipboard: {}", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -267,6 +276,7 @@ mod tests {
|
||||||
transcription_text: transcription.to_string(),
|
transcription_text: transcription.to_string(),
|
||||||
post_processed_text: post_processed.map(|text| text.to_string()),
|
post_processed_text: post_processed.map(|text| text.to_string()),
|
||||||
post_process_prompt: None,
|
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 };
|
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>> {
|
async updateHistoryLimit(limit: number) : Promise<Result<null, string>> {
|
||||||
try {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("update_history_limit", { limit }) };
|
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 ClipboardHandling = "dont_modify" | "copy_to_clipboard"
|
||||||
export type CustomSounds = { start: boolean; stop: boolean }
|
export type CustomSounds = { start: boolean; stop: boolean }
|
||||||
export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary"
|
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 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: "deleted"; id: number } | { action: "toggled"; id: number }
|
export type HistoryUpdatePayload = { action: "added"; entry: HistoryEntry } | { action: "updated"; entry: HistoryEntry } | { action: "deleted"; id: number } | { action: "toggled"; id: number }
|
||||||
/**
|
/**
|
||||||
* Result of changing keyboard implementation
|
* Result of changing keyboard implementation
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
commands,
|
commands,
|
||||||
events,
|
events,
|
||||||
|
|
@ -14,6 +15,27 @@ import { formatDateTime } from "@/utils/dateFormat";
|
||||||
import { AudioPlayer } from "../../ui/AudioPlayer";
|
import { AudioPlayer } from "../../ui/AudioPlayer";
|
||||||
import { Button } from "../../ui/Button";
|
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;
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
interface OpenRecordingsButtonProps {
|
interface OpenRecordingsButtonProps {
|
||||||
|
|
@ -114,6 +136,10 @@ export const HistorySettings: React.FC = () => {
|
||||||
const payload: HistoryUpdatePayload = event.payload;
|
const payload: HistoryUpdatePayload = event.payload;
|
||||||
if (payload.action === "added") {
|
if (payload.action === "added") {
|
||||||
setEntries((prev) => [payload.entry, ...prev]);
|
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,
|
// "deleted" and "toggled" are handled by optimistic updates only,
|
||||||
// so we intentionally ignore them here to avoid double-mutation.
|
// 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 () => {
|
const openRecordingsFolder = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await commands.openRecordingsFolder();
|
const result = await commands.openRecordingsFolder();
|
||||||
|
|
@ -227,6 +260,7 @@ export const HistorySettings: React.FC = () => {
|
||||||
onCopyText={() => copyToClipboard(entry.transcription_text)}
|
onCopyText={() => copyToClipboard(entry.transcription_text)}
|
||||||
getAudioUrl={getAudioUrl}
|
getAudioUrl={getAudioUrl}
|
||||||
deleteAudio={deleteAudioEntry}
|
deleteAudio={deleteAudioEntry}
|
||||||
|
retryTranscription={retryHistoryEntry}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,6 +298,7 @@ interface HistoryEntryProps {
|
||||||
onCopyText: () => void;
|
onCopyText: () => void;
|
||||||
getAudioUrl: (fileName: string) => Promise<string | null>;
|
getAudioUrl: (fileName: string) => Promise<string | null>;
|
||||||
deleteAudio: (id: number) => Promise<void>;
|
deleteAudio: (id: number) => Promise<void>;
|
||||||
|
retryTranscription: (id: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
|
|
@ -272,9 +307,13 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
onCopyText,
|
onCopyText,
|
||||||
getAudioUrl,
|
getAudioUrl,
|
||||||
deleteAudio,
|
deleteAudio,
|
||||||
|
retryTranscription,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [showCopied, setShowCopied] = useState(false);
|
const [showCopied, setShowCopied] = useState(false);
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
|
const hasTranscription = entry.transcription_text.trim().length > 0;
|
||||||
|
|
||||||
const handleLoadAudio = useCallback(
|
const handleLoadAudio = useCallback(
|
||||||
() => getAudioUrl(entry.file_name),
|
() => getAudioUrl(entry.file_name),
|
||||||
|
|
@ -282,6 +321,10 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyText = () => {
|
const handleCopyText = () => {
|
||||||
|
if (!hasTranscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onCopyText();
|
onCopyText();
|
||||||
setShowCopied(true);
|
setShowCopied(true);
|
||||||
setTimeout(() => setShowCopied(false), 2000);
|
setTimeout(() => setShowCopied(false), 2000);
|
||||||
|
|
@ -292,7 +335,19 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
await deleteAudio(entry.id);
|
await deleteAudio(entry.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete entry:", 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="px-4 py-2 pb-5 flex flex-col gap-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-sm font-medium">{formattedDate}</p>
|
<p className="text-sm font-medium">{formattedDate}</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center">
|
||||||
<button
|
<IconButton
|
||||||
onClick={handleCopyText}
|
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")}
|
title={t("settings.history.copyToClipboard")}
|
||||||
>
|
>
|
||||||
{showCopied ? (
|
{showCopied ? (
|
||||||
|
|
@ -313,14 +368,11 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<Copy width={16} height={16} />
|
<Copy width={16} height={16} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
onClick={onToggleSaved}
|
onClick={onToggleSaved}
|
||||||
className={`p-2 rounded-md transition-colors cursor-pointer ${
|
disabled={retrying}
|
||||||
entry.saved
|
active={entry.saved}
|
||||||
? "text-logo-primary hover:text-logo-primary/80"
|
|
||||||
: "text-text/50 hover:text-logo-primary"
|
|
||||||
}`}
|
|
||||||
title={
|
title={
|
||||||
entry.saved
|
entry.saved
|
||||||
? t("settings.history.unsave")
|
? t("settings.history.unsave")
|
||||||
|
|
@ -332,19 +384,61 @@ const HistoryEntryComponent: React.FC<HistoryEntryProps> = ({
|
||||||
height={16}
|
height={16}
|
||||||
fill={entry.saved ? "currentColor" : "none"}
|
fill={entry.saved ? "currentColor" : "none"}
|
||||||
/>
|
/>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<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}
|
onClick={handleDeleteEntry}
|
||||||
className="text-text/50 hover:text-logo-primary transition-colors cursor-pointer"
|
disabled={retrying}
|
||||||
title={t("settings.history.delete")}
|
title={t("settings.history.delete")}
|
||||||
>
|
>
|
||||||
<Trash2 width={16} height={16} />
|
<Trash2 width={16} height={16} />
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</p>
|
||||||
|
|
||||||
<AudioPlayer onLoadRequest={handleLoadAudio} className="w-full" />
|
<AudioPlayer onLoadRequest={handleLoadAudio} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,11 @@
|
||||||
"save": "حفظ التفريغ",
|
"save": "حفظ التفريغ",
|
||||||
"unsave": "إزالة من المحفوظات",
|
"unsave": "إزالة من المحفوظات",
|
||||||
"delete": "حذف الإدخال",
|
"delete": "حذف الإدخال",
|
||||||
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى"
|
"deleteError": ".فشل حذف الإدخال. يرجى المحاولة مرة أخرى",
|
||||||
|
"retranscribe": "إعادة النسخ",
|
||||||
|
"retranscribeError": "فشلت إعادة النسخ. يرجى المحاولة مرة أخرى.",
|
||||||
|
"transcribing": "جارٍ النسخ...",
|
||||||
|
"transcriptionFailed": "فشل النسخ. يمكنك إعادة النسخ باستخدام أيقونة إعادة المحاولة."
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "تصحيح الأخطاء",
|
"title": "تصحيح الأخطاء",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Uložit přepis",
|
"save": "Uložit přepis",
|
||||||
"unsave": "Odebrat z uložených",
|
"unsave": "Odebrat z uložených",
|
||||||
"delete": "Smazat záznam",
|
"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": {
|
"debug": {
|
||||||
"title": "Ladění",
|
"title": "Ladění",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Transkription speichern",
|
"save": "Transkription speichern",
|
||||||
"unsave": "Aus Gespeicherten entfernen",
|
"unsave": "Aus Gespeicherten entfernen",
|
||||||
"delete": "Eintrag löschen",
|
"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": {
|
"debug": {
|
||||||
"title": "Debug",
|
"title": "Debug",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Save transcription",
|
"save": "Save transcription",
|
||||||
"unsave": "Remove from saved",
|
"unsave": "Remove from saved",
|
||||||
"delete": "Delete entry",
|
"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": {
|
"debug": {
|
||||||
"title": "Debug",
|
"title": "Debug",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Guardar transcripción",
|
"save": "Guardar transcripción",
|
||||||
"unsave": "Eliminar de guardados",
|
"unsave": "Eliminar de guardados",
|
||||||
"delete": "Eliminar entrada",
|
"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": {
|
"debug": {
|
||||||
"title": "Depuración",
|
"title": "Depuración",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Enregistrer la transcription",
|
"save": "Enregistrer la transcription",
|
||||||
"unsave": "Retirer des favoris",
|
"unsave": "Retirer des favoris",
|
||||||
"delete": "Supprimer l'entrée",
|
"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": {
|
"debug": {
|
||||||
"title": "Débogage",
|
"title": "Débogage",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Salva la trascrizione",
|
"save": "Salva la trascrizione",
|
||||||
"unsave": "Rimuovi dai salvataggi",
|
"unsave": "Rimuovi dai salvataggi",
|
||||||
"delete": "Elimina elemento",
|
"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": {
|
"debug": {
|
||||||
"title": "Debug",
|
"title": "Debug",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "文字起こしを保存",
|
"save": "文字起こしを保存",
|
||||||
"unsave": "保存から削除",
|
"unsave": "保存から削除",
|
||||||
"delete": "エントリーを削除",
|
"delete": "エントリーを削除",
|
||||||
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。"
|
"deleteError": "エントリーの削除に失敗しました。もう一度お試しください。",
|
||||||
|
"retranscribe": "再文字起こし",
|
||||||
|
"retranscribeError": "再文字起こしに失敗しました。もう一度お試しください。",
|
||||||
|
"transcribing": "文字起こし中...",
|
||||||
|
"transcriptionFailed": "文字起こしに失敗しました。リトライアイコンを使用して再文字起こしできます。"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "デバッグ",
|
"title": "デバッグ",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "변환된 텍스트 저장",
|
"save": "변환된 텍스트 저장",
|
||||||
"unsave": "저장에서 제거",
|
"unsave": "저장에서 제거",
|
||||||
"delete": "항목 삭제",
|
"delete": "항목 삭제",
|
||||||
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요."
|
"deleteError": "항목 삭제에 실패했습니다. 다시 시도해주세요.",
|
||||||
|
"retranscribe": "다시 전사",
|
||||||
|
"retranscribeError": "다시 전사에 실패했습니다. 다시 시도해주세요.",
|
||||||
|
"transcribing": "전사 중...",
|
||||||
|
"transcriptionFailed": "전사에 실패했습니다. 재시도 아이콘을 사용하여 다시 전사할 수 있습니다."
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "디버그",
|
"title": "디버그",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Zapisz transkrypcję",
|
"save": "Zapisz transkrypcję",
|
||||||
"unsave": "Usuń z zapisanych",
|
"unsave": "Usuń z zapisanych",
|
||||||
"delete": "Usuń wpis",
|
"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": {
|
"debug": {
|
||||||
"title": "Debugowanie",
|
"title": "Debugowanie",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Salvar transcrição",
|
"save": "Salvar transcrição",
|
||||||
"unsave": "Remover dos salvos",
|
"unsave": "Remover dos salvos",
|
||||||
"delete": "Excluir entrada",
|
"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": {
|
"debug": {
|
||||||
"title": "Depuração",
|
"title": "Depuração",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Сохранить транскрипцию",
|
"save": "Сохранить транскрипцию",
|
||||||
"unsave": "Удалить из сохраненных",
|
"unsave": "Удалить из сохраненных",
|
||||||
"delete": "Удалить запись",
|
"delete": "Удалить запись",
|
||||||
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз."
|
"deleteError": "Не удалось удалить запись. Пожалуйста, попробуйте еще раз.",
|
||||||
|
"retranscribe": "Перетранскрибировать",
|
||||||
|
"retranscribeError": "Не удалось перетранскрибировать. Пожалуйста, попробуйте снова.",
|
||||||
|
"transcribing": "Транскрибирование...",
|
||||||
|
"transcriptionFailed": "Транскрипция не удалась. Вы можете перетранскрибировать, используя значок повтора."
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "Отлаживать",
|
"title": "Отлаживать",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Transkripsiyonu kaydet",
|
"save": "Transkripsiyonu kaydet",
|
||||||
"unsave": "Kaydedilenlerden kaldır",
|
"unsave": "Kaydedilenlerden kaldır",
|
||||||
"delete": "Kaydı sil",
|
"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": {
|
"debug": {
|
||||||
"title": "Hata Ayıklama",
|
"title": "Hata Ayıklama",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Зберегти транскрипцію",
|
"save": "Зберегти транскрипцію",
|
||||||
"unsave": "Видалити зі збережених",
|
"unsave": "Видалити зі збережених",
|
||||||
"delete": "Видалити запис",
|
"delete": "Видалити запис",
|
||||||
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз."
|
"deleteError": "Не вдалося видалити запис. Спробуйте ще раз.",
|
||||||
|
"retranscribe": "Перетранскрибувати",
|
||||||
|
"retranscribeError": "Не вдалося перетранскрибувати. Будь ласка, спробуйте ще раз.",
|
||||||
|
"transcribing": "Транскрибування...",
|
||||||
|
"transcriptionFailed": "Транскрипція не вдалася. Ви можете перетранскрибувати за допомогою іконки повтору."
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "Дебаг",
|
"title": "Дебаг",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "Lưu bản ghi",
|
"save": "Lưu bản ghi",
|
||||||
"unsave": "Xóa khỏi đã lưu",
|
"unsave": "Xóa khỏi đã lưu",
|
||||||
"delete": "Xóa mục",
|
"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": {
|
"debug": {
|
||||||
"title": "Gỡ lỗi",
|
"title": "Gỡ lỗi",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "儲存轉錄",
|
"save": "儲存轉錄",
|
||||||
"unsave": "從已儲存中移除",
|
"unsave": "從已儲存中移除",
|
||||||
"delete": "刪除條目",
|
"delete": "刪除條目",
|
||||||
"deleteError": "刪除條目失敗,請重試"
|
"deleteError": "刪除條目失敗,請重試",
|
||||||
|
"retranscribe": "重新轉錄",
|
||||||
|
"retranscribeError": "重新轉錄失敗,請重試。",
|
||||||
|
"transcribing": "轉錄中...",
|
||||||
|
"transcriptionFailed": "轉錄失敗。您可以使用重試圖示重新轉錄。"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "偵錯",
|
"title": "偵錯",
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,11 @@
|
||||||
"save": "保存转录",
|
"save": "保存转录",
|
||||||
"unsave": "从已保存中移除",
|
"unsave": "从已保存中移除",
|
||||||
"delete": "删除条目",
|
"delete": "删除条目",
|
||||||
"deleteError": "删除条目失败,请重试。"
|
"deleteError": "删除条目失败,请重试。",
|
||||||
|
"retranscribe": "重新转录",
|
||||||
|
"retranscribeError": "重新转录失败。请重试。",
|
||||||
|
"transcribing": "转录中...",
|
||||||
|
"transcriptionFailed": "转录失败。您可以使用重试图标重新转录。"
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"title": "调试",
|
"title": "调试",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue