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

Create better abstraction for describing format parameters #49

Merged
merged 1 commit into from
May 29, 2022
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
53 changes: 27 additions & 26 deletions app/src/main/java/com/chiller3/bcr/FormatBottomSheetFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.FormatParamType
import com.chiller3.bcr.format.Formats
import com.chiller3.bcr.format.*
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
Expand All @@ -27,7 +25,7 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
private lateinit var formatNameGroup: MaterialButtonToggleGroup
private val buttonIdToFormat = HashMap<Int, Format>()
private val formatToButtonId = HashMap<Format, Int>()
private lateinit var formatParamType: FormatParamType
private lateinit var formatParamInfo: FormatParamInfo

override fun onCreateView(
inflater: LayoutInflater,
Expand Down Expand Up @@ -86,30 +84,33 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
*/
private fun refreshParam() {
val (format, param) = Formats.fromPreferences(requireContext())
formatParamType = format.paramType
formatParamInfo = format.paramInfo

val titleResId = when (format.paramType) {
FormatParamType.CompressionLevel -> R.string.bottom_sheet_compression_level
FormatParamType.Bitrate -> R.string.bottom_sheet_bitrate
}

formatParamGroup.isVisible = format.paramRange.first != format.paramRange.last

formatParamTitle.setText(titleResId)
when (val info = format.paramInfo) {
is RangedParamInfo -> {
formatParamGroup.isVisible = true

formatParamSlider.valueFrom = format.paramRange.first.toFloat()
formatParamSlider.valueTo = format.paramRange.last.toFloat()
formatParamSlider.stepSize = format.paramStepSize.toFloat()
formatParamSlider.value = (param ?: format.paramDefault).toFloat()
formatParamTitle.setText(when (info.type) {
RangedParamType.CompressionLevel -> R.string.bottom_sheet_compression_level
RangedParamType.Bitrate -> R.string.bottom_sheet_bitrate
})

// Needed due to a bug in the material3 library where the slider label does not disappear
// when the slider visibility is set to View.GONE
// https://github.com/material-components/material-components-android/issues/2726
if (format.paramRange.first == format.paramRange.last) {
val ensureLabelsRemoved = formatParamSlider.javaClass.superclass
.getDeclaredMethod("ensureLabelsRemoved")
ensureLabelsRemoved.isAccessible = true
ensureLabelsRemoved.invoke(formatParamSlider)
formatParamSlider.valueFrom = info.range.first.toFloat()
formatParamSlider.valueTo = info.range.last.toFloat()
formatParamSlider.stepSize = info.stepSize.toFloat()
formatParamSlider.value = (param ?: info.default).toFloat()
}
NoParamInfo -> {
formatParamGroup.isVisible = false

// Needed due to a bug in the material3 library where the slider label does not disappear
// when the slider visibility is set to View.GONE
// https://github.com/material-components/material-components-android/issues/2726
val ensureLabelsRemoved = formatParamSlider.javaClass.superclass
.getDeclaredMethod("ensureLabelsRemoved")
ensureLabelsRemoved.isAccessible = true
ensureLabelsRemoved.invoke(formatParamSlider)
}
}
}

Expand All @@ -125,7 +126,7 @@ class FormatBottomSheetFragment : BottomSheetDialogFragment(),
}

override fun getFormattedValue(value: Float): String =
formatParamType.format(value.toUInt())
formatParamInfo.format(value.toUInt())

override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
when (slider) {
Expand Down
11 changes: 8 additions & 3 deletions app/src/main/java/com/chiller3/bcr/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import com.chiller3.bcr.format.Formats
import com.chiller3.bcr.format.NoParamInfo
import com.chiller3.bcr.format.RangedParamInfo

class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -108,11 +110,14 @@ class SettingsActivity : AppCompatActivity() {

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

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

private fun refreshInhibitBatteryOptState() {
Expand Down
18 changes: 10 additions & 8 deletions app/src/main/java/com/chiller3/bcr/format/AacFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import java.io.FileDescriptor

object AacFormat : Format() {
override val name: String = "M4A/AAC"
override val paramType: FormatParamType = FormatParamType.Bitrate
// The format 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 paramRange: UIntRange = 24_000u..128_000u
override val paramStepSize: UInt = 4_000u
override val paramDefault: UInt = 64_000u
override val paramInfo: FormatParamInfo = RangedParamInfo(
RangedParamType.Bitrate,
// The format 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
24_000u..128_000u,
4_000u,
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
Expand Down
12 changes: 7 additions & 5 deletions app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import java.io.FileDescriptor

object FlacFormat : Format() {
override val name: String = "FLAC"
override val paramType: FormatParamType = FormatParamType.CompressionLevel
override val paramRange: UIntRange = 0u..8u
override val paramStepSize: UInt = 1u
// Devices are fast enough nowadays to use the highest compression for realtime recording
override val paramDefault: UInt = 8u
override val paramInfo: FormatParamInfo = RangedParamInfo(
RangedParamType.CompressionLevel,
0u..8u,
1u,
// Devices are fast enough nowadays to use the highest compression for realtime recording
8u,
)
override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC
override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC
override val passthrough: Boolean = false
Expand Down
26 changes: 8 additions & 18 deletions app/src/main/java/com/chiller3/bcr/format/Format.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,8 @@ sealed class Format {
/** User-facing name of the format. */
abstract val name: String

/** Meaning of the format parameter value. */
abstract val paramType: FormatParamType

/** Valid range for the format-specific parameter value. */
abstract val paramRange: UIntRange

/** Reasonable step size for selecting a value via the UI. */
abstract val paramStepSize: UInt

/** Default format parameter value. */
abstract val paramDefault: UInt
/** Details about the format parameter range and default value. */
abstract val paramInfo: FormatParamInfo

/** The MIME type of the container storing the encoded audio stream. */
abstract val mimeTypeContainer: String
Expand All @@ -46,14 +37,13 @@ sealed class Format {
*
* @param audioFormat [AudioFormat.getSampleRate] must not be
* [AudioFormat.SAMPLE_RATE_UNSPECIFIED].
* @param param Format-specific parameter value. Must be in the [paramRange] range. If null,
* [paramDefault] is used.
* @param param Format-specific parameter value. Must be valid according to [paramInfo].
*
* @throws IllegalArgumentException if [param] is outside [paramRange]
* @throws IllegalArgumentException if [FormatParamInfo.validate] fails
*/
fun getMediaFormat(audioFormat: AudioFormat, param: UInt?): MediaFormat {
if (param != null && param !in paramRange) {
throw IllegalArgumentException("Parameter $param not in range $paramRange")
if (param != null) {
paramInfo.validate(param)
}

val format = MediaFormat().apply {
Expand All @@ -62,15 +52,15 @@ sealed class Format {
setInteger(MediaFormat.KEY_SAMPLE_RATE, audioFormat.sampleRate)
}

updateMediaFormat(format, audioFormat, param ?: paramDefault)
updateMediaFormat(format, audioFormat, param ?: paramInfo.default)

return format
}

/**
* Update [mediaFormat] with parameter keys relevant to the format-specific parameter.
*
* @param param Guaranteed to be within [paramRange]
* @param param Guaranteed to be valid according to [paramInfo]
*/
protected abstract fun updateMediaFormat(
mediaFormat: MediaFormat,
Expand Down
77 changes: 77 additions & 0 deletions app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.chiller3.bcr.format

sealed class FormatParamInfo(val default: UInt) {
/**
* Ensure that [param] is valid.
*
* @throws IllegalArgumentException if [param] is invalid
*/
abstract fun validate(param: UInt)

/**
* Convert a potentially-invalid [param] value to the nearest valid value.
*/
abstract fun toNearest(param: UInt): UInt

/**
* Format [param] to present as a user-facing string.
*/
abstract fun format(param: UInt): String
}

enum class RangedParamType {
CompressionLevel,
Bitrate,
}

class RangedParamInfo(
val type: RangedParamType,
val range: UIntRange,
val stepSize: UInt,
default: UInt,
) : FormatParamInfo(default) {
override fun validate(param: UInt) {
if (param !in range) {
throw IllegalArgumentException("Parameter ${format(param)} is not in the range: " +
"[${format(range.first)}, ${format(range.last)}]")
}
}

/** Clamp [param] to [range] and snap to nearest [stepSize]. */
override fun toNearest(param: UInt): UInt {
val offset = param.coerceIn(range) - range.first
val roundedDown = (offset / stepSize) * stepSize

return range.first + if (roundedDown == offset) {
// Already on step size boundary
offset
} else if (roundedDown >= UInt.MAX_VALUE - stepSize) {
// Rounded up would overflow
roundedDown
} else {
// Round to closer boundary, preferring the upper boundary if it's in the middle
val roundedUp = roundedDown + stepSize
if (roundedUp - offset <= offset - roundedDown) {
roundedUp
} else {
roundedDown
}
}
}

override fun format(param: UInt): String =
when (type) {
RangedParamType.CompressionLevel -> param.toString()
RangedParamType.Bitrate -> "${param / 1_000u} kbps"
}
}

object NoParamInfo : FormatParamInfo(0u) {
override fun validate(param: UInt) {
// Always valid
}

override fun toNearest(param: UInt): UInt = param

override fun format(param: UInt): String = ""
}
15 changes: 0 additions & 15 deletions app/src/main/java/com/chiller3/bcr/format/FormatParamType.kt

This file was deleted.

7 changes: 5 additions & 2 deletions app/src/main/java/com/chiller3/bcr/format/Formats.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ object Formats {
?.let { if (it.supported) { it } else { null } }
?: default

// Clamp to the format's allowed parameter range in case the range is shrunk
val param = Preferences.getFormatParam(context, format.name)?.coerceIn(format.paramRange)
// Convert the saved value to the nearest valid value (eg. in case bitrate range or step
// size in changed in a future version)
val param = Preferences.getFormatParam(context, format.name)?.let {
format.paramInfo.toNearest(it)
}

return Pair(format, param)
}
Expand Down
14 changes: 8 additions & 6 deletions app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import java.io.FileDescriptor

object OpusFormat : Format() {
override val name: String = "OGG/Opus"
override val paramType: FormatParamType = FormatParamType.Bitrate
override val paramRange: UIntRange = 6_000u..510_000u
override val paramStepSize: UInt = 2_000u
// "Essentially transparent mono or stereo speech, reasonable music"
// https://wiki.hydrogenaud.io/index.php?title=Opus
override val paramDefault: UInt = 48_000u
override val paramInfo: FormatParamInfo = RangedParamInfo(
RangedParamType.Bitrate,
6_000u..510_000u,
2_000u,
// "Essentially transparent mono or stereo speech, reasonable music"
// https://wiki.hydrogenaud.io/index.php?title=Opus
48_000u,
)
// https://datatracker.ietf.org/doc/html/rfc7845#section-9
override val mimeTypeContainer: String = "audio/ogg"
override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_OPUS
Expand Down
5 changes: 1 addition & 4 deletions app/src/main/java/com/chiller3/bcr/format/WaveFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ object WaveFormat : Format() {
const val KEY_X_FRAME_SIZE_IN_BYTES = "x-frame-size-in-bytes"

override val name: String = "WAV/PCM"
override val paramType: FormatParamType = FormatParamType.CompressionLevel
override val paramRange: UIntRange = 0u..0u
override val paramStepSize: UInt = 0u
override val paramDefault: UInt = 0u
override val paramInfo: FormatParamInfo = NoParamInfo
// Should be "audio/vnd.wave" [1], but Android only recognizes "audio/x-wav" [2] for the
// purpose of picking an appropriate file extension when creating a file via SAF.
// [1] https://datatracker.ietf.org/doc/html/rfc2361
Expand Down