Files
openclaw/apps/macos/Sources/OpenClaw/TalkModeController.swift
Peter Steinberger 20816f676f feat(voice): universal talk waveform driven by real audio levels (#102901)
* feat(voice): universal talk waveform driven by real audio levels

One shared Siri-style talk animation across iOS, watchOS, macOS, and
Android (TalkWaveformView + exact Compose port), replacing per-platform
fakes: synthetic speaking pulses, constant speech-detected power, static
Android bar rows, flat realtime listening. Listening/recording follow
live mic levels on every route; agent speech follows the real playback
envelope (AVAudioPlayer metering + playback-aligned PCM envelope +
WebRTC stats); voice-note recording shows a live capture wave.

* fix(android): order waveform imports and sync native i18n inventory

* chore(i18n): resync native inventory after rebase
2026-07-09 16:33:25 +01:00

151 lines
5.3 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(set) var phase: TalkModePhase = .idle
private(set) var isPaused: Bool = false
/// 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 {
TalkOverlayController.shared.present()
} 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
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) {
TalkOverlayController.shared.updateLevel(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 }
TalkOverlayController.shared.updateLevel(level ?? 0)
}
/// 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>
{
let envelope = self.playbackEnvelope
envelope.begin(sampleRate: sampleRate)
return AsyncThrowingStream { continuation in
let task = Task { @MainActor in
do {
for try await chunk in stream {
envelope.append(chunk)
continuation.yield(chunk)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
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
}