feat: LiveKit voice session, control channel, transcripts (Phase 2)

- VoiceSession: token->connect with fresh-room retry on timeout, agent
  discovery, agent_ready wait, mic enable/mute, text send over lk.chat
- ControlChannel: outbound set_voice/set_model/get_models/set_mode/
  media_state; inbound agent_ready/models/model_changed/task_event
- TranscriptHandler: transcription segments + transcript topic finals,
  partial/final dedupe per role
- ConversationView/OrbView: status orb, mic/text-mode toggles, composer
- ServerConfig: plain ws:// signaling for private IPs (self-signed TLS
  not injectable into SDK WebSocket); Info.plist local networking
This commit is contained in:
snowspeeder committed 2026-08-30 07:26:58 -04:00
1 parent 378b8cb36f
commit 4986b6d7dc
14 files changed
+900 -50

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import Foundation
import LiveKit
/// Observes the agent participant's audio level for UI state inference.
@MainActor
final class AgentMonitor: ObservableObject {
@Published private(set) var agentSpeaking = false
@Published private(set) var localSpeaking = false
private let session: VoiceSession
private var pollTask: Task<Void, Never>?
init(session: VoiceSession) {
self.session = session
}
func start() {
stop()
pollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let remote = self.session.room.remoteParticipants.values.first(where: {
$0.kind == .agent || $0.identity?.stringValue == "voice-assistant"
})
self.agentSpeaking = remote?.isSpeaking ?? false
self.localSpeaking = self.session.room.localParticipant.isSpeaking
try? await Task.sleep(for: .milliseconds(250))
}
}
}
func stop() {
pollTask?.cancel()
pollTask = nil
agentSpeaking = false
localSpeaking = false
}
}