diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 16e797344..1289345ea 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -37,6 +37,7 @@ let package = Package( ], resources: [ .copy("Resources/Clawdis.icns"), + .copy("Resources/Sounds"), .copy("Resources/WebChat"), ], swiftSettings: [ diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index bb9ea7eb7..1e5076bc8 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -43,6 +43,18 @@ final class AppState: ObservableObject { } } + @Published var voiceWakeChimeEnabled: Bool { + didSet { UserDefaults.standard.set(self.voiceWakeChimeEnabled, forKey: voiceWakeChimeEnabledKey) } + } + + @Published var voiceWakeTriggerChime: VoiceWakeChime { + didSet { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } + } + + @Published var voiceWakeSendChime: VoiceWakeChime { + didSet { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } + } + @Published var iconAnimationsEnabled: Bool { didSet { UserDefaults.standard.set(self.iconAnimationsEnabled, forKey: iconAnimationsEnabledKey) } } @@ -140,6 +152,14 @@ final class AppState: ObservableObject { self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false self.swabbleTriggerWords = UserDefaults.standard .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers + self.voiceWakeChimeEnabled = UserDefaults.standard + .object(forKey: voiceWakeChimeEnabledKey) as? Bool ?? true + self.voiceWakeTriggerChime = Self.loadChime( + key: voiceWakeTriggerChimeKey, + fallback: .system(name: defaultVoiceWakeChimeName)) + self.voiceWakeSendChime = Self.loadChime( + key: voiceWakeSendChimeKey, + fallback: .system(name: defaultVoiceWakeChimeName)) if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { self.iconAnimationsEnabled = storedIconAnimations } else { @@ -240,6 +260,21 @@ final class AppState: ObservableObject { func setWorking(_ working: Bool) { self.isWorking = working } + + // MARK: - Chime persistence + + private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime { + guard let data = UserDefaults.standard.data(forKey: key) else { return fallback } + if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) { + return decoded + } + return fallback + } + + private func storeChime(_ chime: VoiceWakeChime, key: String) { + guard let data = try? JSONEncoder().encode(chime) else { return } + UserDefaults.standard.set(data, forKey: key) + } } @MainActor diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index eff3e7421..b3e8711ba 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -8,8 +8,13 @@ let pauseDefaultsKey = "clawdis.pauseEnabled" let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled" let swabbleEnabledKey = "clawdis.swabbleEnabled" let swabbleTriggersKey = "clawdis.swabbleTriggers" +let voiceWakeChimeEnabledKey = "clawdis.voiceWakeChimeEnabled" +let voiceWakeTriggerChimeKey = "clawdis.voiceWakeTriggerChime" +let voiceWakeSendChimeKey = "clawdis.voiceWakeSendChime" let showDockIconKey = "clawdis.showDockIcon" let defaultVoiceWakeTriggers = ["clawd", "claude"] +let defaultVoiceWakeChimeName = "startrek-computer" +let defaultVoiceWakeChimeExtension = "wav" let voiceWakeMicKey = "clawdis.voiceWakeMicID" let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" diff --git a/apps/macos/Sources/Clawdis/Resources/Sounds/startrek-computer.wav b/apps/macos/Sources/Clawdis/Resources/Sounds/startrek-computer.wav new file mode 100644 index 000000000..395c360ea Binary files /dev/null and b/apps/macos/Sources/Clawdis/Resources/Sounds/startrek-computer.wav differ diff --git a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift index b216f3fe9..e6b87a2fd 100644 --- a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift @@ -84,6 +84,9 @@ actor VoicePushToTalk { let micID: String? let localeID: String? let forwardConfig: VoiceWakeForwardConfig + let chimeEnabled: Bool + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime } func begin() async { @@ -97,6 +100,9 @@ actor VoicePushToTalk { let config = await MainActor.run { self.makeConfig() } self.activeConfig = config self.isCapturing = true + if config.chimeEnabled { + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } + } await VoiceWakeRuntime.shared.pauseForPushToTalk() await MainActor.run { VoiceWakeOverlayController.shared.showPartial(transcript: "") @@ -132,6 +138,10 @@ actor VoicePushToTalk { forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } } + if self.activeConfig?.chimeEnabled == true, let chime = self.activeConfig?.sendChime { + await MainActor.run { VoiceWakeChimePlayer.play(chime) } + } + await MainActor.run { VoiceWakeOverlayController.shared.presentFinal( transcript: finalText, @@ -213,7 +223,10 @@ actor VoicePushToTalk { return Config( micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, localeID: state.voiceWakeLocaleID, - forwardConfig: state.voiceWakeForwardConfig) + forwardConfig: state.voiceWakeForwardConfig, + chimeEnabled: state.voiceWakeChimeEnabled, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) } // MARK: - Test helpers diff --git a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift new file mode 100644 index 000000000..25fbcd4c2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift @@ -0,0 +1,82 @@ +import AppKit +import Foundation + +enum VoiceWakeChime: Codable, Equatable { + case system(name: String) + case custom(displayName: String, bookmark: Data) + + var systemName: String? { + if case let .system(name) = self { + return name + } + return nil + } + + var displayLabel: String { + switch self { + case let .system(name): + return VoiceWakeChimeCatalog.displayName(for: name) + case let .custom(displayName, _): + return displayName + } + } +} + +struct VoiceWakeChimeCatalog { + /// Options shown in the picker; first entry is the default bundled tone. + static let systemOptions: [String] = [ + defaultVoiceWakeChimeName, + "Ping", + "Pop", + "Glass", + "Frog", + "Submarine", + "Funk", + "Tink", + ] + + static func displayName(for raw: String) -> String { + if raw == defaultVoiceWakeChimeName { return "Startrek Computer" } + return raw + } +} + +enum VoiceWakeChimePlayer { + @MainActor + static func play(_ chime: VoiceWakeChime) { + guard let sound = self.sound(for: chime) else { return } + sound.play() + } + + private static func sound(for chime: VoiceWakeChime) -> NSSound? { + switch chime { + case let .system(name): + // Prefer bundled tone if present. + if let bundled = bundledSound(named: name) { + return bundled + } + return NSSound(named: NSSound.Name(name)) + + case let .custom(_, bookmark): + var stale = false + guard let url = try? URL( + resolvingBookmarkData: bookmark, + options: [.withoutUI, .withSecurityScope], + bookmarkDataIsStale: &stale) + else { return nil } + + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + return NSSound(contentsOf: url, byReference: false) + } + } + + private static func bundledSound(named name: String) -> NSSound? { + guard let url = Bundle.main.url( + forResource: name, + withExtension: defaultVoiceWakeChimeExtension, + subdirectory: "Resources/Sounds") + else { return nil } + return NSSound(contentsOf: url, byReference: false) + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index ae897e553..a7a08b17c 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -40,6 +40,9 @@ actor VoiceWakeRuntime { let triggers: [String] let micID: String? let localeID: String? + let chimeEnabled: Bool + let triggerChime: VoiceWakeChime + let sendChime: VoiceWakeChime } func refresh(state: AppState) async { @@ -48,7 +51,10 @@ actor VoiceWakeRuntime { let config = RuntimeConfig( triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, - localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID) + localeID: state.voiceWakeLocaleID.isEmpty ? nil : state.voiceWakeLocaleID, + chimeEnabled: state.voiceWakeChimeEnabled, + triggerChime: state.voiceWakeTriggerChime, + sendChime: state.voiceWakeSendChime) return (enabled, config) } @@ -199,6 +205,9 @@ actor VoiceWakeRuntime { private func beginCapture(transcript: String, config: RuntimeConfig) async { self.isCapturing = true + if config.chimeEnabled { + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } + } let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) self.capturedTranscript = trimmed self.committedTranscript = "" @@ -268,6 +277,9 @@ actor VoiceWakeRuntime { committed: finalTranscript, volatile: "", isFinal: true) + if config.chimeEnabled { + await MainActor.run { VoiceWakeChimePlayer.play(config.sendChime) } + } await MainActor.run { VoiceWakeOverlayController.shared.presentFinal( transcript: finalTranscript, diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift index 09d30224c..7ff94fb58 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -1,6 +1,8 @@ +import AppKit import AVFoundation import Speech import SwiftUI +import UniformTypeIdentifiers struct VoiceWakeSettings: View { @ObservedObject var state: AppState @@ -71,6 +73,8 @@ struct VoiceWakeSettings: View { isTesting: self.$isTesting, onToggle: self.toggleTest) + self.chimeSection + self.triggerTable Spacer(minLength: 8) @@ -141,6 +145,35 @@ struct VoiceWakeSettings: View { } } + private var chimeSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Toggle(isOn: self.$state.voiceWakeChimeEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Play sounds") + .font(.callout.weight(.semibold)) + Text("Chimes for wake-word and push-to-talk events.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + Spacer() + } + + self.chimeRow( + title: "Trigger sound", + selection: self.$state.voiceWakeTriggerChime) + .disabled(!self.state.voiceWakeChimeEnabled) + + self.chimeRow( + title: "Send sound", + selection: self.$state.voiceWakeSendChime) + .disabled(!self.state.voiceWakeChimeEnabled) + } + .padding(.top, 4) + } + private func addWord() { self.state.swabbleTriggerWords.append("") } @@ -204,6 +237,73 @@ struct VoiceWakeSettings: View { } } + private func chimeRow(title: String, selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 8) { + Text(title) + .font(.callout.weight(.semibold)) + .frame(width: self.fieldLabelWidth, alignment: .leading) + + Menu { + ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in + Button(VoiceWakeChimeCatalog.displayName(for: option)) { + selection.wrappedValue = .system(name: option) + } + } + } label: { + HStack(spacing: 6) { + Text(selection.wrappedValue.displayLabel) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(width: self.controlWidth, alignment: .leading) + .padding(6) + .background(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.25), lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + Button("Choose file…") { + self.chooseCustomChime(for: selection) + } + + Button("Test") { + VoiceWakeChimePlayer.play(selection.wrappedValue) + } + .keyboardShortcut(.space, modifiers: [.command]) + } + + if case let .custom(displayName, _) = selection.wrappedValue { + Text("Custom: \(displayName)") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private func chooseCustomChime(for selection: Binding) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.audio] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.resolvesAliases = true + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + do { + let bookmark = try url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil) + selection.wrappedValue = .custom(displayName: url.lastPathComponent, bookmark: bookmark) + } catch { + // Ignore failures; user can retry. + } + } + } + private func sanitizedTriggers() -> [String] { sanitizeVoiceWakeTriggers(self.state.swabbleTriggerWords) } diff --git a/docs/mac/voicewake.md b/docs/mac/voicewake.md index 58960f0b2..793b2d0e6 100644 --- a/docs/mac/voicewake.md +++ b/docs/mac/voicewake.md @@ -25,6 +25,7 @@ Updated: 2025-12-08 · Owners: mac app - **Voice Wake** toggle: enables wake-word runtime. - **Hold Cmd+Fn to talk**: enables the push-to-talk monitor. Disabled on macOS < 26. - Language & mic pickers, live level meter, trigger-word table, tester, forward target/command all remain unchanged. +- **Sounds**: optional chimes on trigger detect and on send; defaults to a bundled `startrek-computer.wav`. You can pick any `NSSound`-loadable file (e.g. MP3/WAV/AIFF) for each event. ## Forwarding payload - `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.