feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,94 @@
|
||||
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")
|
||||
Reference in new issue
Block a user