diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7ad733..5638f52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,7 +18,7 @@ android { minSdk = 28 targetSdk = 35 versionCode = 1 - versionName = "1.0" + versionName = "1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt index 476ccce..f9b675e 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt @@ -39,6 +39,10 @@ class AudioEngine( @Volatile private var timedStream: TimedInputStream? = null private var presentationTimeUs = 0L + private var frameDurationUs = 0L + private var framesPerSecond = 0 + private var configuredSampleRate = 0 + private var configuredChannelCount = 0 fun start() { Log.i(TAG, "start() url=$url") @@ -89,52 +93,78 @@ class AudioEngine( val tStream = TimedInputStream(connection.inputStream!!) timedStream = tStream - val sampleRate = 44100 - val channelConfig = AudioFormat.CHANNEL_OUT_STEREO - val encoding = AudioFormat.ENCODING_PCM_16BIT - val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding) - - val audioTrack = AudioTrack.Builder() - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - .setAudioFormat( - AudioFormat.Builder() - .setSampleRate(sampleRate) - .setChannelMask(channelConfig) - .setEncoding(encoding) - .build() - ) - .setBufferSizeInBytes(minBuf) - .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) - .setTransferMode(AudioTrack.MODE_STREAM) - .build() - - val codec = MediaCodec.createDecoderByType("audio/mpeg") - val format = MediaFormat.createAudioFormat("audio/mpeg", sampleRate, 2) - codec.configure(format, null, null, 0) - codec.start() - audioTrack.play() - - currentAudioTrack = audioTrack - currentCodec = codec + var audioTrack: AudioTrack? = null + var codec: MediaCodec? = null + var ringBuffer: RingBuffer? = null _events.tryEmit(AudioEngineEvent.Started) connection.streamInfo?.let { _events.tryEmit(AudioEngineEvent.StreamInfoReceived(it)) } - try { - val bufferFrames = if (bufferMs > 0) (bufferMs / 26).coerceAtLeast(1) else 0 + fun initAudioOutput(format: Mp3FrameInfo) { + frameDurationUs = format.samplesPerFrame.toLong() * 1_000_000 / format.sampleRate + framesPerSecond = (1_000_000L / frameDurationUs).toInt() + configuredSampleRate = format.sampleRate + configuredChannelCount = format.channelCount - val ringBuffer = RingBuffer(bufferFrames) { mp3Frame -> - decodeToPcm(codec, mp3Frame, audioTrack) + Log.i(TAG, "Detected format: ${format.sampleRate} Hz, ${format.channelCount}ch, " + + "${format.samplesPerFrame} samples/frame, frameDuration=${frameDurationUs}us") + + val channelConfig = if (format.channelCount == 1) + AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO + val encoding = AudioFormat.ENCODING_PCM_16BIT + val minBuf = AudioTrack.getMinBufferSize(format.sampleRate, channelConfig, encoding) + + audioTrack = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(format.sampleRate) + .setChannelMask(channelConfig) + .setEncoding(encoding) + .build() + ) + .setBufferSizeInBytes(minBuf) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + + codec = MediaCodec.createDecoderByType("audio/mpeg") + val mediaFormat = MediaFormat.createAudioFormat( + "audio/mpeg", format.sampleRate, format.channelCount + ) + codec!!.configure(mediaFormat, null, null, 0) + codec!!.start() + audioTrack!!.play() + + currentAudioTrack = audioTrack + currentCodec = codec + + val frameDurationMs = (frameDurationUs / 1000).toInt().coerceAtLeast(1) + val bufferFrames = if (bufferMs > 0) (bufferMs / frameDurationMs).coerceAtLeast(1) else 0 + + ringBuffer = RingBuffer(bufferFrames) { mp3Frame -> + decodeToPcm(codec!!, mp3Frame, audioTrack!!) } currentRingBuffer = ringBuffer + _events.tryEmit(AudioEngineEvent.AudioFormatDetected( + format.sampleRate, format.channelCount + )) + } + + try { val frameSync = Mp3FrameSync { mp3Frame -> - ringBuffer.write(mp3Frame) + if (audioTrack == null) { + val format = Mp3FrameSync.parseFrameInfo(mp3Frame) + ?: Mp3FrameInfo(44100, 2, 1152) + initAudioOutput(format) + } + ringBuffer!!.write(mp3Frame) } val icyParser = IcyParser( @@ -146,7 +176,7 @@ class AudioEngine( icyParser.readAll() - ringBuffer.flush() + ringBuffer?.flush() frameSync.flush() _events.tryEmit(AudioEngineEvent.Error(EngineError.StreamEnded)) } finally { @@ -154,10 +184,10 @@ class AudioEngine( currentRingBuffer = null currentCodec = null currentAudioTrack = null - codec.stop() - codec.release() - audioTrack.stop() - audioTrack.release() + codec?.stop() + codec?.release() + audioTrack?.stop() + audioTrack?.release() connection.close() } } @@ -166,8 +196,9 @@ class AudioEngine( if (catchingUp) { catchupFramesSkipped++ val lastReadMs = timedStream?.lastReadDurationMs ?: 0L - if (lastReadMs >= CATCHUP_THRESHOLD_MS || catchupFramesSkipped >= MAX_CATCHUP_FRAMES) { - if (catchupFramesSkipped >= MAX_CATCHUP_FRAMES) { + val maxCatchupFrames = framesPerSecond * 5 + if (lastReadMs >= CATCHUP_THRESHOLD_MS || catchupFramesSkipped >= maxCatchupFrames) { + if (catchupFramesSkipped >= maxCatchupFrames) { Log.w(TAG, "Catchup cap reached after $catchupFramesSkipped frames, starting playback") } catchingUp = false @@ -178,7 +209,7 @@ class AudioEngine( val skips = pendingSkips.getAndSet(0) if (skips > 0) { - val framesToDrop = skips * FRAMES_PER_SECOND + val framesToDrop = skips * framesPerSecond currentRingBuffer?.drop(framesToDrop) audioTrack.pause() audioTrack.flush() @@ -194,12 +225,24 @@ class AudioEngine( inBuf.clear() inBuf.put(mp3Frame) codec.queueInputBuffer(inIdx, 0, mp3Frame.size, presentationTimeUs, 0) - presentationTimeUs += FRAME_DURATION_US + presentationTimeUs += frameDurationUs } val bufferInfo = MediaCodec.BufferInfo() var outIdx = codec.dequeueOutputBuffer(bufferInfo, 1000) while (outIdx >= 0) { + if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + val outFormat = codec.outputFormat + val outRate = outFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) + val outChannels = outFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + Log.i(TAG, "Decoder output format: ${outRate} Hz, ${outChannels}ch") + if (outRate != configuredSampleRate || outChannels != configuredChannelCount) { + Log.w(TAG, "Decoder output ($outRate Hz, ${outChannels}ch) differs from " + + "AudioTrack ($configuredSampleRate Hz, ${configuredChannelCount}ch)") + } + outIdx = codec.dequeueOutputBuffer(bufferInfo, 0) + continue + } val outBuf = codec.getOutputBuffer(outIdx)!! outBuf.position(bufferInfo.offset) outBuf.limit(bufferInfo.offset + bufferInfo.size) @@ -222,10 +265,7 @@ class AudioEngine( companion object { private const val TAG = "AudioEngine" - private const val FRAMES_PER_SECOND = 38 private const val CATCHUP_THRESHOLD_MS = 30L - private const val MAX_CATCHUP_FRAMES = FRAMES_PER_SECOND * 5 // 5 seconds max skip - private const val FRAME_DURATION_US = 26_122L // 1152 samples at 44100 Hz } } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt index ac5217b..0aa4fcf 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineEvent.kt @@ -3,6 +3,7 @@ package xyz.cottongin.radio247.audio sealed interface AudioEngineEvent { data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent data class StreamInfoReceived(val streamInfo: StreamInfo) : AudioEngineEvent + data class AudioFormatDetected(val sampleRate: Int, val channelCount: Int) : AudioEngineEvent data class Error(val cause: EngineError) : AudioEngineEvent data object Started : AudioEngineEvent data object Stopped : AudioEngineEvent diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt b/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt index ce8adc4..114cf67 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt @@ -2,8 +2,17 @@ package xyz.cottongin.radio247.audio import java.io.ByteArrayOutputStream +data class Mp3FrameInfo( + val sampleRate: Int, + val channelCount: Int, + val samplesPerFrame: Int +) + private data class ParsedHeader( - val frameSize: Int + val frameSize: Int, + val sampleRate: Int, + val channelCount: Int, + val samplesPerFrame: Int ) // MPEG1 Layer 3 bitrates (kbps), index 0 and 15 invalid @@ -22,6 +31,9 @@ class Mp3FrameSync( ) { private val buffer = ByteArrayOutputStream() + var detectedFormat: Mp3FrameInfo? = null + private set + fun feed(data: ByteArray, offset: Int = 0, length: Int = data.size) { buffer.write(data, offset, length) processBuffer() @@ -60,6 +72,14 @@ class Mp3FrameSync( } } + if (detectedFormat == null) { + detectedFormat = Mp3FrameInfo( + sampleRate = header.sampleRate, + channelCount = header.channelCount, + samplesPerFrame = header.samplesPerFrame + ) + } + val frame = bytes.copyOfRange(pos, pos + frameSize) onFrame(frame) pos += frameSize @@ -77,6 +97,46 @@ class Mp3FrameSync( return b0 == 0xFF && (b1 and 0xE0) == 0xE0 } + companion object { + fun parseFrameInfo(frame: ByteArray): Mp3FrameInfo? { + if (frame.size < 4) return null + val b0 = frame[0].toInt() and 0xFF + val b1 = frame[1].toInt() and 0xFF + if (b0 != 0xFF || (b1 and 0xE0) != 0xE0) return null + + val b2 = frame[2].toInt() and 0xFF + val b3 = frame[3].toInt() and 0xFF + + val header = (b0 shl 24) or (b1 shl 16) or (b2 shl 8) or b3 + val mpegVersion = (header ushr 19) and 0x03 + val layer = (header ushr 17) and 0x03 + if (mpegVersion == 1 || layer == 0) return null + + val sampleRateIndex = (b2 shr 2) and 0x03 + if (sampleRateIndex == 3) 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 channelMode = (b3 shr 6) and 0x03 + val channelCount = if (channelMode == 3) 1 else 2 + + val samplesPerFrame = when (layer) { + 3 -> 384 // Layer I + 2 -> 1152 // Layer II + 1 -> if (mpegVersion == 3) 1152 else 576 // Layer III + else -> return null + } + + return Mp3FrameInfo(sampleRate, channelCount, samplesPerFrame) + } + } + 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 @@ -84,8 +144,8 @@ class Mp3FrameSync( ((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 b3 = bytes[pos + 3].toInt() and 0xFF val mpegVersion = (header ushr 19) and 0x03 val layer = (header ushr 17) and 0x03 @@ -114,6 +174,16 @@ class Mp3FrameSync( } if (sampleRate == 0) return null + val channelMode = (b3 shr 6) and 0x03 + val channelCount = if (channelMode == 3) 1 else 2 + + val samplesPerFrame = when (layer) { + 3 -> 384 // Layer I + 2 -> 1152 // Layer II + 1 -> if (mpegVersion == 3) 1152 else 576 // Layer III + else -> return null + } + val frameSize = when (layer) { 1 -> { // Layer III when (mpegVersion) { @@ -134,6 +204,6 @@ class Mp3FrameSync( } if (frameSize <= 0) return null - return ParsedHeader(frameSize) + return ParsedHeader(frameSize, sampleRate, channelCount, samplesPerFrame) } } diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt index c78f141..cb61cb4 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt @@ -13,7 +13,9 @@ class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(me data class StreamInfo( val bitrate: Int?, val ssl: Boolean, - val contentType: String? + val contentType: String?, + val sampleRate: Int? = null, + val channelCount: Int? = null ) class StreamConnection(private val url: String) { diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt index 402c97e..349cb3e 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -31,6 +31,7 @@ import xyz.cottongin.radio247.audio.AudioEngine import xyz.cottongin.radio247.audio.AudioEngineEvent import xyz.cottongin.radio247.audio.EngineError import xyz.cottongin.radio247.audio.IcyMetadata +import xyz.cottongin.radio247.audio.StreamInfo import xyz.cottongin.radio247.data.db.ConnectionSpanDao import xyz.cottongin.radio247.data.db.ListeningSessionDao import xyz.cottongin.radio247.data.db.MetadataSnapshotDao @@ -577,6 +578,18 @@ class RadioPlaybackService : MediaLibraryService() { transition(playingState.copy(streamInfo = event.streamInfo)) } } + is AudioEngineEvent.AudioFormatDetected -> { + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + val updated = (playingState.streamInfo ?: StreamInfo( + bitrate = null, ssl = false, contentType = null + )).copy( + sampleRate = event.sampleRate, + channelCount = event.channelCount + ) + transition(playingState.copy(streamInfo = updated)) + } + } is AudioEngineEvent.Error -> { engine?.stop() engine = null diff --git a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt index 226885f..e3f146c 100644 --- a/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt +++ b/app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt @@ -668,6 +668,24 @@ private fun QualityBadge( } Text(text = codec, style = badgeStyle, color = dim) } + val hasPrevBadge = streamInfo.bitrate != null || codec != null + if (streamInfo.sampleRate != null) { + if (hasPrevBadge) { + Text(text = " \u00B7 ", style = badgeStyle, color = dim) + } + val rateLabel = when (streamInfo.sampleRate) { + 44100 -> "44.1 kHz" + 22050 -> "22.05 kHz" + 48000 -> "48 kHz" + 32000 -> "32 kHz" + 24000 -> "24 kHz" + 16000 -> "16 kHz" + 11025 -> "11.025 kHz" + else -> "${streamInfo.sampleRate} Hz" + } + val channelLabel = if (streamInfo.channelCount == 1) "Mono" else "Stereo" + Text(text = "$rateLabel $channelLabel", style = badgeStyle, color = dim) + } if (streamInfo.ssl) { Spacer(modifier = Modifier.width(4.dp)) Icon( diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt index 6633adc..0211fca 100644 --- a/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt +++ b/app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt @@ -2,38 +2,43 @@ package xyz.cottongin.radio247.audio import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull 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. + * Build MPEG1 Layer3 header: 0xFF, (0xE0 | version<<3 | layer<<1 | crc), byte2, byte3 + * MPEG1=11, Layer3=01, noCRC=1. Bitrate in 15-12, sample in 11-10, padding in bit 9. + * channelMode: 0=Stereo, 1=JointStereo, 2=DualChannel, 3=Mono */ private fun buildMpeg1Layer3Header( bitrateIndex: Int, sampleRateIndex: Int, - padding: Boolean + padding: Boolean, + channelMode: Int = 0 ): ByteArray { - // Byte 0: sync. Byte 1: 0xFB = sync + MPEG1(11) + LayerIII(01) + noCRC(1) val byte1 = 0xFB.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) + val byte3 = (channelMode shl 6).toByte() + return byteArrayOf(0xFF.toByte(), byte1, byte2, byte3) } /** * Build MPEG2 Layer3 header. MPEG2 = 10, Layer3 = 01. + * channelMode: 0=Stereo, 1=JointStereo, 2=DualChannel, 3=Mono */ private fun buildMpeg2Layer3Header( bitrateIndex: Int, sampleRateIndex: Int, - padding: Boolean + padding: Boolean, + channelMode: Int = 0 ): ByteArray { - // Byte 1: 0xF3 = sync + MPEG2(10) + LayerIII(01) + noCRC(1) val byte1 = 0xF3.toByte() val byte2 = ((bitrateIndex shl 4) or (sampleRateIndex shl 2) or (if (padding) 2 else 0)).toByte() - return byteArrayOf(0xFF.toByte(), byte1, byte2, 0x00) + val byte3 = (channelMode shl 6).toByte() + return byteArrayOf(0xFF.toByte(), byte1, byte2, byte3) } private fun buildFrame(header: ByteArray, bodySize: Int): ByteArray { @@ -207,4 +212,119 @@ class Mp3FrameSyncTest { assertEquals(2, frames.size) assertEquals(expectedSize, frames[0].size) } + + @Test + fun detectedFormatForMpeg1Stereo44100() { + val header = buildMpeg1Layer3Header(9, 0, false, channelMode = 1) + val frame = buildFrame(header, 417 - 4) + val nextFrame = buildFrame(header, 417 - 4) + + val sync = Mp3FrameSync { } + sync.feed(frame + nextFrame) + + val format = sync.detectedFormat + assertNotNull(format) + assertEquals(44100, format!!.sampleRate) + assertEquals(2, format.channelCount) + assertEquals(1152, format.samplesPerFrame) + } + + @Test + fun detectedFormatForMpeg1Mono48000() { + // sampleRateIndex=1 → 48000 Hz, channelMode=3 → Mono + val header = buildMpeg1Layer3Header(9, 1, false, channelMode = 3) + val expectedSize = 144 * 128 * 1000 / 48000 // = 384 + val frame = buildFrame(header, expectedSize - 4) + val nextFrame = buildFrame(header, expectedSize - 4) + + val sync = Mp3FrameSync { } + sync.feed(frame + nextFrame) + + val format = sync.detectedFormat + assertNotNull(format) + assertEquals(48000, format!!.sampleRate) + assertEquals(1, format.channelCount) + assertEquals(1152, format.samplesPerFrame) + } + + @Test + fun detectedFormatForMpeg2Mono22050() { + // MPEG2 Layer3, 128kbps (index 12), 22050 Hz (index 0), mono + val header = buildMpeg2Layer3Header(12, 0, false, channelMode = 3) + val expectedSize = 72 * 128 * 1000 / 22050 // = 417 + val frame = buildFrame(header, expectedSize - 4) + val nextFrame = buildFrame(header, expectedSize - 4) + + val sync = Mp3FrameSync { } + sync.feed(frame + nextFrame) + + val format = sync.detectedFormat + assertNotNull(format) + assertEquals(22050, format!!.sampleRate) + assertEquals(1, format.channelCount) + assertEquals(576, format.samplesPerFrame) + } + + @Test + fun detectedFormatIsNullBeforeFirstFrame() { + val sync = Mp3FrameSync { } + assertNull(sync.detectedFormat) + } + + @Test + fun parseFrameInfoMpeg1Stereo44100() { + val header = buildMpeg1Layer3Header(9, 0, false, channelMode = 0) + val frame = buildFrame(header, 417 - 4) + + val info = Mp3FrameSync.parseFrameInfo(frame) + assertNotNull(info) + assertEquals(44100, info!!.sampleRate) + assertEquals(2, info.channelCount) + assertEquals(1152, info.samplesPerFrame) + } + + @Test + fun parseFrameInfoMpeg2Mono22050() { + val header = buildMpeg2Layer3Header(12, 0, false, channelMode = 3) + val frame = buildFrame(header, 417 - 4) + + val info = Mp3FrameSync.parseFrameInfo(frame) + assertNotNull(info) + assertEquals(22050, info!!.sampleRate) + assertEquals(1, info.channelCount) + assertEquals(576, info.samplesPerFrame) + } + + @Test + fun parseFrameInfoReturnsNullForGarbage() { + val garbage = ByteArray(100) { 0x42 } + assertNull(Mp3FrameSync.parseFrameInfo(garbage)) + } + + @Test + fun parseFrameInfoReturnsNullForTooShort() { + assertNull(Mp3FrameSync.parseFrameInfo(byteArrayOf(0xFF.toByte(), 0xFB.toByte()))) + } + + @Test + fun parseFrameInfoJointStereoIsTwoChannels() { + // Joint stereo (channelMode=1) should report 2 channels + val header = buildMpeg1Layer3Header(9, 0, false, channelMode = 1) + val frame = buildFrame(header, 417 - 4) + + val info = Mp3FrameSync.parseFrameInfo(frame) + assertNotNull(info) + assertEquals(2, info!!.channelCount) + } + + @Test + fun parseFrameInfoDualChannelIsTwoChannels() { + // Dual channel (channelMode=2) should report 2 channels + val header = buildMpeg1Layer3Header(9, 0, false, channelMode = 2) + val frame = buildFrame(header, 417 - 4) + + val info = Mp3FrameSync.parseFrameInfo(frame) + assertNotNull(info) + assertEquals(2, info!!.channelCount) + } }