Added the recording flow reporting and command listening on the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// A service that reports the lifecycle of the recording flow to a surface outside the Recording feature's own UI.
|
||||
///
|
||||
/// ``RecordingView`` attaches the service to its model at initialization, so the reporting destination can be swapped without touching
|
||||
/// the feature's state machine — for example, with a Live Activity backend in the app, or with a mock in unit tests. The model reports
|
||||
/// every transition of the flow: the start of a recording, its pauses and resumptions, the processing of its input, and the end of the
|
||||
/// whole flow.
|
||||
public protocol Reporting: Sendable {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Reports the start of a new recording.
|
||||
///
|
||||
/// - Parameter anchor: The instant the elapsed recording time counts from.
|
||||
func started(at anchor: Date) async
|
||||
|
||||
/// Reports the pause of the ongoing recording.
|
||||
///
|
||||
/// - Parameter elapsed: The number of seconds spent recording so far, excluding any time spent paused.
|
||||
func paused(elapsed: TimeInterval) async
|
||||
|
||||
/// Reports the resumption of a paused recording.
|
||||
///
|
||||
/// - Parameter anchor: The instant the elapsed recording time counts from, moved back by the time already spent recording.
|
||||
func resumed(anchor: Date) async
|
||||
|
||||
/// Reports the processing of the recorded input.
|
||||
///
|
||||
/// - Parameter elapsed: The number of seconds spent recording, excluding any time spent paused.
|
||||
func processing(elapsed: TimeInterval) async
|
||||
|
||||
/// Reports the end of the recording flow.
|
||||
func ended() async
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// The reporting service used for development, which swallows the reported lifecycle of the recording flow.
|
||||
///
|
||||
/// The service is internal on purpose: it only backs the feature's previews and the default values of its model, and is not part of the
|
||||
/// package's public interface.
|
||||
struct DummyReporting: Reporting {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Swallows the start of a new recording.
|
||||
func started(at anchor: Date) async {}
|
||||
|
||||
/// Swallows the pause of the ongoing recording.
|
||||
func paused(elapsed: TimeInterval) async {}
|
||||
|
||||
/// Swallows the resumption of a paused recording.
|
||||
func resumed(anchor: Date) async {}
|
||||
|
||||
/// Swallows the processing of the recorded input.
|
||||
func processing(elapsed: TimeInterval) async {}
|
||||
|
||||
/// Swallows the end of the recording flow.
|
||||
func ended() async {}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Commanding
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -12,6 +13,11 @@ extension RecordingView {
|
||||
///
|
||||
/// A recording paused by a system interruption of the capture resumes by itself once the interruption ends with the system's
|
||||
/// resume hint, and a recording already being processed can still be discarded, cancelling its in-flight transcription.
|
||||
///
|
||||
/// Every transition of the flow is reported through the ``Reporting`` attached at initialization — surfacing the recording outside
|
||||
/// the feature's UI, in a Live Activity for example — and the flow can be controlled from outside that UI in return: the model
|
||||
/// listens to the ``RecordingCommand``s of the attached ``RecordingCommander``, translating each one into the button press
|
||||
/// it mirrors.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Model {
|
||||
@@ -46,6 +52,10 @@ extension RecordingView {
|
||||
@ObservationIgnored
|
||||
private let clock = ContinuousClock()
|
||||
|
||||
/// The commander whose commands control the recording flow from outside the feature's UI.
|
||||
@ObservationIgnored
|
||||
private let commander: RecordingCommander
|
||||
|
||||
/// Whether the recording was paused by a system interruption of the audio capture, as opposed to by the user.
|
||||
@ObservationIgnored
|
||||
private var isPausedByInterruption = false
|
||||
@@ -54,14 +64,26 @@ extension RecordingView {
|
||||
@ObservationIgnored
|
||||
private let locale: Binding<Locale>
|
||||
|
||||
/// The service that reports the lifecycle of the recording flow outside the feature's UI.
|
||||
@ObservationIgnored
|
||||
private let reporter: any Reporting
|
||||
|
||||
/// The task that serializes the calls to the capturing service, so rapid state changes can never reach the service out of order.
|
||||
@ObservationIgnored
|
||||
private var taskCapturer: Task<Void, Never>?
|
||||
|
||||
/// The task that listens to the commands sent through the commander, or `nil` until the first recording starts.
|
||||
@ObservationIgnored
|
||||
private var taskCommands: Task<Void, Never>?
|
||||
|
||||
/// The task that listens to the events emitted by the capturing service, or `nil` until the first recording starts.
|
||||
@ObservationIgnored
|
||||
private var taskEvents: Task<Void, Never>?
|
||||
|
||||
/// The task that serializes the calls to the reporting service, so rapid state changes can never reach the service out of order.
|
||||
@ObservationIgnored
|
||||
private var taskReporter: Task<Void, Never>?
|
||||
|
||||
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
|
||||
@ObservationIgnored
|
||||
private var taskTimer: Task<Void, Never>?
|
||||
@@ -72,18 +94,25 @@ extension RecordingView {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a model attached to the given recording and transcribing services.
|
||||
/// Creates a model attached to the given recording, transcribing, and reporting services.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - capturer: The service that captures the audio from a microphone. Defaults to ``DummyCapturing``.
|
||||
/// - transcribe: The callable service that transcribes the recorded audio into text. Defaults to ``DummyTranscribing``.
|
||||
/// - reporter: The service that reports the lifecycle of the recording flow outside the feature's UI. Defaults to ``DummyReporting``.
|
||||
/// - commander: The commander whose commands control the recording flow from outside the feature's UI. Defaults to the
|
||||
/// process-wide ``RecordingCommander/shared`` instance.
|
||||
/// - locale: The binding to the locale of the spoken language to transcribe. Defaults to a constant binding to the user's current locale.
|
||||
init(
|
||||
capturer: any Capturing = DummyCapturing(),
|
||||
transcribe: any Transcribing = DummyTranscribing(),
|
||||
reporter: any Reporting = DummyReporting(),
|
||||
commander: RecordingCommander = .shared,
|
||||
locale: Binding<Locale> = .constant(.current)
|
||||
) {
|
||||
self.capturer = capturer
|
||||
self.commander = commander
|
||||
self.reporter = reporter
|
||||
self.transcribe = transcribe
|
||||
self.locale = locale
|
||||
}
|
||||
@@ -176,8 +205,8 @@ extension RecordingView {
|
||||
/// Handles a press of the discard button.
|
||||
///
|
||||
/// Throws away a paused recording — or one already being processed, cancelling its in-flight transcription: it resets the
|
||||
/// timer, returns to the not-recording state, and stops the audio capture, deleting the captured audio file. Does nothing in
|
||||
/// any other state.
|
||||
/// timer, returns to the not-recording state, stops the audio capture, deleting the captured audio file, and reports the end
|
||||
/// of the flow. Does nothing in any other state.
|
||||
func pressedDiscard() {
|
||||
isPausedByInterruption = false
|
||||
|
||||
@@ -197,6 +226,9 @@ extension RecordingView {
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
}
|
||||
enqueueReporter {
|
||||
await self.reporter.ended()
|
||||
}
|
||||
case .processing:
|
||||
taskCapturer?.cancel()
|
||||
|
||||
@@ -212,6 +244,9 @@ extension RecordingView {
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
}
|
||||
enqueueReporter {
|
||||
await self.reporter.ended()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -220,7 +255,7 @@ extension RecordingView {
|
||||
/// Handles a press of the main button.
|
||||
///
|
||||
/// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer
|
||||
/// and the audio capture accordingly. Does nothing while the input is being processed.
|
||||
/// and the audio capture accordingly, and reporting the transition. Does nothing while the input is being processed.
|
||||
func pressedMain() {
|
||||
isPausedByInterruption = false
|
||||
|
||||
@@ -228,9 +263,14 @@ extension RecordingView {
|
||||
case .notRecording:
|
||||
state = .recording
|
||||
|
||||
listenToCommands()
|
||||
listenToEvents()
|
||||
startTimer(true)
|
||||
startCapturer(true)
|
||||
|
||||
enqueueReporter { [anchor = Date.now] in
|
||||
await self.reporter.started(at: anchor)
|
||||
}
|
||||
case .recording:
|
||||
pauseRecording()
|
||||
case .paused:
|
||||
@@ -247,7 +287,8 @@ extension RecordingView {
|
||||
|
||||
/// Handles a press of the send button.
|
||||
///
|
||||
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
|
||||
/// Moves a paused recording into processing, stopping the timer, kicking off ``processInput()``, and reporting the transition.
|
||||
/// Does nothing in any other state.
|
||||
func pressedSend() {
|
||||
isPausedByInterruption = false
|
||||
|
||||
@@ -260,6 +301,9 @@ extension RecordingView {
|
||||
enqueueCapturer {
|
||||
await self.processInput()
|
||||
}
|
||||
enqueueReporter { [elapsed = elapsed.timeInterval] in
|
||||
await self.reporter.processing(elapsed: elapsed)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -341,6 +385,10 @@ private extension RecordingView.Model {
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
state = .notRecording
|
||||
|
||||
enqueueReporter {
|
||||
await self.reporter.ended()
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues an operation behind any previously enqueued ones, so the calls to the capturing service always reach it in the order
|
||||
@@ -356,6 +404,43 @@ private extension RecordingView.Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues an operation behind any previously enqueued ones, so the reports to the reporting service always reach it in the
|
||||
/// order the states changed, no matter how quickly the user presses the view's controls.
|
||||
///
|
||||
/// - Parameter operation: The operation on the reporting service to enqueue.
|
||||
func enqueueReporter(
|
||||
_ operation: @escaping @MainActor () async -> Void
|
||||
) {
|
||||
taskReporter = Task { [taskReporter] in
|
||||
await taskReporter?.value
|
||||
await operation()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts listening to the commands sent through the commander, unless already listening: every received command is translated
|
||||
/// into the button press it mirrors — ``RecordingCommand/toggle`` presses the main button, and ``RecordingCommand/discard``
|
||||
/// presses the discard button.
|
||||
func listenToCommands() {
|
||||
guard taskCommands == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
taskCommands = Task { [weak self, commands = commander.commands] in
|
||||
for await command in commands {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .toggle:
|
||||
pressedMain()
|
||||
case .discard:
|
||||
pressedDiscard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses
|
||||
/// an ongoing recording, exactly like a press of the main button would, and the end of an interruption resumes the recording it
|
||||
/// paused — but only when the system hints the capture may resume, and never one the user paused themselves.
|
||||
@@ -388,7 +473,7 @@ private extension RecordingView.Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pauses the ongoing recording: it stops the timer and pauses the audio capture.
|
||||
/// Pauses the ongoing recording: it stops the timer, pauses the audio capture, and reports the pause.
|
||||
func pauseRecording() {
|
||||
state = .paused
|
||||
|
||||
@@ -397,12 +482,21 @@ private extension RecordingView.Model {
|
||||
enqueueCapturer {
|
||||
try? await self.capturer.pause()
|
||||
}
|
||||
enqueueReporter { [elapsed = elapsed.timeInterval] in
|
||||
await self.reporter.paused(elapsed: elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes the paused recording: it restarts the timer, without resetting it, and resumes the audio capture.
|
||||
/// Resumes the paused recording: it restarts the timer, without resetting it, resumes the audio capture, and reports the
|
||||
/// resumption — anchored at the current instant moved back by the time already spent recording, so a timer counting up
|
||||
/// from the anchor shows the elapsed recording time.
|
||||
func resumeRecording() {
|
||||
state = .recording
|
||||
|
||||
enqueueReporter { [anchor = Date(timeIntervalSinceNow: -accumulated.timeInterval)] in
|
||||
await self.reporter.resumed(anchor: anchor)
|
||||
}
|
||||
|
||||
startTimer(false)
|
||||
startCapturer(false)
|
||||
}
|
||||
@@ -480,6 +574,17 @@ private extension RecordingView.Model {
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Duration+TimeInterval
|
||||
|
||||
private extension Duration {
|
||||
|
||||
/// The duration as a number of seconds, for the reports of the recording flow's lifecycle.
|
||||
var timeInterval: TimeInterval {
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - States
|
||||
|
||||
extension RecordingView.Model {
|
||||
|
||||
@@ -31,22 +31,26 @@ public struct RecordingView: View {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a recording view in the not-recording state, attached to the given recording and transcribing services.
|
||||
/// Creates a recording view in the not-recording state, attached to the given recording, transcribing, and reporting services.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - capturer: The service that captures the audio from a microphone.
|
||||
/// - transcriber: The service that transcribes the recorded audio into text.
|
||||
/// - reporter: The service that reports the lifecycle of the recording flow outside the feature's UI, or `nil` to report nowhere.
|
||||
/// Defaults to `nil`.
|
||||
/// - locale: The binding to the locale of the spoken language to transcribe.
|
||||
/// - onTranscription: The closure invoked with the transcription of every processed recording. Defaults to a closure that does nothing.
|
||||
public init(
|
||||
capturer: any Capturing,
|
||||
transcriber: any Transcribing,
|
||||
reporter: (any Reporting)? = nil,
|
||||
locale: Binding<Locale>,
|
||||
onTranscription: @escaping (Transcription) -> Void
|
||||
) {
|
||||
self.model = .init(
|
||||
capturer: capturer,
|
||||
transcribe: transcriber,
|
||||
reporter: reporter ?? DummyReporting(),
|
||||
locale: locale
|
||||
)
|
||||
self.onTranscription = onTranscription
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import Recording
|
||||
|
||||
/// A reporting service that records the lifecycle reports it receives, in call order, along with the values of the last report.
|
||||
@MainActor
|
||||
final class ReportingMock: Reporting {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The names of the reports received by the service, in call order.
|
||||
private(set) var reports: [String] = []
|
||||
|
||||
/// The anchor received with the last started or resumed report, or `nil` when none arrived yet.
|
||||
private(set) var lastAnchor: Date?
|
||||
|
||||
/// The elapsed recording time received with the last paused or processing report, or `nil` when none arrived yet.
|
||||
private(set) var lastElapsed: TimeInterval?
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func started(
|
||||
at anchor: Date
|
||||
) async {
|
||||
reports.append("started")
|
||||
lastAnchor = anchor
|
||||
}
|
||||
|
||||
func paused(
|
||||
elapsed: TimeInterval
|
||||
) async {
|
||||
reports.append("paused")
|
||||
lastElapsed = elapsed
|
||||
}
|
||||
|
||||
func resumed(
|
||||
anchor: Date
|
||||
) async {
|
||||
reports.append("resumed")
|
||||
lastAnchor = anchor
|
||||
}
|
||||
|
||||
func processing(
|
||||
elapsed: TimeInterval
|
||||
) async {
|
||||
reports.append("processing")
|
||||
lastElapsed = elapsed
|
||||
}
|
||||
|
||||
func ended() async {
|
||||
reports.append("ended")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Commanding
|
||||
import SwiftUI
|
||||
import Testing
|
||||
|
||||
@@ -764,6 +765,199 @@ struct RecordingViewModelTests {
|
||||
|
||||
}
|
||||
|
||||
// MARK: Reporter
|
||||
|
||||
@MainActor
|
||||
@Suite("Reporter")
|
||||
struct Reporter {
|
||||
|
||||
@Test
|
||||
func `reports the start of a new recording with a current anchor`() async throws {
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(reporter: reporter)
|
||||
|
||||
let before = Date.now
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(reporter.reports == ["started"])
|
||||
#expect(reporter.lastAnchor ?? .distantPast >= before)
|
||||
#expect(reporter.lastAnchor ?? .distantFuture <= .now)
|
||||
|
||||
model.pressedMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports a pause with the elapsed recording time`() async throws {
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(reporter: reporter)
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(reporter.reports == ["started", "paused"])
|
||||
#expect(reporter.lastElapsed ?? 0 >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports a resumption with an anchor moved back by the elapsed recording time`() async throws {
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(reporter: reporter)
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(1.2))
|
||||
|
||||
model.pressedMain()
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(reporter.reports == ["started", "paused", "resumed"])
|
||||
#expect(reporter.lastAnchor ?? .distantFuture <= Date(timeIntervalSinceNow: -1))
|
||||
|
||||
model.pressedMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports the processing and end of a sent recording`() async throws {
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(
|
||||
transcribe: TranscribingMock(),
|
||||
reporter: reporter
|
||||
)
|
||||
|
||||
model.drive(to: .processing)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
|
||||
#expect(reporter.reports == ["started", "paused", "processing", "ended"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports the end of a discarded recording`() async throws {
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(reporter: reporter)
|
||||
|
||||
model.drive(to: .paused)
|
||||
|
||||
model.pressedDiscard()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(reporter.reports == ["started", "paused", "ended"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reports a pause when the capture is interrupted`() async throws {
|
||||
let capturer = CapturingMock()
|
||||
let reporter = ReportingMock()
|
||||
let model = Model(
|
||||
capturer: capturer,
|
||||
reporter: reporter
|
||||
)
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
capturer.interrupt()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(reporter.reports == ["started", "paused"])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Commands
|
||||
|
||||
@MainActor
|
||||
@Suite("Commands")
|
||||
struct Commands {
|
||||
|
||||
@Test
|
||||
func `a toggle command pauses the ongoing recording`() async throws {
|
||||
let capturer = CapturingMock()
|
||||
let commander = RecordingCommander()
|
||||
let model = Model(
|
||||
capturer: capturer,
|
||||
commander: commander
|
||||
)
|
||||
|
||||
model.pressedMain()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
commander.send(.toggle)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(model.state == .paused)
|
||||
#expect(capturer.calls == ["start", "pause"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a toggle command resumes a paused recording`() async throws {
|
||||
let capturer = CapturingMock()
|
||||
let commander = RecordingCommander()
|
||||
let model = Model(
|
||||
capturer: capturer,
|
||||
commander: commander
|
||||
)
|
||||
|
||||
model.drive(to: .paused)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
commander.send(.toggle)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(model.state == .recording)
|
||||
#expect(capturer.calls == ["start", "pause", "resume"])
|
||||
|
||||
model.pressedMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a discard command throws a paused recording away`() async throws {
|
||||
let commander = RecordingCommander()
|
||||
let model = Model(commander: commander)
|
||||
|
||||
model.drive(to: .paused)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
commander.send(.discard)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(model.state == .notRecording)
|
||||
#expect(model.elapsedSeconds == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `commands are ignored before the first recording starts`() async throws {
|
||||
let commander = RecordingCommander()
|
||||
let model = Model(commander: commander)
|
||||
|
||||
commander.send(.toggle)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(model.state == .notRecording)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
Reference in New Issue
Block a user