Files
openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift
Peter Steinberger 311f5bfbaa feat(macos): honor the selected microphone in Talk and show live voice activity in chat (#109995)
* feat(macos): honor the selected microphone in Talk and surface live level, transcript, and mic picker in chat

* chore(i18n): translate new talk activity strings and regenerate Apple/Android catalogs
2026-07-17 15:45:46 +01:00

165 lines
6.0 KiB
Swift

import AppKit
import Observation
import OpenClawKit
@MainActor
@Observable
final class TalkModeController {
static let shared = TalkModeController()
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.controller")
private static let transcriptLimit = 20
private(set) var phase: TalkModePhase = .idle
private(set) var isPaused: Bool = false
private(set) var level: Double = 0
private(set) var partialTranscript: String = ""
private(set) var recentTranscripts: [String] = []
/// Meters streamed PCM speech so the orb waveform follows the audible
/// envelope instead of a synthetic pulse.
@ObservationIgnored private lazy var playbackEnvelope = PCMPlaybackEnvelope { [weak self] level in
self?.updateSpeakingLevel(level)
}
func setEnabled(_ enabled: Bool) async {
self.logger.info("talk enabled=\(enabled)")
if enabled {
self.partialTranscript = ""
self.recentTranscripts = []
TalkOverlayController.shared.present()
await VoiceWakeRuntime.shared.pauseForPushToTalk()
} else {
TalkOverlayController.shared.dismiss()
}
TalkSpeechInterruptMonitor.shared.setEnabled(enabled && AppStateStore.shared.talkShiftToStopEnabled)
// Talk Mode and Push-to-Talk share the right Option key disable PTT while Talk Mode is active.
let pttEnabled = !enabled && AppStateStore.shared.voicePushToTalkEnabled
VoicePushToTalkHotkey.shared.setEnabled(pttEnabled)
await TalkModeRuntime.shared.setEnabled(enabled)
// Resume voice wake listener *after* TalkMode audio is fully torn down.
// Check swabbleEnabled (not voiceWakeTriggersTalkMode) so the paused wake listener
// resumes even if the user toggled "Trigger Talk Mode" off during the session.
if !enabled, AppStateStore.shared.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: AppStateStore.shared) }
}
}
func updatePhase(_ phase: TalkModePhase) {
let previousPhase = self.phase
self.phase = phase
if phase == .idle || phase == .thinking {
self.updateLevel(0)
}
TalkOverlayController.shared.updatePhase(phase)
// Play distinct system sounds for each phase transition.
if phase != previousPhase {
Self.playPhaseSound(phase, previousPhase: previousPhase)
}
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
}
private static func playPhaseSound(_ phase: TalkModePhase, previousPhase: TalkModePhase) {
guard AppStateStore.shared.talkPhaseSoundsEnabled else { return }
let soundName: String? = switch phase {
case .thinking:
"Tink" // :
case .speaking:
"Pop" // :
case .listening:
// (speakinglistening):
// (thinkinglistening ):
previousPhase == .speaking ? "Bottle" : "Submarine"
case .idle:
nil
}
if let soundName {
NSSound(named: NSSound.Name(soundName))?.play()
}
}
func updateLevel(_ level: Double) {
let clamped = min(max(level, 0), 1)
if clamped == 0 {
self.level = 0
} else {
let response = clamped > self.level ? 0.45 : 0.18
self.level += (clamped - self.level) * response
}
TalkOverlayController.shared.updateLevel(self.level)
}
/// Playback level published while agent speech plays; nil (path without
/// metering, or playback ended) settles the wave back to its floor.
func updateSpeakingLevel(_ level: Double?) {
guard self.phase == .speaking else { return }
self.updateLevel(level ?? 0)
}
func updatePartialTranscript(_ transcript: String) {
self.partialTranscript = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
}
func commitTranscript(_ transcript: String) {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
self.partialTranscript = ""
guard !trimmed.isEmpty else { return }
self.recentTranscripts.append(trimmed)
if self.recentTranscripts.count > Self.transcriptLimit {
self.recentTranscripts.removeFirst(self.recentTranscripts.count - Self.transcriptLimit)
}
}
/// Passes streamed PCM speech through to the player while feeding the
/// playback envelope; call `endSpeechMetering` once playback returns.
func meteredSpeechStream(
_ stream: AsyncThrowingStream<Data, Error>,
sampleRate: Double) -> AsyncThrowingStream<Data, Error>
{
self.playbackEnvelope.metering(stream, sampleRate: sampleRate)
}
func endSpeechMetering() {
self.playbackEnvelope.cancel()
}
func setPaused(_ paused: Bool) {
guard self.isPaused != paused else { return }
self.logger.info("talk paused=\(paused)")
self.isPaused = paused
TalkOverlayController.shared.updatePaused(paused)
let effectivePhase = paused ? "paused" : self.phase.rawValue
Task {
await GatewayConnection.shared.talkMode(
enabled: AppStateStore.shared.talkEnabled,
phase: effectivePhase)
}
Task { await TalkModeRuntime.shared.setPaused(paused) }
}
func togglePaused() {
self.setPaused(!self.isPaused)
}
func stopSpeaking(reason: TalkStopReason = .userTap) {
Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) }
}
func exitTalkMode() {
Task { await AppStateStore.shared.setTalkEnabled(false) }
}
}
enum TalkStopReason {
case userTap
case speech
case manual
}