diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/java/com/chiller3/bcr/Preferences.kt index 961c4785d..4d8023c3b 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/java/com/chiller3/bcr/Preferences.kt @@ -8,6 +8,8 @@ import java.io.File object Preferences { const val PREF_CALL_RECORDING = "call_recording" + const val PREF_CODEC_NAME = "codec_name" + const val PREF_CODEC_PARAM_FORMAT = "codec_param_%s" const val PREF_OUTPUT_DIR = "output_dir" const val PREF_INHIBIT_BATT_OPT = "inhibit_batt_opt" const val PREF_VERSION = "version" @@ -91,4 +93,69 @@ object Preferences { editor.putBoolean(PREF_CALL_RECORDING, enabled) editor.apply() } + + /** + * Get the saved output codec. + * + * Use [getCodecParam] to get the codec-specific parameter. + */ + fun getCodecName(context: Context): String? { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + return prefs.getString(PREF_CODEC_NAME, null) + } + + /** + * Save the selected output codec. + * + * Use [setCodecParam] to set the codec-specific parameter. + */ + fun setCodecName(context: Context, name: String?) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val editor = prefs.edit() + + if (name == null) { + editor.remove(PREF_CODEC_NAME) + } else { + editor.putString(PREF_CODEC_NAME, name) + } + + editor.apply() + } + + /** + * Get the codec-specific parameter for codec [name]. + */ + fun getCodecParam(context: Context, name: String): UInt? { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val key = PREF_CODEC_PARAM_FORMAT.format(name) + val value = prefs.getInt(key, -1) + + return if (value == -1) { + null + } else { + value.toUInt() + } + } + + /** + * Set the codec-specific parameter for codec [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?) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val editor = prefs.edit() + val key = PREF_CODEC_PARAM_FORMAT.format(name) + val value = param?.toInt() ?: -1 + + if (value == -1) { + editor.remove(key) + } else { + editor.putInt(key, value) + } + + editor.apply() + } } \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index f1285db26..9947c1530 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -14,6 +14,7 @@ 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 java.io.IOException import java.lang.Integer.min @@ -43,8 +44,11 @@ class RecorderThread( private val listener: OnRecordingCompletedListener, call: Call, ): Thread() { + // Thread state @Volatile private var isCancelled = false private var captureFailed = false + + // Filename private val handleUri: Uri = call.details.handle private val creationTime: Long = call.details.creationTimeMillis private val direction: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -58,11 +62,16 @@ class RecorderThread( } private val displayName: String? = call.details.callerDisplayName - // TODO - private val codec = Codec.default + // Codec + private val codec: Codec + private val codecParam: UInt? init { Log.i(TAG, "[${id}] Created thread for call: $call") + + val savedCodec = Codecs.fromPreferences(context) + codec = savedCodec.first + codecParam = savedCodec.second } private fun getFilename(): String = @@ -198,7 +207,7 @@ class RecorderThread( audioRecord.startRecording() try { - val mediaFormat = codec.getMediaFormat(audioFormat, audioRecord.sampleRate) + val mediaFormat = codec.getMediaFormat(audioFormat, audioRecord.sampleRate, codecParam) val mediaCodec = codec.getMediaCodec(mediaFormat) try { diff --git a/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt index 06bbf49a4..4bccf622f 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt @@ -1,35 +1,37 @@ 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 +object AacCodec : Codec() { + 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 // 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 + override val paramRange: UIntRange = 24_000u..128_000u + override val paramDefault: 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) { + override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + mediaFormat.apply { + val profile = if (param >= 32_000u) { MediaCodecInfo.CodecProfileLevel.AACObjectLC } else { MediaCodecInfo.CodecProfileLevel.AACObjectHE } + val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) setInteger(MediaFormat.KEY_AAC_PROFILE, profile) - setInteger(MediaFormat.KEY_BIT_RATE, codecParamValue.toInt() * audioFormat.channelCount) + setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) } + } override fun getContainer(fd: FileDescriptor): Container = MediaMuxerContainer(fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) diff --git a/app/src/main/java/com/chiller3/bcr/codec/Codec.kt b/app/src/main/java/com/chiller3/bcr/codec/Codec.kt index e30b12882..33d19d6d3 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/Codec.kt +++ b/app/src/main/java/com/chiller3/bcr/codec/Codec.kt @@ -8,32 +8,17 @@ import android.util.Log import java.io.FileDescriptor sealed class Codec { + /** User-facing name of the codec. */ + abstract val name: String + /** Meaning of the codec parameter value. */ - abstract val codecParamType: CodecParamType + abstract val paramType: CodecParamType - /** Valid range for [codecParamValue]. */ - abstract val codecParamRange: UIntRange + /** Valid range for the codec-specific parameter value. */ + abstract val paramRange: 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 + abstract val paramDefault: UInt /** The MIME type of the container storing the encoded audio stream. */ abstract val mimeTypeContainer: String @@ -51,14 +36,35 @@ sealed class Codec { /** * Create a [MediaFormat] representing the encoded audio with parameters matching the specified * input PCM audio format. + * + * @param param Codec-specific parameter value. Must be in the [paramRange] range. If null, + * [paramDefault] is used. + * + * @throws IllegalArgumentException if [param] is outside [paramRange] */ - open fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = - MediaFormat().apply { + fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int, param: UInt?): MediaFormat { + if (param != null && param !in paramRange) { + throw IllegalArgumentException("Parameter $param not in range $paramRange") + } + + val format = MediaFormat().apply { setString(MediaFormat.KEY_MIME, mimeTypeAudio) setInteger(MediaFormat.KEY_CHANNEL_COUNT, audioFormat.channelCount) setInteger(MediaFormat.KEY_SAMPLE_RATE, sampleRate) } + updateMediaFormat(format, param ?: paramDefault) + + return format + } + + /** + * Update [mediaFormat] with parameter keys relevant to the codec-specific parameter. + * + * @param param Guaranteed to be within [paramRange] + */ + protected abstract fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) + /** * Create a [MediaCodec] encoder that produces [mediaFormat] output. * @@ -93,17 +99,5 @@ sealed class Codec { 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/Codecs.kt b/app/src/main/java/com/chiller3/bcr/codec/Codecs.kt new file mode 100644 index 000000000..49604a404 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/codec/Codecs.kt @@ -0,0 +1,37 @@ +package com.chiller3.bcr.codec + +import android.content.Context +import com.chiller3.bcr.Preferences + +object Codecs { + val all: Array = arrayOf(FlacCodec, OpusCodec, AacCodec) + val default: Codec = all.first() + + /** Find output codec by name. */ + 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) + val codec = if (savedCodecName != null) { + getByName(savedCodecName) ?: default + } else { + 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) + } + + /** Save the selected codec and its parameter to the preferences. */ + fun saveToPreferences(context: Context, codec: Codec, param: UInt?) { + Preferences.setCodecName(context, codec.name) + Preferences.setCodecParam(context, codec.name, param) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt index d71253e02..27e84e2c0 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt @@ -1,24 +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 +object FlacCodec: Codec() { + override val name: String = "FLAC" + override val paramType: CodecParamType = CodecParamType.CompressionLevel + override val paramRange: 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 val paramDefault: UInt = 8u + override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC + override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC + override val supported: Boolean = true - override fun getMediaFormat(audioFormat: AudioFormat, sampleRate: Int): MediaFormat = - super.getMediaFormat(audioFormat, sampleRate).apply { + override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + mediaFormat.apply { // Not relevant for lossless formats setInteger(MediaFormat.KEY_BIT_RATE, 0) - setInteger(MediaFormat.KEY_FLAC_COMPRESSION_LEVEL, codecParamValue.toInt()) + setInteger(MediaFormat.KEY_FLAC_COMPRESSION_LEVEL, param.toInt()) } + } override fun getContainer(fd: FileDescriptor): Container = FlacContainer(fd) diff --git a/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt b/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt index 79b58de93..d98e5780f 100644 --- a/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt +++ b/app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt @@ -1,27 +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 +object OpusCodec : Codec() { + override val name: String = "OGG/Opus" + override val paramType: CodecParamType = CodecParamType.Bitrate + override val paramRange: 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 + override val paramDefault: 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 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) + override fun updateMediaFormat(mediaFormat: MediaFormat, param: UInt) { + mediaFormat.apply { + val channelCount = getInteger(MediaFormat.KEY_CHANNEL_COUNT) + setInteger(MediaFormat.KEY_BIT_RATE, param.toInt() * channelCount) } + } @RequiresApi(Build.VERSION_CODES.Q) override fun getContainer(fd: FileDescriptor): Container =