feat(settings): add microphone device selection (#306)
This commit is contained in:
1 parent
ed555fb5a5
commit
ec773f08ea
14 files changed
+973
-45
No files matched your search
@@ -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<Coordinator>>;
|
||||
pub type MicrophoneMonitorState = Mutex<Option<Recorder>>;
|
||||
pub type TrayMicrophoneMenuState = Mutex<Vec<TrayMicrophoneMenuItem>>;
|
||||
|
||||
pub struct TrayMicrophoneMenuItem {
|
||||
pub id: String,
|
||||
pub device_name: String,
|
||||
pub item: tauri::menu::CheckMenuItem<tauri::Wry>,
|
||||
}
|
||||
|
||||
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<Coordinator> {
|
||||
impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
|
||||
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<T: SettingsWriter>(
|
||||
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<Vec<crate::recorder::MicrophoneDevice>, 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::<MicrophoneMonitorState>();
|
||||
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<dyn AudioConsumer> = Arc::new(LevelProbeConsumer);
|
||||
let level_app = app.clone();
|
||||
let level_handler: Arc<dyn Fn(f32) + Send + Sync> = 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::<MicrophoneMonitorState>();
|
||||
let recorder = state.lock().take();
|
||||
if let Some(recorder) = recorder {
|
||||
recorder.stop();
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_credentials() -> CredentialsStatus {
|
||||
let snap = CredentialsVault::snapshot();
|
||||
|
||||
@@ -1677,6 +1677,27 @@ fn store_recorder_for_session(inner: &Arc<Inner>, session_id: u64, recorder: Rec
|
||||
*inner.recorder.lock() = Some(SessionResource::new(session_id, recorder));
|
||||
}
|
||||
|
||||
fn selected_microphone_device_name(inner: &Arc<Inner>) -> Option<String> {
|
||||
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<Inner>, owner: &str) {
|
||||
let Some(app) = inner.app.lock().as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let state = app.state::<crate::commands::MicrophoneMonitorState>();
|
||||
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<Inner>, 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<Inner>) -> 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 时
|
||||
|
||||
@@ -35,7 +35,6 @@ 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;
|
||||
|
||||
const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024;
|
||||
@@ -43,7 +42,10 @@ const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024;
|
||||
/// 第一次 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};
|
||||
|
||||
@@ -74,6 +76,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| {
|
||||
init_file_logger();
|
||||
log::info!("=== OpenLess 启动 ===");
|
||||
@@ -153,33 +157,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::<commands::TrayMicrophoneMenuState>();
|
||||
*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");
|
||||
}
|
||||
@@ -203,6 +219,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,
|
||||
@@ -295,6 +314,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
RunEvent::Exit => {
|
||||
TRAY_MICROPHONE_WATCHER_STOPPING.store(true, Ordering::Relaxed);
|
||||
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
|
||||
coordinator.stop_hotkey_listener();
|
||||
coordinator.stop_qa_hotkey_listener();
|
||||
@@ -307,6 +327,168 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
struct MicrophoneTrayMenu {
|
||||
submenu: Submenu<tauri::Wry>,
|
||||
items: Vec<commands::TrayMicrophoneMenuItem>,
|
||||
}
|
||||
|
||||
struct TrayMenu {
|
||||
menu: Menu<tauri::Wry>,
|
||||
microphone_items: Vec<commands::TrayMicrophoneMenuItem>,
|
||||
}
|
||||
|
||||
fn build_tray_menu<M: Manager<tauri::Wry>>(
|
||||
app: &M,
|
||||
coordinator: &Arc<coordinator::Coordinator>,
|
||||
) -> tauri::Result<TrayMenu> {
|
||||
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<M: Manager<tauri::Wry>>(
|
||||
app: &M,
|
||||
coordinator: &Arc<coordinator::Coordinator>,
|
||||
) -> tauri::Result<MicrophoneTrayMenu> {
|
||||
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::<Arc<coordinator::Coordinator>>();
|
||||
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::<commands::TrayMicrophoneMenuState>();
|
||||
*state.lock() = tray_menu.microphone_items;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
|
||||
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::<commands::TrayMicrophoneMenuState>();
|
||||
let items = tray_items.lock();
|
||||
let Some(selected) = items.iter().find(|item| item.id == id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let coord = app.state::<Arc<coordinator::Coordinator>>();
|
||||
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<R: Runtime>(window: &tauri::WebviewWindow<R>) {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
@@ -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<String>,
|
||||
consumer: Arc<dyn AudioConsumer>,
|
||||
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
|
||||
) -> Result<(Self, Receiver<RecorderError>), 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<Vec<MicrophoneDevice>, 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<String>,
|
||||
consumer: Arc<dyn AudioConsumer>,
|
||||
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
startup_tx: Sender<Result<(), RecorderError>>,
|
||||
runtime_error_tx: Sender<RecorderError>,
|
||||
) {
|
||||
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<String>,
|
||||
consumer: Arc<dyn AudioConsumer>,
|
||||
level_handler: Arc<dyn Fn(f32) + Send + Sync>,
|
||||
runtime_error_tx: Sender<RecorderError>,
|
||||
) -> Result<(cpal::Stream, Arc<StreamState>), 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(|_| "<unknown>".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<cpal::Device, RecorderError> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '録音中はミュート',
|
||||
|
||||
@@ -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: '녹음 중 음소거',
|
||||
|
||||
@@ -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: '录音时静音',
|
||||
|
||||
@@ -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: '錄音時靜音',
|
||||
|
||||
@@ -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<WindowsImeStatus> {
|
||||
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
|
||||
}
|
||||
|
||||
export function listMicrophoneDevices(): Promise<MicrophoneDevice[]> {
|
||||
return invokeOrMock('list_microphone_devices', undefined, () => mockMicrophoneDevices);
|
||||
}
|
||||
|
||||
export function startMicrophoneLevelMonitor(deviceName: string): Promise<void> {
|
||||
return invokeOrMock('start_microphone_level_monitor', { deviceName }, () => undefined);
|
||||
}
|
||||
|
||||
export function stopMicrophoneLevelMonitor(): Promise<void> {
|
||||
return invokeOrMock('stop_microphone_level_monitor', undefined, () => undefined);
|
||||
}
|
||||
|
||||
// ── Credentials ────────────────────────────────────────────────────────
|
||||
export function getCredentials(): Promise<CredentialsStatus> {
|
||||
return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus);
|
||||
|
||||
@@ -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` 事件推前端边渲染。 */
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, padding: '14px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 180px) minmax(0, 1fr)', gap: 16, padding: '14px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
|
||||
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start' }}>{children}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', minWidth: 0, width: controlWidth ?? 'auto' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MicrophoneDevice[]>([]);
|
||||
const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
|
||||
const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(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 (
|
||||
<Card>
|
||||
@@ -254,6 +336,67 @@ function RecordingSection() {
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.microphoneLabel')} desc={t('settings.recording.microphoneDesc')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.recording.microphoneLabel')}
|
||||
onClick={() => {
|
||||
setMicrophonePickerOpen(true);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setMicrophonePickerOpen(true);
|
||||
}
|
||||
}}
|
||||
onChange={() => {}}
|
||||
style={{
|
||||
...inputStyle,
|
||||
flex: '0 0 auto',
|
||||
width: 200,
|
||||
maxWidth: 200,
|
||||
height: 32,
|
||||
minWidth: 0,
|
||||
alignSelf: 'flex-start',
|
||||
padding: '0 9px 0 10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
textAlign: 'left',
|
||||
color: 'var(--ol-ink)',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{selectedMicrophoneLabel}
|
||||
</span>
|
||||
<Icon name="chevRight" size={13} />
|
||||
</button>
|
||||
{!microphoneDevicesLoaded && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
)}
|
||||
{microphoneDevicesError && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
|
||||
{t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{microphonePickerOpen && (
|
||||
<MicrophonePickerDialog
|
||||
devices={microphoneDevices}
|
||||
selectedName={effectiveMicrophoneDeviceName}
|
||||
onClose={() => setMicrophonePickerOpen(false)}
|
||||
onRefresh={() => {
|
||||
void loadMicrophoneDevices();
|
||||
}}
|
||||
loading={!microphoneDevicesLoaded}
|
||||
onSelect={(name) => {
|
||||
onMicrophoneDeviceChange(name);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
|
||||
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
|
||||
</SettingRow>
|
||||
@@ -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<string | null>(null);
|
||||
const [pressedName, setPressedName] = useState<string | null>(null);
|
||||
const [monitorError, setMonitorError] = useState<string | null>(null);
|
||||
const monitorQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
const enqueueMonitorTask = useCallback((task: () => Promise<void>) => {
|
||||
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 (
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: 'rgba(0,0,0,0.32)',
|
||||
animation: 'olMicPickerFadeIn 120ms ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={e => 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)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 650 }}>{t('settings.recording.microphoneDialogTitle')}</div>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
style={{
|
||||
border: 0,
|
||||
borderRadius: 999,
|
||||
background: 'transparent',
|
||||
color: loading ? 'var(--ol-ink-4)' : 'var(--ol-ink-3)',
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
opacity: loading ? 0.65 : 1,
|
||||
transition: 'background 0.16s var(--ol-motion-quick), opacity 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!loading) e.currentTarget.style.background = 'rgba(0,0,0,0.05)';
|
||||
}}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
title={t('common.refresh')}
|
||||
>
|
||||
<Icon
|
||||
name="refresh"
|
||||
size={14}
|
||||
style={{ animation: loading ? 'olMicPickerSpin 800ms linear infinite' : undefined }}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 0,
|
||||
borderRadius: 999,
|
||||
background: 'transparent',
|
||||
color: 'var(--ol-ink-3)',
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
transition: 'background 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
title={t('common.close')}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 18 }}>
|
||||
{t('settings.recording.microphoneDialogDesc')}
|
||||
</div>
|
||||
{monitorError && (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-err)', lineHeight: 1.45, marginBottom: 12 }}>
|
||||
{t('settings.recording.microphoneMonitorError', { message: monitorError })}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{rows.map(row => {
|
||||
const active = pickedName === row.name;
|
||||
const previewing = previewName === row.name;
|
||||
const hovered = hoveredName === row.name;
|
||||
const pressed = pressedName === row.name;
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
onMouseEnter={() => {
|
||||
setHoveredName(row.name);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredName(null);
|
||||
setPressedName(null);
|
||||
}}
|
||||
onMouseDown={() => setPressedName(row.name)}
|
||||
onMouseUp={() => setPressedName(null)}
|
||||
onFocus={() => {
|
||||
setHoveredName(row.name);
|
||||
}}
|
||||
onBlur={() => setHoveredName(null)}
|
||||
onClick={() => {
|
||||
setPickedName(row.name);
|
||||
setPreviewName(row.name);
|
||||
onSelect(row.name);
|
||||
}}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 14,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
borderRadius: 10,
|
||||
border: active ? '1px solid rgba(37,99,235,0.7)' : '0.5px solid rgba(0,0,0,0.12)',
|
||||
background: active
|
||||
? 'rgba(37,99,235,0.08)'
|
||||
: hovered
|
||||
? 'rgba(0,0,0,0.035)'
|
||||
: '#fff',
|
||||
boxShadow: active
|
||||
? '0 0 0 3px rgba(37,99,235,0.08)'
|
||||
: hovered
|
||||
? '0 8px 18px rgba(0,0,0,0.06)'
|
||||
: '0 1px 2px rgba(0,0,0,0.03)',
|
||||
color: 'var(--ol-ink)',
|
||||
cursor: 'default',
|
||||
textAlign: 'left',
|
||||
transform: pressed ? 'scale(0.992)' : hovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||
transition: 'background 140ms ease, border-color 140ms ease, box-shadow 160ms ease, transform 120ms ease',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{row.label}
|
||||
</span>
|
||||
{row.desc && (
|
||||
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 3 }}>
|
||||
{row.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<LevelMeter level={previewing ? level : 0} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<style>
|
||||
{`
|
||||
@keyframes olMicPickerFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes olMicPickerPopIn {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes olMicPickerSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, height: 32 }}>
|
||||
{bars.map((weight, index) => {
|
||||
const intensity = Math.min(1, amplified * (0.85 + weight * 0.35));
|
||||
const height = 6 + intensity * (20 * weight);
|
||||
return (
|
||||
<span
|
||||
key={`${weight}-${index}`}
|
||||
style={{
|
||||
width: 5,
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: intensity > 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
|
||||
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
|
||||
function AutostartRow() {
|
||||
|
||||
@@ -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<UserPreferences>('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]);
|
||||
|
||||
Reference in new issue
Block a user