feat: harden Flow cold-start/force-quit and polish macOS dictation UX

Fix cold-start overlay recursion that overflowed the main-thread stack when
recording began while the ready overlay was still up; also remove temporary
on-screen Flow DEBUG panels after the orange-mic investigation, and land the
macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
+109 -69
View File
@@ -1,7 +1,8 @@
// DashboardView.swift
// OSGKeyboard · Mac
//
// Primary workspace: session stats, dictation canvas, floating record bar.
// Primary workspace: brand voice, asymmetric stats, dictation stage, and
// the record bar. History lives on its own page no duplicate list here.
import SwiftUI
@@ -20,33 +21,24 @@ struct DashboardView: View {
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
}
// Four equal-width columns same metrics as the iOS home stats card.
private let columns = Array(
repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md),
count: 4
)
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
if let appName = viewModel.foregroundAppName {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
}
statGrid
dictationCanvas
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.lg)
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
dictationStage
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic bar
// so spacing stays balanced at any window size.
Spacer(minLength: Spacing.xs)
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.sm)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
Spacer(minLength: Spacing.xs)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
@@ -54,18 +46,41 @@ struct DashboardView: View {
}
}
private var statGrid: some View {
LazyVGrid(columns: columns, spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform",
accent: true
)
// MARK: - Hero
private var heroHeader: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(MacL10n.string("mac.brand.tagline", language: lang))
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.85)
HStack(spacing: Spacing.sm) {
Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
if let appName = viewModel.foregroundAppName {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
.lineLimit(1)
}
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Stats (hero word count, full width but content-height
// never stretched to match a taller sibling and left with dead air)
private var statCluster: some View {
VStack(spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.words", language: lang),
value: UsageStatisticsStore.formatCount(
@@ -73,28 +88,44 @@ struct DashboardView: View {
language: lang
),
caption: MacL10n.string("mac.stat.transcribed", language: lang),
systemImage: "text.alignleft"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
systemImage: "text.alignleft",
accent: true,
prominent: true
)
HStack(spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
)
}
}
}
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
// MARK: - Dictation stage
private var dictationStage: some View {
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty {
Text(
@@ -102,24 +133,37 @@ struct DashboardView: View {
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.font(.system(size: 22, weight: .light))
.foregroundStyle(palette.textTertiary)
.contentTransition(.opacity)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
}
}
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
}
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.isRecording)
}
}
// MARK: - Floating record bar
@@ -142,12 +186,10 @@ struct BottomDictationBar: View {
recordControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.padding(.vertical, Spacing.xs)
// No surface fill the mic bar sits on the page background so Home
// stays flat and the canvas above can stay shorter without a second
// floating card competing for height.
}
private var readinessChip: some View {
@@ -189,15 +231,14 @@ struct BottomDictationBar: View {
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
.background(palette.surfaceElevated, in: Capsule())
}
.menuStyle(.borderlessButton)
.fixedSize()
}
//
//
// Option
// Mic stays geometrically centred: waveform lives inside the button,
// press stop floats above neither participates in layout.
private var recordControl: some View {
recordButton
.overlay(alignment: .top) {
@@ -225,7 +266,6 @@ struct BottomDictationBar: View {
)
Group {
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
+71 -2
View File
@@ -11,11 +11,16 @@
final class MacAudioRecorder: @unchecked Sendable {
enum RecorderError: Error, LocalizedError {
case converterUnavailable
case microphoneAccessDenied
var errorDescription: String? {
switch self {
case .converterUnavailable:
return "无法初始化音频转换器 / Failed to initialize audio converter"
case .microphoneAccessDenied:
return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用"
+ " / Microphone access denied — enable it in System Settings"
+ " → Privacy & Security → Microphone"
}
}
}
@@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable {
private var converter: AVAudioConverter?
private let lock = NSLock()
private var samples: [Float] = []
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var isRunning = false
/// Hard cap on accumulated audio: 10 minutes @16 kHz 38 MB of Float32.
/// Recording is push-to-talk, but a stuck hotkey (or a latched Option
/// key) would otherwise grow this buffer without bound; past the cap we
/// keep the newest audio (drop from the front) so the take still ends
/// with what the user last said.
private static let maxSampleCount = 10 * 60 * 16_000
/// Trim hysteresis: dropping from the front is an O(n) memmove of the
/// whole ~38 MB buffer, done under the same lock the UI's level poll
/// takes doing it on EVERY tap callback once capped would stall the
/// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the
/// whole excess in one move instead.
private static let trimHysteresisSamples = 30 * 16_000
private var smoothedLevel: Float = 0
/// One-shot flag for the converter pull block. Taps are serialized per
/// bus, so a plain instance property (not a captured local) is safe here.
@@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable {
lock.withLock { smoothedLevel }
}
func start() throws {
lock.withLock { samples.removeAll(keepingCapacity: true) }
/// Resolves microphone authorization before capture. Prompts on first
/// use; throws `microphoneAccessDenied` once the user has declined so
/// failures surface as a permission problem, not an empty transcription.
private static func ensureMicrophoneAccess() async throws {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return
case .notDetermined:
guard await AVCaptureDevice.requestAccess(for: .audio) else {
throw RecorderError.microphoneAccessDenied
}
case .denied, .restricted:
throw RecorderError.microphoneAccessDenied
@unknown default:
throw RecorderError.microphoneAccessDenied
}
}
func start() async throws {
try await Self.ensureMicrophoneAccess()
try startEngine()
}
/// Live 16 kHz mono snapshots for streaming ASR while the mic is open.
/// The stream is finished automatically in `stop()`.
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
lock.withLock {
snapshotContinuation?.finish()
snapshotContinuation = continuation
}
continuation.onTermination = { [weak self] _ in
self?.lock.withLock {
self?.snapshotContinuation = nil
}
}
}
}
private func startEngine() throws {
lock.withLock {
samples.removeAll(keepingCapacity: true)
snapshotContinuation?.finish()
snapshotContinuation = nil
}
let input = engine.inputNode
let inputFormat = input.outputFormat(forBus: 0)
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
engine.stop()
isRunning = false
return lock.withLock {
snapshotContinuation?.finish()
snapshotContinuation = nil
let out = samples
samples.removeAll(keepingCapacity: false)
return out
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
lock.withLock {
samples.append(contentsOf: chunk)
if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples {
samples.removeFirst(samples.count - Self.maxSampleCount)
}
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
smoothedLevel += (normalized - smoothedLevel) * factor
snapshotContinuation?.yield(
AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)
)
}
}
}
@@ -0,0 +1,55 @@
// MacCloudASRChunkAdapter.swift
// OSGKeyboard · Mac
//
// Adapts configured cloud ASR clients to the shared chunked utterance pipeline.
import Foundation
import os
final class MacCloudASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
private let store: AppGroupStore
private let client: CloudASRTranscribing
private let cancelled = OSAllocatedUnfairLock(initialState: false)
init(store: AppGroupStore) throws {
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else {
throw MacDictationError.providerHasNoCloudASR
}
self.store = store
self.client = CloudASRClientFactory.make(store: store)
}
func prepare() async throws {
try await client.prepare(dictionary: store.personalDictionary)
}
func resetForNewUtterance() {
cancelled.withLock { $0 = false }
}
func cancel() {
cancelled.withLock { $0 = true }
}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
let isCancelled = cancelled.withLock { $0 }
if isCancelled || Task.isCancelled { return .cancelled }
guard !samples.isEmpty else { return .success("") }
do {
let text = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
dictionary: store.personalDictionary
)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return .success(trimmed)
} catch is CancellationError {
return .cancelled
} catch {
return .failure(error.localizedDescription)
}
}
}
+185 -38
View File
@@ -25,6 +25,22 @@ enum MacMetrics {
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Horizontal inset for page titles and scroll *content* (cards).
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
/// window edge; only the content inside is inset.
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
/// Built-in horizontal inset macOS grouped `Form` adds around its section
/// cards, on top of any padding we apply. Subtracted from
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
/// on `pageHorizontalInset` matching the History page and the page title.
static let groupedFormSectionInset: CGFloat = Spacing.lg
/// Default (= minimum) main-window size. Opening the app uses this size;
/// the window cannot shrink below it.
static let windowMinWidth: CGFloat = 860
static let windowMinHeight: CGFloat = 600
/// Compact dictation-canvas height so Home fits the min window without scrolling.
static let dictationCanvasMinHeight: CGFloat = 120
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
@@ -81,16 +97,67 @@ extension View {
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
}
// MARK: - Page header
/// Page title for History / Dictionary / Settings. Applies the shared
/// `pageHorizontalInset` so its left edge matches inset card content below.
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
struct MacPageHeader<Trailing: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
@ViewBuilder var trailing: () -> Trailing
init(
title: String,
subtitle: String? = nil,
@ViewBuilder trailing: @escaping () -> Trailing
) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
}
Spacer(minLength: 0)
trailing()
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
}
}
extension MacPageHeader where Trailing == EmptyView {
init(title: String, subtitle: String? = nil) {
self.init(title: title, subtitle: subtitle) { EmptyView() }
}
}
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.padding(padding)
@@ -111,34 +178,82 @@ struct StatCard: View {
let caption: String
var systemImage: String?
var accent: Bool = false
/// Hero metric: wide horizontal layout that uses full-card width without
/// stretching to fill dead vertical space used for the primary word count.
var prominent: Bool = false
var body: some View {
MacCard {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
}
MacCard(padding: prominent ? Spacing.md : Spacing.md) {
if prominent {
prominentBody
} else {
compactBody
}
}
}
private var compactBody: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
.symbolRenderingMode(.hierarchical)
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Wide "hero bar" layout: icon badge + title/caption on the left, the
/// big number anchored right fills the full card width edge-to-edge
/// instead of a tall card with empty space below a small number.
private var prominentBody: some View {
HStack(spacing: Spacing.md) {
if let systemImage {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 44, height: 44)
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: Spacing.md)
Text(value)
.font(.system(size: 34, weight: .bold))
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.6)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
}
}
}
@@ -152,30 +267,39 @@ struct MiniWaveform: View {
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
var maxBarHeight: CGFloat = 22
var barWidth: CGFloat = 3
var barSpacing: CGFloat = 3
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 3) {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: 3, height: barHeight(index))
.frame(width: barWidth, height: barHeight(index))
}
}
.frame(height: 22)
.frame(height: maxBarHeight)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
// Stronger level coupling + staggered phase so the meter reads as
// "alive" even at modest mic levels.
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
let base = boosted * maxBarHeight
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
+ (maxBarHeight * 0.12)
let parity = 0.55 + CGFloat(index % 3) * 0.2
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
}
}
@@ -195,8 +319,8 @@ enum MacTranslationDisplay {
// MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and
/// the connection state icons and wording mirror the dashboard record bar.
/// Bottom status strip: engine mode, translation target, connection
/// kept visually quiet so it never competes with the record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@@ -204,7 +328,7 @@ struct MacStatusFooter: View {
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.md) {
HStack(spacing: Spacing.sm) {
Spacer()
Label(
viewModel.isCloudMode
@@ -212,24 +336,47 @@ struct MacStatusFooter: View {
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
.foregroundStyle(palette.accent.opacity(0.85))
}
.font(TypeStyle.caption)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
}
}
// MARK: - Form alignment
private struct MacFormPageAlignModifier: ViewModifier {
func body(content: Content) -> some View {
// Form stays full-bleed (scrollbar on the window edge). Section
// cards are inset to match `MacPageHeader`.
content
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
}
}
extension View {
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
func macFormPageAligned() -> some View {
modifier(MacFormPageAlignModifier())
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ struct MacContentView: View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
recordButton
Text(MacL10n.string("mac.hint.holdOption", language: lang))
Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -0,0 +1,222 @@
// MacDictationOverlayController.swift
// OSGKeyboard · Mac
//
// Owns a borderless, non-activating floating NSPanel that hosts the
// dictation HUD. Shown for any recording path (hotkey, menu bar, main
// window) and dismissed after a short success beat when processing ends.
import AppKit
import Combine
import SwiftUI
@MainActor
final class MacDictationOverlayController {
static let shared = MacDictationOverlayController()
private var panel: NSPanel?
private var hosting: NSHostingView<AnyView>?
private var cancellables = Set<AnyCancellable>()
private var hideWorkItem: DispatchWorkItem?
/// Keeps the pill visible briefly after a successful delivery.
private var showingCompletion = false
private var wasBusy = false
private let bottomMargin: CGFloat = 36
private let fallbackSize = NSSize(width: 400, height: 52)
private init() {}
func start(observing viewModel: MacDictationViewModel) {
guard cancellables.isEmpty else { return }
Publishers.CombineLatest3(
viewModel.$isRecording,
viewModel.$isPreparingToRecord,
viewModel.$isProcessing
)
.receive(on: RunLoop.main)
.sink { [weak self] recording, preparing, processing in
self?.handleBusyChange(
recording: recording,
preparing: preparing,
processing: processing,
viewModel: viewModel
)
}
.store(in: &cancellables)
// Keep waveform / app name / copy fresh while visible.
viewModel.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self, self.panel?.isVisible == true else { return }
self.refreshContent(viewModel: viewModel)
self.resizeToFit()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.reposition() }
.store(in: &cancellables)
}
private func handleBusyChange(
recording: Bool,
preparing: Bool,
processing: Bool,
viewModel: MacDictationViewModel
) {
let busy = recording || preparing || processing
if busy {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
wasBusy = true
present(viewModel: viewModel)
return
}
// Transition: busy idle. Flash a short "done" state, then hide.
if wasBusy {
wasBusy = false
showingCompletion = true
present(viewModel: viewModel)
scheduleHide()
return
}
if !showingCompletion {
hideImmediately()
}
}
private func present(viewModel: MacDictationViewModel) {
ensurePanel(viewModel: viewModel)
refreshContent(viewModel: viewModel)
resizeToFit()
reposition()
guard let panel else { return }
if panel.isVisible {
// Already up still bump to front in case another space stole it.
panel.orderFrontRegardless()
return
}
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.22
panel.animator().alphaValue = 1
}
}
private func ensurePanel(viewModel: MacDictationViewModel) {
if panel != nil { return }
let host = NSHostingView(rootView: makeRoot(viewModel: viewModel))
host.frame = NSRect(origin: .zero, size: fallbackSize)
hosting = host
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: fallbackSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.contentView = host
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
// Above normal floating windows so the HUD stays visible over browsers /
// full-screen apps, without going as high as the screen saver.
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
panel.isMovableByWindowBackground = false
panel.hidesOnDeactivate = false
panel.ignoresMouseEvents = false
panel.becomesKeyOnlyIfNeeded = true
self.panel = panel
}
private func refreshContent(viewModel: MacDictationViewModel) {
hosting?.rootView = makeRoot(viewModel: viewModel)
}
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
AnyView(
MacDictationOverlayView(viewModel: viewModel)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
)
}
private func resizeToFit() {
guard let panel, let hosting else { return }
hosting.layoutSubtreeIfNeeded()
let fitting = hosting.fittingSize
let width = fitting.width.isFinite && fitting.width > 1
? min(max(fitting.width, 300), 520)
: fallbackSize.width
let height = fitting.height.isFinite && fitting.height > 1
? max(fitting.height, fallbackSize.height)
: fallbackSize.height
var frame = panel.frame
let midX = frame.midX
frame.size = NSSize(width: width, height: height)
if midX.isFinite {
frame.origin.x = midX - width / 2
}
panel.setFrame(frame, display: true)
hosting.frame = NSRect(origin: .zero, size: frame.size)
}
private func reposition() {
guard let panel else { return }
let screen = NSScreen.main ?? NSScreen.screens.first
guard let visible = screen?.visibleFrame else { return }
let size = panel.frame.size
let origin = NSPoint(
x: visible.midX - size.width / 2,
y: visible.minY + bottomMargin
)
panel.setFrameOrigin(origin)
}
private func scheduleHide() {
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.showingCompletion = false
self?.hideAnimated()
}
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work)
}
private func hideImmediately() {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
panel?.orderOut(nil)
panel?.alphaValue = 1
}
private func hideAnimated() {
guard let panel, panel.isVisible else {
hideImmediately()
return
}
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.2
panel.animator().alphaValue = 0
}, completionHandler: { [weak self] in
Task { @MainActor in
self?.panel?.orderOut(nil)
self?.panel?.alphaValue = 1
}
})
}
}
@@ -0,0 +1,170 @@
// MacDictationOverlayView.swift
// OSGKeyboard · Mac
//
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
// non-activating NSPanel so it never steals focus from the front app.
// One-line layout: status / live transcript preview + waveform + stop.
import SwiftUI
struct MacDictationOverlayView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var isBusy: Bool {
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
}
/// Trimmed live / final transcript for the single-line preview.
private var previewText: String {
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasPreview: Bool { !previewText.isEmpty }
private var showsLiveBadge: Bool {
viewModel.isRecording && viewModel.isStreamingPartial
}
var body: some View {
HStack(spacing: Spacing.sm) {
statusDot
primaryLine
Spacer(minLength: Spacing.xs)
trailingControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, 11)
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
.fixedSize(horizontal: true, vertical: true)
.background(palette.surface, in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
.padding(2)
.animation(Motion.soft, value: hasPreview)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
// MARK: - Primary line (status or one-line transcript)
@ViewBuilder
private var primaryLine: some View {
if hasPreview {
HStack(spacing: 6) {
if showsLiveBadge {
liveBadge
}
Text(previewText)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
.contentTransition(.opacity)
.animation(Motion.quick, value: previewText)
.accessibilityLabel(previewText)
}
} else {
HStack(spacing: 6) {
Text(statusText)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.contentTransition(.opacity)
if let appName = viewModel.foregroundAppName, isBusy {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(appName)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
.animation(Motion.quick, value: statusText)
}
}
private var liveBadge: some View {
Text(MacL10n.string("mac.overlay.live", language: lang))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.recordRed)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
.accessibilityHidden(true)
}
private var statusDot: some View {
Circle()
.fill(dotColor)
.frame(width: 8, height: 8)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
private var dotColor: Color {
if viewModel.isRecording {
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
}
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
return palette.accent
}
private var statusText: String {
if viewModel.isRecording {
return MacL10n.string("mac.overlay.listening", language: lang)
}
if viewModel.isPreparingToRecord {
return MacL10n.string("mac.overlay.preparing", language: lang)
}
if viewModel.isProcessing {
if viewModel.isStreamingPartial {
return MacL10n.string("mac.overlay.polishing", language: lang)
}
return MacL10n.string("mac.overlay.transcribing", language: lang)
}
return MacL10n.string("mac.overlay.done", language: lang)
}
@ViewBuilder
private var trailingControl: some View {
if viewModel.isRecording {
MiniWaveform(
level: viewModel.audioLevel,
barCount: 7,
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
.opacity(0.9),
maxBarHeight: 28,
barWidth: 3.5,
barSpacing: 2.5
)
stopButton
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
private var stopButton: some View {
Button(action: viewModel.toggleRecording) {
Image(systemName: "stop.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textOnAccent)
.frame(width: 28, height: 28)
.background(palette.recordRed, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
}
}
+245 -34
View File
@@ -1,9 +1,7 @@
// MacDictationPipeline.swift
// OSGKeyboard · Mac
//
// Dictation pipeline: samples ASR (cloud or local) polish.
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
// with Apple Speech fallback when weights are missing.
// Dictation pipeline: samples ASR (cloud or local, chunked when long) polish.
import Foundation
@@ -24,39 +22,60 @@ enum MacDictationError: Error, LocalizedError {
}
}
/// Outcome of ASR that ran while the microphone was still open.
struct MacLiveASRCaptureResult: Sendable {
let raw: String
let chunkWarning: String?
let localBias: LocalASRBiasPayload?
/// When true, callers should fall back to batch ASR on the recorded samples.
let shouldFallbackToBatch: Bool
}
enum MacDictationPipeline {
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
/// First-chunk threshold: longer local utterances use pipelined chunk ASR.
private static let chunkedLocalThresholdSamples = Int(
FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000
)
/// Whether the active engine can surface `onPartial` text while recording.
static func supportsLivePartials(store: AppGroupStore) -> Bool {
if store.engineMode == "local" { return true }
return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback
}
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
static func run(
samples: [Float],
store: AppGroupStore,
onPartial: (@Sendable (String) -> Void)? = nil
) async throws -> MacDictationResult {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let locale = resolvedLocale(store: store)
var chunkWarning: String?
let raw: String
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
localBias = resolveLocalBias(store: store, locale: locale)
if samples.count > chunkedLocalThresholdSamples {
let chunked = try await transcribeLocalChunked(
samples: samples,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
bias: localBias,
onPartial: onPartial
)
)
localBias = bias
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
raw = chunked.text
chunkWarning = chunked.chunkWarning
} else {
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: localBias
)
}
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
let client = CloudASRClientFactory.make(store: store)
@@ -69,6 +88,85 @@ enum MacDictationPipeline {
)
}
return try await polishCapturedASR(
raw: raw,
store: store,
localBias: localBias,
chunkWarning: chunkWarning
)
}
/// Consumes a live mic snapshot stream until finished; yields stitched partials.
static func captureLive(
stream: AsyncStream<AudioBufferSnapshot>,
store: AppGroupStore,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
let locale = resolvedLocale(store: store)
let localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
localBias = resolveLocalBias(store: store, locale: locale)
} else {
localBias = nil
}
do {
let adapter = try makeChunkASRAdapter(
store: store,
locale: locale,
bias: localBias
)
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
try? await cloudAdapter.prepare()
}
let pipeline = ChunkedUtterancePipeline(
asr: adapter,
locale: locale,
config: .flowDefault
)
let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial)
switch outcome {
case .success(let success):
return MacLiveASRCaptureResult(
raw: success.text,
chunkWarning: success.chunkWarnings.first,
localBias: localBias,
shouldFallbackToBatch: false
)
case .failure:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
case .cancelled:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
}
} catch {
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
}
}
/// Polish-only step after live or batch ASR has produced raw text.
static func polishCapturedASR(
raw: String,
store: AppGroupStore,
localBias: LocalASRBiasPayload?,
chunkWarning: String?
) async throws -> MacDictationResult {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
@@ -91,14 +189,127 @@ enum MacDictationPipeline {
polishContext = nil
}
if let polished = try? await PolishingService(store: store).polish(
postASR,
mode: store.polishModeForPipeline,
context: polishContext
),
!polished.isEmpty {
return polished
do {
let polished = try await PolishingService(store: store).polish(
postASR,
mode: store.polishModeForPipeline,
context: polishContext
)
guard !polished.isEmpty else {
throw PolishingService.PolishError.noTranscript
}
return MacDictationResult(
text: polished,
polishWarning: nil,
chunkWarning: chunkWarning
)
} catch {
let delivery = TranscriptionPolishFallback.makeDelivery(
rawText: postASR,
error: error,
engineMode: store.engineMode,
chunkWarning: chunkWarning
)
return MacDictationResult(
text: delivery.text,
polishWarning: delivery.polishWarning,
chunkWarning: nil
)
}
}
// MARK: - Chunked local ASR
private struct ChunkedLocalResult {
let text: String
let chunkWarning: String?
}
private static func resolvedLocale(store: AppGroupStore) -> Locale {
Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
}
private static func resolveLocalBias(
store: AppGroupStore,
locale: Locale
) -> LocalASRBiasPayload? {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
)
)
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
return bias
}
private static func makeChunkASRAdapter(
store: AppGroupStore,
locale: Locale,
bias: LocalASRBiasPayload?
) throws -> any ASRChunkTranscribing {
if store.engineMode == "local" {
return MacLocalASRChunkAdapter(locale: locale, bias: bias)
}
return try MacCloudASRChunkAdapter(store: store)
}
private static func transcribeLocalChunked(
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload?,
onPartial: (@Sendable (String) -> Void)?
) async throws -> ChunkedLocalResult {
let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias)
let pipeline = ChunkedUtterancePipeline(
asr: adapter,
locale: locale,
config: .flowDefault
)
let outcome = await pipeline.transcribe(
stream: audioStream(from: samples),
onPartial: { partial in
onPartial?(partial)
}
)
switch outcome {
case .success(let success):
let warning = success.chunkWarnings.first
return ChunkedLocalResult(text: success.text, chunkWarning: warning)
case .failure(let message):
throw MacLocalASRError.qwen3InferenceFailed(message)
case .cancelled:
throw MacLocalASRError.qwen3InferenceFailed("Cancelled")
}
}
/// Feeds recorded PCM into the chunker as if it arrived incrementally.
private static func audioStream(
from samples: [Float],
sliceSamples: Int = 8_000
) -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
var offset = 0
while offset < samples.count {
let end = min(offset + sliceSamples, samples.count)
continuation.yield(
AudioBufferSnapshot(
samples: Array(samples[offset..<end]),
sampleRate: 16_000
)
)
offset = end
}
continuation.finish()
}
return postASR
}
}
+12
View File
@@ -0,0 +1,12 @@
// MacDictationResult.swift
// OSGKeyboard · Mac
import Foundation
struct MacDictationResult: Sendable, Equatable {
let text: String
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
let polishWarning: String?
/// Non-fatal per-chunk ASR issues from long utterance chunking.
let chunkWarning: String?
}
+197 -25
View File
@@ -28,7 +28,7 @@ enum MacSection: String, CaseIterable, Identifiable {
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .dashboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
@@ -45,7 +45,13 @@ final class MacDictationViewModel: ObservableObject {
@Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false
/// True while microphone permission / engine start is in flight.
/// Drives the overlay so the HUD appears on Option-down immediately,
/// instead of waiting for the async `beginRecording` to finish.
@Published private(set) var isPreparingToRecord = false
@Published var isProcessing = false
/// True once live ASR has surfaced at least one partial during this take.
@Published private(set) var isStreamingPartial = false
@Published var transcript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@@ -55,6 +61,7 @@ final class MacDictationViewModel: ObservableObject {
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@Published var hotkeyTrigger: MacHotkeyTrigger
@Published var config: ProviderConfig
@@ -64,6 +71,12 @@ final class MacDictationViewModel: ObservableObject {
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>()
/// In-flight `beginRecording` started by the hotkey cancelled if the
/// key is released before the engine is ready (avoids a stuck session).
private var hotkeyBeginTask: Task<Void, Never>?
/// Live chunked ASR while recording (cloud / supported local paths).
/// Finished in `finishRecording` so partials can become the final draft.
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
let usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared
@@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject {
private enum StoredKeys {
static let autoPaste = "mac.autoPasteEnabled"
static let hotkey = "mac.hotkeyEnabled"
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
}
init(defaults: UserDefaults = .standard) {
@@ -79,6 +93,9 @@ final class MacDictationViewModel: ObservableObject {
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
self.hotkeyTrigger = MacHotkeyTrigger(
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
) ?? .rightOption
MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
@@ -114,10 +131,16 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Derived
var polishSelectableProviders: [LLMProvider] {
LLMProvider.userSelectablePresets
}
var asrSelectableProviders: [LLMProvider] {
LLMProvider.asrSelectablePresets
}
var selectableProviders: [LLMProvider] {
LLMProvider.presets.filter {
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
}
asrSelectableProviders
}
var dictionaryTermCount: Int {
@@ -185,6 +208,12 @@ final class MacDictationViewModel: ObservableObject {
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
}
func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) {
hotkeyTrigger = trigger
defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger)
hotkeyService.trigger = trigger
}
func setEngineMode(_ mode: String) {
config.engineMode = mode
}
@@ -192,23 +221,45 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Recording
func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() }
if isRecording || isPreparingToRecord {
cancelOrFinishRecording()
} else {
Task { await beginRecording() }
}
}
func beginRecording() {
guard !isProcessing else { return }
func beginRecording() async {
guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
isPreparingToRecord = true
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
do {
try recorder.start()
try await recorder.start()
// Hotkey may have been released while we awaited mic permission /
// engine start abandon cleanly instead of latching a stuck session.
isPreparingToRecord = false
if Task.isCancelled {
_ = recorder.stop()
return
}
isRecording = true
transcript = ""
isStreamingPartial = false
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
startLiveCaptureIfSupported(store: store)
// Tiny race: Option released between the cancel check and
// `isRecording = true`. Treat it as end-of-hold and finish.
if Task.isCancelled {
finishRecording()
}
} catch {
statusMessage = error.localizedDescription
isPreparingToRecord = false
if !Task.isCancelled {
statusMessage = error.localizedDescription
}
}
}
@@ -216,49 +267,166 @@ final class MacDictationViewModel: ObservableObject {
guard isRecording else { return }
isRecording = false
isProcessing = true
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
let hadLivePartial = isStreamingPartial
statusMessage = MacL10n.string(
hadLivePartial ? "mac.status.polishing" : "mac.status.transcribing",
language: config.uiLanguage
)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
let liveTask = liveCaptureTask
liveCaptureTask = nil
Task { [weak self] in
guard let self else { return }
do {
let text = try await MacDictationPipeline.run(samples: samples, store: store)
self.transcript = text
let pasted = try self.deliver(text)
self.recordUsage(for: text)
self.speechHistory.append(text: text)
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
let result: MacDictationResult
if let liveTask {
let capture = await liveTask.value
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
if self.transcript.isEmpty {
self.transcript = trimmedLive
}
result = try await MacDictationPipeline.polishCapturedASR(
raw: capture.raw,
store: store,
localBias: capture.localBias,
chunkWarning: capture.chunkWarning
)
} else {
result = try await MacDictationPipeline.run(
samples: samples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
}
}
)
}
} else {
result = try await MacDictationPipeline.run(
samples: samples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
}
}
)
}
self.transcript = result.text
let pasted = try await self.deliver(result.text)
self.recordUsage(for: result.text)
self.speechHistory.append(text: result.text)
self.statusMessage = self.statusAfterDelivery(
pasted: pasted,
polishWarning: result.polishWarning,
chunkWarning: result.chunkWarning
)
} catch {
self.statusMessage = error.localizedDescription
}
self.isStreamingPartial = false
self.isProcessing = false
}
}
private func deliver(_ text: String) throws -> Bool {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
private func startLiveCaptureIfSupported(store: AppGroupStore) {
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
let stream = recorder.makeSnapshotStream()
liveCaptureTask = Task { [weak self] in
await MacDictationPipeline.captureLive(
stream: stream,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
guard let self else { return }
guard self.isRecording || self.isProcessing else { return }
let trimmed = partial.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.transcript = partial
self.isStreamingPartial = true
}
}
)
}
}
private func statusAfterDelivery(pasted: Bool) -> String {
private func cancelLiveCapture() {
liveCaptureTask?.cancel()
liveCaptureTask = nil
isStreamingPartial = false
}
/// Stops an in-flight prepare, or finishes an active recording.
private func cancelOrFinishRecording() {
if isRecording {
finishRecording()
return
}
if isPreparingToRecord {
hotkeyBeginTask?.cancel()
hotkeyBeginTask = nil
// If the button-triggered prepare wasn't tracked by hotkeyBeginTask,
// still clear the preparing flag and stop any engine that raced in.
isPreparingToRecord = false
cancelLiveCapture()
_ = recorder.stop()
}
}
private func deliver(_ text: String) async throws -> Bool {
try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
}
private func statusAfterDelivery(
pasted: Bool,
polishWarning: String? = nil,
chunkWarning: String? = nil
) -> String {
let lang = config.uiLanguage
let base: String
if autoPasteEnabled, pasted {
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
base = MacL10n.string("mac.status.copiedAndPasted", language: lang)
} else if autoPasteEnabled, !pasted {
base = MacL10n.string("mac.status.copied", language: lang)
} else {
base = MacL10n.string("mac.status.copied", language: lang)
}
if autoPasteEnabled, !pasted {
return MacL10n.string("mac.status.copied", language: lang)
if let polishWarning, !polishWarning.isEmpty {
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, polishWarning)
}
return MacL10n.string("mac.status.copied", language: lang)
if let chunkWarning, !chunkWarning.isEmpty {
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, chunkWarning)
}
return base
}
private func wireHotkeyService() {
hotkeyService.trigger = hotkeyTrigger
hotkeyService.onPressBegan = { [weak self] in
self?.beginRecording()
guard let self else { return }
self.hotkeyBeginTask?.cancel()
self.hotkeyBeginTask = Task { [weak self] in
await self?.beginRecording()
}
}
hotkeyService.onPressEnded = { [weak self] in
self?.finishRecording()
guard let self else { return }
// Cancel a still-preparing start so a quick Option tap never
// latches recording. If recording already began, finish it.
if self.isRecording {
self.hotkeyBeginTask = nil
self.finishRecording()
} else {
self.hotkeyBeginTask?.cancel()
self.hotkeyBeginTask = nil
}
}
if hotkeyEnabled { hotkeyService.start() }
}
@@ -304,6 +472,10 @@ final class MacDictationViewModel: ObservableObject {
config.apply(preset: provider)
}
func selectAsrProvider(_ provider: LLMProvider) {
config.applyAsr(preset: provider)
}
func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName()
}
+88 -62
View File
@@ -1,9 +1,8 @@
// MacDictionaryView.swift
// OSGKeyboard · Mac
//
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on
// the desktop (words are authored on iPhone / iPad): grouped cards (native
// `Form`) with search, matching the Settings and History card style.
// Personal dictionary synced via iCloud KVS. ScrollView is full-bleed
// (scrollbar on the window edge); title + cards share `pageHorizontalInset`.
import SwiftUI
@@ -45,16 +44,27 @@ struct MacDictionaryView: View {
}
var body: some View {
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.dictionary", language: lang),
subtitle: MacL10n.string("mac.page.dictionary.subtitle", language: lang)
) {
if !entries.isEmpty {
searchField
}
}
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
list
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: entries.isEmpty)
.task {
@@ -64,33 +74,6 @@ struct MacDictionaryView: View {
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
}
// MARK: - Grouped cards
private var form: some View {
Form {
if sections.isEmpty {
Section {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
Section(MacL10n.string(section.category.labelKey, language: lang)) {
ForEach(section.items) { entry in
row(entry)
}
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.animation(Motion.soft, value: query)
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
.confirmationDialog(
MacL10n.string("mac.dict.deleteTitle", language: lang),
isPresented: deletionDialogBinding,
@@ -110,6 +93,55 @@ struct MacDictionaryView: View {
}
}
// MARK: - List
private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) {
if sections.isEmpty {
MacCard {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
categorySection(section)
}
}
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
.animation(Motion.soft, value: query)
}
}
private func categorySection(
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(MacL10n.string(section.category.labelKey, language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
if index < section.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
}
}
}
}
}
private var deletionDialogBinding: Binding<Bool> {
Binding(
get: { entryPendingDeletion != nil },
@@ -117,25 +149,19 @@ struct MacDictionaryView: View {
)
}
private var centeredSearchField: some View {
HStack {
Spacer()
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.frame(width: 240)
.macGlassSurface(in: Capsule(), fillOpacity: 0.72)
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
Spacer()
private var searchField: some View {
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 6)
.frame(width: 220)
.background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private func row(_ entry: PersonalDictionary.Entry) -> some View {
@@ -165,19 +191,19 @@ struct MacDictionaryView: View {
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "character.book.closed")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
.font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.dict.empty", language: lang))
.font(TypeStyle.body)
.font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
.font(TypeStyle.caption)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
.frame(maxWidth: 320)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, Spacing.xl)
}
private func delete(_ entry: PersonalDictionary.Entry) {
+74 -44
View File
@@ -1,9 +1,8 @@
// MacHistoryView.swift
// OSGKeyboard · Mac
//
// Single-column, day-grouped transcript log rendered as grouped cards (the
// same native `Form` container as Settings). Every entry shows its full text
// inline no master/detail split, so content never pushes the sidebar out.
// Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the
// window edge); title + cards share `pageHorizontalInset` on their content.
import SwiftUI
@@ -31,36 +30,39 @@ struct MacHistoryView: View {
}()
var body: some View {
Group {
if historyStore.entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: historyStore.entries.isEmpty)
}
// MARK: - Grouped cards
private var form: some View {
Form {
ForEach(historyStore.groupedByDay, id: \.day) { group in
Section(Self.dayFormatter.string(from: group.day)) {
ForEach(group.items) { entry in
row(entry)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.history", language: lang),
subtitle: MacL10n.string("mac.page.history.subtitle", language: lang)
) {
if !historyStore.entries.isEmpty {
Button {
showClearConfirmation = true
} label: {
Label(
MacL10n.string("mac.history.clearConfirm", language: lang),
systemImage: "trash"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.buttonStyle(.plain)
}
}
Group {
if historyStore.entries.isEmpty {
emptyState
.transition(.opacity)
} else {
list
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
.animation(Motion.soft, value: historyStore.entries.isEmpty)
.confirmationDialog(
MacL10n.string("mac.history.clearTitle", language: lang),
isPresented: $showClearConfirmation,
@@ -75,21 +77,43 @@ struct MacHistoryView: View {
}
}
private var toolbar: some View {
HStack {
Spacer()
Button {
showClearConfirmation = true
} label: {
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
.font(TypeStyle.caption)
// MARK: - List
private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
// Horizontal inset lives on the content so cards align with the title.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) {
ForEach(historyStore.groupedByDay, id: \.day) { group in
daySection(group)
}
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
}
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(Self.dayFormatter.string(from: group.day))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
if index < group.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
}
}
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: SpeechHistoryEntry) -> some View {
@@ -107,11 +131,17 @@ struct MacHistoryView: View {
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "text.bubble")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
.font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.history.empty", language: lang))
.font(TypeStyle.body)
.font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.history.emptyBody", language: lang))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+112 -19
View File
@@ -1,33 +1,93 @@
// MacHotkeyService.swift
// OSGKeyboard · Mac
//
// Global hold-to-talk: while Option () is held, dictation runs. Mirrors
// Typeless / SayIt push-to-talk from any foreground app.
// Global hold-to-talk: while the configured Option () key is held, dictation
// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
import AppKit
import Foundation
/// Which physical Option () key triggers global hold-to-talk.
///
/// Right Option is the default: the left key is a routine typing modifier
/// (special characters, app shortcuts), so firing on any Option press
/// constantly misfires during normal typing.
enum MacHotkeyTrigger: String, CaseIterable, Identifiable {
case rightOption
case leftOption
case eitherOption
var id: String { rawValue }
var labelKey: String {
switch self {
case .rightOption: return "mac.hotkeyTrigger.rightOption"
case .leftOption: return "mac.hotkeyTrigger.leftOption"
case .eitherOption: return "mac.hotkeyTrigger.eitherOption"
}
}
/// Main-window hint under the record button must follow the picker,
/// or the UI tells left-Option users to hold the right key.
var hintKey: String {
switch self {
case .rightOption: return "mac.hint.hold.rightOption"
case .leftOption: return "mac.hint.hold.leftOption"
case .eitherOption: return "mac.hint.hold.eitherOption"
}
}
/// `@AppStorage`-compatible key; persisted via the view model's defaults.
static let storageKey = "mac.hotkeyTrigger"
/// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` /
/// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the
/// device-independent `.option` flag, telling left and right apart.
private static let leftOptionMask: UInt = 0x20
private static let rightOptionMask: UInt = 0x40
/// Whether this trigger's key is currently down in a `.flagsChanged` event.
func isPressed(in event: NSEvent) -> Bool {
guard event.modifierFlags.contains(.option) else { return false }
let raw = event.modifierFlags.rawValue
switch self {
case .rightOption: return raw & Self.rightOptionMask != 0
case .leftOption: return raw & Self.leftOptionMask != 0
case .eitherOption: return true
}
}
}
@MainActor
final class MacHotkeyService {
/// How long the trigger key must stay held before recording begins.
/// Filters out quick -taps and +key combos (special characters, app
/// shortcuts) that would otherwise start and immediately abort dictation.
private static let holdDebounce: Duration = .milliseconds(150)
var onPressBegan: (() -> Void)?
var onPressEnded: (() -> Void)?
var trigger: MacHotkeyTrigger = .rightOption
private var globalFlagsMonitor: Any?
private var localFlagsMonitor: Any?
private var optionHeld = false
/// The trigger key is physically down (debounce may still be pending).
private var triggerKeyDown = false
/// `onPressBegan` has fired and `onPressEnded` is owed.
private var pressActive = false
private var pendingBegin: Task<Void, Never>?
private var isEnabled = true
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
if !enabled, optionHeld {
optionHeld = false
onPressEnded?()
}
if !enabled { cancelPress() }
}
func start() {
guard globalFlagsMonitor == nil else { return }
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
// Global monitors require Accessibility; without it the call returns
// nil and Option-hold never fires outside our own windows.
let trusted = MacTextInsertionService.requestAccessibilityIfNeeded()
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
@@ -36,6 +96,12 @@ final class MacHotkeyService {
Task { @MainActor in self?.handleFlagsChanged(event) }
return event
}
#if DEBUG
if !trusted || globalFlagsMonitor == nil {
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
}
#endif
}
func stop() {
@@ -47,20 +113,47 @@ final class MacHotkeyService {
NSEvent.removeMonitor(localFlagsMonitor)
self.localFlagsMonitor = nil
}
if optionHeld {
optionHeld = false
onPressEnded?()
}
cancelPress()
}
private func handleFlagsChanged(_ event: NSEvent) {
guard isEnabled else { return }
let optionDown = event.modifierFlags.contains(.option)
if optionDown, !optionHeld {
optionHeld = true
onPressBegan?()
} else if !optionDown, optionHeld {
optionHeld = false
let triggerDown = trigger.isPressed(in: event)
if triggerDown, !triggerKeyDown {
triggerKeyDown = true
scheduleBegin()
} else if !triggerDown, triggerKeyDown {
triggerKeyDown = false
pendingBegin?.cancel()
pendingBegin = nil
if pressActive {
pressActive = false
onPressEnded?()
}
}
}
/// Debounce: begin only after the key has stayed held for `holdDebounce`.
/// Releasing the key first cancels the pending start, so a quick
/// Option+key combo never triggers recording.
private func scheduleBegin() {
pendingBegin?.cancel()
pendingBegin = Task { [weak self] in
try? await Task.sleep(for: Self.holdDebounce)
guard let self, !Task.isCancelled else { return }
self.pendingBegin = nil
guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return }
self.pressActive = true
self.onPressBegan?()
}
}
private func cancelPress() {
pendingBegin?.cancel()
pendingBegin = nil
triggerKeyDown = false
if pressActive {
pressActive = false
onPressEnded?()
}
}
@@ -0,0 +1,43 @@
// MacLocalASRChunkAdapter.swift
// OSGKeyboard · Mac
//
// Adapts macOS local ASR to the shared chunked utterance pipeline.
import Foundation
import os
final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
private let locale: Locale
private let bias: LocalASRBiasPayload?
private let cancelled = OSAllocatedUnfairLock(initialState: false)
init(locale: Locale, bias: LocalASRBiasPayload?) {
self.locale = locale
self.bias = bias
}
func resetForNewUtterance() {
cancelled.withLock { $0 = false }
}
func cancel() {
cancelled.withLock { $0 = true }
}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
let isCancelled = cancelled.withLock { $0 }
if isCancelled || Task.isCancelled { return .cancelled }
guard !samples.isEmpty else { return .success("") }
do {
let text = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
return .success(text)
} catch {
return .failure(error.localizedDescription)
}
}
}
@@ -276,14 +276,17 @@ struct MacLocalASRModelSettingsView: View {
HStack(spacing: Spacing.xs) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
if let badgeKey = model.badgeKey {
modelBadge(
MacL10n.string(badgeKey, language: lang),
emphasized: true
)
}
if model.supportsHotwords {
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang))
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(palette.accent.opacity(0.15))
.foregroundStyle(palette.accent)
.clipShape(Capsule())
modelBadge(
MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
emphasized: false
)
}
}
Text(modelSubtitle(model, installed: installed))
@@ -305,6 +308,20 @@ struct MacLocalASRModelSettingsView: View {
.padding(.vertical, 2)
}
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
Text(title)
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
emphasized
? palette.accent.opacity(0.18)
: palette.textTertiary.opacity(0.12)
)
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
.clipShape(Capsule())
}
@ViewBuilder
private func modelRowActions(
model: LocalASRModelDefinition,
+3 -3
View File
@@ -58,7 +58,7 @@ enum MacLocalASRPreferences {
/// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b":
case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
return "sherpa-qwen3-0.6b-int8"
default:
return id
@@ -120,7 +120,7 @@ enum MacLocalASRService {
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
}
private static func transcribeWithModel(
@@ -141,7 +141,7 @@ enum MacLocalASRService {
bias: bias
)
case .appleSpeech:
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
}
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ struct MacOnboardingView: View {
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 860, minHeight: 600)
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear {
applyDefaults()
model.reload()
+11 -20
View File
@@ -18,15 +18,6 @@ struct MacRootView: View {
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
/// `List` selection is optional; keep the view model's non-optional section
/// in sync without letting a nil selection blank the detail pane.
private var selection: Binding<MacSection?> {
Binding(
get: { viewModel.selectedSection },
set: { if let new = $0 { viewModel.selectedSection = new } }
)
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
@@ -35,7 +26,7 @@ struct MacRootView: View {
detail
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 860, minHeight: 600)
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear {
// Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") }
@@ -72,7 +63,7 @@ struct MacRootView: View {
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 30)
.frame(height: 28)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer()
@@ -80,7 +71,7 @@ struct MacRootView: View {
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.lg)
.padding(.bottom, Spacing.md)
}
private var devicesFooter: some View {
@@ -88,10 +79,10 @@ struct MacRootView: View {
MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone"
)
.font(TypeStyle.caption)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset)
.padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
.padding(.vertical, Spacing.sm)
}
@@ -118,8 +109,8 @@ struct MacRootView: View {
// MARK: - Sidebar row
/// A navigation row with an animated hover highlight and selection state,
/// matching the macOS System Settings feel.
/// Navigation row with a restrained selected state: muted accent fill +
/// accent label (not a solid green pill), matching System Settings polish.
private struct MacSidebarRow: View {
let section: MacSection
let isSelected: Bool
@@ -132,8 +123,8 @@ private struct MacSidebarRow: View {
var body: some View {
Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage)
.font(.system(size: 13))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
@@ -150,7 +141,7 @@ private struct MacSidebarRow: View {
}
private var rowBackground: Color {
if isSelected { return palette.accent }
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
if isSelected { return palette.accentMuted }
return isHovering ? palette.textPrimary.opacity(0.05) : .clear
}
}
+182 -49
View File
@@ -1,9 +1,10 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings built on the native grouped `Form` the same container macOS
// System Settings uses. This gives system-accurate cards, dividers, insets
// and right-aligned controls for free, on both light and dark.
// Settings uses native grouped `Form` for correct control layout (Picker /
// Toggle / LabeledContent). Title and Form share the same plain
// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so
// card chrome lines up with the page title.
import SwiftUI
#if os(macOS)
@@ -20,6 +21,7 @@ struct MacSettingsView: View {
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
@State private var showAsrProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -33,23 +35,43 @@ struct MacSettingsView: View {
var body: some View {
NavigationStack {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
.transition(.opacity)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.settings", language: lang),
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
)
Form {
generalSection
recognitionSection
polishProviderSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
.formStyle(.grouped)
// Zero Form's own scroll margins, then inset via padding so the
// section cards line up with MacPageHeader (contentMargins alone
// does not match plain padding on macOS).
//
// grouped Form adds its own built-in section inset on top of our
// padding, so cards sat ~`groupedFormSectionInset` wider than the
// History page. Subtract that inset here so the card OUTER edge
// lands on `pageHorizontalInset` (40pt), matching History and the
// page title's left edge.
.contentMargins(.horizontal, 0, for: .scrollContent)
.padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.onAppear { refreshAccessibilityState() }
@@ -82,27 +104,21 @@ struct MacSettingsView: View {
}
}
// MARK: - Cloud provider
// MARK: - Polish LLM
private var providerSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
Button {
showProviderPicker = true
} label: {
HStack(spacing: 6) {
providerLogo(currentProvider.id)
Text(currentProvider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
private var polishProviderSection: some View {
Section(MacL10n.string("mac.settings.polishProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
provider: currentPolishProvider,
isPresented: $showProviderPicker
) {
providerPickerList(
providers: viewModel.polishSelectableProviders,
selectedId: viewModel.config.providerId
) { provider in
viewModel.selectProvider(provider)
showProviderPicker = false
}
}
@@ -117,6 +133,17 @@ struct MacSettingsView: View {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
@@ -130,6 +157,86 @@ struct MacSettingsView: View {
}
}
// MARK: - Cloud ASR
private var asrProviderSection: some View {
Section(MacL10n.string("mac.settings.asrProvider", language: lang)) {
providerPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
provider: currentAsrProvider,
isPresented: $showAsrProviderPicker
) {
providerPickerList(
providers: viewModel.asrSelectableProviders,
selectedId: viewModel.config.asrProviderId
) { provider in
viewModel.selectAsrProvider(provider)
showAsrProviderPicker = false
}
}
LabeledContent {
SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrApiKey", language: lang))
}
if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt {
LabeledContent {
TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.baseURL", language: lang))
}
}
LabeledContent {
TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.asrModel", language: lang))
}
}
}
private func providerPickerRow<Content: View>(
title: String,
provider: LLMProvider,
isPresented: Binding<Bool>,
@ViewBuilder picker: @escaping () -> Content
) -> some View {
LabeledContent(title) {
Button {
isPresented.wrappedValue = true
} label: {
HStack(spacing: 6) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: isPresented, arrowEdge: .bottom, content: picker)
}
}
// MARK: - Recognition method
private var recognitionSection: some View {
@@ -161,6 +268,19 @@ struct MacSettingsView: View {
)
}
Picker(selection: hotkeyTriggerBinding) {
ForEach(MacHotkeyTrigger.allCases) { trigger in
Text(MacL10n.string(trigger.labelKey, language: lang))
.tag(trigger.rawValue)
}
} label: {
rowLabel(
MacL10n.string("mac.settings.hotkeyTrigger", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang)
)
}
.disabled(!viewModel.hotkeyEnabled)
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
@@ -258,28 +378,34 @@ struct MacSettingsView: View {
.buttonStyle(.plain)
}
private var currentProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first
private var currentPolishProvider: LLMProvider {
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0]
}
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
/// brand marks in a plain view stack instead.
private var providerPickerList: some View {
private var currentAsrProvider: LLMProvider {
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
?? viewModel.asrSelectableProviders.first
?? LLMProvider.presets[0]
}
private func providerPickerList(
providers: [LLMProvider],
selectedId: String,
onSelect: @escaping (LLMProvider) -> Void
) -> some View {
VStack(spacing: 0) {
ForEach(viewModel.selectableProviders) { provider in
ForEach(providers) { provider in
Button {
viewModel.selectProvider(provider)
showProviderPicker = false
onSelect(provider)
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == currentProvider.id {
if provider.id == selectedId {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
@@ -359,6 +485,13 @@ struct MacSettingsView: View {
)
}
private var hotkeyTriggerBinding: Binding<String> {
Binding(
get: { viewModel.hotkeyTrigger.rawValue },
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
)
}
private var autoPasteBinding: Binding<Bool> {
Binding(
get: { viewModel.autoPasteEnabled },
+59 -6
View File
@@ -165,16 +165,69 @@ enum MacSherpaONNXRunner {
.filter { !$0.isEmpty }
for line in lines.reversed() {
if line.hasPrefix("{"), let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
if line.hasPrefix("{") {
// Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`).
// Trust only its `text` field including when it's empty
// (silence/no-speech) and never fall through to the raw
// JSON below, or the JSON blob itself gets inserted as text.
if let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
return sanitizeTranscript(text)
}
continue
}
if isMetadataNoiseLine(line) { continue }
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
return line
let cleaned = sanitizeTranscript(line)
if !cleaned.isEmpty { return cleaned }
}
}
return ""
}
/// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a
/// scaffold such as `language Chinese<asr_text>`. Older runtimes leave
/// that intact in `result.text`; incomplete generations can even stop at
/// the bare word `language`. Strip the scaffold so only spoken text remains.
private static func sanitizeTranscript(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty { return "" }
// Prefer the payload after the last `<asr_text>` marker.
if let marker = text.range(of: "<asr_text>", options: .backwards) {
text = String(text[marker.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if let match = text.range(
of: #"^language\s+\S+\s*"#,
options: [.regularExpression, .caseInsensitive]
) {
// Fallback when the marker token was lost but the language prefix remains.
text = String(text[match.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Drop leftover control tokens / bare scaffold words.
if isMetadataNoiseLine(text) { return "" }
return text
}
/// Lines that are sherpa/Qwen metadata rather than spoken content.
private static func isMetadataNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
switch lowered {
case "language", "emotion", "event", "text",
"<asr_text>", "</asr_text>", "<|im_end|>":
return true
default:
// Exact scaffold with no spoken payload, e.g. "language Chinese".
if lowered.range(
of: #"^language(\s+\S+)?$"#,
options: .regularExpression
) != nil {
return true
}
return false
}
}
}
+99 -5
View File
@@ -9,10 +9,65 @@ import Foundation
import Speech
enum MacSpeechLocalASR {
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
/// Shared resume-once state for one recognition run. The recognizer
/// callback (delivered on an arbitrary Speech queue) and the timeout task
/// race to finish, and a `CheckedContinuation` must resume exactly once,
/// so both go through this lock-guarded gate. It also retains the
/// `SFSpeechRecognitionTask` so the losing/failing path can cancel it.
private final class RecognitionSession: @unchecked Sendable {
private let lock = NSLock()
private var isResumed = false
private var task: SFSpeechRecognitionTask?
private var timeoutTask: Task<Void, Never>?
func retain(_ task: SFSpeechRecognitionTask) {
lock.lock()
self.task = task
let alreadyResumed = isResumed
lock.unlock()
// Timeout won the race before the task handle was stored.
if alreadyResumed { task.cancel() }
}
func retainTimeout(_ task: Task<Void, Never>) {
lock.lock()
timeoutTask = task
let alreadyResumed = isResumed
lock.unlock()
// Recognition finished before the handle landed stop the timer.
if alreadyResumed { task.cancel() }
}
/// Returns `true` exactly once across all callers; the winner may
/// resume the continuation. Pass `cancellingTask: true` on failure
/// paths so the in-flight recognition stops doing work. The winner
/// also cancels the timeout task so it doesn't keep the session (and
/// continuation captures) alive for the rest of its sleep.
func claimResume(cancellingTask: Bool) -> Bool {
lock.lock()
guard !isResumed else {
lock.unlock()
return false
}
isResumed = true
let task = self.task
let timeout = timeoutTask
lock.unlock()
if cancellingTask { task?.cancel() }
timeout?.cancel()
return true
}
}
static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String {
let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
if Self.isChineseLocale(locale) {
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
_ = try? await CustomLanguageModelManager.shared.prepareIfNeeded()
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) }
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
}
// The request below sets `requiresOnDeviceRecognition = true`, which
// fails (or worse, never produces a final result) when the on-device
// model for the locale is missing fail fast with a clear error.
guard recognizer.supportsOnDeviceRecognition else {
throw MacLocalASRError.speechFailed(
"On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation."
)
}
return try await withCheckedThrowingContinuation { continuation in
// Overall deadline: recognition of a file is normally much faster than
// realtime, so 2× audio length with a 30 s floor is generous. Without
// it, empty audio / cancellation / a missing model can leave the
// callback silent forever and the continuation never resumes.
let audioSeconds = Double(samples.count) / 16_000
let timeoutSeconds = max(30.0, audioSeconds * 2)
let session = RecognitionSession()
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
CustomLanguageModelManager.applyCustomLanguageModel(
to: request,
locale: locale,
bias: bias
)
recognizer.recognitionTask(with: request) { result, error in
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
}
return
}
// Non-final callbacks carry no usable transcript yet; if a
// final result never arrives, the timeout below resumes us.
guard let result, result.isFinal else { return }
guard session.claimResume(cancellingTask: false) else { return }
let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
continuation.resume(returning: text)
}
}
session.retain(task)
let timeout = Task {
try? await Task.sleep(for: .seconds(timeoutSeconds))
guard !Task.isCancelled else { return }
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out"))
}
}
session.retainTimeout(timeout)
}
}
@@ -58,4 +148,8 @@ enum MacSpeechLocalASR {
try wav.write(to: url)
return url
}
private static func isChineseLocale(_ locale: Locale) -> Bool {
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
}
}
+141 -13
View File
@@ -1,8 +1,10 @@
// MacTextInsertionService.swift
// OSGKeyboard · Mac
//
// Inserts transcribed text into the frontmost app: clipboard first, then
// Inserts transcribed text into the target app: clipboard first, then
// a synthetic V (SayIt / Typeless-style). Requires Accessibility trust.
// Re-activates the app the user was dictating into (the popover steals
// focus) and restores the original clipboard once the paste has landed.
import AppKit
@preconcurrency import ApplicationServices
@@ -30,32 +32,158 @@ enum MacTextInsertionService {
return AXIsProcessTrustedWithOptions(options)
}
/// Copy to pasteboard and optionally simulate V in the front app.
// MARK: - Paste-target tracking
/// Start observing app activations early (app launch) so a paste target
/// can still be recovered while OSGKeyboard itself is frontmost e.g.
/// when a recording is started from the menu-bar popover.
@MainActor
static func beginTrackingFrontmostApp() {
_ = FrontmostAppTracker.shared
}
/// The app a synthesized V should land in: the current frontmost app,
/// or when OSGKeyboard is frontmost because the popover has key
/// focus the app that was active immediately before it.
@MainActor
static func captureTargetApplication() -> NSRunningApplication? {
let selfPid = NSRunningApplication.current.processIdentifier
if let front = NSWorkspace.shared.frontmostApplication,
front.processIdentifier != selfPid {
return front
}
return FrontmostAppTracker.shared.lastExternalApp
}
// MARK: - Insertion
/// Copy to pasteboard and optionally simulate V in the target app.
/// Returns `true` only if the V event was actually synthesized. After a
/// successful paste the user's original clipboard is put back, so
/// dictation never permanently clobbers it.
@MainActor
static func insert(
_ text: String,
autoPaste: Bool
) throws -> Bool {
autoPaste: Bool,
targetApp: NSRunningApplication? = nil
) async throws -> Bool {
guard !text.isEmpty else { return false }
let pasteboard = NSPasteboard.general
let snapshot = snapshotItems(of: pasteboard)
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
Thread.sleep(forTimeInterval: 0.08)
postCommandV()
// Make sure V lands in the app the user was dictating into, not in
// OSGKeyboard's own popover / window.
if let targetApp { await activate(targetApp) }
try? await Task.sleep(nanoseconds: 80_000_000)
guard postCommandV() else { return false }
// Give the target app time to read the transcript off the
// pasteboard, then restore whatever the user had on it.
try? await Task.sleep(nanoseconds: 300_000_000)
restoreItems(snapshot, to: pasteboard)
return true
}
private static func postCommandV() {
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
/// the synthesized keystroke isn't swallowed mid-switch.
@MainActor
private static func activate(_ app: NSRunningApplication) async {
func isFront() -> Bool {
NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier
}
guard !isFront() else { return }
app.activate()
var attempts = 0
while !isFront(), attempts < 20 {
try? await Task.sleep(nanoseconds: 50_000_000)
attempts += 1
}
}
// MARK: - Pasteboard preservation
/// Every representation of every pasteboard item, so restore round-trips
/// rich content (images, files, multiple flavours) losslessly.
private static func snapshotItems(
of pasteboard: NSPasteboard
) -> [[NSPasteboard.PasteboardType: Data]] {
(pasteboard.pasteboardItems ?? []).map { item in
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in
flavours[type] = item.data(forType: type)
}
}
}
private static func restoreItems(
_ items: [[NSPasteboard.PasteboardType: Data]],
to pasteboard: NSPasteboard
) {
guard !items.isEmpty else { return }
pasteboard.clearContents()
pasteboard.writeObjects(items.map { flavours in
let item = NSPasteboardItem()
for (type, data) in flavours { item.setData(data, forType: type) }
return item
})
}
/// Returns `false` when the CGEvents could not be created in that case
/// nothing was pasted and callers must not report success.
private static func postCommandV() -> Bool {
let source = CGEventSource(stateID: .combinedSessionState)
let keyCode = CGKeyCode(kVK_ANSI_V)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyDown?.flags = CGEventFlags.maskCommand
keyUp?.flags = CGEventFlags.maskCommand
keyDown?.post(tap: CGEventTapLocation.cghidEventTap)
keyUp?.post(tap: CGEventTapLocation.cghidEventTap)
guard
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
else { return false }
keyDown.flags = CGEventFlags.maskCommand
keyUp.flags = CGEventFlags.maskCommand
keyDown.post(tap: CGEventTapLocation.cghidEventTap)
keyUp.post(tap: CGEventTapLocation.cghidEventTap)
return true
}
}
// MARK: - Frontmost-app tracker
/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because
/// the menu-bar popover activates OSGKeyboard, hiding the real paste target
/// from `NSWorkspace.frontmostApplication`.
@MainActor
private final class FrontmostAppTracker: NSObject {
static let shared = FrontmostAppTracker()
private(set) var lastExternalApp: NSRunningApplication?
private override init() {
super.init()
// Seed with whatever is frontmost now (usually not us at launch).
if let front = NSWorkspace.shared.frontmostApplication,
front.processIdentifier != NSRunningApplication.current.processIdentifier {
lastExternalApp = front
}
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(appDidActivate(_:)),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
}
deinit {
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
@objc private func appDidActivate(_ notification: Notification) {
guard
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.processIdentifier != NSRunningApplication.current.processIdentifier
else { return }
lastExternalApp = app
}
}
+10 -5
View File
@@ -6,7 +6,8 @@
// semantic colours, resolved to a concrete value for the *active* appearance.
// The brand green is kept only as the accent. Light mode uses a warm,
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
// grey on macOS); Dark mode uses stepped elevated greys so cards stay
// readable against the page background.
import AppKit
import SwiftUI
@@ -33,8 +34,8 @@ enum MacSystemPalette {
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
danger: resolved(.systemRed, dark: dark),
success: Palette.accent,
@@ -45,8 +46,12 @@ enum MacSystemPalette {
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white,
divider: resolved(.separatorColor, dark: dark),
dividerStrong: resolved(.separatorColor, dark: dark),
divider: dark
? Color.white.opacity(0.08)
: Color.black.opacity(0.06),
dividerStrong: dark
? Color.white.opacity(0.12)
: Color.black.opacity(0.10),
recordRed: resolved(.systemRed, dark: dark)
)
+6 -1
View File
@@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App {
// very top, matching macOS System Settings.
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
.defaultSize(width: 1_024, height: 720)
// Open at the minimum size same as `MacMetrics.windowMin*`, so the
// first launch already matches the smallest allowed window.
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
}
}
@@ -94,8 +96,11 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
MacAppearancePreference.applyToApp(.current)
MacTextInsertionService.beginTrackingFrontmostApp()
configurePopover()
configureStatusItem()
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
// The menu bar always follows the *system* appearance, so the status
// item must ignore the app's forced light/dark override. Re-pin the