Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ once tagged releases begin.

## [Unreleased]

_Nothing yet._
### Added
- **Error reporting.** A public `PrototypeKitError` type and an optional `onError` closure on the
entry points that can fail at setup time: `ImageClassifierView`, `HandPoseClassifierView`, the
`classifyActivity(...)` modifier (model-load failures), and the `recognizeSounds(...)` modifier
(failed starts, denied microphone access, audio-session interruptions). Apps can now *react* to
these failures — show a message, offer a retry — instead of only reading the log. The parameter
defaults to `nil`, so existing call sites are unaffected, and errors are still logged as before.
Transient per-frame Vision errors remain log-only by design.

## [0.1.0] - 2026-07-02

Expand Down
6 changes: 6 additions & 0 deletions Sources/PrototypeKit/Audio/AudioClassifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ final class SystemAudioClassifier: NSObject {
/// restart classification after an interruption and keep receiving results on the same relay.
let results = PassthroughSubject<SNClassificationResult, Never>()

/// A long-lived relay that publishes classification session errors (denied microphone access,
/// audio interruptions, failed starts). Like ``results`` it never completes, so callers can keep
/// observing across sessions.
let errors = PassthroughSubject<Error, Never>()

/// The subject for the current classification session, if any.
private var sessionSubject: PassthroughSubject<SNClassificationResult, Error>?

Expand Down Expand Up @@ -164,6 +169,7 @@ final class SystemAudioClassifier: NSObject {
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
PKLog.audio.error("Sound classification session ended: \(error.localizedDescription)")
self?.errors.send(error)
}
self?.sessionCancellable = nil
self?.sessionSubject = nil
Expand Down
24 changes: 20 additions & 4 deletions Sources/PrototypeKit/Public/ClassifyActivityModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,18 @@ extension View {
/// model's feature names. Defaults match a standard Create ML Activity Classifier.
/// - latestActivity: A binding updated with the most recently predicted activity label, or `nil`
/// when nothing has been classified yet.
/// - onError: An optional closure called (on the main thread) if the model fails to load. The
/// modifier stays inert; use this to surface the failure in your UI.
/// - Returns: A view that classifies activity while visible.
/// - Important: Activity classification relies on `CoreMotion` and is available on iOS only.
public func classifyActivity(modelURL: URL,
configuration: ActivityClassifierConfiguration = .init(),
latestActivity: Binding<String?>) -> some View {
latestActivity: Binding<String?>,
onError: ((PrototypeKitError) -> Void)? = nil) -> some View {
modifier(ClassifyActivityModifier(modelURL: modelURL,
configuration: configuration,
latestActivity: latestActivity))
latestActivity: latestActivity,
onError: onError))
}
}

Expand All @@ -302,26 +306,38 @@ struct ClassifyActivityModifier: ViewModifier {

@Binding var latestActivity: String?

private let onError: ((PrototypeKitError) -> Void)?

private let loadError: PrototypeKitError?

init(modelURL: URL,
configuration: ActivityClassifierConfiguration,
latestActivity: Binding<String?>) {
latestActivity: Binding<String?>,
onError: ((PrototypeKitError) -> Void)? = nil) {
self._latestActivity = latestActivity
self.onError = onError
let loadedModel: MLModel?
var loadError: PrototypeKitError?
do {
loadedModel = try MLModel(contentsOf: modelURL)
} catch {
// Degrade gracefully: the modifier stays inert rather than crashing the host app
// when the model can't be loaded.
PKLog.model.error("Failed to load activity model at \(modelURL.path): \(error.localizedDescription)")
loadedModel = nil
loadError = .modelLoadFailed(url: modelURL, underlying: error)
}
self.loadError = loadError
self._receiver = StateObject(wrappedValue: ActivityClassifierReceiver(mlModel: loadedModel,
configuration: configuration))
}

func body(content: Content) -> some View {
content
.onAppear { receiver.start() }
.onAppear {
receiver.start()
if let loadError = loadError { onError?(loadError) }
}
.onDisappear { receiver.stop() }
.onReceive(receiver.$latestPrediction) { newPrediction in
guard let newPrediction = newPrediction else { return }
Expand Down
18 changes: 16 additions & 2 deletions Sources/PrototypeKit/Public/HandPoseClassifierView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,31 +89,45 @@ public struct HandPoseClassifierView: View {

@Binding var latestPrediction: String

private let onError: ((PrototypeKitError) -> Void)?

private let loadError: PrototypeKitError?

/// Creates a hand pose classifier view backed by a Core ML model.
///
/// - Parameters:
/// - modelURL: The location of the compiled Core ML / Create ML hand-pose model to load, typically
/// `YourModel.urlOfModelInThisBundle`.
/// - latestPrediction: A binding updated with the most recent predicted hand-pose label. Defaults to a
/// constant empty string when you only need the on-screen camera feed.
public init(modelURL: URL, latestPrediction: Binding<String> = .constant("")) {
/// - onError: An optional closure called (on the main thread) if the model fails to load. The view
/// still shows the camera feed without classification; use this to surface the failure in your UI.
public init(modelURL: URL,
latestPrediction: Binding<String> = .constant(""),
onError: ((PrototypeKitError) -> Void)? = nil) {
self._latestPrediction = latestPrediction
self.onError = onError
do {
let mlModel = try MLModel(contentsOf: modelURL)
self.receiver = HandPoseClassifierReceiver(mlModel: mlModel)
self.loadError = nil
} catch {
// Degrade gracefully: show the camera feed without classification rather than
// crashing the host app when the model can't be loaded.
PKLog.model.error("Failed to load hand pose model at \(modelURL.path): \(error.localizedDescription)")
self.receiver = HandPoseClassifierReceiver(mlModel: nil)
self.loadError = .modelLoadFailed(url: modelURL, underlying: error)
}
}

public var body: some View {
PKCameraView(receiver: receiver)
.onReceive(receiver.$latestPrediction, perform: { newPrediction in
guard let newPrediction = newPrediction else { return }
self.latestPrediction = newPrediction
})
.onAppear {
if let loadError = loadError { onError?(loadError) }
}
}
}
17 changes: 15 additions & 2 deletions Sources/PrototypeKit/Public/ImageClassifierView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public struct ImageClassifierView: View {

private let cameraOptions: CameraOptions?

private let onError: ((PrototypeKitError) -> Void)?

private let loadError: PrototypeKitError?

/// Creates an image classifier view backed by a Core ML model.
///
/// - Parameters:
Expand All @@ -86,28 +90,37 @@ public struct ImageClassifierView: View {
/// constant empty string when you only need the on-screen camera feed.
/// - camera: Optional ``CameraOptions`` selecting the camera position and device type. Pass `nil`
/// to use the default back wide-angle camera. (Ignored on macOS.)
/// - onError: An optional closure called (on the main thread) if the model fails to load. The view
/// still shows the camera feed without classification; use this to surface the failure in your UI.
public init(modelURL: URL,
latestPrediction: Binding<String> = .constant(""),
camera: CameraOptions? = nil) {
camera: CameraOptions? = nil,
onError: ((PrototypeKitError) -> Void)? = nil) {
self._latestPrediction = latestPrediction
self.cameraOptions = camera
self.onError = onError
do {
let mlModel = try MLModel(contentsOf: modelURL)
let vnModel = try VNCoreMLModel(for: mlModel)
self.receiver = ImageClassifierReceiver(vnMLModel: vnModel)
self.loadError = nil
} catch {
// Degrade gracefully: show the camera feed without classification rather than
// crashing the host app when the model can't be loaded.
PKLog.model.error("Failed to load image classification model at \(modelURL.path): \(error.localizedDescription)")
self.receiver = ImageClassifierReceiver(vnMLModel: nil)
self.loadError = .modelLoadFailed(url: modelURL, underlying: error)
}
}

public var body: some View {
PKCameraView(receiver: receiver, options: cameraOptions)
.onReceive(receiver.$latestPrediction, perform: { newPrediction in
guard let newPrediction = newPrediction else { return }
self.latestPrediction = newPrediction
})
.onAppear {
if let loadError = loadError { onError?(loadError) }
}
}
}
40 changes: 40 additions & 0 deletions Sources/PrototypeKit/Public/PrototypeKitError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// PrototypeKitError.swift
//
//
// A public error type that PrototypeKit surfaces to consuming apps.
//

import Foundation

/// An error PrototypeKit reports to your app through an `onError` handler.
///
/// PrototypeKit degrades gracefully — a failure never crashes the host app — but sometimes an app
/// wants to *react* to a failure (show a message, offer a retry) rather than silently show a camera
/// feed with no predictions. The camera and sound views/modifiers that load a model or start an audio
/// session accept an optional `onError` closure that receives one of these values.
///
/// Errors are always also written to the unified log (subsystem `com.prototypekit.PrototypeKit`).
public enum PrototypeKitError: Error {

/// A Core ML / Create ML model could not be loaded from the given URL.
///
/// The affected view still shows the camera feed but produces no predictions.
case modelLoadFailed(url: URL, underlying: Error)

/// Live sound classification could not start or was interrupted (for example, microphone
/// access was denied or the audio session was interrupted).
case soundClassificationFailed(underlying: Error)
}

extension PrototypeKitError: LocalizedError {

public var errorDescription: String? {
switch self {
case .modelLoadFailed(let url, let underlying):
return "PrototypeKit couldn't load the model \"\(url.lastPathComponent)\": \(underlying.localizedDescription)"
case .soundClassificationFailed(let underlying):
return "PrototypeKit sound classification failed: \(underlying.localizedDescription)"
}
}
}
35 changes: 32 additions & 3 deletions Sources/PrototypeKit/Public/RecognizeSoundsModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,16 @@ extension View {
/// when nothing has been classified yet.
/// - configuration: A ``SoundAnalysisConfiguration`` controlling the analysis window and optional
/// custom Core ML model. Defaults to the built-in system sound classifier.
/// - onError: An optional closure called (on the main thread) if classification can't start or is
/// interrupted — for example, denied microphone access or an audio-session interruption.
/// - Returns: A view that performs live sound recognition while visible.
/// - Important: Available on iOS 15.0+ only; sound recognition is not supported on macOS.
public func recognizeSounds(recognizedSound: Binding<String?>, configuration: SoundAnalysisConfiguration = .init()) -> some View {
public func recognizeSounds(recognizedSound: Binding<String?>,
configuration: SoundAnalysisConfiguration = .init(),
onError: ((PrototypeKitError) -> Void)? = nil) -> some View {
ModifiedContent(content: self, modifier: RecognizeSoundsModifier(recognizedSound: recognizedSound,
configuration: configuration))
configuration: configuration,
onError: onError))
}
}

Expand All @@ -80,10 +85,18 @@ struct RecognizeSoundsModifier: ViewModifier {

@Binding var recognizedSound: String?

init(recognizedSound: Binding<String?>, configuration: SoundAnalysisConfiguration = .init()) {
private let onError: ((PrototypeKitError) -> Void)?

private let setupError: PrototypeKitError?

init(recognizedSound: Binding<String?>,
configuration: SoundAnalysisConfiguration = .init(),
onError: ((PrototypeKitError) -> Void)? = nil) {
self._recognizedSound = recognizedSound
self.onError = onError
SystemAudioClassifier.singleton.stopSoundClassification()

var setupError: PrototypeKitError?
if let customModel = configuration.mlModel {
// Use custom Core ML model for sound classification
do {
Expand All @@ -96,20 +109,28 @@ struct RecognizeSoundsModifier: ViewModifier {
// Degrade gracefully: no classification starts rather than crashing the host app
// when the custom model can't back a sound request.
PKLog.audio.error("Failed to create sound request from custom model: \(error.localizedDescription)")
setupError = .soundClassificationFailed(underlying: error)
}
} else {
// Use system sound classifier
SystemAudioClassifier.singleton.startSoundClassification(
inferenceWindowSize: configuration.inferenceWindowSize,
overlapFactor: configuration.overlapFactor)
}
self.setupError = setupError
}

func body(content: Content) -> some View {
content
.onReceive(classificationPublisher, perform: { result in
self.recognizedSound = result.classifications.first?.identifier
})
.onReceive(errorPublisher, perform: { error in
onError?(.soundClassificationFailed(underlying: error))
})
.onAppear {
if let setupError = setupError { onError?(setupError) }
}
}

/// A main-thread publisher of classification results.
Expand All @@ -123,5 +144,13 @@ struct RecognizeSoundsModifier: ViewModifier {
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

/// A main-thread publisher of classification session errors, sourced from the classifier's
/// long-lived `errors` relay (which never completes).
private var errorPublisher: AnyPublisher<Error, Never> {
SystemAudioClassifier.singleton.errors
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
#endif
23 changes: 23 additions & 0 deletions Tests/PrototypeKitTests/ErrorHandling/GracefulFailureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ final class GracefulFailureTests: XCTestCase {
XCTAssertNoThrow(view.body)
}

// MARK: - The optional onError hook is accepted and does not crash construction

func testImageClassifierViewAcceptsOnError() throws {
let view = ImageClassifierView(modelURL: bogusModelURL, onError: { _ in })
XCTAssertNoThrow(view.body)
}

func testHandPoseClassifierViewAcceptsOnError() throws {
let view = HandPoseClassifierView(modelURL: bogusModelURL, onError: { _ in })
XCTAssertNoThrow(view.body)
}

// MARK: - PrototypeKitError provides human-readable descriptions

func testPrototypeKitErrorDescriptions() {
let modelError = PrototypeKitError.modelLoadFailed(url: bogusModelURL, underlying: CocoaError(.fileNoSuchFile))
XCTAssertNotNil(modelError.errorDescription)
XCTAssertTrue(modelError.errorDescription?.contains(bogusModelURL.lastPathComponent) ?? false)

let soundError = PrototypeKitError.soundClassificationFailed(underlying: CocoaError(.fileReadUnknown))
XCTAssertNotNil(soundError.errorDescription)
}

// MARK: - Receivers with no model must ignore frames rather than crash

func testImageClassifierReceiverWithNilModelIgnoresFrames() throws {
Expand Down
Loading