From 92d015333ab59b933b40423ecc4a80afeb896b2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Dec 2025 22:28:49 +0100 Subject: [PATCH] VoiceWake: add level meter --- .../Sources/Clawdis/VoiceWakeOverlay.swift | 36 +++++++++++++++++++ .../Sources/Clawdis/VoiceWakeRuntime.swift | 6 ++++ 2 files changed, 42 insertions(+) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index 14b69dab9..367afb69f 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -18,6 +18,7 @@ final class VoiceWakeOverlayController: ObservableObject { var attributed: NSAttributedString = NSAttributedString(string: "") var isOverflowing: Bool = false var isEditing: Bool = false + var level: Double = 0 // normalized 0...1 speech level for UI } private var window: NSPanel? @@ -42,6 +43,7 @@ final class VoiceWakeOverlayController: ObservableObject { self.model.isSending = false self.model.isEditing = false self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 self.present() self.updateWindowFrame(animate: true) } @@ -61,6 +63,7 @@ final class VoiceWakeOverlayController: ObservableObject { self.model.isSending = false self.model.isEditing = false self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 self.present() self.scheduleAutoSend(after: delay, sendChime: sendChime) } @@ -135,10 +138,15 @@ final class VoiceWakeOverlayController: ObservableObject { Task { @MainActor in window.orderOut(nil) self.model.isVisible = false + self.model.level = 0 } } } + func updateLevel(_ level: Double) { + self.model.level = max(0, min(1, level)) + } + enum DismissReason { case explicit, empty } enum SendOutcome { case sent, empty } @@ -292,6 +300,12 @@ private struct VoiceWakeOverlayView: View { var body: some View { ZStack(alignment: .topLeading) { HStack(alignment: .top, spacing: 8) { + if self.controller.model.isVisible { + LevelBars(level: self.controller.model.level) + .frame(width: 36, height: 26) + .padding(.top, 2) + } + if self.controller.model.isEditing { TranscriptTextView( text: Binding( @@ -569,6 +583,28 @@ private struct CloseButtonOverlay: View { } } +private struct LevelBars: View { + var level: Double + + private let barCount = 14 + + var body: some View { + let capped = max(0, min(1, level)) + let active = Int(Double(barCount) * capped.rounded(.up)) + HStack(alignment: .bottom, spacing: 2) { + ForEach(0.. Void)? var onBeginEditing: (() -> Void)? diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 9408ffd2d..fe39be21e 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -294,6 +294,7 @@ actor VoiceWakeRuntime { self.triggerChimePlayed = false await MainActor.run { AppStateStore.shared.stopVoiceEars() } + await MainActor.run { VoiceWakeOverlayController.shared.updateLevel(0) } let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } // Auto-send should fire as soon as the silence threshold is satisfied (2s after speech, 5s after trigger-only). @@ -330,6 +331,11 @@ actor VoiceWakeRuntime { if rms >= threshold { self.lastHeard = Date() } + + let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) + Task { @MainActor in + VoiceWakeOverlayController.shared.updateLevel(clamped) + } } private static func rmsLevel(buffer: AVAudioPCMBuffer) -> Double? {