Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,6 @@ docs/
.agent/
create_dmg_only.sh
**.m4a
!Sources/Fluid/Resources/*.m4a
# Scratch directory
scratch/
6 changes: 6 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
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 */; };
7CF7A1A530AA000100F00001 /* FV_start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7CF7A1A730AA000100F00001 /* FV_start.m4a */; };
7CF7A1A630AA000100F00001 /* FV_start_2.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7CF7A1A830AA000100F00001 /* FV_start_2.m4a */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -36,6 +38,8 @@
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
7CF7A1A730AA000100F00001 /* FV_start.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start.m4a; sourceTree = "<group>"; };
7CF7A1A830AA000100F00001 /* FV_start_2.m4a */ = {isa = PBXFileReference; lastKnownFileType = audio.m4a; path = Sources/Fluid/Resources/FV_start_2.m4a; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
Expand Down Expand Up @@ -231,6 +235,8 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7CF7A1A530AA000100F00001 /* FV_start.m4a in Resources */,
7CF7A1A630AA000100F00001 /* FV_start_2.m4a in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 1 addition & 1 deletion Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.5.5-Beta.1</string>
<string>1.5.5</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSApplicationCategoryType</key>
Expand Down
3 changes: 3 additions & 0 deletions Sources/Fluid/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ enum AnalyticsEvent: String {
// Meeting transcription
case meetingTranscriptionCompleted = "meeting_transcription_completed"

// Prompts
case customPromptUsed = "custom_prompt_used"

// Errors
case errorOccurred = "error_occurred"
}
Expand Down
63 changes: 56 additions & 7 deletions Sources/Fluid/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -738,16 +738,24 @@ struct ContentView: View {
NavigationLink(value: SidebarItem.voiceEngine) {
Label("Voice Engine", systemImage: "waveform")
.font(.system(size: 15, weight: .medium))
.padding(.leading, 18)
}
.listRowBackground(self.sidebarRowBackground(for: .voiceEngine))

NavigationLink(value: SidebarItem.aiEnhancements) {
Label("AI Enhancements", systemImage: "sparkles")
Label("AI Enhancements", systemImage: "brain")
.font(.system(size: 15, weight: .medium))
.padding(.leading, 18)
}
.listRowBackground(self.sidebarRowBackground(for: .aiEnhancements))
}

NavigationLink(value: SidebarItem.preferences) {
Label("Preferences", systemImage: "gearshape.fill")
.font(.system(size: 15, weight: .medium))
}
.listRowBackground(self.sidebarRowBackground(for: .preferences))

NavigationLink(value: SidebarItem.commandMode) {
Label("Command Mode", systemImage: "terminal.fill")
.font(.system(size: 15, weight: .medium))
Expand Down Expand Up @@ -784,12 +792,6 @@ struct ContentView: View {
}
.listRowBackground(self.sidebarRowBackground(for: .history))

NavigationLink(value: SidebarItem.preferences) {
Label("Preferences", systemImage: "gearshape.fill")
.font(.system(size: 15, weight: .medium))
}
.listRowBackground(self.sidebarRowBackground(for: .preferences))

NavigationLink(value: SidebarItem.feedback) {
Label("Feedback", systemImage: "envelope.fill")
.font(.system(size: 15, weight: .medium))
Expand Down Expand Up @@ -1228,13 +1230,27 @@ struct ContentView: View {
if let profile = SettingsStore.shared.selectedDictationPromptProfile {
let promptBody = SettingsStore.stripBaseDictationPrompt(from: profile.prompt)
if !promptBody.isEmpty {
AnalyticsService.shared.capture(
.customPromptUsed,
properties: self.customPromptAnalyticsProperties(
promptSource: "profile",
overrideEmpty: nil
)
)
return SettingsStore.combineBasePrompt(with: promptBody)
}
}

// Default override (including empty string to intentionally use no system prompt)
if let override = SettingsStore.shared.defaultDictationPromptOverride {
let trimmedOverride = override.trimmingCharacters(in: .whitespacesAndNewlines)
AnalyticsService.shared.capture(
.customPromptUsed,
properties: self.customPromptAnalyticsProperties(
promptSource: "default_override",
overrideEmpty: trimmedOverride.isEmpty
)
)
// Empty override means explicitly use no system prompt
guard !trimmedOverride.isEmpty else { return override }

Expand All @@ -1245,6 +1261,28 @@ struct ContentView: View {
return SettingsStore.defaultDictationPromptText()
}

private func customPromptAnalyticsProperties(promptSource: String, overrideEmpty: Bool?) -> [String: Any] {
let providerID = SettingsStore.shared.selectedProviderID
let providerKey = self.providerKey(for: providerID)
let selectedModel = SettingsStore.shared.selectedModelByProvider[providerKey] ?? SettingsStore.shared.selectedModel ?? ""
let isCustomProvider = !ModelRepository.shared.isBuiltIn(providerID)
let providerName = isCustomProvider ? "Custom Provider" : ModelRepository.shared.displayName(for: providerID)

var properties: [String: Any] = [
"prompt_source": promptSource,
"provider_id": isCustomProvider ? "custom" : providerID,
"provider_name": providerName,
"provider_type": isCustomProvider ? "custom" : "built_in",
]
if !selectedModel.isEmpty {
properties["model"] = isCustomProvider ? "custom" : selectedModel
}
if let overrideEmpty {
properties["override_empty"] = overrideEmpty
}
return properties
}

// MARK: - Local Endpoint Detection

private func isLocalEndpoint(_ urlString: String) -> Bool {
Expand Down Expand Up @@ -1400,6 +1438,7 @@ struct ContentView: View {
// Check if we're in rewrite or command mode
let wasRewriteMode = self.isRecordingForRewrite
let wasCommandMode = self.isRecordingForCommand

if wasRewriteMode {
self.isRecordingForRewrite = false
// Don't reset overlay mode here - let it stay colored until it hides
Expand Down Expand Up @@ -1693,6 +1732,10 @@ struct ContentView: View {
self.menuBarManager.setOverlayMode(.dictation)
}

if SettingsStore.shared.enableTranscriptionSounds, !self.isRecordingForCommand, !self.isRecordingForRewrite {
TranscriptionSoundPlayer.shared.playStartSound()
}

// Capture the focused target PID BEFORE any overlay/UI changes.
// Used to restore focus when the user interacts with overlay dropdowns (e.g. prompt selection).
let focusedPID = TypingService.captureSystemFocusedPID()
Expand Down Expand Up @@ -1939,6 +1982,9 @@ struct ContentView: View {
"Starting voice recording for command",
source: "ContentView"
)
if SettingsStore.shared.enableTranscriptionSounds {
TranscriptionSoundPlayer.shared.playStartSound()
}
Task {
await self.asr.start()
}
Expand Down Expand Up @@ -1969,6 +2015,9 @@ struct ContentView: View {

// Start recording immediately for the rewrite instruction (or text to improve)
DebugLogger.shared.info("Starting voice recording for rewrite/write mode", source: "ContentView")
if SettingsStore.shared.enableTranscriptionSounds {
TranscriptionSoundPlayer.shared.playStartSound()
}
Task {
await self.asr.start()
}
Expand Down
64 changes: 64 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class SettingsStore: ObservableObject {
static let savedProviders = "SavedProviders"
static let verifiedProviderFingerprints = "VerifiedProviderFingerprints"
static let shareAnonymousAnalytics = "ShareAnonymousAnalytics"
static let fluid1InterestCaptured = "Fluid1InterestCaptured"
static let hotkeyShortcutKey = "HotkeyShortcutKey"
static let preferredInputDeviceUID = "PreferredInputDeviceUID"
static let preferredOutputDeviceUID = "PreferredOutputDeviceUID"
Expand All @@ -41,6 +42,8 @@ final class SettingsStore: ObservableObject {
static let launchAtStartup = "LaunchAtStartup"
static let showInDock = "ShowInDock"
static let accentColorOption = "AccentColorOption"
static let enableTranscriptionSounds = "EnableTranscriptionSounds"
static let transcriptionStartSound = "TranscriptionStartSound"
static let pressAndHoldMode = "PressAndHoldMode"
static let enableStreamingPreview = "EnableStreamingPreview"
static let enableAIStreaming = "EnableAIStreaming"
Expand Down Expand Up @@ -373,6 +376,14 @@ final class SettingsStore: ObservableObject {
}
}

var fluid1InterestCaptured: Bool {
get { self.defaults.bool(forKey: Keys.fluid1InterestCaptured) }
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.fluid1InterestCaptured)
}
}

var availableModels: [String] {
get { (self.defaults.array(forKey: Keys.availableAIModels) as? [String]) ?? [] }
set {
Expand Down Expand Up @@ -690,6 +701,33 @@ final class SettingsStore: ObservableObject {
}
}

enum TranscriptionStartSound: String, CaseIterable, Identifiable {
case fluidSfx1 = "fluid_sfx_1"
case fluidSfx2 = "fluid_sfx_2"
case fluidSfx3 = "fluid_sfx_3"
case fluidSfx4 = "fluid_sfx_4"

var id: String { self.rawValue }

var displayName: String {
switch self {
case .fluidSfx1: return "Fluid SFX 1"
case .fluidSfx2: return "Fluid SFX 2"
case .fluidSfx3: return "Fluid SFX 3"
case .fluidSfx4: return "Fluid SFX 4"
}
}

var soundFileName: String {
switch self {
case .fluidSfx1: return "FV_start"
case .fluidSfx2: return "FV_start_2"
case .fluidSfx3: return "sfx_3"
case .fluidSfx4: return "sfx_4"
}
}
}

var accentColorOption: AccentColorOption {
get {
guard let raw = self.defaults.string(forKey: Keys.accentColorOption),
Expand All @@ -709,6 +747,32 @@ final class SettingsStore: ObservableObject {
Color(hex: self.accentColorOption.hex) ?? Color(red: 0.227, green: 0.784, blue: 0.776)
}

var enableTranscriptionSounds: Bool {
get {
let value = self.defaults.object(forKey: Keys.enableTranscriptionSounds)
return value as? Bool ?? true
}
set {
objectWillChange.send()
self.defaults.set(newValue, forKey: Keys.enableTranscriptionSounds)
}
}

var transcriptionStartSound: TranscriptionStartSound {
get {
guard let raw = self.defaults.string(forKey: Keys.transcriptionStartSound),
let option = TranscriptionStartSound(rawValue: raw)
else {
return .fluidSfx4
}
return option
}
set {
objectWillChange.send()
self.defaults.set(newValue.rawValue, forKey: Keys.transcriptionStartSound)
}
}

var launchAtStartup: Bool {
get { self.defaults.bool(forKey: Keys.launchAtStartup) }
set {
Expand Down
Binary file added Sources/Fluid/Resources/FV_end.m4a
Binary file not shown.
Binary file added Sources/Fluid/Resources/FV_start.m4a
Binary file not shown.
Binary file added Sources/Fluid/Resources/FV_start_2.m4a
Binary file not shown.
Binary file added Sources/Fluid/Resources/sfx_3.m4a
Binary file not shown.
Binary file added Sources/Fluid/Resources/sfx_4.m4a
Binary file not shown.
2 changes: 1 addition & 1 deletion Sources/Fluid/Services/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ final class MenuBarManager: ObservableObject {
// Prevent rapid state changes that could cause cycles
guard self.overlayVisible != isRunning else { return }

let delay: DispatchTimeInterval = .milliseconds(150)
let delay: DispatchTimeInterval = .milliseconds(30)
if isRunning {
// Cancel any pending hide operation
self.pendingHideOperation?.cancel()
Expand Down
42 changes: 42 additions & 0 deletions Sources/Fluid/Services/TranscriptionSoundPlayer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import AVFoundation
import Foundation

@MainActor
final class TranscriptionSoundPlayer {
static let shared = TranscriptionSoundPlayer()

private var players: [String: AVAudioPlayer] = [:]

private init() {}

func playStartSound() {
let selected = SettingsStore.shared.transcriptionStartSound
self.play(soundName: selected.soundFileName)
}

private func play(soundName: String) {
guard let url = Bundle.main.url(forResource: soundName, withExtension: "m4a") else {
DebugLogger.shared.error("Missing sound resource: \(soundName).m4a", source: "TranscriptionSoundPlayer")
return
}

do {
let player: AVAudioPlayer
if let existing = self.players[soundName] {
player = existing
} else {
player = try AVAudioPlayer(contentsOf: url)
player.prepareToPlay()
self.players[soundName] = player
}

player.currentTime = 0
player.play()
} catch {
DebugLogger.shared.error(
"Failed to play sound \(soundName).m4a: \(error.localizedDescription)",
source: "TranscriptionSoundPlayer"
)
}
}
}
4 changes: 4 additions & 0 deletions Sources/Fluid/UI/AISettings/AIEnhancementSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ struct AIEnhancementSettingsView: View {
@ObservedObject var promptTest: DictationPromptTestCoordinator
let theme: AppTheme
@State var expandedProviderID: String? = nil
@State var providerSearchText: String = ""
@State var fluid1InterestEmail: String = ""
@State var fluid1InterestErrorMessage: String = ""
@State var fluid1InterestIsSubmitting: Bool = false

var body: some View {
self.aiConfigurationCard
Expand Down
Loading