Archived
feat: conversation state machine, mic meter, history, wake lock (Phase 3)
This commit is contained in:
1 parent
4986b6d7dc
commit
5483e6ed48
8 files changed
+337
-18
No files matched your search
@@ -21,8 +21,8 @@ Goal: full-parity native iOS app per UPDATE_PATH.md, verified in simulator via X
|
||||
## 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
|
||||
- [x] Phase 2: LiveKit session spike — commit 4986b6d (mic -4010 is sim env limitation; verify on device)
|
||||
- [x] Phase 3: state machine, audio levels, wake lock, history (commit 70aef02)
|
||||
- [ ] 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)
|
||||
@@ -51,10 +51,30 @@ Conclusion: simulator microphone hardware/source unavailable here. App code path
|
||||
|
||||
### 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] Verify transcript arrival + disconnect cleanup (verified in sim via text mode: user+agent finals arrived, hang-up cleaned up state)
|
||||
- [x] Fix Swift 6 warnings: ControlChannel @MainActor, VoiceSession closure, duplicate TranscriptHandler removed from ConversationView (build now warning-free)
|
||||
- [ ] Commit Phase 2, mark done
|
||||
|
||||
## Phase 3 status
|
||||
Done:
|
||||
- `ConversationStateModel` (Sources/LiveKit/ConversationState.swift): ports app.js inferState — EMA smoothing `prev*0.6 + raw*0.4`, user threshold 0.035, agent threshold 0.02, speaking→thinking when both silent, thinking chip after 2s in thinking phase. Driven by `RoomDelegate.didUpdateSpeakingParticipants` (participant.audioLevel), ticked only while session ready.
|
||||
- Mic failure is now NON-FATAL: after 3 retries the error is surfaced via `VoiceSession.micError` (orange banner) and state stays `.ready` — text mode still works (also matches Phase 8 mic-denied fallback).
|
||||
- Wake lock: `isIdleTimerDisabled=true` on room connect, false on disconnect.
|
||||
- Text mode toggle: enables → setMuted(true) + `set_mode:text`; disables → `set_mode:voice` + re-enable mic. Persisted via SettingsStore.textMode.
|
||||
- Transcript list: per-row timestamps, delivery-marker strip (web DELIVERY_MARKERS regex), Clear button, history of last 50 finals persisted to UserDefaults JSON (`TranscriptHistory`, key "voice_history").
|
||||
- Barge-in hint line shown while in text mode; mic meter bar under status line in voice mode.
|
||||
|
||||
Verified in simulator (log evidence):
|
||||
- Start → token→connect(retry)→room connected→agent_ready→state=ready.
|
||||
- Mic -4010 x3 retries → error logged, session stayed ready, banner shown (non-fatal).
|
||||
- Text mode toggle → log `set_mode sent: text`; mic muted.
|
||||
- Composer send → log `sendText lk.chat chars=24` → user transcript final arrived → agent TTS audio drove state machine `connecting -> speaking` (log `[dev.shane.voice:state]`) → agent transcript final "Hello! How can I assist you today?" arrived in list.
|
||||
- Hang up → `speaking -> idle`, UI back to Start button.
|
||||
|
||||
Needs device verification:
|
||||
- Live voice-driven states (listening while user speaks, thinking gap >2s chip, barge-in) — sim mic is silent (-4010), so local audio level never rises in sim; only remote (agent) audio drove the machine here.
|
||||
- Mic meter movement with real speech; wake lock behavior on physical screen-off.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -31,7 +31,11 @@ struct ConversationView: View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
TranscriptListView(events: session.transcriptHandler.events)
|
||||
TranscriptListView(
|
||||
events: session.transcriptHandler.events,
|
||||
history: session.transcriptHandler.history,
|
||||
onClear: { session.transcriptHandler.clearHistory() }
|
||||
)
|
||||
composer
|
||||
}
|
||||
.navigationTitle("Hope Voice")
|
||||
@@ -49,6 +53,21 @@ struct ConversationView: View {
|
||||
OrbView(state: orbState)
|
||||
Text(statusText)
|
||||
.font(.headline)
|
||||
if session.state == .ready, session.stateModel.isThinkingChipVisible {
|
||||
Label("Thinking…", systemImage: "ellipsis")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.orange.opacity(0.2), in: Capsule())
|
||||
}
|
||||
if session.state == .ready, !settings.textMode {
|
||||
micMeter
|
||||
}
|
||||
if session.state == .ready, settings.textMode {
|
||||
Text("Barge in anytime — type to reply")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let agent = session.agent {
|
||||
Text("Agent: \(agent.name ?? agent.identity)")
|
||||
.font(.caption)
|
||||
@@ -63,10 +82,16 @@ struct ConversationView: View {
|
||||
Label(session.isMuted ? "Muted" : "Mic on", systemImage: session.isMuted ? "mic.slash" : "mic")
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
Toggle("Text mode", isOn: $settings.textMode)
|
||||
Toggle("Text mode", isOn: textModeBinding)
|
||||
.toggleStyle(.button)
|
||||
}
|
||||
}
|
||||
if let micError = session.micError {
|
||||
Label(micError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let connectError {
|
||||
Label(connectError, systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
@@ -76,6 +101,40 @@ struct ConversationView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var micMeter: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color(.systemFill))
|
||||
Capsule()
|
||||
.fill(Color.green.gradient)
|
||||
.frame(width: min(geo.size.width, geo.size.width * session.stateModel.micLevel * 2.5))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
|
||||
private var textModeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { settings.textMode },
|
||||
set: { newValue in
|
||||
settings.textMode = newValue
|
||||
Task { await applyTextMode(newValue) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func applyTextMode(_ enabled: Bool) async {
|
||||
guard session.isReady else { return }
|
||||
if enabled {
|
||||
await session.setMuted(true)
|
||||
await session.setMode("text")
|
||||
} else {
|
||||
await session.setMode("voice")
|
||||
await session.enableMicrophone()
|
||||
}
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(spacing: 8) {
|
||||
switch session.state {
|
||||
@@ -118,20 +177,35 @@ struct ConversationView: View {
|
||||
|
||||
private var orbState: OrbView.OrbState {
|
||||
switch session.state {
|
||||
case .idle: .idle
|
||||
case .connecting, .waitingAgent: .connecting
|
||||
case .ready: settings.textMode ? .idle : .listening
|
||||
case .failed: .failed
|
||||
case .idle: return .idle
|
||||
case .connecting, .waitingAgent: return .connecting
|
||||
case .ready:
|
||||
guard !settings.textMode else { return .idle }
|
||||
switch session.stateModel.phase {
|
||||
case .idle: return .idle
|
||||
case .connecting: return .connecting
|
||||
case .listening: return .listening
|
||||
case .thinking: return .thinking
|
||||
case .speaking: return .speaking
|
||||
}
|
||||
case .failed: return .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)"
|
||||
case .idle: return "Ready to connect"
|
||||
case .connecting: return "Connecting…"
|
||||
case .waitingAgent: return "Waiting for agent…"
|
||||
case .ready:
|
||||
if settings.textMode { return "Text mode" }
|
||||
switch session.stateModel.phase {
|
||||
case .listening: return "Listening"
|
||||
case .thinking: return "Thinking it through"
|
||||
case .speaking: return "Hope is speaking"
|
||||
default: return "Ready when you are"
|
||||
}
|
||||
case .failed(let message): return "Failed: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,34 @@ struct OrbView: View {
|
||||
|
||||
struct TranscriptListView: View {
|
||||
let events: [TranscriptEvent]
|
||||
@ObservedObject var history: TranscriptHistory
|
||||
var onClear: () -> Void
|
||||
|
||||
private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"#
|
||||
|
||||
private static let timestampFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Transcript")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(role: .destructive, action: onClear) {
|
||||
Label("Clear", systemImage: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(events) { event in
|
||||
@@ -67,6 +92,7 @@ struct TranscriptListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func transcriptRow(_ event: TranscriptEvent) -> some View {
|
||||
let roleName = event.role == .user ? "You" : "Hope"
|
||||
@@ -76,9 +102,15 @@ struct TranscriptListView: View {
|
||||
: Color.blue.opacity(0.08)
|
||||
let textOpacity: Double = event.isFinal ? 1.0 : 0.55
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(roleName)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(nameColor)
|
||||
Spacer()
|
||||
Text(Self.timestampFormatter.string(from: event.timestamp))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Text(stripped(event.text))
|
||||
.font(.body)
|
||||
.opacity(textOpacity)
|
||||
@@ -91,9 +123,8 @@ struct TranscriptListView: View {
|
||||
}
|
||||
|
||||
private func stripped(_ text: String) -> String {
|
||||
// Strip delivery-style markers like [appreciative], [pauses] etc.
|
||||
text.replacingOccurrences(
|
||||
of: #"\[[a-zA-Z ,'-]+\]"#,
|
||||
of: Self.deliveryMarkers,
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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")
|
||||
@@ -21,20 +21,30 @@ struct TranscriptEvent: Equatable, Identifiable {
|
||||
final class TranscriptHandler: ObservableObject {
|
||||
@Published private(set) var events: [TranscriptEvent] = []
|
||||
|
||||
let history = TranscriptHistory()
|
||||
|
||||
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)
|
||||
if isFinal {
|
||||
history.add(role: role.rawValue, text: text)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveDataTranscript(role: TranscriptEvent.Role, text: String) {
|
||||
closePartial(for: role)
|
||||
upsert(id: "final-\(role.rawValue)-\(UUID().uuidString)", role: role, text: text, isFinal: true)
|
||||
history.add(role: role.rawValue, text: text)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
events.removeAll()
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
history.clear()
|
||||
}
|
||||
|
||||
private func closePartial(for role: TranscriptEvent.Role) {
|
||||
for index in events.indices where !events[index].isFinal && events[index].role == role {
|
||||
events[index].isFinal = true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
struct HistoryEntry: Codable, Equatable {
|
||||
let role: String
|
||||
let text: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
/// Last 50 final transcript entries persisted as JSON in UserDefaults.
|
||||
@MainActor
|
||||
final class TranscriptHistory: ObservableObject {
|
||||
@Published private(set) var entries: [HistoryEntry] = []
|
||||
|
||||
private static let key = "voice_history"
|
||||
private static let maxEntries = 50
|
||||
|
||||
init() {
|
||||
if let raw = UserDefaults.standard.string(forKey: Self.key),
|
||||
let data = raw.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode([HistoryEntry].self, from: data) {
|
||||
entries = Array(decoded.suffix(Self.maxEntries))
|
||||
}
|
||||
}
|
||||
|
||||
func add(role: String, text: String) {
|
||||
guard !text.isEmpty else { return }
|
||||
entries.append(HistoryEntry(role: role, text: text, timestamp: Date()))
|
||||
if entries.count > Self.maxEntries {
|
||||
entries.removeFirst(entries.count - Self.maxEntries)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
entries.removeAll()
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
UserDefaults.standard.set(String(decoding: data, as: UTF8.self), forKey: Self.key)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; };
|
||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; };
|
||||
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62BAE22E3A165DCB5DFFEE1 /* OrbView.swift */; };
|
||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C23305A6C55A83F8617D9D /* ConversationState.swift */; };
|
||||
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B40076934C28BA4B92D808 /* TLSPinning.swift */; };
|
||||
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A45228EF59186CFC219F2E /* TranscriptHistory.swift */; };
|
||||
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4B9073EE8EAB51F8C55760 /* ConnectionTestView.swift */; };
|
||||
98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; };
|
||||
A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; };
|
||||
@@ -30,8 +32,10 @@
|
||||
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>"; };
|
||||
61C71C48230C683D199D6F4B /* ControlChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlChannel.swift; sourceTree = "<group>"; };
|
||||
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHistory.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>"; };
|
||||
A0C23305A6C55A83F8617D9D /* ConversationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationState.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>"; };
|
||||
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptHandler.swift; sourceTree = "<group>"; };
|
||||
@@ -66,7 +70,9 @@
|
||||
children = (
|
||||
0FC167F6658CFEC9546A7E9F /* AgentMonitor.swift */,
|
||||
61C71C48230C683D199D6F4B /* ControlChannel.swift */,
|
||||
A0C23305A6C55A83F8617D9D /* ConversationState.swift */,
|
||||
D82D665165A7BC1A4CBCCC6F /* TranscriptHandler.swift */,
|
||||
68A45228EF59186CFC219F2E /* TranscriptHistory.swift */,
|
||||
EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */,
|
||||
);
|
||||
path = LiveKit;
|
||||
@@ -202,6 +208,7 @@
|
||||
C00D1878C2AB3D8B6F9562B6 /* AgentMonitor.swift in Sources */,
|
||||
8DE1363CC0119B5AF9E4E117 /* ConnectionTestView.swift in Sources */,
|
||||
28659D009399CA9C4FE57B74 /* ControlChannel.swift in Sources */,
|
||||
64291BBC8D0DAD47E07E94E0 /* ConversationState.swift in Sources */,
|
||||
36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */,
|
||||
3E01A0AF44FD3324FA72C43C /* OrbView.swift in Sources */,
|
||||
302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */,
|
||||
@@ -210,6 +217,7 @@
|
||||
720C26EC163AE46713B02DD8 /* TLSPinning.swift in Sources */,
|
||||
D18AD347DFF72AB6361165BA /* TokenClient.swift in Sources */,
|
||||
D2E343C8B8657316A993698E /* TranscriptHandler.swift in Sources */,
|
||||
89B47754E5C39418BFD839E6 /* TranscriptHistory.swift in Sources */,
|
||||
1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */,
|
||||
CDE38FE9679472D770C601E6 /* VoiceSession.swift in Sources */,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user