Implemented the AssetPreinstalling service in the Recording package target.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// A service that preinstalls the speech model assets a transcription needs, for the Recording feature.
|
||||
///
|
||||
/// The service resolves the locales the transcriber supports and downloads the speech model assets of a given locale ahead of its
|
||||
/// first transcription. The outcomes of a preinstallation are emitted through ``events`` rather than thrown: a host can surface the
|
||||
/// started, cancelled, and failed downloads however it sees fit, while a download that finishes — or assets that are already
|
||||
/// installed — emit nothing.
|
||||
public protocol Preinstalling: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The stream of events the service emits while preinstalling.
|
||||
var events: AsyncStream<PreinstallingEvent> { get }
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Preinstalls the speech model assets for the supported equivalent of the given locale.
|
||||
///
|
||||
/// - Parameter locale: The locale to preinstall the speech model assets for.
|
||||
func preinstall(for locale: Locale) async
|
||||
|
||||
/// Returns the supported locale equivalent to the given locale.
|
||||
///
|
||||
/// - Parameter locale: The locale to find a supported equivalent for.
|
||||
/// - Returns: The supported equivalent, or `nil` when the transcriber supports none.
|
||||
func supportedLocale(equivalentTo locale: Locale) async -> Locale?
|
||||
|
||||
/// Returns the locales the transcriber supports.
|
||||
///
|
||||
/// - Returns: The supported locales, including ones whose assets are not installed yet.
|
||||
func supportedLocales() async -> [Locale]
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
/// An event emitted by a ``Preinstalling`` service while preinstalling speech model assets.
|
||||
public enum PreinstallingEvent: Sendable {
|
||||
/// The download of the locale's speech model assets was cancelled before it finished.
|
||||
case cancelled(Locale)
|
||||
/// The download of the locale's speech model assets failed.
|
||||
case failed(Locale)
|
||||
/// The download of the locale's speech model assets started.
|
||||
case started(Locale)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
/// The preinstalling service that downloads speech model assets through the system's asset inventory — the sole owner of the
|
||||
/// asset inventory knowledge in the feature.
|
||||
///
|
||||
/// The service offers two installation paths over the same core. ``preinstall(for:)`` installs the assets for the supported equivalent
|
||||
/// of a given locale ahead of its first transcription, logging and emitting its outcomes through ``events`` rather than throwing.
|
||||
/// ``install(for:onDownloadStart:)`` installs the assets for an already supported locale and throws on failure, for callers that
|
||||
/// need the assets right away — like the transcribing service, which uses it as its fallback when a transcription starts.
|
||||
///
|
||||
/// Either path releases the locale reservations of any other locales beforehand: the feature transcribes a single locale at a time,
|
||||
/// and the system only permits a limited number of reservations per app — exceeding it would make the installation request throw.
|
||||
public final class AssetPreinstalling: Preinstalling {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The stream of events the service emits while preinstalling.
|
||||
public let events: AsyncStream<PreinstallingEvent>
|
||||
|
||||
/// The continuation that feeds ``events``.
|
||||
private let continuation: AsyncStream<PreinstallingEvent>.Continuation
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an asset preinstalling service.
|
||||
public init() {
|
||||
(events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Installs the speech model assets for the given supported locale, waiting for any download to finish.
|
||||
///
|
||||
/// The locale reservations of any other locales are released beforehand. Unlike ``preinstall(for:)``, failures are thrown rather
|
||||
/// than emitted, so a caller that needs the assets right away — like the transcribing service — can react to them directly.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - locale: The supported locale to install the speech model assets for.
|
||||
/// - onDownloadStart: The closure invoked when the assets are missing and their download starts. Defaults to `nil`.
|
||||
/// - Throws: Any error thrown while reserving, downloading, or installing the assets, including a `CancellationError`
|
||||
/// when the surrounding task is cancelled mid-download.
|
||||
public func install(
|
||||
for locale: Locale,
|
||||
onDownloadStart: (@Sendable () -> Void)? = nil
|
||||
) async throws {
|
||||
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
|
||||
}
|
||||
|
||||
onDownloadStart?()
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
try await request.downloadAndInstall()
|
||||
} onCancel: { [progress = request.progress] in
|
||||
progress.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Preinstalls the speech model assets for the supported equivalent of the given locale, emitting the started, cancelled, and
|
||||
/// failed downloads through ``events`` — a download that finishes, or assets that are already installed, emit nothing.
|
||||
///
|
||||
/// - Parameter locale: The locale to preinstall the speech model assets for.
|
||||
public func preinstall(
|
||||
for locale: Locale
|
||||
) async {
|
||||
guard let locale = await supportedLocale(equivalentTo: locale) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await install(for: locale) { [continuation] in
|
||||
continuation.yield(.started(locale))
|
||||
}
|
||||
} catch {
|
||||
if error is CancellationError || Task.isCancelled {
|
||||
continuation.yield(.cancelled(locale))
|
||||
} else {
|
||||
continuation.yield(.failed(locale))
|
||||
|
||||
logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the supported locale equivalent to the given locale.
|
||||
///
|
||||
/// - Parameter locale: The locale to find a supported equivalent for.
|
||||
/// - Returns: The supported equivalent, or `nil` when the transcriber supports none.
|
||||
public func supportedLocale(
|
||||
equivalentTo locale: Locale
|
||||
) async -> Locale? {
|
||||
await SpeechTranscriber.supportedLocale(equivalentTo: locale)
|
||||
}
|
||||
|
||||
/// Returns the locales the transcriber supports.
|
||||
///
|
||||
/// - Returns: The supported locales, including ones whose assets are not installed yet.
|
||||
public func supportedLocales() async -> [Locale] {
|
||||
await SpeechTranscriber.supportedLocales
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
/// The logger that records the failures of the asset preinstalling service.
|
||||
private let logger = Logger(
|
||||
subsystem: "Features.Recording",
|
||||
category: "AssetPreinstalling"
|
||||
)
|
||||
@@ -6,10 +6,15 @@ import Speech
|
||||
///
|
||||
/// The service runs the given recorded audio file through a `SpeechAnalyzer` with a `SpeechTranscriber` 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.
|
||||
/// the transcriber supports. Any speech model assets still missing for that locale are installed first through ``AssetPreinstalling``;
|
||||
/// every transcription after that happens entirely offline.
|
||||
public struct AudioTranscribing: Transcribing {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The preinstalling service that installs any speech model assets still missing when a transcription starts.
|
||||
private let preinstaller = AssetPreinstalling()
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an audio transcribing service.
|
||||
@@ -46,9 +51,7 @@ public struct AudioTranscribing: Transcribing {
|
||||
)
|
||||
|
||||
do {
|
||||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||||
try await request.downloadAndInstall()
|
||||
}
|
||||
try await preinstaller.install(for: locale)
|
||||
} catch {
|
||||
logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to install: \(String(describing: error), privacy: .public)")
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Notifying
|
||||
|
||||
@Suite("AppNotification model")
|
||||
struct AppNotificationTests {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
@Suite("Initializers")
|
||||
struct Initializers {
|
||||
|
||||
@Test(arguments: zip(
|
||||
[AppNotification.Kind.error, .info, .warning],
|
||||
["An error occurred.", "Something happened.", "Something needs attention."]
|
||||
))
|
||||
func `initializer stores the given kind, message, and symbol`(
|
||||
kind: AppNotification.Kind,
|
||||
message: String
|
||||
) {
|
||||
let notification = AppNotification(
|
||||
kind: kind,
|
||||
message: message,
|
||||
symbol: "bell"
|
||||
)
|
||||
|
||||
#expect(notification.kind == kind)
|
||||
#expect(notification.message == message)
|
||||
#expect(notification.symbol == "bell")
|
||||
}
|
||||
|
||||
@Test func `initializer stores the given identifier`() {
|
||||
let id = UUID()
|
||||
let notification = AppNotification(
|
||||
id: id,
|
||||
kind: .info,
|
||||
message: "Something happened.",
|
||||
symbol: "bell"
|
||||
)
|
||||
|
||||
#expect(notification.id == id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
@Suite("Equatable")
|
||||
struct EquatableConformance {
|
||||
|
||||
@Test func `notifications with the same identifier and values are equal`() {
|
||||
let id = UUID()
|
||||
|
||||
let first = AppNotification(
|
||||
id: id,
|
||||
kind: .info,
|
||||
message: "Something happened.",
|
||||
symbol: "bell"
|
||||
)
|
||||
let second = AppNotification(
|
||||
id: id,
|
||||
kind: .info,
|
||||
message: "Something happened.",
|
||||
symbol: "bell"
|
||||
)
|
||||
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test func `notifications with generated identifiers are never equal`() {
|
||||
let first = AppNotification(
|
||||
kind: .info,
|
||||
message: "Something happened.",
|
||||
symbol: "bell"
|
||||
)
|
||||
let second = AppNotification(
|
||||
kind: .info,
|
||||
message: "Something happened.",
|
||||
symbol: "bell"
|
||||
)
|
||||
|
||||
#expect(first != second)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user