feat: flow session reliability, keyboard UX, and audio pre-roll fix

Renew voice sessions while the host app stays foreground, auto-start flow from the keyboard with a Start action, and prevent leading audio loss via pre-roll buffering and faster signal polling.
This commit is contained in:
Rocky
2026-06-20 00:57:16 +08:00
parent 38a36ed504
commit db5151ad9a
12 changed files with 196 additions and 77 deletions
@@ -66,6 +66,8 @@ public final class KeyboardViewController: UIInputViewController {
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
// MARK: - Lifecycle
@@ -121,6 +123,7 @@ public final class KeyboardViewController: UIInputViewController {
state.endRecording = { [weak self] in self?.pressEnded() }
state.tapMic = { [weak self] in self?.toggleRecording() }
state.openSettings = { [weak self] in self?.openHostApp() }
state.startFlowSession = { [weak self] in self?.beginFlowStart() }
state.setMode = { [weak self] m in self?.persistMode(m) }
state.setLocale = { [weak self] l in self?.persistLocale(l) }
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
@@ -199,6 +202,24 @@ public final class KeyboardViewController: UIInputViewController {
}
}
wasFlowSessionActive = active
if !active {
maybeAutoStartFlowSession()
}
}
/// When the host session is down, proactively jump to the app to start it.
private func maybeAutoStartFlowSession() {
guard !FlowSessionBridge.isSessionActive() else { return }
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
guard state.mode != .off else { return }
guard hasFullAccess, AppGroup.isAvailable else { return }
guard case .idle = state.phase else { return }
let now = Date().timeIntervalSince1970
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
lastFlowAutoStartAttempt = now
beginFlowStart()
}
private func showFlowSessionExpiredHint() {
@@ -303,6 +324,7 @@ public final class KeyboardViewController: UIInputViewController {
}
private func beginFlowStart() {
guard !isPendingFlowStart else { return }
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
+45 -15
View File
@@ -19,7 +19,13 @@ import SwiftUI
import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 44
static let sideActionButtonSize: CGFloat = 53
static let sideActionIconSize: CGFloat = 19
static let sideSpaceBarWidth: CGFloat = 19
static let micFlankMinSpacing: CGFloat = 36
static let sideActionStackSpacing: CGFloat = 16
/// Outer inset for delete / return·space from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
}
public struct KeyboardRootView: View {
@@ -36,7 +42,6 @@ public struct KeyboardRootView: View {
/// it up.
static let totalHeight: CGFloat = 280
private static let topBarHeight: CGFloat = 38
private static let sideActionStackSpacing: CGFloat = 10
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -52,14 +57,16 @@ public struct KeyboardRootView: View {
}
.padding(.top, 4)
.padding(.bottom, 6)
// Let the system UI chrome show through by drawing no background
// of our own.
.background(Color.clear)
.background(keyboardBackground)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
}
private var keyboardBackground: Color {
colorScheme == .dark ? palette.background : Color.clear
}
// MARK: - Top bar
private var topBar: some View {
@@ -99,7 +106,8 @@ public struct KeyboardRootView: View {
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
openSettings: state.openSettings
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
)
.frame(height: 22)
@@ -117,11 +125,13 @@ public struct KeyboardRootView: View {
/// HStack vertical alignment keeps delete, mic centre, and the gap
/// between return/space on one horizontal axis.
private var micActionRow: some View {
HStack(alignment: .center, spacing: 20) {
HStack(alignment: .center, spacing: 0) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
RecordButton(
phase: buttonPhase,
level: state.level,
@@ -130,7 +140,9 @@ public struct KeyboardRootView: View {
)
.frame(width: 132, height: 132)
VStack(spacing: Self.sideActionStackSpacing) {
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
CircularToolbarButton(systemName: "return", label: "newline") {
state.insertNewline()
}
@@ -139,6 +151,7 @@ public struct KeyboardRootView: View {
}
}
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
}
@@ -191,6 +204,7 @@ private struct TranscriptLine: View {
let transcript: String
let flowSessionActive: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
var body: some View {
ZStack {
@@ -201,9 +215,18 @@ private struct TranscriptLine: View {
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
HStack(spacing: 6) {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
Button(action: startFlowSession) {
ExtL10n.text("keyboard.flow.start")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.flow.startA11y"))
}
}
case .requestingPermissions:
HStack(spacing: 6) {
@@ -267,6 +290,7 @@ private struct TranscriptLine: View {
// MARK: - Circular toolbar button
private struct CircularToolbarButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String?
@@ -294,20 +318,26 @@ private struct CircularToolbarButton: View {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: 16, height: 3)
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
.foregroundStyle(palette.textPrimary)
}
}
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
.background(palette.surfaceElevated, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
.background(sideButtonFill, in: Circle())
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
private var sideButtonFill: Color {
colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
}
}
// MARK: - Status badge
+8 -6
View File
@@ -27,12 +27,12 @@
"common.newline" = "Return";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "Audio stays on device";
"privacy.audio.body" = "Transcribed locally with Apples speech engine.";
"privacy.network.title" = "Only the polished text is sent";
"privacy.network.body" = "Sent to your chosen LLM to add structure & punctuation.";
"privacy.audio.title" = "On-device transcription";
"privacy.audio.body" = "Powered by the on-device iOS speech engine.";
"privacy.network.title" = "Automatic text polish";
"privacy.network.body" = "AI formats and polishes your text automatically.";
"privacy.universal.title" = "Works everywhere";
"privacy.universal.body" = "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.";
"privacy.universal.body" = "Chat, email, vibe coding — just speak.";
/* Home */
"home.status.ready" = "Ready";
@@ -127,7 +127,9 @@
"keyboard.tapToTalkA11y" = "Tap to talk";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Open OSGKeyboard to start voice session";
"keyboard.flow.sessionInactive" = "Voice session off";
"keyboard.flow.start" = "Start";
"keyboard.flow.startA11y" = "Start voice session";
"keyboard.flow.sessionExpired" = "Voice session ended. Open OSGKeyboard to restart.";
"keyboard.flow.startingSession" = "Starting voice session…";
"keyboard.flow.transcribing" = "Transcribing…";
@@ -27,12 +27,12 @@
"common.newline" = "换行";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "音频不出本机";
"privacy.audio.body" = "由 Apple 端侧引擎转录";
"privacy.network.title" = "仅发送润色后文字";
"privacy.network.body" = "仅向所选 LLM 发送润色后文字,用于整理结构和标点。";
"privacy.audio.title" = "支持本地转写";
"privacy.audio.body" = "支持 iOS 本地引擎转录";
"privacy.network.title" = "文字自动润色";
"privacy.network.body" = "通过 AI 自动对文字进行排版润色";
"privacy.universal.title" = "处处可用";
"privacy.universal.body" = "微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方。";
"privacy.universal.body" = "聊天、邮件、Vibe Coding 动嘴就行";
/* Home */
"home.status.ready" = "就绪";
@@ -127,7 +127,9 @@
"keyboard.tapToTalkA11y" = "点按说话";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "请打开 OSGKeyboard 启动语音会话";
"keyboard.flow.sessionInactive" = "语音会话未启动";
"keyboard.flow.start" = "启动";
"keyboard.flow.startA11y" = "启动语音会话";
"keyboard.flow.sessionExpired" = "语音会话已结束,请打开 OSGKeyboard 重新启动";
"keyboard.flow.startingSession" = "正在启动语音会话…";
"keyboard.flow.transcribing" = "识别中…";