Android使用MediaCodec/MediaMuxer渲染带静音间隔PCM音轨异常
问题描述
在Android平台实现简易“点击音轨”文件渲染功能:输入为单段音效的PCM编码数据,以及ClickTrack类定义的有限静音间隔序列,期望输出可正常播放的.m4a文件,文件中音效按照给定间隔重复排布、正确渲染。
当前生成的文件存在半损坏异常:所有重复的音效片段会在文件开头以最快速度连续播放,之后剩余全段时长均为静音;但音轨总时长符合预期,初步判断呈现时间戳(presentation times)配置无误。
相关实现代码
fun render(clickTrack: ClickTrack, onProgress: (Float) -> Unit, onFinished: () -> Unit): File? { var muxer: MediaMuxer? = null var codec: MediaCodec? = null try { val audioFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, 44100, 2) .apply { setInteger(MediaFormat.KEY_BIT_RATE, 96 * 1024) } val outputFile = File.createTempFile("click_track_export", ".m4a", context.cacheDir) muxer = MediaMuxer(outputFile.path, MediaFormat.OutputFormat.MUXER_OUTPUT_MPEG_4) val codecName = MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(audioFormat)!! codec = MediaCodec.createByCodecName(codecName) codec.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.start() // 为方便处理将点击音轨转为带时间戳的音效缓冲序列(所有音效内容一致) // 为节省内存,间隔段的静音数据没有提前生成在缓冲中 val samples = clickTrack.toSamples() val bytesToWrite = samples.sumOf { it.data.data.size.toLong() } val bufferInfo = MediaCodec.BufferInfo() var bytesWritten = 0L var index = 0 var endOfInput = samples.isEmpty() var endOfOutput = samples.isEmpty() var sample = samples.getOrNull(index) var sampleBuffer: ByteBuffer? = null while (!endOfInput || !endOfOutput) { if (!endOfInput) { if (sampleBuffer == null || !sampleBuffer.hasRemaining()) { sample = samples[index] sampleBuffer = ByteBuffer.wrap(samples[index].data.data) ++index } sample!! sampleBuffer!! val inputBufferIndex = codec.dequeueInputBuffer(0L) if (inputBufferIndex >= 0) { val inputBuffer = codec.getInputBuffer(inputBufferIndex)!! while (sampleBuffer.hasRemaining() && inputBuffer.hasRemaining()) { inputBuffer.put(sampleBuffer.get()) ++bytesWritten } onProgress(bytesWritten.toFloat() / bytesToWrite) endOfInput = !sampleBuffer.hasRemaining() && index == samples.size codec.queueInputBuffer( inputBufferIndex, 0, inputBuffer.position(), sample.timestampUs, if (endOfInput) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0 ) } } if (!endOfOutput) { val outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, 0L) if (outputBufferIndex >= 0) { val outputBuffer = codec.getOutputBuffer(outputBufferIndex)!! muxer.writeSampleData(0, outputBuffer, bufferInfo) codec.releaseOutputBuffer(outputBufferIndex, false) } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 不直接使用初始创建的audioFormat,避免编码器专属配置数据(CSD)不匹配 muxer.addTrack(codec.outputFormat) muxer.start() } endOfOutput = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 } } return outputFile } catch (t: Throwable) { Timber.e(t, "Failed to render track") } finally { try { codec?.stop() } catch (t: Throwable) { Timber.e(t, "Failed to stop code") } finally { codec?.release() } try { muxer?.stop() } catch (t: Throwable) { Timber.e(t, "Failed to stop muxer") } finally { muxer?.release() } onFinished() } return null } // 类定义 class Sample( val data: PcmData, val timestampUs: Long, ) class PcmData( val pcmEncoding: Int, val sampleRate: Int, val channelCount: Int, val data: ByteArray, )
故障根因
核心问题是对音频编码器的工作逻辑理解错误:
- 音频编码器处理的是时间连续的PCM字节流,输出帧的时间戳是按送入PCM的字节数顺序计算的,不会因为你给输入Buffer设置了跳变的时间戳就自动生成静音段填补时间空洞。
- 现有代码为了省内存跳过了间隔段的静音数据,把所有音效的PCM连续送入编码器,却给每个输入Buffer打了间隔后的时间戳。最终编码器输出的音频数据实际总时长就是所有音效拼接起来的长度,muxer按照时间戳写入文件时,就会出现所有音效挤在文件开头,后面时间戳对应的位置没有有效音频、全是静音的现象,和遇到的故障完全吻合。
- 额外隐患:创建
audioFormat时没有显式指定KEY_PCM_ENCODING参数,编码器会默认使用16bit PCM配置,如果输入PCM的位宽和默认值不匹配,会直接导致编码出的音频失真;同时代码没有校验输入PCM的采样率、声道数和编码器配置的44100Hz双声道是否一致,参数不匹配时也会出现输出异常。
修复方案
- 不能跳过静音段直接送入音效数据,必须按时间轴顺序构造连续的PCM流:两段音效的间隔位置,填充对应时长的全0字节(即静音PCM数据),保证送入编码器的PCM从时间0到音轨总结束时间是连续无空洞的。
- 送入编码器的输入Buffer时间戳必须和当前送入的PCM数据实际对应的时间严格匹配、单调递增:第一段音效从0us开始,音效结束后送入的静音段时间戳要接在音效结束点,下一段音效的时间戳接在静音段结束点,不要出现时间戳跳变。
- 补全音频格式配置,显式指定PCM编码位宽;编码前校验输入PCM的采样率、声道数、编码格式和编码器配置参数完全一致,参数不匹配时先做重采样、声道转换、位宽转换再送入编码器。
内容的提问来源于stack exchange,提问作者Vsevolod Ganin
相关产品推荐
相关产品推荐

