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 <cj@cjpais.com>
This commit is contained in:
Jorge 2025-11-17 03:38:41 +00:00 committed by GitHub
parent 25f4a564fd
commit 45eec57226
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 279 additions and 24 deletions

View file

@ -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

View file

@ -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<String, String> {
let settings = get_settings(&app);
Ok(settings
.clamshell_microphone
.unwrap_or_else(|| "default".to_string()))
}

View file

@ -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<bool, String> {
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<bool, String> {
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<bool, String> {
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<bool, String> {
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);
}
}
}

View file

@ -0,0 +1 @@
pub mod clamshell;

View file

@ -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,

View file

@ -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<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
@ -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)

View file

@ -160,6 +160,8 @@ pub struct AppSettings {
#[serde(default)]
pub selected_microphone: Option<String>,
#[serde(default)]
pub clamshell_microphone: Option<String>,
#[serde(default)]
pub selected_output_device: Option<String>,
#[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(),

View file

@ -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<ClamshellMicrophoneSelectorProps> =
React.memo(({ descriptionMode = "tooltip", grouped = false }) => {
const {
getSetting,
updateSetting,
resetSetting,
isUpdating,
isLoading,
audioDevices,
refreshAudioDevices,
} = useSettings();
const [hasBuiltinDisplay, setHasBuiltinDisplay] = useState<boolean>(false);
useEffect(() => {
// Check if the device has a built-in display (i.e., is a laptop)
const checkBuiltinDisplay = async () => {
try {
const result = await invoke<boolean>("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 (
<SettingContainer
title="Clamshell Microphone"
description="Choose a different microphone to use when your laptop lid is closed"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="flex items-center space-x-1">
<Dropdown
options={microphoneOptions}
selectedValue={selectedClamshellMicrophone}
onSelect={handleClamshellMicrophoneSelect}
placeholder={
isLoading || audioDevices.length === 0
? "Loading..."
: "Select microphone..."
}
disabled={
isUpdating("clamshell_microphone") ||
isLoading ||
audioDevices.length === 0
}
onRefresh={refreshAudioDevices}
/>
<ResetButton
onClick={handleReset}
disabled={isUpdating("clamshell_microphone") || isLoading}
/>
</div>
</SettingContainer>
);
});
ClamshellMicrophoneSelector.displayName = "ClamshellMicrophoneSelector";

View file

@ -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 = () => {
<WordCorrectionThreshold descriptionMode="tooltip" grouped={true} />
<HistoryLimit descriptionMode="tooltip" grouped={true} />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
<ClamshellMicrophoneSelector descriptionMode="tooltip" grouped={true} />
<PostProcessingToggle descriptionMode="tooltip" grouped={true} />
<MuteWhileRecording descriptionMode="tooltip" grouped={true} />
</SettingsGroup>

View file

@ -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";

View file

@ -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(),

View file

@ -66,6 +66,7 @@ const DEFAULT_SETTINGS: Partial<Settings> = {
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<SettingsStore>()(
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<SettingsStore>()(
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)