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:
@@ -35,6 +35,9 @@ struct OSGKeyboardApp: App {
|
||||
}
|
||||
}
|
||||
.environmentObject(flowManager)
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "osgkeyboard" else { return }
|
||||
switch url.host {
|
||||
@@ -56,6 +59,7 @@ struct OSGKeyboardApp: App {
|
||||
if done { flowManager.autoStartIfNeeded() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
flowManager.setAppForeground(phase == .active)
|
||||
guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return }
|
||||
if flowManager.isActive {
|
||||
flowManager.extendSession()
|
||||
|
||||
@@ -35,6 +35,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var currentPartial = ""
|
||||
private var lastFinal = ""
|
||||
/// True while the host app scene is `.active` — drives foreground renewal.
|
||||
private var isAppForeground = false
|
||||
|
||||
init() {
|
||||
Task { @MainActor [weak self] in
|
||||
@@ -169,6 +171,24 @@ final class FlowSessionManager: ObservableObject {
|
||||
scheduleExpiry(after: duration)
|
||||
}
|
||||
|
||||
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
||||
func setAppForeground(_ foreground: Bool) {
|
||||
isAppForeground = foreground
|
||||
if foreground, isActive {
|
||||
renewSessionIfNeededWhileForeground()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend the session before it expires while the host app stays in foreground.
|
||||
private func renewSessionIfNeededWhileForeground() {
|
||||
guard isActive, isAppForeground else { return }
|
||||
guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return }
|
||||
let threshold = FlowSessionKeys.defaultSessionDuration * 0.25
|
||||
guard remaining < threshold else { return }
|
||||
extendSession()
|
||||
debug("Flow session renewed in foreground (\(Int(threshold))s threshold)")
|
||||
}
|
||||
|
||||
// MARK: - Session start
|
||||
|
||||
private func startSessionAsync(duration: TimeInterval) async {
|
||||
@@ -217,7 +237,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
pollingTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
self?.handleKeyboardSignal()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +444,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
heartbeatTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
self?.renewSessionIfNeededWhileForeground()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard self?.isActive == true else { break }
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ struct HomeView: View {
|
||||
|
||||
engineStatusLine
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.md)
|
||||
.padding(.bottom, Spacing.xs)
|
||||
.padding(.top, Spacing.xl)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ struct HomeView: View {
|
||||
Image("osglogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 34)
|
||||
.frame(width: 144, height: 41)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
statusCapsule
|
||||
@@ -92,13 +92,6 @@ struct HomeView: View {
|
||||
|
||||
private var statusCapsule: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(statusLine)
|
||||
.font(TypeStyle.status)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize()
|
||||
|
||||
capsuleDivider
|
||||
|
||||
flowCapsuleSegment
|
||||
|
||||
if flowManager.isActive {
|
||||
@@ -125,12 +118,6 @@ struct HomeView: View {
|
||||
.animation(Motion.soft, value: flowManager.isActive)
|
||||
}
|
||||
|
||||
private var capsuleDivider: some View {
|
||||
Circle()
|
||||
.fill(palette.dividerStrong)
|
||||
.frame(width: 3, height: 3)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var flowCapsuleSegment: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
@@ -165,12 +152,6 @@ struct HomeView: View {
|
||||
: palette.surface.opacity(0.88)
|
||||
}
|
||||
|
||||
private var statusLine: String {
|
||||
config.isConfigured
|
||||
? NSLocalizedString("home.status.ready", comment: "")
|
||||
: NSLocalizedString("home.status.setupIncomplete", comment: "")
|
||||
}
|
||||
|
||||
// MARK: - Flow extras (warnings / hint)
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -184,6 +184,11 @@ private struct OnboardingHeroIcon: View {
|
||||
}
|
||||
}
|
||||
|
||||
private enum OnboardingLayoutMetrics {
|
||||
/// Matches welcome page: logo → tagline, and subtitle → next block.
|
||||
static let heroTextGap: CGFloat = Spacing.hero
|
||||
}
|
||||
|
||||
// MARK: - Welcome
|
||||
|
||||
private struct WelcomePage: View {
|
||||
@@ -209,11 +214,11 @@ private struct WelcomePage: View {
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.hero)
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
PrivacyFootnote()
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.hero)
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
if let url = LegalLinks.privacyPolicyURL {
|
||||
Link(destination: url) {
|
||||
@@ -388,12 +393,12 @@ private struct PermissionPageLayout: View {
|
||||
var deniedHint: LocalizedStringKey? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
|
||||
|
||||
VStack(spacing: Spacing.sm) {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Text(title)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -403,6 +408,7 @@ private struct PermissionPageLayout: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(statusColor).frame(width: 8, height: 8)
|
||||
@@ -410,6 +416,7 @@ private struct PermissionPageLayout: View {
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
if let deniedHint {
|
||||
Text(deniedHint)
|
||||
@@ -417,6 +424,7 @@ private struct PermissionPageLayout: View {
|
||||
.foregroundStyle(palette.warning)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.md)
|
||||
}
|
||||
|
||||
VStack(spacing: Spacing.sm) {
|
||||
@@ -436,6 +444,7 @@ private struct PermissionPageLayout: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.xl)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -448,12 +457,12 @@ private struct EnableKeyboardPage: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40)
|
||||
|
||||
VStack(spacing: Spacing.sm) {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Text("onboarding.enable.title")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -463,6 +472,7 @@ private struct EnableKeyboardPage: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
}
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
|
||||
@@ -472,7 +482,7 @@ private struct EnableKeyboardPage: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
Button {
|
||||
AppPermissions.openSystemSettings()
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
"onboarding.welcome.subtitle" = "Your open-source voice keyboard";
|
||||
"onboarding.enable.fullAccessNote" = "Full Access lets the keyboard reach the microphone and your LLM API. We never log what you type.";
|
||||
"onboarding.permission.mic.title" = "Microphone access";
|
||||
"onboarding.permission.mic.body" = "Required for voice input. Audio is processed on your device for transcription.";
|
||||
"onboarding.permission.mic.body" = "Voice input requires your permission to use the microphone.";
|
||||
"onboarding.permission.mic.allow" = "Allow microphone";
|
||||
"onboarding.permission.mic.deniedHint" = "Microphone was denied. Open Settings to enable it, or tap Next to continue.";
|
||||
"onboarding.permission.speech.title" = "Speech recognition";
|
||||
"onboarding.permission.speech.body" = "On-device speech recognition turns your voice into text. Audio never leaves your device.";
|
||||
"onboarding.permission.speech.body" = "On-device speech recognition turns your voice into text.";
|
||||
"onboarding.permission.speech.allow" = "Allow speech recognition";
|
||||
"onboarding.permission.speech.deniedHint" = "Speech recognition was denied. Open Settings to enable it, or tap Next to continue.";
|
||||
"onboarding.permission.openSettings" = "Open Settings";
|
||||
@@ -43,12 +43,12 @@
|
||||
"common.newline" = "Return";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "Audio stays on device";
|
||||
"privacy.audio.body" = "Transcribed locally with Apple’s 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";
|
||||
@@ -184,7 +184,7 @@
|
||||
|
||||
/* Home · Flow session */
|
||||
"home.flow.active" = "Voice session active";
|
||||
"home.flow.label" = "Voice session";
|
||||
"home.flow.label" = "Ready";
|
||||
"home.flow.inactive" = "Voice session inactive";
|
||||
"home.flow.hint" = "Voice session starts automatically. Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.flow.starting" = "Starting voice session…";
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
"onboarding.welcome.subtitle" = "你的开源语音输入法";
|
||||
"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。";
|
||||
"onboarding.permission.mic.title" = "麦克风权限";
|
||||
"onboarding.permission.mic.body" = "语音输入需要麦克风。音频在设备端转录,不会上传原始录音。";
|
||||
"onboarding.permission.mic.body" = "语音输入需要您授予麦克风权限";
|
||||
"onboarding.permission.mic.allow" = "允许麦克风";
|
||||
"onboarding.permission.mic.deniedHint" = "麦克风被拒绝。可前往设置开启,或点「下一步」继续。";
|
||||
"onboarding.permission.speech.title" = "语音识别权限";
|
||||
"onboarding.permission.speech.body" = "端侧语音识别将语音转为文字。音频不会离开你的设备。";
|
||||
"onboarding.permission.speech.body" = "端侧语音识别将语音转为文字。";
|
||||
"onboarding.permission.speech.allow" = "允许语音识别";
|
||||
"onboarding.permission.speech.deniedHint" = "语音识别被拒绝。可前往设置开启,或点「下一步」继续。";
|
||||
"onboarding.permission.openSettings" = "打开设置";
|
||||
@@ -43,12 +43,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" = "就绪";
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
/* Home · Flow session */
|
||||
"home.flow.active" = "语音会话进行中";
|
||||
"home.flow.label" = "语音会话";
|
||||
"home.flow.label" = "就绪";
|
||||
"home.flow.inactive" = "语音会话未启动";
|
||||
"home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。";
|
||||
"home.flow.starting" = "正在启动语音会话…";
|
||||
|
||||
Reference in New Issue
Block a user