Cover photo show an analysis graph of BLE timings for sending audio packages.
While designing a BLE device, it’s important to classify BLE service payload size and timings. If there is no immediate user interaction need, Bluetooth timings can be easily done with WRITE
methods. WRITE
method allow us to send a data packet in each connection interval period.
For our use case, we need to send audio frequent enough to not cause a silence or overlapping. We were using the GATT profile for the hardware and no other profile was feasible to apply like BLE Audio profiles. So to accomplish this use case, by staying within GATT profile, we needed to send more packs within the connection interval, so this can be done under two options;
- Shortening the connection interval to fit 1 pack into the interval and don’t wait for other packs.
- Converting our audio service’s related characteristic to
WRITE_WITHOUT_RESPONSE
method to send more packs in the interval.
At the same time, Android platform seems to be more performant on BLE topics. Whenever we call the write method, Android performs the writing without any delay for both
WRITE
andWRITE_WITHOUT_RESPONSE
. And Android side took our minimal effort to handle packages. Apple’s CoreBluetooth design seems more resource-efficiency oriented by putting some optimization layers on timings.
Image Credit: https://punchthrough.com/maximizing-ble-throughput-on-ios-and-android/
First option seems magical, we can do the package transmits without any delay, because Operating Systems are sending packs consecutively without overlapping. But we couldn’t manage out to easily adjust our connection intervals in our hardware side. So while racing against time, we switched our focus to the second option, which gives us less control but more flexibility. We added some controls and some measures of managing buffer (such as circular buffer) to not being affected by some tiny delays or frequently received packs, at the hardware side.
For the mobile side, to manage timings, we evaluated different concepts;
Timer.scheduledTimer
method, frequently used by developers and reliable enough.usleep
is a certainly not-to-use method, which blocks the thread. The only use case might be a C code-base which uses classic sleep. In that case, it can be called from a code block insideDispatchQueue.main.async
method, to run that legacy code in a separate place. In our case, we were calling opus library c codes inside theDispatchQueue
to not block the user interactions.DispatchQueue.asyncAfter
is working like the first method.- For loop (for zero interval transmits); we chose this to benchmark our service design;
WRITE_WITHOUT_RESPONSE
vsWRITE
All the methods were working according to their purposes, but when we put the application in background, we notice that our timings were not accurate so we were having more than 60 ms delays. Our desired interval was 60 ± 5 milliseconds. When the timer hits 120-150 milliseconds intervals for multiple times, our circular buffer at the embedded side was not able to cover the silences. So our problem definition has been altered to the question of “how we can ensure the timings at the background mode?”
To note the infrastructure, our iOS mobile app have had background mode declarations from the capabilities, so we’re not restricted in using timers at the backgrounds. So the question was not how we can run the timers in the background, but the reliability. I should also mention that, we benchmarked both Arik Segal’s A Simple Swift Background Timer (medium) and Daniel Galasko’s A Background Repeating Timer in Swift posts for background timings. We didn’t deep dived into these posts so much, because they were not giving accurate timings in our initial trials.
We ended up evaluating while loops and checking timestamps for the interval calculating. Because we were able to execute in the background, and our device was handling BLE communications from different service and characteristics, which is not timing critical. And initial trials were promising. We ended up with a code block which runs the code in asynchronous from user interactions and calls itself recursively till the end of data.
Here is our helper method, which is called periodically, after sending the packs.
// Using this instead of a thread blocking while loop.
private func recursivePackageHandler(interval: Double, action: @escaping () -> Void) {
DispatchQueue.main.async {
action()
if self.index < self.endIndex {
recursivePackageHandler(interval: interval, action: action)
} else {
calling_termination_methods_here()
print("Reach to the end")
}
}
}
And here is our main method, which is called, when user wants to send an audio to the device.
/// Sends the packs accordingly to specified interval duration.
private func sendSound(){
handle_preparation_tasks()
self.intervalTimer = Date().timeIntervalSince1970
var intervalDiff = self.interval
// Slicing audio to data for processing
let slice = Data(Array(self.audioData[self.indec ..< self.index + self.endIndex]))
var package : Data? = preparePackageFromSlice(slice: slice)
recursivePackageHandler(interval: self.interval) {
if intervalDiff >= self.interval {
// First send the pack.
CBPeripheral.writeValue(pack, for: audioChar, type: .withoutResponse )
// Then prepare the next pack
self.index += self.chunkSize
if self.index + self.endIndex < self.endIndex {
// Call the same slice code above
let slice = ...
package = preparePackageFromSlice(slice: slice)
} else {
packs = nil
calling_buffer_cleaning_codes_here()
}
self.intervalTimer = nowAsTimestamp()
}
intervalDiff = (nowAsTimestamp() - self.intervalTimer) * 1000
}
}
And nowAsTimestamp method is a simple shortcut to get the timestamp.
fileprivate func nowAsTimestamp() -> Double {
return Date().timeIntervalSince1970 as Double
}
After the implementation, we distributed the sample app to our testers via Testflight. Normally I was not using Testflight for distribution. But I wanted to be sure about we’re not violating Apple guidelines, so having a review would be helpful. We got a warning on timestamp usage declaration, linked to Apple’s Privacy Manifest Files page.
Important
If you upload an app to App Store Connect that uses required reason API without describing the reason in its privacy manifest file, Apple sends you an email reminding you to add the reason to the app’s privacy manifest. Starting May 1, 2024, apps that don’t describe their use of required reason API in their privacy manifest file aren’t accepted by App Store Connect.
The following APIs for accessing the system boot time require reasons for use. Use the string
NSPrivacyAccessedAPICategorySystemBootTime
as the value for theNSPrivacyAccessedAPIType
key in yourNSPrivacyAccessedAPITypes
dictionary.
systemUptime
mach_absolute_time()
In your
NSPrivacyAccessedAPITypeReasons
array, supply the relevant values from the list below.
We noticed that, we received this warning not because of our nowAsTimestamp
method, but because of a library we were using. But I guess Apple might ask for a declaration in store page for timestamps (I guess they won’t mandate asking permission for this purpose), because it’s the primary profiling method for user actions.
One last credit: I still wonder, how PunchThrough’s article can be helpful since 2016. I benefitted from this post for checking the simplest concepts and ensure our debugging process is not faulty. 🖖