Skip to content

Commit 4bf7c33

Browse files
committed
add readme
1 parent 8dc5dd7 commit 4bf7c33

File tree

7 files changed

+305
-0
lines changed

7 files changed

+305
-0
lines changed

README.md

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
# 背景
2+
3+
|期待效果|初步效果|
4+
|:---:|:---:|
5+
|![](images/audio_waveform.png)|![](images/final.png)|
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+
![](images/python_output.png)
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+
|![](images/rms.png)|![](images/rms_value.jpg)|
272+
273+
每一个绘制点的数值范围为 [-RMS, RMS]。如下就是我实现的 RMS,我这是一个动态计算 RMS 的方法。
274+
275+
> 啥叫动态呢?就是计算的过程在解码的过程中进行,因为如果我们等解码完成之后再进行 RMS,容易因为数据量过大造成 Android 应用发生 OOM,因为一个 2 分钟的常见的音乐(44.1 KHz16 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+
|![](images/final.png)|![](images/audio_pro.png)|
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

images/audio_pro.png

214 KB
Loading

images/audio_waveform.png

154 KB
Loading

images/final.png

48.7 KB
Loading

images/python_output.png

124 KB
Loading

images/rms.png

4.12 KB
Loading

images/rms_value.jpg

40.2 KB
Loading

0 commit comments

Comments
 (0)