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:
parent
25f4a564fd
commit
45eec57226
12 changed files with 279 additions and 24 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
94
src-tauri/src/helpers/clamshell.rs
Normal file
94
src-tauri/src/helpers/clamshell.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src-tauri/src/helpers/mod.rs
Normal file
1
src-tauri/src/helpers/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod clamshell;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
98
src/components/settings/ClamshellMicrophoneSelector.tsx
Normal file
98
src/components/settings/ClamshellMicrophoneSelector.tsx
Normal 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";
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue