diff --git a/CHANGELOG.md b/CHANGELOG.md index 9988bca9..9cd2619b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC - `MINOR` changes add user-visible features and improvements. - `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements. +## [Unreleased] + +### Added + +- The recording start, stop, and error feedback sounds are now configurable in Settings, with a picker and preview button for each event across the full set of built-in macOS alert sounds. + ## [1.1.0] - 2026-06-03 ### Added diff --git a/Sources/AppState.swift b/Sources/AppState.swift index c65bc809..95aa00d7 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -227,6 +227,9 @@ final class AppState: ObservableObject, @unchecked Sendable { private let pressEnterVoiceCommandStorageKey = "press_enter_voice_command_enabled" private let alertSoundsEnabledStorageKey = "alert_sounds_enabled" private let soundVolumeStorageKey = "sound_volume" + private let startSoundNameStorageKey = "start_sound_name" + private let stopSoundNameStorageKey = "stop_sound_name" + private let errorSoundNameStorageKey = "error_sound_name" private let voiceMacrosStorageKey = "voice_macros" private let commandModeEnabledStorageKey = "command_mode_enabled" private let commandModeStyleStorageKey = "command_mode_style" @@ -529,6 +532,24 @@ final class AppState: ObservableObject, @unchecked Sendable { } } + @Published var startSoundName: String { + didSet { + UserDefaults.standard.set(startSoundName, forKey: startSoundNameStorageKey) + } + } + + @Published var stopSoundName: String { + didSet { + UserDefaults.standard.set(stopSoundName, forKey: stopSoundNameStorageKey) + } + } + + @Published var errorSoundName: String { + didSet { + UserDefaults.standard.set(errorSoundName, forKey: errorSoundNameStorageKey) + } + } + private var precomputedMacros: [PrecomputedMacro] = [] @Published var voiceMacros: [VoiceMacro] = [] { @@ -690,6 +711,9 @@ final class AppState: ObservableObject, @unchecked Sendable { let alertSoundsEnabled = UserDefaults.standard.object(forKey: alertSoundsEnabledStorageKey) != nil ? UserDefaults.standard.bool(forKey: alertSoundsEnabledStorageKey) : soundVolume > 0 + let startSoundName = UserDefaults.standard.string(forKey: startSoundNameStorageKey) ?? "Tink" + let stopSoundName = UserDefaults.standard.string(forKey: stopSoundNameStorageKey) ?? "Pop" + let errorSoundName = UserDefaults.standard.string(forKey: errorSoundNameStorageKey) ?? "Basso" let initialMacros: [VoiceMacro] if let data = UserDefaults.standard.data(forKey: "voice_macros"), @@ -757,6 +781,9 @@ final class AppState: ObservableObject, @unchecked Sendable { self.isPressEnterVoiceCommandEnabled = isPressEnterVoiceCommandEnabled self.alertSoundsEnabled = alertSoundsEnabled self.soundVolume = soundVolume + self.startSoundName = startSoundName + self.stopSoundName = stopSoundName + self.errorSoundName = errorSoundName self.voiceMacros = initialMacros self.pipelineHistory = savedHistory self.hasAccessibility = initialAccessibility @@ -1902,7 +1929,7 @@ final class AppState: ObservableObject, @unchecked Sendable { if triggerMode == .toggle { cancelPendingShortcutStart() } - playAlertSound(named: "Basso") + playErrorSound() scheduleReadyStatusReset(after: 2, matching: ["Select text to transform first"]) } @@ -1918,7 +1945,7 @@ final class AppState: ObservableObject, @unchecked Sendable { if triggerMode == .toggle { cancelPendingShortcutStart() } - playAlertSound(named: "Basso") + playErrorSound() scheduleReadyStatusReset(after: 2, matching: ["Fix Edit Mode modifier"]) } @@ -1999,7 +2026,7 @@ final class AppState: ObservableObject, @unchecked Sendable { activeRecordingTriggerMode = nil currentSessionIntent = .dictation shortcutSessionController.reset() - playAlertSound(named: "Basso") + playErrorSound() showScreenshotPermissionAlert(message: message) return false } @@ -2180,7 +2207,7 @@ final class AppState: ObservableObject, @unchecked Sendable { ) } overlayShown = true - self.playAlertSound(named: "Tink") + self.playStartSound() } } audioRecorder.onRecordingFailure = { [weak self] error in @@ -2412,6 +2439,10 @@ final class AppState: ObservableObject, @unchecked Sendable { sound?.play() } + func playStartSound() { playAlertSound(named: startSoundName) } + func playStopSound() { playAlertSound(named: stopSoundName) } + func playErrorSound() { playAlertSound(named: errorSoundName) } + private func findMatchingMacro(for transcript: String) -> VoiceMacro? { let normalizedTranscript = normalize(transcript) guard !normalizedTranscript.isEmpty else { return nil } @@ -2555,7 +2586,7 @@ final class AppState: ObservableObject, @unchecked Sendable { isTranscribing = true statusText = "Preparing audio..." errorMessage = nil - playAlertSound(named: "Pop") + playStopSound() overlayManager.showTranscribing() audioRecorder.stopRecording { [weak self] fileURL in guard let self else { return } @@ -2999,7 +3030,7 @@ final class AppState: ObservableObject, @unchecked Sendable { statusText = "Screenshot Required" overlayManager.dismiss() - playAlertSound(named: "Basso") + playErrorSound() showScreenshotPermissionAlert(message: message) } // Non-permission errors (transient failures) — continue recording without context diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index d639bf1b..004a3ab7 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1274,7 +1274,7 @@ struct GeneralSettingsView: View { withAnimation(.easeInOut(duration: 0.2)) { showMutedHint = muted || (volume ?? 1) < 0.10 } - appState.playAlertSound(named: "Tink") + appState.playStartSound() } .font(.caption) .disabled(!appState.alertSoundsEnabled) @@ -1290,12 +1290,58 @@ struct GeneralSettingsView: View { .transition(.opacity) } } + + Divider() + + VStack(spacing: 8) { + soundPickerRow("Recording start", selection: $appState.startSoundName) + soundPickerRow("Recording stop", selection: $appState.stopSoundName) + soundPickerRow("Error", selection: $appState.errorSoundName) + } + .disabled(!appState.alertSoundsEnabled) + .opacity(appState.alertSoundsEnabled ? 1 : 0.5) } .onChange(of: appState.alertSoundsEnabled) { enabled in if !enabled { showMutedHint = false } } } + /// The stock macOS alert sounds in /System/Library/Sounds, all loadable + /// by name with NSSound(named:). + private static let systemSoundNames = [ + "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", + "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", + ] + + private func soundPickerRow(_ label: String, selection: Binding) -> some View { + // Include an off-catalog value (e.g. set via `defaults write`) so the + // picker shows it instead of rendering blank. + var options = Self.systemSoundNames + if !options.contains(selection.wrappedValue) { + options.append(selection.wrappedValue) + } + return HStack(spacing: 8) { + Text(label) + .font(.caption) + Spacer() + Picker("", selection: selection) { + ForEach(options, id: \.self) { name in + Text(name).tag(name) + } + } + .labelsHidden() + .frame(width: 130) + Button { + appState.playAlertSound(named: selection.wrappedValue) + } label: { + Image(systemName: "play.circle") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Preview this sound") + } + } + // MARK: Custom Vocabulary private var vocabularySection: some View {