diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index e3ba6d4..87fdb12 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -7,12 +7,12 @@ use crate::audio::{RecordingSession, RecordingStatus}; use crate::models::{model_statuses, ModelWithStatus}; use crate::settings::AppSettings; -#[derive(Debug)] pub struct ManagedAppState { pub settings: Mutex, pub recording: Mutex, pub transcript: Mutex, pub model_dir: PathBuf, + pub recordings_dir: PathBuf, } #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -38,6 +38,7 @@ impl Default for ManagedAppState { recording: Mutex::new(RecordingSession::default()), transcript: Mutex::new(TranscriptDraft::default()), model_dir: default_model_dir(), + recordings_dir: default_recordings_dir(), } } } @@ -76,6 +77,12 @@ fn default_model_dir() -> PathBuf { .join("models") } +fn default_recordings_dir() -> PathBuf { + std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join("recordings") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 90fd48e..de08201 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,3 +1,13 @@ +use std::fs; +use std::io::BufWriter; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::SampleFormat; +use hound::{SampleFormat as WavSampleFormat, WavSpec, WavWriter}; use serde::{Deserialize, Serialize}; use crate::error::{AppError, AppResult}; @@ -9,11 +19,19 @@ pub enum RecordingStatus { Recording, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] +type SharedWriter = Arc>>>>; + pub struct RecordingSession { pub status: RecordingStatus, pub sequence: u64, + active: Option, +} + +struct ActiveRecordingHandle { + path: PathBuf, + stop_tx: Sender<()>, + done_rx: Receiver>, + thread: JoinHandle<()>, } impl Default for RecordingSession { @@ -21,6 +39,7 @@ impl Default for RecordingSession { Self { status: RecordingStatus::Idle, sequence: 0, + active: None, } } } @@ -44,6 +63,194 @@ impl RecordingSession { self.status = RecordingStatus::Idle; Ok(self.sequence) } + + pub fn start_capture(&mut self, recordings_dir: &Path) -> AppResult { + if self.status == RecordingStatus::Recording { + return Err(AppError::RecordingAlreadyActive); + } + + fs::create_dir_all(recordings_dir).map_err(|error| AppError::Io(error.to_string()))?; + self.sequence += 1; + let path = recordings_dir.join(format!("recording-{}.wav", self.sequence)); + let thread_path = path.clone(); + let (stop_tx, stop_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + + let thread = thread::spawn(move || { + let result = run_capture_thread(thread_path, stop_rx, ready_tx); + let _ = done_tx.send(result); + }); + + match ready_rx.recv() { + Ok(Ok(())) => { + self.status = RecordingStatus::Recording; + self.active = Some(ActiveRecordingHandle { + path: path.clone(), + stop_tx, + done_rx, + thread, + }); + Ok(path) + } + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(error) => { + let _ = thread.join(); + Err(AppError::Audio(format!( + "falha ao iniciar thread de gravação: {error}" + ))) + } + } + } + + pub fn stop_capture(&mut self) -> AppResult { + if self.status == RecordingStatus::Idle { + return Err(AppError::NoActiveRecording); + } + + let active = self + .active + .take() + .ok_or_else(|| AppError::Audio("gravação ativa sem thread de áudio".to_string()))?; + let expected_path = active.path.clone(); + active + .stop_tx + .send(()) + .map_err(|error| AppError::Audio(error.to_string()))?; + let result = active + .done_rx + .recv() + .map_err(|error| AppError::Audio(error.to_string()))?; + active + .thread + .join() + .map_err(|_| AppError::Audio("thread de gravação encerrou com panic".to_string()))?; + + self.status = RecordingStatus::Idle; + result.map(|path| if path.as_os_str().is_empty() { expected_path } else { path }) + } +} + +fn run_capture_thread( + path: PathBuf, + stop_rx: Receiver<()>, + ready_tx: Sender>, +) -> AppResult { + let setup = (|| -> AppResult<(cpal::Stream, SharedWriter)> { + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or_else(|| AppError::Audio("nenhum microfone padrão encontrado".to_string()))?; + let supported_config = device + .default_input_config() + .map_err(|error| AppError::Audio(error.to_string()))?; + let sample_format = supported_config.sample_format(); + let config: cpal::StreamConfig = supported_config.into(); + + let writer = WavWriter::create( + &path, + WavSpec { + channels: config.channels, + sample_rate: config.sample_rate.0, + bits_per_sample: 16, + sample_format: WavSampleFormat::Int, + }, + ) + .map_err(|error| AppError::Io(error.to_string()))?; + let writer = Arc::new(Mutex::new(Some(writer))); + let writer_for_callback = Arc::clone(&writer); + let error_callback = |error| eprintln!("erro no stream de áudio: {error}"); + + let stream = match sample_format { + SampleFormat::F32 => device.build_input_stream( + &config, + move |data: &[f32], _| write_f32_samples(data, &writer_for_callback), + error_callback, + None, + ), + SampleFormat::I16 => device.build_input_stream( + &config, + move |data: &[i16], _| write_i16_samples(data, &writer_for_callback), + error_callback, + None, + ), + SampleFormat::U16 => device.build_input_stream( + &config, + move |data: &[u16], _| write_u16_samples(data, &writer_for_callback), + error_callback, + None, + ), + other => { + return Err(AppError::Audio(format!( + "formato de amostra não suportado: {other:?}" + ))) + } + } + .map_err(|error| AppError::Audio(error.to_string()))?; + + stream.play().map_err(|error| AppError::Audio(error.to_string()))?; + Ok((stream, writer)) + })(); + + let (stream, writer) = match setup { + Ok(active) => active, + Err(error) => { + let _ = ready_tx.send(Err(error.clone())); + return Err(error); + } + }; + + let _ = ready_tx.send(Ok(())); + let _ = stop_rx.recv(); + drop(stream); + + let writer = writer + .lock() + .map_err(|_| AppError::Audio("falha ao finalizar arquivo WAV".to_string()))? + .take(); + + if let Some(writer) = writer { + writer + .finalize() + .map_err(|error| AppError::Io(error.to_string()))?; + } + + Ok(path) +} + +fn write_f32_samples(samples: &[f32], writer: &SharedWriter) { + if let Ok(mut guard) = writer.lock() { + if let Some(writer) = guard.as_mut() { + for sample in samples { + let value = (sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; + let _ = writer.write_sample(value); + } + } + } +} + +fn write_i16_samples(samples: &[i16], writer: &SharedWriter) { + if let Ok(mut guard) = writer.lock() { + if let Some(writer) = guard.as_mut() { + for sample in samples { + let _ = writer.write_sample(*sample); + } + } + } +} + +fn write_u16_samples(samples: &[u16], writer: &SharedWriter) { + if let Ok(mut guard) = writer.lock() { + if let Some(writer) = guard.as_mut() { + for sample in samples { + let centered = *sample as i32 - i16::MAX as i32 - 1; + let _ = writer.write_sample(centered as i16); + } + } + } } #[cfg(test)] diff --git a/src-tauri/src/ui_commands.rs b/src-tauri/src/ui_commands.rs index dd75100..54aa8cf 100644 --- a/src-tauri/src/ui_commands.rs +++ b/src-tauri/src/ui_commands.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; use tauri::State; use crate::app_state::{AppSnapshot, ManagedAppState}; use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector}; -use crate::models::{download_default_model, model_statuses, ModelDownloadResult, ModelWithStatus}; +use crate::models::{ + default_model_path, download_default_model, model_statuses, ModelDownloadResult, ModelWithStatus, +}; use crate::settings::AppSettings; use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber}; use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions}; @@ -16,11 +17,12 @@ pub(crate) fn get_app_snapshot(state: State<'_, ManagedAppState>) -> Result) -> Result { + let recordings_dir = state.recordings_dir.clone(); state .recording .lock() .map_err(|_| "falha ao acessar estado de gravação".to_string())? - .start() + .start_capture(&recordings_dir) .map_err(String::from)?; state.snapshot() @@ -30,11 +32,11 @@ pub(crate) fn start_recording(state: State<'_, ManagedAppState>) -> Result, ) -> Result { - let sequence = state + let audio_path = state .recording .lock() .map_err(|_| "falha ao acessar estado de gravação".to_string())? - .stop() + .stop_capture() .map_err(String::from)?; let settings = state @@ -45,11 +47,11 @@ pub(crate) fn stop_recording_and_transcribe( let transcriber = CrispAsrTranscriber::new(CrispAsrConfig { sidecar_name: None, - model_path: None, + model_path: Some(default_model_path(&state.model_dir)), }); let result = transcriber.transcribe(TranscriptionInput { - audio_path: PathBuf::from(format!("recording-{sequence}.wav")), + audio_path, options: TranscriptionOptions { model_id: settings.transcription.model_id, language: settings.transcription.language,