feat(etapa2): transcriber sherpa-onnx-offline em Rust

This commit is contained in:
Evandro committed 2026-06-11 03:42:53 -03:00
1 parent c5baf7a23f
commit 695a0d5c42
11 files changed
+1585 -173

No files matched your search

+14 -29
View File
@@ -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);
}
}
+1
View File
@@ -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")]
+249
View File
@@ -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}"
);
}
}
+22 -11
View File
@@ -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);
}
}