Files
transcript/src-tauri/src/transcription/openai.rs
T
Evandro 20e3f7e501 feat: default to local Parakeet TDT ptBR-TAGARELA
- OpenAiTranscriptionConfig::default() model: alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx
- AppSettings default language: Some("pt") for PT-BR native
- Update tests in openai.rs and app.ts
- Update frontend settings modal placeholder
- Aligns with orelhIA MCP (same model and endpoint) — no API key needed

This connects transcript desktop to the same Parakeet backend that
orelhIA uses, so the same 'a orelha que seus agentes sempre precisaram'
branding applies here too.
2026-06-20 17:17:56 -03:00

284 lines
8.8 KiB
Rust

use std::io::Read;
use std::path::{Path, PathBuf};
use reqwest::blocking::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use crate::diagnostics::{record_audio_size, tail, TranscriptionAttempt};
use crate::error::{AppError, AppResult};
use crate::secrets::SecretReader;
use super::{Transcriber, TranscriptionInput, TranscriptionResult};
/// Configuração para transcrição via API compatível com OpenAI.
///
/// Esta config **não** armazena a `api_key` em texto claro. A chave é obtida
/// via `SecretReader` (credential manager do SO) no momento da requisição,
/// e a presença dela é reportada pelo flag `api_key_configured`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OpenAiTranscriptionConfig {
/// URL completa do endpoint (ex.: `http://localhost:8022/v1/audio/transcriptions`).
pub endpoint: String,
/// Modelo a ser enviado (ex.: `whisper-1`, `Systrim/Kokoro-TTS`).
pub model: String,
/// Idioma opcional no formato ISO-639-1 (ex.: `"pt"`, `"en"`).
pub language: Option<String>,
/// Apenas indica se existe uma api_key salva; nunca exposto para a UI.
pub api_key_configured: bool,
}
impl Default for OpenAiTranscriptionConfig {
fn default() -> Self {
Self {
endpoint: "http://localhost:8022/v1/audio/transcriptions".to_string(), // Parakeet local default
model: "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx".to_string(), // PT-BR nativo
language: None,
api_key_configured: false,
}
}
}
impl OpenAiTranscriptionConfig {
/// Mesma config mas com o flag `api_key_configured` refletindo o estado real.
pub fn with_key_state(mut self, configured: bool) -> Self {
self.api_key_configured = configured;
self
}
}
/// Transcritor que envia o WAV para o endpoint compatível com OpenAI.
#[derive(Clone)]
pub struct OpenAiTranscriber {
config: OpenAiTranscriptionConfig,
/// Fonte de leitura da `api_key` (credential manager do SO).
secrets: std::sync::Arc<dyn SecretReader>,
}
impl std::fmt::Debug for OpenAiTranscriber {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("OpenAiTranscriber")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl OpenAiTranscriber {
pub fn new(
config: OpenAiTranscriptionConfig,
secrets: std::sync::Arc<dyn SecretReader>,
) -> Self {
Self { config, secrets }
}
pub fn config(&self) -> &OpenAiTranscriptionConfig {
&self.config
}
pub fn endpoint(&self) -> &str {
&self.config.endpoint
}
pub fn model(&self) -> &str {
&self.config.model
}
pub fn language(&self) -> Option<&str> {
self.config.language.as_deref()
}
}
impl Transcriber for OpenAiTranscriber {
fn transcribe(&self, input: TranscriptionInput) -> AppResult<TranscriptionResult> {
let audio_path: &Path = &input.audio_path;
let audio_size = record_audio_size(audio_path);
if audio_size == 0 {
return Err(AppError::Audio(format!(
"WAV gravado está vazio: {}",
audio_path.display()
)));
}
if self.config.endpoint.trim().is_empty() {
return Err(AppError::Configuration(
"Endpoint da API compatível com OpenAI não configurado.".to_string(),
));
}
if self.config.model.trim().is_empty() {
return Err(AppError::Configuration(
"Modelo da API compatível com OpenAI não configurado.".to_string(),
));
}
let attempt = TranscriptionAttempt {
stage: "openai_request".to_string(),
audio_path: PathBuf::from(audio_path),
audio_size_bytes: audio_size,
sidecar: "openai-compat-http".to_string(),
model: self.config.model.clone(),
exit_code: None,
stdout_tail: String::new(),
stderr_tail: String::new(),
error: None,
};
let api_key = self.secrets.read_api_key().ok().flatten();
let response = match send_transcription_request(
&self.config.endpoint,
&self.config.model,
self.config.language.as_deref(),
audio_path,
api_key.as_deref(),
) {
Ok(text) => text,
Err(error) => {
return Err(AppError::Transcription(format!(
"{error} (modelo={}, endpoint={})",
self.config.model, self.config.endpoint
)));
}
};
let text = response.trim().to_string();
if text.is_empty() {
return Err(AppError::Transcription(
"API retornou resposta vazia.".to_string(),
));
}
let mut ok_attempt = attempt;
ok_attempt.stage = "ok".to_string();
ok_attempt.stdout_tail = tail(&text, 1024);
Ok(TranscriptionResult {
text,
backend: "openai-compat".to_string(),
model_id: self.config.model.clone(),
detected_language: self.config.language.clone(),
attempt: Some(ok_attempt),
})
}
}
fn send_transcription_request(
endpoint: &str,
model: &str,
language: Option<&str>,
audio_path: &Path,
api_key: Option<&str>,
) -> Result<String, String> {
let mut file = std::fs::File::open(audio_path)
.map_err(|error| format!("falha ao abrir áudio: {error}"))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| format!("falha ao ler áudio: {error}"))?;
let file_name = audio_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("audio.wav")
.to_string();
let audio_part = Part::bytes(bytes)
.file_name(file_name)
.mime_str("audio/wav")
.map_err(|error| format!("mime inválido: {error}"))?;
let mut form = Form::new()
.text("model", model.to_string())
.part("file", audio_part);
if let Some(lang) = language {
if !lang.trim().is_empty() {
form = form.text("language", lang.to_string());
}
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|error| format!("cliente HTTP inválido: {error}"))?;
let mut request = client.post(endpoint).multipart(form);
if let Some(key) = api_key {
if !key.trim().is_empty() {
request = request.bearer_auth(key);
}
}
let response = request
.send()
.map_err(|error| format!("falha na requisição: {error}"))?;
let status = response.status();
let body = response
.text()
.map_err(|error| format!("falha ao ler corpo: {error}"))?;
if !status.is_success() {
return Err(format!(
"API respondeu {status}: {}",
body.chars().take(400).collect::<String>()
));
}
// Formato OpenAI padrão: `{"text": "..."}`.
if let Ok(parsed) = serde_json::from_str::<OpenAiTranscriptionResponse>(&body) {
if let Some(text) = parsed.text {
return Ok(text);
}
}
let trimmed = body.trim();
// Texto puro: o servidor devolve a transcrição sem envelopar em JSON.
if !trimmed.is_empty() && !trimmed.starts_with('{') && !trimmed.starts_with('[') {
return Ok(trimmed.to_string());
}
// Alguns provedores customizam o nome do campo. Tentamos várias chaves.
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
for key in ["text", "transcript", "result", "output", "data"] {
if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
return Ok(text.to_string());
}
}
}
Err(format!(
"não foi possível extrair texto da resposta da API: {}",
body.chars().take(400).collect::<String>()
))
}
#[derive(Debug, Deserialize)]
struct OpenAiTranscriptionResponse {
text: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_endpoint_targets_local_speaches() {
let config = OpenAiTranscriptionConfig::default();
assert_eq!(
config.endpoint,
"http://localhost:8022/v1/audio/transcriptions"
);
assert_eq!(config.model, "alefiury/parakeet-tdt-0.6b-v3-ptBR-TAGARELA-onnx");
assert!(!config.api_key_configured);
}
#[test]
fn with_key_state_reflects_credential() {
let config = OpenAiTranscriptionConfig::default().with_key_state(true);
assert!(config.api_key_configured);
let config = OpenAiTranscriptionConfig::default().with_key_state(false);
assert!(!config.api_key_configured);
}
}