feat: add MP3 frame synchronizer with re-sync and validation
Made-with: Cursor
This commit is contained in:
139
app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt
Normal file
139
app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package xyz.cottongin.radio247.audio
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
private data class ParsedHeader(
|
||||||
|
val frameSize: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// MPEG1 Layer 3 bitrates (kbps), index 0 and 15 invalid
|
||||||
|
private val MPEG1_L3_BITRATE = intArrayOf(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0)
|
||||||
|
|
||||||
|
// MPEG2/2.5 Layer 3 bitrates (kbps)
|
||||||
|
private val MPEG2_L3_BITRATE = intArrayOf(0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0)
|
||||||
|
|
||||||
|
// Sample rates (Hz)
|
||||||
|
private val MPEG1_SAMPLE_RATE = intArrayOf(44100, 48000, 32000, 0)
|
||||||
|
private val MPEG2_SAMPLE_RATE = intArrayOf(22050, 24000, 16000, 0)
|
||||||
|
private val MPEG25_SAMPLE_RATE = intArrayOf(11025, 12000, 8000, 0)
|
||||||
|
|
||||||
|
class Mp3FrameSync(
|
||||||
|
private val onFrame: (ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
private val buffer = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
fun feed(data: ByteArray, offset: Int = 0, length: Int = data.size) {
|
||||||
|
buffer.write(data, offset, length)
|
||||||
|
processBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
processBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processBuffer() {
|
||||||
|
val bytes = buffer.toByteArray()
|
||||||
|
var pos = 0
|
||||||
|
|
||||||
|
while (pos + 4 <= bytes.size) {
|
||||||
|
if (!isSyncWord(bytes, pos)) {
|
||||||
|
pos++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val header = parseHeader(bytes, pos)
|
||||||
|
if (header == null) {
|
||||||
|
pos++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val frameSize = header.frameSize
|
||||||
|
|
||||||
|
if (pos + frameSize > bytes.size) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextFramePos = pos + frameSize
|
||||||
|
if (nextFramePos + 4 <= bytes.size) {
|
||||||
|
if (!isSyncWord(bytes, nextFramePos) || parseHeader(bytes, nextFramePos) == null) {
|
||||||
|
pos++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val frame = bytes.copyOfRange(pos, pos + frameSize)
|
||||||
|
onFrame(frame)
|
||||||
|
pos += frameSize
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.reset()
|
||||||
|
if (pos < bytes.size) {
|
||||||
|
buffer.write(bytes, pos, bytes.size - pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSyncWord(bytes: ByteArray, pos: Int): Boolean {
|
||||||
|
val b0 = bytes[pos].toInt() and 0xFF
|
||||||
|
val b1 = bytes[pos + 1].toInt() and 0xFF
|
||||||
|
return b0 == 0xFF && (b1 and 0xE0) == 0xE0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHeader(bytes: ByteArray, pos: Int): ParsedHeader? {
|
||||||
|
if (pos + 4 > bytes.size) return null
|
||||||
|
val header = ((bytes[pos].toInt() and 0xFF) shl 24) or
|
||||||
|
((bytes[pos + 1].toInt() and 0xFF) shl 16) or
|
||||||
|
((bytes[pos + 2].toInt() and 0xFF) shl 8) or
|
||||||
|
(bytes[pos + 3].toInt() and 0xFF)
|
||||||
|
|
||||||
|
val b1 = bytes[pos + 1].toInt() and 0xFF
|
||||||
|
val b2 = bytes[pos + 2].toInt() and 0xFF
|
||||||
|
|
||||||
|
val mpegVersion = (header ushr 19) and 0x03
|
||||||
|
val layer = (header ushr 17) and 0x03
|
||||||
|
val bitrateIndex = (b2 shr 4) and 0x0F
|
||||||
|
val sampleRateIndex = (b2 shr 2) and 0x03
|
||||||
|
val padding = ((b2 shr 1) and 0x01) == 1
|
||||||
|
|
||||||
|
if (mpegVersion == 1) return null
|
||||||
|
if (layer == 0) return null
|
||||||
|
if (bitrateIndex == 0 || bitrateIndex == 15) return null
|
||||||
|
if (sampleRateIndex == 3) return null
|
||||||
|
|
||||||
|
val bitrate = when (mpegVersion) {
|
||||||
|
3 -> MPEG1_L3_BITRATE[bitrateIndex]
|
||||||
|
2 -> MPEG2_L3_BITRATE[bitrateIndex]
|
||||||
|
0 -> MPEG2_L3_BITRATE[bitrateIndex]
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if (bitrate == 0) return null
|
||||||
|
|
||||||
|
val sampleRate = when (mpegVersion) {
|
||||||
|
3 -> MPEG1_SAMPLE_RATE[sampleRateIndex]
|
||||||
|
2 -> MPEG2_SAMPLE_RATE[sampleRateIndex]
|
||||||
|
0 -> MPEG25_SAMPLE_RATE[sampleRateIndex]
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if (sampleRate == 0) return null
|
||||||
|
|
||||||
|
val frameSize = when (layer) {
|
||||||
|
1 -> { // Layer III
|
||||||
|
when (mpegVersion) {
|
||||||
|
3 -> (144 * bitrate * 1000 / sampleRate) + if (padding) 1 else 0
|
||||||
|
else -> (72 * bitrate * 1000 / sampleRate) + if (padding) 1 else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 -> { // Layer II
|
||||||
|
when (mpegVersion) {
|
||||||
|
3 -> (144 * bitrate * 1000 / sampleRate) + if (padding) 1 else 0
|
||||||
|
else -> (72 * bitrate * 1000 / sampleRate) + if (padding) 1 else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 -> { // Layer I
|
||||||
|
((12 * bitrate * 1000 / sampleRate) + if (padding) 1 else 0) * 4
|
||||||
|
}
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameSize <= 0) return null
|
||||||
|
return ParsedHeader(frameSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package xyz.cottongin.radio247.audio
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class Mp3FrameSyncTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build MPEG1 Layer3 header: 0xFF, (0xE0 | version<<3 | layer<<1 | crc), byte2, 0x00
|
||||||
|
* MPEG1=11, Layer3=01, noCRC=1. 0xFA has Layer=01 (III). Bitrate in 15-12, sample in 11-10, padding in bit 9.
|
||||||
|
*/
|
||||||
|
private fun buildMpeg1Layer3Header(
|
||||||
|
bitrateIndex: Int,
|
||||||
|
sampleRateIndex: Int,
|
||||||
|
padding: Boolean
|
||||||
|
): ByteArray {
|
||||||
|
// Byte 0: sync. Byte 1: 0xF9 = sync + MPEG1(11) + LayerIII(01) + noCRC(1)
|
||||||
|
val byte1 = 0xF9.toByte()
|
||||||
|
// Byte 2: bitrate(4) | sample(2) | padding(1) | private(1)
|
||||||
|
val byte2 = ((bitrateIndex shl 4) or (sampleRateIndex shl 2) or (if (padding) 2 else 0)).toByte()
|
||||||
|
return byteArrayOf(0xFF.toByte(), byte1, byte2, 0x00)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build MPEG2 Layer3 header. MPEG2 = 10, Layer3 = 01.
|
||||||
|
*/
|
||||||
|
private fun buildMpeg2Layer3Header(
|
||||||
|
bitrateIndex: Int,
|
||||||
|
sampleRateIndex: Int,
|
||||||
|
padding: Boolean
|
||||||
|
): ByteArray {
|
||||||
|
// Byte 1: 0xF5 = sync + MPEG2(10) + LayerIII(01) + noCRC(1)
|
||||||
|
val byte1 = 0xF5.toByte()
|
||||||
|
val byte2 = ((bitrateIndex shl 4) or (sampleRateIndex shl 2) or (if (padding) 2 else 0)).toByte()
|
||||||
|
return byteArrayOf(0xFF.toByte(), byte1, byte2, 0x00)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFrame(header: ByteArray, bodySize: Int): ByteArray {
|
||||||
|
return header + ByteArray(bodySize) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun findsFrameAtStartOfData() {
|
||||||
|
// MPEG1 Layer3, 128kbps, 44100Hz, no padding → frameSize = 417 bytes.
|
||||||
|
// Header bytes: 0xFF, 0xFB, 0x90, 0x00 per task spec
|
||||||
|
val header = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
|
||||||
|
val bodySize = 417 - 4
|
||||||
|
val frame1 = buildFrame(header, bodySize)
|
||||||
|
// Add another valid header for two-frame validation (4 bytes only)
|
||||||
|
val nextHeader = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
|
||||||
|
val data = frame1 + nextHeader
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
assertEquals(1, frames.size)
|
||||||
|
assertEquals(417, frames[0].size)
|
||||||
|
assertArrayEquals(header, frames[0].copyOfRange(0, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resyncsAfterGarbageBytes() {
|
||||||
|
val garbage = ByteArray(100) { 0x42 }
|
||||||
|
val header = byteArrayOf(0xFF.toByte(), 0xF9.toByte(), 0x80.toByte(), 0x00)
|
||||||
|
val bodySize = 417 - 4
|
||||||
|
val frame = buildFrame(header, bodySize)
|
||||||
|
// Need second frame for two-frame validation
|
||||||
|
val frame2 = buildFrame(header, bodySize)
|
||||||
|
val data = garbage + frame + frame2
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals(2, frames.size)
|
||||||
|
assertEquals(417, frames[0].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun calculatesFrameSizeCorrectlyForVariousBitrates() {
|
||||||
|
// MPEG1 Layer3: 128kbps/44100Hz = 417, 192kbps/44100Hz = 626, 320kbps/44100Hz = 1044
|
||||||
|
val configs = listOf(
|
||||||
|
Triple(8, 0, 417), // 128kbps, 44100Hz
|
||||||
|
Triple(12, 0, 626), // 192kbps, 44100Hz
|
||||||
|
Triple(14, 0, 1044) // 320kbps, 44100Hz
|
||||||
|
)
|
||||||
|
for ((bitrateIdx, sampleIdx, expectedSize) in configs) {
|
||||||
|
val header = buildMpeg1Layer3Header(bitrateIdx, sampleIdx, padding = false)
|
||||||
|
val bodySize = expectedSize - 4
|
||||||
|
val frame = buildFrame(header, bodySize)
|
||||||
|
val nextHeader = buildMpeg1Layer3Header(bitrateIdx, sampleIdx, padding = false)
|
||||||
|
val nextFrame = buildFrame(nextHeader, expectedSize - 4)
|
||||||
|
val data = frame + nextFrame
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals("bitrateIndex=$bitrateIdx", 1, frames.size)
|
||||||
|
assertEquals("bitrateIndex=$bitrateIdx", expectedSize, frames[0].size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesPaddingBit() {
|
||||||
|
// MPEG1 Layer3, 128kbps, 44100Hz, WITH padding → frameSize = 418. 0x92 = bitrate 8, padding.
|
||||||
|
val header = byteArrayOf(0xFF.toByte(), 0xF9.toByte(), 0x92.toByte(), 0x00) // padding bit set
|
||||||
|
val bodySize = 418 - 4
|
||||||
|
val frame = buildFrame(header, bodySize)
|
||||||
|
val nextHeader = byteArrayOf(0xFF.toByte(), 0xF9.toByte(), 0x92.toByte(), 0x00)
|
||||||
|
val nextFrame = buildFrame(nextHeader, 418 - 4)
|
||||||
|
val data = frame + nextFrame
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals(2, frames.size)
|
||||||
|
assertEquals(418, frames[0].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emitsCompleteFramesViaCallback() {
|
||||||
|
val header = buildMpeg1Layer3Header(8, 0, false)
|
||||||
|
val frame1 = buildFrame(header, 417 - 4)
|
||||||
|
val frame2 = buildFrame(header, 417 - 4)
|
||||||
|
val data = frame1 + frame2
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals(2, frames.size)
|
||||||
|
assertEquals(417, frames[0].size)
|
||||||
|
assertEquals(417, frames[1].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesTruncatedFrameAtEnd() {
|
||||||
|
val header = byteArrayOf(0xFF.toByte(), 0xF9.toByte(), 0x80.toByte(), 0x00)
|
||||||
|
// Only half the body (413/2 ≈ 206 bytes instead of 413)
|
||||||
|
val truncatedBody = ByteArray(206) { 0 }
|
||||||
|
val data = header + truncatedBody
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals(0, frames.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validatesFrameHeaderRejectsInvalidBitrate() {
|
||||||
|
// Bitrate index 0 (free) - invalid
|
||||||
|
val header0 = buildMpeg1Layer3Header(0, 0, false)
|
||||||
|
val frame0 = buildFrame(header0, 100)
|
||||||
|
val next0 = buildFrame(header0, 100)
|
||||||
|
val data0 = frame0 + next0
|
||||||
|
|
||||||
|
val frames0 = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames0.add(it) }.feed(data0)
|
||||||
|
assertEquals(0, frames0.size)
|
||||||
|
|
||||||
|
// Bitrate index 15 (bad) - invalid
|
||||||
|
val header15 = buildMpeg1Layer3Header(15, 0, false)
|
||||||
|
val frame15 = buildFrame(header15, 100)
|
||||||
|
val next15 = buildFrame(header15, 100)
|
||||||
|
val data15 = frame15 + next15
|
||||||
|
|
||||||
|
val frames15 = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames15.add(it) }.feed(data15)
|
||||||
|
assertEquals(0, frames15.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesIncrementalFeeding() {
|
||||||
|
val header = buildMpeg1Layer3Header(8, 0, false)
|
||||||
|
val frame = buildFrame(header, 417 - 4)
|
||||||
|
val nextFrame = buildFrame(header, 417 - 4)
|
||||||
|
val data = frame + nextFrame
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
val sync = Mp3FrameSync { frames.add(it) }
|
||||||
|
var offset = 0
|
||||||
|
val chunkSize = 10
|
||||||
|
while (offset < data.size) {
|
||||||
|
val len = minOf(chunkSize, data.size - offset)
|
||||||
|
sync.feed(data, offset, len)
|
||||||
|
offset += len
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(2, frames.size)
|
||||||
|
assertEquals(417, frames[0].size)
|
||||||
|
assertEquals(417, frames[1].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handlesMpeg2Frames() {
|
||||||
|
// MPEG2 Layer3, 128kbps, 22050Hz → frameSize = (72 * 128000 / 22050) + padding
|
||||||
|
// 72 * 128000 / 22050 = 417.95... → 417 without padding, 418 with
|
||||||
|
val header = buildMpeg2Layer3Header(9, 0, false) // index 9 = 128kbps for MPEG2
|
||||||
|
val expectedSize = (72 * 128 * 1000 / 22050) // = 417
|
||||||
|
val bodySize = expectedSize - 4
|
||||||
|
val frame = buildFrame(header, bodySize)
|
||||||
|
val nextFrame = buildFrame(header, bodySize)
|
||||||
|
val data = frame + nextFrame
|
||||||
|
|
||||||
|
val frames = mutableListOf<ByteArray>()
|
||||||
|
Mp3FrameSync { frames.add(it) }.feed(data)
|
||||||
|
|
||||||
|
assertEquals(2, frames.size)
|
||||||
|
assertEquals(expectedSize, frames[0].size)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user