Skip to content

Commit

Permalink
Add support for saving and loading codec information from shared pref…
Browse files Browse the repository at this point in the history
…erences

Issue: #21
Signed-off-by: Andrew Gunnerson <chillermillerlong@hotmail.com>
  • Loading branch information
chenxiaolong committed May 26, 2022
1 parent 5cfff11 commit 4af8cd7
Show file tree
Hide file tree
Showing 7 changed files with 180 additions and 68 deletions.
67 changes: 67 additions & 0 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
}
}
15 changes: 12 additions & 3 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 11 additions & 9 deletions 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)
Expand Down
66 changes: 30 additions & 36 deletions app/src/main/java/com/chiller3/bcr/codec/Codec.kt
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -93,17 +99,5 @@ sealed class Codec {

companion object {
private val TAG = Codec::class.java.simpleName

private var _all: Array<Codec>? = null
val all: Array<Codec>
get() {
if (_all == null) {
_all = arrayOf(FlacCodec(), OpusCodec(), AacCodec())
}
return _all!!
}

val default: Codec
get() = all.first()
}
}
37 changes: 37 additions & 0 deletions 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<Codec> = 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<Codec, UInt?> {
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)
}
}
23 changes: 12 additions & 11 deletions 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)
Expand Down
20 changes: 11 additions & 9 deletions 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 =
Expand Down

0 comments on commit 4af8cd7

Please sign in to comment.