From 6148d05093e678577d8b3ad4962c27348e4ad90c Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:27:22 +0800 Subject: [PATCH] chore: align iOS 26 capability docs and UI localization Keep repository messaging consistent with the implemented iOS 26 SpeechAnalyzer path, and remove mixed hardcoded copy by routing remaining UI/error text through localized string keys. --- CHANGELOG.md | 18 +++--------- OSGKeyboard/Views/APISettingsCard.swift | 14 +++++---- OSGKeyboard/Views/EnginePickerSection.swift | 8 ++--- OSGKeyboard/Views/HomeView.swift | 2 +- OSGKeyboard/Views/KeyboardPreviewSheet.swift | 5 ++-- OSGKeyboard/Views/OnboardingView.swift | 20 +++++-------- OSGKeyboard/Views/PreviewASRController.swift | 20 +++++++++---- OSGKeyboard/Views/SettingsView.swift | 29 ++++++++++--------- OSGKeyboard/en.lproj/Localizable.strings | 5 ++++ OSGKeyboard/zh-Hans.lproj/Localizable.strings | 5 ++++ OSGKeyboardExt/KeyboardViewController.swift | 14 ++++----- OSGKeyboardShared/Services/ASRService.swift | 12 ++++---- README.md | 15 +++++----- README.zh.md | 17 +++++------ project.yml | 4 +-- 15 files changed, 96 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9cc0a..dc4a73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **iPhone only**: Set `TARGETED_DEVICE_FAMILY` to `"1"` for both `OSGKeyboard` and `OSGKeyboardExt` targets. Removed `UIRequiresFullScreen` and trimmed `UISupportedInterfaceOrientations` to Portrait only (iPad is no longer a supported device). - **Remove top divider line**: Deleted the 0.5 pt `palette.divider` overlay from `KeyboardRootView` — the subtle highlight gradient is retained; the hard separator line is gone. - **Keyboard preview always dark**: `KeyboardPreviewSheet` now injects `.environment(\.themePalette, Palette.dark)` alongside `.environment(\.colorScheme, .dark)` on `KeyboardPreviewStub`, so the preview palette is always the dark variant regardless of the app's active theme. +- **Docs consistency**: README/README.zh now consistently describe the currently implemented capability set (`iOS 26+`, on-device `SpeechAnalyzer` + `DictationTranscriber`) with no deferred-ASR wording. -## [0.1.2] - Planned +## [0.1.2] - In Progress ### Fixed - **Light/Dark mode consistency**: `cardSurface()`, `primaryButton()`, `secondaryButton()`, and `pillChip()` view modifiers in `Theme.swift` now use `ViewModifier` structs that read from `@Environment(\.themePalette)`. Previously they used hardcoded dark `Palette` constants, causing cards and buttons to always render in dark mode even when the main App was in light mode. @@ -28,14 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Note: v0.1.1 polish** — this is a small follow-up to v0.1.0 focused on review-driven cleanup > (theme follow-up, ASR robustness, debug-print hygiene, docs). **No features are removed.** -> The iOS 26 `SpeechAnalyzer` path remains deferred to 0.2.0 (see below); v0.1.1 users continue -> to ship with the iOS 18 `SFSpeechRecognizer` path that shipped in v0.1.0. User experience is -> unchanged from v0.1.0. - ### Fixed - **PrivacyInfo.xcprivacy audited for honesty**: removed the three undeclared `NSPrivacyAccessedAPIType` entries (`FileTimestamp` / `DiskSpace` / `SystemBootTime`) the project doesn't actually use, and added `ActiveKeyboards` (reason `DDA9.1`) to the keyboard extension's manifest because `advanceToNextInputMode()` is in the tap path. The main App now declares only `UserDefaults` (reason `CA92.1`), which is the only Required Reason API it touches. - **Theme follows system appearance**: main App now renders a true light palette in light mode via `ThemedRoot` + `EnvironmentKey`. The keyboard extension deliberately stays dark (Apple's default) and now uses a transparent `.background(Color.clear)` so the system UI chrome shows through. -- **Speech Recognition permission requested on first press**: added `NSSpeechRecognitionUsageDescription` to both targets' `Info.plist` and an explicit `SFSpeechRecognizer.requestAuthorization` call inside `pressBegan()`. Without these the iOS 18 ASR path silently returned `.denied` and the user heard nothing. +- **Speech Recognition permission requested on first press**: added `NSSpeechRecognitionUsageDescription` to both targets' `Info.plist` and an explicit `SFSpeechRecognizer.requestAuthorization` call inside `pressBegan()`. Without these, speech authorization could remain `.denied` and recognition would fail silently. - **ASRService emits a DEBUG warning when on-device recognition isn't supported**, so it's obvious during dev that the request fell back to cloud. - **App Group fallback behaviour**: - In `DEBUG`, a missing App Group now `fatalError`s with a precise remediation message (was a soft print + `.standard` fallback, which desynced the keyboard extension from the main App). @@ -50,20 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 5 new unit tests in `OSGKeyboardTests`: App Group cross-process persistence, 401 / 429 / timeout / noAPIKey catch paths, and `mode = .off` short-circuit. ### Changed -- README + `README.zh.md`: replaced `` placeholder with `hkgood` and rephrased the iOS 26 `SpeechAnalyzer` line as "planned for the next release" (the iOS 18 `SFSpeechRecognizer` path remains the only working ASR for v0.1). +- README + `README.zh.md`: replaced `` placeholder with `hkgood` and aligned capability statements to the implemented iOS 26 path. ### Known limitations -- iOS 26 `SpeechAnalyzer` + `DictationTranscriber` on-device ASR is planned for **0.2.0** (moved out of Unreleased scope to keep the v0.1 release honest about what ships). - The keyboard does not work in password fields (iOS limitation). - Microphone requires "Allow Full Access" to be enabled in iOS Settings. - Whisper.cpp / on-device LLM polish is intentionally out of scope for v1 (cloud-only). -## [0.2.0] - Planned - -### Added -- iOS 26+ `SpeechAnalyzer` + `DictationTranscriber` on-device ASR (lower latency, more locales). -- Bilingual UI (中文 / English) driven by a real `Localizable.strings` table; will land alongside a Settings → Language picker. - ## [0.1.0] - 2026-06-17 ### Added diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift index 1f6b661..8e04db1 100644 --- a/OSGKeyboard/Views/APISettingsCard.swift +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -24,7 +24,7 @@ struct APISettingsCard: View { var body: some View { VStack(spacing: 0) { field( - title: "Base URL · 接口地址", + title: NSLocalizedString("api.baseUrl", comment: ""), placeholder: "https://api.openai.com/v1", text: $config.baseURL, autocap: false @@ -33,7 +33,7 @@ struct APISettingsCard: View { keyField Divider().background(palette.divider) field( - title: "Model · 模型", + title: NSLocalizedString("api.model", comment: ""), placeholder: "gpt-4o-mini", text: $config.model, autocap: false @@ -41,7 +41,7 @@ struct APISettingsCard: View { if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { Divider().background(palette.divider) // Use a Button + UIApplication.open instead of SwiftUI - // `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that + // `Link`. SwiftUI `Link` can have hit-test quirks on some // makes its tappable area eat gestures from the adjacent // TextField, which manifests as "typing jumps to a website". Button { @@ -86,7 +86,9 @@ struct APISettingsCard: View { .foregroundStyle(palette.textSecondary) } .buttonStyle(.plain) - .accessibilityLabel(Text(showKey ? "隐藏密钥 · Hide key" : "显示密钥 · Show key")) + .accessibilityLabel(Text(showKey + ? NSLocalizedString("api.key.hide", comment: "") + : NSLocalizedString("api.key.show", comment: ""))) } Group { if showKey { @@ -120,7 +122,7 @@ struct APISettingsCard: View { .foregroundStyle(palette.textSecondary) // `.asciiCapable` is the *minimum* contract for these fields: // API keys, base URLs, and model names are all ASCII by spec, - // and SwiftUI's `.URL` keyboard on iOS 18 still occasionally + // and SwiftUI's `.URL` keyboard can still occasionally // hands control to the system keyboard which then auto-suggests // Chinese / emoji completions that corrupt the value. Forcing // `.asciiCapable` keeps the system out of the way. @@ -132,7 +134,7 @@ struct APISettingsCard: View { .foregroundStyle(palette.textPrimary) .submitLabel(.done) .onSubmit { /* no-op: prevent the keyboard from "submitting" - and dismissing the sheet on iOS 18 */ } + and dismissing the sheet unexpectedly */ } } .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift index 3a9dee9..15ca6b5 100644 --- a/OSGKeyboard/Views/EnginePickerSection.swift +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -33,15 +33,15 @@ struct EnginePickerSection: View { engineOptionRow( id: "local", icon: "iphone.badge.checkmark", - title: "本地识别 · On-device", + title: NSLocalizedString("settings.engine.local.title", comment: ""), subtitle: localSubtitle ) Divider().background(palette.divider) engineOptionRow( id: "cloud", icon: "wand.and.stars", - title: "云端润色 · Cloud polish", - subtitle: "ASR 转录 + LLM 润色,需要 API Key\nASR + LLM polish, API key required" + title: NSLocalizedString("settings.engine.cloud.title", comment: ""), + subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "") ) } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) @@ -55,7 +55,7 @@ struct EnginePickerSection: View { private var localSubtitle: String { // iOS 26's `SpeechAnalyzer` is always fully on-device, so the // local engine's only contract is "no network, no LLM". - "SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network" + NSLocalizedString("settings.engine.local.ios26", comment: "") } private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View { diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 0e47ba7..7515018 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -45,7 +45,7 @@ struct HomeView: View { Circle() .fill(config.isConfigured ? palette.success : palette.warning) .frame(width: 8, height: 8) - Text(config.isConfigured ? "就绪 · Ready" : "未完成配置 · Setup incomplete") + Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift index 933bed0..c98fe86 100644 --- a/OSGKeyboard/Views/KeyboardPreviewSheet.swift +++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift @@ -4,14 +4,15 @@ // In-app preview of the keyboard extension. Renders a stand-in // `KeyboardPreviewStub` so the user can see what the real extension // looks like, AND drives a *real* `PreviewASRController` so tapping -// the disc actually records from the mic, runs SFSpeechRecognizer, +// the disc actually records from the mic and runs the shared ASR +// service (`SpeechAnalyzer` on iOS 26+), // and lands recognized text in the top textbox. Without the real ASR // the preview was a static mock — "the text never appears" was a // fair review note. // // Lifecycle (local engine = "transcribe" only, no LLM): // tap → start ASR (idempotent re-entry guard) -// → SFSpeechRecognizer emits .partial / .final +// → ASR emits .partial / .final // → currentPartial updates the transcript line in real time // tap → stop ASR // → lastFinal event lands diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index f3e3f18..4e3539c 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -9,7 +9,8 @@ // // We deliberately do NOT use `TabView` with `.page` style for the // pager. That style wraps the content in a `UIPageViewController`, -// and `UIPageViewController` has a long-standing iOS 18 bug where +// and `UIPageViewController` has a long-standing behavior on some iOS +// versions where // the keyboard-showing layout reflow on a `TextField` focus is // misread as a horizontal swipe — the page jumps back to step 1 // the moment the user starts typing. Replacing the TabView with a @@ -136,10 +137,6 @@ private struct WelcomePage: View { .font(TypeStyle.body) .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) - Text("onboarding.welcome.subtitle") - .font(TypeStyle.footnote) - .foregroundStyle(palette.textTertiary) - .multilineTextAlignment(.center) } .padding(.horizontal, Spacing.xl) PrivacyFootnote() @@ -210,15 +207,12 @@ private struct EnableKeyboardPage: View { Text("onboarding.enable.title") .font(TypeStyle.title2) .foregroundStyle(palette.textPrimary) - Text("onboarding.enable.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textTertiary) } VStack(alignment: .leading, spacing: Spacing.sm) { - step(num: 1, text: "设置 → 通用 → 键盘 → 键盘") - step(num: 2, text: "点击「添加新键盘…」并选择 OSGKeyboard") - step(num: 3, text: "点击 OSGKeyboard 并启用「允许完全访问」") - step(num: 4, text: "Allow Full Access is required for microphone + LLM calls · 允许完全访问是麦克风和网络调用的前提") + step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: "")) + step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: "")) + step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: "")) + step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: "")) } .cardSurface() .padding(.horizontal, Spacing.md) @@ -228,7 +222,7 @@ private struct EnableKeyboardPage: View { UIApplication.shared.open(url) } } label: { - Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square") + Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square") .primaryButton() } .padding(.horizontal, Spacing.md) diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index 470a80e..92bb195 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -133,7 +133,10 @@ final class PreviewASRController: ObservableObject { try session.setActive(true, options: .notifyOthersOnDeactivation) didConfigureAudioSession = true } catch { - phase = .error("Audio session 错误 · Audio session error: \(error.localizedDescription)") + phase = .error(String.localizedStringWithFormat( + NSLocalizedString("preview.error.audioSession", comment: ""), + error.localizedDescription + )) return } } @@ -219,7 +222,11 @@ final class PreviewASRController: ObservableObject { // state from a previous foreground/background transition. guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { phase = .error( - "麦克风不可用 · Microphone unavailable (hw format \(hwFormat.sampleRate) Hz / \(hwFormat.channelCount) ch)" + String.localizedStringWithFormat( + NSLocalizedString("preview.error.micUnavailable", comment: ""), + hwFormat.sampleRate, + Int(hwFormat.channelCount) + ) ) return } @@ -231,11 +238,11 @@ final class PreviewASRController: ObservableObject { channels: 1, interleaved: false ) else { - phase = .error("无法创建 16 kHz 音频格式") + phase = .error(NSLocalizedString("preview.error.formatCreate", comment: "")) return } guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else { - phase = .error("无法创建音频转换器") + phase = .error(NSLocalizedString("preview.error.converterCreate", comment: "")) return } @@ -280,7 +287,10 @@ final class PreviewASRController: ObservableObject { do { try audioEngine.start() } catch { - phase = .error("无法启动音频引擎 · Engine start failed: \(error.localizedDescription)") + phase = .error(String.localizedStringWithFormat( + NSLocalizedString("preview.error.engineStart", comment: ""), + error.localizedDescription + )) return } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 96ededc..561af6f 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -83,7 +83,7 @@ struct SettingsView: View { private var apiSection: some View { VStack(alignment: .leading, spacing: Spacing.xs) { - sectionHeader("API · 接口", subtitle: nil) + sectionHeader("settings.api.title", subtitle: nil) APISettingsCard(config: config) } } @@ -91,12 +91,15 @@ struct SettingsView: View { // MARK: - Language (ASR + mode) private var languageSection: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - sectionHeader("Language · 语言", subtitle: "选择识别语言\(config.engineMode == "cloud" ? "和文字处理模式" : "")。Recognition language\(config.engineMode == "cloud" ? " and text processing mode" : "").") + let subtitleKey: LocalizedStringKey = config.engineMode == "cloud" + ? "settings.language.subtitle.cloud" + : "settings.language.subtitle.local" + return VStack(alignment: .leading, spacing: Spacing.xs) { + sectionHeader("settings.language.title", subtitle: subtitleKey) VStack(spacing: 0) { if config.engineMode == "cloud" { PickerRow( - title: "Mode · 模式", + title: NSLocalizedString("settings.mode.title", comment: ""), options: modeOptions, selection: Binding( get: { config.modeId }, @@ -121,7 +124,8 @@ struct SettingsView: View { .stroke(palette.divider, lineWidth: 0.5) ) - // Legend — only relevant for SFSpeechRecognizer path (iOS 18–25) + // Legacy legend block: kept for UI compatibility, but with + // iOS 26 as minimum target this branch never executes. if #unavailable(iOS 26) { HStack(spacing: Spacing.xs) { Image(systemName: "iphone") @@ -146,8 +150,7 @@ struct SettingsView: View { /// ASR engine row. With iOS 26 as the deployment target, the /// only ASR backend is `SpeechAnalyzer` and it is always fully /// on-device — so this row is now a static badge rather than a - /// toggle. The previous `requiresOnDevice` toggle (iOS 18–25 - /// `SFSpeechRecognizer` cloud-fallback control) is gone. + /// toggle. The old cloud-fallback toggle is gone. private var asrEngineRow: some View { HStack(spacing: Spacing.sm) { Text("settings.engineRow.title") @@ -170,7 +173,7 @@ struct SettingsView: View { .padding(.vertical, Spacing.sm) } - /// Falls back to a static list while SFSpeechRecognizer locales are loading. + /// Falls back to a static list while dynamic locales are loading. private var effectiveLocales: [(id: String, label: String, onDevice: Bool)] { dynamicLocales.isEmpty ? staticLocales : dynamicLocales } @@ -197,15 +200,15 @@ struct SettingsView: View { // MARK: - Dynamic locale loading private func loadDynamicLocales() async { - // Run everything in a background task: SFSpeechRecognizer.supportedLocales() + // Run everything in a background task: `SFSpeechRecognizer.supportedLocales()` // can return 100+ locales, and we probe supportsOnDeviceRecognition for each. - // Creating SFSpeechRecognizer instances in a @Sendable closure is safe — - // the existing ASRService.swift does the same thing inside AsyncStream { }. + // Creating `SFSpeechRecognizer` instances in a @Sendable closure is + // safe here; we only read locale metadata (no transcription session). let entries: [(id: String, label: String, onDevice: Bool)] = await Task.detached( priority: .userInitiated ) { var result: [(id: String, label: String, onDevice: Bool)] = [] - result.append(("auto", "Auto · 跟随系统", false)) + result.append(("auto", NSLocalizedString("locale.auto", comment: ""), false)) let currentLocale = Locale.current // snapshot on background thread is fine for locale in SFSpeechRecognizer.supportedLocales() @@ -228,7 +231,7 @@ struct SettingsView: View { private var promptSection: some View { VStack(alignment: .leading, spacing: Spacing.xs) { HStack { - sectionHeader("System Prompt · 系统提示", subtitle: nil) + sectionHeader("settings.systemPrompt.title", subtitle: nil) Spacer() Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt } .font(TypeStyle.caption2) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 3a7408a..7ea665c 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -111,6 +111,11 @@ "preview.openSettingsA11y" = "Open OSGKeyboard settings"; "preview.modeChip.cycle" = "Cycle input mode"; "preview.localeChip.cycle" = "Cycle recognition language"; +"preview.error.audioSession" = "Audio session error: %@"; +"preview.error.micUnavailable" = "Microphone unavailable (hw format %.0f Hz / %d ch)"; +"preview.error.formatCreate" = "Failed to create 16 kHz audio format."; +"preview.error.converterCreate" = "Failed to create audio converter."; +"preview.error.engineStart" = "Engine start failed: %@"; /* Keyboard (ext) */ "keyboard.placeholder.idle" = "Hold to talk"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index cdaced8..02bf4b6 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -111,6 +111,11 @@ "preview.openSettingsA11y" = "打开 OSGKeyboard 设置"; "preview.modeChip.cycle" = "切换输入模式"; "preview.localeChip.cycle" = "切换识别语言"; +"preview.error.audioSession" = "音频会话错误: %@"; +"preview.error.micUnavailable" = "麦克风不可用 (硬件格式 %.0f Hz / %d 声道)"; +"preview.error.formatCreate" = "无法创建 16 kHz 音频格式。"; +"preview.error.converterCreate" = "无法创建音频转换器。"; +"preview.error.engineStart" = "无法启动音频引擎: %@"; /* Keyboard (ext) */ "keyboard.placeholder.idle" = "按住说话"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 307f0ee..b5c58ec 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -53,7 +53,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewDidLoad() { super.viewDidLoad() - // iOS 18 keyboard extension MUST opt in to self-sizing, otherwise + // Keyboard extension MUST opt in to self-sizing, otherwise // our SwiftUI `frame(height:)` is ignored and the keyboard is // cropped by the system chrome (Spotlight bar, home indicator). inputView?.allowsSelfSizing = true @@ -150,14 +150,10 @@ public final class KeyboardViewController: UIInputViewController { self.state.phase = .denied(.mic) 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. + // We explicitly ask for Speech recognition permission here. + // `SpeechAnalyzer` does not expose a dedicated request API, + // so the app still relies on the shared Speech permission + // gate and `NSSpeechRecognitionUsageDescription`. let speechGranted = await self.permissions.requestSpeechPermission() guard speechGranted else { self.state.phase = .denied(.speech) diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift index 8712322..003b1b5 100644 --- a/OSGKeyboardShared/Services/ASRService.swift +++ b/OSGKeyboardShared/Services/ASRService.swift @@ -4,8 +4,8 @@ // Speech-to-text abstraction. As of iOS 26 being the minimum // deployment target, the only ASR backend is `SpeechAnalyzer` + // `DictationTranscriber` — always on-device, no cloud fallback, no -// `requiresOnDevice` toggle. The previous SFSpeechRecognizer path -// (iOS 18–25) is gone; if a future platform ever needs it back, +// `requiresOnDevice` toggle. The previous legacy recognizer path 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 @@ -33,8 +33,8 @@ public protocol ASRService: Sendable { /// Start a transcription session. The returned stream emits `.partial` /// updates and exactly one `.final` (or `.error`) before finishing. /// `SpeechAnalyzer` is always fully on-device, so there is no - /// `requiresOnDevice` flag — the previous iOS 18 SFSpeechRecognizer - /// flag was about cloud fallback, which doesn't apply here. + /// `requiresOnDevice` flag — that legacy cloud-fallback control + /// doesn't apply to the iOS 26 `SpeechAnalyzer` path. func transcribe( stream: AsyncStream, locale: Locale @@ -129,8 +129,8 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { // iOS 26's `DictationTranscriber` requires **Int16** PCM // (precondition `"Audio sample data must be 16-bit signed - // integers"` — Float32 was the iOS 18 `SFSpeechRecognizer` - // shape; the new analyzer is strict). 16 kHz mono, Int16, + // integers"` — the legacy recognizer used Float32 at this + // boundary; `SpeechAnalyzer` is strict Int16). 16 kHz mono, Int16, // interleaved — the canonical layout Apple's Speech // framework examples use. let audioFormat = AVAudioFormat( diff --git a/README.md b/README.md index 8f34587..2309efe 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # OSGKeyboard > Hold a key, speak, release — AI-polished text appears at your cursor in any app. -> An open-source, custom-keyboard-based voice input tool for iOS 18+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). +> An open-source, custom-keyboard-based voice input tool for iOS 26+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). -![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) ![License](https://img.shields.io/badge/license-MIT-green) ![CI](https://github.com/hkgood/OSGKeyboard/actions/workflows/ci.yml/badge.svg) @@ -20,14 +20,14 @@ OSGKeyboard is a free, open alternative to commercial voice-input tools. It runs 2. Speak naturally 3. Release — the AI polishes your words into clean text and inserts it at the cursor -The audio stays on-device (transcribed by Apple's on-device `SFSpeechRecognizer` on iOS 18/19; iOS 26+ `SpeechAnalyzer` planned for the next release). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.** +The audio stays on-device (transcribed by Apple's on-device `SpeechAnalyzer` + `DictationTranscriber` on iOS 26+). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.** --- ## Features - 🎙 **Push-to-talk** with a Typeless-style circular mic button -- 🧠 **On-device ASR** (iOS 18/19 `SFSpeechRecognizer`; iOS 26+ `SpeechAnalyzer` + `DictationTranscriber` planned) +- 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+) - ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists - 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, your own self-hosted server, …) - 🔒 **Privacy first** — audio never leaves your device; transcripts only sent to the LLM you choose @@ -41,7 +41,7 @@ The audio stays on-device (transcribed by Apple's on-device `SFSpeechRecognizer` ### Requirements - macOS with **Xcode 16+** (Xcode 26 recommended) -- iPhone running **iOS 18.0+** +- iPhone running **iOS 26.0+** - [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` - An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)) @@ -80,7 +80,7 @@ OSGKeyboard/ │ ├── KeyboardViewController.swift # Principal class │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM -│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR +│ │ ├── ASRService.swift # iOS 26 SpeechAnalyzer ASR │ │ └── PolishingService.swift # LLM call with timeout │ └── Views/ # RecordButton, Waveform, KeyboardRootView ├── OSGKeyboardShared/ # Framework shared by app + extension @@ -132,8 +132,7 @@ That's it. No other code changes required. - iOS sandboxes keyboard extensions: ~60 MB memory cap, Full Access required. - The keyboard does **not** work in password fields or some `WKWebView` textareas (iOS limitation). -- iOS 18/19 ships with `SFSpeechRecognizer` for on-device ASR. iOS 26+ `SpeechAnalyzer` is planned for the next release — it is significantly faster and supports more locales. -- iOS 26+ users in v0.1.1 use the iOS 18 `SFSpeechRecognizer` path; the iOS 26 `SpeechAnalyzer` is planned for 0.2.0. +- iOS 26+ only. Earlier iOS versions are not supported. --- diff --git a/README.zh.md b/README.zh.md index 6a74ef1..021ddcc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -3,7 +3,7 @@ > 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。 > 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。 -![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) ![License](https://img.shields.io/badge/license-MIT-green) @@ -19,14 +19,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自 2. 自由说话 3. 松开 —— AI 帮你整理成干净的文字,自动插入光标处 -**音频始终在设备本地转写**(iOS 18/19 用 `SFSpeechRecognizer`;iOS 26+ 的 `SpeechAnalyzer` 计划下版接入),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。** +**音频始终在设备本地转写**(iOS 26+ 使用 `SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。** --- ## 特性 - 🎙 **按住说话**,Typeless 风格的圆形麦克风按钮 -- 🧠 **端侧 ASR**(iOS 18/19 `SFSpeechRecognizer`;iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber` 计划下版接入) +- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`) - ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 - 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / 自建服务器 ……) - 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM @@ -40,14 +40,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自 ### 环境要求 - macOS + **Xcode 16+**(推荐 Xcode 26) -- iPhone 运行 **iOS 18.0+** +- iPhone 运行 **iOS 26.0+** - [XcodeGen](https://github.com/yonaskolb/XcodeGen):`brew install xcodegen` - 一个 OpenAI 兼容 API Key([OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一) ### 编译与运行 ```bash -git clone https://github.com/<你的用户名>/OSGKeyboard.git +git clone https://github.com/hkgood/OSGKeyboard.git cd OSGKeyboard xcodegen generate # 生成 OSGKeyboard.xcodeproj open OSGKeyboard.xcodeproj # 或命令行编译: @@ -79,7 +79,7 @@ OSGKeyboard/ │ ├── KeyboardViewController.swift # 主体类 │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM -│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR +│ │ ├── ASRService.swift # iOS 26 SpeechAnalyzer ASR │ │ └── PolishingService.swift # LLM 调用(带超时) │ └── Views/ # 录音按钮、波形、键盘主视图 ├── OSGKeyboardShared/ # 主 App + 键盘共享 framework @@ -131,8 +131,7 @@ LLMProvider( - iOS 沙盒:键盘扩展 ~60 MB 内存上限,必须开完全访问 - 密码框与部分 `WKWebView` 输入框不可用(iOS 限制) -- iOS 18/19 用 `SFSpeechRecognizer` 做端侧 ASR;iOS 26+ 的 `SpeechAnalyzer` 计划下版接入(更快、支持语种更多) -- v0.1.1 中 iOS 26+ 用户仍走 iOS 18 `SFSpeechRecognizer` 路径;iOS 26 `SpeechAnalyzer` 计划在 0.2.0 接入 +- 仅支持 iOS 26+;更早的 iOS 版本不在支持范围内。 --- @@ -150,4 +149,4 @@ LLMProvider( --- -**注意**:把 `<你的用户名>` 替换成你的 GitHub 用户名。 +**说明**:本项目发布仓库为 [`hkgood/OSGKeyboard`](https://github.com/hkgood/OSGKeyboard)。 diff --git a/project.yml b/project.yml index 4dbfc94..9973f0b 100644 --- a/project.yml +++ b/project.yml @@ -5,8 +5,8 @@ name: OSGKeyboard options: bundleIdPrefix: com.osgkeyboard - # Minimum OS: iOS 26. We dropped iOS 18–25 support so the - # SFSpeechRecognizer + AVAudioSession branching could be removed in + # Minimum OS: iOS 26. We dropped older iOS support so the + # legacy speech + 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.