[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:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user