From 3d3eb821800a114a8768539650cbdfd3a0bf17c9 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Tue, 24 May 2022 23:02:36 -0400 Subject: [PATCH] Add initial support for configurable codecs This commit adds the initial framework for allowing the configuration of output codecs. There are three codec choices available: * FLAC: (default) lossless, compression level 0-8 * OGG/Opus: lossy, bitrate 6-510kbps, Android 10+ only * M4A/AAC: lossy, HE-AAC bitrate 24-32kbps, AAC-LC bitrate 32-128kbps While all three codecs are implemented, RecorderThread is currently hardcoded to use the default. The next step is to save and load the codec configuration from SharedPreferences and the finally add the user interface. Issue: #21 Signed-off-by: Andrew Gunnerson --- .../java/com/chiller3/bcr/RecorderThread.kt | 197 +++++------------- .../java/com/chiller3/bcr/codec/AacCodec.kt | 36 ++++ .../main/java/com/chiller3/bcr/codec/Codec.kt | 109 ++++++++++ .../com/chiller3/bcr/codec/CodecParamType.kt | 8 + .../java/com/chiller3/bcr/codec/Container.kt | 54 +++++ .../java/com/chiller3/bcr/codec/FlacCodec.kt | 25 +++ .../com/chiller3/bcr/codec/FlacContainer.kt | 129 ++++++++++++ .../chiller3/bcr/codec/MediaMuxerContainer.kt | 39 ++++ .../java/com/chiller3/bcr/codec/OpusCodec.kt | 29 +++ 9 files changed, 484 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/Codec.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/Container.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt create mode 100644 app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index f5fad87ce..f1285db26 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -1,24 +1,22 @@ -@file:Suppress("OPT_IN_IS_NOT_ENABLED") -@file:OptIn(ExperimentalUnsignedTypes::class) - package com.chiller3.bcr import android.annotation.SuppressLint import android.content.Context -import android.media.* +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaCodec +import android.media.MediaRecorder import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor -import android.system.Os -import android.system.OsConstants import android.telecom.Call import android.telecom.PhoneAccount import android.util.Log import androidx.documentfile.provider.DocumentFile -import java.io.FileDescriptor +import com.chiller3.bcr.codec.Codec +import com.chiller3.bcr.codec.Container import java.io.IOException import java.lang.Integer.min -import java.nio.ByteBuffer import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -60,6 +58,9 @@ class RecorderThread( } private val displayName: String? = call.details.callerDisplayName + // TODO + private val codec = Codec.default + init { Log.i(TAG, "[${id}] Created thread for call: $call") } @@ -136,8 +137,8 @@ class RecorderThread( data class OutputFile(val uri: Uri, val pfd: ParcelFileDescriptor) /** - * Try to create and open a new FLAC file in the user-chosen directory if possible and fall back - * to the default output directory if not. [name] should not contain a file extension. + * Try to create and open a new output file in the user-chosen directory if possible and fall + * back to the default output directory if not. [name] should not contain a file extension. * * @throws IOException if the file could not be created in either directory */ @@ -160,13 +161,13 @@ class RecorderThread( } /** - * Create and open a new FLAC file with name [name] inside [directory]. [name] should not - * contain a file extension. + * Create and open a new output file with name [name] inside [directory]. [name] should not + * contain a file extension. The file extension is automatically determined from [codec]. * * @throws IOException if file creation or opening fails */ private fun openOutputFileInDir(directory: DocumentFile, name: String): OutputFile { - val file = directory.createFile(MediaFormat.MIMETYPE_AUDIO_FLAC, name) + val file = directory.createFile(codec.mimeTypeContainer, name) ?: throw IOException("Failed to create file in ${directory.uri}") val pfd = context.contentResolver.openFileDescriptor(file.uri, "rw") ?: throw IOException("Failed to open file at ${file.uri}") @@ -197,19 +198,26 @@ class RecorderThread( audioRecord.startRecording() try { - val codec = getFlacCodec(audioFormat, audioRecord.sampleRate) + val mediaFormat = codec.getMediaFormat(audioFormat, audioRecord.sampleRate) + val mediaCodec = codec.getMediaCodec(mediaFormat) try { - codec.start() + mediaCodec.start() try { - val frames = encodeLoop(audioRecord, codec, pfd.fileDescriptor) - setFlacHeaderDuration(pfd.fileDescriptor, frames) + val container = codec.getContainer(pfd.fileDescriptor) + + try { + encodeLoop(audioRecord, mediaCodec, container) + container.stop() + } finally { + container.release() + } } finally { - codec.stop() + mediaCodec.stop() } } finally { - codec.release() + mediaCodec.release() } } finally { audioRecord.stop() @@ -223,40 +231,36 @@ class RecorderThread( * Main loop for encoding captured raw audio into an output file. * * The loop runs forever until [cancel] is called. At that point, no further data will be read - * from [audioRecord] and the remaining output data from [codec] will be written to [fd]. - * If [audioRecord] fails to capture data, the loop will behave as if [cancel] was called - * (ie. abort, but ensuring that the output file is valid). + * from [audioRecord] and the remaining output data from [mediaCodec] will be written to + * [container]. If [audioRecord] fails to capture data, the loop will behave as if [cancel] was + * called (ie. abort, but ensuring that the output file is valid). * * The approximate amount of time to cancel reading from the audio source is 100ms. This does * not include the time required to write out the remaining encoded data to the output file. * * @param audioRecord [AudioRecord.startRecording] must have been called - * @param codec [MediaCodec.start] must have been called - * @param fd Will be truncated to 0 bytes before writing + * @param mediaCodec [MediaCodec.start] must have been called + * @param container [Container.start] must *not* have been called. It will be left in a started + * state after this method returns. * * @throws MediaCodec.CodecException if the codec encounters an error - * - * @return The number of frames that were recorded */ - private fun encodeLoop(audioRecord: AudioRecord, codec: MediaCodec, fd: FileDescriptor): ULong { - Os.lseek(fd, 0, OsConstants.SEEK_SET) - Os.ftruncate(fd, 0) - + private fun encodeLoop(audioRecord: AudioRecord, mediaCodec: MediaCodec, container: Container) { // This is the most we ever read from audioRecord, even if the codec input buffer is // larger. This is purely for fast'ish cancellation and not for latency. val maxSamplesInBytes = audioRecord.sampleRate / 10 * getFrameSize(audioRecord.format) - val inputTimestamp = 0L + var inputTimestamp = 0L var inputComplete = false val bufferInfo = MediaCodec.BufferInfo() val frameSize = getFrameSize(audioRecord.format) - var totalFrames = 0uL + var trackIndex = -1 while (true) { if (!inputComplete) { - val inputBufferId = codec.dequeueInputBuffer(10000) + val inputBufferId = mediaCodec.dequeueInputBuffer(10000) if (inputBufferId >= 0) { - val buffer = codec.getInputBuffer(inputBufferId)!! + val buffer = mediaCodec.getInputBuffer(inputBufferId)!! val maxRead = min(maxSamplesInBytes, buffer.remaining()) val n = audioRecord.read(buffer, maxRead) @@ -270,16 +274,13 @@ class RecorderThread( // behavior Log.e(TAG, "MediaCodec's ByteBuffer was not a direct buffer") isCancelled = true + } else { + val frames = n / frameSize + inputTimestamp += frames * 1_000_000L / audioRecord.sampleRate } - val frames = n / frameSize - - // Setting the presentation timestamp will cause `c2.android.flac.encoder` - // software encoder to crash with SIGABRT - //inputTimestamp += frames * 1000000 / audioRecord.sampleRate - if (isCancelled) { - val duration = "%.1f".format(inputTimestamp / 1000000f) + val duration = "%.1f".format(inputTimestamp / 1_000_000.0) Log.d(TAG, "Input complete after ${duration}s") inputComplete = true } @@ -290,33 +291,35 @@ class RecorderThread( 0 } - codec.queueInputBuffer(inputBufferId, 0, n, inputTimestamp, flags) - - totalFrames += frames.toULong() + // Setting the presentation timestamp will cause `c2.android.flac.encoder` + // software encoder to crash with SIGABRT + mediaCodec.queueInputBuffer(inputBufferId, 0, n, 0, flags) } else if (inputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) { Log.w(TAG, "Unexpected input buffer dequeue error: $inputBufferId") } } - val outputBufferId = codec.dequeueOutputBuffer(bufferInfo, 0) + val outputBufferId = mediaCodec.dequeueOutputBuffer(bufferInfo, 0) if (outputBufferId >= 0) { - val buffer = codec.getOutputBuffer(outputBufferId)!! + val buffer = mediaCodec.getOutputBuffer(outputBufferId)!! - Os.write(fd, buffer) + container.writeSamples(trackIndex, buffer, bufferInfo) - codec.releaseOutputBuffer(outputBufferId, false) + mediaCodec.releaseOutputBuffer(outputBufferId, false) if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { // Output has been fully written break } - } else if (outputBufferId != MediaCodec.INFO_OUTPUT_FORMAT_CHANGED && - outputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) { + } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + val outputFormat = mediaCodec.outputFormat + Log.d(TAG, "Output format changed to: $outputFormat") + trackIndex = container.addTrack(outputFormat) + container.start() + } else if (outputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) { Log.w(TAG, "Unexpected output buffer dequeue error: $outputBufferId") } } - - return totalFrames } companion object { @@ -324,8 +327,6 @@ class RecorderThread( private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT - private val FLAC_MAGIC = ubyteArrayOf(0x66u, 0x4cu, 0x61u, 0x43u) // fLaC - // Eg. 20220429_180249.123-0400 private val FORMATTER = DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD) @@ -339,41 +340,6 @@ class RecorderThread( .appendOffset("+HHMMss", "+0000") .toFormatter() - /** - * Create a [MediaCodec] encoder for FLAC using the given PCM audio format as the input. - * - * @throws Exception if the device does not support encoding FLAC with properties matching - * matching the raw PCM data or if configuring the [MediaCodec] fails. - */ - private fun getFlacCodec(audioFormat: AudioFormat, sampleRate: Int): MediaCodec { - // AOSP ignores this because FLAC compression is lossless, but just in case the system - // uses another FLAC encoder that requiress a non-dummy value (eg. 0), we'll just use - // the PCM s16le bitrate. It's an overestimation, but shouldn't cause any issues. - val bitRate = getFrameSize(audioFormat) * sampleRate / 8 - - val format = MediaFormat() - format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_FLAC) - format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) - format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, audioFormat.channelCount) - format.setInteger(MediaFormat.KEY_SAMPLE_RATE, sampleRate) - format.setInteger(MediaFormat.KEY_FLAC_COMPRESSION_LEVEL, 8) - - val encoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(format) - ?: throw Exception("No FLAC encoder found") - Log.d(TAG, "FLAC encoder: $encoder") - - val codec = MediaCodec.createByCodecName(encoder) - - try { - codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - } catch (e: Exception) { - codec.release() - throw e - } - - return codec - } - private fun getFrameSize(audioFormat: AudioFormat): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { audioFormat.frameSizeInBytes @@ -383,59 +349,6 @@ class RecorderThread( 2 * audioFormat.channelCount } } - - /** - * Write the frame count [frames] to the STREAMINFO metadata block of a flac file. [frames] - * should not account for the number of channels (ie. frames, not samples). - * - * @throws IOException If FLAC metadata does not appear to be valid - * @throws IllegalArgumentException if [frames] exceeds the bounds of a 36-bit integer - */ - private fun setFlacHeaderDuration(fd: FileDescriptor, frames: ULong) { - if (frames >= 2uL.shl(36)) { - throw IllegalArgumentException("Frame count cannot be represented in FLAC: $frames") - } - - Os.lseek(fd, 0, OsConstants.SEEK_SET) - - // Magic (4 bytes) - // + metadata block header (4 bytes) - // + streaminfo block (34 bytes) - val buf = UByteArray(42) - - if (Os.read(fd, buf.asByteArray(), 0, buf.size) != buf.size) { - throw IOException("EOF reached when reading FLAC headers") - } - - // Validate the magic - if (ByteBuffer.wrap(buf.asByteArray(), 0, 4) != - ByteBuffer.wrap(FLAC_MAGIC.asByteArray())) { - throw IOException("FLAC magic not found") - } - - // Validate that the first metadata block is STREAMINFO and has the correct size - if (buf[4] and 0x7fu != 0.toUByte()) { - throw IOException("First metadata block is not STREAMINFO") - } - - val streamInfoSize = buf[5].toUInt().shl(16) or - buf[6].toUInt().shl(8) or buf[7].toUInt() - if (streamInfoSize < 34u) { - throw IOException("STREAMINFO block is too small") - } - - // 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) - if (Os.write(fd, buf.asByteArray(), 21, 5) != 5) { - throw IOException("EOF reached when writing frame count") - } - } } interface OnRecordingCompletedListener { diff --git a/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt new file mode 100644 index 000000000..06bbf49a4 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt @@ -0,0 +1,36 @@ +package com.chiller3.bcr.codec + +import android.media.AudioFormat +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.FileDescriptor + +class AacCodec : Codec() { + override val codecParamType: CodecParamType = CodecParamType.Bitrate + // The codec has no hard limits, so the lower bound is ffmpeg's recommended minimum bitrate for + // HE-AAC: 24kbps/channel. The upper bound is twice the bitrate for audible transparency with + // AAC-LC: 2 * 64kbps/channel. + // https://trac.ffmpeg.org/wiki/Encode/AAC + override val codecParamRange: UIntRange = 24_000u..128_000u + override val codecParamDefault: UInt = 64_000u + // https://datatracker.ietf.org/doc/html/rfc6381#section-3.1 + override val mimeTypeContainer: String = "audio/mp4" + override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_AAC + override val supported: Boolean = true + + override fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = + super.getMediaFormat(audioFormat, sampleRate).apply { + val profile = if (codecParamValue >= 32_000u) { + MediaCodecInfo.CodecProfileLevel.AACObjectLC + } else { + MediaCodecInfo.CodecProfileLevel.AACObjectHE + } + + setInteger(MediaFormat.KEY_AAC_PROFILE, profile) + setInteger(MediaFormat.KEY_BIT_RATE, codecParamValue.toInt() * audioFormat.channelCount) + } + + override fun getContainer(fd: FileDescriptor): Container = + MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/Codec.kt b/app/src/main/java/com/chiller3/bcr/codec/Codec.kt new file mode 100644 index 000000000..e30b12882 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/Codec.kt @@ -0,0 +1,109 @@ +package com.chiller3.bcr.codec + +import android.media.AudioFormat +import android.media.MediaCodec +import android.media.MediaCodecList +import android.media.MediaFormat +import android.util.Log +import java.io.FileDescriptor + +sealed class Codec { + /** Meaning of the codec parameter value. */ + abstract val codecParamType: CodecParamType + + /** Valid range for [codecParamValue]. */ + abstract val codecParamRange: UIntRange + + /** Default codec parameter value. */ + abstract val codecParamDefault: UInt + + /** + * User specified codec parameter value. + * + * @throws IllegalArgumentException if the value is not in [codecParamRange] + */ + var codecParamUser: UInt? = null + set(value) { + if (value == null || value in codecParamRange) { + field = value + } else { + throw IllegalArgumentException("Value $value not in range $codecParamRange") + } + } + + /** Get the codec parameter value or the default if unset. */ + val codecParamValue: UInt + get() = codecParamUser ?: codecParamDefault + + /** The MIME type of the container storing the encoded audio stream. */ + abstract val mimeTypeContainer: String + + /** + * The MIME type of the encoded audio stream inside the container. + * + * May be the same as [mimeTypeContainer] for some codecs. + */ + abstract val mimeTypeAudio: String + + /** Whether the codec is supported on the current device. */ + abstract val supported: Boolean + + /** + * Create a [MediaFormat] representing the encoded audio with parameters matching the specified + * input PCM audio format. + */ + open fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = + MediaFormat().apply { + setString(MediaFormat.KEY_MIME, mimeTypeAudio) + setInteger(MediaFormat.KEY_CHANNEL_COUNT, audioFormat.channelCount) + setInteger(MediaFormat.KEY_SAMPLE_RATE, sampleRate) + } + + /** + * Create a [MediaCodec] encoder that produces [mediaFormat] output. + * + * @param mediaFormat The [MediaFormat] instance returned by [getMediaFormat] + * + * @throws Exception if the device does not support encoding with the parameters set in + * [mediaFormat] or if configuring the [MediaCodec] fails. + */ + fun getMediaCodec(mediaFormat: MediaFormat): MediaCodec { + val encoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat) + ?: throw Exception("No suitable encoder found for $mediaFormat") + Log.d(TAG, "Audio encoder: $encoder") + + val codec = MediaCodec.createByCodecName(encoder) + + try { + codec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + } catch (e: Exception) { + codec.release() + throw e + } + + return codec + } + + /** + * Create a container muxer that takes encoded input and writes the muxed output to [fd]. + * + * @param fd The container does not take ownership of the file descriptor + */ + abstract fun getContainer(fd: FileDescriptor): Container + + companion object { + private val TAG = Codec::class.java.simpleName + + private var _all: Array? = null + val all: Array + get() { + if (_all == null) { + _all = arrayOf(FlacCodec(), OpusCodec(), AacCodec()) + } + return _all!! + } + + val default: Codec + get() = all.first() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt b/app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt new file mode 100644 index 000000000..007a7f176 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt @@ -0,0 +1,8 @@ +package com.chiller3.bcr.codec + +enum class CodecParamType { + /** For lossless codecs. Represents a codec-specific arbitrary integer. */ + CompressionLevel, + /** For lossy codecs. Represents a bitrate *per channel* in bits per second. */ + Bitrate, +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/Container.kt b/app/src/main/java/com/chiller3/bcr/codec/Container.kt new file mode 100644 index 000000000..c3a20922c --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/Container.kt @@ -0,0 +1,54 @@ +package com.chiller3.bcr.codec + +import android.media.MediaCodec +import android.media.MediaFormat +import java.io.FileDescriptor +import java.nio.ByteBuffer + +/** + * Abstract class for writing encoded samples to a container format. + * + * @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. + */ +sealed class Container(val fd: FileDescriptor) { + /** + * Start the muxer process. + * + * Must be called before [writeSamples]. + */ + abstract fun start() + + /** + * Stop the muxer process. + * + * Must not be called if [start] did not complete successfully. + */ + abstract fun stop() + + /** + * Free resources used by the muxer process. + * + * Can be called in any state. If the muxer process is started, it will be stopped. + */ + abstract fun release() + + /** + * Add a track to the container with the specified format. + * + * Must not be called after the muxer process is started. + * + * @param mediaFormat Must be the instance returned by [MediaCodec.getOutputFormat] + */ + abstract fun addTrack(mediaFormat: MediaFormat): Int + + /** + * Write encoded samples to the output container. + * + * Must not be called unless the muxer process is started. + * + * @param trackIndex Must be an index returned by [addTrack] + */ + abstract fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo) +} diff --git a/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt new file mode 100644 index 000000000..d71253e02 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt @@ -0,0 +1,25 @@ +package com.chiller3.bcr.codec + +import android.media.AudioFormat +import android.media.MediaFormat +import java.io.FileDescriptor + +class FlacCodec: Codec() { + override val codecParamType: CodecParamType = CodecParamType.CompressionLevel + override val codecParamRange: UIntRange = 0u..8u + // Devices are fast enough nowadays to use the highest compression for realtime recording + override var codecParamDefault: UInt = 8u + override var mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC + override var mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC + override var supported: Boolean = true + + override fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = + super.getMediaFormat(audioFormat, sampleRate).apply { + // Not relevant for lossless formats + setInteger(MediaFormat.KEY_BIT_RATE, 0) + setInteger(MediaFormat.KEY_FLAC_COMPRESSION_LEVEL, codecParamValue.toInt()) + } + + override fun getContainer(fd: FileDescriptor): Container = + FlacContainer(fd) +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt b/app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt new file mode 100644 index 000000000..8b7320ebe --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt @@ -0,0 +1,129 @@ +@file:Suppress("OPT_IN_IS_NOT_ENABLED") +@file:OptIn(ExperimentalUnsignedTypes::class) + +package com.chiller3.bcr.codec + +import android.media.MediaCodec +import android.media.MediaFormat +import android.system.Os +import android.system.OsConstants +import java.io.FileDescriptor +import java.io.IOException +import java.nio.ByteBuffer + +/** + * Dummy FLAC container wrapper that updates the STREAMINFO duration field when complete. + * + * [MediaCodec] already produces a well-formed FLAC file, thus this class writes those samples + * directly to the output file. + */ +class FlacContainer(fd: FileDescriptor) : Container(fd) { + private var lastPresentationTimeUs = -1L + private var isStopped = true + + override fun start() { + if (isStopped) { + Os.lseek(fd, 0, OsConstants.SEEK_SET) + Os.ftruncate(fd, 0) + isStopped = false + } else { + throw IllegalStateException("Called start when already started") + } + } + + override fun stop() { + if (!isStopped) { + isStopped = true + + if (lastPresentationTimeUs >= 0) { + setHeaderDuration() + } + } else { + throw IllegalStateException("Called stop when already stopped") + } + } + + override fun release() { + if (!isStopped) { + stop() + } + } + + override fun addTrack(mediaFormat: MediaFormat): Int = + // Not needed + -1 + + override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo) { + Os.write(fd, byteBuffer) + + if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + lastPresentationTimeUs = bufferInfo.presentationTimeUs + } + } + + /** + * Write the frame count 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 + */ + private fun setHeaderDuration() { + Os.lseek(fd, 0, OsConstants.SEEK_SET) + + // Magic (4 bytes) + // + metadata block header (4 bytes) + // + streaminfo block (34 bytes) + val buf = UByteArray(42) + + if (Os.read(fd, buf.asByteArray(), 0, buf.size) != buf.size) { + throw IOException("EOF reached when reading FLAC headers") + } + + // Validate the magic + if (ByteBuffer.wrap(buf.asByteArray(), 0, 4) != + ByteBuffer.wrap(FLAC_MAGIC.asByteArray())) { + throw IOException("FLAC magic not found") + } + + // Validate that the first metadata block is STREAMINFO and has the correct size + if (buf[4] and 0x7fu != 0.toUByte()) { + throw IOException("First metadata block is not STREAMINFO") + } + + val streamInfoSize = buf[5].toUInt().shl(16) or + buf[6].toUInt().shl(8) or buf[7].toUInt() + if (streamInfoSize < 34u) { + 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") + } + + // 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) + if (Os.write(fd, buf.asByteArray(), 21, 5) != 5) { + throw IOException("EOF reached when writing frame count") + } + } + + companion object { + private val FLAC_MAGIC = ubyteArrayOf(0x66u, 0x4cu, 0x61u, 0x43u) // fLaC + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt b/app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt new file mode 100644 index 000000000..1a87c10de --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt @@ -0,0 +1,39 @@ +package com.chiller3.bcr.codec + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.FileDescriptor +import java.nio.ByteBuffer + +/** + * A thin wrapper around [MediaMuxer]. + * + * @param fd Output file descriptor. This class does not take ownership of the file descriptor. + * @param containerFormat A valid [MediaMuxer.OutputFormat] value for the output container format. + */ +class MediaMuxerContainer( + fd: FileDescriptor, + containerFormat: Int, +) : Container(fd) { + private val muxer = MediaMuxer(fd, containerFormat) + + override fun start() { + muxer.start() + } + + override fun stop() { + muxer.stop() + } + + override fun release() { + muxer.release() + } + + override fun addTrack(mediaFormat: MediaFormat): Int = + muxer.addTrack(mediaFormat) + + override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo) = + muxer.writeSampleData(trackIndex, byteBuffer, bufferInfo) +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt new file mode 100644 index 000000000..79b58de93 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt @@ -0,0 +1,29 @@ +package com.chiller3.bcr.codec + +import android.media.AudioFormat +import android.media.MediaFormat +import android.media.MediaMuxer +import android.os.Build +import androidx.annotation.RequiresApi +import java.io.FileDescriptor + +class OpusCodec : Codec() { + override val codecParamType: CodecParamType = CodecParamType.Bitrate + override val codecParamRange: UIntRange = 6_000u..510_000u + // "Essentially transparent mono or stereo speech, reasonable music" + // https://wiki.hydrogenaud.io/index.php?title=Opus + override val codecParamDefault: UInt = 48_000u + // https://datatracker.ietf.org/doc/html/rfc7845#section-9 + override val mimeTypeContainer: String = "audio/ogg" + override var mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_OPUS + override val supported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + override fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = + super.getMediaFormat(audioFormat, sampleRate).apply { + setInteger(MediaFormat.KEY_BIT_RATE, codecParamValue.toInt() * audioFormat.channelCount) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override fun getContainer(fd: FileDescriptor): Container = + MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG) +} \ No newline at end of file