diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt b/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt new file mode 100644 index 0000000..ce8adc4 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt @@ -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) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt new file mode 100644 index 0000000..83c3bd4 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + Mp3FrameSync { frames.add(it) }.feed(data) + + assertEquals(2, frames.size) + assertEquals(expectedSize, frames[0].size) + } +}