feat(flow): harden host handoff, session UX, and Live Activity brand
- fix(keyboard): drop unusable extensionContext.open; report the real result of UIApplication.open and cancel the start watchdog immediately on failure with clear "open OSGKeyboard" guidance instead of a 30s spin - feat(capture): recover from audio route changes (AirPods/wired headset plug-unplug) and phone-call interruptions by rebuilding the engine/tap against the live hardware format - feat(home): add a "Start" button in the Home footer when a session is inactive and permissions are granted (no-jump manual restart) - feat(hostapps): expand the return whitelist to 46 apps (Signal, Zoom, Instagram, X, Douyin, Zhihu, Bilibili, Things, Firefox, ...) with en/zh-Hans names and synced LSApplicationQueriesSchemes - feat(island): use a "ready" checkmark instead of a mic glyph while idle and set a staleDate so an orphaned Live Activity fades after a force-quit - feat(coldstart): use the template OSG brand mark instead of a system waveform glyph in the cold-start overlay - perf(keyboard): rely on Darwin notifications as the primary session signal and drop the fallback poll from 1s to 3s - fix(keyboard): remove the redundant "Start" text button next to the inactive-session hint on the keyboard - docs: correct the per-take cap to 210s (3.5 min) and document that force-quitting no longer resurrects the session (tracker + READMEs)
This commit is contained in:
@@ -163,6 +163,10 @@ public final class FlowContinuousCapture {
|
||||
private var didInstallTap = false
|
||||
private var isRunning = false
|
||||
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
private var interruptionObserver: NSObjectProtocol?
|
||||
private let log = Logger(subsystem: "com.osgkeyboard.shared", category: "FlowCapture")
|
||||
|
||||
public init() {}
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
@@ -170,7 +174,16 @@ public final class FlowContinuousCapture {
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
installSessionObservers()
|
||||
}
|
||||
|
||||
/// Bring up the audio session + engine for the *current* hardware route.
|
||||
/// Reused for route-change / interruption recovery, so it always rebuilds
|
||||
/// the tap against the live hardware format (which changes when the user
|
||||
/// plugs in AirPods or a wired headset mid-session).
|
||||
private func activateEngine() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
@@ -204,23 +217,22 @@ public final class FlowContinuousCapture {
|
||||
throw StartError.converterCreateFailed
|
||||
}
|
||||
|
||||
if !didInstallTap {
|
||||
let utteranceFlag = isUtteranceActive
|
||||
let relay = streamRelay
|
||||
let preroll = prerollStore
|
||||
let levels = levelStore
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
utteranceFlag: utteranceFlag,
|
||||
levelStore: levels,
|
||||
prerollStore: preroll,
|
||||
streamRelay: relay
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
// Rebuild the tap so its bound hardware format matches the new route.
|
||||
if didInstallTap {
|
||||
inputNode.removeTap(onBus: 0)
|
||||
didInstallTap = false
|
||||
}
|
||||
let tap = Self.makeAudioTapBlock(
|
||||
converter: converter,
|
||||
targetFormat: targetFormat,
|
||||
hwFormat: hwFormat,
|
||||
utteranceFlag: isUtteranceActive,
|
||||
levelStore: levelStore,
|
||||
prerollStore: prerollStore,
|
||||
streamRelay: streamRelay
|
||||
)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||
didInstallTap = true
|
||||
|
||||
audioEngine.prepare()
|
||||
do {
|
||||
@@ -228,11 +240,11 @@ public final class FlowContinuousCapture {
|
||||
} catch {
|
||||
throw StartError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// Tear down the engine and release the audio session.
|
||||
public func stop() {
|
||||
removeSessionObservers()
|
||||
isUtteranceActive.withLock { $0 = false }
|
||||
streamRelay.finish()
|
||||
|
||||
@@ -266,6 +278,98 @@ public final class FlowContinuousCapture {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route / interruption recovery
|
||||
|
||||
private func installSessionObservers() {
|
||||
let center = NotificationCenter.default
|
||||
if routeObserver == nil {
|
||||
routeObserver = center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
// Extract Sendable primitives here (Notification isn't Sendable)
|
||||
// before hopping onto the main actor.
|
||||
let reasonRaw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
MainActor.assumeIsolated { self?.handleRouteChange(reasonRaw: reasonRaw) }
|
||||
}
|
||||
}
|
||||
if interruptionObserver == nil {
|
||||
interruptionObserver = center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let typeRaw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt
|
||||
MainActor.assumeIsolated {
|
||||
self?.handleInterruption(typeRaw: typeRaw, optionsRaw: optionsRaw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSessionObservers() {
|
||||
let center = NotificationCenter.default
|
||||
if let routeObserver { center.removeObserver(routeObserver) }
|
||||
if let interruptionObserver { center.removeObserver(interruptionObserver) }
|
||||
routeObserver = nil
|
||||
interruptionObserver = nil
|
||||
}
|
||||
|
||||
private func handleRouteChange(reasonRaw: UInt?) {
|
||||
guard isRunning else { return }
|
||||
guard let reasonRaw,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
|
||||
// Only rebuild for real device swaps (plugging / unplugging a headset
|
||||
// or AirPods). Ignore `.categoryChange`, which we trigger ourselves.
|
||||
switch reason {
|
||||
case .oldDeviceUnavailable, .newDeviceAvailable:
|
||||
log.info("Audio route changed (\(reasonRaw, privacy: .public)) — rebuilding engine")
|
||||
rebuildEngine()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInterruption(typeRaw: UInt?, optionsRaw: UInt?) {
|
||||
guard let typeRaw,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
// The system already paused our engine; wait for `.ended`.
|
||||
log.info("Audio interruption began")
|
||||
case .ended:
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
if let optionsRaw {
|
||||
shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume)
|
||||
} else {
|
||||
shouldResume = true
|
||||
}
|
||||
if shouldResume {
|
||||
log.info("Audio interruption ended — resuming capture")
|
||||
rebuildEngine()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop and rebuild the engine against the current route, keeping
|
||||
/// `isRunning` intact so the session survives the swap transparently.
|
||||
private func rebuildEngine() {
|
||||
guard isRunning else { return }
|
||||
if audioEngine.isRunning {
|
||||
audioEngine.stop()
|
||||
}
|
||||
do {
|
||||
try activateEngine()
|
||||
} catch {
|
||||
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
|
||||
Reference in New Issue
Block a user