Skip to content

Commit 1791519

Browse files
committed
feat: add microphone selection with bidirectional sync
- Add microphone selection in both settings UI and system tray - Implement bidirectional sync between dashboard and menubar - Auto-fallback to default when selected device unavailable - Stop recording automatically when microphone changes - Replace license restore with Polar customer portal link - Change email links to copy-to-clipboard functionality - Remove unused components (license folder, example files)
1 parent f314922 commit 1791519

File tree

13 files changed

+402
-243
lines changed

13 files changed

+402
-243
lines changed

src-tauri/src/audio/recorder.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ impl AudioRecorder {
7474
}
7575
}
7676

77-
pub fn start_recording(&mut self, output_path: &str) -> Result<(), String> {
77+
pub fn start_recording(&mut self, output_path: &str, device_name: Option<String>) -> Result<(), String> {
7878
log::info!(
7979
"AudioRecorder::start_recording called with path: {}",
8080
output_path
@@ -109,9 +109,25 @@ impl AudioRecorder {
109109
// Spawn recording thread
110110
let thread_handle = thread::spawn(move || -> Result<String, String> {
111111
let host = cpal::default_host();
112-
let device = host
113-
.default_input_device()
114-
.ok_or("No input device available")?;
112+
let device = if let Some(device_name) = device_name {
113+
// Try to find the specified device
114+
host.input_devices()
115+
.map_err(|e| format!("Failed to enumerate input devices: {}", e))?
116+
.find(|d| d.name().map(|n| n == device_name).unwrap_or(false))
117+
.ok_or_else(|| {
118+
log::warn!("Specified device '{}' not found, falling back to default", device_name);
119+
format!("Device '{}' not found", device_name)
120+
})
121+
.or_else(|_| {
122+
// Fallback to default device if specified device not found
123+
host.default_input_device()
124+
.ok_or("No input device available".to_string())
125+
})?
126+
} else {
127+
// Use default device
128+
host.default_input_device()
129+
.ok_or("No input device available")?
130+
};
115131

116132
let device_name = device.name().unwrap_or_else(|_| "Unknown".to_string());
117133
log::info!("======================================");

src-tauri/src/commands/audio.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use tauri::{AppHandle, Manager, State};
33
use crate::audio::recorder::AudioRecorder;
44
use crate::audio::validator::{AudioValidator, AudioValidationResult};
55
use crate::commands::license::check_license_status_internal;
6+
use crate::commands::settings::get_settings;
67
use crate::license::LicenseState;
78
use crate::utils::logger::*;
89
#[cfg(debug_assertions)]
@@ -175,6 +176,23 @@ pub async fn start_recording(
175176
.map_err(|e| format!("Failed to acquire path lock: {}", e))?
176177
.replace(audio_path.clone());
177178

179+
// Get selected microphone from settings (before acquiring recorder lock)
180+
let selected_microphone = match get_settings(app.clone()).await {
181+
Ok(settings) => {
182+
if let Some(mic) = settings.selected_microphone {
183+
log::info!("Using selected microphone: {}", mic);
184+
Some(mic)
185+
} else {
186+
log::info!("Using default microphone");
187+
None
188+
}
189+
},
190+
Err(e) => {
191+
log::warn!("Failed to get settings for microphone selection: {}. Using default.", e);
192+
None
193+
}
194+
};
195+
178196
// Start recording (scoped to release mutex before async operations)
179197
{
180198
let mut recorder = state
@@ -226,10 +244,17 @@ pub async fn start_recording(
226244

227245
log_file_operation("RECORDING_START", audio_path_str, false, None, None);
228246

229-
match recorder.start_recording(audio_path_str) {
247+
// Start recording and get audio level receiver
248+
let audio_level_rx = match recorder.start_recording(audio_path_str, selected_microphone.clone()) {
230249
Ok(_) => {
231250
// Verify recording actually started
232-
if !recorder.is_recording() {
251+
let is_recording = recorder.is_recording();
252+
253+
// Get the audio level receiver before potentially dropping recorder
254+
let rx = recorder.take_audio_level_receiver();
255+
256+
if !is_recording {
257+
drop(recorder); // Release the lock if we're erroring out
233258
log_failed("RECORDER_INIT", "Recording failed to start after initialization");
234259
log_with_context(log::Level::Debug, "Recorder initialization failed", &[
235260
("audio_path", audio_path_str),
@@ -257,6 +282,8 @@ pub async fn start_recording(
257282
#[cfg(debug_assertions)]
258283
system_monitor::log_resources_before_operation("RECORDING_START");
259284
}
285+
286+
rx // Return the audio level receiver
260287
}
261288
Err(e) => {
262289
log_failed("RECORDER_START", &e);
@@ -282,10 +309,13 @@ pub async fn start_recording(
282309

283310
return Err(e);
284311
}
285-
}
312+
};
313+
314+
// Release the recorder lock after successful start
315+
drop(recorder);
286316

287-
// Start audio level monitoring before releasing the lock
288-
if let Some(audio_level_rx) = recorder.take_audio_level_receiver() {
317+
// Start audio level monitoring
318+
if let Some(audio_level_rx) = audio_level_rx {
289319
let app_for_levels = app.clone();
290320
// Use a thread instead of tokio spawn for std::sync::mpsc
291321
std::thread::spawn(move || {
@@ -592,7 +622,7 @@ pub async fn stop_recording(
592622

593623
return Ok("".to_string()); // Don't proceed to transcription
594624
}
595-
Ok(AudioValidationResult::TooQuiet { energy, suggestion }) => {
625+
Ok(AudioValidationResult::TooQuiet { energy, suggestion: _ }) => {
596626
log::warn!("Audio validation FAILED: Audio too quiet (RMS={:.6})", energy);
597627

598628
// Clean up audio file

src-tauri/src/commands/settings.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub struct Settings {
2020
pub onboarding_completed: bool,
2121
pub compact_recording_status: bool,
2222
pub check_updates_automatically: bool,
23+
pub selected_microphone: Option<String>,
2324
}
2425

2526
impl Default for Settings {
@@ -36,6 +37,7 @@ impl Default for Settings {
3637
onboarding_completed: false, // Default to not completed
3738
compact_recording_status: true, // Default to compact mode
3839
check_updates_automatically: true, // Default to automatic updates enabled
40+
selected_microphone: None, // Default to system default microphone
3941
}
4042
}
4143
}
@@ -97,6 +99,9 @@ pub async fn get_settings(app: AppHandle) -> Result<Settings, String> {
9799
.get("check_updates_automatically")
98100
.and_then(|v| v.as_bool())
99101
.unwrap_or_else(|| Settings::default().check_updates_automatically),
102+
selected_microphone: store
103+
.get("selected_microphone")
104+
.and_then(|v| v.as_str().map(|s| s.to_string())),
100105
};
101106

102107
// Pill position is already loaded from store, no need for duplicate state
@@ -137,6 +142,7 @@ pub async fn save_settings(app: AppHandle, settings: Settings) -> Result<(), Str
137142
"check_updates_automatically",
138143
json!(settings.check_updates_automatically),
139144
);
145+
store.set("selected_microphone", json!(settings.selected_microphone));
140146

141147
// Save pill position if provided
142148
if let Some((x, y)) = settings.pill_position {
@@ -329,3 +335,56 @@ pub async fn update_tray_menu(app: AppHandle) -> Result<(), String> {
329335

330336
Ok(())
331337
}
338+
339+
/// Set the selected microphone device
340+
#[tauri::command]
341+
pub async fn set_audio_device(app: AppHandle, device_name: Option<String>) -> Result<(), String> {
342+
log::info!("Setting audio device to: {:?}", device_name);
343+
344+
// Get current settings
345+
let mut settings = get_settings(app.clone()).await?;
346+
347+
// Check if recording is in progress and stop it
348+
let recorder_state = app.state::<crate::commands::audio::RecorderState>();
349+
{
350+
let mut recorder = recorder_state.inner().0.lock()
351+
.map_err(|e| format!("Failed to acquire recorder lock: {}", e))?;
352+
353+
if recorder.is_recording() {
354+
log::info!("Recording in progress, stopping it before changing microphone");
355+
356+
// Update state to notify UI
357+
crate::update_recording_state(&app, crate::RecordingState::Stopping, None);
358+
359+
match recorder.stop_recording() {
360+
Ok(msg) => {
361+
log::info!("Recording stopped: {}", msg);
362+
// Update state to idle after successful stop
363+
crate::update_recording_state(&app, crate::RecordingState::Idle, None);
364+
},
365+
Err(e) => {
366+
log::warn!("Failed to stop recording: {}", e);
367+
// Update state to error if stop failed
368+
crate::update_recording_state(&app, crate::RecordingState::Error, Some(e));
369+
}
370+
}
371+
}
372+
} // Lock released here
373+
374+
// Update the selected microphone
375+
settings.selected_microphone = device_name.clone();
376+
377+
// Save the updated settings
378+
save_settings(app.clone(), settings).await?;
379+
380+
// Update tray menu to reflect the change
381+
update_tray_menu(app.clone()).await?;
382+
383+
// Emit event to notify frontend - just emit a signal, frontend will reload settings
384+
if let Err(e) = app.emit("audio-device-changed", ()) {
385+
log::warn!("Failed to emit audio-device-changed event: {}", e);
386+
}
387+
388+
log::info!("Audio device successfully set to: {:?}", device_name);
389+
Ok(())
390+
}

src-tauri/src/lib.rs

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,19 @@ async fn build_tray_menu<R: tauri::Runtime>(
6464
app: &tauri::AppHandle<R>,
6565
) -> Result<tauri::menu::Menu<R>, Box<dyn std::error::Error>> {
6666
// Get current settings for menu state
67-
let current_model = {
67+
let (current_model, selected_microphone) = {
6868
match app.store("settings") {
69-
Ok(store) => store
70-
.get("current_model")
71-
.and_then(|v| v.as_str().map(|s| s.to_string()))
72-
.unwrap_or_default(),
73-
Err(_) => "".to_string(),
69+
Ok(store) => {
70+
let model = store
71+
.get("current_model")
72+
.and_then(|v| v.as_str().map(|s| s.to_string()))
73+
.unwrap_or_default();
74+
let microphone = store
75+
.get("selected_microphone")
76+
.and_then(|v| v.as_str().map(|s| s.to_string()));
77+
(model, microphone)
78+
}
79+
Err(_) => ("".to_string(), None),
7480
}
7581
};
7682

@@ -131,6 +137,61 @@ async fn build_tray_menu<R: tauri::Runtime>(
131137
None
132138
};
133139

140+
// Get available audio devices
141+
let available_devices = audio::recorder::AudioRecorder::get_devices();
142+
143+
// Create microphone submenu
144+
let microphone_submenu = if !available_devices.is_empty() {
145+
let mut mic_items: Vec<&dyn tauri::menu::IsMenuItem<_>> = Vec::new();
146+
let mut mic_check_items = Vec::new();
147+
148+
// Add "Default" option first
149+
let default_item = CheckMenuItem::with_id(
150+
app,
151+
"microphone_default",
152+
"System Default",
153+
true,
154+
selected_microphone.is_none(), // Selected if no specific microphone is set
155+
None::<&str>,
156+
)?;
157+
mic_check_items.push(default_item);
158+
159+
// Add available devices
160+
for device_name in &available_devices {
161+
let is_selected = selected_microphone.as_ref() == Some(device_name);
162+
let mic_item = CheckMenuItem::with_id(
163+
app,
164+
&format!("microphone_{}", device_name),
165+
device_name,
166+
true,
167+
is_selected,
168+
None::<&str>,
169+
)?;
170+
mic_check_items.push(mic_item);
171+
}
172+
173+
// Convert to trait objects
174+
for item in &mic_check_items {
175+
mic_items.push(item);
176+
}
177+
178+
let current_mic_display = if let Some(ref mic_name) = selected_microphone {
179+
format!("Microphone: {}", mic_name)
180+
} else {
181+
"Microphone: Default".to_string()
182+
};
183+
184+
Some(Submenu::with_id_and_items(
185+
app,
186+
"microphones",
187+
&current_mic_display,
188+
true,
189+
&mic_items,
190+
)?)
191+
} else {
192+
None
193+
};
194+
134195
// Create menu items
135196
let separator1 = PredefinedMenuItem::separator(app)?;
136197
let settings_i = MenuItem::with_id(app, "settings", "Dashboard", true, None::<&str>)?;
@@ -142,6 +203,10 @@ async fn build_tray_menu<R: tauri::Runtime>(
142203
if let Some(model_submenu) = model_submenu {
143204
menu_builder = menu_builder.item(&model_submenu);
144205
}
206+
207+
if let Some(microphone_submenu) = microphone_submenu {
208+
menu_builder = menu_builder.item(&microphone_submenu);
209+
}
145210

146211
let menu = menu_builder
147212
.item(&separator1)
@@ -861,6 +926,43 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
861926
}
862927
}
863928
});
929+
} else if event_id == "microphone_default" {
930+
// Handle default microphone selection
931+
let app_handle = app.app_handle().clone();
932+
933+
tauri::async_runtime::spawn(async move {
934+
match crate::commands::settings::set_audio_device(app_handle.clone(), None).await {
935+
Ok(_) => {
936+
log::info!("Microphone changed from tray to: System Default");
937+
}
938+
Err(e) => {
939+
log::error!("Failed to set default microphone from tray: {}", e);
940+
let _ = app_handle.emit("tray-action-error", &format!("Failed to change microphone: {}", e));
941+
}
942+
}
943+
});
944+
} else if event_id.starts_with("microphone_") {
945+
// Handle specific microphone selection
946+
let device_name = match event_id.strip_prefix("microphone_") {
947+
Some(name) if name != "default" => Some(name.to_string()),
948+
_ => {
949+
// Already handled by microphone_default case above
950+
return;
951+
}
952+
};
953+
let app_handle = app.app_handle().clone();
954+
955+
tauri::async_runtime::spawn(async move {
956+
match crate::commands::settings::set_audio_device(app_handle.clone(), device_name.clone()).await {
957+
Ok(_) => {
958+
log::info!("Microphone changed from tray to: {:?}", device_name);
959+
}
960+
Err(e) => {
961+
log::error!("Failed to set microphone from tray: {}", e);
962+
let _ = app_handle.emit("tray-action-error", &format!("Failed to change microphone: {}", e));
963+
}
964+
}
965+
});
864966
}
865967
})
866968
.on_tray_icon_event(|tray, event| {
@@ -1151,6 +1253,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
11511253
transcribe_audio,
11521254
get_settings,
11531255
save_settings,
1256+
set_audio_device,
11541257
set_global_shortcut,
11551258
get_supported_languages,
11561259
set_model_from_tray,

0 commit comments

Comments
 (0)