import Foundation import os enum ConversationPhase: String { case idle case connecting case listening case thinking case speaking } /// Ports the web UI's `inferState` + EMA smoothing (app.js): mic/agent levels /// are smoothed with `prev * 0.6 + raw * 0.4`, thresholds are user > 0.035 and /// agent > 0.02. When remote audio ends, the state returns to listening /// (matching the current web reference at app.js:414-426) rather than becoming /// "thinking" forever. @MainActor final class ConversationStateModel: ObservableObject { @Published private(set) var phase: ConversationPhase = .idle @Published private(set) var micLevel: Double = 0 @Published private(set) var agentLevel: Double = 0 @Published private(set) var isThinkingChipVisible = false static let userThreshold = 0.035 static let agentThreshold = 0.02 static let thinkingDelay: Duration = .seconds(2) private var smoothedMic: Double = 0 private var smoothedAgent: Double = 0 private var lastTransition = Date() private var chipTask: Task? func start() { stop() setPhase(.connecting) } func stop() { chipTask?.cancel() chipTask = nil smoothedMic = 0 smoothedAgent = 0 micLevel = 0 agentLevel = 0 isThinkingChipVisible = false setPhase(.idle) } func tick(localAudioLevel: Float, remoteAudioLevel: Float) { guard phase != .idle else { return } smoothedMic = smoothedMic * 0.6 + Double(localAudioLevel) * 0.4 smoothedAgent = smoothedAgent * 0.6 + Double(remoteAudioLevel) * 0.4 micLevel = smoothedMic agentLevel = smoothedAgent let userSpeaking = smoothedMic > Self.userThreshold let next: ConversationPhase if smoothedAgent > Self.agentThreshold { next = .speaking } else if userSpeaking { next = .listening } else { // Both silent: return to listening. The previous implementation // converted speaking → thinking forever, leaving the UI apparently // frozen after an agent response (matches current web reference). next = .listening } setPhase(next) } private func setPhase(_ next: ConversationPhase) { guard next != phase else { return } let old = phase logger.log("state \(old.rawValue, privacy: .public) -> \(next.rawValue, privacy: .public)") phase = next lastTransition = Date() if next == .thinking { chipTask?.cancel() chipTask = Task { [weak self] in try? await Task.sleep(for: Self.thinkingDelay) guard !Task.isCancelled, let self else { return } if self.phase == .thinking { self.isThinkingChipVisible = true } } } else { chipTask?.cancel() chipTask = nil isThinkingChipVisible = false } } } private let logger = Logger(subsystem: "dev.shane.voice", category: "state")