feat: initial release v0.1.0

- Custom Keyboard Extension with push-to-talk UI
- iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback)
- OpenAI-compatible LLM client (4 built-in providers + custom)
- 3-page onboarding flow + provider config UI
- App Group shared storage for cross-process config
- 8s LLM timeout with raw-transcript fallback
- App Store privacy manifests for both targets
- SwiftLint + XcodeGen + GitHub Actions CI
- Unit tests (4/4 passing)
This commit is contained in:
2026-06-17 23:08:58 +08:00
commit 07067ca6c5
45 changed files with 3072 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
// ASRService.swift
// OSGKeyboard · Keyboard Extension
//
// Speech-to-text abstraction. iOS 26+ uses SpeechAnalyzer + DictationTranscriber
// (Apple's modern, on-device streaming API). iOS 18 falls back to SFSpeechRecognizer
// with on-device recognition.
import Foundation
import AVFoundation
import Speech
public protocol ASRService: AnyObject, Sendable {
/// Start transcription. Returns an async stream of partial + final strings.
/// The last value emitted on `finish()` is the final transcript.
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent>
/// Cancel any in-flight work.
func cancel()
}
public enum ASREvent: Sendable {
case partial(String) // incremental, may be discarded
case final(String) // the authoritative transcript
case error(String)
}
// MARK: - Factory
public enum ASRServiceFactory {
public static func create() -> ASRService {
if #available(iOS 26, *) {
return SpeechAnalyzerASR()
} else {
return SFSpeechRecognizerASR()
}
}
}
// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber
@available(iOS 26, *)
final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
private var task: Task<Void, Never>?
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
AsyncStream { continuation in
let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current)
guard let recognizer, recognizer.isAvailable else {
continuation.yield(.error("Speech recognizer unavailable"))
continuation.finish()
return
}
recognizer.defaultTaskHint = .dictation
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {
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.task = Task { [request] in
for await snap in stream {
if Task.isCancelled { break }
let pcmStream = AsyncStream<AudioBufferSnapshot> { c in
c.yield(snap)
c.finish()
}
for await pcm in pcmStream.toAVAudioBuffers() {
request.append(pcm)
}
}
request.endAudio()
// give recognizer a moment to finalize
try? await Task.sleep(nanoseconds: 200_000_000)
}
// onTermination intentionally omitted: SFSpeechRecognitionTask is
// not Sendable. Cancellation flows through this class's cancel().
}
}
func cancel() {
task?.cancel()
task = nil
}
}
// MARK: - iOS 18 fallback: SFSpeechRecognizer
final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable {
private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
private var task: SFSpeechRecognitionTask?
private var feedTask: Task<Void, Never>?
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
AsyncStream { continuation in
guard let recognizer, recognizer.isAvailable else {
continuation.yield(.error("Speech recognizer unavailable"))
continuation.finish()
return
}
recognizer.defaultTaskHint = .dictation
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
if recognizer.supportsOnDeviceRecognition {
request.requiresOnDeviceRecognition = true
}
let recognizerTask = recognizer.recognitionTask(with: request) { result, error in
if let error {
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.task = recognizerTask
self.feedTask = Task { [request] in
for await snap in stream {
if Task.isCancelled { break }
let pcmStream = AsyncStream<AudioBufferSnapshot> { c in
c.yield(snap)
c.finish()
}
for await pcm in pcmStream.toAVAudioBuffers() {
request.append(pcm)
}
}
request.endAudio()
}
// onTermination intentionally omitted: SFSpeechRecognitionTask is
// not Sendable. Cancellation is handled by calling cancel() on
// this class, and the feedTask loop respects Task.isCancelled.
}
}
func cancel() {
task?.cancel()
feedTask?.cancel()
task = nil
feedTask = nil
}
}