Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to format-specific sample rates #496

Merged
merged 1 commit into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 52 additions & 26 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import androidx.preference.PreferenceManager
import com.chiller3.bcr.extension.DOCUMENTSUI_AUTHORITY
import com.chiller3.bcr.extension.safTreeToDocument
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.SampleRate
import com.chiller3.bcr.output.Retention
import com.chiller3.bcr.rule.RecordRule
import com.chiller3.bcr.template.Template
Expand Down Expand Up @@ -40,6 +39,7 @@ class Preferences(private val context: Context) {
private const val PREF_RECORD_RULE_PREFIX = "record_rule_"
private const val PREF_FORMAT_NAME = "codec_name"
private const val PREF_FORMAT_PARAM_PREFIX = "codec_param_"
private const val PREF_FORMAT_SAMPLE_RATE_PREFIX = "codec_sample_rate_"
const val PREF_OUTPUT_RETENTION = "output_retention"
const val PREF_SAMPLE_RATE = "sample_rate"
private const val PREF_NEXT_NOTIFICATION_ID = "next_notification_id"
Expand All @@ -58,21 +58,23 @@ class Preferences(private val context: Context) {
)

fun isFormatKey(key: String): Boolean =
key == PREF_FORMAT_NAME || key.startsWith(PREF_FORMAT_PARAM_PREFIX)
key == PREF_FORMAT_NAME
|| key.startsWith(PREF_FORMAT_PARAM_PREFIX)
|| key.startsWith(PREF_FORMAT_SAMPLE_RATE_PREFIX)
}

internal val prefs = PreferenceManager.getDefaultSharedPreferences(context)

/**
* Get a unsigned integer preference value.
*
* @return Will never be [UInt.MAX_VALUE]
* @return Will never be [sentinel]
*/
private fun getOptionalUint(key: String): UInt? {
private fun getOptionalUint(key: String, sentinel: UInt): UInt? {
// Use a sentinel value because doing contains + getInt results in TOCTOU issues
val value = prefs.getInt(key, -1)
val value = prefs.getInt(key, sentinel.toInt())

return if (value == -1) {
return if (value == sentinel.toInt()) {
null
} else {
value.toUInt()
Expand All @@ -82,14 +84,13 @@ class Preferences(private val context: Context) {
/**
* Set an unsigned integer preference to [value].
*
* @param value Must not be [UInt.MAX_VALUE]
* @param value Must not be [sentinel]
*
* @throws IllegalArgumentException if [value] is [UInt.MAX_VALUE]
* @throws IllegalArgumentException if [value] is [sentinel]
*/
private fun setOptionalUint(key: String, value: UInt?) {
// -1 (when casted to int) is used as a sentinel value
if (value == UInt.MAX_VALUE) {
throw IllegalArgumentException("$key value cannot be ${UInt.MAX_VALUE}")
private fun setOptionalUint(key: String, sentinel: UInt, value: UInt?) {
if (value == sentinel) {
throw IllegalArgumentException("$key value cannot be $sentinel")
}

prefs.edit {
Expand Down Expand Up @@ -205,8 +206,11 @@ class Preferences(private val context: Context) {
* Must not be [UInt.MAX_VALUE].
*/
var outputRetention: Retention?
get() = getOptionalUint(PREF_OUTPUT_RETENTION)?.let { Retention.fromRawPreferenceValue(it) }
set(retention) = setOptionalUint(PREF_OUTPUT_RETENTION, retention?.toRawPreferenceValue())
get() = getOptionalUint(PREF_OUTPUT_RETENTION, UInt.MAX_VALUE)?.let {
Retention.fromRawPreferenceValue(it)
}
set(retention) = setOptionalUint(PREF_OUTPUT_RETENTION, UInt.MAX_VALUE,
retention?.toRawPreferenceValue())

/**
* Whether call recording is enabled.
Expand Down Expand Up @@ -242,7 +246,8 @@ class Preferences(private val context: Context) {
/**
* The saved output format.
*
* Use [getFormatParam]/[setFormatParam] to get/set the format-specific parameter.
* Use [getFormatParam]/[setFormatParam] to get/set the format-specific parameter. Use
* [getFormatSampleRate]/[setFormatSampleRate] to get/set the format-specific sample rate.
*/
var format: Format?
get() = prefs.getString(PREF_FORMAT_NAME, null)?.let { Format.getByName(it) }
Expand All @@ -258,7 +263,7 @@ class Preferences(private val context: Context) {
* Get the format-specific parameter for [format].
*/
fun getFormatParam(format: Format): UInt? =
getOptionalUint(PREF_FORMAT_PARAM_PREFIX + format.name)
getOptionalUint(PREF_FORMAT_PARAM_PREFIX + format.name, UInt.MAX_VALUE)

/**
* Set the format-specific parameter for [format].
Expand All @@ -268,7 +273,23 @@ class Preferences(private val context: Context) {
* @throws IllegalArgumentException if [param] is [UInt.MAX_VALUE]
*/
fun setFormatParam(format: Format, param: UInt?) =
setOptionalUint(PREF_FORMAT_PARAM_PREFIX + format.name, param)
setOptionalUint(PREF_FORMAT_PARAM_PREFIX + format.name, UInt.MAX_VALUE, param)

/**
* Get the format-specific sample rate for [format].
*/
fun getFormatSampleRate(format: Format): UInt? =
getOptionalUint(PREF_FORMAT_SAMPLE_RATE_PREFIX + format.name, 0U)

/**
* Set the format-specific sample rate for [format].
*
* @param rate Must not be 0
*
* @throws IllegalArgumentException if [rate] is 0
*/
fun setFormatSampleRate(format: Format, rate: UInt?) =
setOptionalUint(PREF_FORMAT_SAMPLE_RATE_PREFIX + format.name, 0U, rate)

/**
* Remove the default format preference and the parameters for all formats.
Expand All @@ -282,15 +303,6 @@ class Preferences(private val context: Context) {
}
}

/**
* The recording and output sample rate.
*
* Must not be [UInt.MAX_VALUE].
*/
var sampleRate: SampleRate?
get() = getOptionalUint(PREF_SAMPLE_RATE)?.let { SampleRate(it) }
set(sampleRate) = setOptionalUint(PREF_SAMPLE_RATE, sampleRate?.value)

/**
* Whether to write call metadata file.
*/
Expand All @@ -307,4 +319,18 @@ class Preferences(private val context: Context) {
prefs.edit { putInt(PREF_NEXT_NOTIFICATION_ID, nextId + 1) }
nextId
}

/**
* Migrate legacy global sample rate to format-specific sample rate.
*
* This migration will be removed in version 1.65.
*/
fun migrateSampleRate() {
val sampleRate = getOptionalUint(PREF_SAMPLE_RATE, UInt.MAX_VALUE)
if (sampleRate != null) {
val (format, _, _) = Format.fromPreferences(this)
setFormatSampleRate(format, sampleRate)
setOptionalUint(PREF_SAMPLE_RATE, UInt.MAX_VALUE, null)
}
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/chiller3/bcr/RecorderApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class RecorderApplication : Application() {
oldCrashHandler?.uncaughtException(t, e)
}
}

// Migrate legacy preferences.
val prefs = Preferences(this)
prefs.migrateSampleRate()
}

companion object {
Expand Down
9 changes: 4 additions & 5 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.NoParamInfo
import com.chiller3.bcr.format.RangedParamInfo
import com.chiller3.bcr.format.RangedParamType
import com.chiller3.bcr.format.SampleRate
import com.chiller3.bcr.output.CallMetadata
import com.chiller3.bcr.output.CallMetadataCollector
import com.chiller3.bcr.output.DaysRetention
Expand Down Expand Up @@ -128,7 +127,7 @@ class RecorderThread(
// Format
private val format: Format
private val formatParam: UInt?
private val sampleRate = SampleRate.fromPreferences(prefs)
private val sampleRate: UInt

// Logging
private lateinit var logcatPath: OutputPath
Expand All @@ -141,6 +140,7 @@ class RecorderThread(
val savedFormat = Format.fromPreferences(prefs)
format = savedFormat.first
formatParam = savedFormat.second
sampleRate = savedFormat.third ?: format.sampleRateInfo.default
}

fun onCallDetailsChanged(call: Call, details: Call.Details) {
Expand Down Expand Up @@ -483,16 +483,15 @@ class RecorderThread(
private fun recordUntilCancelled(pfd: ParcelFileDescriptor): RecordingInfo {
AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_URGENT_AUDIO)

val minBufSize = AudioRecord.getMinBufferSize(
sampleRate.value.toInt(), CHANNEL_CONFIG, ENCODING)
val minBufSize = AudioRecord.getMinBufferSize(sampleRate.toInt(), CHANNEL_CONFIG, ENCODING)
if (minBufSize < 0) {
throw Exception("Failure when querying minimum buffer size: $minBufSize")
}
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")

val audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_CALL,
sampleRate.value.toInt(),
sampleRate.toInt(),
CHANNEL_CONFIG,
ENCODING,
// On some devices, MediaCodec occasionally has sudden spikes in processing time, so use
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.chiller3.bcr.dialog

import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputType
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import com.chiller3.bcr.Preferences
import com.chiller3.bcr.R
import com.chiller3.bcr.databinding.DialogTextInputBinding
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.RangedSampleRateInfo
import com.google.android.material.dialog.MaterialAlertDialogBuilder

class FormatSampleRateDialogFragment : DialogFragment() {
companion object {
val TAG: String = FormatSampleRateDialogFragment::class.java.simpleName

const val RESULT_SUCCESS = "success"
}

private lateinit var prefs: Preferences
private lateinit var format: Format
private lateinit var binding: DialogTextInputBinding
private var value: UInt? = null
private var success: Boolean = false

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = requireContext()
prefs = Preferences(context)
format = Format.fromPreferences(prefs).first

val sampleRateInfo = format.sampleRateInfo
if (sampleRateInfo !is RangedSampleRateInfo) {
throw IllegalStateException("Selected format is not configurable")
}

binding = DialogTextInputBinding.inflate(layoutInflater)

binding.message.text = getString(
R.string.format_sample_rate_dialog_message,
sampleRateInfo.format(context, sampleRateInfo.range.first),
sampleRateInfo.format(context, sampleRateInfo.range.last),
)

// Try to detect if the displayed format is a prefix or suffix since it may not be the same
// in every language.
val translated = getString(R.string.format_sample_rate, "\u0000")
val placeholder = translated.indexOf('\u0000')
val hasPrefix = placeholder > 0
val hasSuffix = placeholder < translated.length - 1
if (hasPrefix) {
binding.textLayout.prefixText = translated.substring(0, placeholder).trimEnd()
}
if (hasSuffix) {
binding.textLayout.suffixText = translated.substring(placeholder + 1).trimStart()
}
if (hasPrefix && hasSuffix) {
binding.text.textAlignment = View.TEXT_ALIGNMENT_CENTER
} else if (hasSuffix) {
binding.text.textAlignment = View.TEXT_ALIGNMENT_TEXT_END
}

binding.text.inputType = InputType.TYPE_CLASS_NUMBER
binding.text.addTextChangedListener {
value = null

if (it!!.isNotEmpty()) {
try {
val newValue = it.toString().toUInt()
if (newValue in sampleRateInfo.range) {
value = newValue
}
} catch (e: NumberFormatException) {
// Ignore
}
}

refreshOkButtonEnabledState()
}

return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.format_sample_rate_dialog_title)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
prefs.setFormatSampleRate(format, value!!)
success = true
}
.setNegativeButton(android.R.string.cancel, null)
.create()
.apply {
setCanceledOnTouchOutside(false)
}
}

override fun onStart() {
super.onStart()
refreshOkButtonEnabledState()
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)

setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success))
}

private fun refreshOkButtonEnabledState() {
(dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = value != null
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/chiller3/bcr/format/AacFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ object AacFormat : Format() {
128_000u,
),
)
override val sampleRateInfo: SampleRateInfo = DiscreteSampleRateInfo(
// This what Android's C2 software encoder (C2SoftAacEnc.cpp) supports.
uintArrayOf(8_000u, 11_025u, 12_000u, 16_000u, 22_050u, 24_000u, 32_000u, 44_100u, 48_000u),
16_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
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ object FlacFormat : Format() {
8u,
uintArrayOf(0u, 5u, 8u),
)
override val sampleRateInfo: SampleRateInfo = RangedSampleRateInfo(
1u..655_350u,
16_000u,
uintArrayOf(8_000u, 16_000u, 48_000u),
)
override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC
override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC
override val passthrough: Boolean = false
Expand Down
17 changes: 13 additions & 4 deletions app/src/main/java/com/chiller3/bcr/format/Format.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ sealed class Format {
/** Details about the format parameter range and default value. */
abstract val paramInfo: FormatParamInfo

/** Defaults about the supported sample rates. */
abstract val sampleRateInfo: SampleRateInfo

/** The MIME type of the container storing the encoded audio stream. */
abstract val mimeTypeContainer: String

Expand Down Expand Up @@ -98,22 +101,28 @@ sealed class Format {
/**
* 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.
* The parameter, if set, is clamped to the format's allowed parameter range. Similarly, the
* sample rate, if set, is set to the nearest valid value.
*/
fun fromPreferences(prefs: Preferences): Pair<Format, UInt?> {
fun fromPreferences(prefs: Preferences): Triple<Format, UInt?, UInt?> {
// Use the saved format if it is valid and supported on the current device. Otherwise,
// fall back to the default.
val format = prefs.format
?.let { if (it.supported) { it } else { null } }
?: default

// Convert the saved value to the nearest valid value (eg. in case the bitrate range is
// changed in a future version)
// changed in a future version).
val param = prefs.getFormatParam(format)?.let {
format.paramInfo.toNearest(it)
}

return Pair(format, param)
// Same with the sample rate.
val sampleRate = prefs.getFormatSampleRate(format)?.let {
format.sampleRateInfo.toNearest(it)
}

return Triple(format, param, sampleRate)
}
}
}
Loading
Loading