feat: add transcription diagnostics for empty result bug
This commit is contained in:
1 parent
b111ae0a76
commit
b2554006a4
7 files changed
+303
-11
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user