93 lines
2.8 KiB
Swift
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")
|