From 68c070948e42e09ab80aa643dd1168876c4f90f2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 4 Jul 2026 12:03:19 +0200 Subject: [PATCH] Made the transcribing callable and per-call for the transcribing protocol in the Recording package target. --- .../View Models/ContentViewModel.swift | 2 +- .../Recording/Models/Transcription.swift | 6 +-- .../Protocols/RecordingService.swift | 2 + ...ribingService.swift => Transcribing.swift} | 15 ++++++-- ...gService.swift => AudioTranscribing.swift} | 38 ++++++++----------- ...gService.swift => DummyTranscribing.swift} | 15 +++++--- .../View Models/RecordingViewModel.swift | 17 +++++---- .../Recording/Views/RecordingView.swift | 6 +-- .../View Models/RecordingViewModelTests.swift | 16 ++++---- 9 files changed, 60 insertions(+), 57 deletions(-) rename Packages/Features/Sources/Recording/Protocols/{TranscribingService.swift => Transcribing.swift} (50%) rename Packages/Features/Sources/Recording/Services/{AudioTranscribingService.swift => AudioTranscribing.swift} (73%) rename Packages/Features/Sources/Recording/Services/{DummyTranscribingService.swift => DummyTranscribing.swift} (74%) diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift index f15e346..aeef487 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -24,7 +24,7 @@ extension ContentView { /// The service that transcribes the recorded audio into text on device. @ObservationIgnored - let transcriber = AudioTranscribingService() + let transcriber = AudioTranscribing() // MARK: Methods diff --git a/Packages/Features/Sources/Recording/Models/Transcription.swift b/Packages/Features/Sources/Recording/Models/Transcription.swift index 5c88ebc..776a530 100644 --- a/Packages/Features/Sources/Recording/Models/Transcription.swift +++ b/Packages/Features/Sources/Recording/Models/Transcription.swift @@ -1,7 +1,7 @@ import Foundation /// A transcription of a processed recording. -public struct Transcription { +public struct Transcription: Identifiable, Sendable { // MARK: Properties @@ -29,7 +29,3 @@ public struct Transcription { } } - -// MARK: - Identifiable - -extension Transcription: Identifiable {} diff --git a/Packages/Features/Sources/Recording/Protocols/RecordingService.swift b/Packages/Features/Sources/Recording/Protocols/RecordingService.swift index 95253c8..b2e6784 100644 --- a/Packages/Features/Sources/Recording/Protocols/RecordingService.swift +++ b/Packages/Features/Sources/Recording/Protocols/RecordingService.swift @@ -6,6 +6,8 @@ import Foundation /// state machine — for example, with a real microphone backend in the app, or with a mock in unit tests. public protocol RecordingService: Sendable { + // MARK: Methods + /// Starts a new audio recording from the microphone. func start() async throws diff --git a/Packages/Features/Sources/Recording/Protocols/TranscribingService.swift b/Packages/Features/Sources/Recording/Protocols/Transcribing.swift similarity index 50% rename from Packages/Features/Sources/Recording/Protocols/TranscribingService.swift rename to Packages/Features/Sources/Recording/Protocols/Transcribing.swift index 3687df6..4050529 100644 --- a/Packages/Features/Sources/Recording/Protocols/TranscribingService.swift +++ b/Packages/Features/Sources/Recording/Protocols/Transcribing.swift @@ -4,12 +4,19 @@ import Foundation /// /// ``RecordingView`` attaches the service to its model at initialization, so the transcription can be swapped without touching the feature's /// state machine — for example, with a real transcription backend in the app, or with a fast mock in unit tests. -public protocol TranscribingService: Sendable { +public protocol Transcribing: Sendable { - /// Transcribes the given recorded audio into text. + // MARK: Methods + + /// Transcribes the given recorded audio into text, letting the service be called directly as a function. /// - /// - Parameter audio: The recorded audio to transcribe. + /// - Parameters: + /// - audio: The recorded audio to transcribe. + /// - locale: The locale of the spoken language to transcribe. /// - Returns: The transcription of the recorded audio. - func transcribe(_ audio: Data) async throws -> String + func callAsFunction( + _ audio: Data, + locale: Locale + ) async throws -> Transcription } diff --git a/Packages/Features/Sources/Recording/Services/AudioTranscribingService.swift b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift similarity index 73% rename from Packages/Features/Sources/Recording/Services/AudioTranscribingService.swift rename to Packages/Features/Sources/Recording/Services/AudioTranscribing.swift index 64f672e..ebe787b 100644 --- a/Packages/Features/Sources/Recording/Services/AudioTranscribingService.swift +++ b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift @@ -4,38 +4,30 @@ import Speech /// The transcribing service that transcribes recorded audio into text on device. /// /// The service writes the recorded audio into a temporary `.m4a` file and runs it through a `SpeechAnalyzer` with a `SpeechTranscriber` -/// module, joining the finalized results into the returned text. The transcription happens in the locale given at initialization — or rather in the +/// module, joining the finalized results into the returned transcription. The transcription happens in the locale given at each call — or rather in the /// closest equivalent the transcriber supports. The speech model assets for that locale are downloaded and installed on first use; every /// transcription after that happens entirely offline. -public struct AudioTranscribingService: TranscribingService { - - // MARK: Properties - - /// The locale of the spoken language to transcribe. - private let locale: Locale +public struct AudioTranscribing: Transcribing { // MARK: Initializers - /// Creates an audio transcribing service for a locale. - /// - /// - Parameter locale: The locale of the spoken language to transcribe. Defaults to the user's current locale. - public init( - locale: Locale = .current - ) { - self.locale = locale - } + /// Creates an audio transcribing service. + public init() {} // MARK: Methods /// Transcribes the given recorded audio into text, deleting the temporary audio file when the transcription finishes. /// - /// - Parameter audio: The recorded audio to transcribe. + /// - Parameters: + /// - audio: The recorded audio to transcribe. + /// - locale: The locale of the spoken language to transcribe. /// - Returns: The transcription of the recorded audio. - /// - Throws: ``AudioTranscribingError/localeNotSupported`` when the transcriber supports no equivalent of the service's locale, + /// - Throws: ``AudioTranscribingError/localeNotSupported`` when the transcriber supports no equivalent of the given locale, /// or any error thrown while installing the speech model assets, reading the audio file, or analyzing its contents. - public func transcribe( - _ audio: Data - ) async throws -> String { + public func callAsFunction( + _ audio: Data, + locale: Locale + ) async throws -> Transcription { try audio.write(to: Constant.File.url) defer { @@ -71,16 +63,16 @@ public struct AudioTranscribingService: TranscribingService { await analyzer.cancelAndFinishNow() } - return try await text + return try await .init(text: text) } } // MARK: - Errors -/// The errors thrown by ``AudioTranscribingService``. +/// The errors thrown by ``AudioTranscribing``. public enum AudioTranscribingError: Error { - /// The transcriber supports no equivalent of the locale the service was created with. + /// The transcriber supports no equivalent of the locale the transcription was requested with. case localeNotSupported } diff --git a/Packages/Features/Sources/Recording/Services/DummyTranscribingService.swift b/Packages/Features/Sources/Recording/Services/DummyTranscribing.swift similarity index 74% rename from Packages/Features/Sources/Recording/Services/DummyTranscribingService.swift rename to Packages/Features/Sources/Recording/Services/DummyTranscribing.swift index a869f6e..1d6513b 100644 --- a/Packages/Features/Sources/Recording/Services/DummyTranscribingService.swift +++ b/Packages/Features/Sources/Recording/Services/DummyTranscribing.swift @@ -4,20 +4,23 @@ 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 /// package's public interface. -struct DummyTranscribingService: TranscribingService { +struct DummyTranscribing: Transcribing { // MARK: Methods /// Simulates the transcription of the given recorded audio with a two-second delay. /// - /// - Parameter audio: The recorded audio to transcribe. + /// - Parameters: + /// - audio: The recorded audio to transcribe. + /// - locale: The locale of the spoken language to transcribe. /// - Returns: A dummy transcription. - func transcribe( - _ audio: Data - ) async throws -> String { + func callAsFunction( + _ audio: Data, + locale: Locale + ) async throws -> Transcription { try await Task.sleep(for: Constant.Delay.transcribing) - return Constant.Text.transcription + return .init(text: Constant.Text.transcription) } } diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 6a12de8..6f7c26e 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -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 /// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the - /// ``RecordingService`` and ``TranscribingService`` attached at initialization; when either of them fails, the model falls back to the not-recording state. + /// ``RecordingService`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state. @MainActor @Observable final class Model { @@ -31,9 +31,9 @@ extension RecordingView { @ObservationIgnored private var taskTimer: Task? - /// The service that transcribes the recorded audio into text. + /// The callable service that transcribes the recorded audio into text. @ObservationIgnored - private let transcriber: any TranscribingService + private let transcribe: any Transcribing // MARK: Initializers @@ -41,13 +41,13 @@ extension RecordingView { /// /// - Parameters: /// - recorder: The service that captures the audio from a microphone. Defaults to ``DummyRecordingService``. - /// - transcriber: The service that transcribes the recorded audio into text. Defaults to ``DummyTranscribingService``. + /// - transcribe: The callable service that transcribes the recorded audio into text. Defaults to ``DummyTranscribing``. init( recorder: any RecordingService = DummyRecordingService(), - transcriber: any TranscribingService = DummyTranscribingService() + transcribe: any Transcribing = DummyTranscribing() ) { self.recorder = recorder - self.transcriber = transcriber + self.transcribe = transcribe } // MARK: Computed @@ -185,7 +185,10 @@ private extension RecordingView.Model { do { let audio = try await recorder.stop() - textTranscription = try await transcriber.transcribe(audio) + textTranscription = try await transcribe( + audio, + locale: .current + ).text } catch { textTranscription = nil } diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index 50d85be..9bf5aee 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -26,12 +26,12 @@ public struct RecordingView: View { /// - onTranscription: The closure invoked with the transcribed text of every processed recording. Defaults to a closure that does nothing. public init( recorder: any RecordingService, - transcriber: any TranscribingService, + transcriber: any Transcribing, onTranscription: @escaping (String) -> Void = { _ in } ) { self.model = .init( recorder: recorder, - transcriber: transcriber + transcribe: transcriber ) self.onTranscription = onTranscription } @@ -130,6 +130,6 @@ private enum Constant { ) { RecordingView( recorder: DummyRecordingService(), - transcriber: DummyTranscribingService() + transcriber: DummyTranscribing() ) } diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index a1ba8ec..1ccde6b 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -312,7 +312,7 @@ struct RecordingViewModelTests { let recorder = RecordingServiceMock() let model = Model( recorder: recorder, - transcriber: TranscribingServiceMock() + transcribe: TranscribingMock() ) model.state = .processing @@ -348,7 +348,7 @@ struct RecordingViewModelTests { @Test func `returns to not recording with a reset timer and a transcription`() async throws { let model = Model( - transcriber: TranscribingServiceMock() + transcribe: TranscribingMock() ) model.state = .recording @@ -372,12 +372,12 @@ struct RecordingViewModelTests { } @Test func `clears the transcription when the transcriber fails`() async throws { - let transcriber = TranscribingServiceMock() + let transcriber = TranscribingMock() transcriber.error = ErrorMock() let model = Model( - transcriber: transcriber + transcribe: transcriber ) model.state = .processing @@ -392,7 +392,7 @@ struct RecordingViewModelTests { @Test func `clears the transcription when a new recording starts`() async throws { let model = Model( - transcriber: TranscribingServiceMock() + transcribe: TranscribingMock() ) model.state = .processing @@ -472,7 +472,7 @@ private final class RecordingServiceMock: RecordingService { /// A transcribing service with a configurable delay, output, and failure. @MainActor -private final class TranscribingServiceMock: TranscribingService { +private final class TranscribingMock: Transcribing { /// The duration of the simulated transcription work. var delay: Duration = .seconds(0.2) @@ -483,14 +483,14 @@ private final class TranscribingServiceMock: TranscribingService { /// The transcription the service returns. var transcription = "This is a mocked transcription." - func transcribe(_ audio: Data) async throws -> String { + func callAsFunction(_ audio: Data, locale: Locale) async throws -> Transcription { try await Task.sleep(for: delay) if let error { throw error } - return transcription + return .init(text: transcription) } }