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:
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user