feat(etapa2): transcriber sherpa-onnx-offline em Rust
This commit is contained in:
1 parent
c5baf7a23f
commit
695a0d5c42
11 files changed
+1585
-173
No files matched your search
+14
-29
@@ -9,14 +9,14 @@ use crate::models::{model_statuses, ModelWithStatus};
|
||||
use crate::settings::AppSettings;
|
||||
|
||||
pub struct ManagedAppState {
|
||||
pub settings: Mutex<AppSettings>,
|
||||
pub recording: Mutex<RecordingSession>,
|
||||
pub transcript: Mutex<TranscriptDraft>,
|
||||
pub settings: Mutex<AppSettings>,
|
||||
pub model_dir: PathBuf,
|
||||
pub recordings_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscriptDraft {
|
||||
pub text: String,
|
||||
@@ -28,18 +28,18 @@ pub struct TranscriptDraft {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppSnapshot {
|
||||
pub settings: AppSettings,
|
||||
pub recording_status: RecordingStatus,
|
||||
pub transcript: TranscriptDraft,
|
||||
pub settings: AppSettings,
|
||||
pub models: Vec<ModelWithStatus>,
|
||||
}
|
||||
|
||||
impl Default for ManagedAppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(AppSettings::default()),
|
||||
recording: Mutex::new(RecordingSession::default()),
|
||||
transcript: Mutex::new(TranscriptDraft::default()),
|
||||
settings: Mutex::new(AppSettings::default()),
|
||||
model_dir: default_model_dir(),
|
||||
recordings_dir: default_recordings_dir(),
|
||||
}
|
||||
@@ -47,28 +47,30 @@ impl Default for ManagedAppState {
|
||||
}
|
||||
|
||||
impl ManagedAppState {
|
||||
pub fn sherpa_model_dir(&self) -> PathBuf {
|
||||
self.model_dir.join("sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8")
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<AppSnapshot, String> {
|
||||
let settings = self
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "falha ao acessar configurações".to_string())?
|
||||
.clone();
|
||||
let recording_status = self
|
||||
.recording
|
||||
.lock()
|
||||
.map_err(|_| "falha ao acessar estado de gravação".to_string())?
|
||||
.status
|
||||
.clone();
|
||||
let transcript = self
|
||||
.transcript
|
||||
.lock()
|
||||
.map_err(|_| "falha ao acessar transcrição".to_string())?
|
||||
.clone();
|
||||
|
||||
let recording_status = self
|
||||
.recording
|
||||
.lock()
|
||||
.map(|guard| guard.status.clone())
|
||||
.unwrap_or(RecordingStatus::Idle);
|
||||
Ok(AppSnapshot {
|
||||
settings,
|
||||
recording_status,
|
||||
transcript,
|
||||
settings,
|
||||
models: model_statuses(&self.model_dir),
|
||||
})
|
||||
}
|
||||
@@ -85,20 +87,3 @@ fn default_recordings_dir() -> PathBuf {
|
||||
.unwrap_or_else(|_| std::env::temp_dir())
|
||||
.join("recordings")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_snapshot_starts_idle() {
|
||||
let state = ManagedAppState::default();
|
||||
let snapshot = match state.snapshot() {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => panic!("snapshot should work: {error}"),
|
||||
};
|
||||
assert_eq!(snapshot.recording_status, RecordingStatus::Idle);
|
||||
assert!(snapshot.transcript.text.is_empty());
|
||||
assert_eq!(snapshot.models.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::models::DEFAULT_MODEL_ID;
|
||||
|
||||
pub mod crispasr;
|
||||
pub mod openai;
|
||||
pub mod sherpa_onnx;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::diagnostics::{record_audio_size, tail, TranscriptionAttempt};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionResult};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SherpaOnnxConfig {
|
||||
/// Nome do binário sidecar (padrão: `sherpa-onnx-offline`).
|
||||
pub sidecar_name: Option<String>,
|
||||
/// Diretório com os 4 arquivos do modelo (tokens.txt, encoder.onnx, decoder.onnx, joiner.onnx).
|
||||
pub model_dir: Option<PathBuf>,
|
||||
/// Número de threads (default: clamp(1, 4)).
|
||||
pub num_threads: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SherpaOnnxTranscriber {
|
||||
config: SherpaOnnxConfig,
|
||||
}
|
||||
|
||||
impl SherpaOnnxTranscriber {
|
||||
pub fn new(config: SherpaOnnxConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn sidecar_name(&self) -> &str {
|
||||
self.config
|
||||
.sidecar_name
|
||||
.as_deref()
|
||||
.unwrap_or("sherpa-onnx-offline")
|
||||
}
|
||||
|
||||
fn num_threads(&self) -> usize {
|
||||
self.config.num_threads.unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|count| count.get())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, 4)
|
||||
})
|
||||
}
|
||||
|
||||
fn model_dir(&self) -> Option<&Path> {
|
||||
self.config.model_dir.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Transcriber for SherpaOnnxTranscriber {
|
||||
fn transcribe(&self, input: TranscriptionInput) -> AppResult<TranscriptionResult> {
|
||||
let model_dir = self.model_dir().ok_or(AppError::SidecarNotConfigured)?;
|
||||
|
||||
let tokens = model_dir.join("tokens.txt");
|
||||
let encoder = model_dir.join("encoder.int8.onnx");
|
||||
let decoder = model_dir.join("decoder.int8.onnx");
|
||||
let joiner = model_dir.join("joiner.int8.onnx");
|
||||
|
||||
for (label, path) in [
|
||||
("tokens", &tokens),
|
||||
("encoder", &encoder),
|
||||
("decoder", &decoder),
|
||||
("joiner", &joiner),
|
||||
] {
|
||||
if !path.exists() {
|
||||
return Err(AppError::InvalidModel(format!(
|
||||
"arquivo do modelo ausente ({label}): {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let sidecar = self.sidecar_name().to_string();
|
||||
let threads = self.num_threads();
|
||||
let mut program = locate_sidecar(&sidecar, env!("CARGO_MANIFEST_DIR"));
|
||||
let output = program
|
||||
.arg(format!("--tokens={}", tokens.display()))
|
||||
.arg(format!("--encoder={}", encoder.display()))
|
||||
.arg(format!("--decoder={}", decoder.display()))
|
||||
.arg(format!("--joiner={}", joiner.display()))
|
||||
.arg("--model-type=nemo_transducer")
|
||||
.arg(format!("--num-threads={threads}"))
|
||||
.arg(&input.audio_path)
|
||||
.output()
|
||||
.map_err(|error| AppError::Transcription(error.to_string()))?;
|
||||
|
||||
let attempt = TranscriptionAttempt {
|
||||
stage: if output.status.success() {
|
||||
"ok".to_string()
|
||||
} else {
|
||||
"sidecar_error".to_string()
|
||||
},
|
||||
audio_path: input.audio_path.clone(),
|
||||
audio_size_bytes: record_audio_size(&input.audio_path),
|
||||
sidecar: sidecar.clone(),
|
||||
model: model_dir.display().to_string(),
|
||||
exit_code: output.status.code(),
|
||||
stdout_tail: tail(&String::from_utf8_lossy(&output.stdout), 1024),
|
||||
stderr_tail: tail(&String::from_utf8_lossy(&output.stderr), 1024),
|
||||
error: None,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = attempt.stderr_tail.trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
format!("sidecar encerrou com status {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(AppError::Transcription(message));
|
||||
}
|
||||
|
||||
let text = parse_first_text(&attempt.stdout_tail).ok_or_else(|| {
|
||||
AppError::Transcription("sidecar não retornou texto transcrito".to_string())
|
||||
})?;
|
||||
|
||||
Ok(TranscriptionResult {
|
||||
text,
|
||||
backend: "sherpa-onnx".to_string(),
|
||||
model_id: input.options.model_id,
|
||||
detected_language: input.options.language,
|
||||
attempt: Some(attempt),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Procura o sidecar na ordem:
|
||||
/// 1. variável de ambiente `SHERPA_ONNX_SIDECAR` (para testes e override manual)
|
||||
/// 2. `src-tauri/binaries/sherpa-onnx-offline-<triple><ext>` relativo ao
|
||||
/// `CARGO_MANIFEST_DIR` (produção empacotado e dev)
|
||||
/// 3. nome nu `sherpa-onnx-offline` (PATH do sistema, dev, CI)
|
||||
fn locate_sidecar(sidecar_name: &str, manifest_dir: &str) -> Command {
|
||||
if let Ok(path) = std::env::var("SHERPA_ONNX_SIDECAR") {
|
||||
return Command::new(path);
|
||||
}
|
||||
let manifest_path = std::path::Path::new(manifest_dir);
|
||||
if let Some(parent) = manifest_path.parent() {
|
||||
let candidates = [
|
||||
parent.join("binaries").join(format!("{sidecar_name}.exe")),
|
||||
parent.join("binaries").join(sidecar_name),
|
||||
];
|
||||
for candidate in candidates {
|
||||
if candidate.exists() {
|
||||
return Command::new(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
let manifest_candidates = [
|
||||
manifest_path
|
||||
.join("binaries")
|
||||
.join(format!("{sidecar_name}.exe")),
|
||||
manifest_path.join("binaries").join(sidecar_name),
|
||||
];
|
||||
for candidate in manifest_candidates {
|
||||
if candidate.exists() {
|
||||
return Command::new(candidate);
|
||||
}
|
||||
}
|
||||
Command::new(sidecar_name)
|
||||
}
|
||||
|
||||
/// Extrai o campo `text` da **primeira** linha de JSON válida no stdout.
|
||||
/// O `sherpa-onnx-offline` imprime uma linha de status, depois um JSON por
|
||||
/// arquivo. Pegamos o primeiro JSON e seu `text`.
|
||||
fn parse_first_text(stdout: &str) -> Option<String> {
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with('{') {
|
||||
continue;
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_str(trimmed).ok()?;
|
||||
if let Some(text) = parsed.get("text").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn unique_temp_dir(name: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("transcript-sherpa-{name}-{nanos}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_first_text_reads_json_text_field() {
|
||||
let stdout =
|
||||
"Creating recognizer ...\nDone!\n{\"text\": \" hello world \", \"lang\": \"\"}\n----\n";
|
||||
assert_eq!(parse_first_text(stdout).as_deref(), Some("hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_first_text_returns_none_when_no_json() {
|
||||
assert!(parse_first_text("nada aqui\nnem aqui").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_first_text_skips_non_json_lines() {
|
||||
let stdout =
|
||||
"Elapsed seconds: 0.523 s\n{\"text\": \"the quick brown fox\", \"lang\": \"\"}\n";
|
||||
assert_eq!(
|
||||
parse_first_text(stdout).as_deref(),
|
||||
Some("the quick brown fox"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_first_text_keeps_first_valid_json() {
|
||||
let stdout =
|
||||
"{\"text\": \"first\", \"lang\": \"\"}\n{\"text\": \"second\", \"lang\": \"\"}\n";
|
||||
assert_eq!(parse_first_text(stdout).as_deref(), Some("first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_sidecar_name() {
|
||||
let t = SherpaOnnxTranscriber::new(SherpaOnnxConfig {
|
||||
sidecar_name: None,
|
||||
model_dir: Some(unique_temp_dir("cfg")),
|
||||
num_threads: Some(2),
|
||||
});
|
||||
assert_eq!(t.sidecar_name(), "sherpa-onnx-offline");
|
||||
assert_eq!(t.num_threads(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_sidecar_finds_local_binary() {
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
let cmd = locate_sidecar("sherpa-onnx-offline", manifest);
|
||||
let program = cmd.get_program().to_string_lossy().to_string();
|
||||
// Em dev, o sidecar já está commitado em src-tauri/binaries/.
|
||||
assert!(
|
||||
program.contains("sherpa-onnx-offline"),
|
||||
"esperava sherpa-onnx-offline no path, achei {program}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::models::{
|
||||
ModelWithStatus,
|
||||
};
|
||||
use crate::settings::AppSettings;
|
||||
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
||||
use crate::transcription::sherpa_onnx::{SherpaOnnxConfig, SherpaOnnxTranscriber};
|
||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
||||
|
||||
#[tauri::command]
|
||||
@@ -46,9 +46,10 @@ pub(crate) fn stop_recording_and_transcribe(
|
||||
.map_err(|_| "falha ao acessar configurações".to_string())?
|
||||
.clone();
|
||||
|
||||
let transcriber = CrispAsrTranscriber::new(CrispAsrConfig {
|
||||
sidecar_name: Some("parakeet-main".to_string()),
|
||||
model_path: Some(default_model_path(&state.model_dir)),
|
||||
let transcriber = SherpaOnnxTranscriber::new(SherpaOnnxConfig {
|
||||
sidecar_name: Some("sherpa-onnx-offline".to_string()),
|
||||
model_dir: Some(state.sherpa_model_dir()),
|
||||
num_threads: None,
|
||||
});
|
||||
|
||||
let model_id = settings.transcription.model_id.clone();
|
||||
@@ -219,7 +220,7 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::app_state::TranscriptDraft;
|
||||
use crate::transcription::crispasr::CrispAsrTranscriber;
|
||||
use crate::transcription::sherpa_onnx::{SherpaOnnxConfig, SherpaOnnxTranscriber};
|
||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
||||
|
||||
fn unique_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -236,15 +237,24 @@ mod tests {
|
||||
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()),
|
||||
// Cria diretório de modelo com arquivos placeholder para o
|
||||
// transcriber não falhar antes de invocar o sidecar inexistente.
|
||||
let model_dir = unique_path("sherpa-empty-model");
|
||||
std::fs::create_dir_all(&model_dir).unwrap_or_else(|error| panic!("mkdir model: {error}"));
|
||||
for name in ["tokens.txt", "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx"] {
|
||||
std::fs::write(model_dir.join(name), b"placeholder")
|
||||
.unwrap_or_else(|error| panic!("write {name}: {error}"));
|
||||
}
|
||||
let transcriber = SherpaOnnxTranscriber::new(SherpaOnnxConfig {
|
||||
sidecar_name: Some("sherpa-onnx-offline-does-not-exist".to_string()),
|
||||
model_dir: Some(model_dir.clone()),
|
||||
num_threads: Some(1),
|
||||
});
|
||||
|
||||
let input = TranscriptionInput {
|
||||
audio_path: audio_path.clone(),
|
||||
options: TranscriptionOptions {
|
||||
model_id: "parakeet-tdt-0.6b-v3-q8_0".to_string(),
|
||||
model_id: "parakeet-tdt-0.6b-v3".to_string(),
|
||||
language: None,
|
||||
timestamps: false,
|
||||
},
|
||||
@@ -258,8 +268,8 @@ mod tests {
|
||||
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(),
|
||||
sidecar: "sherpa-onnx-offline-does-not-exist".to_string(),
|
||||
model: model_dir.display().to_string(),
|
||||
exit_code: None,
|
||||
stdout_tail: String::new(),
|
||||
stderr_tail: String::new(),
|
||||
@@ -278,5 +288,6 @@ mod tests {
|
||||
assert!(Path::new(&stored.audio_path).exists());
|
||||
|
||||
let _ = fs::remove_file(audio_path);
|
||||
let _ = fs::remove_dir_all(model_dir);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user