Files
hope-voice-api/Sources/LiveKit/ConversationState.swift
T

93 lines
2.8 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, and speaking → thinking when both go silent.
@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 {
switch phase {
case .speaking: next = .thinking
default: 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")