Added a discard button and error handling to the RecordingView view in the Recording package target.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import Recording
|
||||
import Speech
|
||||
|
||||
@@ -30,6 +31,13 @@ extension ContentView {
|
||||
@ObservationIgnored
|
||||
let capturer: AudioCapturing
|
||||
|
||||
/// The logger that records the failures of the speech model asset management.
|
||||
@ObservationIgnored
|
||||
private let logger = Logger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "Attendi",
|
||||
category: "ContentView.Model"
|
||||
)
|
||||
|
||||
/// The service that transcribes the recorded audio into text on device.
|
||||
@ObservationIgnored
|
||||
let transcriber: AudioTranscribing
|
||||
@@ -65,7 +73,9 @@ extension ContentView {
|
||||
/// Preinstalls the speech model assets for the supported equivalent of ``locale``, so the first transcription in that locale
|
||||
/// does not have to download them mid-processing.
|
||||
///
|
||||
/// Failures are ignored on purpose: the transcribing service installs any missing assets itself as a fallback when a
|
||||
/// The locale reservations of any other locales are released beforehand: the app transcribes a single locale at a time, and
|
||||
/// the system only permits a limited number of reservations — exceeding it would make the installation request throw.
|
||||
/// Failures are logged but not surfaced: the transcribing service installs any missing assets itself as a fallback when a
|
||||
/// transcription starts.
|
||||
func preinstallAssets() async {
|
||||
guard let locale = await SpeechTranscriber.supportedLocale(
|
||||
@@ -74,18 +84,26 @@ extension ContentView {
|
||||
return
|
||||
}
|
||||
|
||||
for reserved in await AssetInventory.reservedLocales where reserved != locale {
|
||||
_ = await AssetInventory.release(reservedLocale: reserved)
|
||||
}
|
||||
|
||||
let transcriber = SpeechTranscriber(
|
||||
locale: locale,
|
||||
preset: .transcription
|
||||
)
|
||||
|
||||
guard let request = try? await AssetInventory.assetInstallationRequest(
|
||||
supporting: [transcriber]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
guard let request = try await AssetInventory.assetInstallationRequest(
|
||||
supporting: [transcriber]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
try? await request.downloadAndInstall()
|
||||
try await request.downloadAndInstall()
|
||||
} catch {
|
||||
logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the localized name of a locale for the locale picker.
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.alert.error.message.assets" : {
|
||||
"comment" : "The message describing unavailable speech model assets.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The speech model for the selected language is not available. Please try again or pick another language."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.alert.error.message.capture" : {
|
||||
"comment" : "The message describing a failure of the audio capture.",
|
||||
"extractionState" : "manual",
|
||||
@@ -72,6 +84,90 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.discard.label" : {
|
||||
"comment" : "The accessibility label of the discard button.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Discard recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.main.label.pause" : {
|
||||
"comment" : "The accessibility label of the main button while recording.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pause recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.main.label.record" : {
|
||||
"comment" : "The accessibility label of the main button while not recording.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Start recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.main.label.resume" : {
|
||||
"comment" : "The accessibility label of the main button while paused.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Resume recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.send.label.processing" : {
|
||||
"comment" : "The accessibility label of the send button while processing.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Processing"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.button.send.label.send" : {
|
||||
"comment" : "The accessibility label of the send button while paused.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Send recording"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"view.recording.timer.label" : {
|
||||
"comment" : "The accessibility label of the timer.",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Recording time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -2,6 +2,8 @@ import Foundation
|
||||
|
||||
/// An error of the recording flow, surfaced to the user by ``RecordingView``.
|
||||
public enum RecordingError: Error, Equatable, Sendable {
|
||||
/// The speech model assets for the locale to transcribe could not be reserved or installed.
|
||||
case assetsUnavailable
|
||||
/// The audio capture failed to start, resume, or stop.
|
||||
case captureFailed
|
||||
/// The user denied the app permission to record audio from the microphone.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
/// The transcribing service that transcribes recorded audio into text on device.
|
||||
@@ -23,7 +24,8 @@ public struct AudioTranscribing: Transcribing {
|
||||
/// - 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 given locale,
|
||||
/// or any error thrown while installing the speech model assets, reading the audio file, or analyzing its contents.
|
||||
/// ``AudioTranscribingError/assetsNotInstalled`` when the speech model assets fail to reserve, download, or install,
|
||||
/// or any error thrown while reading the audio file or analyzing its contents.
|
||||
public func callAsFunction(
|
||||
_ audio: URL,
|
||||
locale: Locale
|
||||
@@ -43,8 +45,14 @@ public struct AudioTranscribing: Transcribing {
|
||||
preset: .transcription
|
||||
)
|
||||
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
try await request.downloadAndInstall()
|
||||
do {
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
try await request.downloadAndInstall()
|
||||
}
|
||||
} catch {
|
||||
logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to install: \(String(describing: error), privacy: .public)")
|
||||
|
||||
throw AudioTranscribingError.assetsNotInstalled
|
||||
}
|
||||
|
||||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||||
@@ -70,6 +78,16 @@ public struct AudioTranscribing: Transcribing {
|
||||
|
||||
/// The errors thrown by ``AudioTranscribing``.
|
||||
public enum AudioTranscribingError: Error {
|
||||
/// The speech model assets for the locale failed to reserve, download, or install.
|
||||
case assetsNotInstalled
|
||||
/// The transcriber supports no equivalent of the locale the transcription was requested with.
|
||||
case localeNotSupported
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// The logger that records the failures of the audio transcribing service.
|
||||
private let logger = Logger(
|
||||
subsystem: "Features.Recording",
|
||||
category: "AudioTranscribing"
|
||||
)
|
||||
|
||||
@@ -108,6 +108,11 @@ extension RecordingView {
|
||||
state == .processing
|
||||
}
|
||||
|
||||
/// Whether the discard button should be visible.
|
||||
var shouldShowDiscard: Bool {
|
||||
state == .paused
|
||||
}
|
||||
|
||||
/// Whether the send button should be visible.
|
||||
var shouldShowSend: Bool {
|
||||
state != .notRecording
|
||||
@@ -126,8 +131,43 @@ extension RecordingView {
|
||||
.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2)))
|
||||
}
|
||||
|
||||
/// The elapsed recording time, spelled out in full units for assistive technologies.
|
||||
var textTimerAccessible: String {
|
||||
Duration
|
||||
.seconds(elapsedSeconds)
|
||||
.formatted(.units(
|
||||
allowed: [.minutes, .seconds],
|
||||
width: .wide
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a press of the discard button.
|
||||
///
|
||||
/// Throws away a paused recording: 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.
|
||||
func pressedDiscard() {
|
||||
guard state == .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
state = .notRecording
|
||||
|
||||
stopTimer()
|
||||
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
|
||||
enqueueCapturer {
|
||||
guard let audio = try? await self.capturer.stop() else {
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -213,7 +253,9 @@ private extension RecordingView.Model {
|
||||
)
|
||||
} catch {
|
||||
transcription = nil
|
||||
self.error = .transcriptionFailed
|
||||
self.error = error as? AudioTranscribingError == .assetsNotInstalled
|
||||
? .assetsUnavailable
|
||||
: .transcriptionFailed
|
||||
}
|
||||
} catch {
|
||||
transcription = nil
|
||||
|
||||
@@ -51,9 +51,9 @@ public struct RecordingView: View {
|
||||
|
||||
// MARK: Body
|
||||
|
||||
/// The content of the view: the timer label above the main and send buttons, with the transcription of every processed recording
|
||||
/// forwarded to the closure given at initialization, and an alert surfacing any error of the recording flow — offering to open the
|
||||
/// app's settings when the microphone permission was denied.
|
||||
/// The content of the view: the timer label above the main, discard, and send buttons, with the transcription of every processed
|
||||
/// recording forwarded to the closure given at initialization, and an alert surfacing any error of the recording flow — offering to
|
||||
/// open the app's settings when the microphone permission was denied.
|
||||
public var body: some View {
|
||||
VStack(
|
||||
spacing: Constant.Spacing.stack
|
||||
@@ -69,6 +69,8 @@ public struct RecordingView: View {
|
||||
.easeInOut,
|
||||
value: model.textTimer
|
||||
)
|
||||
.accessibilityLabel(Constant.Text.labelTimer)
|
||||
.accessibilityValue(model.textTimerAccessible)
|
||||
}
|
||||
|
||||
GlassEffectContainer {
|
||||
@@ -86,6 +88,21 @@ public struct RecordingView: View {
|
||||
invertStyle: true
|
||||
))
|
||||
.disabled(model.shouldDisableMain)
|
||||
.accessibilityLabel(labelMain)
|
||||
|
||||
if model.shouldShowDiscard {
|
||||
Button {
|
||||
model.pressedDiscard()
|
||||
} label: {
|
||||
Image(systemName: Constant.Symbol.discard)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
.buttonStyle(.recording(
|
||||
invertStyle: true
|
||||
))
|
||||
.accessibilityLabel(Constant.Text.labelDiscard)
|
||||
}
|
||||
|
||||
if model.shouldShowSend {
|
||||
Button {
|
||||
@@ -102,6 +119,7 @@ public struct RecordingView: View {
|
||||
invertStyle: !model.isProcessing
|
||||
))
|
||||
.disabled(model.isProcessing)
|
||||
.accessibilityLabel(labelSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +167,26 @@ public struct RecordingView: View {
|
||||
|
||||
private extension RecordingView {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The accessibility label for the main button, matching its action in the current state.
|
||||
var labelMain: String {
|
||||
switch model.state {
|
||||
case .notRecording, .processing: Constant.Text.labelRecord
|
||||
case .recording: Constant.Text.labelPause
|
||||
case .paused: Constant.Text.labelResume
|
||||
}
|
||||
}
|
||||
|
||||
/// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise.
|
||||
var labelSend: String {
|
||||
model.isProcessing
|
||||
? Constant.Text.labelProcessing
|
||||
: Constant.Text.labelSend
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Returns the message describing the given error for the alert.
|
||||
///
|
||||
/// - Parameter error: The error to describe.
|
||||
@@ -157,6 +195,7 @@ private extension RecordingView {
|
||||
for error: RecordingError
|
||||
) -> String {
|
||||
switch error {
|
||||
case .assetsUnavailable: Constant.Text.messageAssetsUnavailable
|
||||
case .captureFailed: Constant.Text.messageCaptureFailed
|
||||
case .permissionDenied: Constant.Text.messagePermissionDenied
|
||||
case .transcriptionFailed: Constant.Text.messageTranscriptionFailed
|
||||
@@ -180,6 +219,12 @@ private enum Constant {
|
||||
static let stack: CGFloat = 8
|
||||
}
|
||||
|
||||
/// The symbol constants.
|
||||
enum Symbol {
|
||||
/// The system symbol of the discard button's image.
|
||||
static let discard = "trash"
|
||||
}
|
||||
|
||||
/// The text constants, localized through the package's string catalog.
|
||||
enum Text {
|
||||
/// The title of the alert's dismissal button.
|
||||
@@ -187,11 +232,51 @@ private enum Constant {
|
||||
localized: "view.recording.alert.error.button.ok",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the discard button.
|
||||
static let labelDiscard = String(
|
||||
localized: "view.recording.button.discard.label",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the main button while recording.
|
||||
static let labelPause = String(
|
||||
localized: "view.recording.button.main.label.pause",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the send button while processing.
|
||||
static let labelProcessing = String(
|
||||
localized: "view.recording.button.send.label.processing",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the main button while not recording.
|
||||
static let labelRecord = String(
|
||||
localized: "view.recording.button.main.label.record",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the main button while paused.
|
||||
static let labelResume = String(
|
||||
localized: "view.recording.button.main.label.resume",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the send button while paused.
|
||||
static let labelSend = String(
|
||||
localized: "view.recording.button.send.label.send",
|
||||
bundle: .module
|
||||
)
|
||||
/// The accessibility label of the timer.
|
||||
static let labelTimer = String(
|
||||
localized: "view.recording.timer.label",
|
||||
bundle: .module
|
||||
)
|
||||
/// The title of the alert's button that opens the app's settings.
|
||||
static let buttonSettings = String(
|
||||
localized: "view.recording.alert.error.button.settings",
|
||||
bundle: .module
|
||||
)
|
||||
/// The message describing unavailable speech model assets.
|
||||
static let messageAssetsUnavailable = String(
|
||||
localized: "view.recording.alert.error.message.assets",
|
||||
bundle: .module
|
||||
)
|
||||
/// The message describing a failure of the audio capture.
|
||||
static let messageCaptureFailed = String(
|
||||
localized: "view.recording.alert.error.message.capture",
|
||||
|
||||
@@ -66,6 +66,23 @@ struct RecordingViewModelTests {
|
||||
#expect(model.state == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[Model.State.notRecording, .recording, .notRecording, .processing]
|
||||
))
|
||||
func `pressed discard transitions to the expected state`(
|
||||
from initial: Model.State,
|
||||
to expected: Model.State
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.drive(to: initial)
|
||||
|
||||
model.pressedDiscard()
|
||||
|
||||
#expect(model.state == expected)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Computed properties
|
||||
@@ -134,6 +151,21 @@ struct RecordingViewModelTests {
|
||||
#expect(model.shouldDisableMain == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, false, true, false]
|
||||
))
|
||||
func `discard button is visible only while paused`(
|
||||
for state: Model.State,
|
||||
expected: Bool
|
||||
) {
|
||||
let model = Model()
|
||||
|
||||
model.drive(to: state)
|
||||
|
||||
#expect(model.shouldShowDiscard == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[Model.State.notRecording, .recording, .paused, .processing],
|
||||
[false, false, true, true]
|
||||
@@ -305,6 +337,21 @@ struct RecordingViewModelTests {
|
||||
#expect(capturer.calls == ["start", "pause", "stop"])
|
||||
}
|
||||
|
||||
@Test func `stops the capture when a paused recording is discarded`() async throws {
|
||||
let capturer = CapturingMock()
|
||||
let model = Model(capturer: capturer)
|
||||
|
||||
model.drive(to: .paused)
|
||||
|
||||
model.pressedDiscard()
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(capturer.calls == ["start", "pause", "stop"])
|
||||
#expect(model.state == .notRecording)
|
||||
#expect(model.elapsedSeconds == 0)
|
||||
}
|
||||
|
||||
@Test func `serializes the calls when states change rapidly`() async throws {
|
||||
let capturer = CapturingMock()
|
||||
|
||||
@@ -378,6 +425,23 @@ struct RecordingViewModelTests {
|
||||
#expect(model.error == .permissionDenied)
|
||||
}
|
||||
|
||||
@Test func `surfaces unavailable speech model assets`() async throws {
|
||||
let transcriber = TranscribingMock()
|
||||
|
||||
transcriber.error = AudioTranscribingError.assetsNotInstalled
|
||||
|
||||
let model = Model(
|
||||
transcribe: transcriber
|
||||
)
|
||||
|
||||
model.drive(to: .processing)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
|
||||
#expect(model.error == .assetsUnavailable)
|
||||
#expect(model.transcription == nil)
|
||||
}
|
||||
|
||||
@Test func `surfaces a transcription failure`() async throws {
|
||||
let transcriber = TranscribingMock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user