Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; };
DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; };
7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; };
B51800000000000000000002 /* SpokenSendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51800000000000000000001 /* SpokenSendTests.swift */; };
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
Expand Down Expand Up @@ -59,6 +60,7 @@
D1A600000000000000000201 /* SpeakerTurnMergingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerTurnMergingTests.swift; sourceTree = "<group>"; };
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineRetirementDrainTests.swift; sourceTree = "<group>"; };
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = "<group>"; };
B51800000000000000000001 /* SpokenSendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpokenSendTests.swift; sourceTree = "<group>"; };
7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -141,6 +143,7 @@
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */,
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */,
7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */,
B51800000000000000000001 /* SpokenSendTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -303,6 +306,7 @@
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */,
DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */,
7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */,
B51800000000000000000002 /* SpokenSendTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
432 changes: 403 additions & 29 deletions Sources/Fluid/ContentView.swift

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ struct SettingsBackupPayload: Codable, Equatable {
let enableAIStreaming: Bool
let copyTranscriptionToClipboard: Bool
let textInsertionMode: SettingsStore.TextInsertionMode
let spokenSendEnabled: Bool?
let spokenSendImmediatelyEnabled: Bool?
let spokenSendPhrase: String?
let spokenSendKey: SettingsStore.SpokenSendKey?
let preferredInputDeviceUID: String?
// Optional so backups created before microphone priority ordering still decode.
// swiftlint:disable:next discouraged_optional_collection
Expand Down
91 changes: 91 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3195,6 +3195,10 @@ final class SettingsStore: ObservableObject {
enableAIStreaming: self.enableAIStreaming,
copyTranscriptionToClipboard: self.copyTranscriptionToClipboard,
textInsertionMode: self.textInsertionMode,
spokenSendEnabled: self.spokenSendEnabled,
spokenSendImmediatelyEnabled: self.spokenSendImmediatelyEnabled,
spokenSendPhrase: self.spokenSendPhrase,
spokenSendKey: self.spokenSendKey,
preferredInputDeviceUID: self.preferredInputDeviceUID,
microphonePriority: self.microphonePriority,
suppressedMicrophoneUIDs: self.suppressedMicrophoneUIDs.sorted(),
Expand Down Expand Up @@ -3318,6 +3322,18 @@ final class SettingsStore: ObservableObject {
self.enableAIStreaming = payload.enableAIStreaming
self.copyTranscriptionToClipboard = payload.copyTranscriptionToClipboard
self.textInsertionMode = payload.textInsertionMode
if let spokenSendEnabled = payload.spokenSendEnabled {
self.spokenSendEnabled = spokenSendEnabled
}
if let spokenSendImmediatelyEnabled = payload.spokenSendImmediatelyEnabled {
self.spokenSendImmediatelyEnabled = spokenSendImmediatelyEnabled
}
if let spokenSendPhrase = payload.spokenSendPhrase {
self.spokenSendPhrase = spokenSendPhrase
}
if let spokenSendKey = payload.spokenSendKey {
self.spokenSendKey = spokenSendKey
}
self.preferredInputDeviceUID = payload.preferredInputDeviceUID
self.suppressedMicrophoneUIDs = Set(payload.suppressedMicrophoneUIDs ?? [])
if let microphonePriority = payload.microphonePriority {
Expand Down Expand Up @@ -5094,6 +5110,10 @@ private extension SettingsStore {
static let enableAIStreaming = "EnableAIStreaming"
static let copyTranscriptionToClipboard = "CopyTranscriptionToClipboard"
static let textInsertionMode = "TextInsertionMode"
static let spokenSendEnabled = "SpokenSendEnabled"
static let spokenSendImmediatelyEnabled = "SpokenSendImmediatelyEnabled"
static let spokenSendPhrase = "SpokenSendPhrase"
static let spokenSendKey = "SpokenSendKey"
static let autoUpdateCheckEnabled = "AutoUpdateCheckEnabled"
static let betaReleasesEnabled = "BetaReleasesEnabled"
static let lastUpdateCheckDate = "LastUpdateCheckDate"
Expand Down Expand Up @@ -5222,6 +5242,77 @@ private extension SettingsStore {
}

extension SettingsStore {
enum SpokenSendKey: String, CaseIterable, Identifiable, Codable {
case enter
case shiftEnter
case commandEnter

var id: String {
self.rawValue
}

var displayName: String {
switch self {
case .enter:
return "Enter"
case .shiftEnter:
return "Shift + Enter"
case .commandEnter:
return "Command + Enter"
}
}

var eventFlags: CGEventFlags {
switch self {
case .enter:
return []
case .shiftEnter:
return .maskShift
case .commandEnter:
return .maskCommand
}
}
}

var spokenSendEnabled: Bool {
get { self.defaults.object(forKey: Keys.spokenSendEnabled) as? Bool ?? false }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.spokenSendEnabled)
}
}

var spokenSendImmediatelyEnabled: Bool {
get { self.defaults.object(forKey: Keys.spokenSendImmediatelyEnabled) as? Bool ?? true }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.spokenSendImmediatelyEnabled)
}
}

var spokenSendPhrase: String {
get { self.defaults.string(forKey: Keys.spokenSendPhrase) ?? "send it" }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.spokenSendPhrase)
}
}

var spokenSendKey: SpokenSendKey {
get {
guard let raw = self.defaults.string(forKey: Keys.spokenSendKey),
let key = SpokenSendKey(rawValue: raw)
else {
return .enter
}
return key
}
set {
objectWillChange.send()
self.defaults.set(newValue.rawValue, forKey: Keys.spokenSendKey)
}
}

enum TextInsertionMode: String, CaseIterable, Identifiable, Codable {
case standard
case reliablePaste
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import Foundation

enum TerminalAppClassifier {
private static let identityMarkers = [
"terminal",
"iterm",
"warp",
"ghostty",
"kitty",
"alacritty",
"wezterm",
"hyper",
"termius",
]
private static let contextMarkers = [
"terminal",
"shell",
"ssh",
"console",
"xterm",
"hterm",
]

static func isTerminal(appName: String?, bundleID: String?) -> Bool {
let identity = [appName, bundleID]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
return self.identityMarkers.contains { identity.contains($0) }
}

static func isTerminalContext(labels: [String]) -> Bool {
let context = labels.joined(separator: " ").lowercased()
return self.contextMarkers.contains { context.contains($0) }
}
}

extension ASRService {
static func applySpokenPunctuationFormatting(
_ text: String,
Expand Down Expand Up @@ -30,20 +64,16 @@ private enum SpokenPunctuationFormatter {
let haystack = [self.appName, self.bundleID, self.windowTitle]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
return haystack.contains("codex") ||
return TerminalAppClassifier.isTerminal(appName: self.appName, bundleID: self.bundleID) ||
Comment thread
altic-dev marked this conversation as resolved.
TerminalAppClassifier.isTerminalContext(labels: [self.windowTitle ?? ""]) ||
haystack.contains("codex") ||
haystack.contains("chatgpt") ||
haystack.contains("claude") ||
haystack.contains("cursor") ||
haystack.contains("windsurf") ||
haystack.contains("xcode") ||
haystack.contains("visual studio code") ||
haystack.contains("vscode") ||
haystack.contains("terminal") ||
haystack.contains("iterm") ||
haystack.contains("warp") ||
haystack.contains("ghostty") ||
haystack.contains("kitty") ||
haystack.contains("alacritty") ||
haystack.contains("slack") ||
haystack.contains("discord") ||
haystack.contains("teams")
Expand Down
40 changes: 40 additions & 0 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4554,6 +4554,46 @@ final class ASRService: ObservableObject {
)
}

func typeOutputPlanToActiveFieldAndWait(
_ plan: DictationLiteralOutputPlan,
preferredTargetPID: pid_t?,
textReadyAt: TimeInterval? = nil,
tracksDictionaryCorrections: Bool = false,
postInsertionKey: SettingsStore.SpokenSendKey? = nil,
requiredFocusTarget: TypingService.CapturedFocusTarget? = nil
) async -> TypingService.DeliveryOutcome {
let requestedAt = ProcessInfo.processInfo.systemUptime
let textReadyAge = textReadyAt.map { Int(((requestedAt - $0) * 1000).rounded()) }
let text = plan.plainText
DebugLogger.shared.benchmark(
"TYPING_BENCH",
message: "asr_type_request chars=\(text.count) preferredPID=\(preferredTargetPID.map { String($0) } ?? "nil") textReadyAgeMs=\(textReadyAge.map { String($0) } ?? "nil")",
source: "TypingBenchmark"
)
let outcome = await withCheckedContinuation { continuation in
self.typingService.typeOutputPlanInstantly(
plan,
preferredTargetPID: preferredTargetPID,
textReadyAt: textReadyAt,
tracksDictionaryCorrections: tracksDictionaryCorrections,
postInsertionKey: postInsertionKey,
requiredFocusTarget: requiredFocusTarget
) { outcome in
continuation.resume(returning: outcome)
}
}
let dispatchedAt = ProcessInfo.processInfo.systemUptime
let textReadyToDispatchMs = textReadyAt.map {
String(Int(((dispatchedAt - $0) * 1000).rounded()))
} ?? "nil"
DebugLogger.shared.benchmark(
"TYPING_BENCH",
message: "asr_type_dispatched chars=\(text.count) preferredPID=\(preferredTargetPID.map { String($0) } ?? "nil") textReadyToDispatchMs=\(textReadyToDispatchMs)",
source: "TypingBenchmark"
)
return outcome
}

/// Removes filler sounds from transcribed text
static func removeFillerWords(_ text: String) -> String {
guard SettingsStore.shared.removeFillerWordsEnabled else { return text }
Expand Down
8 changes: 8 additions & 0 deletions Sources/Fluid/Services/GlobalHotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,10 @@ final class GlobalHotkeyManager: NSObject {
return tapRecoveryResult
}

if Self.isSynthesizedTypingEvent(event) {
return Unmanaged.passUnretained(event)
}

if self.isShortcutCaptureActiveProvider?() ?? false {
self.resetModifierOnlyShortcutTracking()
return Unmanaged.passUnretained(event)
Expand Down Expand Up @@ -1282,6 +1286,10 @@ final class GlobalHotkeyManager: NSObject {
}
}

nonisolated static func isSynthesizedTypingEvent(_ event: CGEvent) -> Bool {
event.getIntegerValueField(.eventSourceUserData) == TypingService.synthesizedEventUserData
}

private func handlePrimaryDictationTriggerDown() {
switch self.hotkeyMode {
case .hold:
Expand Down
12 changes: 12 additions & 0 deletions Sources/Fluid/Services/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate {
.receive(on: DispatchQueue.main)
.sink { [weak self] newText in
guard self != nil else { return }
if asrService.isRunning,
NotchContentState.shared.mode == .dictation,
SettingsStore.shared.spokenSendEnabled,
!SettingsStore.shared.spokenSendImmediatelyEnabled
{
let detected = SpokenSendParser.parse(
newText,
phrase: SettingsStore.shared.spokenSendPhrase,
enabled: true
).shouldSend
NotchContentState.shared.setSpokenSendIndicatorState(detected ? .detected : .hidden)
}
if NotchOverlayManager.shared.shouldShowOrTrackLivePreviewText {
NotchOverlayManager.shared.updateTranscriptionText(newText)
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Fluid/Services/NotchOverlayManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ final class NotchOverlayManager {

// Safety: reset processing state when hiding
NotchContentState.shared.setProcessing(false)
NotchContentState.shared.setSpokenSendIndicatorState(.hidden)

// Handle visible or showing states (can hide while still expanding)
guard self.state == .visible || self.state == .showing, self.notch != nil else {
Expand Down
Loading
Loading