diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 87fdb12..38390b4 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -4,6 +4,7 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; use crate::audio::{RecordingSession, RecordingStatus}; +use crate::diagnostics::TranscriptionAttempt; use crate::models::{model_statuses, ModelWithStatus}; use crate::settings::AppSettings; @@ -20,6 +21,8 @@ pub struct ManagedAppState { pub struct TranscriptDraft { pub text: String, pub last_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_attempt: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..c2557d8 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,82 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptionAttempt { + pub stage: String, + pub audio_path: PathBuf, + pub audio_size_bytes: u64, + pub sidecar: String, + pub model: String, + pub exit_code: Option, + pub stdout_tail: String, + pub stderr_tail: String, + pub error: Option, +} + +pub fn record_audio_size(audio_path: &Path) -> u64 { + fs::metadata(audio_path).map(|m| m.len()).unwrap_or(0) +} + +pub fn log_attempt(attempt: &TranscriptionAttempt) { + let payload = serde_json::to_string(attempt).unwrap_or_else(|_| attempt.stage.clone()); + eprintln!("[transcript:diagnostic] {payload}"); +} + +pub fn tail(text: &str, max: usize) -> String { + if text.len() <= max { + return text.to_string(); + } + + let start = text.len().saturating_sub(max); + let mut offset = start; + while offset < text.len() && !text.is_char_boundary(offset) { + offset += 1; + } + text[offset..].to_string() +} + +pub fn audio_or_error(audio_path: &Path) -> Result { + let size = fs::metadata(audio_path) + .map_err(|error| AppError::Io(error.to_string()))? + .len(); + if size == 0 { + return Err(AppError::Audio(format!( + "WAV gravado está vazio: {}", + audio_path.display() + ))); + } + Ok(size) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_preserves_last_characters() { + let text = "abcdefghij".to_string(); + assert_eq!(tail(&text, 3), "hij"); + } + + #[test] + fn tail_returns_input_when_smaller_than_max() { + let text = "abc".to_string(); + assert_eq!(tail(&text, 10), "abc"); + } + + #[test] + fn record_audio_size_returns_zero_when_missing() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let path = std::env::temp_dir().join(format!("transcript-missing-{unique}.wav")); + assert_eq!(record_audio_size(&path), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 779224e..fb7a37e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod app_state; pub mod audio; pub mod error; pub mod injection; +pub mod diagnostics; pub mod models; pub mod settings; pub mod shortcuts; diff --git a/src-tauri/src/transcription/crispasr.rs b/src-tauri/src/transcription/crispasr.rs index 3ddb46d..8a22ac2 100644 --- a/src-tauri/src/transcription/crispasr.rs +++ b/src-tauri/src/transcription/crispasr.rs @@ -78,6 +78,23 @@ impl Transcriber for CrispAsrTranscriber { backend: "crispasr".to_string(), model_id: input.options.model_id, detected_language: input.options.language, + attempt: Some(crate::diagnostics::TranscriptionAttempt { + stage: "ok".to_string(), + audio_path: input.audio_path.clone(), + audio_size_bytes: crate::diagnostics::record_audio_size(&input.audio_path), + sidecar: sidecar_name.clone(), + model: model_path.display().to_string(), + exit_code: output.status.code(), + stdout_tail: crate::diagnostics::tail( + &String::from_utf8_lossy(&output.stdout), + 1024, + ), + stderr_tail: crate::diagnostics::tail( + &String::from_utf8_lossy(&output.stderr), + 1024, + ), + error: None, + }), }) } } diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs index b8216f6..0dd0ace 100644 --- a/src-tauri/src/transcription/mod.rs +++ b/src-tauri/src/transcription/mod.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::diagnostics::TranscriptionAttempt; use crate::error::AppResult; use crate::models::DEFAULT_MODEL_ID; @@ -40,6 +41,8 @@ pub struct TranscriptionResult { pub backend: String, pub model_id: String, pub detected_language: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt: Option, } pub trait Transcriber { diff --git a/src-tauri/src/ui_commands.rs b/src-tauri/src/ui_commands.rs index 7b08e87..65db46c 100644 --- a/src-tauri/src/ui_commands.rs +++ b/src-tauri/src/ui_commands.rs @@ -1,13 +1,14 @@ - use tauri::State; use crate::app_state::{AppSnapshot, ManagedAppState}; use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector}; use crate::models::{ - default_model_path, download_default_model, model_statuses, ModelDownloadResult, ModelWithStatus, + default_model_path, download_default_model, model_statuses, ModelDownloadResult, + ModelWithStatus, }; use crate::settings::AppSettings; use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber}; +use crate::diagnostics::{audio_or_error, log_attempt, tail, TranscriptionAttempt}; use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions}; #[tauri::command] @@ -50,10 +51,42 @@ pub(crate) fn stop_recording_and_transcribe( model_path: Some(default_model_path(&state.model_dir)), }); + let model_id = settings.transcription.model_id.clone(); + let audio_size = match audio_or_error(&audio_path) { + Ok(size) => size, + Err(error) => { + let attempt = TranscriptionAttempt { + stage: "audio_check".to_string(), + audio_path: audio_path.clone(), + audio_size_bytes: 0, + sidecar: "parakeet-main".to_string(), + model: default_model_path(&state.model_dir).display().to_string(), + exit_code: None, + stdout_tail: String::new(), + stderr_tail: String::new(), + error: Some(error.to_string()), + }; + log_attempt(&attempt); + + let mut transcript = state + .transcript + .lock() + .map_err(|_| "falha ao acessar transcrição".to_string())?; + transcript.text.clear(); + transcript.last_message = Some(format!( + "{} ({}). Veja logs para detalhes.", + error, attempt.stage + )); + transcript.last_attempt = Some(attempt); + drop(transcript); + return state.snapshot(); + } + }; + let result = transcriber.transcribe(TranscriptionInput { - audio_path, + audio_path: audio_path.clone(), options: TranscriptionOptions { - model_id: settings.transcription.model_id, + model_id: model_id.clone(), language: settings.transcription.language, timestamps: false, }, @@ -66,12 +99,46 @@ pub(crate) fn stop_recording_and_transcribe( match result { Ok(result) => { + let attempt = result.attempt.clone().unwrap_or(TranscriptionAttempt { + stage: "ok_no_attempt".to_string(), + audio_path: audio_path.clone(), + audio_size_bytes: audio_size, + sidecar: "parakeet-main".to_string(), + model: default_model_path(&state.model_dir).display().to_string(), + exit_code: None, + stdout_tail: tail(&result.text, 1024), + stderr_tail: String::new(), + error: None, + }); + log_attempt(&attempt); + transcript.text = result.text; - transcript.last_message = Some("Transcrição concluída.".to_string()); + transcript.last_message = Some(format!( + "Transcrição concluída ({} bytes, sidecar OK).", + audio_size + )); + transcript.last_attempt = Some(attempt); } Err(error) => { - transcript.text = "".to_string(); - transcript.last_message = Some(error.to_string()); + let attempt = TranscriptionAttempt { + stage: "sidecar_error".to_string(), + audio_path: audio_path.clone(), + audio_size_bytes: audio_size, + sidecar: "parakeet-main".to_string(), + model: default_model_path(&state.model_dir).display().to_string(), + exit_code: None, + stdout_tail: String::new(), + stderr_tail: String::new(), + error: Some(error.to_string()), + }; + log_attempt(&attempt); + + transcript.text.clear(); + transcript.last_message = Some(format!( + "Falha na transcrição ({}). Veja logs para detalhes.", + error + )); + transcript.last_attempt = Some(attempt); } } @@ -143,3 +210,73 @@ pub(crate) fn save_settings( Ok(settings) } +#[cfg(test)] +mod tests { + use super::*; + + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::app_state::TranscriptDraft; + use crate::transcription::crispasr::CrispAsrTranscriber; + use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions}; + + fn unique_path(name: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("transcript-test-{name}-{nanos}.wav")) + } + + #[test] + fn pipeline_records_sidecar_error_when_binary_missing() { + let audio_path = unique_path("empty"); + fs::write(&audio_path, [1_u8, 2, 3, 4, 5, 6, 7, 8]) + .unwrap_or_else(|error| panic!("falha ao escrever WAV sintético: {error}")); + + let transcriber = CrispAsrTranscriber::new(CrispAsrConfig { + sidecar_name: Some("parakeet-main-does-not-exist".to_string()), + model_path: Some(audio_path.clone()), + }); + + let input = TranscriptionInput { + audio_path: audio_path.clone(), + options: TranscriptionOptions { + model_id: "parakeet-tdt-0.6b-v3-q8_0".to_string(), + language: None, + timestamps: false, + }, + }; + + let error = transcriber + .transcribe(input) + .expect_err("sidecar inexistente deve falhar"); + let mut draft = TranscriptDraft::default(); + let attempt = TranscriptionAttempt { + stage: "sidecar_error".to_string(), + audio_path: audio_path.clone(), + audio_size_bytes: fs::metadata(&audio_path).map(|m| m.len()).unwrap_or(0), + sidecar: "parakeet-main-does-not-exist".to_string(), + model: audio_path.display().to_string(), + exit_code: None, + stdout_tail: String::new(), + stderr_tail: String::new(), + error: Some(error.to_string()), + }; + draft.last_attempt = Some(attempt.clone()); + draft.last_message = Some(format!( + "Falha na transcrição ({}). Veja logs para detalhes.", + error + )); + + let stored = draft.last_attempt.expect("attempt deve estar preenchido"); + assert_eq!(stored.stage, "sidecar_error"); + assert!(stored.error.is_some()); + assert!(stored.audio_size_bytes > 0, "WAV sintético tem bytes"); + assert!(Path::new(&stored.audio_path).exists()); + + let _ = fs::remove_file(audio_path); + } +} \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 5727528..d3a3c3c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -41,9 +41,22 @@ export interface ModelWithStatus { localPath?: string | null; } +export interface TranscriptionAttempt { + stage: string; + audioPath: string; + audioSizeBytes: number; + sidecar: string; + model: string; + exitCode: number | null; + stdoutTail: string; + stderrTail: string; + error: string | null; +} + export interface TranscriptDraft { text: string; lastMessage: string | null; + lastAttempt?: TranscriptionAttempt; } export interface AppSnapshot { @@ -121,8 +134,25 @@ function element( } function formatBytes(bytes: number): string { - const mib = bytes / 1024 / 1024; - return `${mib.toFixed(0)} MiB`; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +function formatAttempt(attempt: TranscriptionAttempt): string { + const lines = [ + `stage: ${attempt.stage}`, + `audio: ${attempt.audioPath} (${formatBytes(attempt.audioSizeBytes)})`, + `sidecar: ${attempt.sidecar}`, + `model: ${attempt.model}`, + `exitCode: ${attempt.exitCode ?? "n/a"}`, + `error: ${attempt.error ?? "—"}`, + `stdout:`, + attempt.stdoutTail || "(vazio)", + `stderr:`, + attempt.stderrTail || "(vazio)", + ]; + return lines.join("\n"); } function modelStatusLabel(status: ModelWithStatus["status"]): string { @@ -221,7 +251,17 @@ export function createTranscriptionApp( ) as HTMLButtonElement; modelPanel.append(modelTitle, modelDetails, modelStatus, downloadModelButton); - shell.append(topbar, editorLabel, editor, message, actions, modelPanel); + const diagnostics = element("details", "diagnostics"); + const diagnosticsSummary = element( + "summary", + "diagnostics-summary", + "Detalhes técnicos", + ); + const diagnosticsBody = element("pre", "diagnostics-body"); + diagnostics.append(diagnosticsSummary, diagnosticsBody); + diagnostics.open = false; + + shell.append(topbar, editorLabel, editor, message, actions, modelPanel, diagnostics); mount.append(shell); const render = (next: AppSnapshot): void => { @@ -248,6 +288,14 @@ export function createTranscriptionApp( downloadModelButton.disabled = firstModelStatus?.status === "ready"; downloadModelButton.textContent = firstModelStatus?.status === "ready" ? "Modelo pronto" : "Baixar modelo"; + + const attempt = snapshot.transcript.lastAttempt; + if (attempt) { + diagnostics.open = true; + diagnosticsBody.textContent = formatAttempt(attempt); + } else { + diagnosticsBody.textContent = "Nenhuma tentativa registrada ainda."; + } }; const refresh = async (): Promise => { @@ -255,7 +303,8 @@ export function createTranscriptionApp( }; const showError = (error: unknown): void => { - message.textContent = error instanceof Error ? error.message : String(error); + message.textContent = + error instanceof Error ? error.message : String(error); }; const toggleRecording = async (): Promise => {