Files
OSGKeyboard/OSGKeyboardHostSupport/Services/LiveDictationController.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:31:29 -07:00

602 lines
26 KiB
Swift

// LiveDictationController.swift
// OSGKeyboard · HostSupport
//
// Unified on-device dictation session: mic capture + iOS 26 SpeechAnalyzer.
// Retained for foreground preview and one-shot handoff surfaces that need
// live ASR without duplicating the host-owned audio pipeline.
//
// The *primary* voice-session path is `FlowSessionManager` +
// `FlowContinuousCapture`; the keyboard extension consumes its results
// through `FlowSessionBridge`.
//
// This class is still imported by:
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (in-app preview)
// - `OSGKeyboardTests/PreviewASRControllerStateTests.swift`
//
// Do NOT remove without updating those call sites.
// Owns its own AVAudioEngine + AVAudioSession, downsamples to 16 kHz
// mono Float32 on the audio thread, and feeds `AudioBufferSnapshot` to
// the HostSupport `ASRService`, so previews exercise the real iOS
// speech APIs instead of a stub.
//
// This stays in HostSupport because foreground `AVAudioSession`
// lifecycle and recording ownership do not belong in the keyboard
// extension or the platform-neutral Shared target.
import AVFoundation
import Foundation
import os
import Speech
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
private enum LiveCaptureGatePhase: Equatable {
case idle
case recording
case draining
}
/// Thread-safe relay so the AVAudioEngine tap can yield snapshots without
/// hopping through `@MainActor` (which adds latency and can reorder frames).
private final class CaptureStreamRelay: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock()
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
func bind(_ continuation: AsyncStream<AudioBufferSnapshot>.Continuation) {
lock.withLock { self.continuation = continuation }
}
func yield(_ snapshot: AudioBufferSnapshot) {
_ = lock.withLock { continuation?.yield(snapshot) }
}
func finish() {
lock.withLock {
continuation?.finish()
continuation = nil
}
}
}
@MainActor
public final class LiveDictationController: ObservableObject {
public enum Phase: Equatable {
case idle
case requestingPermission
case recording
case processing
case denied(String)
case error(String)
}
@Published public private(set) var phase: Phase = .idle
/// Normalized 0...1 RMS for the disc level meter. Polled from the
/// audio tap via `Task { @MainActor in ... }` — the tap itself
/// runs on a real-time audio thread, so we never touch published
/// state from there.
@Published public private(set) var level: Double = 0
@Published public private(set) var currentPartial: String = ""
@Published public private(set) var errorMessage: String?
/// Set when a `.final` ASR event lands. The owning sheet observes
/// this and appends the text to its textbox, then clears it so the
/// next recording starts from zero.
@Published public var lastFinal: String = ""
private let asr: ASRService
private var audioEngine = AVAudioEngine()
/// `internal` (not `private`) so the regression test in
/// `OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
/// install a known consumer task and assert `stop()` doesn't
/// cancel it. The class is `@MainActor`-isolated, so the
/// natural Swift 6 isolation rules still prevent production
/// code outside the class from racing on it.
public var asrTask: Task<Void, Never>?
private let streamRelay = CaptureStreamRelay()
private var chunkedPipeline: ChunkedUtterancePipeline?
private let captureGate = OSAllocatedUnfairLock(initialState: LiveCaptureGatePhase.idle)
private let drainTracker = FlowCaptureDrainTracker()
/// Route-adaptive resampler shared with the Flow capture path. Rebuilt
/// lazily whenever the live input format changes, so a headset/Bluetooth
/// swap mid-session downsamples correctly instead of feeding the converter
/// a stale rate.
private var downsampler: FlowAdaptiveDownsampler?
private var didConfigureAudioSession = false
private var didInstallTap = false
public init(asr: ASRService? = nil) {
self.asr = asr ?? ASRServiceFactory.make(store: AppGroupStore())
}
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
public func start(localeId: String) async {
await start(locale: SpeechLocaleResolver.resolve(localeId))
}
public func start(locale: Locale) async {
// Re-entry guard: ignore taps that arrive while we're already
// running. (The sheet's `cyclePhase` is also guarded, but
// async race windows are easier to lock down here.)
switch phase {
case .recording, .requestingPermission, .processing:
return
default:
break
}
// Cancel any leftover consumer task from a previous recording.
// Normally `stop()` lets the task run to completion (so it can
// see the `.final` and transition out of `.processing`), but if
// the user smashed the disc twice — stop, then immediately
// start — the previous task might still be draining. Cancel it
// here so we don't have two consumer tasks fighting over the
// same `events` stream.
asrTask?.cancel()
asrTask = nil
if let pipeline = chunkedPipeline {
Task { await pipeline.cancel() }
}
chunkedPipeline = nil
teardownCapturePipeline()
phase = .requestingPermission
currentPartial = ""
lastFinal = ""
errorMessage = nil
level = 0
// 1. Microphone permission. The helper is `nonisolated` so the
// (iOS < 17) callback closure does not inherit `@MainActor` —
// `AVAudioSession.requestRecordPermission` delivers on a TCC
// reply queue, and a `@MainActor`-inferred closure body there
// hits `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift`.
let micGranted = await Self.requestMicrophonePermission()
guard micGranted else {
phase = .denied(NSLocalizedString("keyboard.denied.mic", comment: ""))
return
}
// 2. Speech recognition permission. Same reasoning as above:
// the callback fires on TCC's reply queue, NOT the main queue.
let speechGranted = await Self.requestSpeechRecognitionPermission()
guard speechGranted else {
phase = .denied(NSLocalizedString("keyboard.denied.speech", comment: ""))
return
}
// 3. Audio session — only configure once per process.
//
// On device we use `.playAndRecord` + `.voiceChat` so Apple Voice
// Processing can suppress competing talkers. The iOS Simulator's
// speaker reports a 0 Hz output format, so `.playAndRecord` fails
// with `kAudioUnitErr_FormatNotSupported` (-10851); keep `.record`
// there so preview still works under CoreSimulator.
if !didConfigureAudioSession {
do {
let session = AVAudioSession.sharedInstance()
#if targetEnvironment(simulator)
try session.setCategory(.record, mode: .default, options: [])
#else
try session.setCategory(
.playAndRecord,
mode: FlowCaptureVoiceProcessing.captureMode,
options: FlowCaptureVoiceProcessing.captureOptions
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#if !targetEnvironment(simulator)
FlowCaptureVoiceProcessing.preferNearTalkBuiltInMic(on: session)
#endif
didConfigureAudioSession = true
} catch {
debug("audio session failed: \(error.localizedDescription)")
phase = .error(String.localizedStringWithFormat(
NSLocalizedString("preview.error.audioSession", comment: ""),
error.localizedDescription
))
return
}
}
// The route may have changed while the preview was closed. A fresh
// engine created after session activation avoids a stale input node.
audioEngine = AVAudioEngine()
// Enable VP before reading hardware format — RemoteIO rate can shift.
_ = FlowCaptureVoiceProcessing.enableVoiceProcessing(on: audioEngine)
// 4. Spin up the engine + ASR.
phase = .recording
startEngineAndASR(locale: locale)
}
public func stop() {
// Don't `asrTask?.cancel()` here — see the comment in
// `startEngineAndASR` for the full rationale. Short version:
// cancelling the consumer task at the same moment we close the
// audio stream also triggers the producer's
// `continuation.onTermination → self?.cancel()` cascade, which
// marks the producer's outer task as cancelled and skips the
// `.final` event. The UI is then left in `.processing` forever
// because no one schedules the transition out. The consumer
// task naturally exits when `events` finishes, so the right
// thing is to let it run.
//
// If a previous `asrTask` is somehow still running (e.g. the
// user smashed the disc twice quickly), `start()` cancels it
// at the entry point as a safety net.
let partial = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
if !partial.isEmpty && lastFinal.isEmpty {
lastFinal = partial
currentPartial = ""
}
if phase == .recording {
phase = .processing
}
Task { @MainActor [weak self] in
await self?.drainTailAndTeardownCapture()
}
// Safety net: if the ASR pipeline never produces a `.final`
// (analyzer hang, system glitch, dropped continuation), force
// the UI back to idle after a short delay so the user isn't
// stuck. Normal recordings complete well under a second, so
// the 3-second budget is only hit on the unhappy path; if the
// pipeline finishes first and flips the phase to `.idle` (or
// `.error`), the check below no-ops.
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
guard let self else { return }
if self.phase == .processing {
let stalePartial = self.currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
if !stalePartial.isEmpty, self.lastFinal.isEmpty {
self.debug("processing timeout, using partial")
self.lastFinal = stalePartial
self.currentPartial = ""
}
self.phase = .idle
}
}
}
public func reset() {
// Called by the sheet after appending `lastFinal` to the textbox,
// so the next recording can produce a fresh final without us
// double-appending.
lastFinal = ""
if phase == .processing {
phase = .idle
}
}
// MARK: - Engine + ASR
private func startEngineAndASR(locale: Locale) {
let inputNode = audioEngine.inputNode
let hwFormat = inputNode.inputFormat(forBus: 0)
// Pre-flight check: a placeholder / unconfigured input bus
// reports `sampleRate == 0` (or `channelCount == 0`), and no
// amount of adaptive resampling downstream makes a dead bus
// usable — `installTap` on one raises "Failed to create tap
// due to format mismatch", an NSException rather than a Swift
// `Error`, so we can't `try`/`catch` it. Refuse up front and
// surface a clear `.error` phase instead of crashing. We've
// seen this on the iOS Simulator when the host's microphone
// permission isn't granted to CoreSimulator, and on devices
// where the audio session is in an unexpected state from a
// previous foreground/background transition.
//
// The *other* half of that crash — a format that was valid
// here but stale by the time the tap is installed — is handled
// by passing `format: nil` below rather than by this check.
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
debug("invalid hardware format sr=\(hwFormat.sampleRate) ch=\(hwFormat.channelCount)")
phase = .error(
String.localizedStringWithFormat(
NSLocalizedString("preview.error.micUnavailable", comment: ""),
hwFormat.sampleRate,
Int(hwFormat.channelCount)
)
)
return
}
let targetSampleRate: Double = 16_000
guard let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: targetSampleRate,
channels: 1,
interleaved: false
) else {
phase = .error(NSLocalizedString("preview.error.formatCreate", comment: ""))
return
}
// The converter itself is built lazily by the downsampler from the
// format of the *first buffer the tap actually delivers*, so a route
// change between here and the first callback cannot desync it. Probe
// once up front purely to fail fast with a clear message.
guard AVAudioConverter(from: hwFormat, to: targetFormat) != nil else {
debug("converter creation failed")
phase = .error(NSLocalizedString("preview.error.converterCreate", comment: ""))
return
}
let downsampler = FlowAdaptiveDownsampler(targetFormat: targetFormat)
self.downsampler = downsampler
drainTracker.reset()
captureGate.withLock { $0 = .recording }
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
streamRelay.bind(continuation)
// Tap the hardware input. The closure passed to `installTap` runs
// on the AVAudioEngine real-time audio thread. In Swift 6 strict
// concurrency, a closure literal defined inside a `@MainActor`
// method inherits `@MainActor` isolation, which would trip
// `dispatch_assert_queue_fail` on first invocation from the
// audio thread. The fix is to build the actual tap body in a
// `nonisolated` helper (`makeAudioTapBlock`) and have the
// installTap closure be a single function reference — function
// references never carry inferred isolation, so the dispatch
// runtime is happy and the body runs wherever AVAudioEngine
// wants it (the audio thread).
let onMeter: @Sendable (Double) -> Void = { [weak self] meter in
Task { @MainActor [weak self] in
guard let self else { return }
// Lightweight smoothing so the disc ring doesn't jitter.
self.level = self.level * 0.55 + meter * 0.45
}
}
let relay = streamRelay
let gate = captureGate
let tracker = drainTracker
let policy = FlowCaptureTailDrainPolicy.flowDefault
let onSnapshot: @Sendable (AudioBufferSnapshot) -> Void = { snapshot in
let phase = gate.withLock { $0 }
guard phase == .recording || phase == .draining else { return }
relay.yield(snapshot)
if phase == .draining {
tracker.noteAudio(samples: snapshot.samples, policy: policy)
}
}
let tap = Self.makeAudioTapBlock(
downsampler: downsampler,
onMeter: onMeter,
onSnapshot: onSnapshot
)
// `format: nil` — NEVER pass an explicit format here. `installTap`
// raises an uncatchable NSException ("Failed to create tap due to
// format mismatch") when the format we captured above has already been
// superseded by a route change, and that kills the whole app. `nil`
// always resolves to the node's live format; `FlowAdaptiveDownsampler`
// absorbs whatever rate actually arrives.
inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil, block: tap)
didInstallTap = true
audioEngine.prepare()
do {
try audioEngine.start()
} catch {
debug("audio engine start failed: \(error.localizedDescription)")
phase = .error(String.localizedStringWithFormat(
NSLocalizedString("preview.error.engineStart", comment: ""),
error.localizedDescription
))
return
}
// 5. Pipelined ASR (same chunk path as Flow host).
let pipeline = ChunkedUtterancePipeline(asr: asr, locale: locale)
chunkedPipeline = pipeline
asrTask = Task.detached(priority: .userInitiated) { [weak controller = self] in
let outcome = await pipeline.transcribe(stream: stream) { partial in
Task { @MainActor in
controller?.currentPartial = partial
}
}
// Re-bind `controller` inside the `@MainActor` block so the
// weak reference is captured under the right isolation. Swift
// 6 strict concurrency otherwise complains about a
// task-isolated reference escaping into a main-actor closure.
await MainActor.run { [weak controller] in
guard let controller else { return }
switch outcome {
case .success(let success):
let trimmed = success.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
controller.lastFinal = trimmed
controller.currentPartial = ""
}
if controller.phase == .processing || controller.phase == .recording {
controller.phase = .idle
}
case .failure(let message):
controller.debug("asr failed messageLen=\(message.count)")
controller.teardownCapturePipeline()
controller.errorMessage = message
controller.phase = .error(message)
case .cancelled:
if controller.phase == .processing {
controller.phase = .idle
}
}
}
}
}
// MARK: - Permission helpers (nonisolated)
//
// `SFSpeechRecognizer.requestAuthorization` delivers its callback
// on a TCC reply queue, NOT the main queue. If we wrap that
// callback inline in `start(locale:)` — which is `@MainActor` —
// Swift 6 strict concurrency infers the closure body as
// `@MainActor`, and the runtime crashes on
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
// soon as TCC calls us back.
//
// The first attempt (commit `e8a0310`) extracted the entire
// permission request into a `nonisolated static func` helper.
// That worked in isolation, but the Swift 6 optimizer
// inlined those helpers back into `start(locale:)`. After
// inlining, the `withCheckedContinuation` body and the
// `requestAuthorization` callback were re-typed in the
// `@MainActor` context of the caller, and the runtime
// assertion came right back — same crash, different symbol:
// `closure #1 in closure #2 in LiveDictationController.start(locale:)`.
//
// The fix that survives inlining is the *function-reference*
// pattern, the same one used for `installTap` in
// `makeAudioTapBlock` below. The callback is built in a
// `nonisolated` static helper that takes a `CheckedContinuation`
// and returns the `(Status) -> Void` handler. The body of that
// helper has no enclosing actor, so the closure is created in
// nonisolated context. When TCC calls us back, the runtime
// sees a nonisolated closure on a non-main queue and is happy.
//
// `cont.resume(...)` is itself thread-safe on
// `CheckedContinuation`, so we don't need to hop back to the
// main actor before resuming.
private nonisolated static func requestMicrophonePermission() async -> Bool {
// 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 {
case .granted: return true
case .denied: return false
case .undetermined: return await AVAudioApplication.requestRecordPermission()
@unknown default: return false
}
}
private nonisolated static func requestSpeechRecognitionPermission() async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization(
Self.makeSpeechAuthHandler(continuation: cont)
)
}
}
private nonisolated static func makeSpeechAuthHandler(
continuation: CheckedContinuation<Bool, Never>
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
return { status in
continuation.resume(returning: status == .authorized)
}
}
// MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread)
//
// `AVAudioNode.installTap`'s callback fires on the audio engine's
// real-time render thread. In Swift 6 strict concurrency, a closure
// literal defined inside a `@MainActor` method inherits `@MainActor`
// isolation — and `dispatch_assert_queue_fail` fires the moment
// the runtime tries to dispatch that closure on a non-main queue.
//
// The trick is to build the actual tap body in a `nonisolated`
// function and have the installTap closure be a *function reference*
// to that helper. Function references never carry inferred
// isolation, so the dispatch runtime is satisfied and the body
// runs wherever AVAudioEngine wants. State updates to
// `self.level` and the AsyncStream continuation hop back to the
// main actor via `Task { @MainActor in … }`, which is itself
// safe to call from a non-isolated context.
private nonisolated static func makeAudioTapBlock(
downsampler: FlowAdaptiveDownsampler,
onMeter: @Sendable @escaping (Double) -> Void,
onSnapshot: @Sendable @escaping (AudioBufferSnapshot) -> Void
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
// `@Sendable` on the returned closure makes the Sendable
// conformance explicit. `AVAudioNodeTapBlock` is declared as
// a plain escaping closure in the SDK; we cast at the call
// site via `as @Sendable`.
return { buffer, _ in
// 1) Level meter from raw hardware buffer.
let n = Int(buffer.frameLength)
var sumSquares: Float = 0
if let channelData = buffer.floatChannelData?[0], n > 0 {
for i in 0..<n {
let v = channelData[i]
sumSquares += v * v
}
}
let rms = n > 0 ? sqrtf(sumSquares / Float(n)) : 0
let meter = min(Double(rms) * 4.0, 1.0)
onMeter(meter)
// 2) Downsample to the 16 kHz mono Float32 format expected by
// HostSupport ASR and Apple's `considering:` hint. The rate comes
// from the buffer we were just handed, not from a format captured
// at install time, so a mid-session route change degrades to a few
// dropped frames instead of garbled audio.
guard case .converted(let outBuffer) =
downsampler.convertReusingScratch(buffer) else { return }
// `outBuffer` is the downsampler's reusable scratch — valid only
// until the next callback, so copy the samples out right here.
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
guard !snapshot.samples.isEmpty else { return }
onSnapshot(snapshot)
}
}
private func drainTailAndTeardownCapture() async {
let beganDrain = captureGate.withLock { phase -> Bool in
switch phase {
case .recording:
phase = .draining
return true
case .draining, .idle:
return false
}
}
guard beganDrain else { return }
drainTracker.beginDrain()
let policy = FlowCaptureTailDrainPolicy.flowDefault
_ = await FlowUtteranceEndCoordinator.awaitTailCapture(
tracker: drainTracker,
policy: policy
)
// Trailing speech is preserved by the live `.draining` forwarding
// loop above. We deliberately do NOT signal `.endOfStream` to the
// converter to squeeze its internal filter tail: that both races the
// still-running audio-thread tap on the same non-thread-safe converter
// and (in reused-converter paths) permanently locks it. The dropped
// tail is sub-millisecond and inaudible.
streamRelay.finish()
teardownCaptureEngine()
captureGate.withLock { $0 = .idle }
drainTracker.reset()
downsampler = nil
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
}
private func teardownCaptureEngine() {
if didInstallTap {
audioEngine.inputNode.removeTap(onBus: 0)
didInstallTap = false
}
if audioEngine.isRunning {
audioEngine.stop()
}
}
private func teardownCapturePipeline() {
teardownCaptureEngine()
streamRelay.finish()
}
private func debug(_ message: String) {
#if DEBUG
print("🎙️[LiveDictationController] \(message)")
#endif
}
}