feat(asr): Apple Speech 本地 ASR 接线 — dictation/QA/cancel + 平台门控 (issue #574)

承接草稿 PR #625 的 provider 基础,补完后端接线:
- coordinator: ActiveAsr::AppleSpeech 变体(macOS only)
- asr_setup: build_apple_speech + ensure_asr_credentials 平台门控 +
  build_qa_asr_start QA 接线 + is_keyless_local_asr_provider
- dictation_session/dictation_end: 听写 start + 批处理 transcribe(沿用
  local_qwen 动态超时)
- qa_session: QA transcribe arm
- resources: cancel arm
- commands: active_apple_speech_asr_is_supported(非 macOS 返回 false)+
  keyless 校验白名单 + asr_configured_for_provider + set_active_asr_provider
  非 macOS 拒绝门控

provider 仅 macOS 编译;非 macOS 由门控判不可用。前端 UI/i18n 已随 #625 落地。
验证:cargo check(macOS)通过、provider 单测 5/5 通过、npm run build 通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
吕柏青andClaude Fable 5 committed 2026-06-10 15:00:14 +08:00
1 parent 7ed72be76e
commit 050927e719
8 files changed
+166

No files matched your search

@@ -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)
{
@@ -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(());
}
@@ -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")]
{
@@ -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 {
@@ -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"))]
{
@@ -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)
@@ -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>,
@@ -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())),
@@ -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 时,这里就会命中。
@@ -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());
@@ -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 → 静默退出。
@@ -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(),
}
}