feat: conversation state machine, mic meter, history, wake lock (Phase 3)

This commit is contained in:
snowspeeder committed 2026-08-30 07:36:13 -04:00
1 parent 4986b6d7dc
commit 5483e6ed48
8 files changed
+348 -29

No files matched your search

+43 -2
View File
@@ -1,3 +1,4 @@
import UIKit
import Foundation
import LiveKit
import os
@@ -24,10 +25,12 @@ final class VoiceSession: ObservableObject {
@Published private(set) var state: VoiceSessionState = .idle
@Published private(set) var agent: AgentInfo?
@Published private(set) var isMuted = false
@Published private(set) var micError: String?
private(set) var room = Room()
let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler()
let stateModel = ConversationStateModel()
private let config: ServerConfig
private let tokenClient: TokenClient
private var reconnectAttempted = false
@@ -78,6 +81,7 @@ final class VoiceSession: ObservableObject {
switch result {
case .success:
logger.log("room connected")
UIApplication.shared.isIdleTimerDisabled = true
scanForAgent()
startAgentWait()
case .failure(let error):
@@ -137,6 +141,7 @@ final class VoiceSession: ObservableObject {
}
agentWaitTask?.cancel()
state = .ready
stateModel.start()
logger.log("state=ready, enabling mic")
Task { await enableMicrophone() }
}
@@ -163,6 +168,7 @@ final class VoiceSession: ObservableObject {
do {
try await room.localParticipant.setMicrophone(enabled: true)
isMuted = false
micError = nil
logger.log("mic enabled")
return
} catch {
@@ -172,8 +178,9 @@ final class VoiceSession: ObservableObject {
try? await Task.sleep(for: .seconds(1))
continue
}
// Non-fatal: keep the session usable (text mode still works).
logger.error("mic enable failed after retries: \(message, privacy: .public)")
state = .failed("Microphone error: \(error.localizedDescription)")
micError = "Microphone unavailable (\(error.localizedDescription)). Text mode still works."
}
}
}
@@ -184,11 +191,22 @@ final class VoiceSession: ObservableObject {
try await room.localParticipant.setMicrophone(enabled: !muted)
isMuted = muted
} catch {
state = .failed("Microphone error: \(error.localizedDescription)")
micError = "Microphone error: \(error.localizedDescription)"
}
}
func setMode(_ mode: String) async {
guard isReady else { return }
do {
try await controlChannel.setMode(mode, room: room)
logger.log("set_mode sent: \(mode, privacy: .public)")
} catch {
logger.error("set_mode failed: \(String(describing: error), privacy: .public)")
}
}
func sendText(_ text: String) async throws {
logger.log("sendText lk.chat chars=\(text.count)")
try await room.localParticipant.sendText(text, for: "lk.chat")
}
@@ -197,10 +215,17 @@ final class VoiceSession: ObservableObject {
state = .idle
agent = nil
isMuted = false
micError = nil
reconnectAttempted = false
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await teardownRoom()
}
func tickAudioLevels(local: Float, remote: Float) {
stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote)
}
}
extension VoiceSession: RoomDelegate {
@@ -211,6 +236,22 @@ extension VoiceSession: RoomDelegate {
}
}
nonisolated func room(_ room: Room, didUpdateSpeakingParticipants participants: [Participant]) {
Task { @MainActor [weak self] in
guard let self, self.state == .ready else { return }
var local = Float(0)
var remote = Float(0)
for participant in participants {
if participant.identity?.stringValue == room.localParticipant.identity?.stringValue {
local = max(local, participant.audioLevel)
} else {
remote = max(remote, participant.audioLevel)
}
}
self.tickAudioLevels(local: local, remote: remote)
}
}
nonisolated func room(_ room: Room, participant: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType: EncryptionType) {
logger.log("data topic=\(topic, privacy: .public) bytes=\(data.count) payload=\(String(data: data.prefix(300), encoding: .utf8) ?? "<binary>", privacy: .public)")
Task { @MainActor [weak self] in