feat: add transcription diagnostics for empty result bug

This commit is contained in:
Evandro committed 2026-06-11 02:09:37 -03:00
1 parent b111ae0a76
commit b2554006a4
7 files changed
+303 -11

No files matched your search

+3
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_attempt: Option<TranscriptionAttempt>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+82
View File
@@ -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<i32>,
pub stdout_tail: String,
pub stderr_tail: String,
pub error: Option<String>,
}
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<u64, AppError> {
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);
}
}
+1
View File
@@ -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;
+17
View File
@@ -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,
}),
})
}
}
+3
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt: Option<TranscriptionAttempt>,
}
pub trait Transcriber {
+144 -7
View File
@@ -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);
}
}