From 815649c456a9b0214619f288d6126b2d8cf04869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Mi=C5=9B?= Date: Fri, 3 Jul 2026 06:26:37 +0200 Subject: [PATCH 1/2] Write FLAC STREAMINFO MD5 checksum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patryk Miś --- .../java/com/chiller3/bcr/format/Container.kt | 13 +++ .../com/chiller3/bcr/format/FlacContainer.kt | 85 ++++++++++++------- .../chiller3/bcr/format/MediaCodecEncoder.kt | 2 + 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/chiller3/bcr/format/Container.kt b/app/src/main/java/com/chiller3/bcr/format/Container.kt index ae7a8d2e0..8dd70beaa 100644 --- a/app/src/main/java/com/chiller3/bcr/format/Container.kt +++ b/app/src/main/java/com/chiller3/bcr/format/Container.kt @@ -52,3 +52,16 @@ interface Container { */ fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) } + +/** + * Optional hook for containers that need metadata derived from the raw PCM input. + */ +interface InputSampleConsumer { + /** + * Consume the same PCM bytes that are submitted to the encoder. + * + * The buffer's position and limit bound the bytes to consume. Implementations may change this + * buffer's position. + */ + fun consumeInputSamples(byteBuffer: ByteBuffer, frameSize: Int) +} diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt index 46ab2f28d..2daf672aa 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt @@ -16,9 +16,11 @@ import com.chiller3.bcr.writeFully import java.io.FileDescriptor import java.io.IOException import java.nio.ByteBuffer +import java.security.MessageDigest /** - * Dummy FLAC container wrapper that updates the STREAMINFO duration field when complete. + * Dummy FLAC container wrapper that updates the STREAMINFO sample count and MD5 fields when + * complete. * * [MediaCodec] already produces a well-formed FLAC file, thus this class writes those samples * directly to the output file. @@ -26,10 +28,13 @@ import java.nio.ByteBuffer * @param fd Output file descriptor. This class does not take ownership of it and it should not * be touched outside of this class until [stop] is called and returns. */ -class FlacContainer(private val fd: FileDescriptor) : Container { +class FlacContainer(private val fd: FileDescriptor) : Container, InputSampleConsumer { private var isStarted = false private var lastPresentationTimeUs = -1L + private var numFrames = 0uL + private var receivedEof = false private var track = -1 + private val md5 = MessageDigest.getInstance("MD5") override fun start() { if (isStarted) { @@ -49,9 +54,9 @@ class FlacContainer(private val fd: FileDescriptor) : Container { isStarted = false - if (lastPresentationTimeUs >= 0) { - Log.d(TAG, "Setting duration field in header") - setHeaderDuration() + if (receivedEof) { + Log.d(TAG, "Setting sample count and MD5 fields in header") + setStreamInfoFields(md5.digest()) } } @@ -86,24 +91,35 @@ class FlacContainer(private val fd: FileDescriptor) : Container { writeFully(fd, byteBuffer) if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + receivedEof = true lastPresentationTimeUs = bufferInfo.presentationTimeUs - Log.d(TAG, "Received EOF; final presentation timestamp: $lastPresentationTimeUs") + Log.d( + TAG, + "Received EOF; final presentation timestamp: $lastPresentationTimeUs; " + + "input frames: $numFrames" + ) } } + override fun consumeInputSamples(byteBuffer: ByteBuffer, frameSize: Int) { + // FLAC STREAMINFO stores an MD5 checksum of the unencoded interleaved PCM samples. + numFrames += (byteBuffer.remaining() / frameSize).toULong() + md5.update(byteBuffer) + } + /** - * Write the frame count to the STREAMINFO metadata block of a flac file. + * Write the sample count and PCM MD5 checksum to the STREAMINFO metadata block of a FLAC file. * - * @throws IOException If FLAC metadata does not appear to be valid or if the number of frames - * computed from [lastPresentationTimeUs] exceeds the bounds of a 36-bit integer + * @throws IOException If FLAC metadata does not appear to be valid or if [numFrames] exceeds + * the bounds of a 36-bit integer */ - private fun setHeaderDuration() { + private fun setStreamInfoFields(md5: ByteArray) { Os.lseek(fd, 0, OsConstants.SEEK_SET) // Magic (4 bytes) // + metadata block header (4 bytes) // + streaminfo block (34 bytes) - val buf = UByteArray(42) + val buf = UByteArray(STREAMINFO_END_OFFSET) if (Os.read(fd, buf.asByteArray(), 0, buf.size) != buf.size) { throw IOException("EOF reached when reading FLAC headers") @@ -126,32 +142,41 @@ class FlacContainer(private val fd: FileDescriptor) : Container { throw IOException("STREAMINFO block is too small") } - // Sample rate field is a 20-bit integer at the 18th byte - val sampleRate = buf[18].toUInt().shl(12) or - buf[19].toUInt().shl(4) or - buf[20].toUInt().shr(4) - - // This underestimates the duration by a miniscule amount because it doesn't account for the - // duration of the final write - val frames = lastPresentationTimeUs.toULong() * sampleRate / 1_000_000uL - - if (frames >= 2uL.shl(36)) { - throw IOException("Frame count cannot be represented in FLAC: $frames") + if (numFrames >= 2uL.shl(36)) { + throw IOException("Frame count cannot be represented in FLAC: $numFrames") } // Total samples field is a 36-bit integer that begins 4 bits into the 21st byte - buf[21] = (buf[21] and 0xf0u) or (frames.shr(32) and 0xfu).toUByte() - buf[22] = (frames.shr(24) and 0xffu).toUByte() - buf[23] = (frames.shr(16) and 0xffu).toUByte() - buf[24] = (frames.shr(8) and 0xffu).toUByte() - buf[25] = (frames and 0xffu).toUByte() - - Os.lseek(fd, 21, OsConstants.SEEK_SET) - writeFully(fd, buf.asByteArray(), 21, 5) + buf[STREAMINFO_SAMPLE_COUNT_OFFSET] = + (buf[STREAMINFO_SAMPLE_COUNT_OFFSET] and 0xf0u) or + (numFrames.shr(32) and 0xfu).toUByte() + buf[STREAMINFO_SAMPLE_COUNT_OFFSET + 1] = (numFrames.shr(24) and 0xffu).toUByte() + buf[STREAMINFO_SAMPLE_COUNT_OFFSET + 2] = (numFrames.shr(16) and 0xffu).toUByte() + buf[STREAMINFO_SAMPLE_COUNT_OFFSET + 3] = (numFrames.shr(8) and 0xffu).toUByte() + buf[STREAMINFO_SAMPLE_COUNT_OFFSET + 4] = (numFrames and 0xffu).toUByte() + + if (md5.size != STREAMINFO_MD5_SIZE) { + throw IOException("Invalid MD5 digest size: ${md5.size}") + } + for (i in md5.indices) { + buf[STREAMINFO_MD5_OFFSET + i] = md5[i].toUByte() + } + + Os.lseek(fd, STREAMINFO_SAMPLE_COUNT_OFFSET.toLong(), OsConstants.SEEK_SET) + writeFully( + fd, + buf.asByteArray(), + STREAMINFO_SAMPLE_COUNT_OFFSET, + STREAMINFO_END_OFFSET - STREAMINFO_SAMPLE_COUNT_OFFSET, + ) } companion object { private val TAG = FlacContainer::class.java.simpleName private val FLAC_MAGIC = ubyteArrayOf(0x66u, 0x4cu, 0x61u, 0x43u) // fLaC + private const val STREAMINFO_SAMPLE_COUNT_OFFSET = 21 + private const val STREAMINFO_MD5_OFFSET = 26 + private const val STREAMINFO_MD5_SIZE = 16 + private const val STREAMINFO_END_OFFSET = STREAMINFO_MD5_OFFSET + STREAMINFO_MD5_SIZE } } diff --git a/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt b/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt index f2a097700..e4703d159 100644 --- a/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt +++ b/app/src/main/java/com/chiller3/bcr/format/MediaCodecEncoder.kt @@ -27,6 +27,7 @@ class MediaCodecEncoder( ) : Encoder(mediaFormat) { private val codec = createCodec(mediaFormat) private val bufferInfo = MediaCodec.BufferInfo() + private val inputSampleConsumer = container as? InputSampleConsumer private var trackIndex = -1 override fun start() = @@ -51,6 +52,7 @@ class MediaCodecEncoder( // Temporarily change buffer limit to avoid overflow val oldLimit = buffer.limit() buffer.limit(buffer.position() + toCopy) + inputSampleConsumer?.consumeInputSamples(buffer.slice(), frameSize) inputBuffer.put(buffer) buffer.limit(oldLimit) From 1c315cd033d6ca86fc0ad9bdc24a102bc8a2ea63 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Fri, 3 Jul 2026 12:27:20 -0400 Subject: [PATCH 2/2] FlacContainer: Remove unused lastPresentationTimeUs Signed-off-by: Andrew Gunnerson --- app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt index 2daf672aa..0b072467b 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt @@ -30,7 +30,6 @@ import java.security.MessageDigest */ class FlacContainer(private val fd: FileDescriptor) : Container, InputSampleConsumer { private var isStarted = false - private var lastPresentationTimeUs = -1L private var numFrames = 0uL private var receivedEof = false private var track = -1 @@ -92,10 +91,9 @@ class FlacContainer(private val fd: FileDescriptor) : Container, InputSampleCons if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { receivedEof = true - lastPresentationTimeUs = bufferInfo.presentationTimeUs Log.d( TAG, - "Received EOF; final presentation timestamp: $lastPresentationTimeUs; " + + "Received EOF; final presentation timestamp: ${bufferInfo.presentationTimeUs}; " + "input frames: $numFrames" ) }