This commit is contained in:
snowspeeder committed 2026-08-31 19:50:46 -04:00
1 parent c9c25f86eb
commit 40aaf4fea7
56 files changed
+5683 -179

No files matched your search

+7 -5
View File
@@ -11,7 +11,9 @@ enum ConversationPhase: String {
/// 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.
/// 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
@@ -58,10 +60,10 @@ final class ConversationStateModel: ObservableObject {
} else if userSpeaking {
next = .listening
} else {
switch phase {
case .speaking: next = .thinking
default: next = .listening
}
// 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)
}
+150 -16
View File
@@ -15,50 +15,184 @@ struct TranscriptEvent: Equatable, Identifiable {
}
/// 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.
/// secondary `transcript` data topic (finals only).
///
/// **Single canonical collection:** `events` is the one visible transcript.
/// It is initialized from the persisted history snapshot at init time and
/// updated in place by live events. The view renders only `events`.
///
/// **Cross-source dedupe:** Records `(role, normalized text, source, timestamp)`
/// and suppresses only the *opposite-source* copy within `dedupeWindow` seconds.
/// Same-source repeated utterances are never suppressed, even inside the window.
@MainActor
final class TranscriptHandler: ObservableObject {
/// The single canonical visible transcript. Initialized from persisted
/// history; live events append/update here. Trimmed to 50 finals.
@Published private(set) var events: [TranscriptEvent] = []
let history = TranscriptHistory()
let history: TranscriptHistory
// MARK: Cross-source dedupe state
enum Source {
case primary // LiveKit transcription segments
case data // `transcript` data topic
}
struct RecentFinal {
let source: Source
let timestamp: Date
}
/// Keyed by normalized (role, text). Records the most recent final for
/// each key and which source produced it. Only the opposite-source copy
/// is suppressed within the window; same-source repeats pass through.
private var recentFinals: [String: RecentFinal] = [:]
static let dedupeWindow: TimeInterval = 5
static let maxEvents = 50
/// Injectable clock for deterministic tests. Defaults to `Date()`.
var now: () -> Date = { Date() }
convenience init() {
self.init(history: TranscriptHistory())
}
init(history: TranscriptHistory) {
self.history = history
// Initialize the canonical events from the persisted snapshot so the
// UI shows prior-session transcripts without a separate "restored" store.
events = history.entries.map { entry in
TranscriptEvent(
id: "hist-\(entry.timestamp.timeIntervalSince1970)-\(entry.role)",
role: entry.role == "agent" ? .agent : .user,
text: entry.text,
isFinal: true,
timestamp: entry.timestamp
)
}
}
// MARK: Ingest
func receiveSegment(id: String, role: TranscriptEvent.Role, text: String, isFinal: Bool) {
guard !text.isEmpty else { return }
if isFinal {
// Cross-source dedupe: suppress only if the *data* source already
// recorded this exact final within the window. Same-source (primary)
// repeats are always allowed. When suppressing, consume (remove) the
// matched record so a second same-source repeat from primary is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .data,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
}
upsert(id: id, role: role, text: text, isFinal: isFinal)
if isFinal {
history.add(role: role.rawValue, text: text)
recordFinal(role: role, text: text, source: .primary)
trimEvents()
syncPersistence()
}
}
func receiveDataTranscript(role: TranscriptEvent.Role, text: String) {
closePartial(for: role)
guard !text.isEmpty else { return }
// When a data final arrives for a role that has an open partial, replace
// the partial's text with the final and mark it final in place. The data
// topic is the authoritative final; the user sees one row either way.
if let index = events.lastIndex(where: { !$0.isFinal && $0.role == role }) {
events[index].text = text
events[index].isFinal = true
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
return
}
// No open partial: check whether the *primary* source already recorded
// this exact final within the window (primary→data order). Same-source
// (data) repeats are always allowed. When suppressing, consume (remove)
// the matched record so a second same-source repeat from data is not
// also suppressed — the next arrival establishes a new pair.
let key = Self.dedupeKey(role: role, text: text)
if let recent = recentFinals[key],
recent.source == .primary,
now().timeIntervalSince(recent.timestamp) < Self.dedupeWindow {
// Consume: remove the matched opposite-source record.
recentFinals[key] = nil
return
}
upsert(id: "final-\(role.rawValue)-\(UUID().uuidString)", role: role, text: text, isFinal: true)
history.add(role: role.rawValue, text: text)
recordFinal(role: role, text: text, source: .data)
trimEvents()
syncPersistence()
}
func clear() {
/// Mirrors the canonical finals into the persisted snapshot so the UI and
/// storage never diverge. Same-source repeats are preserved verbatim.
private func syncPersistence() {
let finals = events.filter(\.isFinal).map { (role: $0.role.rawValue, text: $0.text, timestamp: $0.timestamp) }
history.sync(finals: finals)
}
// MARK: Clear
/// Clears visible rows, dedupe state, and persistence atomically.
func clearAll() {
events.removeAll()
}
func clearHistory() {
recentFinals.removeAll()
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
}
}
// MARK: Private
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()))
events.append(TranscriptEvent(id: id, role: role, text: text, isFinal: isFinal, timestamp: now()))
}
}
private func recordFinal(role: TranscriptEvent.Role, text: String, source: Source) {
let key = Self.dedupeKey(role: role, text: text)
recentFinals[key] = RecentFinal(source: source, timestamp: now())
// Prune expired entries to bound the dictionary.
let current = now()
for (k, recent) in recentFinals where current.timeIntervalSince(recent.timestamp) >= Self.dedupeWindow {
recentFinals[k] = nil
}
}
/// Keeps at most `maxEvents` finals in the canonical collection. Partial
/// (non-final) rows are never trimmed; only surplus old finals are dropped.
private func trimEvents() {
let finalCount = events.filter(\.isFinal).count
guard finalCount > Self.maxEvents else { return }
// Drop oldest finals until we're back to maxEvents.
var toRemove = finalCount - Self.maxEvents
var writeIndex = 0
for event in events {
if event.isFinal && toRemove > 0 {
toRemove -= 1
continue
}
events[writeIndex] = event
writeIndex += 1
}
events.removeSubrange(writeIndex...)
}
private static func dedupeKey(role: TranscriptEvent.Role, text: String) -> String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return "\(role.rawValue):\(normalized)"
}
}
extension TranscriptHandler: RoomDelegate {
+15 -8
View File
@@ -7,12 +7,17 @@ struct HistoryEntry: Codable, Equatable {
}
/// Last 50 final transcript entries persisted as JSON in UserDefaults.
///
/// Persistence-only store. The visible transcript is the single canonical
/// collection owned by `TranscriptHandler.events`; this class provides the
/// durable snapshot it initializes from and syncs to. It performs no dedupe —
/// all suppression is source-aware and time-bounded in the handler.
@MainActor
final class TranscriptHistory: ObservableObject {
@Published private(set) var entries: [HistoryEntry] = []
private static let key = "voice_history"
private static let maxEntries = 50
static let key = "voice_history"
static let maxEntries = 50
init() {
if let raw = UserDefaults.standard.string(forKey: Self.key),
@@ -22,12 +27,14 @@ final class TranscriptHistory: ObservableObject {
}
}
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)
}
/// Replaces the persisted snapshot with exactly the finals currently in the
/// canonical visible collection. Called by `TranscriptHandler` after every
/// change so persistence and the UI never diverge. No dedupe is applied here:
/// same-source repeats are legitimate entries.
func sync(finals: [(role: String, text: String, timestamp: Date)]) {
entries = Array(finals.suffix(Self.maxEntries).map {
HistoryEntry(role: $0.role, text: $0.text, timestamp: $0.timestamp)
})
persist()
}
+381 -27
View File
@@ -11,7 +11,15 @@ enum VoiceSessionState: Equatable {
case connecting
case waitingAgent
case ready
/// Failure teardown in progress. Start is unavailable; the session is not
/// yet safe for a fresh connect. Transitions to `.failed` when cleanup completes.
case tearingDown
case failed(String)
var isFailed: Bool {
if case .failed = self { return true }
return false
}
}
struct AgentInfo: Equatable {
@@ -47,6 +55,36 @@ final class VoiceSession: ObservableObject {
private var reconnectAttempted = false
private var connectAttempts = 0
private var agentWaitTask: Task<Void, Never>?
/// Whether the user (or session mode) intentionally wants the microphone
/// muted, independent of interruption-owned hardware state. Set by public
/// mute actions (`setMuted(true)`, text-mode enable) after the operation
/// succeeds; cleared by a successful normal mic enable (`enableMicrophone`).
/// Interruption begin operations use this *after* awaiting the predecessor
/// to decide whether to mute, rather than snapshotting transient `isMuted`
/// before the predecessor completes (Iteration 08 Item 1).
private(set) var userWantsMicMuted = false
/// Whether the current audio interruption actually muted the mic. Set only
/// *after* the mute operation completes successfully; cleared on `.ended`
/// and `disconnect()`.
private var micMutedByInterruption = false
/// Sequence number for interruption-owned operations. Incremented on every
/// `.began`; a `.ended` handler captures the sequence at `.began` time and
/// only resumes if its sequence is still current (no newer `.began` has
/// superseded it). This prevents out-of-order mute/unmute when interruptions
/// overlap or arrive in rapid succession.
private var interruptionSequence = 0
/// The retained operation chain for microphone interruption operations.
/// Both `.began` (mute) and `.ended` (unmute) are assigned to this chain so
/// every new operation awaits its predecessor. This guarantees:
/// - A newer `.began` orders after an in-flight old end/unmute and finishes
/// by muting again (the old unmute cannot leave hardware enabled).
/// - An `.ended` never launches unretained; it is part of the chain.
/// Cancelled on disconnect/failure.
private var micOpTask: Task<Void, Never>?
/// Set before awaiting `room.disconnect()` during intentional teardown so
/// the resulting `.disconnected` callback is recognized as expected (not a
/// terminal failure) and ignored. Cleared when the new room connects.
private var isTearingDown = false
init(config: ServerConfig, tokenClient: TokenClient, settings: SettingsStore) {
self.config = config
@@ -74,11 +112,16 @@ final class VoiceSession: ObservableObject {
}
private func handleControlEvent(_ event: ControlChannel.InboundEvent) {
guard state != .idle else { return }
// Restrict inbound control-event application to active lifecycle states
// so late events from a failed/tearing-down room cannot repopulate stale
// fields (agent, models, mic state) into a fresh retry (Iteration 08 Item 3).
guard state == .connecting || state == .waitingAgent || state == .ready else { return }
switch event {
case .models(let received, let selected):
models = received
if selectedModel == nil, let selected {
// The persisted preference is deterministic; an early `models.selected`
// event must not silently replace it before sync-on-join has run.
if selectedModel == nil, settings.modelID == nil, let selected {
selectedModel = selected
settings.modelID = selected
}
@@ -96,11 +139,55 @@ final class VoiceSession: ObservableObject {
var isReady: Bool { state == .ready }
/// Clear all per-room/session ephemeral published fields so the failed or
/// idle UI and any subsequent retry do not display stale identity, error,
/// mute, model, or reconnect data from a torn-down room.
///
/// Deliberately preserved across reset: `settings` (durable user
/// preferences), `transcriptHandler` events (conversation history), and
/// `taskStore` (task history). These are not per-room ephemeral state.
///
/// Reused from `teardownForFailure` (both the initial pass and the final
/// pass immediately before `.failed` publication) and `disconnect()`.
private func resetEphemeralFields() {
agent = nil
isMuted = false
userWantsMicMuted = false
micError = nil
models = []
selectedModel = nil
isReconnecting = false
reconnectAttempted = false
micMutedByInterruption = false
}
/// Pure predicate: whether a direct `RoomDelegate` callback that mutates
/// published ephemeral fields (agent discovery, connection-state changes)
/// should be accepted given the current lifecycle state and teardown flag.
///
/// Callbacks are accepted only for active lifecycle states (`.connecting`,
/// `.waitingAgent`, `.ready`) AND only when `isTearingDown` is false. This
/// prevents old-room callbacks delivered during failure teardown from
/// repopulating ephemeral fields that were just cleared (Iteration 09 Item 1).
func shouldAcceptRoomCallback(state: VoiceSessionState, isTearingDown: Bool) -> Bool {
!isTearingDown && (state == .connecting || state == .waitingAgent || state == .ready)
}
/// Start (or restart) a session. Accepts `.idle` and `.failed` so the
/// Start button can retry after a failure without first resetting to idle.
/// Rejects `.tearingDown`: failure cleanup must complete before a fresh
/// connect is allowed, preventing the old failure task from tearing down
/// the new room.
func connect() async {
guard state == .idle else { return }
guard state == .idle || state.isFailed else { return }
reconnectAttempted = false
await start()
}
/// Whether a failure teardown is currently in progress. Exposed for UI and
/// test purposes; `connect()` rejects while this is true.
var isFailureTearingDown: Bool { state == .tearingDown }
private func makeRoom() -> Room {
let room = Room()
room.add(delegate: self)
@@ -149,7 +236,7 @@ final class VoiceSession: ObservableObject {
return
}
logger.error("connect failed: \(String(describing: error), privacy: .public)")
state = .failed(error.localizedDescription)
await teardownForFailure(message: error.localizedDescription)
}
}
@@ -161,15 +248,25 @@ final class VoiceSession: ObservableObject {
)
}
/// Intentional, idempotent room teardown. Sets the teardown flag *before*
/// awaiting `room.disconnect()` so the resulting `.disconnected` callback is
/// recognized as expected and ignored (not treated as a terminal failure).
/// Safe to call when the room is already disconnected: LiveKit's
/// `disconnect()` is a no-op in that case, and the flag is cleared either way.
private func teardownRoom() async {
isTearingDown = true
await room.disconnect()
room = makeRoom()
isTearingDown = false
}
private func handleAgentTimeout(generation: Int) async {
guard generation == sessionGeneration, state == .waitingAgent else { return }
guard !reconnectAttempted else {
state = .failed("Agent never joined. Is the agent worker running?")
// Final failure: tear down the live room, restore the idle timer,
// and end the media scope so no stale room or wake lock survives.
logger.log("agent timeout final, tearing down")
await teardownForFailure(message: "Agent never joined. Is the agent worker running?")
return
}
reconnectAttempted = true
@@ -179,6 +276,53 @@ final class VoiceSession: ObservableObject {
await start()
}
/// Centralized failure teardown: mark teardown active before any await so
/// `connect()` is rejected during cleanup, cancel wait work, invalidate the
/// current generation (so stale callbacks become no-ops), clear all
/// per-room/session ephemeral published fields (agent, mute state, model
/// list, error state, interruption ownership), disconnect and recreate the
/// room, stop conversation state, restore the idle timer, and end the media
/// scope. Only after all cleanup completes does the session publish
/// `.failed(message)`, making retry safe: the old room is fully torn down
/// before Start becomes available again.
///
/// Deliberately preserved across failure: `settings` (durable user
/// preferences), `transcriptHandler` events (conversation history), and
/// `taskStore` (task history). These are not per-room ephemeral state.
///
/// Idempotent: if already tearing down or failed, keeps the first message.
private func teardownForFailure(message: String) async {
// Mark teardown active *before* any await so connect() rejects during
// cleanup and the UI does not expose Start prematurely.
if state == .tearingDown || state.isFailed { return }
state = .tearingDown
agentWaitTask?.cancel()
agentWaitTask = nil
sessionGeneration += 1
resetEphemeralFields()
// Cancel any in-flight interruption operation and invalidate the
// sequence so a pending .ended cannot resume the mic after teardown.
micOpTask?.cancel()
micOpTask = nil
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await mediaController.endConversation()
await teardownRoom()
// Final reset immediately before publishing .failed: any old-room
// callback delivered during the teardownRoom() await (participantDidConnect,
// connection-state changes) may have repopulated ephemeral fields. This
// second pass erases them so the published .failed state is clean
// (Iteration 09 Item 1).
resetEphemeralFields()
// Publish .failed only after all cleanup has completed. This makes the
// state a truthful signal: retry is safe because the old room is gone.
state = .failed(message)
}
private func startAgentWait() {
state = .waitingAgent
let generation = sessionGeneration
@@ -198,26 +342,52 @@ final class VoiceSession: ObservableObject {
agentWaitTask?.cancel()
state = .ready
stateModel.start()
logger.log("state=ready, enabling mic")
let generation = sessionGeneration
Task { await enableMicrophone() }
// Honor the persisted interaction mode: text mode joins with the mic
// off and the agent in text mode; voice mode enables the mic.
if settings.textMode {
isMuted = true
userWantsMicMuted = true
logger.log("state=ready (text mode), mic stays off")
} else {
logger.log("state=ready, enabling mic")
Task { await enableMicrophone() }
}
Task { await syncSettingsOnJoin(generation: generation) }
}
private func syncSettingsOnJoin(generation: Int) async {
guard generation == sessionGeneration, isReady else { return }
// Each sync operation is independent and best-effort: a failed set_voice
// or invalid persisted model must not prevent set_mode from being sent.
let voiceID = settings.voiceID
do {
try await controlChannel.setVoice(voiceID, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_voice sent: \(voiceID, privacy: .public)")
if let model = selectedModel ?? settings.modelID {
} catch {
logger.error("sync-on-join set_voice failed: \(String(describing: error), privacy: .public)")
}
guard generation == sessionGeneration else { return }
let model = settings.modelID ?? selectedModel
if let model {
do {
try await controlChannel.setModel(model, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_model sent: \(model, privacy: .public)")
} catch {
logger.error("sync-on-join set_model failed: \(String(describing: error), privacy: .public)")
}
}
guard generation == sessionGeneration else { return }
// Sync the agent's conversation mode with the persisted setting.
let mode = settings.textMode ? "text" : "voice"
do {
try await controlChannel.setMode(mode, room: room)
guard generation == sessionGeneration else { return }
logger.log("sync-on-join set_mode sent: \(mode, privacy: .public)")
} catch {
logger.error("sync-on-join failed: \(String(describing: error), privacy: .public)")
logger.error("sync-on-join set_mode failed: \(String(describing: error), privacy: .public)")
}
}
@@ -265,12 +435,20 @@ final class VoiceSession: ObservableObject {
}
func enableMicrophone() async {
// Text mode invariant: the microphone must never be enabled while text
// mode is active. This enforces the privacy contract at the API level,
// not just in the view layer (Finding 4).
guard !settings.textMode else {
logger.log("enableMicrophone blocked: text mode active")
return
}
let generation = sessionGeneration
for attempt in 0..<3 {
do {
try await room.localParticipant.setMicrophone(enabled: true)
guard generation == sessionGeneration else { return }
isMuted = false
userWantsMicMuted = false
micError = nil
logger.log("mic enabled")
return
@@ -292,11 +470,21 @@ final class VoiceSession: ObservableObject {
func setMuted(_ muted: Bool) async {
guard isReady else { return }
// Text mode invariant: unmuting (enabling the mic) is not permitted
// while text mode is active. Muting is always allowed.
if !muted && settings.textMode {
logger.log("setMuted(false) blocked: text mode active")
return
}
let generation = sessionGeneration
do {
try await room.localParticipant.setMicrophone(enabled: !muted)
guard generation == sessionGeneration else { return }
isMuted = muted
// Track explicit user mute intent separately from hardware state.
// Interruption begin operations use this after awaiting the
// predecessor to decide whether to mute (Iteration 08 Item 1).
userWantsMicMuted = muted
} catch {
guard generation == sessionGeneration else { return }
micError = "Microphone error: \(error.localizedDescription)"
@@ -315,6 +503,22 @@ final class VoiceSession: ObservableObject {
}
}
/// Apply a persisted text-mode change to the live session: mute + set_mode
/// when enabling, set_mode + mic on when disabling. Called from
/// ConversationView's `.onChange(of: settings.textMode)` so changes from
/// either the conversation toggle or the Settings sheet apply to the
/// current session.
func applyTextMode(_ enabled: Bool) async {
guard isReady else { return }
if enabled {
await setMuted(true)
await setMode("text")
} else {
await setMode("voice")
await enableMicrophone()
}
}
func sendText(_ text: String) async throws {
guard isReady else { throw URLError(.notConnectedToInternet) }
logger.log("sendText lk.chat chars=\(text.count)")
@@ -322,43 +526,150 @@ final class VoiceSession: ObservableObject {
}
func disconnect() async {
// Set the teardown guard first so any .disconnected callback from our
// own room.disconnect() is ignored (not treated as a terminal failure).
isTearingDown = true
agentWaitTask?.cancel()
agentWaitTask = nil
sessionGeneration += 1
state = .idle
agent = nil
isMuted = false
micError = nil
models = []
isReconnecting = false
reconnectAttempted = false
resetEphemeralFields()
// Cancel any in-flight interruption operation and invalidate the
// sequence so a pending .ended cannot resume the mic after disconnect.
micOpTask?.cancel()
micOpTask = nil
interruptionSequence += 1
stateModel.stop()
UIApplication.shared.isIdleTimerDisabled = false
await mediaController.endConversation()
await teardownRoom()
// Final reset before publishing .idle: any old-room callback delivered
// during the teardownRoom() await may have repopulated ephemeral fields.
// This ensures the idle state is clean for server replacement (Iteration 09).
resetEphemeralFields()
// Publish .idle only after media and room teardown have completed.
// This makes idle a truthful readiness signal for server replacement:
// the old session is fully torn down before the UI shows "ready to start".
state = .idle
}
// MARK: Audio session interruptions (phone call, Siri)
/// Handles audio session interruptions with a single retained operation
/// chain (`micOpTask`) for both begin and end operations.
///
/// **Serialization invariants (Iteration 07 Item 2):**
/// - Every new operation captures the prior `micOpTask` and awaits it before
/// executing. This guarantees strict ordering: a newer `.began` orders
/// after an in-flight old end/unmute and finishes by muting again, so a
/// stale unmute cannot leave hardware enabled during a newer interruption.
/// - `.began` increments `interruptionSequence`, then after predecessor
/// completion mutes and records ownership (`micMutedByInterruption`) only
/// after the mute succeeds.
/// - `.ended` is also assigned to the retained chain (never unretained),
/// awaits begin, and un-mutes only for matching sequence/generation/
/// ownership/shouldResume.
/// - Cancel/invalidate on disconnect or failure teardown.
@objc private func handleAudioInterruption(_ notification: Notification) {
guard let info = notification.userInfo else { return }
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
switch type {
case .began:
let wasActive = state == .ready && !isMuted && !settings.textMode
if wasActive {
Task { await setMuted(true) }
// Increment the sequence so any pending `.ended` from a prior
// interruption sees a stale sequence and does not resume.
interruptionSequence += 1
let seq = interruptionSequence
let generation = sessionGeneration
let predecessor = micOpTask
// Assign the new begin operation to the retained chain. It awaits
// the predecessor (which may be an in-flight end/unmute) *before*
// deciding whether to mute. The mute decision uses the user's
// explicit intent (`userWantsMicMuted`) and current text mode, NOT
// a pre-await snapshot of transient `isMuted` which may still
// reflect the predecessor's in-flight state (Iteration 08 Item 1).
//
// This guarantees: begin A → end A/unmute in flight → begin B.
// B awaits A's unmute (which sets isMuted=false, userWantsMicMuted
// already false from the successful enable). B then sees
// userWantsMicMuted == false and textMode == false, so it mutes —
// the final state is muted during interruption B.
micOpTask = Task { [weak self] in
guard let self else { return }
if let pred = predecessor { await pred.value }
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
// Reset ownership: a new interruption supersedes any prior claim.
self.micMutedByInterruption = false
// Decide whether to mute AFTER the predecessor completes, using
// user intent (not a pre-await isMuted snapshot). The mic should
// be muted during the interruption unless the user explicitly
// wanted it muted already (in which case hardware is already off)
// or text mode is active (mic must stay off by invariant).
let shouldMute = !self.userWantsMicMuted && !self.settings.textMode
&& self.state == .ready
guard shouldMute else {
logger.log("audio interruption began, no mute needed (userMuted=\(self.userWantsMicMuted), textMode=\(self.settings.textMode), state=\(String(describing: self.state)), seq=\(seq))")
return
}
do {
try await self.room.localParticipant.setMicrophone(enabled: false)
// Mark ownership only after the mute actually completed and
// the sequence is still current (no even newer .began).
guard !Task.isCancelled,
self.sessionGeneration == generation,
seq == self.interruptionSequence else { return }
self.isMuted = true
self.micMutedByInterruption = true
logger.log("interruption mute succeeded (seq=\(seq))")
} catch {
logger.log("interruption mute failed: \(error.localizedDescription) (seq=\(seq))")
}
}
logger.log("audio interruption began, muted=\(wasActive)")
logger.log("audio interruption began, seq=\(seq)")
case .ended:
let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume)
if state == .ready, isMuted, !settings.textMode, shouldResume {
Task { await setMuted(false) }
logger.log("audio interruption ended, resuming mic")
} else {
let description = String(describing: state)
logger.log("audio interruption ended, no resume (state=\(description, privacy: .public))")
// Capture the current sequence and generation before any async hop.
let currentSeq = interruptionSequence
let generation = sessionGeneration
let predecessor = micOpTask
// Assign the end operation to the retained chain (never unretained).
// It awaits the predecessor (the begin's mute) before deciding.
micOpTask = Task { [weak self] in
guard let self else { return }
if let pred = predecessor { await pred.value }
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
// Re-check all conditions after the await:
// 1. The mute succeeded (flag set only on success).
// 2. No newer .began has superseded this sequence.
// 3. The session generation still matches.
// 4. Text mode is off.
// 5. .shouldResume is present.
let shouldUnmute = self.micMutedByInterruption
&& currentSeq == self.interruptionSequence
&& self.sessionGeneration == generation
&& self.state == .ready
&& !self.settings.textMode
&& shouldResume
// Clear the flag regardless of whether we resume, so a stale
// true cannot cause an incorrect future resume.
self.micMutedByInterruption = false
if shouldUnmute {
do {
try await self.room.localParticipant.setMicrophone(enabled: true)
guard !Task.isCancelled, self.sessionGeneration == generation else { return }
self.isMuted = false
logger.log("audio interruption ended, resuming mic (seq=\(currentSeq))")
} catch {
logger.log("interruption resume failed: \(error.localizedDescription) (seq=\(currentSeq))")
}
} else {
let description = String(describing: self.state)
logger.log("audio interruption ended, no resume (state=\(description, privacy: .public), shouldResume=\(shouldResume), seq=\(currentSeq))")
}
}
@unknown default:
break
@@ -369,12 +680,33 @@ final class VoiceSession: ObservableObject {
stateModel.tick(localAudioLevel: local, remoteAudioLevel: remote)
}
/// Pure decision seam for interruption-begin mute logic (Iteration 08 Item 1).
/// Extracted from `handleAudioInterruption` so the begin A → end A/unmute →
/// begin B transient-state case can be tested deterministically without a
/// live microphone or AVAudioSession.
///
/// Returns `true` if the begin operation should mute the mic, given the
/// user's explicit intent and current session state *after* the predecessor
/// has completed (not a pre-await snapshot).
func interruptionBeginShouldMute(
userWantsMicMuted: Bool,
textMode: Bool,
state: VoiceSessionState
) -> Bool {
!userWantsMicMuted && !textMode && state == .ready
}
}
extension VoiceSession: RoomDelegate {
nonisolated func room(_ room: Room, participantDidConnect participant: RemoteParticipant) {
Task { @MainActor [weak self] in
guard let self, self.room === room, self.isAgentParticipant(participant) else { return }
guard let self, self.room === room else { return }
// Gate on active lifecycle states and teardown flag so old-room
// callbacks delivered during failure teardown cannot repopulate
// the agent field after it was cleared (Iteration 09 Item 1).
guard self.shouldAcceptRoomCallback(state: self.state, isTearingDown: self.isTearingDown) else { return }
guard self.isAgentParticipant(participant) else { return }
self.foundAgent(participant)
}
}
@@ -416,13 +748,35 @@ extension VoiceSession: RoomDelegate {
nonisolated func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState, from oldConnectionState: ConnectionState) {
Task { @MainActor [weak self] in
guard let self, self.room === room else { return }
// Ignore all connection-state updates while intentional teardown is
// in progress. The caller (teardownForFailure or disconnect) has
// already set the correct terminal/transitional state; any callback
// from the old room during the disconnect await must not mutate
// published fields (Iteration 09 Item 1).
if self.isTearingDown {
logger.log("connection-state callback ignored during teardown: \(String(describing: connectionState))")
return
}
switch (oldConnectionState, connectionState) {
case (.connected, .reconnecting):
// Only mutate published fields for active lifecycle states.
guard self.state == .connecting || self.state == .waitingAgent || self.state == .ready else { return }
self.isReconnecting = true
logger.log("connection lost, waiting for LiveKit reconnect")
case (.reconnecting, .connected):
guard self.state == .connecting || self.state == .waitingAgent || self.state == .ready else { return }
self.isReconnecting = false
logger.log("connection restored")
case (_, .disconnected):
// Terminal disconnect (not a transient reconnect). If the
// session was active, tear down cleanly so no stale room or
// wake lock survives. The user can retry via Start.
if self.state == .ready || self.state == .waitingAgent {
logger.log("terminal disconnect, tearing down session")
await self.teardownForFailure(message: "Connection lost.")
} else {
self.isReconnecting = false
}
default:
break
}