VoiceWake: streamline chimes, default to Glass

main
Peter Steinberger 2025-12-08 20:50:34 +01:00
parent feb70aeb6b
commit ffaf968940
9 changed files with 22 additions and 55 deletions

View File

@ -37,7 +37,6 @@ let package = Package(
], ],
resources: [ resources: [
.copy("Resources/Clawdis.icns"), .copy("Resources/Clawdis.icns"),
.copy("Resources/Sounds"),
.copy("Resources/WebChat"), .copy("Resources/WebChat"),
], ],
swiftSettings: [ swiftSettings: [

View File

@ -43,10 +43,6 @@ final class AppState: ObservableObject {
} }
} }
@Published var voiceWakeChimeEnabled: Bool {
didSet { UserDefaults.standard.set(self.voiceWakeChimeEnabled, forKey: voiceWakeChimeEnabledKey) }
}
@Published var voiceWakeTriggerChime: VoiceWakeChime { @Published var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } didSet { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) }
} }
@ -152,14 +148,12 @@ final class AppState: ObservableObject {
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = UserDefaults.standard self.swabbleTriggerWords = UserDefaults.standard
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers .stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.voiceWakeChimeEnabled = UserDefaults.standard
.object(forKey: voiceWakeChimeEnabledKey) as? Bool ?? true
self.voiceWakeTriggerChime = Self.loadChime( self.voiceWakeTriggerChime = Self.loadChime(
key: voiceWakeTriggerChimeKey, key: voiceWakeTriggerChimeKey,
fallback: .system(name: defaultVoiceWakeChimeName)) fallback: .system(name: "Glass"))
self.voiceWakeSendChime = Self.loadChime( self.voiceWakeSendChime = Self.loadChime(
key: voiceWakeSendChimeKey, key: voiceWakeSendChimeKey,
fallback: .system(name: defaultVoiceWakeChimeName)) fallback: .system(name: "Glass"))
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool { if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
self.iconAnimationsEnabled = storedIconAnimations self.iconAnimationsEnabled = storedIconAnimations
} else { } else {

View File

@ -8,13 +8,10 @@ let pauseDefaultsKey = "clawdis.pauseEnabled"
let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled" let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled"
let swabbleEnabledKey = "clawdis.swabbleEnabled" let swabbleEnabledKey = "clawdis.swabbleEnabled"
let swabbleTriggersKey = "clawdis.swabbleTriggers" let swabbleTriggersKey = "clawdis.swabbleTriggers"
let voiceWakeChimeEnabledKey = "clawdis.voiceWakeChimeEnabled"
let voiceWakeTriggerChimeKey = "clawdis.voiceWakeTriggerChime" let voiceWakeTriggerChimeKey = "clawdis.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdis.voiceWakeSendChime" let voiceWakeSendChimeKey = "clawdis.voiceWakeSendChime"
let showDockIconKey = "clawdis.showDockIcon" let showDockIconKey = "clawdis.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"] let defaultVoiceWakeTriggers = ["clawd", "claude"]
let defaultVoiceWakeChimeName = "startrek-computer"
let defaultVoiceWakeChimeExtension = "wav"
let voiceWakeMicKey = "clawdis.voiceWakeMicID" let voiceWakeMicKey = "clawdis.voiceWakeMicID"
let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID" let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs" let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"

View File

@ -84,7 +84,6 @@ actor VoicePushToTalk {
let micID: String? let micID: String?
let localeID: String? let localeID: String?
let forwardConfig: VoiceWakeForwardConfig let forwardConfig: VoiceWakeForwardConfig
let chimeEnabled: Bool
let triggerChime: VoiceWakeChime let triggerChime: VoiceWakeChime
let sendChime: VoiceWakeChime let sendChime: VoiceWakeChime
} }
@ -100,7 +99,7 @@ actor VoicePushToTalk {
let config = await MainActor.run { self.makeConfig() } let config = await MainActor.run { self.makeConfig() }
self.activeConfig = config self.activeConfig = config
self.isCapturing = true self.isCapturing = true
if config.chimeEnabled { if config.triggerChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) }
} }
await VoiceWakeRuntime.shared.pauseForPushToTalk() await VoiceWakeRuntime.shared.pauseForPushToTalk()
@ -138,7 +137,7 @@ actor VoicePushToTalk {
forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
} }
if self.activeConfig?.chimeEnabled == true, let chime = self.activeConfig?.sendChime { if let chime = self.activeConfig?.sendChime, chime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(chime) } await MainActor.run { VoiceWakeChimePlayer.play(chime) }
} }
@ -224,7 +223,6 @@ actor VoicePushToTalk {
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID,
localeID: state.voiceWakeLocaleID, localeID: state.voiceWakeLocaleID,
forwardConfig: state.voiceWakeForwardConfig, forwardConfig: state.voiceWakeForwardConfig,
chimeEnabled: state.voiceWakeChimeEnabled,
triggerChime: state.voiceWakeTriggerChime, triggerChime: state.voiceWakeTriggerChime,
sendChime: state.voiceWakeSendChime) sendChime: state.voiceWakeSendChime)
} }

View File

@ -2,6 +2,7 @@ import AppKit
import Foundation import Foundation
enum VoiceWakeChime: Codable, Equatable { enum VoiceWakeChime: Codable, Equatable {
case none
case system(name: String) case system(name: String)
case custom(displayName: String, bookmark: Data) case custom(displayName: String, bookmark: Data)
@ -14,6 +15,8 @@ enum VoiceWakeChime: Codable, Equatable {
var displayLabel: String { var displayLabel: String {
switch self { switch self {
case .none:
return "No Sound"
case let .system(name): case let .system(name):
return VoiceWakeChimeCatalog.displayName(for: name) return VoiceWakeChimeCatalog.displayName(for: name)
case let .custom(displayName, _): case let .custom(displayName, _):
@ -23,12 +26,11 @@ enum VoiceWakeChime: Codable, Equatable {
} }
struct VoiceWakeChimeCatalog { struct VoiceWakeChimeCatalog {
/// Options shown in the picker; first entry is the default bundled tone. /// Options shown in the picker.
static let systemOptions: [String] = [ static let systemOptions: [String] = [
defaultVoiceWakeChimeName, "Glass", // default
"Ping", "Ping",
"Pop", "Pop",
"Glass",
"Frog", "Frog",
"Submarine", "Submarine",
"Funk", "Funk",
@ -36,7 +38,6 @@ struct VoiceWakeChimeCatalog {
] ]
static func displayName(for raw: String) -> String { static func displayName(for raw: String) -> String {
if raw == defaultVoiceWakeChimeName { return "Startrek Computer" }
return raw return raw
} }
} }
@ -50,11 +51,9 @@ enum VoiceWakeChimePlayer {
private static func sound(for chime: VoiceWakeChime) -> NSSound? { private static func sound(for chime: VoiceWakeChime) -> NSSound? {
switch chime { switch chime {
case .none:
return nil
case let .system(name): case let .system(name):
// Prefer bundled tone if present.
if let bundled = bundledSound(named: name) {
return bundled
}
return NSSound(named: NSSound.Name(name)) return NSSound(named: NSSound.Name(name))
case let .custom(_, bookmark): case let .custom(_, bookmark):
@ -70,13 +69,4 @@ enum VoiceWakeChimePlayer {
return NSSound(contentsOf: url, byReference: false) 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)
}
} }

View File

@ -40,7 +40,6 @@ actor VoiceWakeRuntime {
let triggers: [String] let triggers: [String]
let micID: String? let micID: String?
let localeID: String? let localeID: String?
let chimeEnabled: Bool
let triggerChime: VoiceWakeChime let triggerChime: VoiceWakeChime
let sendChime: VoiceWakeChime let sendChime: VoiceWakeChime
} }
@ -52,7 +51,6 @@ actor VoiceWakeRuntime {
triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords), triggers: sanitizeVoiceWakeTriggers(state.swabbleTriggerWords),
micID: state.voiceWakeMicID.isEmpty ? nil : state.voiceWakeMicID, 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, triggerChime: state.voiceWakeTriggerChime,
sendChime: state.voiceWakeSendChime) sendChime: state.voiceWakeSendChime)
return (enabled, config) return (enabled, config)
@ -205,7 +203,7 @@ actor VoiceWakeRuntime {
private func beginCapture(transcript: String, config: RuntimeConfig) async { private func beginCapture(transcript: String, config: RuntimeConfig) async {
self.isCapturing = true self.isCapturing = true
if config.chimeEnabled { if config.triggerChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) }
} }
let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers)
@ -277,7 +275,7 @@ actor VoiceWakeRuntime {
committed: finalTranscript, committed: finalTranscript,
volatile: "", volatile: "",
isFinal: true) isFinal: true)
if config.chimeEnabled { if config.sendChime != .none {
await MainActor.run { VoiceWakeChimePlayer.play(config.sendChime) } await MainActor.run { VoiceWakeChimePlayer.play(config.sendChime) }
} }
await MainActor.run { await MainActor.run {

View File

@ -148,28 +148,18 @@ struct VoiceWakeSettings: View {
private var chimeSection: some View { private var chimeSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 10) {
Toggle(isOn: self.$state.voiceWakeChimeEnabled) { Text("Sounds")
VStack(alignment: .leading, spacing: 2) { .font(.callout.weight(.semibold))
Text("Play sounds")
.font(.callout.weight(.semibold))
Text("Chimes for wake-word and push-to-talk events.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.toggleStyle(.switch)
Spacer() Spacer()
} }
self.chimeRow( self.chimeRow(
title: "Trigger sound", title: "Trigger sound",
selection: self.$state.voiceWakeTriggerChime) selection: self.$state.voiceWakeTriggerChime)
.disabled(!self.state.voiceWakeChimeEnabled)
self.chimeRow( self.chimeRow(
title: "Send sound", title: "Send sound",
selection: self.$state.voiceWakeSendChime) selection: self.$state.voiceWakeSendChime)
.disabled(!self.state.voiceWakeChimeEnabled)
} }
.padding(.top, 4) .padding(.top, 4)
} }
@ -245,14 +235,19 @@ struct VoiceWakeSettings: View {
.frame(width: self.fieldLabelWidth, alignment: .leading) .frame(width: self.fieldLabelWidth, alignment: .leading)
Menu { Menu {
Button("No Sound") { selection.wrappedValue = .none }
Divider()
ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in ForEach(VoiceWakeChimeCatalog.systemOptions, id: \.self) { option in
Button(VoiceWakeChimeCatalog.displayName(for: option)) { Button(VoiceWakeChimeCatalog.displayName(for: option)) {
selection.wrappedValue = .system(name: option) selection.wrappedValue = .system(name: option)
} }
} }
Divider()
Button("Choose file…") { self.chooseCustomChime(for: selection) }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(selection.wrappedValue.displayLabel) Text(selection.wrappedValue.displayLabel)
Spacer()
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -266,11 +261,7 @@ struct VoiceWakeSettings: View {
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
Button("Choose file…") { Button("Play") {
self.chooseCustomChime(for: selection)
}
Button("Test") {
VoiceWakeChimePlayer.play(selection.wrappedValue) VoiceWakeChimePlayer.play(selection.wrappedValue)
} }
.keyboardShortcut(.space, modifiers: [.command]) .keyboardShortcut(.space, modifiers: [.command])

View File

@ -25,7 +25,7 @@ Updated: 2025-12-08 · Owners: mac app
- **Voice Wake** toggle: enables wake-word runtime. - **Voice Wake** toggle: enables wake-word runtime.
- **Hold Cmd+Fn to talk**: enables the push-to-talk monitor. Disabled on macOS < 26. - **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. - 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. - **Sounds**: chimes on trigger detect and on send; defaults to the macOS “Glass” system sound. You can pick any `NSSound`-loadable file (e.g. MP3/WAV/AIFF) for each event or choose **No Sound**.
## Forwarding payload ## Forwarding payload
- `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths. - `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.