DocsSmartSpectra
Browse documentation
iOS / Swift

Headless Mode on Swift

View MarkdownView source

The SDK doesn’t ship UI. SmartSpectraSDK.shared is observable — read its properties (metrics, validationStatus, error, imageOutput, processingStatus) directly from SwiftUI views and SwiftUI auto-tracks reads. Outside SwiftUI, use withObservationTracking and re-arm the observation after each change. The sample apps include a measurement UI; your own integration looks however you want.

Use this when you want to:

  • Monitor vitals in the background while the app shows other content
  • Build a custom measurement UI

Use Your Own Camera or Video Source

Use this for a camera shared with another feature, or for frames decoded by your app. The demo app’s Video Testing sample shows public frame submission from AVAssetReader, including clip orientation, presentation timestamps, rejected frames, cancellation, and source cleanup.

Select custom input while the SDK is stopped. Await start() before sending frames; you do not need to wait for processingStatus == .running.

import AVFoundation
import SmartSpectra

final class CameraFrames: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
    private let input: CustomInput

    init(input: CustomInput) {
        self.input = input
    }

    func captureOutput(
        _ output: AVCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        switch input.sendFrame(sampleBuffer) {
        case .accepted:
            break
        case .rejected(let error):
            // Handle invalid state, unsupported pixels, or timestamp errors.
            print("Frame rejected: \(error.code): \(error.message)")
        }
    }
}

@MainActor
func connectCameraOutput(_ output: AVCaptureVideoDataOutput) async throws -> CameraFrames {
    let sdk = SmartSpectraSDK.shared
    sdk.config.apiKey = "YOUR_API_KEY"
    let input = try sdk.useCustomInput(frameTransform: .none)
    try await sdk.start()

    let receiver = CameraFrames(input: input)
    output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]
    output.alwaysDiscardsLateVideoFrames = true
    output.setSampleBufferDelegate(receiver, queue: DispatchQueue(label: "camera.frames"))
    return receiver // Retain this receiver for as long as capture runs.
}

Configure and attach the output to your app’s capture session before calling connectCameraOutput. Start capture afterward, on your capture session’s worker queue. To finish, stop capture and detach the delegate, drain the delegate queue, then await sdk.stop() before releasing the receiver. Call try sdk.useCamera(.default) while stopped if the next measurement should use SDK capture. Serialize these lifecycle operations and handle thrown errors; frame-error callbacks run on the delegate queue, so dispatch UI updates to the main actor.

The host app owns its capture session, camera permissions, preview, and camera settings. Configure the capture connection to deliver upright pixels, or select a FrameTransform when calling useCustomInput(). Avoid applying the same rotation twice. Custom input itself requires no SDK camera permission or capture session; normal SDK authentication is still required.

For a decoded video or another source, submit a pixel buffer with its timestamp:

let result = input.sendFrame(pixelBuffer, timestampUs: presentationTimestampUs)
InputSupported layout
CVPixelBufferkCVPixelFormatType_32BGRA, including padded rows
CVPixelBuffer8-bit bi-planar NV12, full or video range, even dimensions
CMSampleBufferA ready, uncompressed video sample containing one of these pixel formats

The sample-buffer overload uses the presentation timestamp. Explicit timestamps are nonnegative, strictly increasing microseconds below Int64.max - 2, on one monotonic timeline per run. Do not use wall-clock arrival times. Duplicate or backward timestamps return .nonMonotonicTimestamp; gaps over two seconds return .timestampGap. Stop and start again after an interruption to begin a fresh timeline. Invalid or unsupported pixels return .frameConversionFailed.

Submit from a serial worker queue. Keep pixels unchanged until sendFrame returns; afterward you can release or reuse them. .accepted confirms input consumption, not completed measurement output. Observe SDK metrics separately.

The handle survives stop()/start() and reset(). Reset stops processing and clears measurement output while preserving configuration and the selected input. Calling useCamera(.default) or selecting another custom input invalidates older handles. Source selection is stopped-only and throws .invalidState during startup, measurement, or teardown. Stop the host’s frame delivery before switching sources; the SDK does not stop the host’s camera.

Processing Status

Lifecycle states:

StatusMeaning
IdlePipeline is not running
StartingPipeline is initializing
RunningActively measuring — data is flowing
StoppingTeardown in progress, will return to Idle
ErrorSomething went wrong

Example

Use SmartSpectraSDK.shared directly for headless processing:

import SwiftUI
import SmartSpectra

struct HeadlessExample: View {
    private let sdk = SmartSpectraSDK.shared
    @State private var isMonitoring = false
    @State private var showCameraFeed = false

    init() {
        sdk.config.apiKey = "YOUR_API_KEY"
        // The default camera selection prefers front when available.
    }

    var body: some View {
        VStack {
            if let metrics = sdk.metrics, metrics.hasBreathing,
               let rate = metrics.breathing.rate.last {
                Text("Breathing: \(Int(rate.value.rounded())) bpm")
            }
            if let metrics = sdk.metrics, metrics.hasCardio,
               let pulse = metrics.cardio.pulseRate.last {
                Text("Pulse: \(Int(pulse.value.rounded())) bpm")
            }
            Text("Status: \(sdk.validationStatus?.hint ?? "")")

            if let error = sdk.error {
                Text(error.message)
                    .foregroundStyle(.red)
            }

            Toggle("Camera Preview", isOn: $showCameraFeed)
                .onChange(of: showCameraFeed) {
                    sdk.config.imageOutputEnabled = showCameraFeed
                }

            if showCameraFeed, let image = sdk.imageOutput {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
            }

            Button(isMonitoring ? "Stop" : "Start") {
                isMonitoring.toggle()
                if isMonitoring {
                    Task { try? await sdk.start() }
                } else {
                    Task { try? await sdk.stop() }
                }
            }
            // Disable the start button when the SDK has an unrecoverable
            // input-unavailable error (e.g. camera permission denied).
            // All other error states either recover on `start()` or
            // return a throwable error that you can surface to the user.
            .disabled(sdk.error?.code == .inputUnavailable && !isMonitoring)
        }
    }
}

Reading Metrics

sdk.metrics is the same observable property in headless integrations as elsewhere — there’s no separate “headless” API. See iOS Metrics for the metric request configuration and the field-by-field reading guide.

Search quick starts, measurements, and API references.