96 lines
3.5 KiB
Swift
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() }
|
|
}
|
|
}
|