|
| 1 | +# 背景 |
| 2 | + |
| 3 | +|期待效果|初步效果| |
| 4 | +|:---:|:---:| |
| 5 | +||| |
| 6 | + |
| 7 | +首先这样的波形图,是根据音频在采样点的采样值来绘制的。像 mp3 m4a 的音乐格式,都会经历音频采样、编码的过程。采样的结果是 PCM,对 PCM 利用不同的编码算法进行编码就产生了不同格式的音乐文件。 |
| 8 | + |
| 9 | +所以要得到绘制波形图的数据,**第一步**需要将压缩编码过的 PCM 音乐,解码为 PCM。这一步在 Android 上可以使用 MediaCodec 实现。获取到了 PCM 数据之后,如果你直接利用采样数据开始绘制,你应该会发现,数据量太大了,会直接导致你的绘制出现问题。 |
| 10 | + |
| 11 | +> 比如一段 PCM 音频数据,44.1 kHz 的采样率就会在每秒生成 44100 个采样点,如果我们要绘制这段音频的音量波形图,1秒就要绘制 44100 个点(单声道的情况下),如果音频时间为10秒,则有 441000 个点。当代显示器的分辨率常见的就是 4K、2K,4K 分辨率下屏幕在水平方向最多能展示 4k 个像素点,如果不对上百万的采样点进行二次采样减小数据的量级,那么绘制出来的波形图,要么非常长,要么不长却会很难画清晰。 |
| 12 | +
|
| 13 | +所以**第二步**就是对 PCM 数据进行二次采样。 |
| 14 | + |
| 15 | +# 获取 PCM 数据 |
| 16 | + |
| 17 | +## 解码音乐 |
| 18 | + |
| 19 | +Android 上利用 MediaCodec 解码音乐还是比较方便的: |
| 20 | + |
| 21 | +```kotlin |
| 22 | +class AudioWaveformGenerator( |
| 23 | + private val path: String, |
| 24 | + private val expectPoints: Int |
| 25 | +) : MediaCodec.Callback() { |
| 26 | + private lateinit var decoder: MediaCodec |
| 27 | + private lateinit var extractor: MediaExtractor |
| 28 | + |
| 29 | + private var onFinish: () -> Unit = {} |
| 30 | + |
| 31 | + @Throws(Exception::class) |
| 32 | + fun startDecode(onFinish: () -> Unit) { |
| 33 | + sampleData.clear() |
| 34 | + this.onFinish = onFinish |
| 35 | + try { |
| 36 | + val format = getFormat(path) ?: error("Not found audio") |
| 37 | + val mime = format.getString(MediaFormat.KEY_MIME) ?: error("Not found mime") |
| 38 | + decoder = MediaCodec.createDecoderByType(mime) |
| 39 | + decoder.configure(format, null, null, 0) |
| 40 | + decoder.setCallback(this) |
| 41 | + decoder.start() |
| 42 | + } catch (e: java.lang.Exception) { |
| 43 | + Log.e(TAG, "start decode", e) |
| 44 | + throw e |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + private fun getFormat(path: String): MediaFormat? { |
| 49 | + extractor = MediaExtractor() |
| 50 | + extractor.setDataSource(path) |
| 51 | + val trackCount = extractor.trackCount |
| 52 | + repeat(trackCount) { |
| 53 | + val format = extractor.getTrackFormat(it) |
| 54 | + val mime = format.getString(MediaFormat.KEY_MIME) ?: "" |
| 55 | + if (mime.contains("audio")) { |
| 56 | + durationS = format.getLong(MediaFormat.KEY_DURATION) / 1000000 |
| 57 | + extractor.selectTrack(it) |
| 58 | + return format |
| 59 | + } |
| 60 | + } |
| 61 | + return null |
| 62 | + } |
| 63 | + |
| 64 | + private var inputEof = false |
| 65 | + private var sampleRate = 0 |
| 66 | + private var channels = 1 |
| 67 | + private var pcmEncodingBit = 16 |
| 68 | + private var totalSamples = 0L |
| 69 | + private var durationS = 0L |
| 70 | + private var perSamplePoints = 0L |
| 71 | + |
| 72 | + override fun onOutputBufferAvailable( |
| 73 | + codec: MediaCodec, |
| 74 | + index: Int, |
| 75 | + info: MediaCodec.BufferInfo |
| 76 | + ) { |
| 77 | + } |
| 78 | + |
| 79 | + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { |
| 80 | + if (inputEof) return |
| 81 | + codec.getInputBuffer(index)?.let { buf -> |
| 82 | + val size = extractor.readSampleData(buf, 0) |
| 83 | + if (size > 0) { |
| 84 | + codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0) |
| 85 | + extractor.advance() |
| 86 | + } else { |
| 87 | + codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) |
| 88 | + inputEof = true |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { |
| 94 | + sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) |
| 95 | + channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) |
| 96 | + pcmEncodingBit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| 97 | + if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) { |
| 98 | + when (format.getInteger(MediaFormat.KEY_PCM_ENCODING)) { |
| 99 | + AudioFormat.ENCODING_PCM_16BIT -> 16 |
| 100 | + AudioFormat.ENCODING_PCM_8BIT -> 8 |
| 101 | + AudioFormat.ENCODING_PCM_FLOAT -> 32 |
| 102 | + else -> 16 |
| 103 | + } |
| 104 | + } else { |
| 105 | + 16 |
| 106 | + } |
| 107 | + } else { |
| 108 | + 16 |
| 109 | + } |
| 110 | + totalSamples = sampleRate.toLong() * durationS |
| 111 | + perSamplePoints = totalSamples / expectPoints |
| 112 | + } |
| 113 | + |
| 114 | + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { |
| 115 | + Log.e(TAG, "onError", e) |
| 116 | + } |
| 117 | + |
| 118 | +} |
| 119 | + |
| 120 | +fun MediaCodec.BufferInfo.isEof() = flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 |
| 121 | +fun MediaCodec.BufferInfo.isConfig() = flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0 |
| 122 | +``` |
| 123 | + |
| 124 | +## 读取 PCM 采样数据 |
| 125 | + |
| 126 | +需要注意 PCM 的数据部分的字节储存方式是**小端序**,如果采样位数大于了 8 位,就需要在读取时注意按照小端序方式读取。 |
| 127 | +接着为了方便后续处理,在读取到了采样值后,首先将每个采样点的采样值转化到 [-1, 1] 的 float 区间内: |
| 128 | +1. 如果是 8 bit 采样大小的数据:读取为 byte(注意:Java 上由于没有无符号类型,所以在 Java 上最好读取为 int),然后除以 128(2^8/2),转化到 [-1, 1] 区间内 |
| 129 | +2. 如果是大于或等于 16 bit 采样大小的数据: |
| 130 | + - 16 bit:采样值范围为 -32678 ~ 32678,读取为 float,然后除以 32678(2^16/2),转化到 [-1, 1] 区间内 |
| 131 | + - 24 bit: 采样值范围为 -8388608 ~ 8388608,读取为 double,然后除以 8388608(2^24/2),转化到 [-1, 1] 区间内 |
| 132 | + - 32 bit 和 64 bit 进行和上面类似的转化 |
| 133 | + |
| 134 | +> 为什么要转化到 [-1, 1] 的区间内呢,这涉及到后面的重采样 |
| 135 | +
|
| 136 | +```kotlin |
| 137 | +private fun handle8bit(size: Int, buf: ByteBuffer) { |
| 138 | + repeat(size / if (channels == 2) 2 else 1) { |
| 139 | + // 左声道 |
| 140 | + // 8 位采样的范围是: -128 ~ 128 |
| 141 | + val left = buf.get().toInt() / 128f |
| 142 | + if (channels == 2) { |
| 143 | + buf.get() |
| 144 | + } |
| 145 | + calRMS(left) |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +private fun handle16bit(size: Int, buf: ByteBuffer) { |
| 150 | + repeat(size / if (channels == 2) 4 else 2) { |
| 151 | + // 左声道 |
| 152 | + val a = buf.get().toInt() |
| 153 | + val b = buf.get().toInt() shl 8 |
| 154 | + // 16 位采样的范围是: -32768 ~ 32768 |
| 155 | + val left = (a or b) / 32768f |
| 156 | + if (channels == 2) { |
| 157 | + buf.get() |
| 158 | + buf.get() |
| 159 | + } |
| 160 | + calRMS(left) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +private fun handle32bit(size: Int, buf: ByteBuffer) { |
| 165 | + repeat(size / if (channels == 2) 8 else 4) { |
| 166 | + // 左声道 |
| 167 | + val a = buf.get().toLong() |
| 168 | + val b = buf.get().toLong() shl 8 |
| 169 | + val c = buf.get().toLong() shl 16 |
| 170 | + val d = buf.get().toLong() shl 24 |
| 171 | + // 32 位采样的范围是: -2147483648 ~ 2147483648 |
| 172 | + val left = (a or b or c or d) / 2147483648f |
| 173 | + if (channels == 2) { |
| 174 | + buf.get() |
| 175 | + buf.get() |
| 176 | + buf.get() |
| 177 | + buf.get() |
| 178 | + } |
| 179 | + calRMS(left) |
| 180 | + } |
| 181 | +} |
| 182 | +``` |
| 183 | + |
| 184 | +然后在 MediaCodec 的输出回调中根据采样大小调用上面的方法: |
| 185 | + |
| 186 | +```kotlin |
| 187 | +override fun onOutputBufferAvailable( |
| 188 | + codec: MediaCodec, |
| 189 | + index: Int, |
| 190 | + info: MediaCodec.BufferInfo |
| 191 | +) { |
| 192 | + if (info.size > 0) { |
| 193 | + codec.getOutputBuffer(index)?.let { buf -> |
| 194 | + val size = info.size |
| 195 | + buf.position(info.offset) |
| 196 | + |
| 197 | + when (pcmEncodingBit) { |
| 198 | + 8 -> { |
| 199 | + handle8bit(size, buf) |
| 200 | + } |
| 201 | + 16 -> { |
| 202 | + handle16bit(size, buf) |
| 203 | + } |
| 204 | + 32 -> { |
| 205 | + handle32bit(size, buf) |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + codec.releaseOutputBuffer(index, false) |
| 210 | + } |
| 211 | + } |
| 212 | +``` |
| 213 | + |
| 214 | +## 利用 Python 验证解码 |
| 215 | + |
| 216 | +可以在解码完成之后,将解码之后的数据存储为 Wav 格式,然后利用如下脚本绘制波形图,测试解码是否正常。 |
| 217 | + |
| 218 | + |
| 219 | + |
| 220 | +```python |
| 221 | +import matplotlib.pyplot as pl |
| 222 | +import numpy as np |
| 223 | +import wave |
| 224 | + |
| 225 | +def read_wav(): |
| 226 | + f = wave.open("test.wav", 'rb') |
| 227 | + params = f.getparams() |
| 228 | + nchannels, sampwidth, framerate, nframes = params[:4] |
| 229 | + print("channels: {} samplewidth:{} framerate:{} frames:{}".format(nchannels, sampwidth, framerate, nframes)) |
| 230 | + str_data = f.readframes(nframes) |
| 231 | + f.close() |
| 232 | + wave_data = np.frombuffer(str_data, dtype=np.short) |
| 233 | + if nchannels == 2: |
| 234 | + wave_data.shape = -1, 2 # 将一维数组拆为二维数组: [1,2,3,4] -> [[1,2], [3,4]] |
| 235 | + wave_data = wave_data.T # 转置数组 [[1,2], [3,4]] -> [[1,3], [2,4]] |
| 236 | + time = np.arange(0, nframes) * (1.0 / framerate) |
| 237 | + pl.subplot(211) |
| 238 | + pl.plot(time, wave_data[0]) # 左声道 |
| 239 | + pl.subplot(212) |
| 240 | + pl.plot(time, wave_data[1], c="g") # 右声道 |
| 241 | + pl.xlabel("time (seconds)") |
| 242 | + pl.show() |
| 243 | + elif nchannels == 1: |
| 244 | + wave_data.shape = -1, 1 |
| 245 | + wave_data = wave_data.T |
| 246 | + time = np.arange(0, nframes) * (1.0 / framerate) |
| 247 | + pl.subplot(211) |
| 248 | + pl.plot(time, wave_data[0]) |
| 249 | + pl.xlabel("time (seconds)") |
| 250 | + pl.show() |
| 251 | + |
| 252 | +if __name__ == "__main__": |
| 253 | + read_wav() |
| 254 | +``` |
| 255 | + |
| 256 | +# 重采样 |
| 257 | + |
| 258 | +在读取到了采样值之后,需要对数据集进行重采样,减少数据集的量级,便于在屏幕上绘制。重采样的方法实现在 `calRMS` 中。 |
| 259 | +具体的计算方法为: |
| 260 | +1. 设数据量总大小为 `T` |
| 261 | +2. 确定你要绘制多少点 `P` |
| 262 | +3. 计算每个绘制点将使用多少数据量进行重采样 `S`, `S=T/P` |
| 263 | +4. 为了让重采样之后的数据集在展现时能最好的表现平均水平,所以为每一个绘制点采用 RMS 算法计算采样值 |
| 264 | + |
| 265 | +## RMS 算法 |
| 266 | + |
| 267 | +> 平方平均数 |
| 268 | + |
| 269 | +|计算方法|结果| |
| 270 | +|:---:|:---:| |
| 271 | +||| |
| 272 | + |
| 273 | +每一个绘制点的数值范围为 [-RMS, RMS]。如下就是我实现的 RMS,我这是一个动态计算 RMS 的方法。 |
| 274 | + |
| 275 | +> 啥叫动态呢?就是计算的过程在解码的过程中进行,因为如果我们等解码完成之后再进行 RMS,容易因为数据量过大造成 Android 应用发生 OOM,因为一个 2 分钟的常见的音乐(44.1 KHz,16 bit 采样),解码完成会产生 10584000 个字节的数据,如果用一个数组来存储,数组将会占用 10 MB 内存。如果一个更大长的音乐,那么内存占用将会更多。所以我只存储每次计算 RMS 之后的结果。 |
| 276 | + |
| 277 | +```kotlin |
| 278 | +private fun calRMS(left: Float) { |
| 279 | + if (sampleCount == perSamplePoints) { |
| 280 | + val rms = sqrt(sampleSum / perSamplePoints) |
| 281 | + sampleData.add(rms.toFloat()) |
| 282 | + sampleCount = 0 |
| 283 | + sampleSum = 0.0 |
| 284 | + } |
| 285 | + |
| 286 | + sampleCount++ |
| 287 | + sampleSum += left.toDouble().pow(2.0) |
| 288 | +} |
| 289 | +``` |
| 290 | + |
| 291 | +> 前面提到了为啥要转化到 [-1, 1] 的区间,其实是为了让 RMS 的结果能在这个区间 |
| 292 | + |
| 293 | +# 最终效果 |
| 294 | + |
| 295 | +|最终效果|专业软件| |
| 296 | +|:---:|:---:| |
| 297 | +||| |
| 298 | + |
| 299 | +可以看到,和专业的音乐编辑软件的显示差不多。 |
| 300 | + |
| 301 | +# 参考 |
| 302 | + |
| 303 | +- https://planetcalc.com/8627/ |
| 304 | +- https://www.egeniq.com/blog/alternative-android-visualizer |
| 305 | +- https://developer.android.com/guide/topics/media/media-formats |
0 commit comments