VoiceWake: add chimes for trigger and send

This commit is contained in:
Peter Steinberger
2025-12-08 20:45:05 +01:00
parent ded106b9e3
commit feb70aeb6b
9 changed files with 251 additions and 2 deletions

View File

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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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<VoiceWakeChime>) -> 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<VoiceWakeChime>) {
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)
}

View File

@@ -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.