[P0-②] On-device ASR three-piece set

- Add NSSpeechRecognitionUsageDescription to both target Info.plists
- KeyboardViewController.requestSpeechPermission() wraps
  SFSpeechRecognizer.requestAuthorization
- pressBegan() now requests Speech permission right after mic permission
- ASRService emits a DEBUG warning when recognizer.supportsOnDeviceRecognition
  is false so cloud fallback is visible during dev
- CHANGELOG: move iOS 26 SpeechAnalyzer out of Unreleased into [0.2.0] Planned,
  so the iOS 18 SFSpeechRecognizer path is the only ASR shipped in v0.1

xcodebuild iOS Simulator: SUCCEEDED
This commit is contained in:
Zhongshu
2026-06-18 10:41:20 +08:00
parent e0b92ff35a
commit e93bab50b4
5 changed files with 68 additions and 15 deletions
+2
View File
@@ -20,6 +20,8 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition to transcribe your dictation. Audio never leaves your device.</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
+35 -2
View File
@@ -20,6 +20,7 @@
import UIKit
import SwiftUI
import AVFoundation
import Speech
import OSGKeyboardShared
@objc(KeyboardViewController)
@@ -199,12 +200,26 @@ public final class KeyboardViewController: UIInputViewController {
// user already feels the press registered.
Task { @MainActor [weak self] in
guard let self else { return }
let granted = await self.requestMicPermission()
guard granted else {
let micGranted = await self.requestMicPermission()
guard micGranted else {
self.state.phase = .error("麦克风被拒绝,请到「设置」中允许")
self.scheduleAutoClearError()
return
}
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech
// recognition permission. Without this call + the
// NSSpeechRecognitionUsageDescription key in Info.plist the
// recogniser silently returns .denied and the user hears
// nothing back.
// iOS 26 SpeechAnalyzer path (planned for the next release)
// does not expose an explicit request API the framework
// prompts via the same plist key on first use.
let speechGranted = await self.requestSpeechPermission()
guard speechGranted else {
self.state.phase = .error("语音识别被拒绝,请到「设置」中允许")
self.scheduleAutoClearError()
return
}
self.startPipeline()
}
}
@@ -348,6 +363,24 @@ public final class KeyboardViewController: UIInputViewController {
}
}
/// Request Speech Recognition permission. Returns true if granted
/// (or already authorised). For the iOS 18 SFSpeechRecognizer path
/// this is required before recognition can begin; for the iOS 26
/// SpeechAnalyzer path the framework prompts on first use.
private func requestSpeechPermission() async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization { status in
switch status {
case .authorized: cont.resume(returning: true)
case .denied, .restricted, .notDetermined:
cont.resume(returning: false)
@unknown default:
cont.resume(returning: false)
}
}
}
}
// MARK: - Open host app
private func openHostApp() {
+5
View File
@@ -73,6 +73,11 @@ final class AppleSpeechASR: ASRService, @unchecked Sendable {
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
if !recognizer.supportsOnDeviceRecognition {
#if DEBUG
print("⚠️ 设备不支持 \(locale.identifier) 端侧 ASR, 回退云端。")
#endif
}
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {