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

+61
View File
@@ -0,0 +1,61 @@
# Voice iOS — Plan & Progress
Goal: full-parity native iOS app per UPDATE_PATH.md, verified in simulator via XcodeBuildMCP.
## Setup (reference)
- Repo /Users/shane/dev/voice-ios, branch feat/ios-app
- xcodegen at /opt/homebrew/bin/xcodegen; regenerate after file changes: `xcodegen generate`
- Build: `xcodebuild -project Voice.xcodeproj -scheme Voice_iOS -destination 'generic/platform=iOS Simulator' build` (scheme is **Voice_iOS**, not Voice)
- Run/relaunch: xcodebuildmcp_build_run_sim or launch_app_sim (bundle dev.shane.voice, sim iPhone 17 Pro AFDA137E-1657-428E-BF01-E5CD7989772C)
- UI: snapshot_ui → tap elementRef. Read logs: `xcrun simctl spawn booted log show --last Nm --predicate 'process == "Voice"' --style compact | grep -E "io.livekit|dev.shane.voice"`
- Mic permission (if simulator wiped): `xcrun simctl privacy booted grant microphone dev.shane.voice`
## Server facts (verified)
- Base https://192.168.86.2:9178 (nginx, self-signed CN=192.168.86.2; cert regenerates daily-ish → pins break on rotation)
- POST /token → {token, room: voice-shane-<hex8>, user{id, display_name}}
- LiveKit host-network container `voice` on Unraid (ssh root@192.168.86.2): signaling 7880/tcp, RTC tcp 7881, udp 7882 — all reachable from Mac (nc verified)
- SDK cannot trust self-signed cert on its WebSocket → use plain ws://192.168.86.2:7880 (no /livekit path; SDK derives /rtc). Token+media only via nginx :9178 TLS (AVPlayer media needs local proxy in Phase 5)
- agent_ready on topic conversation-control; models on voice-control (plain string list); agent identity agent-*, kind .agent; lk.agent.session stream unhandled (harmless)
- TLSPinning.session() = custom URLSessionDelegate DER pin for our HTTP calls
## Phases
- [x] Phase 0: scaffold (commit 297fbaa)
- [x] Phase 1: ServerConfig/TokenClient/TLSPinning/ConnectionTest (commit 378b8cb)
- [ ] Phase 2: LiveKit session spike — **ACTIVE**
- [ ] Phase 3: state machine, audio levels, wake lock, history
- [ ] Phase 4: media playback reconciliation
- [ ] Phase 5: AVPlayer + local reverse proxy for self-signed https media
- [ ] Phase 6: settings parity (voice/model pickers, text mode)
- [ ] Phase 7: polish/edge cases
- [ ] Phase 8: real-device qualification checklist
- [ ] Phase 9: docs/final
## Phase 2 status
Done: VoiceSession (token→connect, agent wait, mic enable, mute, sendText lk.chat), ControlChannel, TranscriptHandler, AgentMonitor, ConversationView/OrbView. Verified once end-to-end yesterday: token→room connected→agent_ready→models received.
UNCOMMITTED: all Phase 2 files + ServerConfig ws:// derivation + Info.plist NSAllowsLocalNetworking.
### CONNECT TIMEOUT BUG — FIXED (2026-08-30 ~07:15)
Root cause: stale `Room` instance after a timed-out connect left signaling/transport in a broken state; subsequent connects hung. Fix in VoiceSession.swift:
- `room` is now `private(set) var`, recreated fresh via `makeRoom()` (delegates re-attached) on each retry.
- `attemptConnect()` retries up to 2 extra times (3 total) on `.timedOut`: tears down the room (`teardownRoom()`) and reconnects with a new Room + fresh token.
- `makeConnectOptions()` sets explicit timeouts (socket 15s, primary/publisher transport 20s).
- Removed manual `AVAudioSession` config/deactivation — the SDK's `AudioSessionEngineObserver` (isAutomaticConfigurationEnabled=true by default) owns the session; our manual setCategory/setActive raced it.
Verified: token→room connected in <1s (retry fired once on a slow first attempt, then succeeded), agent_ready received, state=ready. 3/3 connect attempts now reach room-connected.
### REMAINING: mic enable -4010 (simulator env limitation, NOT app bug)
After state=ready, `setMicrophone(enabled:true)` fails with CoreAudio `-4010` (kAudioUnitErr_InvalidElement) on all 3 retries. Investigated thoroughly:
- TCC: `xcrun simctl privacy booted grant microphone dev.shane.voice` applied; still -4010 → not a permission issue.
- CoreAudio logs: input node reports **0 channel descriptions** and the engine associates with audio session `(0x0)` (null) → the simulator is not providing a usable mic source to this app in this environment.
- NSMicrophoneUsageDescription present in Info.plist; SDK auto-configures the session correctly.
Conclusion: simulator microphone hardware/source unavailable here. App code path is correct (mic enable retries, proper error surfacing). Needs verification on a real device or a simulator with a working mic source. Do NOT treat -4010 as an app regression.
### After connect bug fixed
- [x] Verify ready state reached (state=ready logged; UI transitions to conversation view)
- [ ] Verify transcript arrival + disconnect cleanup (blocked on mic/audio in sim; do on device)
- [x] Fix Swift 6 warnings: ControlChannel @MainActor, VoiceSession closure, duplicate TranscriptHandler removed from ConversationView (build now warning-free)
- [ ] Commit Phase 2, mark done
## Subagent notes
- Small context model: keep prompts self-contained, point at this file + UPDATE_PATH.md
- Watch for loops (repeated identical commands) — kill and redirect if seen
- Don't let subagents loop reading SDK source; compile instead. SDK checkout: ~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/DerivedData/Voice-38a9ac34e5c8/SourcePackages/checkouts/client-sdk-swift
+10 -3
View File
@@ -22,14 +22,21 @@
<string>1</string> <string>1</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSExceptionDomains</key> <key>NSAllowsLocalNetworking</key>
<true/>
<key>NSPinnedDomains</key>
<dict> <dict>
<key>192.168.86.2</key> <key>192.168.86.2</key>
<dict> <dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>vmrfRuHv1oHBBiEmJP4WX3C7mtVy3C4+UJ1Yk/Q5hMU=</string>
</dict>
</array>
</dict> </dict>
</dict> </dict>
</dict> </dict>
+5
View File
@@ -1,9 +1,14 @@
import SwiftUI import SwiftUI
import LiveKit
@main @main
struct VoiceApp: App { struct VoiceApp: App {
@StateObject private var settings = SettingsStore() @StateObject private var settings = SettingsStore()
init() {
LiveKitSDK.setLogLevel(.debug)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ConversationView(settings: settings) ConversationView(settings: settings)
@@ -2,33 +2,161 @@ import SwiftUI
struct ConversationView: View { struct ConversationView: View {
@ObservedObject var settings: SettingsStore @ObservedObject var settings: SettingsStore
@StateObject private var session: VoiceSession
@State private var composerText = ""
@State private var connectError: String?
init(settings: SettingsStore) {
self.settings = settings
let config = ServerConfig.parse(settings.trimmedServerURL)
if case .success(let value) = config {
_session = StateObject(wrappedValue: VoiceSession(
config: value,
tokenClient: TokenClient(config: value)
))
} else {
let fallback = ServerConfig.parse("https://192.168.86.2:9178")
if case .success(let value) = fallback {
_session = StateObject(wrappedValue: VoiceSession(
config: value,
tokenClient: TokenClient(config: value)
))
} else {
fatalError("Unreachable: default server URL is valid")
}
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 24) { VStack(spacing: 0) {
Spacer() statusHeader
Circle() TranscriptListView(events: session.transcriptHandler.events)
.fill(.blue.gradient) composer
.frame(width: 96, height: 96)
Text("Hope Voice")
.font(.title.bold())
Text(settings.trimmedServerURL)
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
} }
.navigationTitle("Voice") .navigationTitle("Hope Voice")
.toolbar { .toolbar { toolbarContent }
ToolbarItem(placement: .topBarTrailing) { .onAppear {
NavigationLink { if case .failure(let error) = ServerConfig.parse(settings.trimmedServerURL) {
SettingsView(settings: settings) connectError = error.localizedDescription
} label: {
Image(systemName: "gearshape")
}
} }
} }
} }
} }
private var statusHeader: some View {
VStack(spacing: 12) {
OrbView(state: orbState)
Text(statusText)
.font(.headline)
if let agent = session.agent {
Text("Agent: \(agent.name ?? agent.identity)")
.font(.caption)
.foregroundStyle(.secondary)
}
if session.state == .ready {
HStack(spacing: 16) {
Toggle(isOn: Binding(
get: { !session.isMuted },
set: { newValue in Task { await session.setMuted(!newValue) } }
)) {
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
}
.toggleStyle(.button)
Toggle("Text mode", isOn: $settings.textMode)
.toggleStyle(.button)
}
}
if let connectError {
Label(connectError, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding()
}
private var composer: some View {
HStack(spacing: 8) {
switch session.state {
case .idle, .failed:
Button(action: startSession) {
Label("Start", systemImage: "phone.fill")
}
.buttonStyle(.borderedProminent)
case .connecting, .waitingAgent:
ProgressView()
case .ready:
TextField(settings.textMode ? "Type a message…" : "Voice mode active",
text: $composerText,
axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.disabled(!settings.textMode)
Button(action: sendComposer) {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
.disabled(!settings.textMode || composerText.isEmpty)
Button(role: .destructive, action: endSession) {
Image(systemName: "phone.down.fill")
}
}
}
.padding()
}
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
SettingsView(settings: settings)
} label: {
Image(systemName: "gearshape")
}
}
}
private var orbState: OrbView.OrbState {
switch session.state {
case .idle: .idle
case .connecting, .waitingAgent: .connecting
case .ready: settings.textMode ? .idle : .listening
case .failed: .failed
}
}
private var statusText: String {
switch session.state {
case .idle: "Ready to connect"
case .connecting: "Connecting…"
case .waitingAgent: "Waiting for agent…"
case .ready: settings.textMode ? "Text mode" : "Listening"
case .failed(let message): "Failed: \(message)"
}
}
private func startSession() {
connectError = nil
session.transcriptHandler.clear()
Task { await session.connect() }
}
private func endSession() {
Task { await session.disconnect() }
}
private func sendComposer() {
let text = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
composerText = ""
Task {
do {
try await session.sendText(text)
} catch {
connectError = error.localizedDescription
}
}
}
} }
#Preview { #Preview {
+101
View File
@@ -0,0 +1,101 @@
import SwiftUI
struct OrbView: View {
enum OrbState {
case idle
case connecting
case listening
case speaking
case thinking
case failed
}
let state: OrbState
@State private var animating = false
private var baseColor: Color {
switch state {
case .idle, .connecting: .blue
case .listening: .green
case .speaking, .thinking: .orange
case .failed: .red
}
}
private var scale: CGFloat {
switch state {
case .connecting, .listening, .speaking, .thinking: 1.1
default: 1.0
}
}
var body: some View {
let shape = Circle()
.fill(baseColor.gradient)
.frame(width: 120, height: 120)
let animated = shape
.scaleEffect(animating ? scale : 0.95)
.opacity(state == .idle || state == .failed ? 0.7 : 1.0)
let anim: Animation? = state == .idle || state == .failed
? nil
: .easeInOut(duration: 0.8).repeatForever(autoreverses: true)
animated
.animation(anim, value: animating)
.onAppear { animating = true }
.onChange(of: state) { _, _ in animating = true }
}
}
struct TranscriptListView: View {
let events: [TranscriptEvent]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(events) { event in
transcriptRow(event)
}
}
.padding(.horizontal)
}
.onChange(of: events.count) { _, count in
if let last = events.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
}
private func transcriptRow(_ event: TranscriptEvent) -> some View {
let roleName = event.role == .user ? "You" : "Hope"
let nameColor: Color = event.role == .user ? .secondary : .blue
let rowBackground: Color = event.role == .user
? Color(.secondarySystemBackground)
: Color.blue.opacity(0.08)
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
return VStack(alignment: .leading, spacing: 2) {
Text(roleName)
.font(.caption2.bold())
.foregroundStyle(nameColor)
Text(stripped(event.text))
.font(.body)
.opacity(textOpacity)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(rowBackground)
.cornerRadius(10)
.id(event.id)
}
private func stripped(_ text: String) -> String {
// Strip delivery-style markers like [appreciative], [pauses] etc.
text.replacingOccurrences(
of: #"\[[a-zA-Z ,'-]+\]"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
}
}
+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
}
}
+149
View File
@@ -0,0 +1,149 @@
import Foundation
import LiveKit
/// Outbound helpers for agent control topics and inbound parsing of
/// voice-control / conversation-control / media-control data messages.
@MainActor
final class ControlChannel: NSObject, RoomDelegate {
struct ModelInfo: Equatable {
let id: String
let name: String?
}
enum InboundEvent {
case agentReady
case models([ModelInfo], selected: String?)
case modelChanged(String)
case taskEvent(TaskEventPayload)
}
struct TaskEventPayload: Equatable {
let type: String
let taskId: String?
let description: String?
let role: String?
let content: String?
let result: String?
let error: String?
}
var onEvent: ((InboundEvent) -> Void)?
private let decoder = JSONDecoder()
/// Inbound voice-control handling. Returns true when the message was consumed.
func handleVoiceControl(_ data: Data, session: VoiceSession) -> Bool {
guard let json = try? decoder.decode([String: JSONValue].self, from: data),
let type = json["type"]?.stringValue else { return false }
switch type {
case "agent_ready":
Task { @MainActor [weak session] in
session?.agentReadyReceived()
}
return true
case "models":
var models: [ModelInfo] = []
for item in json["models"]?.arrayValue ?? [] {
if let obj = item.objectValue, let id = obj["id"]?.stringValue {
models.append(ModelInfo(id: id, name: obj["name"]?.stringValue))
} else if let id = item.stringValue {
models.append(ModelInfo(id: id, name: nil))
}
}
onEvent?(.models(models, selected: json["selected"]?.stringValue))
return true
case "model_changed":
if let model = json["model"]?.stringValue {
onEvent?(.modelChanged(model))
}
return true
default:
return false
}
}
/// Inbound task_event handling (topic-agnostic per web UI). Returns true when consumed.
func handleTaskEvent(_ data: Data) -> Bool {
guard let json = try? decoder.decode([String: JSONValue].self, from: data),
let type = json["type"]?.stringValue,
["created", "step", "completed", "failed"].contains(type) else { return false }
let payload = TaskEventPayload(
type: type,
taskId: json["task_id"]?.stringValue,
description: json["description"]?.stringValue,
role: json["role"]?.stringValue,
content: json["content"]?.stringValue,
result: json["result"]?.stringValue,
error: json["error"]?.stringValue
)
onEvent?(.taskEvent(payload))
return true
}
// MARK: Outbound (topic'd JSON over reliable data streams)
private func publish(_ object: [String: Any], topic: String, room: Room) async throws {
let text = String(data: try JSONSerialization.data(withJSONObject: object), encoding: .utf8) ?? "{}"
_ = try await room.localParticipant.sendText(text, for: topic)
}
func setVoice(_ voiceID: String, room: Room) async throws {
try await publish(["type": "set_voice", "voice": voiceID], topic: "voice-control", room: room)
}
func setModel(_ modelID: String, room: Room) async throws {
try await publish(["type": "set_model", "model": modelID], topic: "voice-control", room: room)
}
func getModels(room: Room) async throws {
try await publish(["type": "get_models"], topic: "voice-control", room: room)
}
func setMode(_ mode: String, room: Room) async throws {
try await publish(["type": "set_mode", "mode": mode], topic: "conversation-control", room: room)
}
func sendMediaState(_ state: String, trackID: String?, room: Room) async throws {
var object: [String: Any] = ["type": "media_state", "state": state]
if let trackID { object["track_id"] = trackID }
try await publish(object, topic: "media-control", room: room)
}
}
// MARK: - Minimal JSON value decoder for flexible agent payloads
indirect enum JSONValue: Decodable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([JSONValue].self) { self = .array(value) }
else if let value = try? container.decode([String: JSONValue].self) { self = .object(value) }
else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") }
}
var stringValue: String? {
if case .string(let value) = self { return value }
return nil
}
var objectValue: [String: JSONValue]? {
if case .object(let value) = self { return value }
return nil
}
var arrayValue: [JSONValue]? {
if case .array(let value) = self { return value }
return nil
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
import LiveKit
struct TranscriptEvent: Equatable, Identifiable {
enum Role: String {
case user
case agent
}
let id: String
let role: Role
var text: String
var isFinal: Bool
let timestamp: Date
}
/// Merges the primary transcription segments (Room delegate) with the
/// secondary `transcript` data topic (finals only). Dedupe rule: a final for a
/// role closes any open partial row for that role.
@MainActor
final class TranscriptHandler: ObservableObject {
@Published private(set) var events: [TranscriptEvent] = []
func receiveSegment(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
guard !text.isEmpty else { return }
upsert(id: id, role: role, text: text, isFinal: isFinal)
}
func receiveDataTranscript(role: TranscriptEvent.Role, text: String) {
closePartial(for: role)
upsert(id: "final-\(role.rawValue)-\(UUID().uuidString)", role: role, text: text, isFinal: true)
}
func clear() {
events.removeAll()
}
private func closePartial(for role: TranscriptEvent.Role) {
for index in events.indices where !events[index].isFinal && events[index].role == role {
events[index].isFinal = true
}
}
private func upsert(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
if let index = events.firstIndex(where: { $0.id == id }) {
events[index].text = text
events[index].isFinal = isFinal
} else {
events.append(TranscriptEvent(id: id, role: role, text: text, isFinal: isFinal, timestamp: Date()))
}
}
}
extension TranscriptHandler: RoomDelegate {
nonisolated func room(_ room: Room, participant: Participant, trackPublication: TrackPublication, didReceiveTranscriptionSegments segments: [TranscriptionSegment]) {
let role: TranscriptEvent.Role = participant.isAgent ? .agent : .user
let isLocal = participant.identity?.stringValue == room.localParticipant.identity?.stringValue
let resolvedRole: TranscriptEvent.Role = isLocal ? .user : role
let events = segments.map {
TranscriptEvent(id: $0.id, role: resolvedRole, text: $0.text, isFinal: $0.isFinal, timestamp: $0.lastReceivedTime)
}
Task { @MainActor [weak self] in
for event in events {
self?.receiveSegment(id: event.id, role: event.role, text: event.text, isFinal: event.isFinal)
}
}
}
}
+231
View File
@@ -0,0 +1,231 @@
import Foundation
import LiveKit
import os
private let logger = Logger(subsystem: "dev.shane.voice", category: "session")
enum VoiceSessionState: Equatable {
case idle
case connecting
case waitingAgent
case ready
case failed(String)
}
struct AgentInfo: Equatable {
let identity: String
let name: String?
}
/// Room lifecycle: connect, agent discovery, agent_ready wait, reconnect-once,
/// disconnect teardown. Ports the web UI's connect flow.
@MainActor
final class VoiceSession: ObservableObject {
@Published private(set) var state: VoiceSessionState = .idle
@Published private(set) var agent: AgentInfo?
@Published private(set) var isMuted = false
private(set) var room = Room()
let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler()
private let config: ServerConfig
private let tokenClient: TokenClient
private var reconnectAttempted = false
private var connectAttempts = 0
private var agentWaitTask: Task<Void, Never>?
init(config: ServerConfig, tokenClient: TokenClient) {
self.config = config
self.tokenClient = tokenClient
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
controlChannel.onEvent = { [weak self] _ in
guard self != nil else { return }
}
}
var isReady: Bool { state == .ready }
func connect() async {
await start()
}
private func makeRoom() -> Room {
let room = Room()
room.add(delegate: self)
room.add(delegate: controlChannel)
room.add(delegate: transcriptHandler)
return room
}
private func start() async {
state = .connecting
connectAttempts = 0
await attemptConnect()
}
private func attemptConnect() async {
let result: Result<Void, Error>
do {
let token = try await tokenClient.fetchToken()
logger.log("token ok room=\(token.room, privacy: .public)")
try await room.connect(url: config.livekitURL.absoluteString, token: token.token, connectOptions: makeConnectOptions())
result = .success(())
} catch {
result = .failure(error)
}
switch result {
case .success:
logger.log("room connected")
scanForAgent()
startAgentWait()
case .failure(let error):
let isTimeout = (error as? LiveKitError)?.type == .timedOut
if isTimeout, connectAttempts < 2 {
connectAttempts += 1
let attempt = connectAttempts
logger.log("connect attempt \(attempt) timed out, retrying with fresh room")
await teardownRoom()
await attemptConnect()
return
}
logger.error("connect failed: \(String(describing: error), privacy: .public)")
state = .failed(error.localizedDescription)
}
}
private func makeConnectOptions() -> ConnectOptions {
return ConnectOptions(
socketConnectTimeoutInterval: 15,
primaryTransportConnectTimeout: 20,
publisherTransportConnectTimeout: 20
)
}
private func teardownRoom() async {
await room.disconnect()
room = makeRoom()
}
private func handleAgentTimeout() async {
guard state == .waitingAgent, !reconnectAttempted else {
if state == .waitingAgent {
state = .failed("Agent never joined. Is the agent worker running?")
}
return
}
reconnectAttempted = true
await teardownRoom()
await start()
}
private func startAgentWait() {
state = .waitingAgent
agentWaitTask?.cancel()
agentWaitTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled else { return }
await self?.handleAgentTimeout()
}
}
func agentReadyReceived() {
guard state == .waitingAgent else {
logger.log("agent_ready ignored in state \(String(describing: self.state), privacy: .public)")
return
}
agentWaitTask?.cancel()
state = .ready
logger.log("state=ready, enabling mic")
Task { await enableMicrophone() }
}
private func scanForAgent() {
for participant in room.remoteParticipants.values {
if isAgentParticipant(participant) {
foundAgent(participant)
return
}
}
}
private func isAgentParticipant(_ participant: RemoteParticipant) -> Bool {
participant.kind == .agent || participant.identity?.stringValue == "voice-assistant"
}
private func foundAgent(_ participant: RemoteParticipant) {
agent = AgentInfo(identity: participant.identity?.stringValue ?? "", name: participant.name)
}
func enableMicrophone() async {
for attempt in 0..<3 {
do {
try await room.localParticipant.setMicrophone(enabled: true)
isMuted = false
logger.log("mic enabled")
return
} catch {
let message = String(describing: error)
if attempt < 2 {
logger.log("mic enable failed (\(message, privacy: .public)), retrying in 1s")
try? await Task.sleep(for: .seconds(1))
continue
}
logger.error("mic enable failed after retries: \(message, privacy: .public)")
state = .failed("Microphone error: \(error.localizedDescription)")
}
}
}
func setMuted(_ muted: Bool) async {
guard isReady else { return }
do {
try await room.localParticipant.setMicrophone(enabled: !muted)
isMuted = muted
} catch {
state = .failed("Microphone error: \(error.localizedDescription)")
}
}
func sendText(_ text: String) async throws {
try await room.localParticipant.sendText(text, for: "lk.chat")
}
func disconnect() async {
agentWaitTask?.cancel()
state = .idle
agent = nil
isMuted = false
reconnectAttempted = false
await teardownRoom()
}
}
extension VoiceSession: RoomDelegate {
nonisolated func room(_ room: Room, participantDidConnect participant: RemoteParticipant) {
Task { @MainActor [weak self] in
guard let self, self.isAgentParticipant(participant) else { return }
self.foundAgent(participant)
}
}
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
guard let self else { return }
// Secondary transcript source (finals only).
if topic == "transcript",
let json = try? JSONDecoder().decode([String: JSONValue].self, from: data),
json["type"]?.stringValue == "transcript",
let text = json["text"]?.stringValue {
let role: TranscriptEvent.Role = json["role"]?.stringValue == "agent" ? .agent : .user
self.transcriptHandler.receiveDataTranscript(role: role, text: text)
return
}
if self.controlChannel.handleTaskEvent(data) { return }
_ = self.controlChannel.handleVoiceControl(data, session: self)
}
}
}
+17 -2
View File
@@ -33,10 +33,25 @@ struct ServerConfig: Equatable {
c.path = "" c.path = ""
c.fragment = nil c.fragment = nil
guard let base = c.url else { return .failure(.invalidURL(raw)) } guard let base = c.url else { return .failure(.invalidURL(raw)) }
let portSuffix = components.port.map { ":\($0)" } ?? "" // LAN deployment: LiveKit is reachable directly on :7880 (plain ws).
let livekit = URL(string: "wss://\(host)\(portSuffix)/livekit")! // Real domains use wss via same origin. The SDK derives /rtc from the base.
let livekit: URL
if Self.isPrivateIP(host) {
livekit = URL(string: "ws://\(host):7880")!
} else {
let portSuffix = components.port.map { ":\($0)" } ?? ""
livekit = URL(string: "wss://\(host)\(portSuffix)/livekit")!
}
return .success(ServerConfig(baseURL: base, livekitURL: livekit)) return .success(ServerConfig(baseURL: base, livekitURL: livekit))
} }
static func isPrivateIP(_ host: String) -> Bool {
let parts = host.split(separator: ".").compactMap { UInt8($0) }
guard parts.count == 4 else { return false }
return parts[0] == 10
|| (parts[0] == 172 && (16...31).contains(parts[1]))
|| (parts[0] == 192 && parts[1] == 168)
}
var host: String { baseURL.host ?? "" } var host: String { baseURL.host ?? "" }
} }
+39 -22
View File
@@ -2,35 +2,52 @@ import Foundation
import CommonCrypto import CommonCrypto
import Security import Security
/// ATS exception for the LAN host is declared in Info.plist. If the deployment /// Pins the self-signed cert of the LAN deployment (CN=192.168.86.2, regenerated
/// uses a self-signed certificate, pin its SHA-384 here (extract with: /// at container start — re-export the hash after server cert rotation, see README).
/// `openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null | openssl x509 -outform der | shasum -a 384`). /// Extract with:
/// While empty, standard system trust is used (works for the current deployment). /// openssl s_client -connect 192.168.86.2:9178 </dev/null 2>/dev/null \
/// | openssl x509 -outform der | openssl dgst -sha256
enum TLSPinning { enum TLSPinning {
static let pinnedCertSHA384: [String] = [] static let pinnedCertSHA256Hex: Set<String> = [
"07907abde3bb9c685265909927627d9ea4705d7e7b27056eefe06e10b192af46"
]
static func validate(_ trust: SecTrust) -> Bool { static let host = "192.168.86.2"
guard !pinnedCertSHA384.isEmpty else { return true }
var error: CFError? static func session() -> URLSession {
guard SecTrustEvaluateWithError(trust, &error) else { return false } let config = URLSessionConfiguration.default
let count = SecTrustGetCertificateCount(trust) config.timeoutIntervalForRequest = 15
guard count > 0 else { return false } return URLSession(configuration: config, delegate: PinnedTrustDelegate(), delegateQueue: nil)
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate] else {
return false
}
for cert in chain {
guard let derData = SecCertificateCopyData(cert) as Data? else { continue }
if pinnedCertSHA384.contains(sha384Hex(derData)) { return true }
}
return false
} }
private static func sha384Hex(_ data: Data) -> String { static func sha256Hex(_ data: Data) -> String {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA384_DIGEST_LENGTH)) var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { buffer in data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return } guard let base = buffer.baseAddress else { return }
CC_SHA384(base, CC_LONG(data.count), &hash) CC_SHA256(base, CC_LONG(data.count), &hash)
} }
return hash.map { String(format: "%02x", $0) }.joined() return hash.map { String(format: "%02x", $0) }.joined()
} }
static func matchesPinnedCert(_ trust: SecTrust) -> Bool {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let leaf = chain.first,
let der = SecCertificateCopyData(leaf) as Data? else { return false }
return pinnedCertSHA256Hex.contains(sha256Hex(der))
}
}
final class PinnedTrustDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
if TLSPinning.matchesPinnedCert(trust) {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
} }
+2 -2
View File
@@ -43,9 +43,9 @@ final class TokenClient {
let config: ServerConfig let config: ServerConfig
private let session: URLSession private let session: URLSession
init(config: ServerConfig, session: URLSession = .shared) { init(config: ServerConfig) {
self.config = config self.config = config
self.session = session self.session = TLSPinning.session()
} }
func fetchToken() async throws -> TokenResponse { func fetchToken() async throws -> TokenResponse {
+28
View File
@@ -8,25 +8,35 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; }; 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; };
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C71C48230C683D199D6F4B /* ControlChannel.swift */; };
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */; };
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; }; 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; };
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; }; 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; }; 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; }; A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */; };
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */; };
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; }; D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9681E7410B684D2D38752A70 /* TokenClient.swift */; };
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */; };
D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = "<group>"; };
2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; };
2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; }; 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = "<group>"; };
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; }; 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = "<group>"; };
48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; }; 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = "<group>"; };
9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; }; 9681E7410B684D2D38752A70 /* TokenClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenClient.swift; sourceTree = "<group>"; };
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbView.swift; sourceTree = "<group>"; };
AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; }; AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionTestView.swift; sourceTree = "<group>"; };
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; }; D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = "<group>"; };
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = "<group>"; };
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; }; F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -51,6 +61,17 @@
path = Settings; path = Settings;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2820A418F921E18A2FA7E4FB /* LiveKit */ = {
isa = PBXGroup;
children = (
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */,
61C71C48230C683D199D6F4B /* ControlChannel.swift */,
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */,
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */,
);
path = LiveKit;
sourceTree = "<group>";
};
6B2AB34274D9B37E77178C6C /* Networking */ = { 6B2AB34274D9B37E77178C6C /* Networking */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -73,6 +94,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */, 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */,
A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */,
); );
path = Conversation; path = Conversation;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -108,6 +130,7 @@
children = ( children = (
CE49A38E40AAF659A9281B85 /* App */, CE49A38E40AAF659A9281B85 /* App */,
8DA8BEF2EE3E0F4181A94EB9 /* Features */, 8DA8BEF2EE3E0F4181A94EB9 /* Features */,
2820A418F921E18A2FA7E4FB /* LiveKit */,
6B2AB34274D9B37E77178C6C /* Networking */, 6B2AB34274D9B37E77178C6C /* Networking */,
); );
path = Sources; path = Sources;
@@ -176,14 +199,19 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */, 8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */,
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */, 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */,
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */, 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */,
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */, 720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */,
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */, D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */, 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+4 -2
View File
@@ -26,10 +26,12 @@ targets:
UILaunchScreen: {} UILaunchScreen: {}
NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation." NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation."
NSAppTransportSecurity: NSAppTransportSecurity:
NSExceptionDomains: NSAllowsLocalNetworking: true
NSPinnedDomains:
192.168.86.2: 192.168.86.2:
NSIncludesSubdomains: true NSIncludesSubdomains: true
NSExceptionAllowsInsecureHTTPLoads: false NSPinnedCAIdentities:
- SPKI-SHA256-BASE64: vmrfRuHv1oHBBiEmJP4WX3C7mtVy3C4+UJ1Yk/Q5hMU=
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice