fix(nests): G4+G5 plumb catalog sampleRate through decoder + AudioTrack
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.
Thread sampleRate alongside channelCount through every layer:
- MediaCodecOpusDecoder constructor takes
`sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
and buildOpusIdHeader both take it as a parameter.
- AudioTrackPlayer constructor takes the same. Used in
AudioTrack.getMinBufferSize, the 250 ms-equivalent target
bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
the diagnostic log.
- decoderFactory and playerFactory in NestViewModel become
`(channelCount: Int, sampleRate: Int) -> ...`.
- awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
a private `AudioPipelineConfig(channelCount, sampleRate)`
struct so openSubscription handles both fields uniformly.
`sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
timeout / non-positive declaration with a warning log.
- NestViewModelFactory + NestViewModelTest updated to the
two-arg factory shape.
G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
+24
-7
@@ -86,11 +86,26 @@ class AudioTrackPlayer(
|
||||
* call sites that don't pass a channel count keep the prior behaviour.
|
||||
*/
|
||||
private val channelCount: Int = AudioFormat.CHANNELS,
|
||||
/**
|
||||
* PCM sample rate in Hz. Drives the AudioTrack output rate and the
|
||||
* 250 ms target-buffer calculation. For Opus, Android's Codec2
|
||||
* decoder always emits 48 kHz PCM regardless of the OpusHead
|
||||
* `inputSampleRate`, so for the production Opus path this is
|
||||
* always [AudioFormat.SAMPLE_RATE_HZ] — but threading the
|
||||
* parameter through keeps the AudioTrack's declared rate matched
|
||||
* to whatever the catalog says, and lets a future codec or
|
||||
* container variant whose decoder DOES respect input sample rate
|
||||
* (a non-Opus rendition) get the correct PCM clock.
|
||||
*/
|
||||
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
|
||||
) : AudioPlayer {
|
||||
init {
|
||||
require(channelCount in 1..2) {
|
||||
"AudioTrackPlayer supports mono (1) or stereo (2) only, got $channelCount"
|
||||
}
|
||||
require(sampleRate > 0) {
|
||||
"AudioTrackPlayer sampleRate must be positive, got $sampleRate"
|
||||
}
|
||||
}
|
||||
|
||||
private var track: AudioTrack? = null
|
||||
@@ -123,14 +138,14 @@ class AudioTrackPlayer(
|
||||
|
||||
val minBuffer =
|
||||
AudioTrack.getMinBufferSize(
|
||||
AudioFormat.SAMPLE_RATE_HZ,
|
||||
sampleRate,
|
||||
channelMask,
|
||||
AndroidAudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBuffer <= 0) {
|
||||
throw AudioException(
|
||||
AudioException.Kind.DeviceUnavailable,
|
||||
"AudioTrack.getMinBufferSize returned $minBuffer for ${AudioFormat.SAMPLE_RATE_HZ} Hz",
|
||||
"AudioTrack.getMinBufferSize returned $minBuffer for $sampleRate Hz",
|
||||
)
|
||||
}
|
||||
// Target ~250 ms of audio: enough headroom so the decode loop can
|
||||
@@ -138,10 +153,12 @@ class AudioTrackPlayer(
|
||||
// underruns. Take the larger of `minBuffer * 16` and an explicit
|
||||
// 250 ms-equivalent so devices that report a small minBuffer still
|
||||
// get the same wall-clock slack. Stereo doubles the byte count
|
||||
// per sample (interleaved L,R 16-bit shorts) — scaling
|
||||
// [channelCount] in keeps the wall-clock target constant.
|
||||
// per sample (interleaved L,R 16-bit shorts); a higher sample
|
||||
// rate scales the per-second byte count linearly — both factor
|
||||
// into the target so the wall-clock 250 ms target is preserved
|
||||
// regardless of channel layout / sample rate.
|
||||
val targetBytes250Ms =
|
||||
(AudioFormat.SAMPLE_RATE_HZ / 4) * AudioFormat.BYTES_PER_SAMPLE * channelCount
|
||||
(sampleRate / 4) * AudioFormat.BYTES_PER_SAMPLE * channelCount
|
||||
val bufferBytes = maxOf(minBuffer * 16, targetBytes250Ms)
|
||||
|
||||
val newTrack =
|
||||
@@ -158,7 +175,7 @@ class AudioTrackPlayer(
|
||||
AndroidAudioFormat
|
||||
.Builder()
|
||||
.setEncoding(AndroidAudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(AudioFormat.SAMPLE_RATE_HZ)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask)
|
||||
.build(),
|
||||
).setBufferSizeInBytes(bufferBytes)
|
||||
@@ -199,7 +216,7 @@ class AudioTrackPlayer(
|
||||
track = newTrack
|
||||
com.vitorpamplona.quartz.utils.Log.d("NestPlay") {
|
||||
"AudioTrack allocated: state=${newTrack.state} playState=${newTrack.playState} " +
|
||||
"bufferSizeBytes=$bufferBytes minBuffer=$minBuffer sampleRate=${AudioFormat.SAMPLE_RATE_HZ}"
|
||||
"bufferSizeBytes=$bufferBytes minBuffer=$minBuffer sampleRate=$sampleRate"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -50,11 +50,30 @@ import java.nio.ByteOrder
|
||||
*/
|
||||
class MediaCodecOpusDecoder(
|
||||
private val channelCount: Int = AudioFormat.CHANNELS,
|
||||
/**
|
||||
* Source sample rate in Hz. Drives the OpusHead `inputSampleRate`
|
||||
* field and the MediaFormat `audio/opus` sample-rate hint.
|
||||
*
|
||||
* **Note on Opus's actual decode rate**: Opus always decodes at
|
||||
* 48 kHz internally regardless of [sampleRate] (the codec's
|
||||
* design — RFC 6716). Android's Codec2 `audio/opus` decoder
|
||||
* always emits 48 kHz PCM. So this parameter is informational at
|
||||
* the OpusHead level and doesn't affect the PCM rate the
|
||||
* downstream [AudioPlayer] sees. We thread it through anyway so
|
||||
* the decoder declaration matches what the catalog says, and so a
|
||||
* future codec or container variant that DOES respect input
|
||||
* sample rate (e.g. a non-Opus rendition) gets the correct
|
||||
* configuration.
|
||||
*/
|
||||
private val sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ,
|
||||
) : OpusDecoder {
|
||||
init {
|
||||
require(channelCount in 1..2) {
|
||||
"MediaCodecOpusDecoder supports mono (1) or stereo (2) only, got $channelCount"
|
||||
}
|
||||
require(sampleRate > 0) {
|
||||
"MediaCodecOpusDecoder sampleRate must be positive, got $sampleRate"
|
||||
}
|
||||
}
|
||||
|
||||
private val codec: MediaCodec =
|
||||
@@ -62,7 +81,7 @@ class MediaCodecOpusDecoder(
|
||||
MediaCodec
|
||||
.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||
.apply {
|
||||
configure(buildFormat(channelCount), null, null, 0)
|
||||
configure(buildFormat(channelCount, sampleRate), null, null, 0)
|
||||
start()
|
||||
}.also {
|
||||
com.vitorpamplona.quartz.utils.Log.d("NestPlay") {
|
||||
@@ -221,14 +240,17 @@ class MediaCodecOpusDecoder(
|
||||
|
||||
private const val FRAME_DURATION_US = 20_000L // 20 ms
|
||||
|
||||
private fun buildFormat(channelCount: Int): MediaFormat {
|
||||
private fun buildFormat(
|
||||
channelCount: Int,
|
||||
sampleRate: Int,
|
||||
): MediaFormat {
|
||||
val format =
|
||||
MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS,
|
||||
AudioFormat.SAMPLE_RATE_HZ,
|
||||
sampleRate,
|
||||
channelCount,
|
||||
)
|
||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(buildOpusIdHeader(channelCount)))
|
||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(buildOpusIdHeader(channelCount, sampleRate)))
|
||||
// Pre-skip + seek pre-roll: both zero, encoded as little-endian
|
||||
// 64-bit nanoseconds per Android's MediaCodec contract.
|
||||
format.setByteBuffer("csd-1", ByteBuffer.wrap(zeroLongLe()))
|
||||
@@ -236,7 +258,10 @@ class MediaCodecOpusDecoder(
|
||||
return format
|
||||
}
|
||||
|
||||
private fun buildOpusIdHeader(channelCount: Int): ByteArray {
|
||||
private fun buildOpusIdHeader(
|
||||
channelCount: Int,
|
||||
sampleRate: Int,
|
||||
): ByteArray {
|
||||
// RFC 7845 §5.1 — 19 bytes, mapping family 0 (covers mono and
|
||||
// stereo with implicit L,R interleaving; no per-channel
|
||||
// mapping table required).
|
||||
@@ -245,7 +270,7 @@ class MediaCodecOpusDecoder(
|
||||
buf.put(1.toByte()) // version
|
||||
buf.put(channelCount.toByte()) // channel count (1 or 2)
|
||||
buf.putShort(0) // pre-skip
|
||||
buf.putInt(AudioFormat.SAMPLE_RATE_HZ) // input sample rate
|
||||
buf.putInt(sampleRate) // input sample rate
|
||||
buf.putShort(0) // output gain (Q7.8 dB)
|
||||
buf.put(0.toByte()) // mapping family 0
|
||||
return buf.array()
|
||||
|
||||
Reference in New Issue
Block a user