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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 37 additions & 6 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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] = [] {
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"])
}

Expand All @@ -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"])
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -2180,7 +2207,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
)
}
overlayShown = true
self.playAlertSound(named: "Tink")
self.playStartSound()
}
}
audioRecorder.onRecordingFailure = { [weak self] error in
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion Sources/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<String>) -> 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 {
Expand Down