From bb3606b64f4607aaaf9ba42aa95c02b886fd0975 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Dec 2025 21:25:30 +0100 Subject: [PATCH] VoiceWake: centralize send chime and guard play --- apps/macos/Sources/Clawdis/Utilities.swift | 26 ++++++++++++------- .../Sources/Clawdis/VoicePushToTalk.swift | 11 +++++--- .../Sources/Clawdis/VoiceWakeChime.swift | 5 ++++ .../Sources/Clawdis/VoiceWakeRuntime.swift | 17 +++++++----- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 403a5768e..ef23841a2 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -349,19 +349,23 @@ enum CommandResolver { // Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac. let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm:$PATH" - let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && " - let prjVar = settings.projectRoot.isEmpty ? "" : "PRJ=\(self.shellQuote(settings.projectRoot)); " let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") + let userPRJ = settings.projectRoot + let prjInit = userPRJ.isEmpty ? "" : "PRJ=\(self.shellQuote(userPRJ));" let scriptBody = """ PATH=\(exportedPath); - CLI=""; - \(prjVar) + \(prjInit) + DEFAULT_PRJ="$HOME/Projects/clawdis" + if [ -z "${PRJ:-}" ] && [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ"; fi + if [ -n "${PRJ:-}" ]; then + cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } + fi if command -v clawdis >/dev/null 2>&1; then - \(cdPrefix)clawdis \(quotedArgs); + clawdis \(quotedArgs); elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/clawdis.js" ] && command -v node >/dev/null 2>&1; then - \(cdPrefix)node "$PRJ/bin/clawdis.js" \(quotedArgs); + node "$PRJ/bin/clawdis.js" \(quotedArgs); elif command -v pnpm >/dev/null 2>&1; then - \(cdPrefix)pnpm --silent clawdis \(quotedArgs); + pnpm --silent clawdis \(quotedArgs); else echo "clawdis CLI missing on remote host"; exit 127; fi @@ -383,12 +387,16 @@ enum CommandResolver { args.append(userHost) let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" - let cdPrefix = settings.projectRoot.isEmpty ? "" : "cd \(self.shellQuote(settings.projectRoot)) && " + let userPRJ = settings.projectRoot let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") let scriptBody = """ PATH=\(exportedPath); + PRJ=\(userPRJ.isEmpty ? "" : self.shellQuote(userPRJ)) + DEFAULT_PRJ="$HOME/Projects/clawdis" + if [ -z "${PRJ:-}" ] && [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ"; fi + if [ -n "${PRJ:-}" ]; then cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }; fi if ! command -v clawdis-mac >/dev/null 2>&1; then echo "clawdis-mac missing on remote host"; exit 127; fi; - \(cdPrefix)clawdis-mac \(quotedArgs) + clawdis-mac \(quotedArgs) """ args.append(contentsOf: ["/bin/sh", "-c", scriptBody]) return ["/usr/bin/ssh"] + args diff --git a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift index 303b3a21a..7665c8c12 100644 --- a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift @@ -79,6 +79,7 @@ actor VoicePushToTalk { private var volatile: String = "" private var activeConfig: Config? private var isCapturing = false + private var triggerChimePlayed = false private struct Config { let micID: String? @@ -99,7 +100,9 @@ actor VoicePushToTalk { let config = await MainActor.run { self.makeConfig() } self.activeConfig = config self.isCapturing = true + self.triggerChimePlayed = false if config.triggerChime != .none { + self.triggerChimePlayed = true await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } } await VoiceWakeRuntime.shared.pauseForPushToTalk() @@ -137,21 +140,21 @@ actor VoicePushToTalk { forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } } - if !finalText.isEmpty, let chime = self.activeConfig?.sendChime, chime != .none { - await MainActor.run { VoiceWakeChimePlayer.play(chime) } - } + let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none) await MainActor.run { VoiceWakeOverlayController.shared.presentFinal( transcript: finalText, forwardConfig: forward, delay: finalText.isEmpty ? 0.0 : 0.8, + sendChime: chime, attributed: attributed) } self.committed = "" self.volatile = "" self.activeConfig = nil + self.triggerChimePlayed = false // Resume the wake-word runtime after push-to-talk finishes. _ = await MainActor.run { @@ -209,8 +212,8 @@ actor VoicePushToTalk { self.volatile = Self.delta(after: self.committed, current: transcript) } - let attributed = Self.makeAttributed(committed: self.committed, volatile: self.volatile, isFinal: isFinal) let snapshot = self.committed + self.volatile + let attributed = Self.makeAttributed(committed: self.committed, volatile: self.volatile, isFinal: isFinal) await MainActor.run { VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift index fef4d4f97..a0e661076 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift @@ -42,10 +42,15 @@ struct VoiceWakeChimeCatalog { } } +@MainActor enum VoiceWakeChimePlayer { + private static var lastSound: NSSound? + @MainActor static func play(_ chime: VoiceWakeChime) { guard let sound = self.sound(for: chime) else { return } + self.lastSound = sound + sound.stop() sound.play() } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 2755aa824..7b34ec869 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -22,6 +22,7 @@ actor VoiceWakeRuntime { private var capturedTranscript: String = "" private var isCapturing: Bool = false private var heardBeyondTrigger: Bool = false + private var triggerChimePlayed: Bool = false private var committedTranscript: String = "" private var volatileTranscript: String = "" private var cooldownUntil: Date? @@ -124,6 +125,7 @@ actor VoiceWakeRuntime { self.isCapturing = false self.capturedTranscript = "" self.captureStartedAt = nil + self.triggerChimePlayed = false self.recognitionTask?.cancel() self.recognitionTask = nil self.recognitionRequest?.endAudio() @@ -203,9 +205,6 @@ actor VoiceWakeRuntime { private func beginCapture(transcript: String, config: RuntimeConfig) async { self.isCapturing = true - if config.triggerChime != .none { - await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } - } let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) self.capturedTranscript = trimmed self.committedTranscript = "" @@ -213,6 +212,12 @@ actor VoiceWakeRuntime { self.captureStartedAt = Date() self.cooldownUntil = nil self.heardBeyondTrigger = !trimmed.isEmpty + self.triggerChimePlayed = false + + if config.triggerChime != .none { + self.triggerChimePlayed = true + await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } + } let snapshot = self.committedTranscript + self.volatileTranscript let attributed = Self.makeAttributed( @@ -264,6 +269,7 @@ actor VoiceWakeRuntime { self.captureStartedAt = nil self.lastHeard = nil self.heardBeyondTrigger = false + self.triggerChimePlayed = false await MainActor.run { AppStateStore.shared.stopVoiceEars() } @@ -275,14 +281,13 @@ actor VoiceWakeRuntime { committed: finalTranscript, volatile: "", isFinal: true) - if !finalTranscript.isEmpty, config.sendChime != .none { - await MainActor.run { VoiceWakeChimePlayer.play(config.sendChime) } - } + let sendChime = finalTranscript.isEmpty ? .none : config.sendChime await MainActor.run { VoiceWakeOverlayController.shared.presentFinal( transcript: finalTranscript, forwardConfig: forwardConfig, delay: delay, + sendChime: sendChime, attributed: finalAttributed) }