perf(asr): speed up local Flow dictation and land CLM/keyboard refactor

Reduce perceived latency from key release to final text:
- Adaptive chunking: 2.5s first chunk + 5s follow-ups so short
  utterances start on-device recognition while still recording.
- Session-level ASR warmup and audio-format cache reuse to remove
  per-utterance cold-start of SpeechAnalyzer.
- Mirror live pipelined partials to the keyboard transcript line via
  a new flow.transcriptionPartial App Group key + Darwin ping.

Also commits the accumulated custom language model, Flow session,
keyboard extension restructure, and Xiaomi MiMo provider work in
progress on this branch.
This commit is contained in:
Rocky
2026-07-06 00:00:19 +08:00
parent cfbfb542cc
commit 537a68552a
76 changed files with 3456 additions and 121086 deletions
@@ -141,9 +141,6 @@ public struct KeyboardRootView: View {
flowSessionActive: state.flowSessionActive,
micDisabled: state.micDisabled,
micDisabledHint: state.micDisabledHint,
isLocalEngine: state.isLocalEngine,
localModelsReady: state.localModelsReady,
localModelsLoaded: state.localModelsLoaded,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
@@ -337,9 +334,6 @@ private struct TranscriptLine: View {
let flowSessionActive: Bool
let micDisabled: Bool
let micDisabledHint: String
let isLocalEngine: Bool
let localModelsReady: Bool
let localModelsLoaded: Bool
let cursorDragHintActive: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
@@ -367,22 +361,6 @@ private struct TranscriptLine: View {
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else if isLocalEngine, !localModelsReady {
Button(action: openSettings) {
HStack(spacing: 4) {
Text(ExtL10n.string("keyboard.models.notDownloaded"))
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint"))
} else if flowSessionActive {
ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption)
-187
View File
@@ -1,187 +0,0 @@
// RecordButton.swift
// OSGKeyboard · Keyboard Extension
//
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
// remaining-time countdown while recording; last 10 seconds turn red.
import SwiftUI
import OSGKeyboardShared
struct RecordButton: View {
@Environment(\.themePalette) private var palette: ThemePalette
enum Phase: Equatable {
case idle
case recording
case processing
case error
}
let phase: Phase
let level: Double // 0...1
/// Seconds left in the current utterance; shown only while recording.
let remainingSeconds: Int?
let isEnabled: Bool
let onToggle: () -> Void
@State private var breath: Bool = false
init(
phase: Phase,
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
onToggle: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.onToggle = onToggle
}
private var isUrgent: Bool {
guard phase == .recording, let remainingSeconds else { return false }
return remainingSeconds <= 10
}
/// Decorative rings are sized to stay inside the 121 pt frame applied
/// by `KeyboardRootView` so glow / breath animations are not clipped.
private enum Layout {
static let disc: CGFloat = 95
static let outerRing: CGFloat = 106
static let breathRing: CGFloat = 100
static let glow: CGFloat = 119
}
var body: some View {
ZStack {
Circle()
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.frame(width: Layout.breathRing, height: Layout.breathRing)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
Circle()
.fill(
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 46,
endRadius: 92
)
)
.frame(width: Layout.glow, height: Layout.glow)
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
.blur(radius: 18)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
Circle()
.stroke(
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
lineWidth: 0.5
)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
Circle()
.fill(discGradient)
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.blendMode(.overlay)
Group {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
case .recording:
VStack(spacing: 3) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
//
.offset(y: 3)
}
WaveformView(
level: level,
color: Color(red: 1.0, green: 0.78, blue: 0.78),
active: true
)
.frame(width: 73, height: 32)
.opacity(0.4)
.scaleEffect(0.96)
}
.transition(.opacity)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(palette.textPrimary)
.scaleEffect(1.25)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(palette.warning)
}
}
}
.frame(width: Layout.disc, height: Layout.disc)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.opacity(isEnabled ? 1 : 0.45)
.onTapGesture {
guard isEnabled, phase != .processing else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
.onChange(of: phase) { _, new in
breath = (new == .recording)
}
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
}
private func formatRemaining(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%d:%02d", m, s)
}
private var discGradient: LinearGradient {
switch phase {
case .recording:
let colors: [Color] = isUrgent
? [palette.recordRed, palette.recordRed.opacity(0.85)]
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
case .processing:
return LinearGradient(
colors: [palette.surfaceElevated, palette.surface],
startPoint: .top,
endPoint: .bottom
)
case .error:
return LinearGradient(
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
startPoint: .top,
endPoint: .bottom
)
case .idle:
return LinearGradient(
colors: [
palette.accent.opacity(0.95),
palette.accent.opacity(0.75)
],
startPoint: .top,
endPoint: .bottom
)
}
}
}
-148
View File
@@ -1,148 +0,0 @@
// TranslationChip.swift
// OSGKeyboard · Keyboard Extension
//
// Compact chip rendered to the right of `LocaleChip` on the keyboard
// top bar. Doubles as both the on/off switch and the target-language
// picker same Menu pattern as `LocaleChip` so muscle memory transfers.
//
// v0.2.1 follow-up: removed the explicit on/off toggle entry. The
// chip is now a pure picker over the 11 catalog rows (off + 10
// locales); selecting "" turns translation off, selecting any
// locale turns it on with that target. `translationEnabled` is
// derived from the locale id so the chip / pipeline read the same
// source of truth.
//
// v0.2.1 final review: dropped the "needs cloud" warning state
// both engines now run the translate-and-polish step (the local
// engine routes through DeepSeek via
// `ProviderConfig.localModeProviderId`). The chip is therefore just
// off / on, with the same accent treatment either way.
//
// Visual states:
// off dim outline, "" chip label (menu first row = "")
// on (any engine) accent fill, " EN" / " " style label
//
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar
// doesn't grow when translation is enabled.
import SwiftUI
import OSGKeyboardShared
struct TranslationChip: View, Equatable {
/// Passed in as a value (not read from `@Environment`) so the chip can
/// be wrapped in `.equatable()` at the call site: `EquatableView`
/// suppresses environment-driven refreshes, so injecting the palette
/// here keeps colours correct across dark/light switches.
let palette: ThemePalette
/// The active target-locale id (`offLocaleId` == translation off).
let targetLocaleId: String
/// Writes the picked locale id wired to `state.setTranslationTargetLocaleId`.
let onSelect: (String) -> Void
/// Only `palette` and `targetLocaleId` drive the visuals; the
/// `onSelect` closure is deliberately excluded from equality. Because
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
/// `KeyboardState`), the parent view re-renders every second. Without
/// this, SwiftUI would rebuild the `Menu` on every poll dismissing an
/// open picker or snapping its scroll position back to the top. With
/// `.equatable()` the picker is rebuilt only on a real state change.
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
var body: some View {
Menu {
// v0.2.1 follow-up: pure picker over the full catalog,
// including `offLocaleId` at the top so "turn off" is one
// tap from any enabled state. Picking a row writes
// `translationTargetLocaleId`; `translationEnabled` is
// derived from it.
ForEach(TranslationLanguageCatalog.all) { language in
Button {
onSelect(language.id)
} label: {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.translation.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint"))
}
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled))
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(background(enabled: enabled), in: Capsule())
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return ExtL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
if !enabled {
return ExtL10n.string("keyboard.translation.chip")
}
// Short form: "EN" / "" style. Falls back to the prompt
// language name for languages without a chip-style abbreviation
// (e.g. French "FR" via the 2-letter prefix).
let short = shortLabel(for: target)
return "\(short)"
}
private func shortLabel(for target: TranslationLanguage) -> String {
switch target.id {
case "en": return "EN"
case "zh-Hans": return ""
case "zh-Hant": return ""
case "ja": return ""
case "ko": return ""
case "fr": return "FR"
case "de": return "DE"
case "es": return "ES"
case "ru": return "RU"
case "pt": return "PT"
default: return target.promptLanguageName
}
}
private func foreground(enabled: Bool) -> Color {
if enabled { return palette.accent }
return palette.textPrimary
}
private func background(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.15) }
return palette.surfaceElevated
}
private func stroke(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.35) }
return palette.divider
}
}
-60
View File
@@ -1,60 +0,0 @@
// WaveformView.swift
// OSGKeyboard · Keyboard Extension
//
// Symmetric, real-time driven waveform. 18 bars centred around a vertical
// axis. The dominant bar is driven by the current RMS; surrounding bars
// decay on a small position-based curve so the visual feels like a
// horizontal speaker cone, not random noise.
import SwiftUI
import OSGKeyboardShared
struct WaveformView: View {
@Environment(\.themePalette) private var palette: ThemePalette
let level: Double // 0...1, smoothed RMS
let barCount: Int
let color: Color?
let active: Bool // when false, bars collapse to a thin resting line
init(
level: Double,
barCount: Int = 18,
color: Color? = nil,
active: Bool = true
) {
self.level = max(0, min(1, level))
self.barCount = barCount
self.color = color
self.active = active
}
private var resolvedColor: Color {
color ?? palette.recordRed
}
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
HStack(alignment: .center, spacing: 3) {
ForEach(0..<barCount, id: \.self) { i in
Capsule()
.fill(resolvedColor)
.frame(width: 2.4, height: height(for: i, time: context.date.timeIntervalSinceReferenceDate))
.opacity(active ? 1.0 : 0.45)
}
}
}
}
private func height(for index: Int, time: TimeInterval) -> CGFloat {
guard active else { return 4 }
let centre = Double(barCount - 1) / 2.0
let distance = abs(Double(index) - centre) / max(centre, 1)
// Per-bar small wobble so the line is alive but tied to level.
let phase = sin(time * 4.0 + Double(index) * 0.45)
let wobble = 0.18 * phase
let magnitude = max(0, min(1, Double(level) + wobble))
let profile = 1.0 - pow(distance, 1.4) * 0.85
return CGFloat(max(6, 32 * magnitude * profile))
}
}