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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// AudioCaptureService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
|
||||
// Exposes an AsyncStream<AVAudioPCMBuffer> that ASR services can consume.
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import AVFoundation
|
||||
|
||||
public actor AudioCaptureService {
|
||||
|
||||
public enum CaptureError: Error {
|
||||
case sessionConfigFailed(Error)
|
||||
case engineStartFailed(Error)
|
||||
case noInputNode
|
||||
}
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private let converter = AVAudioConverter(
|
||||
from: AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 48000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!,
|
||||
to: AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
)
|
||||
|
||||
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
self.continuation = continuation
|
||||
do {
|
||||
try configureSession()
|
||||
try attachTap()
|
||||
try engine.start()
|
||||
isRunning = true
|
||||
} catch {
|
||||
continuation.finish()
|
||||
self.continuation = nil
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private func configureSession() throws {
|
||||
#if canImport(UIKit)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.duckOthers, .defaultToSpeaker, .allowBluetooth]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw CaptureError.sessionConfigFailed(error)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func attachTap() throws {
|
||||
let input = engine.inputNode
|
||||
let hardwareFormat = input.outputFormat(forBus: 0)
|
||||
guard hardwareFormat.sampleRate > 0 else {
|
||||
throw CaptureError.noInputNode
|
||||
}
|
||||
let bufferSize: AVAudioFrameCount = 4096
|
||||
input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
// Downsample to 16 kHz mono Float32
|
||||
let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
|
||||
)
|
||||
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
|
||||
return
|
||||
}
|
||||
var error: NSError?
|
||||
let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
if status == .haveData, error == nil {
|
||||
// AVAudioPCMBuffer is not Sendable; copy the raw float data
|
||||
// into a Sendable wrapper so we can hand it to the actor.
|
||||
let copy = AudioBufferSnapshot(buffer: outBuffer)
|
||||
Task { await self.deliver(copy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ snapshot: AudioBufferSnapshot) {
|
||||
guard isRunning else { return }
|
||||
continuation?.yield(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendable wrapper around a Float32 audio buffer's raw samples.
|
||||
/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable
|
||||
/// type crossing the actor boundary.
|
||||
public struct AudioBufferSnapshot: Sendable {
|
||||
public let samples: [Float]
|
||||
public let sampleRate: Double
|
||||
|
||||
public init(buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
self.samples = []
|
||||
self.sampleRate = 16_000
|
||||
return
|
||||
}
|
||||
let n = Int(buffer.frameLength)
|
||||
var copy = [Float](repeating: 0, count: n)
|
||||
memcpy(©, channelData[0], n * MemoryLayout<Float>.size)
|
||||
self.samples = copy
|
||||
self.sampleRate = buffer.format.sampleRate
|
||||
}
|
||||
}
|
||||
|
||||
public extension AsyncStream where Element == AudioBufferSnapshot {
|
||||
/// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32.
|
||||
func toAVAudioBuffers() -> AsyncStream<AVAudioPCMBuffer> {
|
||||
let format = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
let snapshots = self
|
||||
return AsyncStream<AVAudioPCMBuffer> { continuation in
|
||||
Task {
|
||||
for await snap in snapshots {
|
||||
guard !snap.samples.isEmpty,
|
||||
let pcm = AVAudioPCMBuffer(pcmFormat: format, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.yield(pcm)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// PolishingService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Takes raw ASR transcript and runs it through the user's configured LLM
|
||||
// to produce polished, well-punctuated text. Falls back to the raw transcript
|
||||
// if the LLM call fails or times out.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
public actor PolishingService {
|
||||
|
||||
public enum PolishError: Error {
|
||||
case noTranscript
|
||||
case timeout
|
||||
}
|
||||
|
||||
private let store: AppGroupStore
|
||||
private let timeout: TimeInterval
|
||||
|
||||
public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 8) {
|
||||
self.store = store
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
public func polish(_ raw: String) async throws -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
let client = store.makeClient()
|
||||
let prompt = store.systemPrompt
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
group.addTask {
|
||||
try await client.polish(trimmed, systemPrompt: prompt)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000))
|
||||
throw PolishError.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user