feat: add transcription backend contracts

This commit is contained in:
Evandro committed 2026-06-10 20:55:10 -03:00
1 parent 3b05cb22a0
commit 54e8b42330
9 files changed
+461

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum RecordingStatus {
Idle,
Recording,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RecordingSession {
pub status: RecordingStatus,
pub sequence: u64,
}
impl Default for RecordingSession {
fn default() -> Self {
Self {
status: RecordingStatus::Idle,
sequence: 0,
}
}
}
impl RecordingSession {
pub fn start(&mut self) -> AppResult<()> {
if self.status == RecordingStatus::Recording {
return Err(AppError::RecordingAlreadyActive);
}
self.sequence += 1;
self.status = RecordingStatus::Recording;
Ok(())
}
pub fn stop(&mut self) -> AppResult<u64> {
if self.status == RecordingStatus::Idle {
return Err(AppError::NoActiveRecording);
}
self.status = RecordingStatus::Idle;
Ok(self.sequence)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_session_requires_start_before_stop() {
let mut session = RecordingSession::default();
assert_eq!(session.stop(), Err(AppError::NoActiveRecording));
session.start().expect("start should work");
assert_eq!(session.stop(), Ok(1));
}
}
+27
View File
@@ -0,0 +1,27 @@
use serde::Serialize;
use thiserror::Error;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug, Error, Serialize, Clone, PartialEq, Eq)]
#[serde(tag = "code", content = "message")]
pub enum AppError {
#[error("modelo offline não instalado: {0}")]
ModelNotInstalled(String),
#[error("sidecar CrispASR não configurado")]
SidecarNotConfigured,
#[error("gravação já está em andamento")]
RecordingAlreadyActive,
#[error("nenhuma gravação ativa")]
NoActiveRecording,
#[error("operação ainda não suportada neste ambiente: {0}")]
Unsupported(String),
#[error("erro de configuração: {0}")]
Configuration(String),
}
impl From<AppError> for String {
fn from(value: AppError) -> Self {
value.to_string()
}
}
+116
View File
@@ -0,0 +1,116 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FocusTarget {
pub platform: Platform,
pub display_server: DisplayServer,
pub window_title: Option<String>,
pub process_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum Platform {
Windows,
Linux,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum DisplayServer {
Windows,
X11,
Wayland,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum InsertCapability {
Full,
Partial,
Unsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum InsertMethod {
ClipboardPaste,
KeyTyping,
ManualCopy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InsertRequest {
pub text: String,
pub target: Option<FocusTarget>,
pub method: InsertMethod,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InsertResult {
pub capability: InsertCapability,
pub method_used: InsertMethod,
pub message: String,
}
pub trait TextInjector {
fn can_insert(&self, target: Option<&FocusTarget>) -> InsertCapability;
fn insert_text(&self, request: InsertRequest) -> InsertResult;
}
#[derive(Debug, Default, Clone)]
pub struct FallbackInjector;
impl TextInjector for FallbackInjector {
fn can_insert(&self, target: Option<&FocusTarget>) -> InsertCapability {
match target.map(|target| &target.display_server) {
Some(DisplayServer::Windows | DisplayServer::X11) => InsertCapability::Full,
Some(DisplayServer::Wayland) => InsertCapability::Partial,
_ => InsertCapability::Unsupported,
}
}
fn insert_text(&self, request: InsertRequest) -> InsertResult {
let capability = self.can_insert(request.target.as_ref());
let message = match capability {
InsertCapability::Full => {
"Inserção automática preparada; implementação nativa será conectada ao adaptador."
}
InsertCapability::Partial => {
"Wayland pode bloquear automação. Texto mantido para cópia manual com Ctrl+V."
}
InsertCapability::Unsupported => {
"Destino não confiável. Copie o texto manualmente a partir do modal."
}
};
InsertResult {
capability,
method_used: request.method,
message: message.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wayland_reports_partial_insert_capability() {
let injector = FallbackInjector;
let target = FocusTarget {
platform: Platform::Linux,
display_server: DisplayServer::Wayland,
window_title: Some("Editor".to_string()),
process_name: Some("app".to_string()),
};
assert_eq!(injector.can_insert(Some(&target)), InsertCapability::Partial);
}
}
+6
View File
@@ -1,4 +1,10 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub mod audio;
pub mod error;
pub mod injection;
pub mod models;
pub mod settings;
pub mod transcription;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
+105
View File
@@ -0,0 +1,105 @@
use serde::{Deserialize, Serialize};
use crate::models::DEFAULT_MODEL_ID;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub transcription: TranscriptionSettings,
pub insertion: InsertionSettings,
pub storage: StorageSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionSettings {
pub mode: TranscriptionMode,
pub model_id: String,
pub language: Option<String>,
pub openai_compatible: OpenAiCompatibleSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum TranscriptionMode {
LocalFirst,
ApiOnly,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OpenAiCompatibleSettings {
pub endpoint: String,
pub api_key_configured: bool,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct InsertionSettings {
pub method: InsertionMethod,
pub restore_clipboard: bool,
pub wayland_manual_fallback: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum InsertionMethod {
ClipboardPaste,
KeyTyping,
ManualCopy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StorageSettings {
pub keep_audio: bool,
pub audio_format: AudioFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AudioFormat {
Wav,
Ogg,
Mp3,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
transcription: TranscriptionSettings {
mode: TranscriptionMode::LocalFirst,
model_id: DEFAULT_MODEL_ID.to_string(),
language: None,
openai_compatible: OpenAiCompatibleSettings {
endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(),
api_key_configured: false,
model: "whisper-1".to_string(),
},
},
insertion: InsertionSettings {
method: InsertionMethod::ClipboardPaste,
restore_clipboard: true,
wayland_manual_fallback: true,
},
storage: StorageSettings {
keep_audio: false,
audio_format: AudioFormat::Wav,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_language_is_optional_for_auto_detecting_model() {
let settings = AppSettings::default();
assert_eq!(settings.transcription.language, None);
assert_eq!(settings.transcription.mode, TranscriptionMode::LocalFirst);
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{AppError, AppResult};
use super::{Transcriber, TranscriptionInput, TranscriptionResult};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CrispAsrConfig {
pub sidecar_name: Option<String>,
pub model_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct CrispAsrTranscriber {
config: CrispAsrConfig,
}
impl CrispAsrTranscriber {
pub fn new(config: CrispAsrConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &CrispAsrConfig {
&self.config
}
}
impl Transcriber for CrispAsrTranscriber {
fn transcribe(&self, input: TranscriptionInput) -> AppResult<TranscriptionResult> {
let Some(model_path) = &self.config.model_path else {
return Err(AppError::ModelNotInstalled(input.options.model_id));
};
if self.config.sidecar_name.is_none() {
return Err(AppError::SidecarNotConfigured);
}
Ok(TranscriptionResult {
text: format!(
"Transcrição local pendente via CrispASR para {} usando {}.",
input.audio_path.display(),
model_path.display()
),
backend: "crispasr".to_string(),
model_id: input.options.model_id,
detected_language: input.options.language,
})
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::error::AppError;
use crate::models::DEFAULT_MODEL_ID;
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
use super::{CrispAsrConfig, CrispAsrTranscriber};
#[test]
fn missing_model_returns_clear_error() {
let transcriber = CrispAsrTranscriber::new(CrispAsrConfig {
sidecar_name: Some("crispasr".to_string()),
model_path: None,
});
let err = transcriber
.transcribe(TranscriptionInput {
audio_path: PathBuf::from("sample.wav"),
options: TranscriptionOptions::default(),
})
.expect_err("modelo ausente deve falhar");
assert_eq!(err, AppError::ModelNotInstalled(DEFAULT_MODEL_ID.to_string()));
}
}
+47
View File
@@ -0,0 +1,47 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::AppResult;
use crate::models::DEFAULT_MODEL_ID;
pub mod crispasr;
pub mod openai;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionInput {
pub audio_path: PathBuf,
pub options: TranscriptionOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionOptions {
pub model_id: String,
pub language: Option<String>,
pub timestamps: bool,
}
impl Default for TranscriptionOptions {
fn default() -> Self {
Self {
model_id: DEFAULT_MODEL_ID.to_string(),
language: None,
timestamps: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptionResult {
pub text: String,
pub backend: String,
pub model_id: String,
pub detected_language: Option<String>,
}
pub trait Transcriber {
fn transcribe(&self, input: TranscriptionInput) -> AppResult<TranscriptionResult>;
}
+19
View File
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OpenAiTranscriptionConfig {
pub endpoint: String,
pub model: String,
pub language: Option<String>,
}
impl Default for OpenAiTranscriptionConfig {
fn default() -> Self {
Self {
endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(),
model: "whisper-1".to_string(),
language: None,
}
}
}