Skip to content

Commit d5b1777

Browse files
committed
feat: per-app writing styles for dictation output
Dictation is now shaped for the app that receives it. The same spoken words become `open config.json` in Cursor, `*bold*` in Slack, and a plain sentence in Messages. Seven built-in styles (Plain, Code, Terminal, Chat, Slack, Email, Notes) are bound to apps, seeded on first run for apps installed on this Mac. Plain reproduces the previous pipeline byte for byte, and one master toggle reverts everything. Deterministic and on-device — no LLM, no network call on the dictation path. - WritingStyleEngine: pipeline of filler trim, newline/list commands, symbol substitution, emphasis dialect, then capitalization/punctuation/spacing - SpokenSymbolTransformer: tiered by confidence. Tier A is context-locked (extension whitelist, spoken case commands, explicit bracket commands) and safe in prose; Tier B is heuristic (path slashes, identifier joiners) and limited to Code and Terminal. "literally" suppresses substitution. - FrontmostAppResolver: resolves the target at injection time, with a record-start snapshot as fallback when VocaMac's own window has focus. One NSWorkspace read per dictation, no polling. - AppIdentityMatching: bundle ID or process name, shared with auto-pause - Settings: new Writing Styles page with rule list, per-app rule editor, and a live preview; menu bar shows the active style and re-binds in one tap - capitalizeSentences now capitalizes the first letter rather than the first character, so markup prefixes do not swallow sentence case Catalog seeding runs off the launch path — a menu bar app should not stall its first paint on LaunchServices lookups.
1 parent cc1f282 commit d5b1777

27 files changed

Lines changed: 3410 additions & 57 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
## ✨ Features
3737

3838
- **🔒 100% Local** - All audio processing happens on your machine. No internet required — the Tiny model ships bundled and works out of the box offline.
39+
- **✍️ Per-App Writing Styles** - Dictation is shaped for the app receiving it: filenames and paths in Cursor or VS Code, `*bold*` in Slack, plain sentences in Messages, full stops in Mail. Runs on-device with deterministic rules — no LLM, no network.
3940
- **⌨️ System-Wide Text Injection** - Transcribed text is typed wherever your cursor is: browsers, Slack, VS Code, spreadsheets, terminals - everywhere.
4041
- **🎯 Push-to-Talk** - Hold a hotkey (default: Right Option) to record. Release to transcribe.
4142
- **👆 Double-Tap Toggle** - Double-tap the hotkey to start/stop recording.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// AppIdentity.swift
2+
// VocaMac
3+
//
4+
// Shared identity and matching for other macOS applications. Auto-pause and
5+
// writing styles both need "is this running app the one the user configured",
6+
// and both must answer it the same way, so the rules live here once.
7+
8+
import Foundation
9+
import AppKit
10+
11+
/// Lightweight view of a running process, used for matching and picker UI.
12+
struct RunningAppSnapshot: Hashable {
13+
var displayName: String
14+
var bundleIdentifier: String?
15+
var processName: String?
16+
17+
init(displayName: String, bundleIdentifier: String? = nil, processName: String? = nil) {
18+
self.displayName = displayName
19+
self.bundleIdentifier = bundleIdentifier
20+
self.processName = processName
21+
}
22+
}
23+
24+
/// Matching rules shared by every feature that keys off another app.
25+
enum AppIdentityMatching {
26+
27+
/// Normalize a process or configured name for comparison.
28+
///
29+
/// Strips any directory component, lowercases, and drops a `.exe` suffix
30+
/// so `/Applications/Foo.app/Contents/MacOS/Foo` and `foo` compare equal.
31+
static func normalizeProcessName(_ name: String) -> String {
32+
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
33+
guard !trimmed.isEmpty else { return "" }
34+
let base = (trimmed as NSString).lastPathComponent.lowercased()
35+
if base.hasSuffix(".exe") {
36+
return String(base.dropLast(4))
37+
}
38+
return base
39+
}
40+
41+
/// Whether a configured entry identifies the same app as a running snapshot.
42+
///
43+
/// Matches on case-insensitive bundle identifier equality **or** normalized
44+
/// executable basename equality. `configuredID` is the fallback used when
45+
/// the entry carries no explicit process name.
46+
static func matches(
47+
configuredBundleIdentifier: String?,
48+
configuredProcessName: String?,
49+
configuredID: String,
50+
snapshot: RunningAppSnapshot
51+
) -> Bool {
52+
if let configuredBundle = configuredBundleIdentifier?.lowercased(),
53+
let snapshotBundle = snapshot.bundleIdentifier?.lowercased(),
54+
configuredBundle == snapshotBundle {
55+
return true
56+
}
57+
58+
let configuredProcess = configuredProcessName.map { normalizeProcessName($0) }
59+
?? normalizeProcessName(configuredID)
60+
guard !configuredProcess.isEmpty else { return false }
61+
62+
if let snapshotProcess = snapshot.processName.map({ normalizeProcessName($0) }),
63+
configuredProcess == snapshotProcess {
64+
return true
65+
}
66+
67+
// A configured entry stored as a bare process name should still match an
68+
// app whose bundle ID ends in that name (e.g. `ghostty` vs the app's
69+
// bundle path basename).
70+
if let snapshotBundle = snapshot.bundleIdentifier.map({ normalizeProcessName($0) }),
71+
configuredProcess == snapshotBundle {
72+
return true
73+
}
74+
75+
return false
76+
}
77+
78+
/// Snapshot the currently running applications, excluding VocaMac itself.
79+
static func workspaceRunningApps() -> [RunningAppSnapshot] {
80+
NSWorkspace.shared.runningApplications.compactMap { app in
81+
snapshot(for: app)
82+
}
83+
}
84+
85+
/// Convert one `NSRunningApplication` into a snapshot, or `nil` when it is
86+
/// VocaMac itself or has no usable name.
87+
static func snapshot(for app: NSRunningApplication) -> RunningAppSnapshot? {
88+
if app.bundleIdentifier == Bundle.main.bundleIdentifier {
89+
return nil
90+
}
91+
let display = app.localizedName ?? app.bundleIdentifier ?? app.executableURL?.lastPathComponent
92+
guard let display, !display.isEmpty else { return nil }
93+
return RunningAppSnapshot(
94+
displayName: display,
95+
bundleIdentifier: app.bundleIdentifier,
96+
processName: app.executableURL?.lastPathComponent ?? app.localizedName
97+
)
98+
}
99+
}

Sources/VocaMac/Models/AppState.swift

Lines changed: 169 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ final class AppState: ObservableObject {
125125
@AppStorage(PreferenceKey.autoPausePollInterval) var autoPausePollIntervalSeconds: Double = 5
126126
@AppStorage(PreferenceKey.modelKeepAliveEnabled) var modelKeepAliveEnabled: Bool = false
127127
@AppStorage(PreferenceKey.modelKeepAliveIdleTimeout) var modelKeepAliveIdleTimeoutSeconds: Double = 300
128+
@AppStorage(PreferenceKey.writingStyleEnabled) var writingStyleEnabled: Bool = true
129+
@AppStorage(PreferenceKey.writingStyleDefault) var writingStyleDefault: WritingStyle = .plain
130+
@AppStorage(PreferenceKey.writingStyleCatalogSeeded) private var writingStyleCatalogSeeded: Bool = false
128131

129132
/// JSON-encoded `[AutoPauseAppEntry]` list (complex value not stored via `@AppStorage`).
130133
var autoPauseAppsJSON: String {
@@ -152,6 +155,23 @@ final class AppState: ObservableObject {
152155
}
153156
}
154157

158+
/// JSON-encoded `WritingStyleBindingStore` (complex value not stored via `@AppStorage`).
159+
var writingStyleBindingsJSON: String {
160+
get { UserDefaults.standard.string(forKey: PreferenceKey.writingStyleBindings) ?? "" }
161+
set { UserDefaults.standard.set(newValue, forKey: PreferenceKey.writingStyleBindings) }
162+
}
163+
164+
/// Per-app writing style rules. A corrupt payload decodes to an empty list
165+
/// so dictation falls back to the default style rather than failing.
166+
var writingStyleBindings: [AppStyleBinding] {
167+
get { WritingStyleBindingStore.decode(json: writingStyleBindingsJSON).bindings }
168+
set {
169+
writingStyleBindingsJSON = WritingStyleBindingStore(bindings: newValue).encodedJSON()
170+
refreshActiveWritingStyle()
171+
objectWillChange.send()
172+
}
173+
}
174+
155175
/// True while a configured auto-pause app is running and dictation is blocked.
156176
@Published var isAutoPaused: Bool = false
157177

@@ -168,6 +188,10 @@ final class AppState: ObservableObject {
168188
/// negotiating the route.
169189
private var pendingStopDuringStart: PendingStopKind?
170190

191+
/// Frontmost app captured when recording started. Used only when the app
192+
/// in front at injection time is VocaMac itself (Settings has focus).
193+
private var pendingTargetApp: RunningAppSnapshot?
194+
171195
private enum PendingStopKind {
172196
/// Push-to-talk released: keep whatever the engine managed to capture.
173197
case transcribe
@@ -181,6 +205,15 @@ final class AppState: ObservableObject {
181205
/// Display name of the app that triggered the current auto-pause, if any.
182206
@Published var autoPauseTriggerDisplayName: String?
183207

208+
/// Style that would be used if the user dictated right now. Drives the
209+
/// menu bar indicator; refreshed when the popover appears and after every
210+
/// dictation, never on a timer.
211+
@Published private(set) var activeWritingStyle: ResolvedWritingStyle = .plain
212+
213+
/// Style used by the Settings preview and by Test Dictation, where the
214+
/// frontmost app is VocaMac's own window.
215+
@Published var settingsPreviewStyle: WritingStyle = .plain
216+
184217
/// Approximate process RSS (MB) sampled just before the last unload.
185218
@Published var processMemoryBeforeUnloadMB: Double?
186219

@@ -210,6 +243,8 @@ final class AppState: ObservableObject {
210243
let statsManager: StatsManaging
211244
let updateChecker = UpdateChecker()
212245
let permissionManager: any PermissionManaging
246+
/// Identifies the app that will receive injected text.
247+
let frontmostAppResolver: FrontmostAppResolving
213248

214249
/// Polls configured apps and pauses dictation while they run.
215250
let autoPauseMonitor = AutoPauseMonitor()
@@ -289,6 +324,7 @@ final class AppState: ObservableObject {
289324
cursorOverlay: CursorOverlayManaging,
290325
statsManager: StatsManaging,
291326
permissionManager: (any PermissionManaging)? = nil,
327+
frontmostAppResolver: FrontmostAppResolving = FrontmostAppResolver(),
292328
skipSystemIntegration: Bool = false
293329
) {
294330
self.audioEngine = audioEngine
@@ -299,6 +335,7 @@ final class AppState: ObservableObject {
299335
self.soundManager = soundManager
300336
self.cursorOverlay = cursorOverlay
301337
self.statsManager = statsManager
338+
self.frontmostAppResolver = frontmostAppResolver
302339
self.permissionManager = permissionManager ?? PermissionManager(audioEngine: audioEngine, hotKeyManager: hotKeyManager)
303340
self.skipSystemIntegration = skipSystemIntegration
304341

@@ -402,6 +439,12 @@ final class AppState: ObservableObject {
402439
// Detect system capabilities
403440
systemCapabilities = SystemInfo.detect()
404441

442+
// One-shot writing style seeding. Skipped in tests and the CLI, which
443+
// must not touch LaunchServices.
444+
if !skipSystemIntegration {
445+
seedWritingStyleCatalogIfNeeded()
446+
}
447+
405448
// Get WhisperKit's device recommendation.
406449
// WhisperKit's `.default` may not be in the supported list for some
407450
// devices. If so, fall back to the best supported model instead.
@@ -823,6 +866,101 @@ final class AppState: ObservableObject {
823866
VocaLogger.debug(.appState, "Hotkey configuration synced (keyCode=\(hotKeyCode), modifiers=\(hotKeyModifiers.rawValue), mode=\(activationMode.rawValue))")
824867
}
825868

869+
// MARK: - Writing Styles
870+
871+
/// Resolve the style for a target app using the current preferences.
872+
func resolveWritingStyle(for target: RunningAppSnapshot?) -> ResolvedWritingStyle {
873+
WritingStyleResolver.resolve(
874+
target: target,
875+
bindings: writingStyleBindings,
876+
defaultStyle: writingStyleDefault,
877+
isEnabled: writingStyleEnabled
878+
)
879+
}
880+
881+
/// Recompute `activeWritingStyle` from whatever app is in front now.
882+
///
883+
/// Called when the menu bar popover appears and after settings changes —
884+
/// deliberately not on a timer.
885+
func refreshActiveWritingStyle() {
886+
activeWritingStyle = resolveWritingStyle(for: frontmostAppResolver.currentFrontmostApp())
887+
}
888+
889+
/// Bind the frontmost app to a style, replacing any existing rule for it.
890+
///
891+
/// This is the menu bar's one-tap fix for "that came out wrong".
892+
@discardableResult
893+
func bindFrontmostApp(to style: WritingStyle) -> String? {
894+
guard let target = frontmostAppResolver.currentFrontmostApp() else {
895+
VocaLogger.warning(.appState, "Cannot bind writing style: no frontmost app")
896+
return nil
897+
}
898+
var bindings = writingStyleBindings
899+
bindings.removeAll { $0.matches(target) }
900+
bindings.append(AppStyleBinding.from(snapshot: target, style: style))
901+
writingStyleBindings = bindings
902+
VocaLogger.info(.appState, "Bound \(target.displayName) to writing style '\(style.rawValue)'")
903+
return target.displayName
904+
}
905+
906+
/// Add the suggested rules for apps installed on this Mac, once.
907+
///
908+
/// Runs on first launch after upgrading. Existing bindings are never
909+
/// touched, and the marker means a user who deletes every rule does not
910+
/// get them back on the next launch.
911+
///
912+
/// The LaunchServices lookups behind `suggestionsForInstalledApps` are one
913+
/// per catalog entry, so they run off the launch path — a menu bar app must
914+
/// not stall its first paint on a few dozen disk-backed queries.
915+
func seedWritingStyleCatalogIfNeeded() {
916+
guard !writingStyleCatalogSeeded else { return }
917+
// Claim the marker on the main actor before detaching, so a second
918+
// call cannot start a duplicate seed.
919+
writingStyleCatalogSeeded = true
920+
921+
let running = AppIdentityMatching.workspaceRunningApps()
922+
Task.detached(priority: .utility) { [weak self] in
923+
let suggestions = WritingStyleCatalog.suggestionsForInstalledApps(running: running)
924+
guard let self else { return }
925+
await self.applyWritingStyleSeed(suggestions)
926+
}
927+
}
928+
929+
/// Merge a computed seed into the binding list. Split out so tests can
930+
/// supply suggestions directly instead of querying LaunchServices.
931+
func applyWritingStyleSeed(_ suggestions: [WritingStyleCatalog.Suggestion]) {
932+
guard !suggestions.isEmpty else {
933+
VocaLogger.info(.appState, "No writing style suggestions matched installed apps")
934+
return
935+
}
936+
writingStyleBindings = WritingStyleCatalog.merging(writingStyleBindings, with: suggestions)
937+
VocaLogger.info(.appState, "Seeded \(suggestions.count) writing style rules")
938+
}
939+
940+
/// Add every suggestion for an installed app that is not already bound.
941+
/// Returns how many rules were added.
942+
@discardableResult
943+
func addSuggestedWritingStyles() -> Int {
944+
let existing = writingStyleBindings
945+
let merged = WritingStyleCatalog.merging(
946+
existing,
947+
with: WritingStyleCatalog.suggestionsForInstalledApps()
948+
)
949+
writingStyleBindings = merged
950+
return merged.count - existing.count
951+
}
952+
953+
/// Format sample text the way the given style would, for the Settings
954+
/// preview. Uses the same engine as the real pipeline.
955+
func writingStylePreview(_ sample: String, style: WritingStyle) -> String {
956+
WritingStyleEngine.format(
957+
sample.trimmingCharacters(in: .whitespacesAndNewlines),
958+
rules: style.defaultRules,
959+
globalAutoCapitalize: autoCapitalize,
960+
globalTrailingSpace: appendTrailingSpace
961+
)
962+
}
963+
826964
// MARK: - Force Recovery
827965

828966
/// Forcibly reset the entire recording pipeline to idle state.
@@ -871,6 +1009,11 @@ final class AppState: ObservableObject {
8711009
return
8721010
}
8731011

1012+
// Snapshot the target app now. Injection re-reads the frontmost app —
1013+
// that is what actually receives the text — and only falls back to this
1014+
// when VocaMac itself is in front at that point.
1015+
pendingTargetApp = frontmostAppResolver.currentFrontmostApp()
1016+
8741017
guard appStatus == .idle else {
8751018
// If stuck in .processing or .error for too long, force recovery
8761019
// so the user can start a fresh recording.
@@ -1023,19 +1166,38 @@ final class AppState: ObservableObject {
10231166

10241167
let trimmedText = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
10251168
if !trimmedText.isEmpty {
1026-
let polished = DictationOutputFormatter.apply(
1027-
trimmedText,
1028-
autoCapitalize: autoCapitalize,
1029-
appendTrailingSpace: appendTrailingSpace
1030-
)
10311169
if injectResult {
1170+
// Resolve against the app in front right now: that is where
1171+
// the text lands. `pendingTargetApp` covers the case where
1172+
// VocaMac's own window took focus mid-dictation.
1173+
let target = frontmostAppResolver.currentFrontmostApp() ?? pendingTargetApp
1174+
let resolved = resolveWritingStyle(for: target)
1175+
activeWritingStyle = resolved
1176+
1177+
let polished = WritingStyleEngine.format(
1178+
trimmedText,
1179+
rules: resolved.rules,
1180+
globalAutoCapitalize: autoCapitalize,
1181+
globalTrailingSpace: appendTrailingSpace
1182+
)
1183+
VocaLogger.debug(
1184+
.appState,
1185+
"Writing style '\(resolved.style.rawValue)' applied for \(resolved.matchedAppName ?? "default")"
1186+
)
10321187
textInjector.inject(
10331188
text: polished,
10341189
preserveClipboard: preserveClipboard
10351190
)
10361191
} else {
1037-
// Settings Test Dictation: show only in the sidebar footer.
1038-
settingsTestResultText = polished
1192+
// Settings Test Dictation: the frontmost app is VocaMac's
1193+
// own window, so preview against the style the user picked
1194+
// in Settings instead of resolving from the workspace.
1195+
settingsTestResultText = WritingStyleEngine.format(
1196+
trimmedText,
1197+
rules: settingsPreviewStyle.defaultRules,
1198+
globalAutoCapitalize: autoCapitalize,
1199+
globalTrailingSpace: appendTrailingSpace
1200+
)
10391201
}
10401202
} else {
10411203
VocaLogger.info(.appState, "Transcription produced no usable text (silence or blank audio)")

0 commit comments

Comments
 (0)