diff --git a/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt b/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt new file mode 100644 index 000000000..1986a3aef --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/AudioFormatExtension.kt @@ -0,0 +1,13 @@ +package com.chiller3.bcr + +import android.media.AudioFormat +import android.os.Build + +val AudioFormat.frameSizeInBytesCompat: Int + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + frameSizeInBytes + } else{ + // Hardcoded for Android 9 compatibility only + assert(encoding == AudioFormat.ENCODING_PCM_16BIT) + 2 * channelCount + } \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/CodecBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/CodecBottomSheetFragment.kt deleted file mode 100644 index 006fb5d6e..000000000 --- a/app/src/main/java/com/chiller3/bcr/CodecBottomSheetFragment.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.chiller3.bcr - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.core.view.ViewCompat -import com.chiller3.bcr.codec.Codec -import com.chiller3.bcr.codec.CodecParamType -import com.chiller3.bcr.codec.Codecs -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.button.MaterialButton -import com.google.android.material.button.MaterialButtonToggleGroup -import com.google.android.material.slider.LabelFormatter -import com.google.android.material.slider.Slider - -class CodecBottomSheetFragment : BottomSheetDialogFragment(), - MaterialButtonToggleGroup.OnButtonCheckedListener, LabelFormatter, Slider.OnChangeListener, - View.OnClickListener { - private lateinit var codecParamTitle: TextView - private lateinit var codecParam: Slider - private lateinit var codecReset: MaterialButton - private lateinit var codecNameGroup: MaterialButtonToggleGroup - private val buttonIdToCodec = HashMap() - private val codecToButtonId = HashMap() - private lateinit var codecParamType: CodecParamType - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val bottomSheet = inflater.inflate(R.layout.codec_bottom_sheet, container, false) - - codecParamTitle = bottomSheet.findViewById(R.id.codec_param_title) - - codecParam = bottomSheet.findViewById(R.id.codec_param) - codecParam.setLabelFormatter(this) - codecParam.addOnChangeListener(this) - - codecReset = bottomSheet.findViewById(R.id.codec_reset) - codecReset.setOnClickListener(this) - - codecNameGroup = bottomSheet.findViewById(R.id.codec_name_group)!! - - for (codec in Codecs.all) { - if (!codec.supported) { - continue - } - - val button = layoutInflater.inflate( - R.layout.codec_bottom_sheet_button, codecNameGroup, false) as MaterialButton - val id = ViewCompat.generateViewId() - button.id = id - button.text = codec.name - codecNameGroup.addView(button) - buttonIdToCodec[id] = codec - codecToButtonId[codec] = id - } - - codecNameGroup.addOnButtonCheckedListener(this) - - refreshCodec() - - return bottomSheet - } - - /** - * Update UI based on currently selected codec in the preferences. - * - * Calls [refreshParam] via [onButtonChecked]. - */ - private fun refreshCodec() { - val (codec, _) = Codecs.fromPreferences(requireContext()) - codecNameGroup.check(codecToButtonId[codec]!!) - } - - /** - * Update parameter title and slider to match codec parameter specifications. - */ - private fun refreshParam() { - val (codec, param) = Codecs.fromPreferences(requireContext()) - codecParamType = codec.paramType - - val titleResId = when (codec.paramType) { - CodecParamType.CompressionLevel -> R.string.bottom_sheet_compression_level - CodecParamType.Bitrate -> R.string.bottom_sheet_bitrate - } - - codecParamTitle.setText(titleResId) - - codecParam.valueFrom = codec.paramRange.first.toFloat() - codecParam.valueTo = codec.paramRange.last.toFloat() - codecParam.stepSize = codec.paramStepSize.toFloat() - - codecParam.value = (param ?: codec.paramDefault).toFloat() - } - - override fun onButtonChecked( - group: MaterialButtonToggleGroup?, - checkedId: Int, - isChecked: Boolean - ) { - if (isChecked) { - Preferences.setCodecName(requireContext(), buttonIdToCodec[checkedId]!!.name) - refreshParam() - } - } - - override fun getFormattedValue(value: Float): String = - codecParamType.format(value.toUInt()) - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - when (slider) { - codecParam -> { - val codec = buttonIdToCodec[codecNameGroup.checkedButtonId]!! - Preferences.setCodecParam(requireContext(), codec.name, value.toUInt()) - } - } - } - - override fun onClick(v: View?) { - when (v) { - codecReset -> { - Preferences.resetAllCodecs(requireContext()) - refreshCodec() - // Need to explicitly refresh the parameter when the default codec is already chosen - refreshParam() - } - } - } - - companion object { - val TAG: String = CodecBottomSheetFragment::class.java.simpleName - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt new file mode 100644 index 000000000..c08a27514 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt @@ -0,0 +1,153 @@ +package com.chiller3.bcr + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import com.chiller3.bcr.format.Format +import com.chiller3.bcr.format.FormatParamType +import com.chiller3.bcr.format.Formats +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.slider.LabelFormatter +import com.google.android.material.slider.Slider + +class FormatBottomSheetFragment : BottomSheetDialogFragment(), + MaterialButtonToggleGroup.OnButtonCheckedListener, LabelFormatter, Slider.OnChangeListener, + View.OnClickListener { + private lateinit var formatParamGroup: LinearLayout + private lateinit var formatParamTitle: TextView + private lateinit var formatParamSlider: Slider + private lateinit var formatReset: MaterialButton + private lateinit var formatNameGroup: MaterialButtonToggleGroup + private val buttonIdToFormat = HashMap() + private val formatToButtonId = HashMap() + private lateinit var formatParamType: FormatParamType + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val bottomSheet = inflater.inflate(R.layout.format_bottom_sheet, container, false) + + formatParamGroup = bottomSheet.findViewById(R.id.format_param_group) + + formatParamTitle = bottomSheet.findViewById(R.id.format_param_title) + + formatParamSlider = bottomSheet.findViewById(R.id.format_param_slider) + formatParamSlider.setLabelFormatter(this) + formatParamSlider.addOnChangeListener(this) + + formatReset = bottomSheet.findViewById(R.id.format_reset) + formatReset.setOnClickListener(this) + + formatNameGroup = bottomSheet.findViewById(R.id.format_name_group)!! + + for (format in Formats.all) { + if (!format.supported) { + continue + } + + val button = layoutInflater.inflate( + R.layout.format_bottom_sheet_button, formatNameGroup, false) as MaterialButton + val id = ViewCompat.generateViewId() + button.id = id + button.text = format.name + formatNameGroup.addView(button) + buttonIdToFormat[id] = format + formatToButtonId[format] = id + } + + formatNameGroup.addOnButtonCheckedListener(this) + + refreshFormat() + + return bottomSheet + } + + /** + * Update UI based on currently selected format in the preferences. + * + * Calls [refreshParam] via [onButtonChecked]. + */ + private fun refreshFormat() { + val (format, _) = Formats.fromPreferences(requireContext()) + formatNameGroup.check(formatToButtonId[format]!!) + } + + /** + * Update parameter title and slider to match format parameter specifications. + */ + private fun refreshParam() { + val (format, param) = Formats.fromPreferences(requireContext()) + formatParamType = format.paramType + + val titleResId = when (format.paramType) { + FormatParamType.CompressionLevel -> R.string.bottom_sheet_compression_level + FormatParamType.Bitrate -> R.string.bottom_sheet_bitrate + } + + formatParamGroup.isVisible = format.paramRange.first != format.paramRange.last + + formatParamTitle.setText(titleResId) + + formatParamSlider.valueFrom = format.paramRange.first.toFloat() + formatParamSlider.valueTo = format.paramRange.last.toFloat() + formatParamSlider.stepSize = format.paramStepSize.toFloat() + formatParamSlider.value = (param ?: format.paramDefault).toFloat() + + // Needed due to a bug in the material3 library where the slider label does not disappear + // when the slider visibility is set to View.GONE + // https://github.com/material-components/material-components-android/issues/2726 + if (format.paramRange.first == format.paramRange.last) { + val ensureLabelsRemoved = formatParamSlider.javaClass.superclass + .getDeclaredMethod("ensureLabelsRemoved") + ensureLabelsRemoved.isAccessible = true + ensureLabelsRemoved.invoke(formatParamSlider) + } + } + + override fun onButtonChecked( + group: MaterialButtonToggleGroup?, + checkedId: Int, + isChecked: Boolean + ) { + if (isChecked) { + Preferences.setFormatName(requireContext(), buttonIdToFormat[checkedId]!!.name) + refreshParam() + } + } + + override fun getFormattedValue(value: Float): String = + formatParamType.format(value.toUInt()) + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + when (slider) { + formatParamSlider -> { + val format = buttonIdToFormat[formatNameGroup.checkedButtonId]!! + Preferences.setFormatParam(requireContext(), format.name, value.toUInt()) + } + } + } + + override fun onClick(v: View?) { + when (v) { + formatReset -> { + Preferences.resetAllFormats(requireContext()) + refreshFormat() + // Need to explicitly refresh the parameter when the default format is already chosen + refreshParam() + } + } + } + + companion object { + val TAG: String = FormatBottomSheetFragment::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/java/com/chiller3/bcr/Preferences.kt index 0335facf5..f27100ccf 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/java/com/chiller3/bcr/Preferences.kt @@ -14,8 +14,8 @@ object Preferences { const val PREF_VERSION = "version" // Not associated with a UI preference - private const val PREF_CODEC_NAME = "codec_name" - private const val PREF_CODEC_PARAM_PREFIX = "codec_param_" + private const val PREF_FORMAT_NAME = "codec_name" + private const val PREF_FORMAT_PARAM_PREFIX = "codec_param_" /** * Get the default output directory. The directory should always be writable and is suitable for @@ -97,43 +97,43 @@ object Preferences { editor.apply() } - fun isCodecKey(key: String): Boolean = - key == PREF_CODEC_NAME || key.startsWith(PREF_CODEC_PARAM_PREFIX) + fun isFormatKey(key: String): Boolean = + key == PREF_FORMAT_NAME || key.startsWith(PREF_FORMAT_PARAM_PREFIX) /** - * Get the saved output codec. + * Get the saved output format. * - * Use [getCodecParam] to get the codec-specific parameter. + * Use [getFormatParam] to get the format-specific parameter. */ - fun getCodecName(context: Context): String? { + fun getFormatName(context: Context): String? { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - return prefs.getString(PREF_CODEC_NAME, null) + return prefs.getString(PREF_FORMAT_NAME, null) } /** - * Save the selected output codec. + * Save the selected output format. * - * Use [setCodecParam] to set the codec-specific parameter. + * Use [setFormatParam] to set the format-specific parameter. */ - fun setCodecName(context: Context, name: String?) { + fun setFormatName(context: Context, name: String?) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val editor = prefs.edit() if (name == null) { - editor.remove(PREF_CODEC_NAME) + editor.remove(PREF_FORMAT_NAME) } else { - editor.putString(PREF_CODEC_NAME, name) + editor.putString(PREF_FORMAT_NAME, name) } editor.apply() } /** - * Get the codec-specific parameter for codec [name]. + * Get the format-specific parameter for format [name]. */ - fun getCodecParam(context: Context, name: String): UInt? { + fun getFormatParam(context: Context, name: String): UInt? { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val key = PREF_CODEC_PARAM_PREFIX + name + val key = PREF_FORMAT_PARAM_PREFIX + name // Use a sentinel value because doing contains + getInt results in TOCTOU issues val value = prefs.getInt(key, -1) @@ -145,13 +145,13 @@ object Preferences { } /** - * Set the codec-specific parameter for codec [name]. + * Set the format-specific parameter for format [name]. * * @param param Must not be [UInt.MAX_VALUE] * * @throws IllegalArgumentException if [param] is [UInt.MAX_VALUE] */ - fun setCodecParam(context: Context, name: String, param: UInt?) { + fun setFormatParam(context: Context, name: String, param: UInt?) { // -1 (when casted to int) is used as a sentinel value if (param == UInt.MAX_VALUE) { throw IllegalArgumentException("Parameter cannot be ${UInt.MAX_VALUE}") @@ -159,7 +159,7 @@ object Preferences { val prefs = PreferenceManager.getDefaultSharedPreferences(context) val editor = prefs.edit() - val key = PREF_CODEC_PARAM_PREFIX + name + val key = PREF_FORMAT_PARAM_PREFIX + name if (param == null) { editor.remove(key) @@ -171,11 +171,11 @@ object Preferences { } /** - * Remove the default codec preference and the parameters for all codecs. + * Remove the default format preference and the parameters for all formats. */ - fun resetAllCodecs(context: Context) { + fun resetAllFormats(context: Context) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val keys = prefs.all.keys.filter(::isCodecKey) + val keys = prefs.all.keys.filter(::isFormatKey) val editor = prefs.edit() for (key in keys) { diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index 107a6ccdd..e5e22b9b6 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -2,10 +2,7 @@ package com.chiller3.bcr import android.annotation.SuppressLint import android.content.Context -import android.media.AudioFormat -import android.media.AudioRecord -import android.media.MediaCodec -import android.media.MediaRecorder +import android.media.* import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor @@ -13,11 +10,12 @@ import android.telecom.Call import android.telecom.PhoneAccount import android.util.Log import androidx.documentfile.provider.DocumentFile -import com.chiller3.bcr.codec.Codec -import com.chiller3.bcr.codec.Codecs -import com.chiller3.bcr.codec.Container +import com.chiller3.bcr.format.Container +import com.chiller3.bcr.format.Format +import com.chiller3.bcr.format.Formats 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 @@ -53,18 +51,18 @@ class RecorderThread( private lateinit var filename: String private val redactions = HashMap() - // Codec - private val codec: Codec - private val codecParam: UInt? + // Format + private val format: Format + private val formatParam: UInt? init { logI("Created thread for call: $call") onCallDetailsChanged(call.details) - val savedCodec = Codecs.fromPreferences(context) - codec = savedCodec.first - codecParam = savedCodec.second + val savedFormat = Formats.fromPreferences(context) + format = savedFormat.first + formatParam = savedFormat.second } private fun logD(msg: String) { @@ -93,7 +91,7 @@ class RecorderThread( for ((source, target) in redactions) { result = result - .replace(Uri.encode(source), Uri.encode(target)) + .replace(Uri.encode(source), target) .replace(source, target) } @@ -241,12 +239,12 @@ class RecorderThread( /** * 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]. + * contain a file extension. The file extension is automatically determined from [format]. * * @throws IOException if file creation or opening fails */ private fun openOutputFileInDir(directory: DocumentFile, name: String): OutputFile { - val file = directory.createFile(codec.mimeTypeContainer, name) + val file = directory.createFile(format.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}") @@ -278,26 +276,35 @@ class RecorderThread( try { // audioRecord.format has the detected native sample rate - val mediaFormat = codec.getMediaFormat(audioRecord.format, codecParam) - val mediaCodec = codec.getMediaCodec(mediaFormat) + val mediaFormat = format.getMediaFormat(audioRecord.format, formatParam) + val mediaCodec = if (!format.passthrough) { + format.getMediaCodec(mediaFormat) + } else { + null + } try { - mediaCodec.start() + mediaCodec?.start() try { - val container = codec.getContainer(pfd.fileDescriptor) + val container = format.getContainer(pfd.fileDescriptor) try { - encodeLoop(audioRecord, mediaCodec, container) + if (mediaCodec != null) { + encodeLoop(audioRecord, mediaCodec, container) + } else { + passthroughLoop(audioRecord, mediaFormat, container) + } + container.stop() } finally { container.release() } } finally { - mediaCodec.stop() + mediaCodec?.stop() } } finally { - mediaCodec.release() + mediaCodec?.release() } } finally { audioRecord.stop() @@ -307,6 +314,72 @@ class RecorderThread( } } + /** + * Main loop for capturing raw audio into a PCM-based output file. + * + * The loop runs forever until [cancel] is called. At that point, no further data will be read + * from [audioRecord]. 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 container [Container.start] must *not* have been called. It will be left in a started + * state after this method returns. + */ + private fun passthroughLoop( + audioRecord: AudioRecord, + mediaFormat: MediaFormat, + container: Container, + ) { + var inputTimestamp = 0L + val bufferInfo = MediaCodec.BufferInfo() + val frameSize = audioRecord.format.frameSizeInBytesCompat + + // 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 * frameSize + + val buffer = ByteBuffer.allocateDirect(maxSamplesInBytes) + val trackIndex = container.addTrack(mediaFormat) + container.start() + + while (true) { + val maxRead = min(maxSamplesInBytes, buffer.remaining()) + val n = audioRecord.read(buffer, maxRead) + if (n < 0) { + logE("Error when reading samples from ${audioRecord}: $n") + isCancelled = true + captureFailed = true + } else if (n == 0) { + logE( "Unexpected EOF from AudioRecord") + isCancelled = true + } else { + val frames = n / frameSize + inputTimestamp += frames * 1_000_000L / audioRecord.sampleRate + + bufferInfo.offset = 0 + bufferInfo.size = buffer.limit() + bufferInfo.presentationTimeUs = 0 + bufferInfo.flags = if (isCancelled) { + MediaCodec.BUFFER_FLAG_END_OF_STREAM + } else { + 0 + } + + container.writeSamples(trackIndex, buffer, bufferInfo) + buffer.clear() + } + + if (isCancelled) { + val duration = "%.1f".format(inputTimestamp / 1_000_000.0) + logD("Input complete after ${duration}s") + break + } + } + } + /** * Main loop for encoding captured raw audio into an output file. * @@ -326,16 +399,16 @@ class RecorderThread( * @throws MediaCodec.CodecException if the codec encounters an error */ 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) - var inputTimestamp = 0L var inputComplete = false val bufferInfo = MediaCodec.BufferInfo() - val frameSize = getFrameSize(audioRecord.format) + val frameSize = audioRecord.format.frameSizeInBytesCompat var trackIndex = -1 + // 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 * frameSize + while (true) { if (!inputComplete) { val inputBufferId = mediaCodec.dequeueInputBuffer(10000) @@ -419,16 +492,6 @@ class RecorderThread( .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) .appendOffset("+HHMMss", "+0000") .toFormatter() - - private fun getFrameSize(audioFormat: AudioFormat): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - audioFormat.frameSizeInBytes - } else{ - // Hardcoded for Android 9 compatibility only - assert(ENCODING == AudioFormat.ENCODING_PCM_16BIT) - 2 * audioFormat.channelCount - } - } } interface OnRecordingCompletedListener { diff --git a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt index 285862bb9..cb7662e4a 100644 --- a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt +++ b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt @@ -9,7 +9,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat -import com.chiller3.bcr.codec.Codecs +import com.chiller3.bcr.format.Formats class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -107,12 +107,12 @@ class SettingsActivity : AppCompatActivity() { } private fun refreshOutputFormat() { - val (codec, codecParamSaved) = Codecs.fromPreferences(requireContext()) - val codecParam = codecParamSaved ?: codec.paramDefault + val (format, formatParamSaved) = Formats.fromPreferences(requireContext()) + val formatParam = formatParamSaved ?: format.paramDefault val summary = getString(R.string.pref_output_format_desc) - val paramText = codec.paramType.format(codecParam) + val paramText = format.paramType.format(formatParam) - prefOutputFormat.summary = "${summary}\n\n${codec.name} (${paramText})" + prefOutputFormat.summary = "${summary}\n\n${format.name} (${paramText})" } private fun refreshInhibitBatteryOptState() { @@ -150,8 +150,8 @@ class SettingsActivity : AppCompatActivity() { return true } prefOutputFormat -> { - CodecBottomSheetFragment().show( - childFragmentManager, CodecBottomSheetFragment.TAG) + FormatBottomSheetFragment().show( + childFragmentManager, FormatBottomSheetFragment.TAG) return true } prefVersion -> { @@ -188,7 +188,7 @@ class SettingsActivity : AppCompatActivity() { } } // Update the output format state when it's changed by the bottom sheet - Preferences.isCodecKey(key) -> { + Preferences.isFormatKey(key) -> { refreshOutputFormat() } } diff --git a/app/src/main/java/com/chiller3/bcr/codec/Codecs.kt b/app/src/main/java/com/chiller3/bcr/codec/Codecs.kt deleted file mode 100644 index d89633c98..000000000 --- a/app/src/main/java/com/chiller3/bcr/codec/Codecs.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.chiller3.bcr.codec - -import android.content.Context -import com.chiller3.bcr.Preferences - -object Codecs { - val all: Array = arrayOf(OpusCodec, AacCodec, FlacCodec) - private val default: Codec = all.first { it.supported } - - /** Find output codec by name. */ - private fun getByName(name: String): Codec? = all.find { it.name == name } - - /** - * Get the saved codec from the preferences or fall back to the default. - * - * The parameter, if set, is clamped to the codec's allowed parameter range. - */ - fun fromPreferences(context: Context): Pair { - val savedCodecName = Preferences.getCodecName(context) - - // Use the saved codec if it is valid and supported on the current device. Otherwise, fall - // back to the default. - val codec = savedCodecName - ?.let { getByName(it) } - ?.let { if (it.supported) { it } else { null } } - ?: default - - // Clamp to the codec's allowed parameter range in case the range is shrunk - val param = Preferences.getCodecParam(context, codec.name)?.coerceIn(codec.paramRange) - - return Pair(codec, param) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt b/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt similarity index 75% rename from app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt rename to app/src/main/java/com/chiller3/bcr/format/AacFormat.kt index f841fdaf3..c1484d287 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt @@ -1,14 +1,15 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format +import android.media.AudioFormat import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.MediaMuxer import java.io.FileDescriptor -object AacCodec : Codec() { +object AacFormat : Format() { override val name: String = "M4A/AAC" - override val paramType: CodecParamType = CodecParamType.Bitrate - // The codec has no hard limits, so the lower bound is ffmpeg's recommended minimum bitrate for + override val paramType: FormatParamType = FormatParamType.Bitrate + // The format 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 @@ -18,9 +19,14 @@ object AacCodec : Codec() { // 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 passthrough: Boolean = false override val supported: Boolean = true - override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + override fun updateMediaFormat( + mediaFormat: MediaFormat, + audioFormat: AudioFormat, + param: UInt, + ) { mediaFormat.apply { val profile = if (param >= 32_000u) { MediaCodecInfo.CodecProfileLevel.AACObjectLC diff --git a/app/src/main/java/com/chiller3/bcr/codec/Container.kt b/app/src/main/java/com/chiller3/bcr/format/Container.kt similarity index 82% rename from app/src/main/java/com/chiller3/bcr/codec/Container.kt rename to app/src/main/java/com/chiller3/bcr/format/Container.kt index c3a20922c..6a7227f66 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/Container.kt +++ b/app/src/main/java/com/chiller3/bcr/format/Container.kt @@ -1,17 +1,13 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format 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) { +sealed class Container { /** * Start the muxer process. * diff --git a/app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt similarity index 94% rename from app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt rename to app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt index 8b7320ebe..d821d0c77 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/FlacContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt @@ -1,7 +1,7 @@ @file:Suppress("OPT_IN_IS_NOT_ENABLED") @file:OptIn(ExperimentalUnsignedTypes::class) -package com.chiller3.bcr.codec +package com.chiller3.bcr.format import android.media.MediaCodec import android.media.MediaFormat @@ -16,8 +16,11 @@ import java.nio.ByteBuffer * * [MediaCodec] already produces a well-formed FLAC file, thus this class writes those samples * directly to the output file. + * + * @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(fd: FileDescriptor) : Container(fd) { +class FlacContainer(private val fd: FileDescriptor) : Container() { private var lastPresentationTimeUs = -1L private var isStopped = true diff --git a/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt b/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt similarity index 70% rename from app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt rename to app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt index a5171603a..44d791079 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt @@ -1,20 +1,26 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format +import android.media.AudioFormat import android.media.MediaFormat import java.io.FileDescriptor -object FlacCodec: Codec() { +object FlacFormat : Format() { override val name: String = "FLAC" - override val paramType: CodecParamType = CodecParamType.CompressionLevel + override val paramType: FormatParamType = FormatParamType.CompressionLevel override val paramRange: UIntRange = 0u..8u override val paramStepSize: UInt = 1u // Devices are fast enough nowadays to use the highest compression for realtime recording override val paramDefault: UInt = 8u override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC + override val passthrough: Boolean = false override val supported: Boolean = true - override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + override fun updateMediaFormat( + mediaFormat: MediaFormat, + audioFormat: AudioFormat, + param: UInt, + ) { mediaFormat.apply { // Not relevant for lossless formats setInteger(MediaFormat.KEY_BIT_RATE, 0) diff --git a/app/src/main/java/com/chiller3/bcr/codec/Codec.kt b/app/src/main/java/com/chiller3/bcr/format/Format.kt similarity index 71% rename from app/src/main/java/com/chiller3/bcr/codec/Codec.kt rename to app/src/main/java/com/chiller3/bcr/format/Format.kt index 1e1316d9e..c88b784c3 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/Codec.kt +++ b/app/src/main/java/com/chiller3/bcr/format/Format.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format import android.media.AudioFormat import android.media.MediaCodec @@ -6,21 +6,22 @@ import android.media.MediaCodecList import android.media.MediaFormat import android.util.Log import java.io.FileDescriptor +import java.lang.IllegalStateException -sealed class Codec { - /** User-facing name of the codec. */ +sealed class Format { + /** User-facing name of the format. */ abstract val name: String - /** Meaning of the codec parameter value. */ - abstract val paramType: CodecParamType + /** Meaning of the format parameter value. */ + abstract val paramType: FormatParamType - /** Valid range for the codec-specific parameter value. */ + /** Valid range for the format-specific parameter value. */ abstract val paramRange: UIntRange /** Reasonable step size for selecting a value via the UI. */ abstract val paramStepSize: UInt - /** Default codec parameter value. */ + /** Default format parameter value. */ abstract val paramDefault: UInt /** The MIME type of the container storing the encoded audio stream. */ @@ -29,11 +30,14 @@ sealed class Codec { /** * The MIME type of the encoded audio stream inside the container. * - * May be the same as [mimeTypeContainer] for some codecs. + * May be the same as [mimeTypeContainer] for some formats. */ abstract val mimeTypeAudio: String - /** Whether the codec is supported on the current device. */ + /** Whether the format takes the PCM samples as is without encoding. */ + abstract val passthrough: Boolean + + /** Whether the format is supported on the current device. */ abstract val supported: Boolean /** @@ -42,7 +46,7 @@ sealed class Codec { * * @param audioFormat [AudioFormat.getSampleRate] must not be * [AudioFormat.SAMPLE_RATE_UNSPECIFIED]. - * @param param Codec-specific parameter value. Must be in the [paramRange] range. If null, + * @param param Format-specific parameter value. Must be in the [paramRange] range. If null, * [paramDefault] is used. * * @throws IllegalArgumentException if [param] is outside [paramRange] @@ -58,17 +62,21 @@ sealed class Codec { setInteger(MediaFormat.KEY_SAMPLE_RATE, audioFormat.sampleRate) } - updateMediaFormat(format, param ?: paramDefault) + updateMediaFormat(format, audioFormat, param ?: paramDefault) return format } /** - * Update [mediaFormat] with parameter keys relevant to the codec-specific parameter. + * Update [mediaFormat] with parameter keys relevant to the format-specific parameter. * * @param param Guaranteed to be within [paramRange] */ - protected abstract fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) + protected abstract fun updateMediaFormat( + mediaFormat: MediaFormat, + audioFormat: AudioFormat, + param: UInt, + ) /** * Create a [MediaCodec] encoder that produces [mediaFormat] output. @@ -77,8 +85,13 @@ sealed class Codec { * * @throws Exception if the device does not support encoding with the parameters set in * [mediaFormat] or if configuring the [MediaCodec] fails. + * @throws IllegalStateException if [passthrough] is true */ fun getMediaCodec(mediaFormat: MediaFormat): MediaCodec { + if (passthrough) { + throw IllegalStateException("Tried to create MediaCodec for passthrough format") + } + val encoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat) ?: throw Exception("No suitable encoder found for $mediaFormat") Log.d(TAG, "Audio encoder: $encoder") @@ -98,11 +111,11 @@ sealed class 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 + * @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 val TAG = Format::class.java.simpleName } } \ 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/format/FormatParamType.kt similarity index 51% rename from app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt rename to app/src/main/java/com/chiller3/bcr/format/FormatParamType.kt index cc957e48a..3303b7a6f 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FormatParamType.kt @@ -1,12 +1,12 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format -enum class CodecParamType { - /** For lossless codecs. Represents a codec-specific arbitrary integer. */ +enum class FormatParamType { + /** For lossless formats. Represents a format-specific arbitrary integer. */ CompressionLevel { override fun format(param: UInt): String = param.toString() }, - /** For lossy codecs. Represents a bitrate *per channel* in bits per second. */ + /** For lossy formats. Represents a bitrate *per channel* in bits per second. */ Bitrate { override fun format(param: UInt): String = "${param / 1_000u} kbps" }; diff --git a/app/src/main/java/com/chiller3/bcr/format/Formats.kt b/app/src/main/java/com/chiller3/bcr/format/Formats.kt new file mode 100644 index 000000000..8a30c1f8c --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/format/Formats.kt @@ -0,0 +1,33 @@ +package com.chiller3.bcr.format + +import android.content.Context +import com.chiller3.bcr.Preferences + +object Formats { + val all: Array = arrayOf(OpusFormat, AacFormat, FlacFormat, WaveFormat) + private val default: Format = all.first { it.supported } + + /** Find output format by name. */ + private fun getByName(name: String): Format? = all.find { it.name == name } + + /** + * Get the saved format from the preferences or fall back to the default. + * + * The parameter, if set, is clamped to the format's allowed parameter range. + */ + fun fromPreferences(context: Context): Pair { + val savedFormatName = Preferences.getFormatName(context) + + // Use the saved format if it is valid and supported on the current device. Otherwise, fall + // back to the default. + val format = savedFormatName + ?.let { getByName(it) } + ?.let { if (it.supported) { it } else { null } } + ?: default + + // Clamp to the format's allowed parameter range in case the range is shrunk + val param = Preferences.getFormatParam(context, format.name)?.coerceIn(format.paramRange) + + return Pair(format, param) + } +} \ 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/format/MediaMuxerContainer.kt similarity index 95% rename from app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt rename to app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt index 1a87c10de..f93baf6ab 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/MediaMuxerContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/MediaMuxerContainer.kt @@ -1,4 +1,4 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format import android.media.MediaCodec import android.media.MediaFormat @@ -15,7 +15,7 @@ import java.nio.ByteBuffer class MediaMuxerContainer( fd: FileDescriptor, containerFormat: Int, -) : Container(fd) { +) : Container() { private val muxer = MediaMuxer(fd, containerFormat) override fun start() { diff --git a/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt b/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt similarity index 76% rename from app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt rename to app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt index db6d8cfe0..a2c30d520 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt @@ -1,14 +1,15 @@ -package com.chiller3.bcr.codec +package com.chiller3.bcr.format +import android.media.AudioFormat import android.media.MediaFormat import android.media.MediaMuxer import android.os.Build import androidx.annotation.RequiresApi import java.io.FileDescriptor -object OpusCodec : Codec() { +object OpusFormat : Format() { override val name: String = "OGG/Opus" - override val paramType: CodecParamType = CodecParamType.Bitrate + override val paramType: FormatParamType = FormatParamType.Bitrate override val paramRange: UIntRange = 6_000u..510_000u override val paramStepSize: UInt = 2_000u // "Essentially transparent mono or stereo speech, reasonable music" @@ -17,9 +18,14 @@ object OpusCodec : Codec() { // https://datatracker.ietf.org/doc/html/rfc7845#section-9 override val mimeTypeContainer: String = "audio/ogg" override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_OPUS + override val passthrough: Boolean = false override val supported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + override fun updateMediaFormat( + mediaFormat: MediaFormat, + audioFormat: AudioFormat, + param: UInt, + ) { mediaFormat.apply { val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) diff --git a/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt b/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt new file mode 100644 index 000000000..474e1523d --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt @@ -0,0 +1,122 @@ +@file:Suppress("OPT_IN_IS_NOT_ENABLED") +@file:OptIn(ExperimentalUnsignedTypes::class) + +package com.chiller3.bcr.format + +import android.media.MediaCodec +import android.media.MediaFormat +import android.system.Os +import android.system.OsConstants +import java.io.FileDescriptor +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class WaveContainer(private val fd: FileDescriptor) : Container() { + private var isStopped = true + private var trackAdded = true + private var frameSize = 0 + private var channelCount = 0 + private var sampleRate = 0 + + override fun start() { + if (isStopped) { + Os.ftruncate(fd, 0) + + // Skip header + Os.lseek(fd, HEADER_SIZE.toLong(), OsConstants.SEEK_SET) + + isStopped = false + } else { + throw IllegalStateException("Called start when already started") + } + } + + override fun stop() { + if (!isStopped) { + isStopped = true + + if (trackAdded) { + val fileSize = Os.lseek(fd, 0, OsConstants.SEEK_CUR) + val header = buildHeader(fileSize) + Os.lseek(fd, 0, OsConstants.SEEK_SET) + Os.write(fd, header) + } + } else { + throw IllegalStateException("Called stop when already stopped") + } + } + + override fun release() { + if (!isStopped) { + stop() + } + } + + override fun addTrack(mediaFormat: MediaFormat): Int { + trackAdded = true + frameSize = mediaFormat.getInteger(WaveFormat.KEY_X_FRAME_SIZE_IN_BYTES) + channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) + + return -1 + } + + override fun writeSamples(trackIndex: Int, byteBuffer: ByteBuffer, + bufferInfo: MediaCodec.BufferInfo) { + if (!trackAdded) { + throw IllegalStateException("No track has been added") + } + + Os.write(fd, byteBuffer) + } + + private fun buildHeader(fileSize: Long): ByteBuffer = + ByteBuffer.allocate(HEADER_SIZE).apply { + order(ByteOrder.LITTLE_ENDIAN) + + val (chunkSize, dataSize) = if (fileSize >= Int.MAX_VALUE) { + // If, for some reason, the recording is excessively huge, don't set a size and just + // let the audio player guess + Pair(0, 0) + } else { + Pair(fileSize.toInt() - 8, fileSize.toInt() - HEADER_SIZE) + } + + // 0-3: Chunk ID + put(RIFF_MAGIC.asByteArray()) + // 4-7: Chunk size + putInt(chunkSize) + // 8-11: Format + put(WAVE_MAGIC.asByteArray()) + // 12-15: Subchunk 1 ID + put(FMT_MAGIC.asByteArray()) + // 16-19: Subchunk 1 size + putInt(16) + // 20-21: Audio format + putShort(1) + // 22-23: Number of channels + putShort(channelCount.toShort()) + // 24-27: Sample rate + putInt(sampleRate) + // 28-31: Byte rate + putInt(sampleRate * frameSize) + // 32-33: Block align + putShort(frameSize.toShort()) + // 34-35: Bits per sample + putShort(((frameSize / channelCount) * 8).toShort()) + // 36-39: Subchunk 2 ID + put(DATA_MAGIC.asByteArray()) + // 40-43: Subchunk 2 size + putInt(dataSize) + + flip() + } + + companion object { + private const val HEADER_SIZE = 44 + private val RIFF_MAGIC = ubyteArrayOf(0x52u, 0x49u, 0x46u, 0x46u) // RIFF + private val WAVE_MAGIC = ubyteArrayOf(0x57u, 0x41u, 0x56u, 0x45u) // WAVE + private val FMT_MAGIC = ubyteArrayOf(0x66u, 0x6du, 0x74u, 0x20u) // "fmt " + private val DATA_MAGIC = ubyteArrayOf(0x64u, 0x61u, 0x74u, 0x61u) // data + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt b/app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt new file mode 100644 index 000000000..99023ecb9 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt @@ -0,0 +1,35 @@ +package com.chiller3.bcr.format + +import android.media.AudioFormat +import android.media.MediaFormat +import com.chiller3.bcr.frameSizeInBytesCompat +import java.io.FileDescriptor + +object WaveFormat : Format() { + const val KEY_X_FRAME_SIZE_IN_BYTES = "x-frame-size-in-bytes" + + override val name: String = "WAV/PCM" + override val paramType: FormatParamType = FormatParamType.CompressionLevel + override val paramRange: UIntRange = 0u..0u + override val paramStepSize: UInt = 0u + override val paramDefault: UInt = 0u + // Should be "audio/vnd.wave" [1], but Android only recognizes "audio/x-wav" [2] for the + // purpose of picking an appropriate file extension when creating a file via SAF. + // [1] https://datatracker.ietf.org/doc/html/rfc2361 + // [2] https://android.googlesource.com/platform/external/mime-support/+/refs/tags/android-12.1.0_r5/mime.types#571 + override val mimeTypeContainer: String = "audio/x-wav" + override val mimeTypeAudio: String = "audio/x-wav" + override val passthrough: Boolean = true + override val supported: Boolean = true + + override fun updateMediaFormat( + mediaFormat: MediaFormat, + audioFormat: AudioFormat, + param: UInt, + ) { + mediaFormat.setInteger(KEY_X_FRAME_SIZE_IN_BYTES, audioFormat.frameSizeInBytesCompat) + } + + override fun getContainer(fd: FileDescriptor): Container = + WaveContainer(fd) +} \ No newline at end of file diff --git a/app/src/main/res/layout/codec_bottom_sheet.xml b/app/src/main/res/layout/format_bottom_sheet.xml similarity index 60% rename from app/src/main/res/layout/codec_bottom_sheet.xml rename to app/src/main/res/layout/format_bottom_sheet.xml index 12d3b63fc..719f0ea2f 100644 --- a/app/src/main/res/layout/codec_bottom_sheet.xml +++ b/app/src/main/res/layout/format_bottom_sheet.xml @@ -15,28 +15,35 @@ android:textAppearance="?attr/textAppearanceHeadline6" /> - - - + android:orientation="vertical" + android:gravity="center_horizontal"> + + + +