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

Add support for configurable sample rate #56

Merged
merged 3 commits into from
May 31, 2022
Merged
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
1 change: 1 addition & 0 deletions app/magisk/updates/release/changelog.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
### Unreleased

* Change output format button group to material chips to prevent text from being cut off with narrower screen widths (Issue: #52, PR: #55, @chenxiaolong)
* Add support for configuring the capture sample rate (PR: #56, @chenxiaolong)

### Version 1.6

82 changes: 63 additions & 19 deletions app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
@file:Suppress("OPT_IN_IS_NOT_ENABLED")
@file:OptIn(ExperimentalUnsignedTypes::class)

package com.chiller3.bcr

import android.os.Bundle
@@ -10,12 +13,10 @@ import com.chiller3.bcr.databinding.FormatBottomSheetChipBinding
import com.chiller3.bcr.format.*
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.chip.ChipGroup
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider

class FormatBottomSheetFragment : BottomSheetDialogFragment(),
ChipGroup.OnCheckedStateChangeListener, LabelFormatter, Slider.OnChangeListener,
View.OnClickListener {
ChipGroup.OnCheckedStateChangeListener, Slider.OnChangeListener, View.OnClickListener {
private var _binding: FormatBottomSheetBinding? = null
private val binding
get() = _binding!!
@@ -24,14 +25,19 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
private val formatToChipId = HashMap<Format, Int>()
private lateinit var formatParamInfo: FormatParamInfo

private val chipIdToSampleRate = HashMap<Int, UInt?>()
private val sampleRateToChipId = HashMap<UInt?, Int>()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FormatBottomSheetBinding.inflate(inflater, container, false)

binding.paramSlider.setLabelFormatter(this)
binding.paramSlider.setLabelFormatter {
formatParamInfo.format(it.toUInt())
}
binding.paramSlider.addOnChangeListener(this)

binding.reset.setOnClickListener(this)
@@ -41,20 +47,20 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
continue
}

val chipBinding = FormatBottomSheetChipBinding.inflate(
inflater, binding.nameGroup, false)
val id = View.generateViewId()
chipBinding.root.id = id
chipBinding.root.text = format.name
chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
binding.nameGroup.addView(chipBinding.root)
chipIdToFormat[id] = format
formatToChipId[format] = id
addFormatChip(inflater, format)
}

binding.nameGroup.setOnCheckedStateChangeListener(this)

addSampleRateChip(inflater, null)
for (sampleRate in SampleRates.all) {
addSampleRateChip(inflater, sampleRate)
}

binding.sampleRateGroup.setOnCheckedStateChangeListener(this)

refreshFormat()
refreshSampleRate()

return binding.root
}
@@ -64,6 +70,30 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
_binding = null
}

private fun addFormatChip(inflater: LayoutInflater, format: Format) {
val chipBinding = FormatBottomSheetChipBinding.inflate(
inflater, binding.nameGroup, false)
val id = View.generateViewId()
chipBinding.root.id = id
chipBinding.root.text = format.name
chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
binding.nameGroup.addView(chipBinding.root)
chipIdToFormat[id] = format
formatToChipId[format] = id
}

private fun addSampleRateChip(inflater: LayoutInflater, sampleRate: UInt?) {
val chipBinding = FormatBottomSheetChipBinding.inflate(
inflater, binding.sampleRateGroup, false)
val id = View.generateViewId()
chipBinding.root.id = id
chipBinding.root.text = SampleRates.format(requireContext(), sampleRate)
chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE
binding.sampleRateGroup.addView(chipBinding.root)
chipIdToSampleRate[id] = sampleRate
sampleRateToChipId[sampleRate] = id
}

/**
* Update UI based on currently selected format in the preferences.
*
@@ -74,6 +104,11 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
binding.nameGroup.check(formatToChipId[format]!!)
}

private fun refreshSampleRate() {
val sampleRate = SampleRates.fromPreferences(requireContext())
binding.sampleRateGroup.check(sampleRateToChipId[sampleRate]!!)
}

/**
* Update parameter title and slider to match format parameter specifications.
*/
@@ -110,12 +145,18 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
}

override fun onCheckedChanged(group: ChipGroup, checkedIds: MutableList<Int>) {
Preferences.setFormatName(requireContext(), chipIdToFormat[checkedIds.first()]!!.name)
refreshParam()
}
val context = requireContext()

override fun getFormattedValue(value: Float): String =
formatParamInfo.format(value.toUInt())
when (group) {
binding.nameGroup -> {
Preferences.setFormatName(context, chipIdToFormat[checkedIds.first()]!!.name)
refreshParam()
}
binding.sampleRateGroup -> {
Preferences.setSampleRate(context, chipIdToSampleRate[checkedIds.first()])
}
}
}

override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
when (slider) {
@@ -129,10 +170,13 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
override fun onClick(v: View?) {
when (v) {
binding.reset -> {
Preferences.resetAllFormats(requireContext())
val context = requireContext()
Preferences.resetAllFormats(context)
Preferences.setSampleRate(context, null)
refreshFormat()
// Need to explicitly refresh the parameter when the default format is already chosen
refreshParam()
refreshSampleRate()
}
}
}
41 changes: 41 additions & 0 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ object Preferences {
// Not associated with a UI preference
private const val PREF_FORMAT_NAME = "codec_name"
private const val PREF_FORMAT_PARAM_PREFIX = "codec_param_"
const val PREF_SAMPLE_RATE = "sample_rate"

/**
* Get the default output directory. The directory should always be writable and is suitable for
@@ -184,4 +185,44 @@ object Preferences {

editor.apply()
}

/**
* Get the saved sample rate.
*/
fun getSampleRate(context: Context): UInt? {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
// Use a sentinel value because doing contains + getInt results in TOCTOU issues
val value = prefs.getInt(PREF_SAMPLE_RATE, -1)

return if (value == -1) {
null
} else {
value.toUInt()
}
}

/**
* Set the sample rate.
*
* @param sampleRate Must not be [UInt.MAX_VALUE]
*
* @throws IllegalArgumentException if [sampleRate] is [UInt.MAX_VALUE]
*/
fun setSampleRate(context: Context, sampleRate: UInt?) {
// -1 (when casted to int) is used as a sentinel value
if (sampleRate == UInt.MAX_VALUE) {
throw IllegalArgumentException("Sample rate cannot be ${UInt.MAX_VALUE}")
}

val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val editor = prefs.edit()

if (sampleRate == null) {
editor.remove(PREF_SAMPLE_RATE)
} else {
editor.putInt(PREF_SAMPLE_RATE, sampleRate.toInt())
}

editor.apply()
}
}
14 changes: 10 additions & 4 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import androidx.documentfile.provider.DocumentFile
import com.chiller3.bcr.format.Container
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.Formats
import com.chiller3.bcr.format.SampleRates
import java.io.IOException
import java.lang.Integer.min
import java.nio.ByteBuffer
@@ -54,6 +55,7 @@ class RecorderThread(
// Format
private val format: Format
private val formatParam: UInt?
private val sampleRate = SampleRates.fromPreferences(context)

init {
logI("Created thread for call: $call")
@@ -261,10 +263,14 @@ class RecorderThread(
private fun recordUntilCancelled(pfd: ParcelFileDescriptor) {
AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_AUDIO)

val audioFormat = AudioFormat.Builder()
.setEncoding(ENCODING)
.setChannelMask(CHANNEL_CONFIG)
.build()
val audioFormat = AudioFormat.Builder().run {
setEncoding(ENCODING)
setChannelMask(CHANNEL_CONFIG)
if (sampleRate != null) {
setSampleRate(sampleRate.toInt())
}
build()
}
val audioRecord = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_CALL)
.setAudioFormat(audioFormat)
13 changes: 8 additions & 5 deletions app/src/main/java/com/chiller3/bcr/SettingsActivity.kt
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import androidx.preference.SwitchPreferenceCompat
import com.chiller3.bcr.format.Formats
import com.chiller3.bcr.format.NoParamInfo
import com.chiller3.bcr.format.RangedParamInfo
import com.chiller3.bcr.format.SampleRates

class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -109,15 +110,17 @@ class SettingsActivity : AppCompatActivity() {
}

private fun refreshOutputFormat() {
val (format, formatParamSaved) = Formats.fromPreferences(requireContext())
val context = requireContext()
val (format, formatParamSaved) = Formats.fromPreferences(context)
val formatParam = formatParamSaved ?: format.paramInfo.default
val summary = getString(R.string.pref_output_format_desc)
val suffix = when (val info = format.paramInfo) {
is RangedParamInfo -> " (${info.format(formatParam)})"
val prefix = when (val info = format.paramInfo) {
is RangedParamInfo -> "${info.format(formatParam)}, "
NoParamInfo -> ""
}
val sampleRate = SampleRates.format(context, SampleRates.fromPreferences(context))

prefOutputFormat.summary = "${summary}\n\n${format.name}${suffix}"
prefOutputFormat.summary = "${summary}\n\n${format.name} (${prefix}${sampleRate})"
}

private fun refreshInhibitBatteryOptState() {
@@ -193,7 +196,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
// Update the output format state when it's changed by the bottom sheet
Preferences.isFormatKey(key) -> {
Preferences.isFormatKey(key) || key == Preferences.PREF_SAMPLE_RATE -> {
refreshOutputFormat()
}
}
47 changes: 47 additions & 0 deletions app/src/main/java/com/chiller3/bcr/format/SampleRates.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@file:Suppress("OPT_IN_IS_NOT_ENABLED")
@file:OptIn(ExperimentalUnsignedTypes::class)

package com.chiller3.bcr.format

import android.content.Context
import com.chiller3.bcr.Preferences
import com.chiller3.bcr.R

object SampleRates {
/**
* Hardcoded list of sample rates supported by every [Format].
*
* Ideally, there would be a way to query what sample rates are supported for a given audio
* source and then filter that list based on what the [Format] supports. Unfortunately, no such
* API exists.
*/
val all = uintArrayOf(
8_000u,
12_000u,
16_000u,
24_000u,
48_000u,
)

/**
* Get the saved sample rate from the preferences.
*
* If the saved sample rate is no longer valid, then null is returned.
*/
fun fromPreferences(context: Context): UInt? {
val savedSampleRate = Preferences.getSampleRate(context)

if (savedSampleRate != null && all.contains(savedSampleRate)) {
return savedSampleRate
}

return null
}

fun format(context: Context, sampleRate: UInt?): String =
if (sampleRate == null) {
context.getString(R.string.bottom_sheet_sample_rate_native)
} else {
"$sampleRate Hz"
}
}
Loading