From 98e3d3715ed409bbc30983879314ca5cccda6866 Mon Sep 17 00:00:00 2001 From: "kaku.ki" Date: Wed, 6 May 2026 16:48:31 +0900 Subject: [PATCH] feat(settings): add microphone device selection --- openless-all/app/src-tauri/src/commands.rs | 94 +++- openless-all/app/src-tauri/src/coordinator.rs | 29 +- openless-all/app/src-tauri/src/lib.rs | 210 +++++++- openless-all/app/src-tauri/src/recorder.rs | 92 +++- openless-all/app/src-tauri/src/types.rs | 8 + openless-all/app/src/i18n/en.ts | 10 + openless-all/app/src/i18n/ja.ts | 10 + openless-all/app/src/i18n/ko.ts | 10 + openless-all/app/src/i18n/zh-CN.ts | 12 +- openless-all/app/src/i18n/zh-TW.ts | 10 + openless-all/app/src/lib/ipc.ts | 19 + openless-all/app/src/lib/types.ts | 7 + openless-all/app/src/pages/Settings.tsx | 477 +++++++++++++++++- .../app/src/state/HotkeySettingsContext.tsx | 30 +- 14 files changed, 973 insertions(+), 45 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 97190067..e80ef597 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -3,14 +3,16 @@ use std::sync::Arc; use std::time::Duration; +use parking_lot::Mutex; use serde::Serialize; use serde_json::Value; -use tauri::{AppHandle, Emitter, State, Window}; +use tauri::{AppHandle, Emitter, Manager, State, Window}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; +use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, @@ -18,6 +20,26 @@ use crate::types::{ }; type CoordinatorState<'a> = State<'a, Arc>; +pub type MicrophoneMonitorState = Mutex>; +pub type TrayMicrophoneMenuState = Mutex>; + +pub struct TrayMicrophoneMenuItem { + pub id: String, + pub device_name: String, + pub item: tauri::menu::CheckMenuItem, +} + +pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) { + for item in items { + let _ = item.item.set_checked(item.device_name == device_name); + } +} + +struct LevelProbeConsumer; + +impl AudioConsumer for LevelProbeConsumer { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} +} // ─────────────────────────── settings + credentials ─────────────────────────── @@ -66,33 +88,33 @@ impl SettingsWriter for Coordinator { } } -impl SettingsWriter for Arc { +impl SettingsWriter for Arc { fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { - self.prefs().set(prefs).map_err(|e| e.to_string()) + (**self).write_settings(prefs) } fn refresh_dictation_hotkey(&self) { - self.update_hotkey_binding(); + (**self).refresh_dictation_hotkey(); } fn refresh_qa_hotkey(&self) { - self.update_qa_hotkey_binding(); + (**self).refresh_qa_hotkey(); } fn refresh_combo_hotkey(&self) { - self.update_combo_hotkey_binding(); + (**self).refresh_combo_hotkey(); } fn refresh_translation_hotkey(&self) { - self.update_translation_hotkey_binding(); + (**self).refresh_translation_hotkey(); } fn refresh_switch_style_hotkey(&self) { - self.update_switch_style_hotkey_binding(); + (**self).refresh_switch_style_hotkey(); } fn refresh_open_app_hotkey(&self) { - self.update_open_app_hotkey_binding(); + (**self).refresh_open_app_hotkey(); } } @@ -116,12 +138,17 @@ fn persist_settings( pub fn set_settings( coord: CoordinatorState<'_>, app: AppHandle, + tray_microphones: State<'_, TrayMicrophoneMenuState>, prefs: UserPreferences, ) -> Result<(), String> { // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 persist_settings(&*coord, prefs.clone())?; + if let Err(err) = crate::refresh_tray_microphone_menu(&app) { + log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); + sync_tray_microphone_selection(&tray_microphones.lock(), &prefs.microphone_device_name); + } let _ = app.emit("prefs:changed", &prefs); Ok(()) } @@ -146,6 +173,55 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { crate::windows_ime_profile::get_windows_ime_status() } +#[tauri::command] +pub fn list_microphone_devices() -> Result, String> { + crate::recorder::list_input_devices().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn start_microphone_level_monitor( + app: AppHandle, + device_name: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + if let Some(existing) = state.lock().take() { + existing.stop(); + } + + let selected = device_name.trim().to_string(); + let microphone_device_name = if selected.is_empty() { + None + } else { + Some(selected) + }; + let consumer: Arc = Arc::new(LevelProbeConsumer); + let level_app = app.clone(); + let level_handler: Arc = Arc::new(move |level| { + let _ = level_app.emit("microphone:level", serde_json::json!({ "level": level })); + }); + let (recorder, _runtime_errors) = + Recorder::start(microphone_device_name, consumer, level_handler) + .map_err(|e| e.to_string())?; + *state.lock() = Some(recorder); + Ok(()) + }) + .await + .map_err(|e| format!("start microphone monitor task failed: {e}"))? +} + +#[tauri::command] +pub async fn stop_microphone_level_monitor(app: AppHandle) { + let _ = tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + let recorder = state.lock().take(); + if let Some(recorder) = recorder { + recorder.stop(); + } + }) + .await; +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 20a8df43..82dda2a4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1677,6 +1677,27 @@ fn store_recorder_for_session(inner: &Arc, session_id: u64, recorder: Rec *inner.recorder.lock() = Some(SessionResource::new(session_id, recorder)); } +fn selected_microphone_device_name(inner: &Arc) -> Option { + let name = inner.prefs.get().microphone_device_name.trim().to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +fn stop_microphone_preview_monitor(inner: &Arc, owner: &str) { + let Some(app) = inner.app.lock().as_ref().cloned() else { + return; + }; + let state = app.state::(); + let recorder = state.lock().take(); + if let Some(recorder) = recorder { + log::info!("[recorder] stopping microphone preview monitor before {owner}"); + recorder.stop(); + } +} + fn acquire_recording_mute(inner: &Arc, owner: &str) { if !inner.prefs.get().mute_during_recording { return; @@ -2093,8 +2114,10 @@ fn start_recorder_for_starting( ); }); + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "dictation recorder"); acquire_recording_mute(inner, "dictation"); - match Recorder::start(consumer, level_handler) { + match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); @@ -3383,8 +3406,10 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { ); }); + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "QA recorder"); acquire_recording_mute(inner, "qa"); - match Recorder::start(consumer, level_handler) { + match Recorder::start(microphone_device_name, consumer, level_handler) { Ok((rec, runtime_errors)) => { *inner.qa_recorder.lock() = Some(rec); // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index f8900a44..3c93b55c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -35,13 +35,15 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::mpsc; use std::sync::Arc; -#[cfg(target_os = "macos")] use std::time::Duration; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); -use tauri::menu::{MenuBuilder, MenuItemBuilder}; +static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false); +use tauri::menu::{ + CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder, +}; use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Runtime}; @@ -75,6 +77,8 @@ pub fn run() { )) .manage(coordinator.clone()) .manage(local_asr_download_manager.clone()) + .manage(commands::MicrophoneMonitorState::new(None)) + .manage(commands::TrayMicrophoneMenuState::new(Vec::new())) .setup(move |app| { // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 @@ -151,33 +155,45 @@ pub fn run() { // 菜单栏图标 — 与 Swift `MenuBarController` 同语义: // 左键点 → 显示/聚焦主窗口;菜单含「显示主窗口」「退出」。 - let toggle = MenuItemBuilder::with_id("toggle", "显示主窗口").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "退出 OpenLess").build(app)?; - let menu = MenuBuilder::new(app).items(&[&toggle, &quit]).build()?; + let tray_menu = build_tray_menu(app, &coordinator)?; + let menu = tray_menu.menu; // 与 Swift `StatusBarIcon.swift` 行为一致:用全彩 AppIcon,**不**走 template 模式 // (走 template 会被 macOS 染成单色 → 看起来像个黑方块)。 if let Some(icon) = app.default_window_icon() { + { + let state = app.state::(); + *state.lock() = tray_menu.microphone_items; + } let _tray = TrayIconBuilder::with_id("main-tray") .icon(icon.clone()) .icon_as_template(false) .menu(&menu) .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { + .on_menu_event(move |app, event| match event.id.as_ref() { "toggle" => show_main_window(app), "quit" => app.exit(0), - _ => {} + id => handle_microphone_tray_menu_event( + app, + id, + ), }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - .. - } = event - { - show_main_window(tray.app_handle()); + .on_tray_icon_event(move |tray, event| { + match event { + TrayIconEvent::Enter { .. } => { + if let Err(err) = refresh_tray_microphone_menu(tray.app_handle()) { + log::warn!("[tray] refresh microphone menu on hover failed: {err}"); + } + } + TrayIconEvent::Click { + button: MouseButton::Left, + .. + } => show_main_window(tray.app_handle()), + _ => {} } }) .build(app)?; + start_tray_microphone_watcher(app.handle().clone()); } else { log::warn!("[startup] default window icon missing; tray icon disabled"); } @@ -201,6 +217,9 @@ pub fn run() { commands::get_hotkey_capability, commands::set_shortcut_recording_active, commands::get_windows_ime_status, + commands::list_microphone_devices, + commands::start_microphone_level_monitor, + commands::stop_microphone_level_monitor, commands::get_credentials, commands::set_credential, commands::list_history, @@ -293,6 +312,7 @@ pub fn run() { } } RunEvent::Exit => { + TRAY_MICROPHONE_WATCHER_STOPPING.store(true, Ordering::Relaxed); let coordinator = app.state::>(); coordinator.stop_hotkey_listener(); coordinator.stop_qa_hotkey_listener(); @@ -305,6 +325,168 @@ pub fn run() { }); } +struct MicrophoneTrayMenu { + submenu: Submenu, + items: Vec, +} + +struct TrayMenu { + menu: Menu, + microphone_items: Vec, +} + +fn build_tray_menu>( + app: &M, + coordinator: &Arc, +) -> tauri::Result { + let toggle = MenuItemBuilder::with_id("toggle", "显示主窗口").build(app)?; + let microphone_menu = build_microphone_tray_menu(app, coordinator)?; + let quit = MenuItemBuilder::with_id("quit", "退出 OpenLess").build(app)?; + let menu = MenuBuilder::new(app) + .items(&[&toggle, µphone_menu.submenu, &quit]) + .build()?; + Ok(TrayMenu { + menu, + microphone_items: microphone_menu.items, + }) +} + +fn build_microphone_tray_menu>( + app: &M, + coordinator: &Arc, +) -> tauri::Result { + let selected = coordinator.prefs().get().microphone_device_name; + let mut items = Vec::new(); + let mut submenu = SubmenuBuilder::with_id(app, "microphone", "选择麦克风"); + let devices = match recorder::list_input_devices() { + Ok(devices) => devices, + Err(err) => { + log::warn!("[tray] list microphone devices failed: {err}"); + Vec::new() + } + }; + let selected_available = selected.trim().is_empty() + || devices.iter().any(|device| device.name == selected); + + let default_item = CheckMenuItemBuilder::with_id("mic-default", "系统默认麦克风") + .checked(selected.trim().is_empty() || !selected_available) + .build(app)?; + submenu = submenu.item(&default_item); + items.push(commands::TrayMicrophoneMenuItem { + id: "mic-default".to_string(), + device_name: String::new(), + item: default_item, + }); + + if devices.is_empty() { + let empty = MenuItemBuilder::with_id("mic-empty", "未发现麦克风") + .enabled(false) + .build(app)?; + submenu = submenu.item(&empty); + } else { + for (index, device) in devices.into_iter().enumerate() { + let id = format!("mic-device-{index}"); + let label = if device.is_default { + format!("{}(系统默认)", device.name) + } else { + device.name.clone() + }; + let item = CheckMenuItemBuilder::with_id(&id, label) + .checked(selected == device.name) + .build(app)?; + submenu = submenu.item(&item); + items.push(commands::TrayMicrophoneMenuItem { + id, + device_name: device.name, + item, + }); + } + } + + Ok(MicrophoneTrayMenu { + submenu: submenu.build()?, + items, + }) +} + +pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> { + let coordinator = app.state::>(); + let tray_menu = build_tray_menu(app, &coordinator)?; + if let Some(tray) = app.tray_by_id("main-tray") { + tray.set_menu(Some(tray_menu.menu))?; + } + let state = app.state::(); + *state.lock() = tray_menu.microphone_items; + Ok(()) +} + +fn microphone_device_signature() -> Option> { + match recorder::list_input_devices() { + Ok(devices) => Some( + devices + .into_iter() + .map(|device| (device.name, device.is_default)) + .collect(), + ), + Err(err) => { + log::warn!("[tray] watch microphone devices failed: {err}"); + None + } + } +} + +fn start_tray_microphone_watcher(app: AppHandle) { + TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed); + if let Err(err) = std::thread::Builder::new() + .name("openless-tray-mic-watch".into()) + .spawn(move || { + let mut last_signature = microphone_device_signature(); + while !TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1500)); + if TRAY_MICROPHONE_WATCHER_STOPPING.load(Ordering::Relaxed) { + break; + } + let signature = microphone_device_signature(); + if signature == last_signature { + continue; + } + last_signature = signature; + let app = app.clone(); + let refresh_app = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = refresh_tray_microphone_menu(&refresh_app) { + log::warn!("[tray] refresh microphone menu after device change failed: {err}"); + } + let _ = refresh_app.emit("microphone:devices-changed", serde_json::json!({})); + }); + } + }) { + log::warn!("[tray] start microphone watcher failed: {err}"); + } +} + +fn handle_microphone_tray_menu_event( + app: &AppHandle, + id: &str, +) { + let tray_items = app.state::(); + let items = tray_items.lock(); + let Some(selected) = items.iter().find(|item| item.id == id) else { + return; + }; + + let coord = app.state::>(); + let mut prefs = coord.prefs().get(); + prefs.microphone_device_name = selected.device_name.clone(); + if let Err(err) = coord.prefs().set(prefs.clone()) { + log::warn!("[tray] save microphone preference failed: {err}"); + return; + } + let _ = app.emit("prefs:changed", &prefs); + + commands::sync_tray_microphone_selection(&items, &selected.device_name); +} + #[cfg(target_os = "windows")] fn apply_windows_rounded_frame(window: &tauri::WebviewWindow) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index b558fc9d..35b44788 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -18,6 +18,7 @@ use std::thread::{self, JoinHandle}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{SampleFormat, StreamConfig}; use parking_lot::Mutex; +use serde::Serialize; use thiserror::Error; /// 目标采样率(与 Swift 端常量一致;不要改)。 @@ -34,6 +35,13 @@ pub trait AudioConsumer: Send + Sync { fn consume_pcm_chunk(&self, pcm: &[u8]); } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrophoneDevice { + pub name: String, + pub is_default: bool, +} + /// 采集器错误。 #[derive(Debug, Error)] pub enum RecorderError { @@ -55,6 +63,7 @@ impl Recorder { /// /// 实际的 cpal Stream 在独立线程里构造、播放、最终析构——因为它 `!Send`。 pub fn start( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, ) -> Result<(Self, Receiver), RecorderError> { @@ -69,6 +78,7 @@ impl Recorder { .name("openless-recorder".into()) .spawn(move || { run_audio_thread( + microphone_device_name, consumer, level_handler, stop_for_thread, @@ -107,23 +117,54 @@ impl Recorder { } } +pub fn list_input_devices() -> Result, RecorderError> { + let host = cpal::default_host(); + let default_name = host + .default_input_device() + .and_then(|device| device.name().ok()); + let devices = host + .input_devices() + .map_err(|e| RecorderError::EngineFailed(format!("input_devices: {e}")))?; + + let mut result = Vec::new(); + for device in devices { + let name = match device.name() { + Ok(name) => name, + Err(err) => { + log::warn!("[recorder] failed to read input device name: {err}"); + continue; + } + }; + result.push(MicrophoneDevice { + is_default: default_name.as_deref() == Some(name.as_str()), + name, + }); + } + Ok(result) +} + /// 音频线程主体:构造 Stream → 通过 startup_tx 报告 → 循环到 stop_flag。 fn run_audio_thread( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { - let (stream, state) = - match build_input_stream(consumer, level_handler, runtime_error_tx.clone()) { - Ok(s) => s, - Err(err) => { - // 启动失败:通知主线程后即退出。 - let _ = startup_tx.send(Err(err)); - return; - } - }; + let (stream, state) = match build_input_stream( + microphone_device_name, + consumer, + level_handler, + runtime_error_tx.clone(), + ) { + Ok(s) => s, + Err(err) => { + // 启动失败:通知主线程后即退出。 + let _ = startup_tx.send(Err(err)); + return; + } + }; if let Err(err) = stream.play() { let _ = startup_tx.send(Err(RecorderError::EngineFailed(format!("play: {err}")))); @@ -204,14 +245,13 @@ fn run_audio_thread( /// 选默认输入设备 + 默认配置 + 构造 Stream。 fn build_input_stream( + microphone_device_name: Option, consumer: Arc, level_handler: Arc, runtime_error_tx: Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { let host = cpal::default_host(); - let device = host - .default_input_device() - .ok_or_else(|| RecorderError::EngineFailed("no default input device".into()))?; + let device = select_input_device(&host, microphone_device_name.as_deref())?; let supported = device .default_input_config() @@ -223,7 +263,8 @@ fn build_input_stream( let channels = config.channels as usize; log::info!( - "[recorder] inputFormat sampleRate={} channels={} fmt={:?}", + "[recorder] inputDevice={} inputFormat sampleRate={} channels={} fmt={:?}", + device.name().unwrap_or_else(|_| "".into()), input_sr, channels, sample_format @@ -244,6 +285,31 @@ fn build_input_stream( Ok((stream, state)) } +fn select_input_device( + host: &cpal::Host, + microphone_device_name: Option<&str>, +) -> Result { + let preferred = microphone_device_name + .map(str::trim) + .filter(|name| !name.is_empty()); + if let Some(preferred) = preferred { + let devices = host + .input_devices() + .map_err(|e| RecorderError::EngineFailed(format!("input_devices: {e}")))?; + for device in devices { + if device.name().ok().as_deref() == Some(preferred) { + return Ok(device); + } + } + log::warn!( + "[recorder] preferred input device not found; falling back to default: {preferred}" + ); + } + + host.default_input_device() + .ok_or_else(|| RecorderError::EngineFailed("no default input device".into())) +} + /// 启动期 default_input_config 失败:依靠错误字符串关键字粗判权限问题。 /// cpal 在 macOS 没拿到 mic 授权时通常返回 `BackendSpecific`,我们尽力识别。 fn classify_default_config_err(msg: String) -> RecorderError { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 72ee04e8..f6c0e83d 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -121,6 +121,9 @@ pub struct UserPreferences { /// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 #[serde(default)] pub mute_during_recording: bool, + /// 录音输入设备名称。空字符串 = 使用系统默认麦克风。 + #[serde(default)] + pub microphone_device_name: String, pub active_asr_provider: String, // "volcengine" | "apple-speech" | ... pub active_llm_provider: String, // "ark" | "openai" | ... /// Windows/Linux 粘贴成功后是否恢复用户原剪贴板。默认 true 跟历史行为一致; @@ -208,6 +211,8 @@ struct UserPreferencesWire { show_capsule: bool, #[serde(default)] mute_during_recording: bool, + #[serde(default)] + microphone_device_name: String, active_asr_provider: String, active_llm_provider: String, restore_clipboard_after_paste: bool, @@ -242,6 +247,7 @@ impl Default for UserPreferencesWire { launch_at_login: prefs.launch_at_login, show_capsule: prefs.show_capsule, mute_during_recording: prefs.mute_during_recording, + microphone_device_name: prefs.microphone_device_name, active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, @@ -282,6 +288,7 @@ impl<'de> Deserialize<'de> for UserPreferences { launch_at_login: wire.launch_at_login, show_capsule: wire.show_capsule, mute_during_recording: wire.mute_during_recording, + microphone_device_name: wire.microphone_device_name, active_asr_provider: wire.active_asr_provider, active_llm_provider: wire.active_llm_provider, restore_clipboard_after_paste: wire.restore_clipboard_after_paste, @@ -386,6 +393,7 @@ impl Default for UserPreferences { launch_at_login: false, show_capsule: true, mute_during_recording: false, + microphone_device_name: String::new(), active_asr_provider: "volcengine".into(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b8f4a3c3..0c22e0c4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -274,6 +274,16 @@ export const en: typeof zhCN = { modeHold: 'Push-to-talk', migrationNoticeTitle: 'Default recording mode is now Toggle', migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + microphoneLabel: 'Preferred microphone', + microphoneDesc: 'Choose the preferred input device. If it is temporarily unavailable, OpenLess uses the system default microphone and switches back when it reconnects.', + microphoneDefault: 'System default microphone', + microphoneDefaultDesc: 'Use the system default input device', + microphoneSystemDefault: 'system default', + microphoneUnavailable: 'unavailable', + microphoneLoadError: 'Failed to load microphones: {{message}}', + microphoneDialogTitle: 'Microphone', + microphoneDialogDesc: 'Choose a microphone that can pick up your voice. If the meter does not move, try another microphone.', + microphoneMonitorError: 'Failed to monitor input level: {{message}}', capsuleLabel: 'Recording capsule', capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', muteDuringRecordingLabel: 'Mute while recording', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 1f290269..45c8f8f8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -276,6 +276,16 @@ export const ja: typeof zhCN = { modeHold: '押し続けて話す', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', + microphoneLabel: '優先マイク', + microphoneDesc: '優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。', + microphoneDefault: 'システムのデフォルトマイク', + microphoneDefaultDesc: 'システムのデフォルト入力デバイスを使用', + microphoneSystemDefault: 'システムデフォルト', + microphoneUnavailable: '利用不可', + microphoneLoadError: 'マイクの読み込みに失敗:{{message}}', + microphoneDialogTitle: 'マイク', + microphoneDialogDesc: '声を拾えるマイクを選択してください。メーターが動かない場合は別のマイクを試してください。', + microphoneMonitorError: '入力レベルの監視に失敗:{{message}}', capsuleLabel: '録音カプセル', capsuleDesc: '録音 / 転写中、画面下部に半透明のカプセルを表示。', muteDuringRecordingLabel: '録音中はミュート', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 5556339d..b7003c37 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -276,6 +276,16 @@ export const ko: typeof zhCN = { modeHold: '눌러서 말하기', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', + microphoneLabel: '기본 선택 마이크', + microphoneDesc: '우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.', + microphoneDefault: '시스템 기본 마이크', + microphoneDefaultDesc: '시스템 기본 입력 장치 사용', + microphoneSystemDefault: '시스템 기본값', + microphoneUnavailable: '사용할 수 없음', + microphoneLoadError: '마이크 로드 실패: {{message}}', + microphoneDialogTitle: '마이크', + microphoneDialogDesc: '목소리를 받을 수 있는 마이크를 선택하세요. 미터가 움직이지 않으면 다른 마이크를 시도하세요.', + microphoneMonitorError: '입력 레벨 모니터링 실패: {{message}}', capsuleLabel: '녹음 캡슐', capsuleDesc: '녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.', muteDuringRecordingLabel: '녹음 중 음소거', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 57e0ec88..97f72673 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -271,7 +271,17 @@ export const zhCN = { modeToggle: '切换式', modeHold: '按住说话', migrationNoticeTitle: '默认已改为切换式说话', - migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回"按住说话"。', + migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + microphoneLabel: '首选麦克风', + microphoneDesc: '选择优先使用的输入设备。设备暂时不可用时会使用系统默认麦克风,重新连接后自动切回首选设备。', + microphoneDefault: '系统默认麦克风', + microphoneDefaultDesc: '使用系统默认输入设备', + microphoneSystemDefault: '系统默认', + microphoneUnavailable: '不可用', + microphoneLoadError: '麦克风列表读取失败:{{message}}', + microphoneDialogTitle: '麦克风', + microphoneDialogDesc: '选择能捕捉到您声音的麦克风。如果指示条没有移动,请尝试其他麦克风。', + microphoneMonitorError: '输入电平监听失败:{{message}}', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', muteDuringRecordingLabel: '录音时静音', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 49a41990..44d92096 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -281,6 +281,16 @@ export const zhTW: typeof zhCN = { comboRecorded: '已錄製', comboClear: '清除', comboConflict: '此快捷鍵組合不可用', + microphoneLabel: '首選麥克風', + microphoneDesc: '選擇優先使用的輸入設備。設備暫時不可用時會使用系統默認麥克風,重新連接後自動切回首選設備。', + microphoneDefault: '系統默認麥克風', + microphoneDefaultDesc: '使用系統默認輸入設備', + microphoneSystemDefault: '系統默認', + microphoneUnavailable: '不可用', + microphoneLoadError: '麥克風列表讀取失敗:{{message}}', + microphoneDialogTitle: '麥克風', + microphoneDialogDesc: '選擇能捕捉到您聲音的麥克風。如果指示條沒有移動,請嘗試其他麥克風。', + microphoneMonitorError: '輸入電平監聽失敗:{{message}}', capsuleLabel: '錄音膠囊', capsuleDesc: '錄音 / 轉寫時在屏幕底部顯示半透明膠囊。', muteDuringRecordingLabel: '錄音時靜音', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 50986f60..a0aa78af 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -9,6 +9,7 @@ import type { DictionaryEntry, HotkeyCapability, HotkeyStatus, + MicrophoneDevice, PermissionStatus, PolishMode, QaHotkeyBinding, @@ -50,6 +51,7 @@ const mockSettings: UserPreferences = { launchAtLogin: false, showCapsule: true, muteDuringRecording: false, + microphoneDeviceName: '', activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, @@ -110,6 +112,11 @@ const mockWindowsImeStatus: WindowsImeStatus = { dllPath: null, }; +const mockMicrophoneDevices: MicrophoneDevice[] = [ + { name: 'Built-in Microphone', isDefault: true }, + { name: 'USB Microphone', isDefault: false }, +]; + const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ id: `mock-${i}`, createdAt: new Date().toISOString(), @@ -154,6 +161,18 @@ export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } +export function listMicrophoneDevices(): Promise { + return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices); +} + +export function startMicrophoneLevelMonitor(deviceName: string): Promise { + return invokeOrMock('start_microphone_level_monitor', { deviceName }, () => undefined); +} + +export function stopMicrophoneLevelMonitor(): Promise { + return invokeOrMock('stop_microphone_level_monitor', undefined, () => undefined); +} + // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index c0a27a23..71513411 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -119,6 +119,8 @@ export interface UserPreferences { showCapsule: boolean; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ muteDuringRecording: boolean; + /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ + microphoneDeviceName: string; activeAsrProvider: string; activeLlmProvider: string; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ @@ -154,6 +156,11 @@ export interface UserPreferences { localAsrKeepLoadedSecs: number; } +export interface MicrophoneDevice { + name: string; + isDefault: boolean; +} + /** Rust 通过 `qa:state` 事件下发的 payload。 * v2 (issue #118 v2):支持多轮对话,messages 数组每次由后端整段下发(单一可信源)。 * v2.1:开 `stream:true`,LLM 答案逐 chunk 通过 `answer_delta` 事件推前端边渲染。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 050852f6..b667fdb7 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -2,7 +2,7 @@ // Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About) // keep their inline-style literals 1:1 with the source JSX. -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; @@ -16,6 +16,7 @@ import { getHotkeyStatus, getWindowsImeStatus, isTauri, + listMicrophoneDevices, openExternal, openSystemSettings, listProviderModels, @@ -30,12 +31,16 @@ import { setQaHotkey, setSwitchStyleHotkey, setTranslationHotkey, + startMicrophoneLevelMonitor, + stopMicrophoneLevelMonitor, validateProviderCredentials, } from '../lib/ipc'; import type { HotkeyCapability, HotkeyMode, HotkeyStatus, + HotkeyTrigger, + MicrophoneDevice, PermissionStatus, WindowsImeStatus, } from '../lib/types'; @@ -156,16 +161,17 @@ interface SettingRowProps { label: string; desc?: string; children: ReactNode; + controlWidth?: number | string; } -function SettingRow({ label, desc, children }: SettingRowProps) { +function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) { return ( -
-
+
+
{label}
{desc &&
{desc}
}
-
{children}
+
{children}
); } @@ -173,6 +179,69 @@ function SettingRow({ label, desc, children }: SettingRowProps) { function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const [microphoneDevices, setMicrophoneDevices] = useState([]); + const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); + const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); + const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false); + + const loadMicrophoneDevices = useCallback(async ( + signal?: { cancelled: boolean }, + options: { showLoading?: boolean } = {}, + ) => { + if (options.showLoading ?? true) { + setMicrophoneDevicesLoaded(false); + } + setMicrophoneDevicesError(null); + try { + const devices = await listMicrophoneDevices(); + if (signal?.cancelled) return; + setMicrophoneDevices(devices); + setMicrophoneDevicesLoaded(true); + } catch (err) { + console.error('[settings] list microphone devices failed', err); + if (signal?.cancelled) return; + setMicrophoneDevices([]); + setMicrophoneDevicesError(err instanceof Error ? err.message : String(err)); + setMicrophoneDevicesLoaded(true); + } + }, []); + + useEffect(() => { + const signal = { cancelled: false }; + void loadMicrophoneDevices(signal); + return () => { + signal.cancelled = true; + }; + }, [loadMicrophoneDevices]); + + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + async function listenForDeviceChanges() { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen('microphone:devices-changed', () => { + void loadMicrophoneDevices(undefined, { showLoading: false }); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + } + void listenForDeviceChanges(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [loadMicrophoneDevices]); + + useEffect(() => { + if (microphonePickerOpen) { + void loadMicrophoneDevices(undefined, { showLoading: false }); + } + }, [loadMicrophoneDevices, microphonePickerOpen]); if (!prefs || !capability) { return ( @@ -188,6 +257,8 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const onMuteDuringRecordingChange = (muteDuringRecording: boolean) => savePrefs({ ...prefs, muteDuringRecording }); + const onMicrophoneDeviceChange = (microphoneDeviceName: string) => + savePrefs({ ...prefs, microphoneDeviceName }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => @@ -200,6 +271,17 @@ function RecordingSection() { const hotkeyDesc = capability.requiresAccessibilityPermission ? t('settings.recording.hotkeyDescAcc') : t('settings.recording.hotkeyDescNoAcc'); + const preferredMicrophoneAvailable = Boolean( + prefs.microphoneDeviceName + && microphoneDevices.some(device => device.name === prefs.microphoneDeviceName), + ); + const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName + && (!microphoneDevicesLoaded || preferredMicrophoneAvailable) + ? prefs.microphoneDeviceName + : ''; + const selectedMicrophoneLabel = effectiveMicrophoneDeviceName + ? effectiveMicrophoneDeviceName + : t('settings.recording.microphoneDefault'); return ( @@ -254,6 +336,67 @@ function RecordingSection() { ))}
+ +
+ + {!microphoneDevicesLoaded && ( +
{t('common.loading')}
+ )} + {microphoneDevicesError && ( +
+ {t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })} +
+ )} +
+
+ {microphonePickerOpen && ( + setMicrophonePickerOpen(false)} + onRefresh={() => { + void loadMicrophoneDevices(); + }} + loading={!microphoneDevicesLoaded} + onSelect={(name) => { + onMicrophoneDeviceChange(name); + }} + /> + )} @@ -290,6 +433,330 @@ function RecordingSection() { ); } +function MicrophonePickerDialog({ + devices, + selectedName, + onClose, + onRefresh, + loading, + onSelect, +}: { + devices: MicrophoneDevice[]; + selectedName: string; + onClose: () => void; + onRefresh: () => void; + loading: boolean; + onSelect: (name: string) => void; +}) { + const { t } = useTranslation(); + const [pickedName, setPickedName] = useState(selectedName); + const [previewName, setPreviewName] = useState(selectedName); + const [level, setLevel] = useState(0); + const [hoveredName, setHoveredName] = useState(null); + const [pressedName, setPressedName] = useState(null); + const [monitorError, setMonitorError] = useState(null); + const monitorQueueRef = useRef>(Promise.resolve()); + + const enqueueMonitorTask = useCallback((task: () => Promise) => { + const next = monitorQueueRef.current.catch(() => undefined).then(task); + monitorQueueRef.current = next.catch(() => undefined); + return next; + }, []); + + useEffect(() => { + setPickedName(selectedName); + setPreviewName(selectedName); + }, [selectedName]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + let timer: number | undefined; + setLevel(0); + setMonitorError(null); + + async function start() { + await enqueueMonitorTask(async () => { + try { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen<{ level: number }>('microphone:level', event => { + setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0))); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + await startMicrophoneLevelMonitor(previewName); + if (cancelled) { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + } + } else { + const tick = window.setInterval(() => { + setLevel(0.25 + Math.random() * 0.55); + }, 120); + if (cancelled) { + window.clearInterval(tick); + return; + } + unlisten = () => window.clearInterval(tick); + } + } catch (err) { + console.warn('[settings] microphone level monitor failed', err); + if (!cancelled) { + setMonitorError(err instanceof Error ? err.message : String(err)); + } + } + }); + } + + timer = window.setTimeout(() => { + void start(); + }, 140); + return () => { + cancelled = true; + if (timer !== undefined) { + window.clearTimeout(timer); + } + void enqueueMonitorTask(async () => { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + }); + }; + }, [enqueueMonitorTask, previewName]); + + const rows = [ + { + id: 'default', + name: '', + label: t('settings.recording.microphoneDefault'), + desc: t('settings.recording.microphoneDefaultDesc'), + isDefault: false, + }, + ...devices.map((device, index) => ({ + id: `${device.name}-${index}`, + name: device.name, + label: device.name, + desc: device.isDefault ? t('settings.recording.microphoneSystemDefault') : '', + isDefault: device.isDefault, + })), + ]; + + return ( +
+
e.stopPropagation()} + style={{ + width: 450, + maxWidth: 'calc(100vw - 48px)', + borderRadius: 16, + background: 'rgba(255,255,255,0.96)', + border: '0.5px solid rgba(0,0,0,0.12)', + boxShadow: '0 24px 70px rgba(0,0,0,0.28)', + padding: 24, + animation: 'olMicPickerPopIn 160ms cubic-bezier(.2,.8,.2,1)', + }} + > +
+
{t('settings.recording.microphoneDialogTitle')}
+
+ + +
+
+
+ {t('settings.recording.microphoneDialogDesc')} +
+ {monitorError && ( +
+ {t('settings.recording.microphoneMonitorError', { message: monitorError })} +
+ )} +
+ {rows.map(row => { + const active = pickedName === row.name; + const previewing = previewName === row.name; + const hovered = hoveredName === row.name; + const pressed = pressedName === row.name; + return ( + + ); + })} +
+ +
+
+ ); +} + +function LevelMeter({ level }: { level: number }) { + const amplified = Math.min(1, Math.max(0, level * 4.5)); + const bars = [0.25, 0.5, 0.75, 1, 0.75, 0.5]; + return ( + + {bars.map((weight, index) => { + const intensity = Math.min(1, amplified * (0.85 + weight * 0.35)); + const height = 6 + intensity * (20 * weight); + return ( + 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.10)', + opacity: 0.35 + intensity * 0.65, + transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease', + }} + /> + ); + })} + + ); +} + // 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / // windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 function AutostartRow() { diff --git a/openless-all/app/src/state/HotkeySettingsContext.tsx b/openless-all/app/src/state/HotkeySettingsContext.tsx index 4d3c21b0..15f5a66e 100644 --- a/openless-all/app/src/state/HotkeySettingsContext.tsx +++ b/openless-all/app/src/state/HotkeySettingsContext.tsx @@ -8,7 +8,7 @@ import { useState, type ReactNode, } from 'react'; -import { getHotkeyCapability, getSettings, setSettings } from '../lib/ipc'; +import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc'; import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types'; import i18n, { outputPrefsForLocale, type SupportedLocale } from '../i18n'; @@ -56,6 +56,34 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) { void refresh(); }, [refresh]); + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('prefs:changed', event => { + const nextPrefs = event.payload; + if (!nextPrefs) return; + latestPrefsRef.current = nextPrefs; + setPrefs(nextPrefs); + }); + if (cancelled) { + handle(); + } else { + unlisten = handle; + } + } catch (error) { + console.warn('[settings] prefs:changed listener setup failed', error); + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + useEffect(() => { latestPrefsRef.current = prefs; }, [prefs]);