Skip to content

Commit 7341e41

Browse files
committed
android: add custom EQ settings (ios27)
will be released into stable as soon as I implement capability parsing
1 parent bffb5c8 commit 7341e41

11 files changed

Lines changed: 856 additions & 44 deletions

File tree

android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
130130
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
131131
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
132132
import me.kavishdevar.librepods.presentation.screens.DebugScreen
133+
import me.kavishdevar.librepods.presentation.screens.EqualizerScreen
133134
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
134135
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
135136
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
@@ -479,6 +480,9 @@ fun Main() {
479480
val purchaseViewModel: PurchaseViewModel = viewModel()
480481
PurchaseScreen(purchaseViewModel, navController)
481482
}
483+
composable("equalizer_screen") {
484+
if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel)
485+
}
482486
}
483487
}
484488

android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
package me.kavishdevar.librepods.bluetooth
2222

2323
import android.util.Log
24+
import me.kavishdevar.librepods.data.Capability
25+
import me.kavishdevar.librepods.data.CustomEq
2426
import java.nio.ByteBuffer
2527
import java.nio.ByteOrder
2628
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -47,14 +49,15 @@ class AACPManager {
4749
const val PROXIMITY_KEYS_REQ: Byte = 0x30
4850
const val PROXIMITY_KEYS_RSP: Byte = 0x31
4951
const val STEM_PRESS: Byte = 0x19
50-
const val EQ_DATA: Byte = 0x53
52+
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
5153
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
5254
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
5355
const val SMART_ROUTING: Byte = 0x10
5456
const val TIPI_3: Byte = 0x0C // Don't know this one
5557
const val SMART_ROUTING_RESP: Byte = 0x11
5658
const val SEND_CONNECTED_MAC: Byte = 0x14
5759
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
60+
const val CUSTOM_EQ: Byte = 0x63
5861
}
5962

6063
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -199,6 +202,11 @@ class AACPManager {
199202
var eqOnMedia: Boolean = false
200203
private set
201204

205+
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
206+
private set
207+
208+
var customEqCallback: ((CustomEq) -> Unit)? = null
209+
202210
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
203211
return controlCommandStatusList.find { it.identifier == identifier }
204212
}
@@ -235,7 +243,9 @@ class AACPManager {
235243
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
236244
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
237245
fun onShowNearbyUI(sender: String)
238-
fun onEQPacketReceived(eqData: FloatArray)
246+
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
247+
fun onCustomEqReceived(customEq: CustomEq)
248+
fun onCapabilitiesReceived(capabilities: List<Capability>)
239249
}
240250

241251
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -548,18 +558,18 @@ class AACPManager {
548558
}
549559
}
550560

551-
Opcodes.EQ_DATA -> {
561+
Opcodes.HEADPHONE_ACCOMMODATION -> {
552562
if (packet.size != 140) {
553563
Log.w(
554564
TAG,
555-
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
565+
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
556566
)
557567
return
558568
}
559569
if (packet[6] != 0x84.toByte()) {
560570
Log.w(
561571
TAG,
562-
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
572+
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
563573
)
564574
return
565575
}
@@ -582,7 +592,7 @@ class AACPManager {
582592
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
583593
)
584594

585-
callback?.onEQPacketReceived(eqData)
595+
callback?.onHeadphoneAccommodationReceived(eqData)
586596
}
587597

588598
Opcodes.INFORMATION -> {
@@ -591,6 +601,13 @@ class AACPManager {
591601
callback?.onDeviceInformationReceived(information)
592602
}
593603

604+
Opcodes.CUSTOM_EQ -> {
605+
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
606+
customEq = parseCustomEqPacket(packet)
607+
customEqCallback?.invoke(customEq)
608+
callback?.onCustomEqReceived(customEq)
609+
}
610+
594611
else -> {
595612
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
596613
callback?.onUnknownPacketReceived(packet)
@@ -1296,4 +1313,38 @@ class AACPManager {
12961313
version3 = strings.getOrNull(10) ?: "",
12971314
)
12981315
}
1316+
1317+
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
1318+
return sendDataPacket(customEq.toPacket())
1319+
}
1320+
1321+
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
1322+
val data = packet.sliceArray(6 until packet.size)
1323+
1324+
if (data.size < 7) {
1325+
Log.e(TAG, "custom EQ packet length less than 7, returning default")
1326+
return CustomEq(1, 50, 50, 50)
1327+
}
1328+
1329+
val lengthLow = data[0].toInt() and 0xFF
1330+
val lengthHigh = data[1].toInt() and 0xFF
1331+
1332+
val length = (lengthHigh shl 8) or lengthLow
1333+
1334+
if (length != 5) {
1335+
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
1336+
}
1337+
1338+
val state = data[3].toInt()
1339+
val low = data[4].toInt()
1340+
val mid = data[5].toInt()
1341+
val high = data[6].toInt()
1342+
1343+
return CustomEq(
1344+
state,
1345+
low,
1346+
mid,
1347+
high
1348+
)
1349+
}
12991350
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package me.kavishdevar.librepods.data
2+
3+
import me.kavishdevar.librepods.bluetooth.AACPManager
4+
5+
enum class CustomEqBand { LOW, MID, HIGH }
6+
7+
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
8+
9+
fun isEnabled(): Boolean {
10+
return state == 2
11+
}
12+
13+
fun toPacket(): ByteArray {
14+
return byteArrayOf(
15+
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
16+
0x05, 0x00, // length (LE)
17+
0x01, state.toByte(),
18+
low.toByte(), mid.toByte(), high.toByte()
19+
)
20+
}
21+
22+
init {
23+
require(low in 0..100) { "low must be between 0 and 100, was $low" }
24+
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
25+
require(high in 0..100) { "high must be between 0 and 100, was $high" }
26+
}
27+
}

android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import android.os.Parcelable
2222
import android.util.Log
2323
import kotlinx.parcelize.Parcelize
2424

25+
// TODO: Remove everything but Battery-related stuff
26+
2527
enum class Enums(val value: ByteArray) {
26-
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
28+
NOISE_CANCELLATION(byteArrayOf(0x0d)),
2729
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
2830
SETTINGS(byteArrayOf(0x09, 0x00)),
2931
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
@@ -81,12 +83,12 @@ class AirPodsNotifications {
8183
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
8284
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
8385
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
84-
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
86+
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
8587
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
8688
}
8789

8890
class EarDetection {
89-
private val notificationBit = Capabilities.EAR_DETECTION
91+
private val notificationBit = 6.toByte()
9092
private val notificationPrefix = Enums.PREFIX.value + notificationBit
9193

9294
var status: List<Byte> = listOf(0x01, 0x01)
@@ -243,13 +245,6 @@ class AirPodsNotifications {
243245
}
244246
}
245247

246-
class Capabilities {
247-
companion object {
248-
val NOISE_CANCELLATION = byteArrayOf(0x0d)
249-
val EAR_DETECTION = byteArrayOf(0x06)
250-
}
251-
}
252-
253248
fun isHeadTrackingData(data: ByteArray): Boolean {
254249
if (data.size <= 60) return false
255250

android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ fun AudioSettings(
5353
conversationalAwarenessCapability: Boolean,
5454
loudSoundReductionCapability: Boolean,
5555
adaptiveAudioCapability: Boolean,
56+
customEqCapability: Boolean,
5657

5758
adaptiveVolumeChecked: Boolean,
5859
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
@@ -157,6 +158,20 @@ fun AudioSettings(
157158
navController = navController,
158159
independent = false
159160
)
161+
HorizontalDivider(
162+
thickness = 1.dp,
163+
color = Color(0x40888888),
164+
modifier = Modifier
165+
.padding(horizontal = 12.dp)
166+
)
167+
}
168+
if (customEqCapability) {
169+
NavigationButton(
170+
to = "equalizer_screen",
171+
name = stringResource(R.string.equalizer),
172+
navController = navController,
173+
independent = false
174+
)
160175
}
161176
}
162177
}
@@ -170,6 +185,7 @@ fun AudioSettingsPreview() {
170185
conversationalAwarenessCapability = true,
171186
loudSoundReductionCapability = true,
172187
adaptiveAudioCapability = true,
188+
customEqCapability = true,
173189
adaptiveVolumeChecked = true,
174190
onAdaptiveVolumeCheckedChange = { },
175191
conversationalAwarenessChecked = true,

android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ half4 main(float2 coord) {
140140
}
141141
drawRect(color)
142142
} else {
143-
if (isPressed) {
143+
if (isPressed && enabled) {
144144
drawRect(Color.Black.copy(alpha = 0.4f))
145145
drawRect(Color.White.copy(alpha = 0.2f))
146146
}
@@ -264,41 +264,52 @@ half4 main(float2 coord) {
264264
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
265265
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
266266
val onDragStop: () -> Unit = {
267-
scope.launch {
268-
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
269-
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
270-
launch {
271-
offsetAnimation.animateTo(
272-
Offset.Zero,
273-
offsetAnimationSpec
274-
)
267+
if (enabled) {
268+
scope.launch {
269+
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
270+
launch {
271+
progressAnimation.animateTo(
272+
0f,
273+
progressAnimationSpec
274+
)
275+
}
276+
launch {
277+
offsetAnimation.animateTo(
278+
Offset.Zero,
279+
offsetAnimationSpec
280+
)
281+
}
275282
}
276283
}
277284
}
278285
inspectDragGestures(
279286
onDragStart = { down ->
280287
pressStartPosition = down.position
281-
scope.launch {
282-
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
283-
launch {
284-
progressAnimation.animateTo(
285-
1f,
286-
progressAnimationSpec
287-
)
288+
if (enabled) {
289+
scope.launch {
290+
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
291+
launch {
292+
progressAnimation.animateTo(
293+
1f,
294+
progressAnimationSpec
295+
)
296+
}
297+
launch { offsetAnimation.snapTo(Offset.Zero) }
288298
}
289-
launch { offsetAnimation.snapTo(Offset.Zero) }
290299
}
291300
},
292301
onDragEnd = {
293302
onDragStop()
294303
},
295304
onDragCancel = onDragStop
296305
) { _, dragAmount ->
297-
scope.launch {
298-
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
299-
HapticFeedbackType.SegmentFrequentTick
300-
)
301-
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
306+
if (enabled) {
307+
scope.launch {
308+
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
309+
HapticFeedbackType.SegmentFrequentTick
310+
)
311+
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
312+
}
302313
}
303314
}
304315
}

android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
365365
conversationalAwarenessCapability = conversationalAwarenessCapability,
366366
loudSoundReductionCapability = loudSoundReductionCapability,
367367
adaptiveAudioCapability = adaptiveAudioCapability,
368+
customEqCapability = true,
368369
adaptiveVolumeChecked = adaptiveVolumeChecked,
369370
onAdaptiveVolumeCheckedChange = { checked ->
370371
viewModel.setControlCommandBoolean(

0 commit comments

Comments
 (0)