diff --git a/app/magisk/updates/release/changelog.txt b/app/magisk/updates/release/changelog.txt index 9bef52e8c..3cd648379 100644 --- a/app/magisk/updates/release/changelog.txt +++ b/app/magisk/updates/release/changelog.txt @@ -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 diff --git a/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt index a89de929d..ac92cc331 100644 --- a/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt @@ -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,6 +25,9 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(), private val formatToChipId = HashMap() private lateinit var formatParamInfo: FormatParamInfo + private val chipIdToSampleRate = HashMap() + private val sampleRateToChipId = HashMap() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -31,7 +35,9 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(), ): 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) { - 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() } } } diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/java/com/chiller3/bcr/Preferences.kt index f27100ccf..c1274b32f 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/java/com/chiller3/bcr/Preferences.kt @@ -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() + } } \ 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 e5e22b9b6..8b1e86cb1 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -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) diff --git a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt index edd6b41e9..08871a895 100644 --- a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt +++ b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt @@ -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() } } diff --git a/app/src/main/java/com/chiller3/bcr/format/SampleRates.kt b/app/src/main/java/com/chiller3/bcr/format/SampleRates.kt new file mode 100644 index 000000000..2ff4a8251 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/format/SampleRates.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/format_bottom_sheet.xml b/app/src/main/res/layout/format_bottom_sheet.xml index f28479448..dd476a745 100644 --- a/app/src/main/res/layout/format_bottom_sheet.xml +++ b/app/src/main/res/layout/format_bottom_sheet.xml @@ -1,52 +1,72 @@ - - - - - + android:layout_height="wrap_content"> + android:gravity="center_horizontal" + android:padding="@dimen/bottom_sheet_overall_padding"> + + + + + + + + + + + - - + app:selectionRequired="true" + app:singleSelection="true" /> - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cda8c0d3c..d9278a5db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,8 @@ Output format Compression level Bitrate + Sample rate + Native sample rate Reset to defaults