Files

96 lines
3.5 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import Observation
/// A question surface owns one session, including while its adaptive layout
/// measures multiple copies of the answer field.
@Observable
@MainActor
public final class UserInputSpeechSession {
public let ownerID = UUID()
private var coordinator: SpeechTranscriptionCoordinator?
private var startTask: Task<Void, Never>?
private var token: UUID?
public init() {}
public var isActive: Bool {
startTask != nil || coordinator?.isOwned(by: ownerID) == true
}
public var isRecording: Bool {
coordinator?.isOwned(by: ownerID) == true && coordinator?.phase == .recording
}
public var error: SpeechTranscriptionFailure? {
coordinator?.errorOwnerID == ownerID ? coordinator?.error : nil
}
public func start(
coordinator: SpeechTranscriptionCoordinator,
conversation: ConversationModel,
onApplied: @escaping @MainActor () -> Void
) {
guard !isActive, coordinator.canStart(for: ownerID),
let requestID = conversation.pendingUserInput?.id,
let questionID = conversation.currentUserInputQuestion?.id,
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
else { return }
self.coordinator = coordinator
let token = UUID()
self.token = token
observeTarget(conversation, requestID: requestID, questionID: questionID, token: token)
startTask = Task { [weak self, weak conversation] in
guard let self else { return }
defer { if self.token == token { self.startTask = nil } }
guard let conversation, !Task.isCancelled, self.token == token,
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
else { return }
await coordinator.start(ownerID: ownerID) { [weak self, weak conversation] transcript, _ in
guard let self, self.token == token,
conversation?.applyUserInputTranscription(
transcript, requestID: requestID, questionID: questionID) == true
else { return }
onApplied()
}
}
}
public func stop() {
coordinator?.stop(ownerID: ownerID)
}
public func cancel() {
token = nil
startTask?.cancel()
startTask = nil
coordinator?.cancel(ownerID: ownerID)
dismissError()
}
public func dismissError() {
coordinator?.dismissError(ownerID: ownerID)
}
/// Request retirement also happens outside a view (timeouts, cancellation, and queue changes).
/// Keep microphone ownership tied to that request even when no SwiftUI update has run yet.
private func observeTarget(_ conversation: ConversationModel, requestID: UUID, questionID: String, token: UUID) {
let accepts = withObservationTracking {
conversation.acceptsUserInputTranscription(requestID: requestID, questionID: questionID)
} onChange: { [weak self, weak conversation] in
Task { @MainActor in
guard let self, self.token == token else { return }
guard let conversation else {
self.cancel()
return
}
self.observeTarget(conversation, requestID: requestID, questionID: questionID, token: token)
}
}
if !accepts { cancel() }
}
}