Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 85 additions & 9 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,43 @@
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,
UserPreferences, VocabPresetStore, WindowsImeStatus,
};

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

Expand Down Expand Up @@ -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();
}
}

Expand All @@ -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(())
}
Expand All @@ -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();
Expand Down
29 changes: 27 additions & 2 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 时
Expand Down
Loading
Loading