Files
hope-voice-api/Sources/LiveKit/ConversationState.swift
T
2026-08-31 19:50:46 -04:00

95 lines
3.1 KiB
Swift

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<Void, Never>?
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")