chore: drop iOS 18–25 / non-iPhone support, require iOS 26
Two related cleanups the user asked for in one shot:
1. iPhone-only is now enforced at every target — Mac Catalyst and
visionOS were never configured in `project.yml`, but the
`OSGKeyboardShared` framework and the two test bundles were
still defaulting to `TARGETED_DEVICE_FAMILY = "1,2"` (iPhone +
iPad). All four targets now explicitly set `"1"`. SDK is
`iphoneos` for everyone, no `xros` / `macosx`.
2. Deployment target bumped from iOS 18.0 to iOS 26.0 across the
board (`project.yml` + the ext's per-target setting). With
iOS 26 as the floor, the iOS 18–25 SFSpeechRecognizer path
became dead code and several `#available` checks became
always-true. Removed:
- `AppleSpeechASR` (the entire SFSpeechRecognizer-based ASR
backend) and the `#available(iOS 26.0, *)` factory branch.
`ASRServiceFactory.make()` now returns `SpeechAnalyzerASR()`
directly. SpeechAnalyzer is always fully on-device, which
also made the `requiresOnDevice` flag meaningless.
- `requiresOnDevice` from the `ASRService.transcribe` protocol
signature, from `ProviderConfig`, `AppGroupStore`,
`KeyboardState`, `AppGroupPersistor`, and the ext's
`KeyboardViewController` (`state.requiresOnDevice`,
`state.setRequiresOnDevice`, `persistRequiresOnDevice`).
- `#available(iOS 17.0, *)` branch in
`PreviewASRController.requestMicrophonePermission` and the
ext's `PermissionManager.requestMicPermission` — both now
just call the iOS 17+ `AVAudioApplication` API directly.
- The `else` (iOS 18–25) branch in `SettingsView.asrEngineRow`
— the on-device-only toggle is gone, the row is a static
"SpeechAnalyzer active" badge. Same for the `else` branch
in `EnginePickerSection.localSubtitle`.
- `makeMicAuthHandler` (the iOS < 17 mic permission callback
wrapper) from `PreviewASRController`.
No `#available` / `@available` checks remain in the codebase
except for the SpeechAnalyzer class itself (now unnecessary
too, but kept for clarity — `AVAudioApplication` and
`SpeechAnalyzer` are both iOS 17+ / iOS 26+ respectively,
and the deployment target of 26 makes the explicit
`@available` redundant; I left the SpeechAnalyzer class
un-`@available` and removed the `@available(iOS 26.0, *)`
decoration since it's no longer needed).
The Keyboard ext's existing ASRService usage
(`asr.transcribe(stream:locale:)`) is unchanged at the
call-site level — just the third argument is gone.
3. Updated `info.plist` UISupportedInterfaceOrientations is
already `[UIInterfaceOrientationPortrait]` only, which is
correct for an iPhone-only app; no change needed.
Build: BUILD SUCCEEDED.
Tests: 22/22 pass.
Verified: `TARGETED_DEVICE_FAMILY = 1` on all four targets,
`SDKROOT = iphoneos` on all four.
🤖 Generated with Claude Code
This commit is contained in:
@@ -53,11 +53,9 @@ struct EnginePickerSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var localSubtitle: String {
|
private var localSubtitle: String {
|
||||||
if #available(iOS 26, *) {
|
// iOS 26's `SpeechAnalyzer` is always fully on-device, so the
|
||||||
return "SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network"
|
// local engine's only contract is "no network, no LLM".
|
||||||
} else {
|
"SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network"
|
||||||
return "端侧 ASR · 仅转录,无润色\nOn-device ASR, transcription only, no polish"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
|
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
|
||||||
|
|||||||
@@ -208,8 +208,7 @@ final class PreviewASRController: ObservableObject {
|
|||||||
// 5. Wire up ASR.
|
// 5. Wire up ASR.
|
||||||
let events = asr.transcribe(
|
let events = asr.transcribe(
|
||||||
stream: stream,
|
stream: stream,
|
||||||
locale: locale,
|
locale: locale
|
||||||
requiresOnDevice: false
|
|
||||||
)
|
)
|
||||||
asrTask = Task { @MainActor [weak self] in
|
asrTask = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -242,10 +241,9 @@ final class PreviewASRController: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Permission helpers (nonisolated)
|
// MARK: - Permission helpers (nonisolated)
|
||||||
//
|
//
|
||||||
// `SFSpeechRecognizer.requestAuthorization` and (iOS < 17)
|
// `SFSpeechRecognizer.requestAuthorization` delivers its callback
|
||||||
// `AVAudioSession.requestRecordPermission` deliver their callbacks
|
// on a TCC reply queue, NOT the main queue. If we wrap that
|
||||||
// on a TCC reply queue, NOT the main queue. If we wrap those
|
// callback inline in `start(locale:)` — which is `@MainActor` —
|
||||||
// callbacks inline in `start(locale:)` — which is `@MainActor` —
|
|
||||||
// Swift 6 strict concurrency infers the closure body as
|
// Swift 6 strict concurrency infers the closure body as
|
||||||
// `@MainActor`, and the runtime crashes on
|
// `@MainActor`, and the runtime crashes on
|
||||||
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
|
||||||
@@ -275,20 +273,15 @@ final class PreviewASRController: ObservableObject {
|
|||||||
// main actor before resuming.
|
// main actor before resuming.
|
||||||
|
|
||||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
if #available(iOS 17.0, *) {
|
// iOS 17+ API; the iOS < 17 fallback (`AVAudioSession.recordPermission`
|
||||||
|
// + `requestRecordPermission` callback) is gone now that the
|
||||||
|
// deployment target is iOS 26.
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVAudioApplication.shared.recordPermission {
|
||||||
case .granted: return true
|
case .granted: return true
|
||||||
case .denied: return false
|
case .denied: return false
|
||||||
case .undetermined: return await AVAudioApplication.requestRecordPermission()
|
case .undetermined: return await AVAudioApplication.requestRecordPermission()
|
||||||
@unknown default: return false
|
@unknown default: return false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission(
|
|
||||||
Self.makeMicAuthHandler(continuation: cont)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
|
||||||
@@ -307,14 +300,6 @@ final class PreviewASRController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func makeMicAuthHandler(
|
|
||||||
continuation: CheckedContinuation<Bool, Never>
|
|
||||||
) -> @Sendable (Bool) -> Void {
|
|
||||||
return { granted in
|
|
||||||
continuation.resume(returning: granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread)
|
// MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread)
|
||||||
//
|
//
|
||||||
// `AVAudioNode.installTap`'s callback fires on the audio engine's
|
// `AVAudioNode.installTap`'s callback fires on the audio engine's
|
||||||
|
|||||||
@@ -143,12 +143,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ASR engine row — adapts to OS version:
|
/// ASR engine row. With iOS 26 as the deployment target, the
|
||||||
/// • iOS 26+: shows a badge indicating SpeechAnalyzer is active (always on-device).
|
/// only ASR backend is `SpeechAnalyzer` and it is always fully
|
||||||
/// • iOS 18–25: shows a toggle to force on-device recognition only.
|
/// on-device — so this row is now a static badge rather than a
|
||||||
@ViewBuilder
|
/// toggle. The previous `requiresOnDevice` toggle (iOS 18–25
|
||||||
|
/// `SFSpeechRecognizer` cloud-fallback control) is gone.
|
||||||
private var asrEngineRow: some View {
|
private var asrEngineRow: some View {
|
||||||
if #available(iOS 26, *) {
|
|
||||||
HStack(spacing: Spacing.sm) {
|
HStack(spacing: Spacing.sm) {
|
||||||
Text("settings.engineRow.title")
|
Text("settings.engineRow.title")
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
@@ -168,24 +168,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
.padding(.vertical, Spacing.sm)
|
.padding(.vertical, Spacing.sm)
|
||||||
} else {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { config.requiresOnDevice },
|
|
||||||
set: { config.requiresOnDevice = $0 }
|
|
||||||
)) {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("settings.onDeviceOnly.title")
|
|
||||||
.font(TypeStyle.body)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
Text("禁用云端回退,识别失败时会报错而非联网 · Disable cloud fallback, fail locally instead of going online")
|
|
||||||
.font(TypeStyle.caption2)
|
|
||||||
.foregroundStyle(palette.textTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tint(palette.accent)
|
|
||||||
.padding(.horizontal, Spacing.md)
|
|
||||||
.padding(.vertical, Spacing.sm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Falls back to a static list while SFSpeechRecognizer locales are loading.
|
/// Falls back to a static list while SFSpeechRecognizer locales are loading.
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||||
state.setMode = { [weak self] m in self?.persistMode(m) }
|
state.setMode = { [weak self] m in self?.persistMode(m) }
|
||||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||||
state.setRequiresOnDevice = { [weak self] v in self?.persistRequiresOnDevice(v) }
|
|
||||||
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
||||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||||
@@ -185,8 +184,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
let locale = resolveLocale(state.localeId)
|
let locale = resolveLocale(state.localeId)
|
||||||
let events = asr.transcribe(
|
let events = asr.transcribe(
|
||||||
stream: session.audio,
|
stream: session.audio,
|
||||||
locale: locale,
|
locale: locale
|
||||||
requiresOnDevice: state.requiresOnDevice
|
|
||||||
)
|
)
|
||||||
|
|
||||||
asrTask = Task { @MainActor [weak self] in
|
asrTask = Task { @MainActor [weak self] in
|
||||||
@@ -334,11 +332,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
persistor.persist(localeId: id)
|
persistor.persist(localeId: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistRequiresOnDevice(_ value: Bool) {
|
|
||||||
state.requiresOnDevice = value
|
|
||||||
persistor.persist(requiresOnDevice: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistEngineMode(_ mode: String) {
|
private func persistEngineMode(_ mode: String) {
|
||||||
state.engineMode = mode
|
state.engineMode = mode
|
||||||
persistor.persist(engineMode: mode)
|
persistor.persist(engineMode: mode)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ public struct AppGroupPersistor {
|
|||||||
let store = AppGroupStore()
|
let store = AppGroupStore()
|
||||||
state.localeId = store.localeId
|
state.localeId = store.localeId
|
||||||
state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish
|
state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish
|
||||||
state.requiresOnDevice = store.requiresOnDevice
|
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -71,11 +70,6 @@ public struct AppGroupPersistor {
|
|||||||
AppGroupStore().setLocaleId(localeId)
|
AppGroupStore().setLocaleId(localeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the `requiresOnDevice` flag to the App Group store.
|
|
||||||
public func persist(requiresOnDevice: Bool) {
|
|
||||||
AppGroupStore().setRequiresOnDevice(requiresOnDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist `engineMode` to the App Group store.
|
/// Persist `engineMode` to the App Group store.
|
||||||
public func persist(engineMode: String) {
|
public func persist(engineMode: String) {
|
||||||
AppGroupStore().setEngineMode(engineMode)
|
AppGroupStore().setEngineMode(engineMode)
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ public final class PermissionManager: @unchecked Sendable {
|
|||||||
private var didRequestMicOnce: Bool = false
|
private var didRequestMicOnce: Bool = false
|
||||||
|
|
||||||
/// Request microphone access. Returns true if granted (already or
|
/// Request microphone access. Returns true if granted (already or
|
||||||
/// after this call). iOS 17 uses `AVAudioApplication.recordPermission`;
|
/// after this call). Uses the iOS 17+ `AVAudioApplication` API.
|
||||||
/// older systems fall back to `AVAudioSession.recordPermission`.
|
|
||||||
public func requestMicPermission() async -> Bool {
|
public func requestMicPermission() async -> Bool {
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVAudioApplication.shared.recordPermission {
|
||||||
case .granted: return true
|
case .granted: return true
|
||||||
case .denied: return false
|
case .denied: return false
|
||||||
@@ -40,28 +38,14 @@ public final class PermissionManager: @unchecked Sendable {
|
|||||||
return false
|
return false
|
||||||
@unknown default: return false
|
@unknown default: return false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
switch session.recordPermission {
|
|
||||||
case .granted: return true
|
|
||||||
case .denied: return false
|
|
||||||
case .undetermined:
|
|
||||||
if !didRequestMicOnce {
|
|
||||||
didRequestMicOnce = true
|
|
||||||
return await withCheckedContinuation { cont in
|
|
||||||
session.requestRecordPermission { cont.resume(returning: $0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
@unknown default: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request Speech Recognition permission. Returns true if granted
|
/// Request Speech Recognition permission. Returns true if granted
|
||||||
/// (already or after this call). For iOS 18 SFSpeechRecognizer this
|
/// (already or after this call). The `SFSpeechRecognizer` plist
|
||||||
/// is required before recognition can begin; for iOS 26 SpeechAnalyzer
|
/// key + this call are still required even on iOS 26 — the
|
||||||
/// the framework prompts on first use, so this call is a no-op there.
|
/// `SpeechAnalyzer` API does not expose an explicit request
|
||||||
|
/// method of its own and the framework checks the same TCC
|
||||||
|
/// entry on first use.
|
||||||
public func requestSpeechPermission() async -> Bool {
|
public func requestSpeechPermission() async -> Bool {
|
||||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
static let systemPrompt = "config.systemPrompt"
|
static let systemPrompt = "config.systemPrompt"
|
||||||
static let modeId = "config.modeId"
|
static let modeId = "config.modeId"
|
||||||
static let localeId = "config.localeId"
|
static let localeId = "config.localeId"
|
||||||
static let requiresOnDevice = "config.requiresOnDevice"
|
|
||||||
static let engineMode = "config.engineMode"
|
static let engineMode = "config.engineMode"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,11 +61,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
@Published public var localeId: String {
|
@Published public var localeId: String {
|
||||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
||||||
}
|
}
|
||||||
/// When `true`, forces SFSpeechRecognizer to on-device only mode.
|
|
||||||
/// Ignored on iOS 26+ where SpeechAnalyzer is always on-device.
|
|
||||||
@Published public var requiresOnDevice: Bool {
|
|
||||||
didSet { defaults.set(requiresOnDevice, forKey: Key.requiresOnDevice) }
|
|
||||||
}
|
|
||||||
/// "local" → on-device ASR only, no LLM polishing.
|
/// "local" → on-device ASR only, no LLM polishing.
|
||||||
/// "cloud" → ASR + LLM polish (default).
|
/// "cloud" → ASR + LLM polish (default).
|
||||||
@Published public var engineMode: String {
|
@Published public var engineMode: String {
|
||||||
@@ -109,7 +103,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
||||||
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
|
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
|
||||||
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
||||||
self.requiresOnDevice = defaults.bool(forKey: Key.requiresOnDevice)
|
|
||||||
self.engineMode = defaults.string(forKey: Key.engineMode) ?? "cloud"
|
self.engineMode = defaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// ASRService.swift
|
// ASRService.swift
|
||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Speech-to-text abstraction.
|
// Speech-to-text abstraction. As of iOS 26 being the minimum
|
||||||
// • iOS 26+: uses `SpeechAnalyzer` + `DictationTranscriber` — always on-device.
|
// deployment target, the only ASR backend is `SpeechAnalyzer` +
|
||||||
// • iOS 18–25: uses `SFSpeechRecognizer`, with optional requiresOnDevice flag.
|
// `DictationTranscriber` — always on-device, no cloud fallback, no
|
||||||
// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP …) so
|
// `requiresOnDevice` toggle. The previous SFSpeechRecognizer path
|
||||||
// dictation is first-class for non-English languages.
|
// (iOS 18–25) is gone; if a future platform ever needs it back,
|
||||||
|
// reintroduce as a sibling class in `ASRServiceFactory.make()`.
|
||||||
//
|
//
|
||||||
// Lives in `OSGKeyboardShared` (not the keyboard extension target) so
|
// Lives in `OSGKeyboardShared` (not the keyboard extension target) so
|
||||||
// that the host app's `KeyboardPreviewSheet` can run the same ASR
|
// that the host app's `KeyboardPreviewSheet` can run the same ASR
|
||||||
@@ -20,28 +21,23 @@ import os
|
|||||||
|
|
||||||
// MARK: - Sendable conformance
|
// MARK: - Sendable conformance
|
||||||
|
|
||||||
// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We
|
// `AVAudioPCMBuffer` and `SpeechAnalyzer` are not Sendable. We only
|
||||||
// only ever access them serially — the PCM buffer is built and consumed
|
// ever access them serially — the PCM buffer is built and consumed
|
||||||
// inside a single Task, and the recogniser task is cancelled but never
|
// inside a single Task, and the analyzer is cancelled but never
|
||||||
// shared concurrently — so an unchecked conformance is sound here.
|
// shared concurrently — so an unchecked conformance is sound here.
|
||||||
extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
|
extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
|
||||||
extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {}
|
|
||||||
|
|
||||||
// MARK: - Protocol
|
// MARK: - Protocol
|
||||||
|
|
||||||
public protocol ASRService: Sendable {
|
public protocol ASRService: Sendable {
|
||||||
/// Start a transcription session. The returned stream emits `.partial`
|
/// Start a transcription session. The returned stream emits `.partial`
|
||||||
/// updates and exactly one `.final` (or `.error`) before finishing.
|
/// updates and exactly one `.final` (or `.error`) before finishing.
|
||||||
/// - Parameters:
|
/// `SpeechAnalyzer` is always fully on-device, so there is no
|
||||||
/// - stream: Audio buffer stream from `AudioCaptureService`.
|
/// `requiresOnDevice` flag — the previous iOS 18 SFSpeechRecognizer
|
||||||
/// - locale: Target recognition locale.
|
/// flag was about cloud fallback, which doesn't apply here.
|
||||||
/// - requiresOnDevice: When `true`, forces on-device recognition only
|
|
||||||
/// (SFSpeechRecognizer path). Ignored on iOS 26+ where
|
|
||||||
/// `SpeechAnalyzer` is always fully on-device.
|
|
||||||
func transcribe(
|
func transcribe(
|
||||||
stream: AsyncStream<AudioBufferSnapshot>,
|
stream: AsyncStream<AudioBufferSnapshot>,
|
||||||
locale: Locale,
|
locale: Locale
|
||||||
requiresOnDevice: Bool
|
|
||||||
) -> AsyncStream<ASREvent>
|
) -> AsyncStream<ASREvent>
|
||||||
|
|
||||||
/// Cancel any in-flight recognition and tear down its tasks.
|
/// Cancel any in-flight recognition and tear down its tasks.
|
||||||
@@ -62,138 +58,17 @@ public enum ASREvent: Sendable, Equatable {
|
|||||||
// MARK: - Factory
|
// MARK: - Factory
|
||||||
|
|
||||||
public enum ASRServiceFactory {
|
public enum ASRServiceFactory {
|
||||||
/// Returns the best available ASR backend for the current OS:
|
/// Returns the ASR backend. With iOS 26 as the deployment target,
|
||||||
/// `SpeechAnalyzerASR` on iOS 26+ (always on-device), `AppleSpeechASR`
|
/// there is exactly one backend (`SpeechAnalyzer`).
|
||||||
/// on older OS versions.
|
|
||||||
public static func make() -> ASRService {
|
public static func make() -> ASRService {
|
||||||
if #available(iOS 26.0, *) {
|
SpeechAnalyzerASR()
|
||||||
return SpeechAnalyzerASR()
|
|
||||||
}
|
|
||||||
return AppleSpeechASR()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Apple Speech implementation (iOS 18–25)
|
|
||||||
|
|
||||||
final class AppleSpeechASR: ASRService, @unchecked Sendable {
|
|
||||||
|
|
||||||
private let lock = OSAllocatedUnfairLock()
|
|
||||||
private var recognizerTask: SFSpeechRecognitionTask?
|
|
||||||
private var feedTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
func transcribe(
|
|
||||||
stream: AsyncStream<AudioBufferSnapshot>,
|
|
||||||
locale: Locale,
|
|
||||||
requiresOnDevice: Bool
|
|
||||||
) -> AsyncStream<ASREvent> {
|
|
||||||
AsyncStream { continuation in
|
|
||||||
let recognizer = SFSpeechRecognizer(locale: locale)
|
|
||||||
?? SFSpeechRecognizer(locale: .current)
|
|
||||||
guard let recognizer, recognizer.isAvailable else {
|
|
||||||
continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)"))
|
|
||||||
continuation.finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recognizer.defaultTaskHint = .dictation
|
|
||||||
|
|
||||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
||||||
request.shouldReportPartialResults = true
|
|
||||||
// Honour the user's "force on-device" preference; fall back to
|
|
||||||
// whatever the device natively supports if the flag is off.
|
|
||||||
request.requiresOnDeviceRecognition = requiresOnDevice || recognizer.supportsOnDeviceRecognition
|
|
||||||
let onDeviceSupported = recognizer.supportsOnDeviceRecognition
|
|
||||||
if !onDeviceSupported {
|
|
||||||
#if DEBUG
|
|
||||||
print("⚠️ 设备不支持 \(locale.identifier) 端侧 ASR, 回退云端。")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
// Tell the UI about the capability *before* any partials so
|
|
||||||
// the StatusBadge can light up the cloud-fallback indicator
|
|
||||||
// as soon as the user presses the mic.
|
|
||||||
continuation.yield(.capability(onDeviceSupported: onDeviceSupported))
|
|
||||||
|
|
||||||
let task = recognizer.recognitionTask(with: request) { result, error in
|
|
||||||
if let error {
|
|
||||||
let nsErr = error as NSError
|
|
||||||
// Codes 203 / 1110 = "no speech detected" — a normal exit.
|
|
||||||
if nsErr.code == 203 || nsErr.code == 1110 {
|
|
||||||
continuation.yield(.final(""))
|
|
||||||
} else {
|
|
||||||
continuation.yield(.error(error.localizedDescription))
|
|
||||||
}
|
|
||||||
continuation.finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let result else { return }
|
|
||||||
if result.isFinal {
|
|
||||||
continuation.yield(.final(result.bestTranscription.formattedString))
|
|
||||||
continuation.finish()
|
|
||||||
} else {
|
|
||||||
continuation.yield(.partial(result.bestTranscription.formattedString))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.lock.withLock { self.recognizerTask = task }
|
|
||||||
|
|
||||||
// Feed audio: for each snapshot, build a 16 kHz mono Float32
|
|
||||||
// PCM buffer and immediately `request.append(pcm)`. The PCM
|
|
||||||
// buffer never leaves this task, so it doesn't need to be
|
|
||||||
// Sendable.
|
|
||||||
let feedFormat = AVAudioFormat(
|
|
||||||
commonFormat: .pcmFormatFloat32,
|
|
||||||
sampleRate: 16_000,
|
|
||||||
channels: 1,
|
|
||||||
interleaved: false
|
|
||||||
)!
|
|
||||||
self.feedTask = Task { [request] in
|
|
||||||
for await snap in stream {
|
|
||||||
if Task.isCancelled { break }
|
|
||||||
guard !snap.samples.isEmpty,
|
|
||||||
let pcm = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: feedFormat,
|
|
||||||
frameCapacity: AVAudioFrameCount(snap.samples.count)
|
|
||||||
)
|
|
||||||
else { continue }
|
|
||||||
pcm.frameLength = AVAudioFrameCount(snap.samples.count)
|
|
||||||
if let dst = pcm.floatChannelData?[0] {
|
|
||||||
snap.samples.withUnsafeBufferPointer { src in
|
|
||||||
if let base = src.baseAddress {
|
|
||||||
memcpy(dst, base, snap.samples.count * MemoryLayout<Float>.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request.append(pcm)
|
|
||||||
}
|
|
||||||
if !Task.isCancelled {
|
|
||||||
request.endAudio()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.onTermination = { @Sendable [weak self] _ in
|
|
||||||
self?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task<Void, Never>?) in
|
|
||||||
let r = self.recognizerTask
|
|
||||||
let f = self.feedTask
|
|
||||||
self.recognizerTask = nil
|
|
||||||
self.feedTask = nil
|
|
||||||
return (r, f)
|
|
||||||
}
|
|
||||||
recTask?.cancel()
|
|
||||||
feedT?.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SpeechAnalyzer implementation (iOS 26+)
|
// MARK: - SpeechAnalyzer implementation (iOS 26+)
|
||||||
|
|
||||||
/// ASR backend that uses the iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
|
/// ASR backend that uses the iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
|
||||||
/// APIs. This engine is always fully on-device — `requiresOnDevice` has no
|
/// APIs. This engine is always fully on-device.
|
||||||
/// effect and `.capability(onDeviceSupported: true)` is always emitted.
|
|
||||||
@available(iOS 26.0, *)
|
|
||||||
final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||||
|
|
||||||
private let lock = OSAllocatedUnfairLock()
|
private let lock = OSAllocatedUnfairLock()
|
||||||
@@ -202,8 +77,7 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
|||||||
|
|
||||||
func transcribe(
|
func transcribe(
|
||||||
stream: AsyncStream<AudioBufferSnapshot>,
|
stream: AsyncStream<AudioBufferSnapshot>,
|
||||||
locale: Locale,
|
locale: Locale
|
||||||
requiresOnDevice: Bool // ignored — SpeechAnalyzer is always on-device
|
|
||||||
) -> AsyncStream<ASREvent> {
|
) -> AsyncStream<ASREvent> {
|
||||||
AsyncStream { continuation in
|
AsyncStream { continuation in
|
||||||
// SpeechAnalyzer is always fully on-device.
|
// SpeechAnalyzer is always fully on-device.
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
static let systemPrompt = "config.systemPrompt"
|
static let systemPrompt = "config.systemPrompt"
|
||||||
static let modeId = "config.modeId"
|
static let modeId = "config.modeId"
|
||||||
static let localeId = "config.localeId"
|
static let localeId = "config.localeId"
|
||||||
static let requiresOnDevice = "config.requiresOnDevice"
|
|
||||||
static let engineMode = "config.engineMode"
|
static let engineMode = "config.engineMode"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +63,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
defaults.string(forKey: Key.localeId) ?? "auto"
|
defaults.string(forKey: Key.localeId) ?? "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When `true`, `SFSpeechRecognizer` is forced to on-device mode
|
|
||||||
/// (`requiresOnDeviceRecognition = true`). Ignored on iOS 26+ where
|
|
||||||
/// `SpeechAnalyzer` is always on-device.
|
|
||||||
public var requiresOnDevice: Bool {
|
|
||||||
defaults.bool(forKey: Key.requiresOnDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "local" → on-device ASR only, no LLM polishing.
|
/// "local" → on-device ASR only, no LLM polishing.
|
||||||
/// "cloud" → ASR + LLM polish (default behaviour).
|
/// "cloud" → ASR + LLM polish (default behaviour).
|
||||||
public var engineMode: String {
|
public var engineMode: String {
|
||||||
@@ -87,10 +79,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
defaults.set(id, forKey: Key.localeId)
|
defaults.set(id, forKey: Key.localeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setRequiresOnDevice(_ value: Bool) {
|
|
||||||
defaults.set(value, forKey: Key.requiresOnDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setEngineMode(_ mode: String) {
|
public func setEngineMode(_ mode: String) {
|
||||||
defaults.set(mode, forKey: Key.engineMode)
|
defaults.set(mode, forKey: Key.engineMode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,14 +63,10 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var localeId: String = "auto"
|
@Published public var localeId: String = "auto"
|
||||||
@Published public var lastTranscript: String = ""
|
@Published public var lastTranscript: String = ""
|
||||||
/// `true` if the active ASR session is running on-device for the
|
/// `true` if the active ASR session is running on-device for the
|
||||||
/// current locale. `false` means the request fell back to the
|
/// current locale. With iOS 26's `SpeechAnalyzer` this is always
|
||||||
/// network (e.g. ja-JP on a device that doesn't ship on-device
|
/// `true` — kept on the state object because the UI's status
|
||||||
/// ASR for Japanese). Updated once per recording by the ASR
|
/// badge still wants a single source of truth to read from.
|
||||||
/// pipeline before any `.partial` is emitted.
|
|
||||||
@Published public var onDeviceSupported: Bool = false
|
@Published public var onDeviceSupported: Bool = false
|
||||||
/// When `true`, `SFSpeechRecognizer` is forced to on-device mode.
|
|
||||||
/// Ignored on iOS 26+ where `SpeechAnalyzer` is always on-device.
|
|
||||||
@Published public var requiresOnDevice: Bool = false
|
|
||||||
/// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish.
|
/// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish.
|
||||||
@Published public var engineMode: String = "cloud"
|
@Published public var engineMode: String = "cloud"
|
||||||
|
|
||||||
@@ -84,7 +80,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var openSettings: () -> Void = {}
|
public var openSettings: () -> Void = {}
|
||||||
public var setMode: (InputMode) -> Void = { _ in }
|
public var setMode: (InputMode) -> Void = { _ in }
|
||||||
public var setLocale: (String) -> Void = { _ in }
|
public var setLocale: (String) -> Void = { _ in }
|
||||||
public var setRequiresOnDevice: (Bool) -> Void = { _ in }
|
|
||||||
public var setEngineMode: (String) -> Void = { _ in }
|
public var setEngineMode: (String) -> Void = { _ in }
|
||||||
public var insertNewline: () -> Void = {}
|
public var insertNewline: () -> Void = {}
|
||||||
public var insertSpace: () -> Void = {}
|
public var insertSpace: () -> Void = {}
|
||||||
|
|||||||
+11
-3
@@ -5,8 +5,13 @@
|
|||||||
name: OSGKeyboard
|
name: OSGKeyboard
|
||||||
options:
|
options:
|
||||||
bundleIdPrefix: com.osgkeyboard
|
bundleIdPrefix: com.osgkeyboard
|
||||||
|
# Minimum OS: iOS 26. We dropped iOS 18–25 support so the
|
||||||
|
# SFSpeechRecognizer + AVAudioSession branching could be removed in
|
||||||
|
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and
|
||||||
|
# `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone
|
||||||
|
# only — no Mac Catalyst, no visionOS.
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "18.0"
|
iOS: "26.0"
|
||||||
developmentLanguage: en
|
developmentLanguage: en
|
||||||
createIntermediateGroups: true
|
createIntermediateGroups: true
|
||||||
generateEmptyDirectories: true
|
generateEmptyDirectories: true
|
||||||
@@ -15,7 +20,7 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
|
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
@@ -110,7 +115,7 @@ targets:
|
|||||||
- path: OSGKeyboardExt/zh-Hans.lproj
|
- path: OSGKeyboardExt/zh-Hans.lproj
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
|
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||||
entitlements:
|
entitlements:
|
||||||
path: OSGKeyboardExt/OSGKeyboardExt.entitlements
|
path: OSGKeyboardExt/OSGKeyboardExt.entitlements
|
||||||
properties:
|
properties:
|
||||||
@@ -158,6 +163,7 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
DEFINES_MODULE: YES
|
DEFINES_MODULE: YES
|
||||||
SKIP_INSTALL: YES
|
SKIP_INSTALL: YES
|
||||||
BUILD_LIBRARY_FOR_DISTRIBUTION: NO
|
BUILD_LIBRARY_FOR_DISTRIBUTION: NO
|
||||||
@@ -183,6 +189,7 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 键盘扩展单元测试 (TEST-4)
|
# 键盘扩展单元测试 (TEST-4)
|
||||||
@@ -199,6 +206,7 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
OSGKeyboard:
|
OSGKeyboard:
|
||||||
|
|||||||
Reference in New Issue
Block a user