VoiceWake: add chimes for trigger and send
This commit is contained in:
@@ -37,6 +37,7 @@ let package = Package(
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources/Clawdis.icns"),
|
||||
.copy("Resources/Sounds"),
|
||||
.copy("Resources/WebChat"),
|
||||
],
|
||||
swiftSettings: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
82
apps/macos/Sources/Clawdis/VoiceWakeChime.swift
Normal file
82
apps/macos/Sources/Clawdis/VoiceWakeChime.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user