* add basic logging to handy * 100kb of logs * add open button to the ui for logs * use log everywhere * some minor cleanup * finish debug logging
438 lines
14 KiB
Rust
438 lines
14 KiB
Rust
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
|
|
use crate::helpers::clamshell;
|
|
use crate::settings::{get_settings, AppSettings};
|
|
use crate::utils;
|
|
use log::{debug, error, info};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
use tauri::Manager;
|
|
|
|
fn set_mute(mute: bool) {
|
|
// Expected behavior:
|
|
// - Windows: works on most systems using standard audio drivers.
|
|
// - Linux: works on many systems (PipeWire, PulseAudio, ALSA),
|
|
// but some distros may lack the tools used.
|
|
// - macOS: works on most standard setups via AppleScript.
|
|
// If unsupported, fails silently.
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
unsafe {
|
|
use windows::Win32::{
|
|
Media::Audio::{
|
|
eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator,
|
|
MMDeviceEnumerator,
|
|
},
|
|
System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED},
|
|
};
|
|
|
|
macro_rules! unwrap_or_return {
|
|
($expr:expr) => {
|
|
match $expr {
|
|
Ok(val) => val,
|
|
Err(_) => return,
|
|
}
|
|
};
|
|
}
|
|
|
|
// Initialize the COM library for this thread.
|
|
// If already initialized (e.g., by another library like Tauri), this does nothing.
|
|
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
|
|
|
|
let all_devices: IMMDeviceEnumerator =
|
|
unwrap_or_return!(CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL));
|
|
let default_device =
|
|
unwrap_or_return!(all_devices.GetDefaultAudioEndpoint(eRender, eMultimedia));
|
|
let volume_interface = unwrap_or_return!(
|
|
default_device.Activate::<IAudioEndpointVolume>(CLSCTX_ALL, None)
|
|
);
|
|
|
|
let _ = volume_interface.SetMute(mute, std::ptr::null());
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
use std::process::Command;
|
|
|
|
let mute_val = if mute { "1" } else { "0" };
|
|
let amixer_state = if mute { "mute" } else { "unmute" };
|
|
|
|
// Try multiple backends to increase compatibility
|
|
// 1. PipeWire (wpctl)
|
|
if Command::new("wpctl")
|
|
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", mute_val])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 2. PulseAudio (pactl)
|
|
if Command::new("pactl")
|
|
.args(["set-sink-mute", "@DEFAULT_SINK@", mute_val])
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 3. ALSA (amixer)
|
|
let _ = Command::new("amixer")
|
|
.args(["set", "Master", amixer_state])
|
|
.output();
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use std::process::Command;
|
|
let script = format!(
|
|
"set volume output muted {}",
|
|
if mute { "true" } else { "false" }
|
|
);
|
|
let _ = Command::new("osascript").args(["-e", &script]).output();
|
|
}
|
|
}
|
|
|
|
const WHISPER_SAMPLE_RATE: usize = 16000;
|
|
|
|
/* ──────────────────────────────────────────────────────────────── */
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum RecordingState {
|
|
Idle,
|
|
Recording { binding_id: String },
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum MicrophoneMode {
|
|
AlwaysOn,
|
|
OnDemand,
|
|
}
|
|
|
|
/* ──────────────────────────────────────────────────────────────── */
|
|
|
|
fn create_audio_recorder(
|
|
vad_path: &str,
|
|
app_handle: &tauri::AppHandle,
|
|
) -> Result<AudioRecorder, anyhow::Error> {
|
|
let silero = SileroVad::new(vad_path, 0.3)
|
|
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
|
|
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);
|
|
|
|
// Recorder with VAD plus a spectrum-level callback that forwards updates to
|
|
// the frontend.
|
|
let recorder = AudioRecorder::new()
|
|
.map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?
|
|
.with_vad(Box::new(smoothed_vad))
|
|
.with_level_callback({
|
|
let app_handle = app_handle.clone();
|
|
move |levels| {
|
|
utils::emit_levels(&app_handle, &levels);
|
|
}
|
|
});
|
|
|
|
Ok(recorder)
|
|
}
|
|
|
|
/* ──────────────────────────────────────────────────────────────── */
|
|
|
|
#[derive(Clone)]
|
|
pub struct AudioRecordingManager {
|
|
state: Arc<Mutex<RecordingState>>,
|
|
mode: Arc<Mutex<MicrophoneMode>>,
|
|
app_handle: tauri::AppHandle,
|
|
|
|
recorder: Arc<Mutex<Option<AudioRecorder>>>,
|
|
is_open: Arc<Mutex<bool>>,
|
|
is_recording: Arc<Mutex<bool>>,
|
|
did_mute: Arc<Mutex<bool>>,
|
|
}
|
|
|
|
impl AudioRecordingManager {
|
|
/* ---------- construction ------------------------------------------------ */
|
|
|
|
pub fn new(app: &tauri::AppHandle) -> Result<Self, anyhow::Error> {
|
|
let settings = get_settings(app);
|
|
let mode = if settings.always_on_microphone {
|
|
MicrophoneMode::AlwaysOn
|
|
} else {
|
|
MicrophoneMode::OnDemand
|
|
};
|
|
|
|
let manager = Self {
|
|
state: Arc::new(Mutex::new(RecordingState::Idle)),
|
|
mode: Arc::new(Mutex::new(mode.clone())),
|
|
app_handle: app.clone(),
|
|
|
|
recorder: Arc::new(Mutex::new(None)),
|
|
is_open: Arc::new(Mutex::new(false)),
|
|
is_recording: Arc::new(Mutex::new(false)),
|
|
did_mute: Arc::new(Mutex::new(false)),
|
|
};
|
|
|
|
// Always-on? Open immediately.
|
|
if matches!(mode, MicrophoneMode::AlwaysOn) {
|
|
manager.start_microphone_stream()?;
|
|
}
|
|
|
|
Ok(manager)
|
|
}
|
|
|
|
/* ---------- helper methods --------------------------------------------- */
|
|
|
|
fn get_effective_microphone_device(&self, settings: &AppSettings) -> Option<cpal::Device> {
|
|
// Check if we're in clamshell mode and have a clamshell microphone configured
|
|
let use_clamshell_mic = if let Ok(is_clamshell) = clamshell::is_clamshell() {
|
|
is_clamshell && settings.clamshell_microphone.is_some()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
let device_name = if use_clamshell_mic {
|
|
settings.clamshell_microphone.as_ref().unwrap()
|
|
} else {
|
|
settings.selected_microphone.as_ref()?
|
|
};
|
|
|
|
// Find the device by name
|
|
match list_input_devices() {
|
|
Ok(devices) => devices
|
|
.into_iter()
|
|
.find(|d| d.name == *device_name)
|
|
.map(|d| d.device),
|
|
Err(e) => {
|
|
debug!("Failed to list devices, using default: {}", e);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- microphone life-cycle -------------------------------------- */
|
|
|
|
/// Applies mute if mute_while_recording is enabled and stream is open
|
|
pub fn apply_mute(&self) {
|
|
let settings = get_settings(&self.app_handle);
|
|
let mut did_mute_guard = self.did_mute.lock().unwrap();
|
|
|
|
if settings.mute_while_recording && *self.is_open.lock().unwrap() {
|
|
set_mute(true);
|
|
*did_mute_guard = true;
|
|
debug!("Mute applied");
|
|
}
|
|
}
|
|
|
|
/// Removes mute if it was applied
|
|
pub fn remove_mute(&self) {
|
|
let mut did_mute_guard = self.did_mute.lock().unwrap();
|
|
if *did_mute_guard {
|
|
set_mute(false);
|
|
*did_mute_guard = false;
|
|
debug!("Mute removed");
|
|
}
|
|
}
|
|
|
|
pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> {
|
|
let mut open_flag = self.is_open.lock().unwrap();
|
|
if *open_flag {
|
|
debug!("Microphone stream already active");
|
|
return Ok(());
|
|
}
|
|
|
|
let start_time = Instant::now();
|
|
|
|
// Don't mute immediately - caller will handle muting after audio feedback
|
|
let mut did_mute_guard = self.did_mute.lock().unwrap();
|
|
*did_mute_guard = false;
|
|
|
|
let vad_path = self
|
|
.app_handle
|
|
.path()
|
|
.resolve(
|
|
"resources/models/silero_vad_v4.onnx",
|
|
tauri::path::BaseDirectory::Resource,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("Failed to resolve VAD path: {}", e))?;
|
|
let mut recorder_opt = self.recorder.lock().unwrap();
|
|
|
|
if recorder_opt.is_none() {
|
|
*recorder_opt = Some(create_audio_recorder(
|
|
vad_path.to_str().unwrap(),
|
|
&self.app_handle,
|
|
)?);
|
|
}
|
|
|
|
// Get the selected device from settings, considering clamshell mode
|
|
let settings = get_settings(&self.app_handle);
|
|
let selected_device = self.get_effective_microphone_device(&settings);
|
|
|
|
if let Some(rec) = recorder_opt.as_mut() {
|
|
rec.open(selected_device)
|
|
.map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?;
|
|
}
|
|
|
|
*open_flag = true;
|
|
info!(
|
|
"Microphone stream initialized in {:?}",
|
|
start_time.elapsed()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop_microphone_stream(&self) {
|
|
let mut open_flag = self.is_open.lock().unwrap();
|
|
if !*open_flag {
|
|
return;
|
|
}
|
|
|
|
let mut did_mute_guard = self.did_mute.lock().unwrap();
|
|
if *did_mute_guard {
|
|
set_mute(false);
|
|
}
|
|
*did_mute_guard = false;
|
|
|
|
if let Some(rec) = self.recorder.lock().unwrap().as_mut() {
|
|
// If still recording, stop first.
|
|
if *self.is_recording.lock().unwrap() {
|
|
let _ = rec.stop();
|
|
*self.is_recording.lock().unwrap() = false;
|
|
}
|
|
let _ = rec.close();
|
|
}
|
|
|
|
*open_flag = false;
|
|
debug!("Microphone stream stopped");
|
|
}
|
|
|
|
/* ---------- mode switching --------------------------------------------- */
|
|
|
|
pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {
|
|
let mode_guard = self.mode.lock().unwrap();
|
|
let cur_mode = mode_guard.clone();
|
|
|
|
match (cur_mode, &new_mode) {
|
|
(MicrophoneMode::AlwaysOn, MicrophoneMode::OnDemand) => {
|
|
if matches!(*self.state.lock().unwrap(), RecordingState::Idle) {
|
|
drop(mode_guard);
|
|
self.stop_microphone_stream();
|
|
}
|
|
}
|
|
(MicrophoneMode::OnDemand, MicrophoneMode::AlwaysOn) => {
|
|
drop(mode_guard);
|
|
self.start_microphone_stream()?;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
*self.mode.lock().unwrap() = new_mode;
|
|
Ok(())
|
|
}
|
|
|
|
/* ---------- recording --------------------------------------------------- */
|
|
|
|
pub fn try_start_recording(&self, binding_id: &str) -> bool {
|
|
let mut state = self.state.lock().unwrap();
|
|
|
|
if let RecordingState::Idle = *state {
|
|
// Ensure microphone is open in on-demand mode
|
|
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
|
if let Err(e) = self.start_microphone_stream() {
|
|
error!("Failed to open microphone stream: {e}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
|
|
if rec.start().is_ok() {
|
|
*self.is_recording.lock().unwrap() = true;
|
|
*state = RecordingState::Recording {
|
|
binding_id: binding_id.to_string(),
|
|
};
|
|
debug!("Recording started for binding {binding_id}");
|
|
return true;
|
|
}
|
|
}
|
|
error!("Recorder not available");
|
|
false
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn update_selected_device(&self) -> Result<(), anyhow::Error> {
|
|
// If currently open, restart the microphone stream to use the new device
|
|
if *self.is_open.lock().unwrap() {
|
|
self.stop_microphone_stream();
|
|
self.start_microphone_stream()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop_recording(&self, binding_id: &str) -> Option<Vec<f32>> {
|
|
let mut state = self.state.lock().unwrap();
|
|
|
|
match *state {
|
|
RecordingState::Recording {
|
|
binding_id: ref active,
|
|
} if active == binding_id => {
|
|
*state = RecordingState::Idle;
|
|
drop(state);
|
|
|
|
let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
|
|
match rec.stop() {
|
|
Ok(buf) => buf,
|
|
Err(e) => {
|
|
error!("stop() failed: {e}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
} else {
|
|
error!("Recorder not available");
|
|
Vec::new()
|
|
};
|
|
|
|
*self.is_recording.lock().unwrap() = false;
|
|
|
|
// In on-demand mode turn the mic off again
|
|
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
|
self.stop_microphone_stream();
|
|
}
|
|
|
|
// Pad if very short
|
|
let s_len = samples.len();
|
|
// debug!("Got {} samples", s_len);
|
|
if s_len < WHISPER_SAMPLE_RATE && s_len > 0 {
|
|
let mut padded = samples;
|
|
padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0);
|
|
Some(padded)
|
|
} else {
|
|
Some(samples)
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Cancel any ongoing recording without returning audio samples
|
|
pub fn cancel_recording(&self) {
|
|
let mut state = self.state.lock().unwrap();
|
|
|
|
if let RecordingState::Recording { .. } = *state {
|
|
*state = RecordingState::Idle;
|
|
drop(state);
|
|
|
|
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
|
|
let _ = rec.stop(); // Discard the result
|
|
}
|
|
|
|
*self.is_recording.lock().unwrap() = false;
|
|
|
|
// In on-demand mode turn the mic off again
|
|
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
|
|
self.stop_microphone_stream();
|
|
}
|
|
}
|
|
}
|
|
}
|