Renamed the RecordingService protocol in the Recording package target as Capturing.
This commit is contained in:
@@ -28,7 +28,7 @@ extension ContentView {
|
|||||||
|
|
||||||
/// The service that captures the audio from the device's microphone.
|
/// The service that captures the audio from the device's microphone.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
let recorder: AudioRecordingService
|
let capturer: AudioCapturing
|
||||||
|
|
||||||
/// The service that transcribes the recorded audio into text on device.
|
/// The service that transcribes the recorded audio into text on device.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -41,7 +41,7 @@ extension ContentView {
|
|||||||
init() {
|
init() {
|
||||||
self.locale = .current
|
self.locale = .current
|
||||||
self.locales = []
|
self.locales = []
|
||||||
self.recorder = .init()
|
self.capturer = .init()
|
||||||
self.transcriber = .init()
|
self.transcriber = .init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
RecordingView(
|
RecordingView(
|
||||||
recorder: model.recorder,
|
capturer: model.capturer,
|
||||||
transcriber: model.transcriber,
|
transcriber: model.transcriber,
|
||||||
locale: $model.locale,
|
locale: $model.locale,
|
||||||
) { transcription in
|
) { transcription in
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// ``RecordingView`` attaches the service to its model at initialization, so the audio capture can be swapped without touching the feature's
|
/// ``RecordingView`` attaches the service to its model at initialization, so the audio capture can be swapped without touching the feature's
|
||||||
/// state machine — for example, with a real microphone backend in the app, or with a mock in unit tests.
|
/// state machine — for example, with a real microphone backend in the app, or with a mock in unit tests.
|
||||||
public protocol RecordingService: Sendable {
|
public protocol Capturing: Sendable {
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
+15
-15
@@ -6,7 +6,7 @@ import AVFoundation
|
|||||||
/// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is
|
/// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is
|
||||||
/// configured for recording while the capture is in progress.
|
/// configured for recording while the capture is in progress.
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class AudioRecordingService: RecordingService {
|
public final class AudioCapturing: Capturing {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
@@ -22,12 +22,12 @@ public final class AudioRecordingService: RecordingService {
|
|||||||
|
|
||||||
/// Starts a new audio recording from the microphone.
|
/// Starts a new audio recording from the microphone.
|
||||||
///
|
///
|
||||||
/// - Throws: ``AudioRecordingError/permissionNotGranted`` when the user denies the app access to the microphone,
|
/// - Throws: ``AudioCapturingError/permissionNotGranted`` when the user denies the app access to the microphone,
|
||||||
/// ``AudioRecordingError/captureNotStarted`` when the recorder fails to start, or any error thrown while configuring
|
/// ``AudioCapturingError/captureNotStarted`` when the recorder fails to start, or any error thrown while configuring
|
||||||
/// the audio session or creating the recorder.
|
/// the audio session or creating the recorder.
|
||||||
public func start() async throws {
|
public func start() async throws {
|
||||||
guard await AVAudioApplication.requestRecordPermission() else {
|
guard await AVAudioApplication.requestRecordPermission() else {
|
||||||
throw AudioRecordingError.permissionNotGranted
|
throw AudioCapturingError.permissionNotGranted
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(visionOS)
|
#if os(iOS) || os(visionOS)
|
||||||
@@ -43,7 +43,7 @@ public final class AudioRecordingService: RecordingService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard recorder.record() else {
|
guard recorder.record() else {
|
||||||
throw AudioRecordingError.captureNotStarted
|
throw AudioCapturingError.captureNotStarted
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recorder = recorder
|
self.recorder = recorder
|
||||||
@@ -51,10 +51,10 @@ public final class AudioRecordingService: RecordingService {
|
|||||||
|
|
||||||
/// Pauses the ongoing recording.
|
/// Pauses the ongoing recording.
|
||||||
///
|
///
|
||||||
/// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress.
|
/// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress.
|
||||||
public func pause() async throws {
|
public func pause() async throws {
|
||||||
guard let recorder else {
|
guard let recorder else {
|
||||||
throw AudioRecordingError.noOngoingRecording
|
throw AudioCapturingError.noOngoingRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.pause()
|
recorder.pause()
|
||||||
@@ -62,25 +62,25 @@ public final class AudioRecordingService: RecordingService {
|
|||||||
|
|
||||||
/// Resumes a paused recording.
|
/// Resumes a paused recording.
|
||||||
///
|
///
|
||||||
/// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or
|
/// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress, or
|
||||||
/// ``AudioRecordingError/captureNotStarted`` when the recorder fails to resume.
|
/// ``AudioCapturingError/captureNotStarted`` when the recorder fails to resume.
|
||||||
public func resume() async throws {
|
public func resume() async throws {
|
||||||
guard let recorder else {
|
guard let recorder else {
|
||||||
throw AudioRecordingError.noOngoingRecording
|
throw AudioCapturingError.noOngoingRecording
|
||||||
}
|
}
|
||||||
guard recorder.record() else {
|
guard recorder.record() else {
|
||||||
throw AudioRecordingError.captureNotStarted
|
throw AudioCapturingError.captureNotStarted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the recording, deleting the temporary audio file after its contents are read.
|
/// Stops the recording, deleting the temporary audio file after its contents are read.
|
||||||
///
|
///
|
||||||
/// - Returns: The audio captured since the recording started.
|
/// - Returns: The audio captured since the recording started.
|
||||||
/// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or any error thrown
|
/// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress, or any error thrown
|
||||||
/// while reading the recorded audio file.
|
/// while reading the recorded audio file.
|
||||||
public func stop() async throws -> Data {
|
public func stop() async throws -> Data {
|
||||||
guard let recorder else {
|
guard let recorder else {
|
||||||
throw AudioRecordingError.noOngoingRecording
|
throw AudioCapturingError.noOngoingRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
@@ -102,8 +102,8 @@ public final class AudioRecordingService: RecordingService {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
/// The errors thrown by ``AudioRecordingService``.
|
/// The errors thrown by ``AudioCapturing``.
|
||||||
public enum AudioRecordingError: Error {
|
public enum AudioCapturingError: Error {
|
||||||
/// The recorder failed to start or resume the audio capture.
|
/// The recorder failed to start or resume the audio capture.
|
||||||
case captureNotStarted
|
case captureNotStarted
|
||||||
/// No recording is in progress.
|
/// No recording is in progress.
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// 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
|
/// 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.
|
/// package's public interface.
|
||||||
struct DummyRecordingService: RecordingService {
|
struct DummyCapturing: Capturing {
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ extension RecordingView {
|
|||||||
///
|
///
|
||||||
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the
|
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the
|
||||||
/// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the
|
/// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the
|
||||||
/// ``RecordingService`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
/// ``Capturing`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class Model {
|
final class Model {
|
||||||
@@ -41,7 +41,7 @@ extension RecordingView {
|
|||||||
|
|
||||||
/// The service that captures the audio from a microphone.
|
/// The service that captures the audio from a microphone.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private let recorder: any RecordingService
|
private let capturer: any Capturing
|
||||||
|
|
||||||
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
|
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
@@ -56,15 +56,15 @@ extension RecordingView {
|
|||||||
/// Creates a model attached to the given recording and transcribing services.
|
/// Creates a model attached to the given recording and transcribing services.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - recorder: The service that captures the audio from a microphone. Defaults to ``DummyRecordingService``.
|
/// - 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``.
|
/// - transcribe: The callable service that transcribes the recorded audio into text. Defaults to ``DummyTranscribing``.
|
||||||
/// - locale: The binding to the locale of the spoken language to transcribe. Defaults to a constant binding to the user's current locale.
|
/// - locale: The binding to the locale of the spoken language to transcribe. Defaults to a constant binding to the user's current locale.
|
||||||
init(
|
init(
|
||||||
recorder: any RecordingService = DummyRecordingService(),
|
capturer: any Capturing = DummyCapturing(),
|
||||||
transcribe: any Transcribing = DummyTranscribing(),
|
transcribe: any Transcribing = DummyTranscribing(),
|
||||||
locale: Binding<Locale> = .constant(.current)
|
locale: Binding<Locale> = .constant(.current)
|
||||||
) {
|
) {
|
||||||
self.recorder = recorder
|
self.capturer = capturer
|
||||||
self.transcribe = transcribe
|
self.transcribe = transcribe
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
}
|
}
|
||||||
@@ -167,12 +167,12 @@ extension RecordingView {
|
|||||||
switch state {
|
switch state {
|
||||||
case .recording:
|
case .recording:
|
||||||
startTimer(shouldRestartTimer)
|
startTimer(shouldRestartTimer)
|
||||||
startRecorder(shouldRestartTimer)
|
startCapturer(shouldRestartTimer)
|
||||||
case .paused:
|
case .paused:
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
try? await recorder.pause()
|
try? await capturer.pause()
|
||||||
}
|
}
|
||||||
case .processing:
|
case .processing:
|
||||||
stopTimer()
|
stopTimer()
|
||||||
@@ -213,7 +213,7 @@ private extension RecordingView.Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let audio = try await recorder.stop()
|
let audio = try await capturer.stop()
|
||||||
|
|
||||||
transcription = try await transcribe(
|
transcription = try await transcribe(
|
||||||
audio,
|
audio,
|
||||||
@@ -228,17 +228,17 @@ private extension RecordingView.Model {
|
|||||||
state = .notRecording
|
state = .notRecording
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the audio capture through the attached ``RecordingService``, falling back to the not-recording state when the service fails.
|
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state when the service fails.
|
||||||
///
|
///
|
||||||
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
|
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
|
||||||
func startRecorder(
|
func startCapturer(
|
||||||
_ isNewRecording: Bool
|
_ isNewRecording: Bool
|
||||||
) {
|
) {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
isNewRecording
|
isNewRecording
|
||||||
? try await recorder.start()
|
? try await capturer.start()
|
||||||
: try await recorder.resume()
|
: try await capturer.resume()
|
||||||
} catch {
|
} catch {
|
||||||
stopTimer()
|
stopTimer()
|
||||||
|
|
||||||
|
|||||||
@@ -21,18 +21,18 @@ public struct RecordingView: View {
|
|||||||
/// 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 and transcribing services.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - recorder: The service that captures the audio from a microphone.
|
/// - capturer: The service that captures the audio from a microphone.
|
||||||
/// - transcriber: The service that transcribes the recorded audio into text.
|
/// - transcriber: The service that transcribes the recorded audio into text.
|
||||||
/// - locale: The binding to the locale of the spoken language to transcribe.
|
/// - 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.
|
/// - onTranscription: The closure invoked with the transcription of every processed recording. Defaults to a closure that does nothing.
|
||||||
public init(
|
public init(
|
||||||
recorder: any RecordingService,
|
capturer: any Capturing,
|
||||||
transcriber: any Transcribing,
|
transcriber: any Transcribing,
|
||||||
locale: Binding<Locale>,
|
locale: Binding<Locale>,
|
||||||
onTranscription: @escaping (Transcription) -> Void
|
onTranscription: @escaping (Transcription) -> Void
|
||||||
) {
|
) {
|
||||||
self.model = .init(
|
self.model = .init(
|
||||||
recorder: recorder,
|
capturer: capturer,
|
||||||
transcribe: transcriber,
|
transcribe: transcriber,
|
||||||
locale: locale
|
locale: locale
|
||||||
)
|
)
|
||||||
@@ -132,7 +132,7 @@ private enum Constant {
|
|||||||
"Recording view"
|
"Recording view"
|
||||||
) {
|
) {
|
||||||
RecordingView(
|
RecordingView(
|
||||||
recorder: DummyRecordingService(),
|
capturer: DummyCapturing(),
|
||||||
transcriber: DummyTranscribing(),
|
transcriber: DummyTranscribing(),
|
||||||
locale: .constant(.current)
|
locale: .constant(.current)
|
||||||
) { _ in
|
) { _ in
|
||||||
|
|||||||
@@ -249,31 +249,31 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Recorder
|
// MARK: Capturer
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("Recorder")
|
@Suite("Capturer")
|
||||||
struct Recorder {
|
struct Capturer {
|
||||||
|
|
||||||
@Test func `starts the capture for a new recording`() async throws {
|
@Test func `starts the capture for a new recording`() async throws {
|
||||||
let recorder = RecordingServiceMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(recorder: recorder)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
model.state = .recording
|
model.state = .recording
|
||||||
model.updatedState(shouldRestartTimer: true)
|
model.updatedState(shouldRestartTimer: true)
|
||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(recorder.countStart == 1)
|
#expect(capturer.countStart == 1)
|
||||||
#expect(recorder.countResume == 0)
|
#expect(capturer.countResume == 0)
|
||||||
|
|
||||||
model.state = .notRecording
|
model.state = .notRecording
|
||||||
model.updatedState(shouldRestartTimer: false)
|
model.updatedState(shouldRestartTimer: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `pauses the capture while paused`() async throws {
|
@Test func `pauses the capture while paused`() async throws {
|
||||||
let recorder = RecordingServiceMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(recorder: recorder)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
model.state = .recording
|
model.state = .recording
|
||||||
model.updatedState(shouldRestartTimer: true)
|
model.updatedState(shouldRestartTimer: true)
|
||||||
@@ -283,12 +283,12 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(recorder.countPause == 1)
|
#expect(capturer.countPause == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `resumes the capture after a pause`() async throws {
|
@Test func `resumes the capture after a pause`() async throws {
|
||||||
let recorder = RecordingServiceMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(recorder: recorder)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
model.state = .recording
|
model.state = .recording
|
||||||
model.updatedState(shouldRestartTimer: true)
|
model.updatedState(shouldRestartTimer: true)
|
||||||
@@ -301,17 +301,17 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.1))
|
try await Task.sleep(for: .seconds(0.1))
|
||||||
|
|
||||||
#expect(recorder.countStart == 1)
|
#expect(capturer.countStart == 1)
|
||||||
#expect(recorder.countResume == 1)
|
#expect(capturer.countResume == 1)
|
||||||
|
|
||||||
model.state = .notRecording
|
model.state = .notRecording
|
||||||
model.updatedState(shouldRestartTimer: false)
|
model.updatedState(shouldRestartTimer: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `stops the capture when the input is sent`() async throws {
|
@Test func `stops the capture when the input is sent`() async throws {
|
||||||
let recorder = RecordingServiceMock()
|
let capturer = CapturingMock()
|
||||||
let model = Model(
|
let model = Model(
|
||||||
recorder: recorder,
|
capturer: capturer,
|
||||||
transcribe: TranscribingMock()
|
transcribe: TranscribingMock()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,15 +320,15 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
try await Task.sleep(for: .seconds(0.5))
|
try await Task.sleep(for: .seconds(0.5))
|
||||||
|
|
||||||
#expect(recorder.countStop == 1)
|
#expect(capturer.countStop == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func `falls back to not recording when the capture fails`() async throws {
|
@Test func `falls back to not recording when the capture fails`() async throws {
|
||||||
let recorder = RecordingServiceMock()
|
let capturer = CapturingMock()
|
||||||
|
|
||||||
recorder.error = ErrorMock()
|
capturer.error = ErrorMock()
|
||||||
|
|
||||||
let model = Model(recorder: recorder)
|
let model = Model(capturer: capturer)
|
||||||
|
|
||||||
model.state = .recording
|
model.state = .recording
|
||||||
model.updatedState(shouldRestartTimer: true)
|
model.updatedState(shouldRestartTimer: true)
|
||||||
@@ -419,7 +419,7 @@ struct RecordingViewModelTests {
|
|||||||
|
|
||||||
/// A recording service that counts its invocations and can be configured to fail.
|
/// A recording service that counts its invocations and can be configured to fail.
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class RecordingServiceMock: RecordingService {
|
private final class CapturingMock: Capturing {
|
||||||
|
|
||||||
/// The error the service throws from every method, or `nil` when it should succeed.
|
/// The error the service throws from every method, or `nil` when it should succeed.
|
||||||
var error: Error?
|
var error: Error?
|
||||||
|
|||||||
Reference in New Issue
Block a user