diff --git a/README.md b/README.md index c1cc4edb5..79be84cf8 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ This is being worked upon, check the #⁠reverse-engineering channel on the Libr ## High quality two-way audio On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP. -Since this needs deeper integration with audio on Android, it will most likely need root. +On Android, LibrePods can decode that AAC-ELD stream for its own in-app features while A2DP remains +active. Publishing it as a system microphone for other apps still requires deeper privileged audio +integration. # Installation diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..60b35555a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -158,6 +158,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigationevent) + testImplementation("junit:junit:4.13.2") } aboutLibraries { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/audio/AacEldAudio.kt b/android/app/src/main/java/me/kavishdevar/librepods/audio/AacEldAudio.kt new file mode 100644 index 000000000..773caa830 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/audio/AacEldAudio.kt @@ -0,0 +1,191 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. +*/ + +package me.kavishdevar.librepods.audio + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +data class AacEldAccessUnit(val timestamp: Long, val data: ByteArray) + +/** Parses AACP 0x58/subtype 0x0001 microphone SDUs without reading past malformed frames. */ +object AacEldPacketParser { + private const val HEADER_SIZE = 22 + + fun isAudioPacket(packet: ByteArray): Boolean = + packet.size >= 8 && + packet[0] == 0x04.toByte() && + packet[2] == 0x04.toByte() && + packet[4] == 0x58.toByte() && + packet[5] == 0x00.toByte() && + packet[6] == 0x01.toByte() && + packet[7] == 0x00.toByte() + + fun parse(packet: ByteArray): List { + if (!isAudioPacket(packet) || packet.size < HEADER_SIZE) return emptyList() + + val frames = mutableListOf() + var offset = HEADER_SIZE + while (offset + 5 <= packet.size) { + val timestamp = ByteBuffer.wrap(packet, offset, 4) + .order(ByteOrder.LITTLE_ENDIAN) + .int + .toLong() and 0xffffffffL + val length = packet[offset + 4].toInt() and 0xff + val start = offset + 5 + val end = start + length + if (end > packet.size) break + frames += AacEldAccessUnit(timestamp, packet.copyOfRange(start, end)) + offset = end + } + return frames + } +} + +/** + * Decodes the AirPods AAC-ELD stream on a dedicated bounded worker. + * PCM callbacks run on that worker and must return promptly. The decoder reports its actual output + * sample rate because observed implementations distinguish the 48 kHz coding rate from a 64 kHz + * presentation rate. + */ +class AacEldDecoder( + private val listener: Listener, + private val codecFactory: () -> MediaCodec = { + MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC) + }, +) : AutoCloseable { + interface Listener { + fun onPcmData(data: ByteArray, sampleRate: Int, channelCount: Int) + fun onDecoderError(message: String, cause: Throwable? = null) + } + + private val queue = ArrayBlockingQueue(QUEUE_CAPACITY) + private val running = AtomicBoolean(false) + private var worker: Thread? = null + private var codec: MediaCodec? = null + + fun start(): Boolean { + if (!running.compareAndSet(false, true)) return true + return try { + codec = codecFactory().also { decoder -> + val format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, + AAC_CODING_RATE, + CHANNEL_COUNT, + ).apply { + setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectELD) + setInteger(MediaFormat.KEY_IS_ADTS, 0) + setByteBuffer("csd-0", ByteBuffer.wrap(AUDIO_SPECIFIC_CONFIG)) + } + decoder.configure(format, null, null, 0) + decoder.start() + } + worker = Thread(::decodeLoop, "librepods-aac-eld").also { it.start() } + true + } catch (error: Throwable) { + running.set(false) + releaseCodec() + listener.onDecoderError("AAC-ELD decoder is unavailable", error) + false + } + } + + fun offer(packet: ByteArray) { + if (!running.get()) return + for (frame in AacEldPacketParser.parse(packet)) { + if (!queue.offer(frame)) { + queue.poll() + queue.offer(frame) + } + } + } + + private fun decodeLoop() { + var presentationTimeUs = 0L + try { + while (running.get()) { + val frame = queue.take() + val decoder = codec ?: break + val inputIndex = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US) + if (inputIndex >= 0) { + decoder.getInputBuffer(inputIndex)?.apply { + clear() + put(frame.data) + } + decoder.queueInputBuffer(inputIndex, 0, frame.data.size, presentationTimeUs, 0) + presentationTimeUs += FRAME_DURATION_US + } + drain(decoder) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } catch (error: Throwable) { + if (running.get()) listener.onDecoderError("AAC-ELD decode failed", error) + } finally { + releaseCodec() + } + } + + private fun drain(decoder: MediaCodec) { + val info = MediaCodec.BufferInfo() + while (true) { + when (val outputIndex = decoder.dequeueOutputBuffer(info, 0)) { + MediaCodec.INFO_TRY_AGAIN_LATER -> return + MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit + else -> if (outputIndex >= 0) { + decoder.getOutputBuffer(outputIndex)?.let { output -> + output.position(info.offset) + output.limit(info.offset + info.size) + val pcm = ByteArray(info.size) + output.get(pcm) + val format = decoder.outputFormat + listener.onPcmData( + pcm, + format.getInteger(MediaFormat.KEY_SAMPLE_RATE), + format.getInteger(MediaFormat.KEY_CHANNEL_COUNT), + ) + } + decoder.releaseOutputBuffer(outputIndex, false) + } + } + } + } + + override fun close() { + if (!running.getAndSet(false)) return + worker?.interrupt() + worker = null + queue.clear() + } + + @Synchronized + private fun releaseCodec() { + val decoder = codec ?: return + codec = null + runCatching { decoder.stop() } + decoder.release() + } + + companion object { + private const val AAC_CODING_RATE = 48_000 + private const val CHANNEL_COUNT = 1 + private const val FRAME_DURATION_US = 7_500L + private const val CODEC_TIMEOUT_US = 10_000L + private const val QUEUE_CAPACITY = 256 + private val AUDIO_SPECIFIC_CONFIG = byteArrayOf( + 0xF8.toByte(), 0xE6.toByte(), 0x30.toByte(), 0x00.toByte(), + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index ac6d356b7..4aec04f06 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -58,9 +58,17 @@ class AACPManager { const val SEND_CONNECTED_MAC: Byte = 0x14 const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant? const val CUSTOM_EQ: Byte = 0x63 + const val AUDIO_STREAM: Byte = 0x58 } private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) + private val START_AUDIO_STREAM_PACKET = byteArrayOf( + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x09, 0x00, + 0x00, 0x01, 0x82.toByte(), 0x00, 0x00, 0x00, 0x04, 0x96.toByte(), 0x00, + ) + private val STOP_AUDIO_STREAM_PACKET = byteArrayOf( + 0x04, 0x00, 0x04, 0x00, 0x58, 0x00, 0x00, 0x00, 0x02, 0x00, 0x03, 0x01, + ) data class ControlCommandStatus( val identifier: ControlCommandIdentifiers, val value: ByteArray @@ -246,6 +254,7 @@ class AACPManager { fun onHeadphoneAccommodationReceived(eqData: FloatArray) fun onCustomEqReceived(customEq: CustomEq) fun onCapabilitiesReceived(capabilities: List) + fun onAudioStreamReceived(packet: ByteArray) } fun parseStemPressResponse(data: ByteArray): Pair { @@ -496,6 +505,8 @@ class AACPManager { callback?.onHeadTrackingReceived(packet) } + Opcodes.AUDIO_STREAM -> callback?.onAudioStreamReceived(packet) + Opcodes.PROXIMITY_KEYS_RSP -> { callback?.onProximityKeysReceived(packet) } @@ -1175,6 +1186,12 @@ class AACPManager { } } + /** Starts the proprietary AAC-ELD microphone stream without switching Android to SCO. */ + fun sendStartAudioStream(): Boolean = sendPacket(START_AUDIO_STREAM_PACKET) + + /** Stops the proprietary AAC-ELD microphone stream. */ + fun sendStopAudioStream(): Boolean = sendPacket(STOP_AUDIO_STREAM_PACKET) + fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) { if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats") val header = byteArrayOf( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..12deee688 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -80,9 +80,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.audio.AacEldDecoder +import me.kavishdevar.librepods.audio.AacEldPacketParser import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.bluetooth.ATTHandles @@ -160,6 +163,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var cameraActive = false private var disconnectedBecauseReversed = false private var otherDeviceTookOver = false + @Volatile + private var highResolutionMicDecoder: AacEldDecoder? = null + private var conversationDetectionBeforeMic: Byte? = null data class ServiceConfig( var deviceName: String = "AirPods", @@ -692,6 +698,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // } } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { + stopHighResolutionMicrophone() device = null // isConnectedLocally = false popupShown = false @@ -1176,6 +1183,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // TODO } + override fun onAudioStreamReceived(packet: ByteArray) { + highResolutionMicDecoder?.offer(packet) + } + override fun onUnknownPacketReceived(packet: ByteArray) { Log.d( "AACPManager", @@ -2775,28 +2786,35 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList while (socket.isConnected) { try { - val buffer = ByteArray(1024) + // 0x58 microphone SDUs exceed 1 KiB and SOCK_SEQPACKET truncates them. + val buffer = ByteArray(4096) val bytesRead = it.inputStream.read(buffer) var data: ByteArray if (bytesRead > 0) { data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } + val isAudioPacket = AacEldPacketParser.isAudioPacket(data) + if (!isAudioPacket) { + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { + putExtra("data", data) + setPackage(packageName) + }) + } // CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) + if (!isAudioPacket) { + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) + } aacpManager.receivePacket(data) - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") + if (!isAudioPacket && !isHeadTrackingData(data)) { + Log.d( + "AirPodsData", + "Data received: ${data.joinToString(" ") { "%02X".format(it) }}", + ) logPacket(data, "AirPods") } @@ -2805,6 +2823,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) + stopHighResolutionMicrophone() aacpManager.disconnected() return@launch } @@ -2814,6 +2833,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) + stopHighResolutionMicrophone() aacpManager.disconnected() return@launch } @@ -3106,6 +3126,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") override fun onDestroy() { + stopHighResolutionMicrophone() clearPacketLogs() Log.d(TAG, "Service stopped is being destroyed for some reason!") @@ -3144,6 +3165,68 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList super.onDestroy() } + /** + * Starts the AirPods high-resolution microphone and delivers mono PCM off the main thread. + * This stream is app-scoped; Android does not expose it as a system microphone to other apps. + */ + suspend fun startHighResolutionMicrophone(listener: AacEldDecoder.Listener): Boolean = + withContext(Dispatchers.IO) { startHighResolutionMicrophoneBlocking(listener) } + + @Synchronized + private fun startHighResolutionMicrophoneBlocking(listener: AacEldDecoder.Listener): Boolean { + if (highResolutionMicDecoder != null) return false + if (BluetoothConnectionManager.aacpSocket?.isConnected != true) { + listener.onDecoderError("AirPods AACP socket is not connected") + return false + } + + val decoder = AacEldDecoder(listener) + if (!decoder.start()) return false + + highResolutionMicDecoder = decoder + conversationDetectionBeforeMic = aacpManager.getControlCommandStatus( + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, + )?.value?.firstOrNull() + conversationDetectionBeforeMic?.let { current -> + if (current != 0x02.toByte()) { + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, + false, + ) + } + } + + if (!aacpManager.sendStartAudioStream()) { + stopHighResolutionMicrophone(sendStopPacket = false) + listener.onDecoderError("Could not send the AirPods microphone start packet") + return false + } + return true + } + + @Synchronized + fun stopHighResolutionMicrophone() { + stopHighResolutionMicrophone(sendStopPacket = true) + } + + private fun stopHighResolutionMicrophone(sendStopPacket: Boolean) { + val decoder = highResolutionMicDecoder ?: return + highResolutionMicDecoder = null + if (sendStopPacket && BluetoothConnectionManager.aacpSocket?.isConnected == true) { + aacpManager.sendStopAudioStream() + } + decoder.close() + conversationDetectionBeforeMic?.let { previous -> + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, + byteArrayOf(previous), + ) + } + } + conversationDetectionBeforeMic = null + } + var isHeadTrackingActive = false fun startHeadTracking() { diff --git a/android/app/src/test/java/me/kavishdevar/librepods/audio/AacEldPacketParserTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/audio/AacEldPacketParserTest.kt new file mode 100644 index 000000000..76f4e1e63 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/audio/AacEldPacketParserTest.kt @@ -0,0 +1,54 @@ +package me.kavishdevar.librepods.audio + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AacEldPacketParserTest { + @Test + fun parsesAllCompleteAccessUnits() { + val packet = audioHeader() + byteArrayOf( + 0x04, 0x03, 0x02, 0x01, 0x03, 0x11, 0x22, 0x33, + 0x08, 0x07, 0x06, 0x05, 0x02, 0x44, 0x55, + ) + + val frames = AacEldPacketParser.parse(packet) + + assertEquals(2, frames.size) + assertEquals(0x01020304L, frames[0].timestamp) + assertArrayEquals(byteArrayOf(0x11, 0x22, 0x33), frames[0].data) + assertEquals(0x05060708L, frames[1].timestamp) + assertArrayEquals(byteArrayOf(0x44, 0x55), frames[1].data) + } + + @Test + fun dropsTruncatedTailWithoutDiscardingCompleteFrames() { + val packet = audioHeader() + byteArrayOf( + 0x04, 0x03, 0x02, 0x01, 0x01, 0x66, + 0x08, 0x07, 0x06, 0x05, 0x03, 0x77, + ) + + val frames = AacEldPacketParser.parse(packet) + + assertEquals(1, frames.size) + assertArrayEquals(byteArrayOf(0x66), frames.single().data) + } + + @Test + fun rejectsControlAndShortPackets() { + val control = audioHeader().also { it[6] = 0x00 } + + assertFalse(AacEldPacketParser.isAudioPacket(control)) + assertTrue(AacEldPacketParser.parse(control).isEmpty()) + assertTrue(AacEldPacketParser.parse(byteArrayOf(0x04, 0x00)).isEmpty()) + } + + private fun audioHeader() = ByteArray(22).apply { + this[0] = 0x04 + this[2] = 0x04 + this[4] = 0x58 + this[6] = 0x01 + } +}