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
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
<string>OpenLess 需要辅助功能权限来监听全局快捷键并把识别结果粘贴到当前光标位置。</string>
<key>NSAppleEventsUsageDescription</key>
<string>OpenLess 需要发送按键事件,把识别结果粘贴到当前光标位置。</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OpenLess 使用 macOS 系统语音识别来在本地把你的语音转成文字(无需联网、无需凭据)。</string>
</dict>
</plist>
4 changes: 4 additions & 0 deletions openless-all/app/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,8 @@ fn build_qwen_asr_macos() {

// BLAS = Accelerate
println!("cargo:rustc-link-lib=framework=Accelerate");

// Apple Speech 本地 ASR(issue #574):apple_speech_provider 用
// SFSpeechRecognizer / SFSpeechURLRecognitionRequest,符号在 Speech.framework。
println!("cargo:rustc-link-lib=framework=Speech");
}
433 changes: 433 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ pub use sherpa_provider::SherpaOnnxAsr;
#[allow(unused_imports)]
pub use sherpa_runtime::SherpaOnnxRuntime;

#[cfg(target_os = "macos")]
mod apple_speech_provider;
#[cfg(target_os = "macos")]
mod qwen_engine;
#[cfg(target_os = "macos")]
mod qwen_ffi;

#[cfg(target_os = "macos")]
#[allow(unused_imports)]
pub use apple_speech_provider::AppleSpeechAsr;
#[cfg(target_os = "macos")]
pub use local_provider::LocalQwenAsr;
#[cfg(target_os = "macos")]
Expand All @@ -48,3 +53,14 @@ pub const PROVIDER_ID: &str = "local-qwen3";
pub fn is_local_qwen3(id: &str) -> bool {
id == PROVIDER_ID
}

/// Apple Speech(SFSpeechRecognizer)本地 ASR 的 provider id;与前端
/// ASR_PRESETS 的 id 对齐(issue #574)。该字符串在所有平台都可被识别,
/// 但 provider 实现只在 macOS 编译;非 macOS 上由上层判为 not-configured /
/// 不可用(见 commands / coordinator 的平台门控)。
pub const APPLE_SPEECH_PROVIDER_ID: &str = "apple-speech";

#[allow(dead_code)]
pub fn is_apple_speech(id: &str) -> bool {
id == APPLE_SPEECH_PROVIDER_ID
}
6 changes: 6 additions & 0 deletions openless-all/app/src-tauri/src/commands/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnap
return volcengine_configured(snap);
}
if provider == crate::asr::local::PROVIDER_ID
|| active_apple_speech_asr_is_supported(provider)
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
{
Expand Down Expand Up @@ -168,6 +169,11 @@ pub async fn set_active_asr_provider(
{
return Err("sherpa-onnx local ASR is only available on Windows".to_string());
}
if provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID
&& !active_apple_speech_asr_is_supported(&provider)
{
return Err("Apple Speech recognition is only available on macOS".to_string());
}
if CredentialsVault::get_active_asr() == provider {
return Ok(());
}
Expand Down
13 changes: 13 additions & 0 deletions openless-all/app/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,23 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {

pub(crate) fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
provider == crate::asr::local::PROVIDER_ID
|| active_apple_speech_asr_is_supported(provider)
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
}

pub(crate) fn active_apple_speech_asr_is_supported(provider: &str) -> bool {
#[cfg(target_os = "macos")]
{
provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID
}
#[cfg(not(target_os = "macos"))]
{
let _ = provider;
false
}
}

pub(crate) fn active_foundry_asr_is_supported(provider: &str) -> bool {
#[cfg(target_os = "windows")]
{
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ pub(crate) enum ActiveAsr {
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
#[cfg(target_os = "macos")]
Local(Arc<crate::asr::local::LocalQwenAsr>),
/// Apple Speech(SFSpeechRecognizer)系统本地 ASR;只在 macOS 可达。
/// 无模型下载、无凭据,首次使用弹系统授权(issue #574)。
#[cfg(target_os = "macos")]
AppleSpeech(Arc<crate::asr::local::AppleSpeechAsr>),
}

fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
Expand Down
32 changes: 32 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/asr_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ pub(crate) fn ensure_asr_credentials() -> Result<(), String> {
}
}

// Apple Speech 没有"凭据"也没有要下载的模型,只需:macOS 平台。
// 系统语音识别资源由 OS 管理,首次使用时弹授权框(见 apple_speech_provider)。
if crate::asr::local::is_apple_speech(&active_asr) {
#[cfg(not(target_os = "macos"))]
{
return Err("Apple Speech 语音识别仅支持 macOS".to_string());
}
#[cfg(target_os = "macos")]
{
return Ok(());
}
}

if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
Expand Down Expand Up @@ -124,6 +137,10 @@ pub(crate) fn is_keyless_local_asr_provider(id: &str) -> bool {
if crate::asr::local::is_local_qwen3(id) {
return true;
}
#[cfg(target_os = "macos")]
if crate::asr::local::is_apple_speech(id) {
return true;
}
#[cfg(target_os = "windows")]
{
crate::asr::local::foundry::is_foundry_local_whisper(id)
Expand Down Expand Up @@ -261,6 +278,13 @@ pub(crate) async fn build_local_qwen3(
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}

/// 构建 Apple Speech provider。与 build_local_qwen3 不同:无模型、无缓存、无
/// AppHandle 依赖,授权/识别由 provider 内部按需处理(首次弹系统授权框)。
#[cfg(target_os = "macos")]
pub(crate) fn build_apple_speech() -> Arc<crate::asr::local::AppleSpeechAsr> {
Arc::new(crate::asr::local::AppleSpeechAsr::new())
}

pub(crate) enum QaAsrStart {
Volcengine {
asr: Arc<VolcengineStreamingASR>,
Expand Down Expand Up @@ -390,6 +414,14 @@ pub(crate) async fn build_qa_asr_start(
return Ok(QaAsrStart::Ready { active, consumer });
}

#[cfg(target_os = "macos")]
if crate::asr::local::is_apple_speech(active_asr) {
let local = build_apple_speech();
let active = ActiveAsr::AppleSpeech(Arc::clone(&local));
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
return Ok(QaAsrStart::Ready { active, consumer });
}

match active_asr_provider_kind(active_asr) {
ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian {
asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())),
Expand Down
59 changes: 59 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,65 @@ pub(crate) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
// Apple Speech:系统语音识别,无模型加载耗时。批处理 transcribe 受音频
// 长度影响,沿用 local_qwen_transcribe_timeout 的动态超时公式(基础 15s
// 兜短录音,长录音按音频 0.6 倍 + 10s 余量),coordinator 侧再加一层防线。
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] Apple Speech transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match tokio::time::timeout(timeout_duration, local.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
if inner.state.lock().cancelled {
log::info!(
"[coord] Apple Speech transcribe cancelled — discarding transcript"
);
restore_prepared_windows_ime_session(inner, current_session_id);
set_phase_idle_if_session_matches(inner, current_session_id);
return Ok(());
}
log::error!("[coord] Apple Speech transcribe failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("本地识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] Apple Speech 动态超时 {}s(音频 {:.2}s)",
timeout_duration.as_secs(),
audio_secs
);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some("识别超时".to_string()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("apple-speech global timeout".to_string());
}
}
}
};

// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
Expand Down
16 changes: 16 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,22 @@ pub(crate) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
return Ok(());
}

// Apple Speech:无模型加载,构建即用;停止录音后整段批处理识别,再复用
// 现有 polish / insert / history 收尾路径(与 local-qwen3 同形)。
#[cfg(target_os = "macos")]
if crate::asr::local::is_apple_speech(&active_asr) {
let local = build_apple_speech();
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::AppleSpeech(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}

if is_bailian_provider(&active_asr) {
let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials()));
let bridge = Arc::new(DeferredAsrBridge::new());
Expand Down
34 changes: 34 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/qa_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,40 @@ pub(crate) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => {
debug_assert!(uses_global_timeout);
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
log::info!(
"[coord] QA Apple Speech transcribe: audio={:.2}s timeout={}s",
audio_secs,
timeout_duration.as_secs()
);
match tokio::time::timeout(timeout_duration, local.transcribe()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
if inner.qa_state.lock().cancelled {
log::info!(
"[coord] QA Apple Speech transcribe cancelled — discarding transcript"
);
finish_qa_idle_silently(inner);
return Ok(());
}
log::error!("[coord] QA Apple Speech transcribe failed: {e:#}");
finish_qa_with_error(inner, format!("本地识别失败: {e}"));
return Err(e.to_string());
}
Err(_) => {
log::error!(
"[coord] QA Apple Speech transcribe timeout after {}s",
timeout_duration.as_secs()
);
finish_qa_with_error(inner, "本地识别超时".to_string());
return Err("apple-speech transcribe timeout".to_string());
}
}
}
};

// cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
ActiveAsr::SherpaOnnxLocal(local) => local.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => local.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::AppleSpeech(local) => local.cancel(),
}
}

Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ export const en: typeof zhCN = {
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
asrLocalQwen3: 'Local Qwen3-ASR',
asrAppleSpeech: 'Apple Speech (macOS)',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
Expand Down Expand Up @@ -1000,6 +1001,9 @@ export const en: typeof zhCN = {
modelDir: 'Model directory',
revealDir: 'Open directory',
deleteConfirm: 'Delete local model files for {{name}}? This cannot be undone.',
appleSpeechTitle: 'Apple Speech recognition (macOS)',
appleSpeechDesc: "Transcribe speech locally using macOS's built-in speech recognition: no model download, no API key, no network. A zero-credential local fallback when your cloud ASR is unreliable. macOS will prompt for speech recognition permission on first use.",
appleSpeechUse: 'Use Apple Speech',
qwenTitle: 'Qwen3-ASR model manager',
qwenExperimentalBadge: 'Experimental',
engineUnavailable: 'The Qwen3-ASR inference engine is not bundled on this platform. You can still download models, but Qwen3-ASR cannot be activated here yet.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ export const ja: typeof zhCN = {
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
asrLocalQwen3: 'ローカル Qwen3-ASR',
asrAppleSpeech: 'Apple 音声認識 (macOS)',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
Expand Down Expand Up @@ -1002,6 +1003,9 @@ export const ja: typeof zhCN = {
modelDir: 'モデルフォルダ',
revealDir: 'フォルダを開く',
deleteConfirm: '{{name}} のローカルモデルファイルを削除しますか?この操作は取り消せません。',
appleSpeechTitle: 'Apple 音声認識(macOS)',
appleSpeechDesc: 'macOS 標準の音声認識を使ってローカルで文字起こしします。モデルのダウンロード・API キー・ネットワークは不要。クラウド ASR が不安定なときの認証情報不要なローカルフォールバックです。初回利用時に音声認識の許可ダイアログが表示されます。',
appleSpeechUse: 'Apple 音声認識を使う',
qwenTitle: 'Qwen3-ASR モデル管理',
qwenExperimentalBadge: '実験的',
engineUnavailable: '現在のプラットフォームには Qwen3-ASR 推論エンジンが同梱されていません。モデルのダウンロードは可能ですが、ここではまだ Qwen3-ASR を有効化できません。',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ export const ko: typeof zhCN = {
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
asrLocalQwen3: '로컬 Qwen3-ASR',
asrAppleSpeech: 'Apple 음성 (macOS)',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
Expand Down Expand Up @@ -1002,6 +1003,9 @@ export const ko: typeof zhCN = {
modelDir: '모델 폴더',
revealDir: '폴더 열기',
deleteConfirm: '{{name}} 로컬 모델 파일을 삭제할까요? 되돌릴 수 없습니다.',
appleSpeechTitle: 'Apple 음성 인식(macOS)',
appleSpeechDesc: 'macOS 기본 음성 인식을 사용해 로컬에서 음성을 텍스트로 변환합니다. 모델 다운로드, API 키, 네트워크가 모두 필요 없습니다. 클라우드 ASR이 불안정할 때 자격 증명이 필요 없는 로컬 폴백입니다. 처음 사용할 때 음성 인식 권한 요청이 표시됩니다.',
appleSpeechUse: 'Apple 음성 사용',
qwenTitle: 'Qwen3-ASR 모델 관리',
qwenExperimentalBadge: '실험적',
engineUnavailable: '현재 플랫폼에는 Qwen3-ASR 추론 엔진이 포함되어 있지 않습니다. 모델은 다운로드할 수 있지만 여기서는 아직 Qwen3-ASR 을 활성화할 수 없습니다.',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ export const zhCN = {
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
asrAppleSpeech: 'Apple 语音 (macOS)',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
Expand Down Expand Up @@ -998,6 +999,9 @@ export const zhCN = {
modelDir: '模型目录',
revealDir: '打开目录',
deleteConfirm: '确定删除 {{name}} 的本地模型文件吗?此操作无法撤销。',
appleSpeechTitle: 'Apple 语音识别(macOS)',
appleSpeechDesc: '使用 macOS 系统自带的语音识别在本地把语音转成文字:无需下载模型、无需 API Key、无需联网。云端 ASR 网络不稳时的零凭据本地兜底。首次使用会弹出系统语音识别授权。',
appleSpeechUse: '使用 Apple 语音',
qwenTitle: 'Qwen3-ASR 模型管理',
qwenExperimentalBadge: '实验性',
engineUnavailable: '当前平台暂未集成 Qwen3-ASR 推理引擎。可下载模型,但暂时无法启用 Qwen3-ASR。',
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ export const zhTW: typeof zhCN = {
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
asrAppleSpeech: 'Apple 語音 (macOS)',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
Expand Down Expand Up @@ -1000,6 +1001,9 @@ export const zhTW: typeof zhCN = {
modelDir: '模型目錄',
revealDir: '開啟目錄',
deleteConfirm: '確定刪除 {{name}} 的本地模型檔案嗎?此操作無法復原。',
appleSpeechTitle: 'Apple 語音辨識(macOS)',
appleSpeechDesc: '使用 macOS 系統內建的語音辨識在本地將語音轉成文字:無需下載模型、無需 API Key、無需連網。雲端 ASR 網路不穩時的零憑證本地後備。首次使用會跳出系統語音辨識授權。',
appleSpeechUse: '使用 Apple 語音',
qwenTitle: 'Qwen3-ASR 模型管理',
qwenExperimentalBadge: '實驗性',
engineUnavailable: '當前平臺暫未集成 Qwen3-ASR 推理引擎。可下載模型,但暫時無法啟用 Qwen3-ASR。',
Expand Down
Loading
Loading