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) + } + } + +} +