The lesson text is only available in Chinese for now. Lesson titles, introductions, menus and buttons are shown in English.

Lesson 0823 lessons in totalAbout 8 min

Turning the amplitude-envelope formula into code that actually runs

The last lesson explained three formulas but didn’t implement a single line. This lesson builds the first one from scratch: the amplitude envelope. We load three music clips, check their basic information, plot the waveforms, write the envelope by hand, convert frame numbers to seconds, and finally overlay the envelope on the waveform to compare the three genres. Along the way we find an easy-to-miss abs (absolute value): it makes almost no visible difference to the maximum over a whole clip, but frame by frame it causes 18% to 36% of the frames in the three clips to be underestimated.

导读: 上一课把三个公式讲完了,但一行实现都没写。这一课从头做第一个:振幅包络。我们会载入三段音乐、确认基本信息、画出波形、手写包络、把帧编号换算成秒,最后把包络叠到波形上比较三种曲风。中间还会发现一个容易漏掉的 abs:它在整段最大值上几乎看不出影响,逐帧检查时却会让三段音乐里 18% 到 36% 的帧被低估。

第 8 课属于第 2 组「把录音变成可计算片段」,本组共 5 课

第 1 步:载入三段音乐,看清基本信息

开始计算前,先确认手上的数据是什么样。

from soundlab import io

for name in ["debussy", "duke", "redhot"]:
    y, sr = io.load(name)
    print(name, len(y), sr, len(y) / sr)

实测:

        样本数  采样率  总时长
古典    661500  22050  30.00s
爵士    661500  22050  30.00s
摇滚    661500  22050  30.00s
一个样本 = 0.0454 毫秒

三个数要看:

  • 样本数:一段 30 秒的音乐是 661500 个数。这就是「为什么不能把原始数据直接丢给分类程序」的具体规模。
  • 一个样本多久: 毫秒。第 06 课说过,人耳的时间分辨率约 10 毫秒——一个样本比它短两百多倍。
  • 第 04 课讲过的采样率:三段完全一致。所以呢? 所以帧编号换算成秒时,三段可以用同一个换算。

第 2 步:画出三条波形

import librosa.display
librosa.display.waveshow(y, sr=sr, alpha=0.5)
三段音乐的波形,以及叠在上面的振幅包络

图 1:灰色是原始波形(每段 661500 个数),红色是这一课要算出来的振幅包络。包络贴着波形的外沿走,这就是「包络」这个名字的来历。

先只看灰色那部分:三条波形的外形轮廓明显不同。古典那条起伏最大,摇滚那条最「满」。这一课要做的,就是把这个轮廓变成一串可以计算的数。

第 3 步:手写振幅包络

一种容易直接写出来的版本是这样:

def amplitude_envelope(signal, frame_size, hop_length):
    ae = []
    for i in range(0, len(signal), hop_length):
        ae.append(max(signal[i:i + frame_size]))
    return np.array(ae)

逐行看。下面两个长度参数都在第 06 课定义过:

  • range(0, len(signal), hop_length):从 0 开始,每次往前走一个帧移。i 就是每一帧的起点。
  • signal[i:i + frame_size]:从起点往后取一个帧长,这就是第 帧。
  • max(...):取这一帧里最大的那个数。

参数用 FRAME_SIZE = 1024、HOP_LENGTH = 512,和第 06 课定下的一致。

同一段循环也可以压成一行,做的事完全一样:

np.array([max(signal[i:i + frame_size])
          for i in range(0, len(signal), hop_length)])

这个写法漏了一个 abs

上一课手算八个数时就发现了:max 会漏掉负方向上的峰。现在在真实音乐上量一次,看看影响有多大。

先看整段的最大值:

        不取绝对值   取绝对值
古典     0.6196     0.6252
爵士     0.5634     0.5634
摇滚     0.7645     0.7645

几乎一样。爵士和摇滚甚至一模一样。

如果只看这张表,会得出「加不加 abs 无所谓」的结论——而这个结论是错的。因为一首 30 秒的曲子里,最高的正峰和最深的负谷本来就差不多深,整段的最大值把问题掩盖了。

逐帧看才暴露:

       帧数  被低估   占比   最多低估
古典   1290    231  17.9%   0.2952
爵士   1290    315  24.4%   0.2531
摇滚   1290    461  35.7%   0.2650
整段最大值几乎没差别,逐帧却有两成到三成半的帧被低估

图 2:左边是整段最大值——两种写法几乎重合。右边是逐帧——被低估的帧占了两成到三成半。包络用的是逐帧的结果,不是整段最大值。

所以呢? 三段各有 18% 到 36% 的帧被低估,最多的一帧少算了 0.2952——那几乎是满量程的三分之一。而包络要用的正是逐帧的结果,不是整段最大值。

所以正确实现必须补上 abs:

ae = np.max(np.abs(frames), axis=1)

补上这一处以后,后面的时间换算和曲线比较才能继续使用正确结果。

第 4 步:把帧编号换算成时间

算出来的包络是一串按帧编号排列的数:第 0 帧、第 1 帧、第 2 帧……可要把它画到波形上,横轴得是秒。

帧编号换成秒可以这样写:

frames = range(len(ae))
t = librosa.frames_to_time(frames, hop_length=HOP_LENGTH)

换算很简单——第 帧的起点是第 个样本,除以采样率就是秒:

第    0 帧:起点   0.000s,中心   0.023s
第    1 帧:起点   0.023s,中心   0.046s
第    2 帧:起点   0.046s,中心   0.070s
第 1289 帧:起点  29.931s,中心  29.954s

但「一帧」对应的不是一个瞬间,是一段 46.4 毫秒的区间。 那这个点该标在哪儿?

帧起点与帧中心两种时间标法相差半个帧长

图 3:同一帧,标在起点和标在中心相差 23.22 毫秒,永远是半个帧长。

两种标法整条相差 23.22 毫秒,永远是半个帧长。

  • librosa 默认按帧起点标(frames_to_time 不加中心偏移)。
  • 本课程按帧中心标,因为一帧的读数概括的是整段 46.4 毫秒,不是它开头那一瞬。

用真实数据验证一下哪种更准。找出波形上最高的那个样本,再看包络最高的那一帧标在哪儿:

波形最高的样本在 1.0502 秒
按起点标 1.0217 秒,差 28.5 毫秒
按中心标 1.0449 秒,差  5.3 毫秒

按中心标误差小了五倍多。 但这不是说 librosa 错了——只要整个项目自始至终用同一种,画图和找位置就都对得上。 混用才会出问题。

第 5 步:三段的包络放在一起比

        峰值    包络平均   平均/峰值
古典   0.6252   0.1786    0.2857
爵士   0.5634   0.2091    0.3712
摇滚   0.7645   0.2949    0.3857

前两列受录音音量影响,跨录音比没意义。最后一列不受——它是包络平均值占自身峰值的比例,分子分母同时缩放会约掉。

所以呢? 摇滚 0.39 最高,说明它整段都更贴近自己的峰值,起伏小、一直很满;古典 0.29 最低,说明它忽强忽弱的落差最大。这和听感一致,也和第 01 课「摇滚更满、古典起伏更大」那句话对上了。

注意这个比例能比、绝对峰值不能比。 判断一个特征跨录音能不能直接比,就看它是不是「同时缩放会约掉」。

三个真写代码时会撞上的问题

完成上面的核心实验后,还有三件真写代码时一定会遇到的问题。

一、帧长决定你能不能分开两个挨得近的事件

造两下相隔 30 毫秒的敲击,用三种帧长各算一次包络,数数能看出几个峰:

   帧长   覆盖时间   帧数   数出几个峰
   256    11.6ms    85       2
  1024    46.4ms    20       1
  4096   185.8ms     4       1
三种帧长对两下相邻敲击的分辨结果

图 4:帧长 256 能数出两个峰;1024 和 4096 都只剩一个——两下被并成了一下。

所以呢? 帧长 46.4 毫秒已经比两下之间的 30 毫秒还长,一帧同时罩住了两下。要分开相隔 毫秒的两个事件,帧长必须明显小于 。 这是选帧长唯一的硬约束。

二、结尾那一小截怎么办

样本数 66150,用掉 66048
剩下 102 个没进任何一帧 = 4.63 毫秒

三种处理各得到多少帧:

丢掉尾巴    128 帧
  最后 102 个样本没用上
保留短尾帧  130 帧
  其中 2 帧不满,长 614 和 102
补零到整帧  129 帧
  补进去 410 个零
录音结尾不足一帧时的三种处理规则

图 5:三种做法各有各的代价。

三种都对。错的是训练时用一种、上线时用另一种。 4.63 毫秒通常无所谓,但录音很短、或者关键事件正好在最末尾时就必须选定一种并写进配置。

三、和 librosa 对齐:center=True 会多出两帧

我们的 frame()        128 帧
librosa center=False  128 帧
librosa center=True   130 帧
  (两头各补了 512 个零)

差的这两帧不是 bug。 center=True 让第 0 帧的中心落在 0 秒,代价是开头和结尾各多出半帧凭空补的零。

所以呢? 两批用不同 center 算出来的特征混在一起,整体会错半帧。这是个不会报错的错误——形状对得上,数值全偏。

这一课得到了什么,下一课接着讲什么

完整走完了第一个特征的实现:载入 → 看基本信息 → 画波形 → 手写包络 → 帧号换成秒 → 叠图比较。

四件必须写进配置的事:帧长、帧移、尾帧规则、时间标在起点还是中心。四个里改任何一个,算出来的曲线就对不上。

一个能带走的判据:跨录音能不能直接比,看这个数「同时缩放会不会约掉」。 峰值不能比,「平均占峰值的比例」能比。

下一课按同样的方式实现另外两个:先用 librosa 现成的算一遍,再从零手写一遍并和它对齐——上一课那个「除以 7 还是除以 8」的口径差,就在那里第一次真正对上。