feat: add MP3 frame synchronizer with re-sync and validation

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 01:51:24 -04:00
parent 1a3a58b8f0
commit 3d4d163508
2 changed files with 348 additions and 0 deletions

View 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)
}
}