diff --git a/Package.swift b/Package.swift
new file mode 100644
index 000000000..20271d4ce
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,37 @@
+// swift-tools-version: 5.9
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "omi-lib",
+ platforms: [
+ .iOS(.v17) // Set minimum version to iOS 17
+ ],
+ products: [
+ .library(
+ name: "omi-lib",
+ targets: ["omi-lib"]
+ ),
+ ],
+ dependencies: [
+ // Add your dependency here
+ .package(url: "https://github.com/nelcea/swift-opus.git", from: "1.0.0"),
+ .package(url: "https://github.com/exPHAT/SwiftWhisper.git", branch: "fast"),
+ .package(url: "https://github.com/AudioKit/AudioKit.git", from: "5.6.4"),
+ ],
+ targets: [
+ .target(
+ name: "omi-lib",
+ dependencies: [
+ .product(name: "Opus", package: "swift-opus"),
+ .product(name: "SwiftWhisper", package: "SwiftWhisper"),
+ .product(name: "AudioKit", package: "AudioKit"),
+ ],
+ path: "sdks/swift", // Correct the path to your source files
+ resources: [
+ .process("Sources/omi-lib/Resources") // Make sure this resource is in the correct directory
+ ]
+ ),
+ ]
+)
diff --git a/README.md b/README.md
index 04c801d92..bd778b556 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ transcriptions of meetings, chats, and voice memos wherever you are.
- [Build the device](https://docs.omi.me/assembly/Build_the_device/)
- [Install firmware](https://docs.omi.me/assembly/Install_firmware/)
- [3rd Party Integrations](https://docs.omi.me/developer/plugins/Introduction/).
-
+- [SDKs](sdks/README.md/)
## Contributions
* Check out our [contributions guide](https://docs.omi.me/developer/Contribution/).
diff --git a/sdks/README.md b/sdks/README.md
new file mode 100644
index 000000000..b08408b31
--- /dev/null
+++ b/sdks/README.md
@@ -0,0 +1,6 @@
+## Omi SDKs
+
+The Omi SDKs make it easy to build on top of omi in different languages
+
+## Languages Supported:
+– [Swift SDK](swift/README.md)
\ No newline at end of file
diff --git a/sdks/swift/Package.resolved b/sdks/swift/Package.resolved
new file mode 100644
index 000000000..d911ad5a6
--- /dev/null
+++ b/sdks/swift/Package.resolved
@@ -0,0 +1,32 @@
+{
+ "pins" : [
+ {
+ "identity" : "audiokit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/AudioKit/AudioKit.git",
+ "state" : {
+ "revision" : "2ebd422855e4645d3169f83d1765d1c8196b4f46",
+ "version" : "5.6.4"
+ }
+ },
+ {
+ "identity" : "swift-opus",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/nelcea/swift-opus.git",
+ "state" : {
+ "revision" : "a22f542387f1dd72c6c045ecc3b21dfed71acce2",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swiftwhisper",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/exPHAT/SwiftWhisper.git",
+ "state" : {
+ "branch" : "fast",
+ "revision" : "deb1cb6a27256c7b01f5d3d2e7dc1dcc330b5d01"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/sdks/swift/README.md b/sdks/swift/README.md
new file mode 100644
index 000000000..44402df86
--- /dev/null
+++ b/sdks/swift/README.md
@@ -0,0 +1,88 @@
+### Omi Swift Library
+An easy to install package to get started with the omi dev kit 1 in seconds.
+
+
+## Installation
+1. In Xcode navigate to File → Swift Packages → Add Package Dependency...
+2. Select a project
+3. Paste the repository URL (https://github.com/ashbhat/omi.git) and click Next.
+4. For Rules, select Version (Up to Next Major) and click Next.
+5. Click Finish.
+
+## Requirements
+iOS requires you to include Bluetooth permissions in the info.plist. This can be done by adding the following row
+```xml
+NSBluetoothAlwaysUsageDescription
+This app needs Bluetooth access to connect to BLE devices.
+```
+
+## Usage
+The core interface for interacting with the Omi device is the **OmiManager.swift**. The OmiManager abstracts things like scanning, connecting, and reading bluetooth data into a few simple function calls.
+
+**Looking for a device**
+```swift
+import omi_lib
+
+func lookForDevice() {
+ OmiManager.startScan { device, error in
+ // connect to first found omi device
+ if let device = device {
+ print("got device ", device)
+ self.connectToOmiDevice(device: device)
+ OmiManager.endScan()
+ }
+ }
+}
+
+func lookForSpecificDevice(device_id: String) {
+ OmiManager.startScan { device, error in
+ // connect to first found omi device
+ if let device = device, device.id == "some_device_id" {
+ print("got device ", device)
+ self.connectToOmiDevice(device: device)
+ OmiManager.endScan()
+ }
+ }
+}
+```
+
+**Connecting / Reconnecting to a device**
+```swift
+func connectToOmiDevice(device: Device) {
+ OmiManager.connectToDevice(device: device)
+ self.reconnectIfDisconnects()
+}
+
+func reconnectIfDisconnects() {
+ OmiManager.connectionUpdated { connected in
+ if connected == false {
+ self.lookForDevice()
+ }
+ }
+}
+```
+
+**Getting Live Data**
+```swift
+func listenToLiveTranscript(device: Device) {
+ OmiManager.getLiveTranscription(device: device) { transcription in
+ print("transcription:", transcription ?? "no transcription")
+ }
+}
+
+func listenToLiveAudio(device: Device) {
+ OmiManager.getLiveAudio(device: device) { file_url in
+ print("file_url: ", file_url?.absoluteString ?? "no url")
+ }
+}
+```
+
+## Licensing
+
+Omi's Swift SDK is available under MIT License
+
+### Third-Party Code
+
+An excerpt of code from the PAL project, licensed under the MIT License, is used in this project. The original code can be found at: [nelcea/PAL](https://github.com/nelcea/PAL).
+
+- Copyright (c) 2024 Nelcea
\ No newline at end of file
diff --git a/sdks/swift/Sources/omi-lib/FriendManager.swift b/sdks/swift/Sources/omi-lib/FriendManager.swift
new file mode 100644
index 000000000..37dabfe7b
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/FriendManager.swift
@@ -0,0 +1,361 @@
+//
+// FriendManager.swift
+// scribehardware
+//
+// Created by Ash Bhat on 9/28/24.
+//
+
+import UIKit
+import CoreBluetooth
+import Speech
+import AVFoundation
+import SwiftWhisper
+import AudioKit
+
+class FriendManager {
+
+ static var singleton = FriendManager()
+
+ var bluetoothScanner: BluetoothScanner!
+ var friendDevice: Friend? // Retain Friend object
+ var bleManager: BLEManager? // Retain BLEManager
+ var audioPlayer: AVAudioPlayer?
+
+ var deviceCompletion: ((Friend?, Error?) -> Void)?
+ var transcriptCompletion: ((String?) -> Void)?
+
+ var connectionCompletion: ((Bool) -> Void)?
+ let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
+
+ let whisper: Whisper?
+
+ var transcriptTimer: Timer?
+ var audioFileTimer: Timer?
+
+ init() {
+ let modelURL = Bundle.module.url(forResource: "ggml-tiny.en", withExtension: "bin")!
+ whisper = Whisper(fromFileURL: modelURL)
+ // whisper = nil
+ bluetoothScanner = BluetoothScanner()
+ bluetoothScanner.delegate = self
+ }
+
+ @objc func transcribeAudio(url: URL, completion: @escaping (String?, Error?) ->Void) {
+ self.extractTextFromAudio(url) { result, error in
+ if let result = result {
+ completion(result, error)
+ }
+ else {
+ print("error")
+ completion(result, error)
+ }
+ }
+ }
+
+ func extractTextFromAudio(_ audioURL: URL, completionHandler: @escaping (String?, Error?) ->Void) {
+
+ let originalStderr = dup(fileno(stderr))
+ let nullDevice = open("/dev/null", O_WRONLY)
+ dup2(nullDevice, fileno(stderr))
+ close(nullDevice)
+
+ convertAudioFileToPCMArray(fileURL: audioURL) { result in
+ guard let whisper = self.whisper else {
+ completionHandler(nil, nil)
+ return
+ }
+ switch result {
+ case .success(let success):
+ Task {
+ do {
+ let segments = try await whisper.transcribe(audioFrames: success)
+ completionHandler(segments.map(\.text).joined(), nil)
+ } catch {
+ completionHandler(nil, error)
+ }
+ }
+ case .failure(_):
+ completionHandler(nil, nil)
+ }
+
+ // Restore stdout after function execution
+ // Restore the original stderr
+ fflush(stderr)
+ dup2(originalStderr, fileno(stderr))
+ close(originalStderr)
+ }
+ }
+
+ func getLiveTranscription(device: Friend, completion: @escaping (String?) -> Void) {
+ transcriptCompletion = completion
+ transcriptTimer?.invalidate()
+ transcriptTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true, block: { timer in
+ if let recording = device.recording {
+ let recordingFileURL = recording.fileURL
+ device.resetRecording()
+ if self.fileHasData(url: recordingFileURL) {
+ print("file has data")
+ }
+ else {
+ print("no data in file")
+ }
+
+ self.transcribeAudio(url: recordingFileURL, completion: { result, error in
+ completion(result)
+ })
+ }
+ else {
+ completion(nil)
+ }
+ })
+ }
+
+ func getRawAudio(device: Friend, completion: @escaping (URL?) -> Void) {
+// transcriptCompletion = completion
+ audioFileTimer?.invalidate()
+ audioFileTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: true, block: { timer in
+ if let recording = device.recording {
+ let recordingFileURL = recording.fileURL
+ device.resetRecording()
+ completion(recordingFileURL)
+ }
+ else {
+ completion(nil)
+ }
+ })
+ }
+
+ func getCurrentTranscription(completion: @escaping (String?) -> Void) {
+ if let friendDevice = self.friendDevice, let recording = friendDevice.recording {
+ let recordingFileURL = recording.fileURL
+ self.friendDevice?.resetRecording()
+ if self.fileHasData(url: recordingFileURL) {
+ print("file has data")
+ }
+ else {
+ print("no data in file")
+ }
+
+ self.transcribeAudio(url: recordingFileURL, completion: { result, error in
+ completion(result)
+ })
+ }
+ else {
+ completion(nil)
+ }
+ }
+
+ func fileHasData(url: URL) -> Bool {
+ do {
+ let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path)
+ if let fileSize = fileAttributes[FileAttributeKey.size] as? UInt64 {
+ return fileSize > 0
+ }
+ } catch {
+ print("Error checking file size: \(error.localizedDescription)")
+ }
+ return false
+ }
+
+ func replayAudio(from url: URL) {
+ do {
+ // Initialize the audio player with the file URL
+ audioPlayer = try AVAudioPlayer(contentsOf: url)
+ audioPlayer?.prepareToPlay()
+ audioPlayer?.play()
+ } catch let error {
+ print("Failed to play audio: \(error.localizedDescription)")
+ }
+ }
+
+ func connectionStatus(completion: @escaping(Bool) -> Void) {
+ self.connectionCompletion = completion
+ }
+
+ func startScan() {
+ self.bluetoothScanner.centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
+ }
+
+ func startRecordingWhenReady() {
+ switch self.friendDevice?.status {
+ case .ready:
+ let uuidString = UUID().uuidString
+ let recording = Recording(filename: "\(uuidString).wav") // Your custom recording handler
+ self.friendDevice!.start(recording: recording)
+ case .error(_):
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: {
+ self.startRecordingWhenReady()
+ })
+ case .none:
+ print("should not reach here")
+ }
+ }
+
+ func startRecordingWhenReady(device: Friend) {
+ switch device.status {
+ case .ready:
+ let uuidString = UUID().uuidString
+ let recording = Recording(filename: "\(uuidString).wav") // Your custom recording handler
+ device.start(recording: recording)
+ case .error(_):
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: {
+ self.startRecordingWhenReady(device: device)
+ })
+ }
+ }
+
+
+ func startRealTimeTranscription(from url: URL) {
+ guard let recognizer = recognizer else {
+ print("Speech recognizer is not available")
+ return
+ }
+
+ let request = SFSpeechURLRecognitionRequest(url: url)
+
+ request.requiresOnDeviceRecognition = false // Change this to true if you want on-device recognition
+ request.taskHint = .dictation // Hints that this is conversational speech
+
+ recognizer.recognitionTask(with: request) { (result, error) in
+ if let error = error {
+ print("Error transcribing audio: \(error.localizedDescription)")
+ // Handle error
+ } else if let result = result {
+ // Print the transcribed text in real time
+ print("Real-time Transcription: \(result.bestTranscription.formattedString)")
+ }
+ }
+
+ if friendDevice?.isRecording == true, let fileURL = friendDevice?.recording?.fileURL {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: {
+ self.startRealTimeTranscription(from: fileURL)
+ })
+ }
+
+ }
+
+ func transcribeAudioFile(url: URL, completion: @escaping (String?) -> Void) {
+ // Create a recognizer for the user's current locale
+
+ let request = SFSpeechURLRecognitionRequest(url: url)
+
+ request.requiresOnDeviceRecognition = false // Change this to true if you want on-device recognition
+ request.taskHint = .dictation // Hints that this is conversational speech
+
+ // Check if the recognizer is available
+ guard recognizer?.isAvailable == true else {
+ completion(nil)
+ return
+ }
+
+ // Perform the recognition
+ recognizer?.recognitionTask(with: request) { (result, error) in
+ if let error = error {
+ print("Error transcribing audio: \(error.localizedDescription)")
+ completion(nil)
+ } else if let result = result, result.isFinal {
+ // Return the transcribed text
+ completion(result.bestTranscription.formattedString)
+ }
+ }
+ }
+}
+
+extension FriendManager: BluetoothScannerDelegate {
+ func deviceFound(device: CBPeripheral) {
+ if device.name == "Friend" || device.name == "Friend DevKit 2" {
+ print("found friend device")
+ WearableDeviceRegistry.shared.registerDevice(wearable: Friend.self)
+ self.bleManager = BLEManager(deviceRegistry: WearableDeviceRegistry.shared)
+ self.bleManager?.delegate = self
+ let friend_device = Friend(bleManager: bleManager!, name: "Friend")
+ friend_device.id = device.identifier
+ self.deviceCompletion?(friend_device, nil)
+ }
+ }
+
+ func connectToDevice(device: Friend) {
+ let deviceUUID = device.id
+ bleManager!.reconnect(to: deviceUUID)
+ self.connectionCompletion?(true)
+ self.startRecordingWhenReady(device: device)
+ }
+}
+
+extension FriendManager: BLEManagerDelegate {
+ func lostConnection() {
+ connectionCompletion?(false)
+ }
+}
+
+extension FriendManager {
+ func convertAudioFileToPCMArray(fileURL: URL, completionHandler: @escaping (Result<[Float], Error>) -> Void) {
+ var options = FormatConverter.Options()
+ options.format = .wav
+ options.sampleRate = 16000
+ options.bitDepth = 16
+ options.channels = 1
+ options.isInterleaved = false
+
+ let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
+ let converter = FormatConverter(inputURL: fileURL, outputURL: tempURL, options: options)
+ converter.start { error in
+ if let error {
+ completionHandler(.failure(error))
+ return
+ }
+
+ let data = try! Data(contentsOf: tempURL) // Handle error here
+
+ let floats = stride(from: 44, to: data.count, by: 2).map {
+ return data[$0..<$0 + 2].withUnsafeBytes {
+ let short = Int16(littleEndian: $0.load(as: Int16.self))
+ return max(-1.0, min(Float(short) / 32767.0, 1.0))
+ }
+ }
+
+ try? FileManager.default.removeItem(at: tempURL)
+
+ completionHandler(.success(floats))
+ }
+ }
+
+}
+
+protocol BluetoothScannerDelegate: AnyObject {
+ func deviceFound(device: CBPeripheral)
+}
+
+class BluetoothScanner: NSObject, CBCentralManagerDelegate {
+ weak var delegate: BluetoothScannerDelegate?
+ var centralManager: CBCentralManager!
+
+ override init() {
+ super.init()
+ // Initialize CBCentralManager with self as the delegate
+ centralManager = CBCentralManager(delegate: self, queue: nil)
+ }
+
+ // This is called when the central manager's state is updated (e.g., Bluetooth is turned on/off)
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ switch central.state {
+ case .poweredOn:
+ // Bluetooth is powered on and available, you can start scanning
+ print("ready to start scan")
+ centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
+ case .poweredOff:
+ print("Bluetooth is off.")
+ case .resetting, .unauthorized, .unknown, .unsupported:
+ print("Bluetooth not available.")
+ @unknown default:
+ print("Unknown state.")
+ }
+ }
+
+ // This is called when a new peripheral (device) is discovered during scanning
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
+ if let name = peripheral.name, name == "Friend" || name == "Friend DevKit 2" {
+ self.delegate?.deviceFound(device: peripheral)
+ }
+ }
+}
diff --git a/sdks/swift/Sources/omi-lib/Resources/ggml-tiny.en.bin b/sdks/swift/Sources/omi-lib/Resources/ggml-tiny.en.bin
new file mode 100644
index 000000000..9dd1a6b40
Binary files /dev/null and b/sdks/swift/Sources/omi-lib/Resources/ggml-tiny.en.bin differ
diff --git a/sdks/swift/Sources/omi-lib/helpers/BLEAssignedNumber.swift b/sdks/swift/Sources/omi-lib/helpers/BLEAssignedNumber.swift
new file mode 100644
index 000000000..76ef8e9de
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/BLEAssignedNumber.swift
@@ -0,0 +1,15 @@
+//
+// BLEAssignedNumber.swift
+// PALApp
+//
+// Created by Eric Bariaux on 23/05/2024.
+//
+
+import Foundation
+import CoreBluetooth
+
+enum BatteryService {
+ public static let serviceUUID = CBUUID(string: "0x180F")
+ public static let batteryLevelCharacteristicUUID = CBUUID(string: "0x2A19")
+ public static let batteryLevelStatusCharacteristicUUID = CBUUID(string: "0x2BED")
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/BLEManager.swift b/sdks/swift/Sources/omi-lib/helpers/BLEManager.swift
new file mode 100644
index 000000000..6d9c571b4
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/BLEManager.swift
@@ -0,0 +1,175 @@
+//
+// BLEManager.swift
+// PALApp
+//
+// Created by Eric Bariaux on 27/04/2024.
+//
+
+import Foundation
+import CoreBluetooth
+import Combine
+import os
+
+enum BLEStatus {
+ case off
+ case on
+ case scanning
+ case connecting
+ case connected
+ case linked
+ case disconnected
+}
+
+protocol BLEManagerDelegate: AnyObject {
+ func lostConnection()
+}
+
+class BLEManager : NSObject, ObservableObject {
+
+ weak var delegate: BLEManagerDelegate?
+ let log = Logger(subsystem: "be.nelcea.PALApp", category: "BLE")
+
+ init(deviceRegistry: WearableDeviceRegistry) {
+ self.deviceRegistry = deviceRegistry
+ }
+
+ @Published var status: BLEStatus = .off
+
+ var servicesRegistry: [CBUUID: CBService] = [:]
+ var characteristicsRegistry: [CBUUID: CBCharacteristic] = [:]
+
+ let valueChanges = PassthroughSubject<(CBUUID, Data), Error>()
+
+ var connectedDevice: WearableDevice?
+
+ private var deviceRegistry: WearableDeviceRegistry
+
+ private var manager: CBCentralManager?
+ private var peripheral: CBPeripheral?
+
+ private var uuidToConnect: UUID?
+ private var peripheralToConnect: CBPeripheral?
+
+ func reconnect(to: UUID) {
+ if manager == nil {
+ manager = CBCentralManager(delegate: self, queue: nil)
+ }
+ uuidToConnect = to
+ if let manager, manager.state == .poweredOn {
+ forceConnect()
+ }
+ }
+
+ func stopConnecting() {
+ if let manager, let peripheralToConnect {
+ manager.cancelPeripheralConnection(peripheralToConnect)
+ }
+ }
+
+ func disconnect() {
+ if let manager, let peripheral {
+ manager.cancelPeripheralConnection(peripheral)
+ }
+ }
+
+ func setNotify(enabled: Bool, forCharacteristics characteristicId: CBUUID) {
+ if let peripheral, let characteritic = characteristicsRegistry[characteristicId] {
+ peripheral.setNotifyValue(enabled, for: characteritic)
+ }
+ }
+
+ /// Force connection to a peripheral irrelevant of the power state of the manager, will result in error if not powered on
+ private func forceConnect() {
+ if let manager, let uuid = uuidToConnect {
+ if let p = manager.retrievePeripherals(withIdentifiers: [uuid]).first {
+ peripheralToConnect = p
+ status = .connecting
+ manager.connect(p, options: [CBConnectPeripheralOptionNotifyOnDisconnectionKey: true])
+ // connect do not timeout, need to explicitly cancel it
+ }
+ uuidToConnect = nil
+ }
+ }
+}
+
+extension BLEManager : CBCentralManagerDelegate {
+
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ status = (central.state == .poweredOn ? .on : .off)
+
+ if central.state == .poweredOn && uuidToConnect != nil {
+ forceConnect()
+ }
+ }
+
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+ status = .connected
+ self.peripheral = peripheral
+ log.info("Did connect to peripheral with identifier: \(peripheral.identifier)")
+ peripheral.delegate = self
+ peripheral.discoverServices(nil)
+ }
+
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, timestamp: CFAbsoluteTime, isReconnecting: Bool, error: (any Error)?) {
+ log.info("Did disconnect \(peripheral), isReconnecting \(isReconnecting)")
+ if let error {
+ log.info("\(error.localizedDescription)")
+ }
+ connectedDevice = nil
+ characteristicsRegistry.removeAll()
+ servicesRegistry.removeAll()
+ self.peripheral = nil
+ status = .disconnected
+
+ self.delegate?.lostConnection()
+ /*
+ Did disconnect
+ The connection has timed out unexpectedly.
+ */
+ }
+
+}
+
+extension BLEManager : CBPeripheralDelegate {
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
+ if let services = peripheral.services {
+ for service in services {
+ if let wearable = deviceRegistry.deviceTypeForService(uuid: service.uuid) {
+ connectedDevice = wearable.init(bleManager: self, name: peripheral.name ?? peripheral.identifier.uuidString)
+ status = .linked
+ }
+ }
+
+ for service in services {
+ log.debug("Discovered service \(service)")
+ servicesRegistry[service.uuid] = service
+ peripheral.discoverCharacteristics(nil, for: service)
+ }
+ }
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: (any Error)?) {
+ if let characteristics = service.characteristics {
+ for c in characteristics {
+ characteristicsRegistry[c.uuid] = c
+ log.debug("Discovered characteristic \(c)")
+ if let connectedDevice {
+ if type(of: connectedDevice).deviceConfiguration.notifyCharacteristicsUUIDs.contains(c.uuid) {
+ log.debug("Asking for notifications")
+ peripheral.setNotifyValue(true, for: c)
+ peripheral.readValue(for: c)
+ } else {
+ peripheral.readValue(for: c)
+ }
+ }
+ }
+ }
+ }
+
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) {
+ if let v = characteristic.value {
+ valueChanges.send((characteristic.uuid, v))
+ }
+ }
+
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/BLEScanner.swift b/sdks/swift/Sources/omi-lib/helpers/BLEScanner.swift
new file mode 100644
index 000000000..8bef87d3f
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/BLEScanner.swift
@@ -0,0 +1,101 @@
+//
+// BLEScanner.swift
+// PALApp
+//
+// Created by Eric Bariaux on 04/07/2024.
+//
+
+import CoreBluetooth
+import Foundation
+
+
+struct DiscoveredDevice: Identifiable, Hashable {
+ var name: String
+ var deviceIdentifier: UUID
+ var deviceType: UserDeviceType
+
+ var id: UUID {
+ deviceIdentifier
+ }
+}
+
+@Observable
+class BLEScanner : NSObject {
+ init(deviceRegistry: WearableDeviceRegistry) {
+ self.deviceRegistry = deviceRegistry
+ super.init()
+ manager = CBCentralManager(delegate: self, queue: nil)
+ }
+
+ var status: BLEStatus = .off
+
+ private var deviceRegistry: WearableDeviceRegistry
+
+ private var manager: CBCentralManager!
+
+ private var peripheral: CBPeripheral?
+
+ var discoveredPeripheralsMap: [UUID: CBPeripheral] = [:]
+
+ var discoveredDevicesMap: [UUID: DiscoveredDevice] = [:]
+ var discoveredDevices: [DiscoveredDevice] {
+ discoveredDevicesMap.values.sorted { $0.name < $1.name }
+ }
+
+ func stopScanning() {
+ manager.stopScan()
+ status = .off
+ }
+}
+
+extension BLEScanner : CBCentralManagerDelegate {
+
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ if central.state == .poweredOn {
+ manager.scanForPeripherals(withServices: deviceRegistry.scanServices)
+ status = .scanning
+ } else {
+ status = .off
+ }
+ }
+
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
+
+ print("Discovered \(peripheral.identifier) - \(String(describing: peripheral.name))")
+ print("Adv data: \(advertisementData)")
+ let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
+ /*
+ TODO: review this, seems use CBAdvertisementDataLocalNameKey will not work / is not required
+ But it can be that we receive the didDiscover message twice, once without the name, then afterwards with the name -> we should update our list if that's the case
+
+ Discovered 5A7F01CA-CD35-37C4-38DA-0B6464CD94FA - nil
+ Adv data: ["kCBAdvDataServiceUUIDs": <__NSArrayM 0x301c2c390>(
+ Device Information,
+ 19B10000-E8F2-537E-4F6C-D104768A1214
+ )
+ , "kCBAdvDataRxSecondaryPHY": 0, "kCBAdvDataRxPrimaryPHY": 129, "kCBAdvDataIsConnectable": 1, "kCBAdvDataTimestamp": 742038261.889917]
+ Discovered 5A7F01CA-CD35-37C4-38DA-0B6464CD94FA - Optional("Friend")
+ Adv data: ["kCBAdvDataIsConnectable": 1, "kCBAdvDataLocalName": Friend, "kCBAdvDataRxPrimaryPHY": 129, "kCBAdvDataTimestamp": 742038261.891065, "kCBAdvDataServiceUUIDs": <__NSArrayM 0x301c2a580>(
+ Device Information,
+ 19B10000-E8F2-537E-4F6C-D104768A1214
+ )
+ , "kCBAdvDataRxSecondaryPHY": 0]
+ */
+
+ if let servicesId = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [Any] {
+ for serviceId in servicesId {
+ if let serviceId = serviceId as? CBUUID {
+ if let d = deviceRegistry.deviceTypeForService(uuid: serviceId) {
+ if discoveredPeripheralsMap[peripheral.identifier] == nil {
+ discoveredPeripheralsMap[peripheral.identifier] = peripheral
+ discoveredDevicesMap[peripheral.identifier] = DiscoveredDevice(name: peripheral.name ?? advertisedName ?? peripheral.identifier.uuidString,
+ deviceIdentifier: peripheral.identifier,
+ deviceType: .wearable(d.deviceConfiguration.reference))
+ }
+ }
+ }
+ }
+ }
+ }
+
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/Codecs.swift b/sdks/swift/Sources/omi-lib/helpers/Codecs.swift
new file mode 100644
index 000000000..b0126ebaf
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/Codecs.swift
@@ -0,0 +1,129 @@
+//
+// Codecs.swift
+// PALApp
+//
+// Created by Eric Bariaux on 24/06/2024.
+//
+
+import AVFoundation
+@_implementationOnly import Opus
+
+// TODO: Can we have some automated tests ?
+
+enum CodecError: Error {
+ case invalidAudioFormat
+ case audioBufferCreationError
+}
+
+protocol Codec {
+ var sampleRate: Double { get }
+
+ init(sampleRate: Double) throws
+
+ func pcmBuffer(decodedData: Data) throws -> AVAudioPCMBuffer
+
+ func decode(data: Data) throws -> Data
+}
+
+extension Codec {
+ func pcmBuffer(decodedData: Data) throws -> AVAudioPCMBuffer {
+ guard let audioFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: sampleRate, channels: 1, interleaved: false) else {
+ throw CodecError.invalidAudioFormat
+ }
+
+ guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: audioFormat, frameCapacity: UInt32(decodedData.count / MemoryLayout.size)) else {
+ throw CodecError.audioBufferCreationError
+ }
+ pcmBuffer.frameLength = pcmBuffer.frameCapacity
+
+ let channels = UnsafeBufferPointer(start: pcmBuffer.int16ChannelData, count: Int(pcmBuffer.format.channelCount))
+ UnsafeMutableRawPointer(channels[0]).withMemoryRebound(to: UInt8.self, capacity: decodedData.count) {
+ (bytes: UnsafeMutablePointer) in
+ decodedData.copyBytes(to: bytes, count: decodedData.count)
+ }
+ return pcmBuffer
+ }
+
+ func decode(data: Data) -> Data {
+ return data
+ }
+}
+
+struct PcmCodec: Codec {
+ let sampleRate: Double
+
+ init(sampleRate: Double) {
+ self.sampleRate = sampleRate
+ }
+}
+
+struct µLawCodec: Codec {
+ // From https://web.archive.org/web/20110719132013/http://hazelware.luggle.com/tutorials/mulawcompression.html
+ static let muLawToLinearTable: [Int16] = [
+ -32124, -31100, -30076, -29052, -28028, -27004, -25980, -24956,
+ -23932, -22908, -21884, -20860, -19836, -18812, -17788, -16764,
+ -15996, -15484, -14972, -14460, -13948, -13436, -12924, -12412,
+ -11900, -11388, -10876, -10364, -9852, -9340, -8828, -8316,
+ -7932, -7676, -7420, -7164, -6908, -6652, -6396, -6140,
+ -5884, -5628, -5372, -5116, -4860, -4604, -4348, -4092,
+ -3900, -3772, -3644, -3516, -3388, -3260, -3132, -3004,
+ -2876, -2748, -2620, -2492, -2364, -2236, -2108, -1980,
+ -1884, -1820, -1756, -1692, -1628, -1564, -1500, -1436,
+ -1372, -1308, -1244, -1180, -1116, -1052, -988, -924,
+ -876, -844, -812, -780, -748, -716, -684, -652,
+ -620, -588, -556, -524, -492, -460, -428, -396,
+ -372, -356, -340, -324, -308, -292, -276, -260,
+ -244, -228, -212, -196, -180, -164, -148, -132,
+ -120, -112, -104, -96, -88, -80, -72, -64,
+ -56, -48, -40, -32, -24, -16, -8, -1,
+ 32124, 31100, 30076, 29052, 28028, 27004, 25980, 24956,
+ 23932, 22908, 21884, 20860, 19836, 18812, 17788, 16764,
+ 15996, 15484, 14972, 14460, 13948, 13436, 12924, 12412,
+ 11900, 11388, 10876, 10364, 9852, 9340, 8828, 8316,
+ 7932, 7676, 7420, 7164, 6908, 6652, 6396, 6140,
+ 5884, 5628, 5372, 5116, 4860, 4604, 4348, 4092,
+ 3900, 3772, 3644, 3516, 3388, 3260, 3132, 3004,
+ 2876, 2748, 2620, 2492, 2364, 2236, 2108, 1980,
+ 1884, 1820, 1756, 1692, 1628, 1564, 1500, 1436,
+ 1372, 1308, 1244, 1180, 1116, 1052, 988, 924,
+ 876, 844, 812, 780, 748, 716, 684, 652,
+ 620, 588, 556, 524, 492, 460, 428, 396,
+ 372, 356, 340, 324, 308, 292, 276, 260,
+ 244, 228, 212, 196, 180, 164, 148, 132,
+ 120, 112, 104, 96, 88, 80, 72, 64,
+ 56, 48, 40, 32, 24, 16, 8, 0]
+
+ let sampleRate: Double
+
+ init(sampleRate: Double) {
+ self.sampleRate = sampleRate
+ }
+
+ func decode(data: Data) -> Data {
+ let i16Array = data.map( { Self.muLawToLinearTable[Int($0)] })
+ return i16Array.withUnsafeBufferPointer( { Data(buffer: $0 )})
+ }
+}
+
+struct OpusCodec: Codec {
+ let sampleRate: Double
+ let opusDecoder: Opus.Decoder
+
+ init(sampleRate: Double) throws {
+ self.sampleRate = sampleRate
+ guard let opusFormat = AVAudioFormat(opusPCMFormat: .int16, sampleRate: .opus16khz, channels: 1) else {
+ throw CodecError.invalidAudioFormat
+ }
+ opusDecoder = try Opus.Decoder(format: opusFormat)
+ }
+
+ func decode(data: Data) throws -> Data {
+ do {
+ return try opusDecoder.decodeToData(data)
+ } catch {
+ print(error.localizedDescription)
+ throw CodecError.audioBufferCreationError
+ }
+ }
+}
+
diff --git a/sdks/swift/Sources/omi-lib/helpers/Friend.swift b/sdks/swift/Sources/omi-lib/helpers/Friend.swift
new file mode 100644
index 000000000..e5a89a7d1
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/Friend.swift
@@ -0,0 +1,169 @@
+//
+// .swift
+// PALApp
+//
+// Created by Eric Bariaux on 27/04/2024.
+//
+
+import Foundation
+import CoreBluetooth
+import Combine
+import AVFoundation
+import os
+
+class Friend : WearableDevice, BatteryInformation, AudioRecordingDevice {
+ let log = Logger(subsystem: "omi.opensource", category: "Friend")
+
+ private static let audioServiceUUID = CBUUID(string: "19B10000-E8F2-537E-4F6C-D104768A1214")
+ private static let audioCharacteristicUUID = CBUUID(string: "19B10001-E8F2-537E-4F6C-D104768A1214")
+ private static let audioCodecCharacteristicUUID = CBUUID(string: "19B10002-E8F2-537E-4F6C-D104768A1214")
+ private static let lightCodecCharacteristicUUID = CBUUID(string: "19B10003-E8F2-537E-4F6C-D104768A1214")
+
+ private var cancellable: Cancellable?
+
+ @Published var batteryLevel: UInt8 = 0
+
+ @Published var isRecording = false
+ var recording: Recording?
+
+ private var codec: FriendCodec? {
+ didSet {
+ status = .ready
+ }
+ }
+
+ var packetCounter = PacketCounter()
+ private var packetsBuffer = [AudioPacket]()
+
+ required init(bleManager: BLEManager, name: String) {
+ super.init(bleManager: bleManager, name: name)
+ cancellable = bleManager.valueChanges.sink(receiveCompletion: { (error) in
+ }, receiveValue: { [weak self] (value) in
+ let (uuid, data) = value
+
+ switch uuid {
+ case BatteryService.batteryLevelCharacteristicUUID:
+ self?.batteryCharacteristicUpdated(data: data)
+ case Friend.audioCharacteristicUUID:
+ self?.audioCharacteristicUpdated(data: data)
+ case Friend.audioCodecCharacteristicUUID:
+ self?.audioCodecCharacteristicUpdated(data: data)
+ case Friend.lightCodecCharacteristicUUID:
+ self?.audioCodecCharacteristicUpdated(data: data)
+ default:
+ self?.log.warning("Received value for unknown characteristic UUID \(uuid)")
+ }
+ })
+ }
+
+ deinit {
+ self.log.debug("Friend deinit")
+ cancellable?.cancel()
+ }
+
+ private func batteryCharacteristicUpdated(data: Data) {
+ batteryLevel = UInt8(littleEndian: data.withUnsafeBytes { $0.load(as: UInt8.self) })
+ log.info("Received battery level \(self.batteryLevel)")
+ }
+
+ private func audioCharacteristicUpdated(data: Data) {
+// log.debug("Received packet of size \(data.count)")
+ guard data.count >= 3 else {
+ log.warning("### Received a packet of size \(data.count)")
+ return
+ }
+
+ // Starts at 0 on first notification, continues the sequence after a pause but I have seen a small gap
+ let packetNumber = UInt16(littleEndian: data.withUnsafeBytes { $0.load(as: UInt16.self) })
+ // Starts at 0
+ let index = UInt8(littleEndian: data.advanced(by: 2).withUnsafeBytes {$0.load(as: UInt8.self) })
+
+// log.debug("Packet number \(packetNumber)")
+// log.debug("Index \(index)")
+
+ do {
+ try packetCounter.checkPacketNumber(packetNumber)
+ } catch {
+ log.warning("### Error, missing packet")
+ }
+
+ if index == 0 {
+ // Only flush if we're starting a new packet, otherwise we would split between packet content
+ flushRecordingBuffer()
+ packetsBuffer.append(AudioPacket(packetNumber: packetNumber))
+ }
+ if let packet = packetsBuffer.last {
+ packet.append(data: data.advanced(by: 3))
+ }
+ }
+
+ private func audioCodecCharacteristicUpdated(data: Data) {
+ let codecType = UInt8(littleEndian: data.withUnsafeBytes { $0.load(as: UInt8.self) })
+ codec = FriendCodec(rawValue: codecType)
+ log.info("Codec type \(codecType)")
+ }
+
+ override class var deviceConfiguration: WearableDeviceConfiguration {
+ return WearableDeviceConfiguration(
+ reference: "Friend",
+ scanServiceUUID: audioServiceUUID,
+ notifyCharacteristicsUUIDs: [BatteryService.batteryLevelCharacteristicUUID])
+ }
+
+ func start(recording: Recording) {
+ self.recording = recording
+
+ guard let audioCodec = try? codec?.codec else { return }
+ if recording.startRecording(usingCodec: audioCodec) {
+ isRecording = true
+ bleManager.setNotify(enabled: true, forCharacteristics: Friend.audioCharacteristicUUID)
+ }
+ else {
+ print("failed to start recording")
+ }
+ }
+
+ func stopRecording() {
+ bleManager.setNotify(enabled: false, forCharacteristics: Friend.audioCharacteristicUUID)
+ isRecording = false
+ flushRecordingBuffer()
+ recording?.closeRecording()
+ packetCounter.reset()
+ }
+
+ func resetRecording() {
+ flushRecordingBuffer()
+ recording?.updateFileURL()
+ }
+
+ func flushRecordingBuffer() {
+ if packetsBuffer.isEmpty {
+ return
+ }
+ recording?.append(packets: packetsBuffer)
+ packetsBuffer.removeAll()
+ }
+
+ enum FriendCodec: UInt8 {
+ case pcm16 = 0, pcm8
+ case µLaw16 = 10, µLaw8
+ case opus16 = 20
+
+ var codec: Codec {
+ get throws {
+ switch self {
+ case .pcm8:
+ return PcmCodec(sampleRate: 8000.0)
+ case .µLaw8:
+ return µLawCodec(sampleRate: 8000.0)
+ case .pcm16:
+ return PcmCodec(sampleRate: 16000.0)
+ case .µLaw16:
+ return µLawCodec(sampleRate: 16000.0)
+ case .opus16:
+ return try OpusCodec(sampleRate: 16000.0)
+ }
+ }
+ }
+ }
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/PacketCounter.swift b/sdks/swift/Sources/omi-lib/helpers/PacketCounter.swift
new file mode 100644
index 000000000..9657ce7b2
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/PacketCounter.swift
@@ -0,0 +1,30 @@
+//
+// PacketCounter.swift
+// scribehardware
+//
+// Created by Ash Bhat on 9/28/24.
+//
+
+import Foundation
+
+struct PacketCounter {
+ private var lastPacketNumber: UInt16?
+
+ mutating func checkPacketNumber(_ packetNumber: UInt16) throws {
+ if let lpn = lastPacketNumber {
+ let packetNumberToCheck = (lpn == UInt16.max) ? 0 : lpn + 1
+ if packetNumber != packetNumberToCheck {
+ throw PacketCounterError.invalidSequenceNumber
+ }
+ }
+ lastPacketNumber = packetNumber
+ }
+
+ mutating func reset() {
+ lastPacketNumber = nil
+ }
+}
+
+enum PacketCounterError: Error {
+ case invalidSequenceNumber
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/Recording.swift b/sdks/swift/Sources/omi-lib/helpers/Recording.swift
new file mode 100644
index 000000000..726aa5878
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/Recording.swift
@@ -0,0 +1,184 @@
+//
+// Recording.swift
+// PALApp
+//
+// Created by Eric Bariaux on 29/04/2024.
+//
+
+import Foundation
+import AVFoundation
+import CoreTransferable
+import SwiftData
+
+
+class Recording: Identifiable {
+
+ var id = UUID()
+ var filename: String
+ var name: String
+ var comment = ""
+ var timestamp: Date
+ var duration_: Double?
+
+ var duration: Duration? {
+ get {
+ if let seconds = duration_ {
+ return Duration.seconds(seconds)
+ } else {
+ return nil
+ }
+ }
+ set {
+ if let d = newValue {
+ duration_ = d.inSeconds
+ } else {
+ duration_ = nil
+ }
+ }
+ }
+
+ @Transient var fileURL: URL {
+ self.getDocumentsDirectory().appendingPathComponent(filename)
+ }
+
+ @Transient private var audioFormat: AVAudioFormat?
+ @Transient private var codec: Codec?
+ @Transient private var recordingFile: AVAudioFile?
+
+ init(filename: String) {
+ self.filename = filename
+ self.name = filename
+ self.timestamp = Self.extractStartDate(filename: filename)
+ }
+
+ private static func extractStartDate(filename: String) -> Date {
+ let timestampString = filename.deletingPrefix("Recording_").deletingSuffix(".wav")
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "yyyyMMdd_HHmmss"
+ return dateFormatter.date(from: timestampString) ?? Date()
+ }
+
+ func readInfo() {
+ if let file = try? AVAudioFile(forReading: fileURL) {
+ duration = Duration.seconds(Double(file.length) / file.fileFormat.sampleRate)
+ }
+ }
+
+ func startRecording(usingCodec codec: Codec) -> Bool {
+ self.codec = codec
+ audioFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: codec.sampleRate, channels: 1, interleaved: false)
+
+ let recordingFormat = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: codec.sampleRate, channels: 1, interleaved: false)
+ guard let recordingFormat else { return false }
+ recordingFile = try? AVAudioFile(forWriting: fileURL, settings: recordingFormat.settings, commonFormat: .pcmFormatInt16, interleaved: false)
+ return recordingFile != nil
+ }
+
+ func append(packets: [AudioPacket]) {
+ if let recordingFile, let codec {
+ do {
+ var decodedDataBlock = Data()
+ for packet in packets {
+ try decodedDataBlock.append(codec.decode(data: packet.packetData))
+ }
+ let pcmBuffer = try codec.pcmBuffer(decodedData: decodedDataBlock)
+ try recordingFile.write(from: pcmBuffer)
+ } catch {
+ print(error.localizedDescription)
+ }
+ }
+ }
+
+ func getDocumentsDirectory() -> URL {
+ let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
+ let documentsDirectory = paths[0]
+ return documentsDirectory
+ }
+
+ func updateFileURL() {
+ // Generate a new filename (e.g., with a new timestamp or unique identifier)
+ let newFilename = "Recording_\(UUID().uuidString).wav"
+
+ // Create a new file URL with the new filename
+ let newFileURL = self.getDocumentsDirectory().appendingPathComponent(newFilename)
+
+ do {
+ // Copy the contents from the current file URL to the new file URL
+ try FileManager.default.copyItem(at: fileURL, to: newFileURL)
+
+ // Update the filename property to the new filename
+ self.filename = newFilename
+
+ // Update the recordingFile with the new file
+ if let audioFormat = audioFormat {
+ recordingFile = try AVAudioFile(forWriting: newFileURL, settings: audioFormat.settings, commonFormat: .pcmFormatInt16, interleaved: false)
+ }
+
+ } catch {
+ print("Failed to update file URL: \(error.localizedDescription)")
+ }
+ }
+
+ func closeRecording() {
+ recordingFile = nil
+ codec = nil
+ }
+}
+
+
+extension Recording: Hashable {
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+extension Recording: Equatable {
+ static func == (lhs: Recording, rhs: Recording) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+extension Recording: Transferable {
+ static var transferRepresentation: some TransferRepresentation {
+ FileRepresentation(contentType: .wav) { recording in
+ SentTransferredFile(recording.fileURL)
+ } importing: { data in
+ // TODO: write data to doc folder
+ Recording(filename: "")
+ }
+ }
+
+}
+
+extension String {
+ func deletingPrefix(_ prefix: String) -> String {
+ guard self.hasPrefix(prefix) else { return self }
+ return String(self.dropFirst(prefix.count))
+ }
+
+ func deletingSuffix(_ suffix: String) -> String {
+ guard self.hasSuffix(suffix) else { return self }
+ return String(self.dropLast(suffix.count))
+ }
+}
+
+extension Duration {
+ var inSeconds: Double {
+ let v = components
+ return Double(v.seconds) + Double(v.attoseconds) * 1e-18
+ }
+}
+
+struct RecordingDTO: Codable {
+ var id: String
+ var filename: String
+ var name: String
+ var comment: String
+ var timestamp: Date
+}
+
+extension Recording {
+ func toDTO() -> RecordingDTO {
+ return RecordingDTO(id: self.id.uuidString, filename: self.filename, name: self.name, comment: self.comment, timestamp: self.timestamp)
+ }
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/UserDevice.swift b/sdks/swift/Sources/omi-lib/helpers/UserDevice.swift
new file mode 100644
index 000000000..ae9bc5c6a
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/UserDevice.swift
@@ -0,0 +1,28 @@
+//
+// UserDevice.swift
+// PALApp
+//
+// Created by Eric Bariaux on 02/07/2024.
+//
+
+import Foundation
+import SwiftData
+
+
+class UserDevice {
+ var name: String
+ var deviceIdentifier: UUID
+ var deviceType: UserDeviceType
+
+ init(name: String, deviceIdentifier: UUID, deviceType: UserDeviceType) {
+ self.name = name
+ self.deviceIdentifier = deviceIdentifier
+ self.deviceType = deviceType
+ }
+}
+
+enum UserDeviceType: Codable, Hashable {
+ case wearable(WearableDevice.DeviceReference)
+ case localMicrophone
+ case appleWatch
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/WearableDevice.swift b/sdks/swift/Sources/omi-lib/helpers/WearableDevice.swift
new file mode 100644
index 000000000..766e225db
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/WearableDevice.swift
@@ -0,0 +1,74 @@
+//
+// WearableDevice.swift
+// PALApp
+//
+// Created by Eric Bariaux on 27/04/2024.
+//
+
+import Foundation
+import CoreBluetooth
+
+class WearableDevice: ObservableObject {
+
+ typealias DeviceReference = String
+
+ var name: String
+ var bleManager: BLEManager
+ var id = UUID()
+ @Published var status = WearableDeviceStatus.error(message: "Not initialized")
+
+ required init(bleManager: BLEManager, name: String) {
+ self.bleManager = bleManager
+ self.name = name
+ }
+
+ class var deviceConfiguration: WearableDeviceConfiguration {
+ return WearableDeviceConfiguration(reference: "Abstract", scanServiceUUID: CBUUID(), notifyCharacteristicsUUIDs: [])
+ }
+
+}
+
+struct WearableDeviceConfiguration {
+ /// Name of the device itself (not the individual instance)
+ var reference: WearableDevice.DeviceReference
+
+ /// UUID of service that identifies the device when scanning for BLE peripherals
+ var scanServiceUUID: CBUUID
+
+ /// UUID of characterstics for which it wants a notification (from the start)
+ var notifyCharacteristicsUUIDs: [CBUUID]
+}
+
+enum WearableDeviceStatus {
+ case ready
+ case error(message: String)
+}
+
+protocol BatteryInformation {
+
+ var batteryLevel: UInt8 { get }
+
+}
+
+protocol AudioRecordingDevice {
+
+ var isRecording: Bool { get }
+
+ func start(recording: Recording)
+
+ func stopRecording()
+
+}
+
+class AudioPacket {
+ var packetData = Data()
+ var packetNumber: UInt16
+
+ init(packetNumber: UInt16) {
+ self.packetNumber = packetNumber
+ }
+
+ func append(data: Data) {
+ packetData.append(data)
+ }
+}
diff --git a/sdks/swift/Sources/omi-lib/helpers/WearableDeviceRegistry.swift b/sdks/swift/Sources/omi-lib/helpers/WearableDeviceRegistry.swift
new file mode 100644
index 000000000..dfd089d2d
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/helpers/WearableDeviceRegistry.swift
@@ -0,0 +1,35 @@
+//
+// WearableDeviceRegistry.swift
+// PALApp
+//
+// Created by Eric Bariaux on 03/07/2024.
+//
+
+import CoreBluetooth
+import Foundation
+
+class WearableDeviceRegistry {
+ static let shared = WearableDeviceRegistry()
+
+ private init() { }
+
+ private var wearableRegistry: [WearableDevice.Type] = []
+ private(set) var scanServices: [CBUUID] = []
+
+ func registerDevice(wearable: WearableDevice.Type) {
+ if !scanServices.contains(wearable.deviceConfiguration.scanServiceUUID) {
+ wearableRegistry.append(wearable)
+ scanServices.append(wearable.deviceConfiguration.scanServiceUUID)
+ }
+ // TODO: return error if service with same scan UUID already registered
+ }
+
+ func deviceTypeForService(uuid serviceUUID: CBUUID) -> WearableDevice.Type? {
+ return wearableRegistry.first(where: { $0.deviceConfiguration.scanServiceUUID == serviceUUID })
+ }
+
+ func deviceTypeForReference(_ reference: WearableDevice.DeviceReference) -> WearableDevice.Type? {
+ return wearableRegistry.first(where: { $0.deviceConfiguration.reference == reference })
+ }
+
+}
diff --git a/sdks/swift/Sources/omi-lib/omi_lib.swift b/sdks/swift/Sources/omi-lib/omi_lib.swift
new file mode 100644
index 000000000..6f05936a6
--- /dev/null
+++ b/sdks/swift/Sources/omi-lib/omi_lib.swift
@@ -0,0 +1,62 @@
+// The Swift Programming Language
+// https://docs.swift.org/swift-book
+import AVFoundation
+
+
+public struct Device {
+ public var id: String
+}
+
+public class OmiManager {
+ public init() {}
+
+ private static var singleton = OmiManager()
+ private static var friend_singleton = FriendManager()
+ var deviceCompletion: ((Device?, Error?) -> Void)?
+
+ var seen_devices: [Friend] = []
+
+ public static func startScan(completion: ((Device?, Error?) -> Void)?) {
+ friend_singleton.deviceCompletion = { device, error in
+ if let id = device?.id.uuidString {
+ if singleton.seen_devices.firstIndex(where: {$0.id.uuidString == id}) == nil {
+ singleton.seen_devices.append(device!)
+ }
+ completion?(Device(id: id), nil)
+ }
+ else {
+ completion?(nil, error)
+ }
+ }
+ friend_singleton.startScan()
+ }
+
+ public static func endScan() {
+ self.friend_singleton.deviceCompletion = nil
+ self.friend_singleton.bluetoothScanner.centralManager.stopScan()
+ }
+
+ public static func connectToDevice(device: Device) {
+ if let device = self.singleton.seen_devices.first(where: {$0.id.uuidString == device.id}) {
+ self.friend_singleton.connectToDevice(device: device)
+ }
+ }
+
+ public static func connectionUpdated(completion: @escaping(Bool) -> Void) {
+ self.friend_singleton.connectionStatus(completion: completion)
+ }
+
+ public static func getLiveTranscription(device: Device, completion: @escaping (String?) -> Void) {
+ if let device = self.singleton.seen_devices.first(where: {$0.id.uuidString == device.id}) {
+ self.friend_singleton.getLiveTranscription(device: device, completion: completion)
+ }
+ }
+
+ public static func getLiveAudio(device: Device, completion: @escaping (URL?) -> Void) {
+ if let device = self.singleton.seen_devices.first(where: {$0.id.uuidString == device.id}) {
+ self.friend_singleton.getRawAudio(device: device, completion: completion)
+ }
+ }
+
+}
+