feat: add transcription backend contracts
This commit is contained in:
1 parent
3b05cb22a0
commit
54e8b42330
9 files changed
+461
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user