Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AacEldAccessUnit> {
if (!isAudioPacket(packet) || packet.size < HEADER_SIZE) return emptyList()

val frames = mutableListOf<AacEldAccessUnit>()
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<AacEldAccessUnit>(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(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -246,6 +254,7 @@ class AACPManager {
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
fun onCustomEqReceived(customEq: CustomEq)
fun onCapabilitiesReceived(capabilities: List<Capability>)
fun onAudioStreamReceived(packet: ByteArray)
}

fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
Expand Down Expand Up @@ -496,6 +505,8 @@ class AACPManager {
callback?.onHeadTrackingReceived(packet)
}

Opcodes.AUDIO_STREAM -> callback?.onAudioStreamReceived(packet)

Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
Expand Down Expand Up @@ -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(
Expand Down
Loading