From 45eec572260cee26238003e82d856b1cba756b8f Mon Sep 17 00:00:00 2001 From: Jorge <103663493+jorgedanisc@users.noreply.github.com> Date: Mon, 17 Nov 2025 03:38:41 +0000 Subject: [PATCH] feat: add fallback microphone for clamshell/desktop mode (#329) * feat: add fallback microphone for clamshell/desktop mode * refactor: moved "Closed Laptop Microphone" setting to Debug * cleanup * run format * add check back --------- Co-authored-by: CJ Pais --- .github/workflows/build.yml | 2 +- src-tauri/src/commands/audio.rs | 20 ++++ src-tauri/src/helpers/clamshell.rs | 94 ++++++++++++++++++ src-tauri/src/helpers/mod.rs | 1 + src-tauri/src/lib.rs | 5 + src-tauri/src/managers/audio.rs | 50 ++++++---- src-tauri/src/settings.rs | 3 + .../settings/ClamshellMicrophoneSelector.tsx | 98 +++++++++++++++++++ .../settings/debug/DebugSettings.tsx | 2 + src/components/settings/index.ts | 1 + src/lib/types.ts | 1 + src/stores/settingsStore.ts | 26 +++-- 12 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/helpers/clamshell.rs create mode 100644 src-tauri/src/helpers/mod.rs create mode 100644 src/components/settings/ClamshellMicrophoneSelector.tsx diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf5f488..ab70571 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,7 +145,7 @@ jobs: $outFile = "vulkan_sdk_arm.exe" Write-Host "Downloading Vulkan SDK binaries from $url" Invoke-WebRequest -Uri $url -OutFile $outFile - + - name: Extract Vulkan SDK binaries (Windows ARM64) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') shell: pwsh diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index d39cc54..fe3aa5d 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -159,3 +159,23 @@ pub fn play_test_sound(app: AppHandle, sound_type: String) { }; audio_feedback::play_test_sound(&app, sound); } + +#[tauri::command] +pub fn set_clamshell_microphone(app: AppHandle, device_name: String) -> Result<(), String> { + let mut settings = get_settings(&app); + settings.clamshell_microphone = if device_name == "default" { + None + } else { + Some(device_name) + }; + write_settings(&app, settings); + Ok(()) +} + +#[tauri::command] +pub fn get_clamshell_microphone(app: AppHandle) -> Result { + let settings = get_settings(&app); + Ok(settings + .clamshell_microphone + .unwrap_or_else(|| "default".to_string())) +} diff --git a/src-tauri/src/helpers/clamshell.rs b/src-tauri/src/helpers/clamshell.rs new file mode 100644 index 0000000..70b224b --- /dev/null +++ b/src-tauri/src/helpers/clamshell.rs @@ -0,0 +1,94 @@ +#[cfg(target_os = "macos")] +use std::process::Command; + +/// Checks if the MacBook is in clamshell mode (lid closed with external display) +/// +/// This queries the macOS IORegistry for the AppleClamshellState key. +/// Returns true if the lid is closed, false if open. +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn is_clamshell() -> Result { + let output = Command::new("ioreg") + .args(["-r", "-k", "AppleClamshellState", "-d", "4"]) + .output() + .map_err(|e| format!("Failed to execute ioreg: {}", e))?; + + if !output.status.success() { + return Err(format!( + "ioreg command failed with status: {}", + output.status + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for "AppleClamshellState" = Yes in the output + Ok(stdout.contains("\"AppleClamshellState\" = Yes")) +} + +/// Checks if the Mac has a built-in display (i.e., is a laptop) +/// +/// This queries the macOS IORegistry for built-in displays. +/// Returns true if a built-in display is found (MacBook), false otherwise (Mac Mini, Mac Studio, etc.) +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn has_builtin_display() -> Result { + let output = Command::new("ioreg") + .args(["-l", "-w", "0", "-r", "-c", "IODisplayConnect"]) + .output() + .map_err(|e| format!("Failed to execute ioreg: {}", e))?; + + if !output.status.success() { + return Err(format!( + "ioreg command failed with status: {}", + output.status + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for built-in display indicators + // Built-in displays typically have AppleDisplay or AppleBacklightDisplay + Ok(stdout.contains("AppleBacklightDisplay") + || (stdout.contains("built-in") && stdout.contains("IODisplayConnect"))) +} + +/// Stub implementation for non-macOS platforms +/// Always returns false since clamshell mode is macOS-specific +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub fn is_clamshell() -> Result { + Ok(false) +} + +/// Stub implementation for non-macOS platforms +/// Always returns false since built-in display detection is macOS-specific +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub fn has_builtin_display() -> Result { + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(target_os = "macos")] + fn test_clamshell_check() { + // This will run on macOS and should not panic + let result = is_clamshell(); + assert!(result.is_ok()); + println!("Clamshell state: {:?}", result.unwrap()); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_has_builtin_display() { + let result = has_builtin_display(); + assert!(result.is_ok()); + if let Ok(has_builtin) = result { + println!("Has built-in display (is laptop): {}", has_builtin); + } + } +} diff --git a/src-tauri/src/helpers/mod.rs b/src-tauri/src/helpers/mod.rs new file mode 100644 index 0000000..e6d4ff2 --- /dev/null +++ b/src-tauri/src/helpers/mod.rs @@ -0,0 +1 @@ +pub mod clamshell; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa202fb..d788770 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod audio_feedback; pub mod audio_toolkit; mod clipboard; mod commands; +mod helpers; mod llm_client; mod managers; mod overlay; @@ -274,6 +275,10 @@ pub fn run() { commands::audio::get_selected_output_device, commands::audio::play_test_sound, commands::audio::check_custom_sounds, + commands::audio::set_clamshell_microphone, + commands::audio::get_clamshell_microphone, + helpers::clamshell::is_clamshell, + helpers::clamshell::has_builtin_display, commands::transcription::set_model_unload_timeout, commands::transcription::get_model_load_status, commands::transcription::unload_model_manually, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index c99c389..3249991 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -1,5 +1,6 @@ use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad}; -use crate::settings::get_settings; +use crate::helpers::clamshell; +use crate::settings::{get_settings, AppSettings}; use crate::utils; use log::{debug, info}; use std::sync::{Arc, Mutex}; @@ -180,6 +181,35 @@ impl AudioRecordingManager { Ok(manager) } + /* ---------- helper methods --------------------------------------------- */ + + fn get_effective_microphone_device(&self, settings: &AppSettings) -> Option { + // 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 @@ -234,23 +264,9 @@ impl AudioRecordingManager { )?); } - // Get the selected device from settings + // Get the selected device from settings, considering clamshell mode let settings = get_settings(&self.app_handle); - let selected_device = if let Some(device_name) = settings.selected_microphone { - // 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 - } - } - } else { - None - }; + let selected_device = self.get_effective_microphone_device(&settings); if let Some(rec) = recorder_opt.as_mut() { rec.open(selected_device) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d6f6d28..5ed45c0 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -160,6 +160,8 @@ pub struct AppSettings { #[serde(default)] pub selected_microphone: Option, #[serde(default)] + pub clamshell_microphone: Option, + #[serde(default)] pub selected_output_device: Option, #[serde(default = "default_translate_to_english")] pub translate_to_english: bool, @@ -350,6 +352,7 @@ pub fn get_default_settings() -> AppSettings { selected_model: "".to_string(), always_on_microphone: false, selected_microphone: None, + clamshell_microphone: None, selected_output_device: None, translate_to_english: false, selected_language: "auto".to_string(), diff --git a/src/components/settings/ClamshellMicrophoneSelector.tsx b/src/components/settings/ClamshellMicrophoneSelector.tsx new file mode 100644 index 0000000..8722955 --- /dev/null +++ b/src/components/settings/ClamshellMicrophoneSelector.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { ResetButton } from "../ui/ResetButton"; +import { useSettings } from "../../hooks/useSettings"; + +interface ClamshellMicrophoneSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ClamshellMicrophoneSelector: React.FC = + React.memo(({ descriptionMode = "tooltip", grouped = false }) => { + const { + getSetting, + updateSetting, + resetSetting, + isUpdating, + isLoading, + audioDevices, + refreshAudioDevices, + } = useSettings(); + + const [hasBuiltinDisplay, setHasBuiltinDisplay] = useState(false); + + useEffect(() => { + // Check if the device has a built-in display (i.e., is a laptop) + const checkBuiltinDisplay = async () => { + try { + const result = await invoke("has_builtin_display"); + setHasBuiltinDisplay(result); + } catch (error) { + console.error("Failed to check for built-in display:", error); + setHasBuiltinDisplay(false); + } + }; + + checkBuiltinDisplay(); + }, []); + + // Only render on devices with built-in displays (laptops) + if (!hasBuiltinDisplay) { + return null; + } + + const selectedClamshellMicrophone = + getSetting("clamshell_microphone") === "default" + ? "Default" + : getSetting("clamshell_microphone") || "Default"; + + const handleClamshellMicrophoneSelect = async (deviceName: string) => { + await updateSetting("clamshell_microphone", deviceName); + }; + + const handleReset = async () => { + await resetSetting("clamshell_microphone"); + }; + + const microphoneOptions = audioDevices.map((device) => ({ + value: device.name, + label: device.name, + })); + + return ( + +
+ + +
+
+ ); + }); + +ClamshellMicrophoneSelector.displayName = "ClamshellMicrophoneSelector"; diff --git a/src/components/settings/debug/DebugSettings.tsx b/src/components/settings/debug/DebugSettings.tsx index b061c04..b9251bd 100644 --- a/src/components/settings/debug/DebugSettings.tsx +++ b/src/components/settings/debug/DebugSettings.tsx @@ -6,6 +6,7 @@ import { AlwaysOnMicrophone } from "../AlwaysOnMicrophone"; import { SoundPicker } from "../SoundPicker"; import { PostProcessingToggle } from "../PostProcessingToggle"; import { MuteWhileRecording } from "../MuteWhileRecording"; +import { ClamshellMicrophoneSelector } from "../ClamshellMicrophoneSelector"; export const DebugSettings: React.FC = () => { return ( @@ -18,6 +19,7 @@ export const DebugSettings: React.FC = () => { + diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index e8da4c0..cecc122 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -8,6 +8,7 @@ export { PostProcessingSettings } from "./post-processing/PostProcessingSettings // Individual setting components export { MicrophoneSelector } from "./MicrophoneSelector"; +export { ClamshellMicrophoneSelector } from "./ClamshellMicrophoneSelector"; export { OutputDeviceSelector } from "./OutputDeviceSelector"; export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; export { PushToTalk } from "./PushToTalk"; diff --git a/src/lib/types.ts b/src/lib/types.ts index 9a180bc..521c25f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -79,6 +79,7 @@ export const SettingsSchema = z.object({ selected_model: z.string(), always_on_microphone: z.boolean(), selected_microphone: z.string().nullable().optional(), + clamshell_microphone: z.string().nullable().optional(), selected_output_device: z.string().nullable().optional(), translate_to_english: z.boolean(), selected_language: z.string(), diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index d78c102..df55f4e 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -66,6 +66,7 @@ const DEFAULT_SETTINGS: Partial = { autostart_enabled: false, push_to_talk: false, selected_microphone: "Default", + clamshell_microphone: "Default", selected_output_device: "Default", translate_to_english: false, selected_language: "auto", @@ -102,6 +103,10 @@ const settingUpdaters: { invoke("set_selected_microphone", { deviceName: value === "Default" ? "default" : value, }), + clamshell_microphone: (value) => + invoke("set_clamshell_microphone", { + deviceName: value === "Default" ? "default" : value, + }), selected_output_device: (value) => invoke("set_selected_output_device", { deviceName: value === "Default" ? "default" : value, @@ -166,12 +171,17 @@ export const useSettingsStore = create()( const settings = (await store.get("settings")) as Settings; // Load additional settings that come from invoke calls - const [microphoneMode, selectedMicrophone, selectedOutputDevice] = - await Promise.allSettled([ - invoke("get_microphone_mode"), - invoke("get_selected_microphone"), - invoke("get_selected_output_device"), - ]); + const [ + microphoneMode, + selectedMicrophone, + clamshellMicrophone, + selectedOutputDevice, + ] = await Promise.allSettled([ + invoke("get_microphone_mode"), + invoke("get_selected_microphone"), + invoke("get_clamshell_microphone"), + invoke("get_selected_output_device"), + ]); // Merge all settings const mergedSettings: Settings = { @@ -184,6 +194,10 @@ export const useSettingsStore = create()( selectedMicrophone.status === "fulfilled" ? (selectedMicrophone.value as string) : "Default", + clamshell_microphone: + clamshellMicrophone.status === "fulfilled" + ? (clamshellMicrophone.value as string) + : "Default", selected_output_device: selectedOutputDevice.status === "fulfilled" ? (selectedOutputDevice.value as string)