feat: capture microphone audio to wav
This commit is contained in:
1 parent
f324566b42
commit
1f6ea8e5db
3 files changed
+226
-10
No files matched your search
@@ -7,12 +7,12 @@ use crate::audio::{RecordingSession, RecordingStatus};
|
|||||||
use crate::models::{model_statuses, ModelWithStatus};
|
use crate::models::{model_statuses, ModelWithStatus};
|
||||||
use crate::settings::AppSettings;
|
use crate::settings::AppSettings;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ManagedAppState {
|
pub struct ManagedAppState {
|
||||||
pub settings: Mutex<AppSettings>,
|
pub settings: Mutex<AppSettings>,
|
||||||
pub recording: Mutex<RecordingSession>,
|
pub recording: Mutex<RecordingSession>,
|
||||||
pub transcript: Mutex<TranscriptDraft>,
|
pub transcript: Mutex<TranscriptDraft>,
|
||||||
pub model_dir: PathBuf,
|
pub model_dir: PathBuf,
|
||||||
|
pub recordings_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -38,6 +38,7 @@ impl Default for ManagedAppState {
|
|||||||
recording: Mutex::new(RecordingSession::default()),
|
recording: Mutex::new(RecordingSession::default()),
|
||||||
transcript: Mutex::new(TranscriptDraft::default()),
|
transcript: Mutex::new(TranscriptDraft::default()),
|
||||||
model_dir: default_model_dir(),
|
model_dir: default_model_dir(),
|
||||||
|
recordings_dir: default_recordings_dir(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,12 @@ fn default_model_dir() -> PathBuf {
|
|||||||
.join("models")
|
.join("models")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_recordings_dir() -> PathBuf {
|
||||||
|
std::env::current_dir()
|
||||||
|
.unwrap_or_else(|_| std::env::temp_dir())
|
||||||
|
.join("recordings")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+209
-2
@@ -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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
@@ -9,11 +19,19 @@ pub enum RecordingStatus {
|
|||||||
Recording,
|
Recording,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
type SharedWriter = Arc<Mutex<Option<WavWriter<BufWriter<fs::File>>>>>;
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RecordingSession {
|
pub struct RecordingSession {
|
||||||
pub status: RecordingStatus,
|
pub status: RecordingStatus,
|
||||||
pub sequence: u64,
|
pub sequence: u64,
|
||||||
|
active: Option<ActiveRecordingHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveRecordingHandle {
|
||||||
|
path: PathBuf,
|
||||||
|
stop_tx: Sender<()>,
|
||||||
|
done_rx: Receiver<AppResult<PathBuf>>,
|
||||||
|
thread: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RecordingSession {
|
impl Default for RecordingSession {
|
||||||
@@ -21,6 +39,7 @@ impl Default for RecordingSession {
|
|||||||
Self {
|
Self {
|
||||||
status: RecordingStatus::Idle,
|
status: RecordingStatus::Idle,
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
|
active: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +63,194 @@ impl RecordingSession {
|
|||||||
self.status = RecordingStatus::Idle;
|
self.status = RecordingStatus::Idle;
|
||||||
Ok(self.sequence)
|
Ok(self.sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn start_capture(&mut self, recordings_dir: &Path) -> AppResult<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<()>>,
|
||||||
|
) -> AppResult<PathBuf> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::app_state::{AppSnapshot, ManagedAppState};
|
use crate::app_state::{AppSnapshot, ManagedAppState};
|
||||||
use crate::injection::{FallbackInjector, InsertMethod, InsertRequest, InsertResult, TextInjector};
|
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::settings::AppSettings;
|
||||||
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
use crate::transcription::crispasr::{CrispAsrConfig, CrispAsrTranscriber};
|
||||||
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
use crate::transcription::{Transcriber, TranscriptionInput, TranscriptionOptions};
|
||||||
@@ -16,11 +17,12 @@ pub(crate) fn get_app_snapshot(state: State<'_, ManagedAppState>) -> Result<AppS
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn start_recording(state: State<'_, ManagedAppState>) -> Result<AppSnapshot, String> {
|
pub(crate) fn start_recording(state: State<'_, ManagedAppState>) -> Result<AppSnapshot, String> {
|
||||||
|
let recordings_dir = state.recordings_dir.clone();
|
||||||
state
|
state
|
||||||
.recording
|
.recording
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "falha ao acessar estado de gravação".to_string())?
|
.map_err(|_| "falha ao acessar estado de gravação".to_string())?
|
||||||
.start()
|
.start_capture(&recordings_dir)
|
||||||
.map_err(String::from)?;
|
.map_err(String::from)?;
|
||||||
|
|
||||||
state.snapshot()
|
state.snapshot()
|
||||||
@@ -30,11 +32,11 @@ pub(crate) fn start_recording(state: State<'_, ManagedAppState>) -> Result<AppSn
|
|||||||
pub(crate) fn stop_recording_and_transcribe(
|
pub(crate) fn stop_recording_and_transcribe(
|
||||||
state: State<'_, ManagedAppState>,
|
state: State<'_, ManagedAppState>,
|
||||||
) -> Result<AppSnapshot, String> {
|
) -> Result<AppSnapshot, String> {
|
||||||
let sequence = state
|
let audio_path = state
|
||||||
.recording
|
.recording
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "falha ao acessar estado de gravação".to_string())?
|
.map_err(|_| "falha ao acessar estado de gravação".to_string())?
|
||||||
.stop()
|
.stop_capture()
|
||||||
.map_err(String::from)?;
|
.map_err(String::from)?;
|
||||||
|
|
||||||
let settings = state
|
let settings = state
|
||||||
@@ -45,11 +47,11 @@ pub(crate) fn stop_recording_and_transcribe(
|
|||||||
|
|
||||||
let transcriber = CrispAsrTranscriber::new(CrispAsrConfig {
|
let transcriber = CrispAsrTranscriber::new(CrispAsrConfig {
|
||||||
sidecar_name: None,
|
sidecar_name: None,
|
||||||
model_path: None,
|
model_path: Some(default_model_path(&state.model_dir)),
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = transcriber.transcribe(TranscriptionInput {
|
let result = transcriber.transcribe(TranscriptionInput {
|
||||||
audio_path: PathBuf::from(format!("recording-{sequence}.wav")),
|
audio_path,
|
||||||
options: TranscriptionOptions {
|
options: TranscriptionOptions {
|
||||||
model_id: settings.transcription.model_id,
|
model_id: settings.transcription.model_id,
|
||||||
language: settings.transcription.language,
|
language: settings.transcription.language,
|
||||||
|
|||||||
Reference in new issue
Block a user