diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..a34a461 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs new file mode 100644 index 0000000..31a5ac6 --- /dev/null +++ b/src-tauri/src/audio/mod.rs @@ -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 { + 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)); + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs new file mode 100644 index 0000000..c5cc2c0 --- /dev/null +++ b/src-tauri/src/error.rs @@ -0,0 +1,27 @@ +use serde::Serialize; +use thiserror::Error; + +pub type AppResult = Result; + +#[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 for String { + fn from(value: AppError) -> Self { + value.to_string() + } +} diff --git a/src-tauri/src/injection/mod.rs b/src-tauri/src/injection/mod.rs new file mode 100644 index 0000000..29c969a --- /dev/null +++ b/src-tauri/src/injection/mod.rs @@ -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, + pub process_name: Option, +} + +#[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, + 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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b4614e..39f56de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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()) diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs new file mode 100644 index 0000000..b8c76db --- /dev/null +++ b/src-tauri/src/settings/mod.rs @@ -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, + 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); + } +} diff --git a/src-tauri/src/transcription/crispasr.rs b/src-tauri/src/transcription/crispasr.rs new file mode 100644 index 0000000..01068ad --- /dev/null +++ b/src-tauri/src/transcription/crispasr.rs @@ -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, + pub model_path: Option, +} + +#[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 { + 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())); + } +} diff --git a/src-tauri/src/transcription/mod.rs b/src-tauri/src/transcription/mod.rs new file mode 100644 index 0000000..b8216f6 --- /dev/null +++ b/src-tauri/src/transcription/mod.rs @@ -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, + 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, +} + +pub trait Transcriber { + fn transcribe(&self, input: TranscriptionInput) -> AppResult; +} diff --git a/src-tauri/src/transcription/openai.rs b/src-tauri/src/transcription/openai.rs new file mode 100644 index 0000000..4c13569 --- /dev/null +++ b/src-tauri/src/transcription/openai.rs @@ -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, +} + +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, + } + } +}