利用MATLAB来进行批量图片颜色匹配

Edit: FML,回到家才发现用MATLAB处理出来的颜色和死了妈似的,学校的垃圾TN显示器太坑了……下文已经尽量修正。

在图像处理尤其是对冻鳗扫图处理时,“颜色匹配”是一个经常需要进行的操作。因为印刷和扫描的技术和载体所限,扫描出来的结果经常和艺术家想呈现的有很大偏差。当然,最理想的纠正方式应该是从源头上去校准扫描仪,但是很多时候这是不现实的(例如,图片来自网络)。

在有官方提供的Sample的情况下(需要注意:在官网、亚马逊等地方获取样图时,诸如CMYK硬拉成RGB之类的错误屡见不鲜,一定要避免从源头上就选了个错误图片做reference),利用数字原档来校准、还原颜色就成为了可能。

Photoshop有个内建的匹配颜色(英文:Match color)功能:

2017-04-21

可以看到,功能还算丰富。除了上面的滑块选项以外,你可以选择用于计算的选取范围(包括源和目标的都能选),最后计算出来的映射/变换则可以应用到全图(最上面那个现在灰色的选项),而不是仅限于选区。

PS的颜色匹配效果还凑合,但是为了追求最佳效果,在匹配之前一定要尽量把图像对齐。如果两者的范围不一致,也要把多出的部分裁掉。还好,这个过程在PS里也很简单:

  1. 导入两张图片为两个不同的图层。你的扫图置于底层(底层是不会变形的),样图置于上层。注意,不能是“背景”,所以如果是的话,双击一下转换为图层先。
  2. 选中两个图层,然后点Edit->Auto align layers。
    • Projection选Auto就好,下面两个透镜扭曲修正的选项不用勾。确定之后过几秒,应该就对齐了。PS这个算法不是那么完美,尤其是在图像有缩放的时候做不到pixel级对齐,不过对于我们这里的应用足够了。
    • 如果样图太小,可能会无法自动对齐。这种情况下,预先用自由变换(Ctrl+T)调整一下样图的大小吧。记住拖拽时按住Shift可以保持宽高比。你不用刻意调整和你的扫图完全一样大,差不多随便拖一下就行,因为自动对齐时PS会自动帮你做。
  3. 把两者不一致的地方裁剪掉。也很简单:ctrl+点击第一个图层,会建立一个和该图层一样大的选区;然后Crop就好。如法炮制第二个图层。这样,剩下的部分就是两个图层都有的部分。如果只有一个图像上有水印、文字之类的,最好把那部分也裁掉,否则影响颜色匹配。

接下来,只要进行颜色匹配就行了,注意源和目标别选反就行。

但是这里一个问题:如果你有一组扫图,其色彩基本类似,理论上而言,你应该只需要找其中一张的sample匹配,然后把那个transofrmation存下来,适用到其他图即可。可惜,PS并不提供这样的功能。当然,有一个笨方法:先把所有图片拼到一起,然后只选中其中一张图的区域进行颜色匹配,但是应用时忽略选区。但是这个方法实在听上去太蠢了还不能自动化。

于是,我转而追求其他的办法。MATLAB的Image processing toolbox集成了许多现成的图像处理函数,当年学DIP的时候也用得不少,于是我第一个就想到它。随便找一下,这个Histogram matching(imhistmatch)似乎就符合我的需要。

虽然不知道PS的图像匹配是什么原理(毕竟是商业机密),但是我约莫着其实也八九不离十也就是histogram matching。说白了,就是找到一个映射,使得变换后的直方图累计分布函数(CDF)和reference相似。我先试了下MATLAB这函数的效果,嘿还不错,甚至可以说比PS还强一点:

compare

(点击大图,注意观察云彩的颜色)

修正!PS的效果其实秒杀MATLAB的…只是由于学校的垃圾屏幕太烂我才没看出来orz 不过后面的内容姑且保留了,从RGB改用YCbCr效果会好很多。

既然我们找到这个函数,那现在就只需要剖析一下他(选中函数ctrl+D可以看源代码),看看里面到底是怎么运行的,这样我们就能把那个transofrmation给导出来。

结果这个imhistmatch的代码相当简单,其核心部分居然是调用另外一个函数——histeq,即直方图均衡化(Histogram equalization)。其实也很好理解,直方图均匀化本来就是直方图匹配的一个特例——即匹配出来的目标CDF是一条直直的斜线。在MATLAB里,两者都集成到了histeq而不是imhistmatch里可能也是因为前者叫起来更顺口吧。histeq除了直接均衡化之外,还可以手动设一个作为目标/参照的直方图来match,所以这里就是把我们的目标/参照的直方图拿来作为input了。需要注意histeq只支持单通道(准确地说是imhist只支持单通道),所以需要手动写个矩阵运算或者循环来分别搞RGB。

那么,histeq里面的原理又是怎样呢?文档里可以看到,其实这个函数本身就可以返回一个变换T给你了。这个变换T的形式是1×256个double array,每个分别是一个0-1闭区间内的值。用法嘛其实不言而喻了,就是用每个位置对应的double去乘原图的[0, 255]的强度值,得出来的就是output,其实和PS里的Curve是一模一样的。不过,这里可以偷个懒,PS有个内部函数grayxformmex就是帮你算这个的,会自动处理所有支持的图像数据类型(从double,uint8到uint16),而且是用C/C++写的速度更快,推荐直接用它。不过这个内部函数不在path里所以不能直接用,你得先复制grayxformmex.mexw64到你得工作目录才行(是编译过的文件,源代码如果感兴趣的话,网上能找到一个老版本的)。

OK,有了这一切东西,我们就可以很简单地写一个script:

我尽量模仿了imhistmatch的格式,所以应该对多种数据类型都能处理。不过可能数据验证方面的鲁棒性会差一点。最后%%后的部分是批量处理其他文件的代码。

由于前面红字所说的原因,我紧急改用了YCbCr channel来进行处理,效果确实好很多,但是天空的颜色果然就不对了orz,而且还是不如PS的…HSV我也试了,结果会出现奇怪的色块,懒得深究了。上面的代码已经更新,可以选择用RGB(默认),YCbCr或是HSV(开关在最上面)。对比一下三种的效果:

compare2.png

不过需要注意,MATLAB的算出来的结果颜色有点离散,纯色区域多多少少有些banding。这点PS的祖传算法就略胜一筹了,出来的图像的直方图还是非常连续的,没有太多的banding的问题。不过后来发现其实只有RGB模式会有这个问题,换成YCbCr就基本无大碍了。无论如何,这里推荐俩解决办法:方法1,先跑色彩匹配,再进行其他去噪步骤,会很大地由于去噪中的模糊等因素缓解这个问题;方法2,加点白噪声先,至少观感会强很多。这两种方法可以结合使用。

另外,PS的明度控制明显要好很多,我用YCbCr跑出来的结果高光区域都变淡了(比如有几页的纯白背景)。

2017-05-21补充:

后来我又多次使用上文所述的方法进行校色。我发现这个方法其实还是相当可行的,在很多时候甚至比PS的结果要好。不过要注意的是,即使是用YCbCr,有时候(很少见)也会出现和HSV一样的色块问题,所以出了结果一定要检查一下。

关于JPEG的那点事儿 Part 2:JPEG原理

前言

本文其实于差不多正好1年前写成,是关于JPEG的那点事儿的补充。但是由于实战篇一直烂尾,拖到现在。前几天看到Google发了个JPEG新算法,说是可以将JPEG的体积同质量情况下再压缩35%,突然想起了这文了。为了说清楚Google为什么能在古老的JPEG上压榨出新的空间,我觉得还是有必要先讲清楚JPEG的原理。但是本文成文之后实在太长,所以我想了想还是把和Google算法相关的、以及一个TL;DR版的JPEG原理单独发文(大概明天8点发w)。另外,前面提到的“实战篇”也会分割放送,减少文章长度。

序言

有一位朋友看了上文后问到,为什么步进(progressive)JPEG可以提高压缩率?

严格来讲,步进(Progressive)和交错(Interlacing,虽然“交错”是最常用的翻译,但是我是在无法完全理解这两个字的汉字想表达啥…)并不是一个概念。要讲步进,得先讲讲交错。

交错指的是图像解码时(以及存储时)并不是按照某个逐个像素依顺序解码——而是采用跳跃的方法:例如将整个图像分割成九个区域,先解码出每个区域的大体形状,然后再逐步解码细节。一般而言,这样的层级解码会分不止2层,例如在PNG使用的Adam7算法中,一共有7个子图像会被存储起来。维基百科上这张图可能会更直观一些:

https://upload.wikimedia.org/wikipedia/commons/2/27/Adam7_passes.gif

(From Wikimedia Commons)

这种方式的好处是,在图像加载过程中,图像会由模糊(准确地说是马赛克状)逐渐变清晰,而不是从上到下一行行地显示。这样在图像加载中途读者就可以对图像大概有个概念,而不是只能看到上面完全看不到下面:观感上加载速度会变快,而且也更方便一些。

你肯定要问了,这么做不是相当于在原图上在集成几个不同尺寸的略缩图,体积不增加就不错了,怎么会减小呢?事实上,对于其他图片格式,例如PNG和GIF,如果开启“交错”选项,确实图像会变大。但是对于JPEG的具体实现,所谓的“ progressive”,情况又不太一样。用IJG官方的FAQ里的话说“Basically, progressive JPEG is just a rearrangement of the same data into a more complicated order.”。但是具体技术上的实现方式是什么?

要说清这事儿可能还真得从头说一下JPEG的压缩过程。既然要讲,那就讲的详细一点。接下来我将把JPEG压缩的每个细节步骤(除了DCT的数学原理,这个我真不行……)都讲清楚。如果只是想了解大概,维基百科就写的不错了:但是如果真想做到自己写一个解码编码器的程度,有些细节不厘清还真不行。

JPEG编码基本原理

在DCT之前

JPEG编码主要分成三步,DCT、量化以及无损压缩。不过,在DCT之前,还要先色彩空间转换和色度抽样。色彩空间转换干的就是将RGB转换成YCbCr——即将亮度(Luma)和色度(Chroma)分离开,其理念是人眼对亮度的变化远敏感于色度变化等一大套感知视觉理论,这里不再赘述。顺便一提,色彩空间转换并不是完全无损的——因为转换前后都是整数,自然不可避免会有舍入误差。

转换之后,既然我们知道亮度更敏感,那就有做文章的空间。所谓色度抽样,就是对色度的部分进行抽样/缩小。主流的抽样方法有4:2:2、4:2:0,在JPEG的语境下更多叫做2×1和2×2,前者指水平分辨率抽样一半,垂直不变,后者指水平垂直各抽样一半。完全无抽样的叫做4:4:4,或者1×1。至于具体抽样缩图的算法JPEG里好像没有定义,一般都是直接将相邻两个像素求个平均了事(这里可能会导致图像处理界另一个著名的历史遗留问题:线性vs非线性色彩空间,以后抽空再单独写一下)。

抽样完毕后,终于可以进行到真正的编码部分了。JPEG压缩时,先将原图分割成8×8的block进行编码,又叫“最小编码单元(Minimum Coded Unit,MCU)”。当然,如果你有用色度抽样,MCU的大小也会相应放大。例如,如果你用了4:2:0的抽样,那么MCU就会变成16×16——但是Cr和Cb的实质大小其实依然只有1个8×8,只是塞进去了4个8×8的Y罢了。在编码的时候,每个通道也是分开的,所以这样的MCU可以理解成6个不同的blocks就行(但是压缩完之后的数据顺序又有讲究,后叙)。下面,我用“block”来特指单个通道的8×8的单元,来和MCU区分。

DCT

接下来,我们要对每个block的像素值(8bit图像就是0-255了,接下来全部以最常见的8bit为例。JPEG标准额外支持12bit图像,但是主要用于医疗领域,普通情况极少有人用)进行偏置128之后(使其集中在0两侧,加快DCT运算)做2D DCT转换成频域。转换出的结果依然是一个8×8的矩阵,只不过每个数据点代表的是不同频率(准确地说是一组不同的pattern(见下图))的强度:左上是低频,越往右下越高频。

File:Dctjpeg.png

(From Wikimedia Commons)

所以DCT说白了就是把原图分拆成这些pattern的线性叠加。其中左上角的DC分量,可以近似理解为整个block的强度均值,剩下的则是高频(或称AC)分量。

量化

DCT这步数学上来讲(不考虑舍入误差)是可逆的,真正的有损编码的是下面的量化步骤。量化在这个语境下其实就是拿一个预设的系数矩阵(量化表)去逐一除之前得出的DCT矩阵——介于人类对低频比较敏感,细节可以适当丢失,这个表的原则是越往左上系数越小,越往右下系数越大。至于具体的数据,据说都是实验出来的,不同的软件可能不同。主流的libjpeg根据JPEG标准的推荐,提供了一套0(1?)-100质量分别对应的表,可能也是最常见的系数。当然,你也可以自定义系数,例如Photoshop内置的量化表就和别的软件大多不一样。ImpulseAdventure的作者提供了一份非常详尽的市面上常见软件、相机的内置系数表。表格里数据的总体大小决定了JPEG的质量——系数越大,质量越低。高质量的表可能系数都只有个位数(事实上,100%质量的量化表全是1),而低质量的,例如拿一个JPEG 50%质量的量化矩阵来说,左上角的DC分量的除数有16,而右下区的甚至高达100左右。想象一下去拿这个表去除DCT矩阵,除出来的结果再近似到整数,考虑到右下的高频AC分量本来强度就不高,除以100之后基本都肯定小于0.5了,也就是会被约成0。这么搞下来,整个表就会变成一个右下区几乎都是0,而其他区域数值也很小的矩阵。

Y和Cb/Cr会有不同的量化表(可以猜到,色度那张压缩更狠),这个表会被嵌到JPEG文件的头部中。通过表格的数据可以估算JPEG的当时编码时的质量。

无损压缩

之后就到了无损压缩的部分,也是整个JPEG编码中最麻烦的部分。首先又得学个新词儿——Interleaving。这个一般也翻译成“交错”……但是和上面提到过的Interlacing不是一回事。我们前面知道,每个MCU有少至3个、多至6个8×8的blocks。编码的时候,我们既可以按照MCU分类,一次编码完整个MCU的所有block再进行下一个、也可以采用别的方式,这里按下不表。不过,最常用的、baseline的方法是先按MCU归类,然后按照一个固定的顺序读取。如果是无色度抽样的3 blocks MCU,那就是YCbCr的顺序,如果有多个Y,那就是Y00/Y01/Y10/Y11(左上,右上,左下,右下)/Cb/Cr的顺序。

sequence_2x2

(From ImpulseAdventure)

这种“不停地在不同components交替取数据编码”的方式(components在这个语境下就是不同的通道,Y、Cb和Cr都分别是一个component)就叫做“interleaving”,这样的JPEG叫做interleaved JPEG。可以看到,绝大部分的baseline JPEG都是interleaved的。

让我们回到每个block里面。首先要将我们的64个分量1D化。我们这里并不是按行或者列的顺序排队,而是通过斜对角蛇形的方式,从左上逐渐跑到右下。

600px-jpeg_zigzag-svg

(From Wikimedia Commons)

其原因是:越靠近左上的频率越低,量化压缩之后也越可能不是0,这样排序之后便于下面的游程编码(RLE,run-length encoding)进行。

游程编码

所谓RLE,是一种无损压缩高重复率数据的算法,还是直接引用维基的例子好了:“举例来说,一组资料串”AAAABBBCCDEEEE”,由4个A、3个B、2个C、1个D、4个E组成,经过变动长度编码法可将资料压缩为4A3B2C1D4E(由14个单位转成10个单位)。”

至于JPEG中的实现说起来则比较麻烦。简单概括,对于AC分量,我们只描述非零的强度和他们的位置。具体来讲,就是把非零的coefficient转换成“前置0的数量+强度的比特数”的分类单元+具体强度的形式。而强度是0的AC,自然被包含在那“前置0”里面了。这样说可能太抽象了,举个例子。假设我们的AC1和AC3(分别是矩阵第一行第二排和第三行第一排)分别是-2和3,而AC2是零。那么,AC1就会被表示成

(0,2)(-2)

(0,2)乃是分类单元(categories),0表示前面有0个0(毕竟这是第一个),2表示后面跟的数据(-2)需要用两位(2bit)才能表示(下述)。同理,AC3前面有1个0(AC2),那么就会被表示成

(1,2)(3)

或者我们把前面俩改写成16进制,共同占用1个字节:因为后面的数据不会超过15位(F)(实际上不会超过14位,高位F的部分除了F0特殊定义之外,并用不到),零的数量虽然确实会超过15个,但是我们特别指定(15,0)(0xF0,有的地方称作ZLF,“zero run length”的意思)为用来表示16个连续的0。如果某个block最后部分全是零,可以提前输出(0x00,End-of-Block,EoB,无需跟强度值)来结束这个block。

DC分量处理

对于DC量,首先我们用和前一个block的DC分量的差分的方式来记录——这样做可以节省一部分体积,因为图像大多是连续的,相邻block的DC分量一般相差不大,这么操作可以大幅度降低DC分量(一般是最大的一个数,也需要最多比特)的比特长度。这种编码方式叫做Differential pulse-code modulation

另外,我们也要像AC一样,对每个强度量先指定其位长的分类单元(否则我们怎么知道读到哪里算完呢?):0表示该DC量是0、1表示该DC量的数据只有1位、2表示有两位、……直到15(或者F),然后再写我们的数值。例如,如果一个DC量是15,那么就会被表示成

(4)(15)

即(位长)(数据)的形式。因为15需要4位才能编码下(下述),所以是4。

编码为二进制及霍夫曼编码

接下来,我们要进行最后的终极编码,也就是将上面这一堆劳什子转换成二进制。

对于强度的部分,很简单,按照之前分配好的位数转换即可。但是别忘了,我们的数值是有符号整数,要转成无符号的0和1,所以要稍微偏移(shift)一下。如果数据是0,对于AC自然就直接跳过了,DC会被分配“0位”,也就是只有分类单元的部分,而并没有数据。分配1位时,0代表-1,1代表1;分配2位时,用00表示-3、01表示-2、10表示2、11表示3,以此类推。这个编码方式是JPEG标准指定的,所有JPEG都一样,不能自定义。其实,它是用补码(二补数)推算出来的:如果数据是正的,那么就取补码最后N位(位数前面分配了);如果数据是负的,就取补码最后N位再减一。举例子的话,15被分配4位,15的补码是0…00001111(具体有多少个前置零取决于你的比特数,但是这里不影响),取最后四位就是1111;如果是-15,补码是1..1110001,取最后4位再减一就是0000。到这里也应该看出来了,我们的位数也不是随便分配的,说白了就是对于绝对值处在[2^N, 2^(N+1))之间的数,我们分配N位来表示。具体可以参照这文中的Table 5。

不过对于前面的分类单元部分,则不是直接转换成二进制就算完了,我们要充分利用霍夫曼编码的方法进一步压缩。如果有不了解霍夫曼的,其实就是简单地重排数据编码方式,出现频率高的用更少的比特编码,频率低的用更多的来编码,并最终得到一个总比特数更短的二进制码。如果我们不用霍夫曼,可以看到上面的分类单元对于AC量每个有8位,对于DC也有4位。霍夫曼编码后,其长度变成2至十几位不等。

JPEG默认就是启用霍夫曼的,其标准中也有个推荐的霍夫曼码表。但是和量化表一样,你也可以自定义——事实上,有人就发现,Photoshop就有一套自己单独搞的霍夫曼码表,而且根据JPEG的质量不同还稍有不同,以求达到最佳的压缩效果。另外,DC和AC、Luma和Chroma都可以分别使用不同的霍夫曼表,也就是说一般会有四张霍夫曼表。该表显然也会和量化表一样存在header中,否则无法解码。

不过,最优的霍夫曼(在JPEG文件结构的限制内。有研究称JPEG的霍夫曼从设计上就不可达到最优)自然是对于每个JPEG单独统计每个分类单元出现频率然后构建霍夫曼表了:——这也就是一般软件保存JPEG时的“优化霍夫曼”或者“Optimize”的意思了。不过很显然,这样做会要求先对所有MCU进行一遍扫描,自然会降低编码速度(也需要更大的buffer,这点在设计encoder的时候需要注意)。

最后,这些纯二进制的数据会按照MCU1-Y-DC、MCU1-Y-AC、MCU1-Cb-DC、MCU1-Cb-AC、MCU1-Cr-DC、MCU1-Cr-AC、MCU2-Y-DC……的顺序拼在一起。整个数据块必须结束在整字节里,最后不足的部分补1。另外一个特殊之处在于,如果数据中某个字节出现了0xFF(1111 1111),为了防止和JPEG的marker(标示各个组成部分开始的标示)混淆(全部以x0FF+非0字节组成),会加入padding 0来改写成0xFF00。

讲到现在,终于把普通的baseline、sequential的JPEG编码原理讲完了。

Baseline?Sequential?

插播一段:关于“baseline”的定义,其实非常含糊。根据ITU的standard的术语表,baseline其实是和“extended”相对应的,而不是progressive——sequential才是。“extended”是指每次Scan(下述)可以有高达8张霍夫曼表、并且每个component的可以是12位等等的扩展格式(极为罕见)。但是,在统一标准中后文中又出现了baseline和progressive的相对应……总而言之,连ITU自己的文档里术语都不是很严谨。在民间使用时,baseline多用来和progressive相对(虽然sequential更合适一点),这点要搞明白。

步进(Progressive)JPEG

我们还完全没提到最重要的步进(progressive)到底是怎么回事。不过看到上一段我们应该能想到,由于DCT的特性,高低频的数据都已经分离出来了。如果我们在存储时并不是按照完全按照MCU的顺序,而是先把DC和一些序号较小的AC分量挑出来先存储,这样加载的时候不就可以做到从模糊到清晰的效果了吗?没错,这就是progressive JPEG的基本原理了。而且,这样做我们只是调整了同样数据的位置而已,理论上并不会增加体积。

在具体实现上,得先讲一下Scan的概念。JPEG中经过DCT和量化之后的那堆系数(就是那些8×8矩阵),可以分为多成多个Scan来保存——每个Scan中只分配、存储部分数据。具体的分配方式,可以分为三种:

  1. 按照component分开。还记得上面提过的interleaved的概念吗?一般的JPEG,是将三个components(Y、Cb、Cr)全部混在一起编码的。你也可以全部分开——每个Scan只处理一个component。
  2. 按照8×8 block中的序列号(依然是蛇形顺序)分开。
  3. 按照编码后(二进制)的强度量的比特位置分开。

其中,后两个又称作“progression”,采用这种方式来分scan的JPEG就是progressive scan了。

Spectral selection

采用第2种方式的,叫做“Spectral selection”。例如,我们可以单独压缩0(DC),然后是AC1-AC6,然后剩下的AC7-63再一起。这样在传输图像时,DC部分在第一个scan就会扫描到,后面的慢慢读取。不过具体分配方式并不是任意的,有以下规定(ITU T.81 G.1.1.1.1):1)DC和AC必须分开;2)只有DC的Scan可以是interleaved(包含多个components,也就是色度亮度一起编码),AC的Scan必须是只含有一个component。所以,更现实的分配方式可以是

# Interleaved DC scan for Y,Cb,Cr:
0,1,2: 0-0, 0, 0 ;
# AC scans:
0: 1-2, 0, 0 ; # First two Y AC coefficients
0: 3-5, 0, 0 ; # Three more
1: 1-63, 0, 0 ; # All AC coefficients for Cb
2: 1-63, 0, 0 ; # All AC coefficients for Cr
0: 6-9, 0, 0 ; # More Y coefficients
0: 10-63, 0, 0 ; # Remaining Y coefficients

这个范例引用自 libjpeg-turbo的wizard.txt,这也是cjpeg.exe支持的scan file的格式。其中的0,0部分,下面马上提到。

Successive approximation

采用第3种的,叫做“Successive approximation”(有的地方叫做Successive renement,而把两者共用叫做Successive approximation……没错就是这么混乱)——所谓按照比特位置,就是把每个分量系数(coefficient)的二进制强度(或者称值)按照高位低位分开。例如,假设我们的值都是8 bit,我们可以在第一次scan只传输前7位,最后一次scan再把最低一位(Least significant bit,LSB)传输——可想而知,其效果就是图片精度逐渐变高了。上图的0,0部分的意思就是没有successive approximation。

这么说可能还是觉得有点迷茫,ITU T.81标准中的这张图可能是最直观的了:

itu-t81 124

(From ITU T.81)

Spectral selection和Successive approximation可以结合起来一起用。例如,cjpeg默认的-progressive采用以下这样的scan file:

# Initial DC scan for Y,Cb,Cr (lowest bit not sent)
0,1,2: 0-0, 0, 1 ;
# First AC scan: send first 5 Y AC coefficients, minus 2 lowest bits:
0: 1-5, 0, 2 ;
# Send all Cr,Cb AC coefficients, minus lowest bit:
# (chroma data is usually too small to be worth subdividing further;
# but note we send Cr first since eye is least sensitive to Cb)
2: 1-63, 0, 1 ;
1: 1-63, 0, 1 ;
# Send remaining Y AC coefficients, minus 2 lowest bits:
0: 6-63, 0, 2 ;
# Send next-to-lowest bit of all Y AC coefficients:
0: 1-63, 2, 1 ;
# At this point we’ve sent all but the lowest bit of all coefficients.
# Send lowest bit of DC coefficients
0,1,2: 0-0, 1, 0 ;
# Send lowest bit of AC coefficients
2: 1-63, 1, 0 ;
1: 1-63, 1, 0 ;
# Y AC lowest bit scan is last; it’s usually the largest scan
0: 1-63, 1, 0 ;

可以看出,整个过程有高达10个Scan。第一个Scan输送所有component的DC分量(除了最后一个bit,即LSB);然后输送Y通道的前5个AC分量,不过不包含最后两个bit;接下来是Chrma的所有AC分量,但是不包含最后一个bit(如上所述,在progressive中除了DC分量其他的都不允许interleaving,所以分了两次scan;而且,这里选择了先scan了Cr,因为人眼对Cb最不敏感)。再接下来是Y通道的后面6-63个AC分量,依然不包含最后两个bit。再下来是Y通道所有AC分量(1-63)的倒数第二个bit。

最后4个scan就是重复上面的顺序将所有的通道、分量的最后一个bit给传送了。

其他细节

至于在具体实现方式上,Spectral selection倒是蛮好理解的,每处理到某个MCU只要只处理其中一部分分量就是了。但是由于每次Scan现在只含有少数几个分量,对于编号比较大的的高频(例如:6-63)可能会有大量block完全是0。为了进一步节省字节,在EOB的基础上,我们又重新定义了一组EOBn控制符,来表示之后n个block都是空(纯0)的。当然这些控制符也会被霍夫曼编码了,就像EOB和ZLF一样。

Successive approximation说起来就有点复杂了。如果上面的scan file有仔细看,就会发现每个部分(DC或者每个component的AC)第一次Scan发送的比特数不一,但是之后每次Scan都只输送一个bit。这也是JPEG的规定。对于DC,很容易理解:假设我们某个MCU的某个component是7好了,那么二进制就是111,三位。如果我们分成两次Scan,第一次只传送前两位——也就是11,那么很显然,其“分类单元”应该是0x02;在第二次Scan的时候,因为很显然只有一位,那么分类单元那部分就可以省略掉了,直接把最后的bit补齐即可。

结语

那么,回到最开头的问题,为什么用progressive模式,就会体积减小呢?这里我没有一个确定的答案(…),但是可以看到,和DC/AC全部interleave在一起的Sequential模式相比,其最大的优势是把各个MCU的相似的分量都排在了一起;可以想到,这么做绝对有利于含有预测性质的游程编码,乃至后面的霍夫曼编码。另外,单纯把数据分成好几份(好几个Scan)然后设定不同的霍夫曼表这件事本身可能就能提升不少效率。

 

魔力女管家音乐歌词数据库搭建完成,顺便说说Sphinx

就像上一篇文章许诺的,我费了三天时间,把魔力女管家歌词库给搭出来了!

先上地址:http://fireattack.github.io/mahoromaticdb/ 在Github因为侵权把我的网站日掉之前,暂时就挂在这里了。

包括了所有的CD的简要信息,以及所有歌曲信息歌词(大部分含翻译)。所有的CD我都用当年收藏的Booklet精心制作了1000 px的封面(除了少数几个找不到BK的),欢迎使用。哦里面在最后还随意地包含了两张同人CD(C60、C61发售)的信息,其中第一张网上应该是能找到的,意外地非常好听,强烈推荐。

搭建过程中才发现我之前的文档写的有多烂。有好多后来才找到歌词的歌没包括就算了,格式也是一团糟,错字、标点符号不统一的问题比比皆是。看来我当年的强迫症要轻得多啊!我尽量把标点符号统一为:跟日文用全角,跟英文用半角,但是连用符号(例如:!?)用半角,波浪号的副标题前空一格(但是魔力女管家第二季的标题则不空),括号统一用全角。艺术家名义统一用角色(声优)的格式,但是少数早期CD直接单用声优名字。

呃好吧我承认这并不是特别统一…因为我有个更严重的强迫症,叫做“名从主人”…我一般会尽量遵照BK上的写法,因此牺牲一些统一度。

接下来的地方讲讲搭建网站过程中的一些值得记录的东西吧。

这次用的技术是Sphinx——一个用Python写成的Doc建站软件。之所以没有用Github Pages支持的Jekyll,主要是那个是用Ruby写的,不想去接触。不过后来才发现使用过程中99%都只是在和reStructuredText这玩意打交道,和后端的语言一点关系都没有,早知道就用Jekyll了——毕竟那个是用更简单的Markdown的来生成HTML的。

果然还是先说reStructuredText这种标记语言吧,毕竟大部分时间都耗在和他打交道了。一言以蔽之,这玩意的语法非常的反直觉和不灵活。不过用了三天之后,也算是慢慢熟悉了。reStructuredText的设计思想就是非常注重可读性:基本上而言,源代码就已经在ascii的层面上“格式化”了。例如其最反人类的设定——表格,正常来讲你需要手动用各种线把框框画出来(见下)!

+------------------------+------------+----------+----------+
| Header row, column 1   | Header 2   | Header 3 | Header 4 |
| (header rows optional) |            |          |          |
+========================+============+==========+==========+
| body row 1, column 1   | column 2   | column 3 | column 4 |
+------------------------+------------+----------+----------+
| body row 2             | Cells may span columns.          |
+------------------------+------------+---------------------+
| body row 3             | Cells may  | - Table cells       |
+------------------------+ span rows. | - contain           |
| body row 4             |            | - body elements.    |
+------------------------+------------+---------------------+

还好,还有csv table可以用,否则这真的要死人的(不过这里有个table ganerator可以用)。

除了最基本的一些标记(例如,粗体、斜体啥的,不过注意和Markdown不同),reStructuredText核心元素是directive和role这俩东西。前者是一种特定格式/结构的元素,一般“成块”出现;一般格式是:

.. directivename:: argument ...
   :option: value

   Content of the directive.

在第一行调用directive的名字,然后在第二行起带缩进写设置,然后空一行带缩进(缩进必须和前面保持一致)写被格式化的内容(有些时候则没有内容,比如图片啥的)。这个东西可以用来实现插入目录、图片、目标(锚点)、给内容加class等功能。role和directive类似,但是一般是用于行内(inline)mark一些内容,例如上面提到的粗体、斜体啥的,本质上也是一种预定义好的role。你也可以自定义role。

那么就大致按照我写站的时间顺序来讲吧,没啥逻辑关系。

在建立了你的Sphinx网站之后(推荐使用官方带的Sphinx-quickstart),第一件事在index(链接是到rst源代码,下同)页面中把站点的目录放在上面。这个倒是蛮简单,用Sphinx自带的toctree这个directive就行。在目录里你可以输入想包括的页面名称,以及目录深度。我设想的结构是所有CD信息在一个页面(cdlist.rst),而歌曲因为歌词较长,则每首歌分割为一个单独的页面放到里子目录songs/里。

这里就遇到了第一个问题:如果你用songs/kaerimichi的方式添加页面到toctree中,他会和cdlist都是平级——而不是处于一个Songs的一级目录之下。稍微研究了一下发现要这么做:在songs/子目录下先建立一个index.rst,然后在该rst中再建立一个toctree(下称toctree2),包含所有songs/下的页面。因为toctree总是从当前目录开始查询的,所以对于toctree2,你只需要罗列所有歌曲页面的名字就行,无需加songs/前缀。当然因为歌曲太多了,我们利用:glob:这个参数,就可以用通配符*来匹配所有页面了。回到根目录index的toctree,我们也只需要包含songs就可以了,会自动把toctree2里面的项显示为二级项(结果)。

正式写页面,对于CD list,我需要在最上面附一个(本页面内的)目录。这个用带的contents directive就能轻松做到。但是默认会加一个很多余的根目录节点,用:local:参数去掉它。搞定后,这个目录就会列出所有的section了。加一个section很简单,只要在一行文字下面加一堆“-”、“=”之类的就行。如果要多级section则需要分别套用不同的符号,不过我这里只有一层所以无所谓。一般而言,每个section会自动生成锚点,上面加的目录就能跳转。

因为页面上某些文字需要是小字,而且这些文字混在正文中(inline),我们需要一个role。当你给内容指定了role之后,生成html会自动指定对应的class名,因此配合CSS就可以实现想要的样式。要定义一个role,要先在文档某处(一般是最开头,我不确定在别的地方可不可以)写:

.. role:: smallfont

之后(注意:每个用到:smallfont:的rst都必须重新写一遍这个…),你就能用:smallfont:`your content`来标记你的内容了。不过这里有个限制:这一段代码的前后必须是非“word”的东西,也就是说中间一般得有一个空格。如果你不想要你的普通内容和小字内容之间有空格?需要加反斜杠来消掉空格。即:

your normal content\ :smallfont:`your small font content without space inbetween`

OK,那现在在CSS里写:

.smallfont{
 font-size: 80%;
 color: grey;
}

就行了。不过,怎么把自定义的CSS包含在你生成的网站呢?这里有好几个办法

  1. conf.py中加上
    def setup(app):
        app.add_stylesheet('custom.css')  # may also be an URL

    (你的custom.css应该在_static/目录下)

  2. conf.py中加上(注意这里又变成从根目录起了…下同)
    html_context = {
     'css_files': ['_static/custom.css'],
    }
  3. 先把你模板里的layout.html拷贝到目录下的_templates里(当然,保证你没删掉conf.py里的templates_path = ['_templates']),然后找地方加一行
    {% set css_files = ['_static/custom.css'] %}

    (在SO看到的是

    {% set css_files = css_files + ['_static/custom.css'] %}

    但似乎使用上并没有区别。)

  4. 还是上面的说的layout.html,直接强行加
    {%- block extrahead %}
      		<link rel="stylesheet" href="{{ pathto('_static/custom.css', 1) }}" type="text/css" />
    {% endblock %}
    
  5. 最暴力的方法,找到你模板的CSS文件,然后修改之;或者修改之后放在_static/里(原因见下面)。

对于我用的alabaster模板,默认的layout.html已经包含了上面的选择4,所以我只需要把custom.css放在_static/下即可。哦这里顺便说句,你放在_static/下的文件默认全部都会复制到你build出来的html里(不管用不用得到),你可以利用这个来覆盖模板里的东西(如上面的5所述)——不过这里注意是覆盖,不是添加。另外,图片之类的resource就别往里放了,否则会复制两遍(因为所有引用过的图片会自动被Sphinx复制在build目录的_images/目录里),占地方。

搞定了“小字体”这个样式之后,在每个歌曲的页面,我还需要引入两种新的样式:日文和中文,分别用来标记不同语种的歌词,从而实现更好的字体显示效果。

这里因为是成块的内容,我们就不用role了,用一个directive:class。如名字所示,其功能和role类似,也是给一块内容标记class。原始的reStructuredText直接用class就行,但是Sphinx是为Python文档开发的,默认把class给定义成一个role了,所以需要改用rst-class

.. rst-class:: ja

	| まなざし そっと ひとつ
	| 誰にもみつからぬように
	| ふんわり時間だけが
	| 流れては消えてく

这里可以看到,argument(双冒号+空格后面的)自然是想要的class名称。因为没有选项(options),所以内容就从第三行(空一行)开始。至于pipe符号(|)这里的目的是强制换行。另外注意,所有的内容必须有一致缩进(具体多少无所谓,反正不会显示成缩进)——缩进结束就退出了这个rst-class了。而相对地,在普通正文中的缩进就是正常的缩进,而且你加几个空格都会如实反映。当然,你还得去你的CSS文件里定义.ja,这里就不赘述。

在写歌曲页面的时候,我遇到一个非常蛋疼的锚点问题。一般而言,锚点在加section/heading时是自动生成的,但是如果你不想开新的section呢?方法是在文档中加入这么一行:

.. _targetname:

至于引用(指向)目标时,Sphinx推荐的用法是用:ref:这个role:即形如:ref:`targetname`这样来引用。但是注意!一般而言target是配合section来用的,所以会自动成section的名字。但是现在我们是在正文中随便添加的,我们必须显式指定他的名字::ref:`Display Name <targetname>`才行。

但是这里有个问题——在Sphinx里,所有的target和ref都是全局的,跨文件的。所以,我每个文件里的target还不能一样,比如如果我每个文件里都有个.. _ja:,用:ref:`Display Name <targetname>`会不知道飞到哪个文件的_ja锚点里去。

研究了半天,发现只能用reStructuredText自带的引用方式——`targetname`_因为这个只适用于本文件,这样即使我每个文件都有个重名的锚点,也只会正确跳转到本文件内的。不过这个有个缺点,不支持Display name和targetname不一样(就是上面带尖括号的用法),所以我的targetname必须就是我想显示的文字。还好,似乎支持中文字符和符号,我用了“[中文]”当锚点名称也没事儿。最终效果

那么最后需要的功能就是给CD曲目列表里的对应歌曲添加链接了。因为是跨文档引用,所以推荐的方法是给每个文档的标题加上个target,然后用:ref:;但是我嫌麻烦,直接用另外一个role,:doc:做。方法基本一样,直接输入:doc:`filename`就行了——显示的文字自动从对应文档的标题提取。不过这里我也并没有直接这么做,因为Sphinx带了个非常好用的role,叫:any::用它可以智能地自动寻找最接近的reference,可以是:ref:,可以是:doc:。在用:any:之前,我们更可以把它指定为“default role”——在conf.py里添加:

default_role = 'any'

这样,当你使用单个撇括起来时(例如:`songs/kaerimichi`),会自动调用:any:这个role。这里,因为这是文档名,又会进而自动调用:doc:

到这里,基本在reStructuredText里遇到的困难都说完了。在Sphinx这边,我对默认的alabaster模板也没怎么改,但是有一点要注意:如果要用这个模板的完整功能,要修改sidebar为

html_sidebars = {
 '**': [
 'about.html',
 'navigation.html',
 'relations.html',
 'searchbox.html',
 'donate.html',
 ]
}

才行,因为模板自带了一些sidebar并没有包括在默认的设置中。模板带的选择在conf.py里修改html_theme_options,基本很好理解,我就改了logo和字体。

Sphinx有一个自带的“basic”模板,还有一些JS和CSS,基本所有的模板都有inherit。但是那个JS(doctools.js)有个问题(Chrome only,我已经汇报到Sphinx dev team),重现方法如下:

  1. 点一个带锚点的地址
  2. 滚动一些(即,你已经不再在锚点的原始位置)
  3. 点一个(非本页内的)链接,跳转到了其他页面
  4. 点“后退键”

正常来讲,点了后退之后,会后退到之前页面的之前位置。但是bug就是,后退之后会强制再读取一次锚点位置,然后跳转过去,而非你之前的阅读位置。你可以在Python 3的官方文档页面重现此bug,因为他用的就是Sphinx。相反Sphinx自己的文档页面则不会,因为他用的是旧版的doctools.js文件。Firefox下无此bug。

我研究了半天,发现是该JS文件中的以下函数:

/**
* workaround a firefox stupidity
* see: https://bugzilla.mozilla.org/show_bug.cgi?id=645075
*/
fixFirefoxAnchorBug : function() {
if (document.location.hash)
window.setTimeout(function() {
document.location.href += '';
}, 10);
},

导致的。讽刺地是,该函数的目的是为了workaround Firefox的一个bug——Firefox对于HTML5新增的<section>锚点tag支持不好——才加的。本来函数有一个判断是只对Firefox有效,但是由于JQuery移除了$.browser,这里被改成了对所有浏览器有效,从而导致了上面所述的副作用。因为那个Firefox的bug对一般应用并没有什么影响(一般应用的锚点都是靠id="xxx"来搞的,并不会用到<section>),所以我们这里直接删掉就好。删除的方式则是复制一份这个JS文件修改,然后放到_static/文件夹内。这样,每次build,会自动覆盖。注意复制的时候,别复制成了原始模板文件夹里那个doctools.js_t了——那个是个JS“模板”,中间有些参数还没生成的。正确的方法是先build一遍,然后从html的目录里复制个原始版的JS。

还有一点要注意,用sphinx-build来build HTML文件的时候,有的时候并不会刷新改刷新的文件(尤其是sidebar之类的),所以隔一段时间最好把_build/目录全删了然后重新build。当然你也可以用-E选项,不过一般还是别强制了,因为要慢许多。

往Github Host的时候,需要加个.nojekyll文件来禁用GitHub Pages自带的翻译引擎,否则会有问题。但是这个文件只有加在根目录才有效,而我是把整个网站放到mahoromaticdb/子目录下的(因为GitHub一个用户好像就支持一个站),所以一开始迷惑了一阵为什么不好使。

从“アニメソングの歌詞ならここにおまかせ?”关闭说起

警告:本文大量个人的无病呻吟。

无意间点开尘封多年的书签,震惊地发现,アニメソングの歌詞ならここにおまかせ?(网址:http://www.jtw.zaq.ne.jp/animesong/)这网站居然关闭了。

一番搜索,可以知道关闭的时间点是今年一月底,原因也没有多说,作者就是一句“感谢长久以来的支持”。

C4Eq6cuUoAA8Ssx.png large.png

(Via:Twitter

不过,从页面报错信息来看,直接原因是作者用来Host网站的服务(J:COM NET加入者向けホームページサービス)停止运营了。当然,如果作者真的想继续,换个服务商并不是不可能,但是果然有些时候事情就是欠个导火索。我喜欢吹日本人或者外国人的“根性”,能不辞辛苦、不计报酬(准确地说是倒贴钱)地维护这类爱好网站,但是果然一切都是有极限的。毕竟到后来大多都是惯性使然,突然弦可能就断掉了。

这件事我居然过了一个多月才发现,可以说是过去的我所不可想象的:没错,这网站应该是我正好10年前那会,不夸张地说上的最多的网站。说起这话题,似乎应该先从网站介绍起。如名字所示,该站是一个收集动画歌词的网站。作者按照作品分类,收集了大量动画歌曲的歌词,并按时(一般一周一两次)更新最新出的歌曲的歌词。另外值得一提的是作者是有JASRAC的牌照的,所以是完全合法运营。当然,这些歌词多数在无数日本其他歌词网站都能找到,但是作为一个只更新动画歌词的网站,有其独有的优越之处,这得从我当年的“工作”说起。

06年那会大概是我刚入宅的时候,相信很多人都知道了,契机就是一部《魔力女管家》。我当时着迷程度非常深,在网络上疯狂搜刮和魔力女管家有关的一切资源,其中自然也不会放过音乐。魔力女管家的BGM是非常优秀的(作曲:増田俊郎),歌曲也不错。主打的自然是麻幌的配音川澄绫子,唱了两部的OP以及一些插曲不说,更在各种相关CD里唱了大量歌曲,甚至单独出了一张专辑。几个配角也都有歌曲,其中男主的三个青梅竹马(?)还组成了一个组合叫“とりおまてぃっく”(很显然名字捏他作品标题「まほろまてぃっく」),包办了所有的ED,也出过张专辑。当年这些歌我翻来覆去也算是听到烂了,更是我Anison的入门。

既然有歌曲,自然少不了歌词。我在搜索魔力女管家的歌词的时候无意发现了一个叫“漫网”的论坛,上面就有一个区叫做动漫歌词区。当时这区还挺活跃的。里面的好多版主我几年后还经常在ASTOST见到。虽然不能记起全部,但是有位叫紫亚的妹子倒是蛮深刻,还玩翻唱来着。

魔力女管家的歌曲歌词慢慢收集齐了,但是在这过程中,我逐渐对动漫歌曲歌词这个东西本身感了兴趣。于是我开始经常在这网站上发帖。不过歌词从哪里来呢?这就不得不提到标题这个网站了。作为一个只更新动漫歌词的网站,自然比去那种什么都更的网站方便,而且最棒的地方在有这网站还有change log,每次更新了哪些一目了然,只要原样照搬就是了。虽然网站有防复制,但是实在是很弱一般开源代码复制就是了。当然还有一些别的网站,比如当年比较有名的月之舞歌词站(一个国产的日音歌词收藏站,早就挂掉了)等等,但是还数“アニメソングの歌詞ならここにおまかせ?”用得最多。

不过,虽然我最早知道的是漫网,也一直有在混,我真正驻扎下去的则是另外一个论坛:动漫花园之音乐花园。动漫花园本身的名气相信不用多说了,即使是到今天大部分人也知道,因为其BT站的缘故。不过最早的动漫花园是从字幕组起家(和PPG算是齐名),然后有论坛和tracker的。动漫花园论坛当年的有意思之处在于,其音乐分区是一个独立的网站,名字叫音乐花园,管理基本也独立。当时上面聚集了一些动漫音乐或者日音牛人,很多现在都还在AO混,比如牛叔(nalanchen,即现在“MGRT音花雪月”的组长,说来MGRT正是Music Garden RIP Team的意思),jedi_vs_sith等,不过我和这些大大都不熟,算是单方面认识。音乐花园当年还算是一个满活跃的网站,还经常搞些音乐精选集什么的:

Cover_Inside.jpg
当年的音乐精选集  vol.3的伪·封面

因为音乐花园人比起漫网(哦顺便说句,当年漫网主站是个卖动漫资源的网站)比较少的缘故,“抢发”歌词也不剧烈,我逐渐在这边的歌词区(名字叫词苑)活跃起来。后来也记不清是自己主动申请还是被邀请,总之稀里糊涂地当了歌词区的版主。

在音乐花园这边打交道比较多的是词苑的几个老版主,冰河的海,草上飞校长,以及最老资格的domonick。一些具体事情就不详细说了,我就记得当初年纪小,为了歌词翻译脸皮超级厚的疯狂追着屁股求助,而草上飞校长总是非常客气地帮我翻译,实在是感激不尽。dominick基本算是歌词区的奠基人了,早期的大量歌词、以及目录的整理都是他涵盖的,还请Ayumu(不知道是谁,可能是上古巨神)制作了两款相关的软件,转码用的Sapphire和注音用的Ruby:

QQ截图20170310005223

QQ截图20170310005218

其他的著名人士还有Shion(很多ID,其他的还有“水色”等),她在漫网和DMHY这边都有活跃,是个非常细心的人;Frank543,他后来制作了一个非常好用的跨站歌词搜索引擎Lyrics Get(自带破解防复制),现在还可以用哦。

至于我自己嘛,只做过一些微小的工作,多数就是搬运歌词,其中自己比较感兴趣的几部作品,比如当时迷的爱马仕系列整理的稍微用了些心。另外很多歌词尤其是很多角色歌,网上是没有的,需要自己从booklet里抄写下来。另外还用自己三脚猫的C#功夫写了一两个自动格式化发帖的辅助小工具之类的。

就这么过了两三年,国内早期的动漫论坛开始慢慢走向一个衰败期。音乐花园后来也被动漫花园吸收了回去,成为了里面的一个版块。接下来就是DMHY的分裂风波了。我不知道还有多少人今天还记得,总而言之就是站长“很闲”和一些会员的运营思路没走到一起。过程很复杂,形成了“站长派”和“元老派”,当年作为一个论坛的积极参与者,自然队站在了“元老”这边,好像还参与过一些撕逼,现在想想简直幼稚得可怕。经过一番周折之后(中间有段时间甚至有俩动漫花园论坛),最后的妥协是站长继续经营资源网(同时在这前后建立了u2)出走,其他论坛元老保留了论坛。后面的结局自然都知道了,资源网活得好好的,论坛死的比原来更快。讽刺地是,在几年之后的今天,论坛(的尸体)又回归了很闲的手下:https://bbs.dmhy.org/ ,我当年复出无数心血的词苑的时间也定格在了2010年。

也大概是因为这个事情,就像标题网站的站长一样,我逐渐地也倦怠了,词苑的工作慢慢就荒废了。哦其实中间还在ASTOST混过一段,当过那边的歌词区的见习版主,不过没坚持多久就是了(然而就几个月还很傻地和那边的小圈子对喷过一次,年轻真好)。

好了,莫名其妙的追忆就到此为止了。作为祭奠,我把最初的起点——我整理的魔力女管家歌词传到GitHub了。没错你没看错,所有的歌词页面的编码都是错的(Firefox的话手动选成GB2312就行。Chrome把编码选择阉割了我也没办法呀哈哈哈),毕竟这是我N年前用Word文档(原始文档/Google Doc版,更全一些,还有所有CD的曲目信息)转的……闲的话再搞个正常点的网页吧。

ReplayGain在UPnP media server中的应用和补遗

UPnP media server是个挺有用的东西。在PC端配合一个功能非常全面的foobar2000插件——foo_upnp,安卓端配合foobar2000 Mobile或是BubbleUPnP,即可轻松实现包括但不限:用手机播放电脑里的媒体库,用手机当电脑的音响用(呃延迟没测过,估计只够听歌),用手机控制电脑播放器,以及前面的全部反过来控制等功能。当然,最理想的情况是把media server设在诸如NAS之类的设备上,但是就我个人来说,最常用的应用场景是躺在床上耳机插手机听电脑的媒体库/播放列表。

既然前一段时间折腾了那么就RG,这也得用上不是。让我们先仔细研究一下foobar2000那边的server的profile设置。

qq%e5%9b%be%e7%89%8720170220192532

另外为了便于测试,将Basic Settings里设成始终使用默认profile和关闭增加兼容性的额外流:

qq%e6%88%aa%e5%9b%be20170220193855

可以看到大抵来说,有这么几种streaming的方案:源文件直出,转码为MP3,解码为WAV。

直出最好理解,就是在http上host对应的文件而已。直出可以保有原始文件的所有metatag,自然也就包括RG信息;这样,在移动端使用支持RG的客户端时(这里就是foobar2000 mobile了),就会读取到信息进行响度调整。

但是直出的局限性非常大。foobar2000 mobile支持的格式非常有限。TAK这种就不用说了,自然也不支持任何音频文件+cue的类型。不过倒也不会播不了,只是sever会自动无条件帮你转成WAV。最吊诡的是,像FLAC这种明明是支持的格式(即,你把歌曲拷贝到手机里是能放的),有时候会在foobar2k mobile那边出现播放列表里完全看不到的现象(不能稳定重现。与之相对,第三方的BubbleUPnP则很稳定不会有这个问题)。

既然直出这条路不行,那就只能转码或者解码了。介于局域网带宽对于音频来说完全不是问题,自然选择音质更好的WAV,也避免了二次压缩的问题。不过,转码/解码之后有一个问题,就是RG信息完全丢失。哪怕是转码成MP3也是一样。还好,在下面那个Audio processing里,和Converter(转码器)一样,可以设置转码/解码时的处理。注意这里既然叫processing那自然是硬编码进去,而不是靠RG信息。所以foobar2000 mobile端会识别成无RG信息,调整那边相应的RG设置时要注意(我则是完全关闭)。

哦从图中可以看到,你可以设置规则对不同文件类型设置不同的转码规则(例如有些转,有些直出等等)。但是没啥卵用,因为你一旦开启了processing,所有的都会被强制转一遍mp3/WAV,所以还是死了这条心。

这里又是和上一篇文章中提到的普通用电脑回放时一样,面对一个抉择:是选择全部降低到和RG目标响度一样的响度(即:较低的响度),还是全部增益到和“新歌”一致的一个比较高的响度。这里还是选择了前者,因为毕竟那样对音质的保存是最完整的,而且主要用耳机听歌的话也不用担心有输出不足的问题。具体来说,就是在Processing里开启apply RG,同时对无RG信息的歌曲(主要是响度大的新歌)加个-9.5 dB的pre-amp。如果还不放心可以再加个Advanced limiter(虽然99%的情况根本不会有任何区别),但是介于Advanced limiter目前有个会把1缩成0.9999的bug,还是别了罢。

当然这种选择下就会有一个问题,那就是和我手机里那些已经转换好的、响度和新歌一个水平的音频文件有冲突。而且因为两者都是无RG信息,也无法靠调整手机端的RG设置中pre-amp来弥补。不过还好我一般其他场合听歌是用Google Music而不是foobar2000 mobile,以后就用fb2k mobile专门听媒体库就是。

多说一句,客户端那边选用foobar2000 mobile而不是BubbleUPnP的原因倒不是fb2k mobile支持RG(毕竟在我的最终配置下也用不到),主要还是foobar2000 mobile支持手机端的last.fm统计插件,虽然BubbleUPnP的界面大概好一万倍(电脑的foobar我觉得还行了,手机那个真的是丑的惨绝人寰)。另外,BubbleUPnP有个很烦的地方就是每次我修改服务器端(我电脑)的UPnP设置那边就必须会自动切断连接,也就是退出所有的远程playlist啥的,我还得重新选,在测试的时候尤其烦。而foobar2000 mobile则是另外一个极端……不但不会断,他还会缓存一部分媒体的地址(其实就是个URL,可以在电脑端或手机端的console里看到),也就是说我切换了设置之后有时候得在那边强制退出一次来让他刷新URL(服务器端这边即使你修改了设置,老设置的URL实际上还是并没有禁用的,依然有效)和播放列表。

screenshot_20170220-193326
客户端(foobar2000 mobile)读取流媒体的log范例

UPnP媒体库播放的响度规格化这事儿到此也就告一段落了。不过,我又回头去想在移动设备播放歌曲的workflow能否有可优化的地方。

如前文所述,我目前在移动设备采用的是“新歌不变,老歌先RG再+9.5dB”的方案,原因是因为这样兼容性最好,对没有RG支持的播放器也能完美播放,而且不会有输出太低的问题(我手机插车上听,即使在这种设置下都几乎要开到最大音量才行了,如果全部都低9.5dB那可调整的余地就太小了)。当然最大的问题就是+9.5dB导致的超过full scale引起的削波问题。

解决方案上次也说过,就是在转换时再用DSP加一层Advanced limiter,把超出的部分动态调整到1以下。不过由于犹豫会对音乐的完美呈现造成影响(毕竟还是部分压缩了动态范围)我之前一直没用。不过最近遇到这么一首歌,一下子就听出削波的问题了(下载链接):

废话一下,歌曲是菊池桃子的「卒業 -GRADUATION-」,个人的五星歌曲。算是无数好听的叫“卒业”的歌曲之一(其他的还有尾崎豊的,斉藤由貴等等)。菊池桃子天使般的声线在这歌里得到完美的体现。咳跑题了。

这歌的问题在于动态范围极大,在我的传统转换设置下,自然会溢出full scale很多,事实上,其peak达到了1.40(+2.93 dBFS)之高。从1:00左右开始,就会疯狂爆音,用手机+耳机听时最明显。因此,Advanced limiter就是非常必要的了。我对比了下开启Advanced limiter的版本和原版,完全听不出区别(毕竟音量大于1的部分只是极小的spike,频段可能更不是人耳敏感的部分),可见这个limiter的效果比起一般的动态压缩要好得多,也解除了我之前的一大顾虑。

不过在研究过程中我发现一个有趣的事实:AAC(m4a)这个格式居然可以保存大于1的数值!也就是说,我上面发这个+过9.5dB的音频文件,其实并没有损失任何信息或者削波——超过FS的1.4的peak也完整地记录在了音频中。这说明了什么呢?如果你的播放流程在输送到DAC之前有足够的衰减处理(例如软件中的波形层面的音量降低,但包含音响上的Analog的音量旋钮),又或者你的DAC/Analog设备足够专业,留有headroom支持超过1的波形,其实并不会产生削波/爆音。对应到电脑,foobar2000里的volume就是个不错的例子(但是别忘了上文提到的Win7的自带的limiter适用过早的bug,所以避免);对应手机端,根据我的观察,至少安卓系统的音量是不行的(不过安卓的音频处理一直都臭名昭著):如果仅仅靠调整系统的媒体音量,该爆音的还是爆(不清楚安卓的系统音量具体原理是数字上衰减波形还是模拟级)。不过,foobar2000 mobile中有个单独的volume设置,通过那个,或者里面带的RG设置的pre-amp,可以做到同响度情况下(即先用foobar2000 mobile的音量降低几个dB,再在系统音量里稍微开大一点达到同样响度)完美无爆音播放上面那个m4a文件。

不过这个用起来其实也有颇多不便:第一个不便是foobar2000 mobile的音量设置……他有bug。每次切歌,音量就会回归到0dB,即使界面里显示的还是你的设置。另外还是上面说的问题了,如果你降了几个dB,那输出不够这老问题又来了。所以,我最终还是经过权衡选择了转换时选择高响度水平+Advanced limiter的方案。当然还有第三个方案:转换时不加Advanced limiter(利用m4a的特性保留超过1的数值),但在播放时靠foobar2000 mobile自带的DSP即时加。这样可以最大限度保存动态范围,以后这些mp4拷贝到电脑上听时还可以还原。我现在还在想要不要全面转成这样——因为如果这么做,就表示了我就锁死只能用foobar2000 mobile当手机播放器了,这方面有点犹豫。

前面说了AAC(m4a)可以保存高于full scale的数值,那相对地WAV、FLAC和APE就不行了,如果你转换过程中增益到超过1,最终出来的文件会被削波到1。当然FLAC/APE之类的是为了完美呈现WAV而故意这么设置的吧,大概。MP3则比较奇怪,还是上面那个歌,转换之后会出来个peak为1.075的玩意…大概和MP3算法有关吧。另外每个的平均响度也会细微的差别,理论上来讲很自然AAC会高一些(毕竟没削波),虽然差别很小就是了。

qq%e6%88%aa%e5%9b%be20170221233307
利用RG扫描来查看peak和平均响度。因为Gain是负值,所以绝对值越大说明响度越大。音频全部通过原始wav文件+硬编码进(RG+增益9.5dB)的方式逐一生成

所以很显然地,如果你是转成了FLAC之类的已经会直接削波的格式,即使上面那个很复杂的操作也没法拯救你这音频了,怎么放都会爆音,万万要避免。

嗯,最后附一个几种流程的图示吧。三脚猫ps功夫不要笑话(点击大图)。

blog.png

上面的是最终响度=RG目标响度的workflow,优点是基本无损,缺点是输出比较低。目前我在电脑和UPnP媒体库输送到手机两种回放方式时使用。可以看到新歌直接降低-9.5dB(图里写成9了…),老歌直接RG,两者会获得一个相对一致的响度,同时不破坏老歌的动态范围。注意这里的-9.5dB和RG都是纯粹回放时加的,原始音频是不经过修改的。

下面的是方法2,即最终响度=新歌平均响度的workflow。我在转换歌曲到单独m4a文件时使用此方法。优点是兼容性强,和大多数市面上现成的歌曲文件响度一致;缺点自然是对于高动态的老歌峰值会溢出。为了防止削波,如果是即时回放或转换成支持超过FS的格式(AAC/m4a)时,可以通过再加个digital音量调整拯救(这种情况下就依然无损动态范围),或者如图所示干脆直接最后加个可选的Advance limiter进去(可以是转换时的硬编码,可以是回放的DSP)。对于不支持超过FS的音频格式,这大概是唯一可以接受的转换方案)。

半场好戏——妖精的旋律【旧文】

10年的读后感,当年不知道为啥只发了Bangumi。


说来惭愧,身为一个ACG fan(自称),居然一直没有看过这部大名鼎鼎的作品。可以说在我刚入宅的05年,在学校vod看到这部介绍就觉得很重口,不过当时没见过市面,看了点寒蝉,以为日本动画这样也很通常,就一直撂那里没看。昨天整理硬盘突然看到几个月前下的这套漫画(台版翻译成「變異體少女」),再想想自己也算是个原作党,于是就开始看了。

故事讲述地球上最近突然出现一种长角的新人类——二觭人,他们命中注定要取代人类,而旧人类自然不会束手就擒,他们很早就开始将这种人类圈养起来研究对策,但是意外还是发生,一个实力最强的二觭人——露西,从实验室中逃了出来。故事因此开始。露西因为人格转换而变成了一个人畜无害的无知少女,被一家公寓「枫庄」的主人耕太收留。而他俩其实早就见过面——八年前,正是露西杀掉了耕太的全家,而露西由于人格转换、耕太由于在这样的冲击下失忆,两人却互不相识。随着时间的推进,研究所不断派来各种人员来捉拿露西,而男主人公的记忆似乎也随时可能恢复……

可以说,整个故事在前半段张力十足,前一秒还一副主角相的谁谁,下一秒就可能变成一堆肉块,作者用这种果断,这种「杀人不眨眼」的功夫,很好地塑造出了二觭人的残忍恐怖特性;另外一方面,虽然出场人物不算很多,但是每个人都个性分明,而且几位女主人公都背负着悲惨的过去,可以说打下了一个不错的基调。但对我来说感触最深的却在于,作者笔下的女孩们那略显空洞的眼神加上大量的绯红,给人一种随便触碰就会坏掉的感觉,非常非常让人想怜惜(吐槽下,露西和娜娜变身后那御姐形象完全不喜…)。尤其是娜娜,从她一出场我就被迷倒了,她那种对父爱的渴求的感觉,实在是… 当然还有真理子,可以说,藏间和他两个女儿之间的爱恨情仇这条线,就是整部漫画最精华的部分。当真理子抓着露西爆炸之后,故事已经达到了他的最高潮(但是很可惜,作者并不这么想,所以这里渲染并不够,而且这场戏排的也不好,否则感人程度肯定可以再上升)。至此,作者成功地导演了半场好戏,如果故事在这里戛然而止(当然,如果真要这么做,得来AfterStory或者之前把二觭人和研究所那档子事儿讲清楚),那对我来说可以封神;但是很可惜作者并没有这么做。

但也许作者根本没有做好长期抗战的准备,在故事的后半段剧情开始急转直下,可以说在真理子之后,作者墨迹了5、6卷,在剧情上也基本没有什么实质性的发展了,说难听了感觉就像在骗稿费。如果说你要把二觭人的来龙去脉说清楚也不是不可以,但是用整整5卷来讲……?作者你也辛苦了。在这样苍白的故事发展下,别的一些问题开始大量暴露,比如剧情的前后不一致——比如到后来,二觭人似乎变得不再那么不可战胜,甚至某些情况下苍白无力;没错,人类方的武器是有提升,但是拜托编的圆一点,靠那样一个人都杀不死的破弹弓打二觭人,我没法接受。当然相反的,二觭人也会莫名变强,前面铺垫了那么久的触手长度,后面也不重要了,反正实在不行二觭人还会爆种嘛。比如前面伏笔的浪费,真由前面被后爸性侵犯过,而之后看到男主(被Nyu强迫)摸Nyu胸时还露出了很异样的眼神,但是后面也就这样不了了之了;真理子用自己生命换来露西的角断掉,而后面台词是「啊露西已经死了,现在她只是人畜无害的Nyu了」,但是后面露西不是随随便便又出来了?!而且角又长的更大了。最让我无语的是那个荒川(角泽的女助手,最后研发出疫苗那位),前面以为她后来必然有大作为,结果这个人整个就是一说相声的,尤其在那个岛要崩坏的时候一边逃一边和那个队长说冷笑话,完全是无厘头。再比如最后露西在地下湖碰到了角泽和那个巨型怪物,这终极对话本来可以玩的更有意思点,结果就是露西三下两下刮死那怪物,然后再刮死角泽和他那儿子(说他他儿子我又要喷了,这娃出场一共不到一话,你是来打酱油的??)…其实角泽自己并不是二觭人而仅仅是头骨异化这么好、这么讽刺的点子,我可以说我自己完全就没有想到,作者你要是好好经营一下,比如哪怕描写一下角泽知道之后那种「整个世界掉入漩涡」的感觉,相信这结局的档次至少能上两个台阶,结果这样的好包袱就这么埋没了。而男主人公(其实我觉得他根本不算主人公)在过完了属于自己的家家之后,后面的作用也虚无化,感觉就是为了结局时和露西缠绵几下而已,而且还突然变得非常中二……实话说,前面其实也算是他的线,但是风头全部被某个女儿控的大叔抢走光啊…

我个人而言,是比较讨厌剧情中出现什么新的人物,大概是一种不愿意接受改变的心理作祟;但是很明显对于一部作品,应该不断有新鲜血液加入,作者逐渐维护一个更大的人物关系交织的网,故事的格局才会因此而变大,而不是只有三四个人自己的过家家,尤其是对于这部张口闭口毁灭全人类的漫画来说。但是很可惜,作者显然有点小家子气了,打来打去还都是那几号人,甚至发指到真理子死了之后作者为了不浪费人设(喂),又套用模型造人造人出来。而一些新人物的出现,要么是功能性极强,要么是根本不知道他的作用,而最多的情况是直接给人作者想表达「啊,我终于编出来个新人物」的感觉。比如那第二个研究所,突然搞出来这劳什子干嘛?

到了后来另外一个问题是,读者对那些重口味的东西已经麻木,而作者反而越来越「仁慈」了。且不说战斗场面的残酷给人带来的刺激会下降很多这点,作者后来不知道是不是改信佛了,死的人越来越少,几个主人公都能在受极重伤的程度下存活,实在是太狗血,你当你是海贼王吗!不是我觉得一定要死人才能煽情,而是作者你自己拉高了读者的口味。前面坂东(就是那个SAT的暴力男)被露西整的右手断掉、双目失明的时候,你也许会有所感触;但是后来你再看到他连肠子都炸出来的时候,反而没了什么感觉;而如果看到他在故事的结尾,又走了出来的时候,你是否会掀桌呢?读者早就习惯了一会儿掉个胳膊一会儿缺根腿什么的,残肢已经不能再当成卖点了啊,作者!其实坂东的安排还算好了,至少时间上拉开了,就当是一个番外的Happy Ending;而之前的藏间为了真理子之死而拔枪自尽,结果几话之后你发现他原来被坂东救了没死成;前面男主为了挡子弹刚中枪,没几话你发现他原来只是肺穿孔;这种的才更让人有很强烈的「被耍了」的感觉。我觉得如果让我安排,结局我会安排让由香抑或真由死掉。二觭人怎么也非我族类,而且本身就是杀人机器,如果作者敢于让主角5人组中这两位完完全全的普通女孩牺牲,哪怕只是失去几个胳膊腿的话,我相信给人的冲击会猛烈得多。当然了,可以看出作者最后根本就只是想要一个大团圆结局而已,那么牺牲的只能是露西。

接下来讲一下在本篇作品里出现的两个元素——人格转换和失忆。这两个元素,可以说在各种作品中早已被用滥,其原因很简单:非常好用!对于编不下去的地方——玩失忆,玩分裂!简直是居家旅行杀人越货必备良品。如果你不是写搞笑作品,对于这两项你应该非常谨慎的使用,用好了是增加戏剧性,用不好了——那就是都合主义了,怎么方便怎么编。对于本部作品,可以说先天底子不错,一个变种人,玩玩分裂也无可厚非;男主受到那么大冲击失忆了也可以理解嘛。而且作者一开始还是很谨慎的,有人来抓我了我就是露西,平时我就是Nyu陪男主过家家,不是没有违和,但是可以接受。但是后来呢?当你看到露西可以在子弹射到肉里的时候紧急变身,将子弹弹出的时候,我想不用我再多评论。关于失忆这点本来没什么好说的,不过我想说些别的地方的一些所谓「主动失忆」:比如真由看到露西大战娜娜,她却认为那都是看错了,来自我麻痹;而男主一直中二地叫「Nyu没有杀人」,甚至完全不会有一丝怀疑,我也没什么话好说了。

还有一个很有意思的命题是于二觭人定位的思考。对于这样注定要取代新人类的物种,我们应该赶尽杀绝,还是和平共处? 抑或,就干脆被他们征服?作者没有明确给出答案,但是我个人感觉作者倾向于赶尽杀绝——首先,在他的笔下,二觭人被塑造了一个无差别杀人的残忍性格,最大的表现还不在什么三岁就会杀人这些描述上,而在于经常发生的一种场景:前一秒二觭人还在和你交谈,似乎也没有话不投机,结果下一秒你的你已经身首分离。根本就搞不清你为啥就死了。这种杀人的随意性,实在是让人类很难以接受这样的物种。当然,作者也是有对他们的怜悯的,但多建立在几个特定的角色之上,对于整个群体而言,作者并未流露出太多的感情。

最后,还有些乱七八糟的感想,不单独成章了。作者的画风可以说在第一卷时惨不忍睹,尤其是人物的脖子——每个人都向前伸着脖子的诡异模样,太惊悚了… 幸亏后面有很大改善,从第二卷之后基本就不会关心这个问题了。作品名称叫作「妖精的旋律」,在最后时刻露西死前唱了望美交她的歌,也算是点了题。不过看到那时候我一是已经很困,而是已经被后半段白开水的剧情摧残的不行了,也没剩下多少感触就是了…作者之前还画过一个同名读切(一话完),而且是他的处女作,也有一并收录在单行本之中,不过那画风,那剧情,我觉得还是不提也罢…刚翻看百度百科,看到这么一条:「在漫画原作中,旺太的小狗姿态只是伪装,其实它是拥有不死之身的生命体」……我的妈呀居然还有这么一出,我居然一直没想到……难怪那狗N次都没死……不过这种设定有意义吗?!莫非作者在玩票?另外这狗和露西小时候那条是一条吗………?嘛不管了不管了。

总体而言,虽然只是半场好戏,但是这依然是部不错的作品,评分的话7星吧。有人总吐槽说我写的读后感多半是在骂,这是实话,主要是很多时候作品的好,实在是难以用语言来描述,难免词穷;但是缺点说起来却很容易就滔滔不绝……嗯,还是那句话,大家批判地看。看动画的一些介绍,动画对本作改动比较大,而最关键的在于剧情只到前七卷,就是我所谓的「高潮」那里,说不定能拍出更好看的故事,有空找来看看。

补充几句关于真由吧。很喜欢的角色,而且其人物塑造上比起来也是比较丰满的。

只有12岁,就因为被继父虐待而离家,走时甚至连裙子都没有穿(她是在继父准备再次故技重施时逃跑的),下身穿着内裤就跑了出来。后来她碰到了从主人那里跑掉的汪太,两个相依为命,她在那个海滩边上的棚子里和汪太一起吃面包边的场景,很是温馨。而在汪太被主人要走、卖面包的店要搬走这个对她来说简直是末日一样的那个生日,她一个人在那里哭的场景,更让人难忘。还记得她刚到枫庄的时候,看到食物流下口水的那个可爱的表情。

在后来,同样是在那个棚子里,他照顾了坂东和藏间两个重要人物,和娜娜关系最好最熟悉的也是她,可以说她串起了很多重要的剧情。她也在枫庄找到了属于自己的幸福,我前面有说过最好剧情把她写死会加强作品的感觉,但是果然即使让我来写,我也下不了这个手。

ReplayGain、音量规格化与实战应用

又一个兔子洞(笑)。

ReplayGain和响度

故事开始前,大概得先讲讲到底什么是ReplayGain,以及为什么我们需要它。

响度规格化

一言以蔽之,ReplayGain是用来规格化(Normalize)音乐,或者说数字音频文件,的响度(Loudness)的。但是这里要非常小心术语,因为规格化这个词有很多不同的应用。控制动态范围——即“最响”和“最安静”之间的差别,这个有时候也叫作“规格化”(例如在PotPlayer中),但是在音频世界里,更常用的说法叫“压缩”(Compress)。可以看到,一个音频在单纯经过“压缩”之后,其平均响度可能并不会变,但动态范围会变小。这可以解决诸如“电影声效太大,人声小到听不见”之类的问题(但是注意:这并不是一个“正确”的解决方案。至少对我个人来讲,多数动态压缩滤镜听感极差),不过在混音界 Compressor 更常用的用法是指控制输出的范围,便于后期处理。可以看到,一个音频如果经过压缩,其声音特征就改变了。比如艺术家的本意就是这里是悄悄话,那里是爆炸声,结果压缩之后两者的差距就缩小了。因此,在播放音乐时,一般是不应该引入压缩的。

响度规格化,包括ReplayGain,则不同,它要解决的是这么一个问题:不同音轨之间的平均响度差别很大,用户播放时需要不停地调整音量来获得一个相对舒适的响度。在经过响度规格化之后,一个音轨本身的动态范围理想情况下应该不变,但是会整体地提升或者降低,从而达到音轨于音轨之间相对一致的响度。

既然知道了目标,那实现原理其实就不难理解:只要找到一种测量平均响度的方法,然后分别测量每个音轨的响度,然后再设定一个标准(Reference),比较两者不同,补上差值就行了。

不过这里有俩问题:一个是如何测量响度,一个拿什么当标准。ReplayGain作为音量规格化的一种实现,其实就是解决了这两个问题。

响度的测量

我之前一直以为,ReplayGain的响度测量其实就是单纯测量了一下音频信号的平均强度。

这里稍微赘述一下,(解码后的)音频信号其实表示起来非常简单,就俩量:一个是采样率(每秒多少个点,一般音频是44100 Hz),一个就是按照该采样间隔一字排开的一组时序的数据,每个代表当时的信号强度(当然,存储的时候这系列数据是逐个量化成一定位长的二进制)。至于强度的的范围,有多种表示方式,不过最常用的是是[-1, 1]。绝对值越大,理论上声音也就越响。其中最大的绝对值,一般叫做“Full Scale”,缩写FS。一个点的值既可以写成单纯的一个数字,也可以用dB来表示,例如如果是0.5,就可以写成-6.0206 dB relative to full scale,一般简称dBFS。这里有个计算器可以用(注意这里针对场量和功率量中dB的算法不同,这里应该用和电压、电流一样的场量的算法)。

说回“平均强度”。学过信号与处理的应该都知道(虽然我并没学过w),在这里是测量信号的方均根(Root mean square,RMS),而不是平均值或者绝对值平均。RMS这个度量,可以更好地反映比较不同音轨之间的“能量”差别。例如,一个幅值为1的正弦波的RMS就是 0.7071,这个值也可以写成dbFS的形式,这里就是-3.0103 dBFS(这个值很重要,我们后面还会遇到)。它的能量,就应该和峰值为0.7071的方波一致。同理,音乐文件多是多个波形(多为正弦波)的叠加,最简单的方法度量其“能量”的方法就是对所有的点求RMS。

公平地讲,用音频文件的RMS(物理特性)来表示响度,不算是个太坏的方式。响度和能量,两者本身就是非常相关的。不过由于人的听觉感应曲线并不是直线一根,对于不同频率的声音,敏感程度不同,还是应该有更先进的模型。“Loudness”这个词本身的含义其实就包含了人的主观感应在里面的,如果单纯讲物理特性一般会有其他的术语,这个一会再说。

ReplayGain的实现

关于ReplayGain的具体实现方式,其实在当年(2001年?)的Proposal里面讲得很清楚(这是另外一个版本,排版稍微好一点)。总体而言,分为三步:

  1. 先利用等响度曲线,通过滤镜对不同的频率部分的进行修正,给与不同的权重;
  2. 再讲音频分为每个50ms的段落,测量每段的的RMS;
  3. 将所有段落的RMS进行排序,然后选取位于95%处的RMS,作为整个音频的“代表RMS”。在这步作者的理论是,人类的感知响度其实和其中比较响的部分有关,而不是整个音轨的RMS。例如对于对话类型的音频,其中大部分时间都是空白因此总体RMS会很低,但是人只对有声音的地方敏感,所以并不会这么觉得。

最后,只要把这个代表RMS的dBFS值和标准进行比较,然后增加/减少其中的差值就行了。可以看到,整个过程很好理解,而且其本质上还是基于RMS。另外从算法中显然可见,如果放大整个音轨几个dB,其“代表RMS”也会提升对应的dB,所以要调整某个音频的响度,也非常地简单。

能量、声压、响度、RMS的关系

这里再废言几句。我们前面已经说了RMS是对音轨“平均”能量的一种度量,于是这里顺便讲讲感知响度/声压/能量的关系。说到声音大小,最常听说的说法就是“分贝”。其实准确而言,这里的分贝是dB SPL,即声压级(Sound pressure level)。我们知道dB是一个比较量,这里就是指相对于一个标准化了的基准声压,20 μPa(有时称为听阈)的dB值。声压级是个场量,所以20 dB = 10倍。而声音的能量,一般可以用声音能量密度(Sound intensity)或声功率(Sound power)(这两者之间就差了一个面积)来表示。这是另外一个不错的度量声音强度的方法。其和声压的对应关系是:声音能量密度扩大100倍,声压扩大10倍。不过由于声音能量密度和声功率都是功率量,所以如果也表示成级(Level)的话,是每扩大10倍=10 dB,或者20 dB等于变大100倍。因此,假设一个基准声音为1 [单位]的声压和1 [单位]的声音能量密度,那么一个有20 dB声压级的声音也正好会有20 dB的声音能量密度级——不过绝对数值上,分别会是10 [单位] 声压和100 [单位] 声音能量密度。

回到RMS的话,可以观察到,RMS的量纲是和波形的点的数值一致的场量(因为又开方过了),而非功率量。因此,和RMS对应的其实应该是声压级——也就是说,两个RMS差了10倍的音频,其“声压级”会差10倍(假设你的音响完全无其他损耗),“能量密度”或“功率”则其实应该是差了100倍。不过和上面一样,如果都用dB表示,数值则都是一样的,20dB。也就是说如果你整体提升一个音轨所有点的值2 dB,其RMS也会提升2dB(自己算算就知道),声压级和声能密度级也都会提升2dB。

到此为止,都是纯粹的物理量。牵扯到(感知)响度就复杂起来。说到响度,其实搜了一下相关的文献以外地少,连维基百科都说的很模糊。网上比较常见的是sengpielaudio的一系列文章([1][2]),这里也以此为基准。我们知道,人的各种感知和对应的物理强度,一般都是呈指数级关系(Stevens’ power law)。其中比较有名的是视觉(对光的敏感程度),这个话题我原来在知乎谈过一次,什么时候也可以整理一下发个blog。听觉自然也不例外,但是“指数级”的指数到底是多少呢?

一般而言,长度不过短、频率适中的声音,可以经验地认为声压级(SPL)每扩大10倍(即20dB),感知响度扩大4倍。也就是说,响度L正比于声压SP^0.6。如果换成声音能量密度,那就是L正比于SI^0.5*0.6=SI^0.3(因为声压和声能密度是开方关系)。换句话说,就是每10dB,响度翻倍(别忘了对于SI和SP,dB数是一样的)。

这里无耻地盗一张图来说明。

Loudness - Sound Pressure - Sound Intensity
响度/声压/声音能量密度对应表

Source:http://www.sengpielaudio.com/calculator-levelchange.htm

OK,既然我们知道了响度至少和能量、声压、甚至常说的dB数都不是线性而是指数关系,那么为啥我们还用RMS?仔细想想就会发现,我们的目的只有一个:使音量保持在一个水平线上。所以,不论他们之间的关系是啥,线性还是不线性,只要都是正相关的就可以:如果俩文件的RMS(或者是ReplayGain算出来的“代表RMS”)经过加减dB之后相同,那至少我们可以说,他们的响度也类似。毕竟,我们并不需要准确知道“A比B响多少”。其实就像我们说RMS是音频“能量”的代表,但也不是RMS大10倍能量就大10倍(而是100倍)。这就好比我们用摄氏度表示物体的冷热程度,你也不能说10度比1度热10倍一样(即使你换算成K,这种说法也不一定成立)。但是如果仅用来比较两者孰大孰小,就没问题。

响度的基准

现在,我们解决了第一个问题——如何测量响度。第二个问题自然就是选取标准。在当时,音响行业并没有任何相应的规范,于是RG的作者从电影行业——电影电视工程师协会(SMPTE)那里借来了一个规范:RMS是-20 dBFS的粉噪音,应该(在听众的位置上)呈现为83dB SPL。仔细解读这句话的话,你会发现它其实讲的是信号强度和实际声压的对应关系。你也可以说成“-15 dBFS的粉噪音应该呈现为88dB SPL”,关系依然不变。不过,这两个数也不能认为是任意选取的——粉噪音用-20 dBFS,是因为这个RMS级别的声音在电影、电视中比较典型,所谓“Alignment level”。“平均”强度是-20 dBFS,意味着在单侧有着20 dB的动态空间(即“headroom”)。这个强度的声音会被呈现为83dB SPL,大概也是认为83dB SPL是一个比较舒适的数值(这里有一份解读)。不过这里要注意,这个标准的本意是电影院用的,而电影院的音量,观众是不可调的,所以有一个规定的声压大小(83 dB SPL)很有意义。但是换成家庭媒体,意义就不大了:用户无论是在软件还是硬件,都有额外的Gain或者Volume可以调,这个数字并无太大意义。真正有意义的,是前半部分:-20 dBFS。

但是搞笑之处在于,ReplayGain自己觉得这个数太小(即平均音量太小),而且一般音乐也用不着这么大的headroom,于是自行加了6dB,变成-14 dbFS RMS粉噪音——作为RG的目标。那么,如果假设对应关系不变,那自然这个声音在理想的电影院环境里也就会被呈现为89 dB SPL了。这也就是你为什么会在各种地方看到,RG的目标是89 dB这种说法。但是可以看到,“89 dB”这个数字本身已经并没有任何实际意义了:在RG处理音轨的时候,纯粹是根据上面三步走,算出一个音轨的代表RMS,然后和-14 dbFS RMS的粉噪音的代表RMS比较而已。

事实上,RG1.0的原始代码都可以在这里下到,是MATLAB写的。里面也包括了一个ref_pink.wav文件,不过这还个是-20 dBFS RMS的,没有修改成-14 dBFS RMS。由于代码非常陈旧(2001年的…),MATLAB的一些函数已经发生了变化。所以,我进行了一些修改,发了个能用的版本在GitHub。对ref_pink.wav进行RG,可以看到得出的reference vRMS(前面我叫作“代表RMS”的那个东西)是-31.5。也就是说,如果是一个-14 dBFS RMS的粉噪音,就应是-25.5了。在真正的RG的实现中,无论是89/83还是-14/-20,其实都不需要参与计算,只要有这个-31.5/-25.5在就可以了。用同样的函数处理随便一个音频,得到其对应的vRMS为-14.9,也就是说我们需要降低16.6 dB,来使得其vRMS和reference(粉噪音)一致(如果换成-14 dBFS,那就是降低10.6dB)。

dBFS RMS的定义

这里又㕛叒叕得插播一段。如果你好奇地计算一下那个ref_pink.wav的RMS:

[y, Fs] = audioread(‘ref_pink.wav’);
myrms=rms(y);
valueDBFS = 20*log10(abs(myrms))

会发现……他并不是-20 dBFS,而是-23.0103 dBFS。这又是怎么回事?原来,dbFS又有“传统定义”和“数学定义”之分。所谓数学定义,就是我们这里计算的。但是在音响业中,经常用另外一种传统定义:因为音频大多是正弦波的叠加,故所谓的Full scale并无法达到。真正能达到的(在不发生削波的前提下),是一个幅值为1的正弦波的RMS,也就是0.7071。是不是觉得眼熟?前面出现过。这个数字换算成相对于数学上的Full sacle(通过方波可以达到),就刚好是-3.0103 dB。所以,经常情况下,业内说的“Full scale”,或者dBFS,是以这个值作为基准(0 dB)的。因此,一个数学上是-23.0103 dBFS的音频,在传统定义下,就变成了-20 dBFS了。这个定义一般只用于讨论RMS,在讨论波形上某个点、或者峰值时,FS依然是以1为基准。

ReplayGain——实战篇

OK,在彻底厘清了ReplayGain的今生前世,和相关的一些容易混淆的概念,我们终于可以进入实际应用,以及其中会遇到的问题——这也正是我要写此文的初衷。

响度竞赛

前面说过RG的目的是音量规格化,那就么具体到音乐,其问题来自于从90年代开始的“响度竞赛”。简单来说,商业公司发现,如果一个音轨明显比别人音量大,观众会心理上觉得更好听。因此,混音业开始悄悄地渐渐提升音频的响度,以求和别人一起放时“更突出”。

要提升响度,第一步自然是“maximize”——也就是把增益整个音频直到峰值(peak)达到1(或者0 dBFS)。不过要知道,几乎所有的商业混音本身就已经这么做了,那自然就没有上升空间。要进一步提高响度,唯有压缩动态范围——从而可以得到更高的平均响度(RMS)。因此,响度竞赛最大的影响其实并不只是响度增加,而是歌曲的动态范围也减小,起伏变小了(这里有一篇蛮长的文章,认为响度竞赛并没有导致动态范围减少。我没细看,不过在RMS level和峰值的差值这个语境下,动态范围变小是不争的事实)。

举例而言,おニャン子クラブ在1985年发行的专辑《KICK OFF》,随便选取一轨“真赤な自転車”,其RMS分别是(左右声道)[-19.6993  -20.3342](此处为“数学定义的”RMS,下同);如果拿它去跑ReplayGain,结果是-3.7319 dB(-14 dBFS粉噪音为基准,下同)。至于其峰值,是0.918945(或-0.7342 dBFS),甚至都没有max’d out(不过整张专辑的峰值确实是1就是了),单边动态范围约是19dB左右。

而前年发售的“ときめきポポロン♪”,RMS是[-11.5458  -11.5820],足足比上面的大了快9dB!峰值是1,也就是说从RMS到峰值的单边动态范围,只有11dB。如果拿这个去跑RG,算出来需要降低9.7776 dB。

在foobar2000中使用ReplayGain

可以看到,如果要想平衡所有的音量,最简单的办法当然全部都跑一遍RG,那自然也就都均衡了。不过这有俩问题:

  1. 跑所有的音乐实在是太不现实了。我本地的音乐足足有8 week+长,全部都跑一边的话大概要累死,而且这个计算其实还是挺慢的。
  2. 从上面的计算可以看到,RG默认的reference,实在是响度太低了。连以今天的标准来说声音小到不行的“真赤な自転車”,居然计算出来的是-3.7 dB,也就是还要再小3.7 dB!虽然我们可以通过其他途径再对结果进行增益,例如音量滑块或者音响的音量旋钮,但是这样的话就很难掌握foobar2000和其他程序相互的音量差。

然而,foobar2000内置的增益的目标响度是不可调的[*]。因此,我想到一种折衷的方法,利用foobar2000带的pre-amp选项。Pre-amp是一个RG之外的额外选项,可以在RG之后再加减一个dB,可以分别给有RG信息的音轨和无RG信息的音轨的设置不同的数值。我先选取大量近年的音乐,也就是音量合适不会太小、不用调整的音乐,然后全部视为一个整体进行RG计算。算出来的结果,大约是-9.5 dB。也就是说,近年的音乐的vRMS比RG的基准,平均来说高了9.5 dB。我们这里记住这个数,但是取消不保存,因为这些音乐并不需要任何处理。相反,对于那些音量偏小的老CD,我们先去真的跑RG:跑完之后的结果自然是一个一致、但是较低的音量。不过,我们只需要在pre-amp里设为+9.5 dB,所有的音乐就一样响了。而且,因为老CD毕竟是少数,这样也不用跑太多次。

[*] Foobar2000的RG的目标(-18 LUFS)是不能调的。在选项的高级里,确实倒有个选项可以设target volume level (dB)(默认89,呃至于这个数字怎么来的前面说了,这里你就理解为你修改后的数值和89的差值加到那个-18 LUFS就是),但是那个只对文件转换有用,对播放器内播放没用。

Clipping 和 True peak

和所有的增益有关的东西都需要注意一个点,就是clipping(削波)。我们知道信号的极限就是0 dBFS,如果你通过上述的RG+pre-amp之后,峰值超过了0,那就会被削波——这是我们不愿意看到的。事实上,前面也说了,80年代的音乐虽然RMS很低,但是峰值依然也是接近1的(毕竟,几乎所有的商业CD,出厂前都会max’d out)。经过我们上面的RG+pre-amp的处理,很容易地就超过了1。例如,“真赤な自転車”的峰值是-0.7342 dB,加上RG的-3.7 dB,和pre-amp的+9.5 dB,峰值就变成了+5.1 dB。这也是为什么RG同时也会扫描并且在元数据里记录峰值,便于事后再降低(fb2k里有选项)来防止clipping的缘故。

虽说一般而言,对少数峰值的削波,人耳并不敏感,不过还是尽量要避免这种情况。但是别忘了,在音频输出到mixer之前,还要经过“音量”的计算。一般而言,我的音量都设在-10到-20 dB之间,因此理论上来讲,应该不会有任何削波的问题。

呃,其实说到峰值和削波,还得插播一段讲讲true peak的问题。如果把数字形式的音乐(即一串数字)还原成模拟信号,相当于从一堆离散的采样点还原出波形。根据采样定理,在遵守原始的频带限制(频率不超过采样频率的一半)下,每一组离散点只能还原出唯一的波形信号(注:非科班出身的弊端又体现了,我原来一直没能真正理解采样定理。直到今天听到这句把常见说法“2倍于信号的频率采样可以完美采样”反过来的说法才豁然开朗。推荐看Xiph.org的这个视频,说的非常清楚,是科普的典范)。但是,能完美还原波形,不代表波形中的峰值就在我们的采样点中会出现(所以我上面说信号的极限就是0 dBFS也不太准确):

modified signal with ripple
曲线是过采样点的唯一的波形,但是峰值高于采样点

Source: https://techblog.izotope.com/2015/08/24/true-peak-detection/

因此,如果我们仅仅测试采样点的数值,来选取peak,可能会相差甚远。因此,更高级的峰值检测,一般会把信号还原之后再过采样2x甚至4x,从而来找到更准确的峰值。foobar2000的RG也自带oversample factor的选项,在advanced里的tools里。至于这个峰值搞这么准确到底有什么用(毕竟,我们的采样点本身并没有溢出嘛),就要牵扯到DAC了,这里就不赘述。

Windows mixer 的隐藏 Limiter 问题

不过,理论和实践总是不一样的。在我用上述方案播放音乐时,每当放到那些经过RG+pre-amp的老歌的大动态区域,总有一种很奇怪的感觉——嗯对,就是像被动态压缩过一样,听起来非常不适。百思不得其解之下,我决定把某首歌的RG和pre-amp硬编码进去(也就是说,彻底地修改波形,合成进去RG和pre-amp。RG本身则只是通过元数据的方法,并不真的修改波形文件本身)。虽然这样自然也会产生削波了,不过播放时的最终输出响度应该和有RG tag的原始文件一致。那么,现在播放这俩文件(原始文件+RG tag+re-amp)和转码后的文件,我惊奇地发现,听上去响度居然不一致:原始文件+RG+pre-amp的组合,要明显的小很多。

在实在没有思路的情况下,我斗胆去Fb2k的论坛发了一贴(要知道这种论坛,一般大牛脾气都不好)。不过惊喜地是,居然得到了答案。

原来,Windows的默认音频输出(Direct Sound,DS)混音器,有一个内置的“Limiter”。当输出音频超过一定值(这个值具体是多少不清楚,一说是0 dBFS)时,Windows会自行降低响度来防止削波。至于降低的方式不明,不过从我的感觉上来讲,不是整体降低音量,应该是类似于动态压缩的方式,即在一定的buffer内遇到超过1的峰值就降低,算法不是很高级从而产生不适感。

前面说过,考虑了音量之后明明根本不会超过0 dBFS才对。但是Foobar2000现在是用Windows自带的mixer来处理音量的(你改fb2k的音量,会发现右下角音量控制里面的foobar程序的音量也会跟着改),结果这个Limiter作用的时间早于音量(会检测音量调整前的峰值),结果就导致了这个现象。至于我自己压制的那个对比的文件,因为溢出的部分已经削波削掉了,自然就不会被Windows限制。不过,这个(limiter作用于音量前的问题)似乎是个Windows的bug:只有Win 7或者Win 8.1会有这个问题,在Win 10中,已经得到修复。另外,如果使用WASAPI之类的输出绕过DS,就完全不会触发这个Limiter,无论你真的溢出了没。

既然知道了原理,那解决方案也很显然了:我把所有没有RG信息的pre-amp设成-9.5 dB,有RG的设为0 dB,就可以保证两者依然均衡;但是总体音量偏小的问题,则只能通过把foobar的音量提升到0到-10 dB,还好之前留的余量足。其实,我也试过有RG信息的设成+3 dB(另外一个对应设成-6.5 dB),好像也不会被限制(虽然明明有少许溢出0 dbFS的peak),这就搞不明白了。

不过,往移动设备上转换的时候,我依然用的是新音乐原封不动、老音乐硬编码进RG+9.5 dB的方式。否则用我的解决方案播放转换过的文件,音量反而会变小了。虽然这样做可能会导致削波(因为转换时自然不会考虑音量进去),不过移动设备听不太出来。更好的方案应该是再加一层DSP,用fb2k自带的Advanced Limiter,把超过的部分智能地调整到0 dBFS以内;虽然有点像动态压缩,但是算法更高级,完全没有一般动态压缩的不适感。

顺便一提,根据某个帖子的说法,一般动态压缩的不适感似乎是来源于压缩“释放”得不够快,或者开始得太早的缘故:例如,你有一个音轨一直是很小声突然一个巨响,然后又恢复到很小声。我们想要压缩的自然是这个巨响,但是一般的动态压缩在检测到巨响(会有一个buffer预先检测)之后,会释放的比较慢,从而导致后面紧接着的“小声”部分会有个从小变大的过程,听起来就很不舒适了。

ReplayGain 2.0

故事到此,似乎也就告一段落。但是别急,还有最后一个爆炸性消息:fb2k的“ReplayGain”,并不(再)是ReplayGain。或者准确地说,并不再是原始的、也就是上面提到的算法了。我也是无意发现这件事的:你把之前我提过的ref_pink.wav文件放进foobar2000跑RG,因为是个-20dBFS RMS的而后来的RG标准是-14(见前文),理论上应该跑出来+6dB对不?结果却是:+2.35 dB

这又是怎么回事?!原来在2011年,欧洲广播联盟(EBU)提出了一个新规范,EBU R128解读),其中对响度规格化进行了规范。其中提出,响度规格化的目标应为-23 LUFS。LUFS又称LKFS,是ITU-R BS.1770中详细规定的一种测量响度的算法。比起RG,这好歹是一个正儿八经的行业协会,经过多年研究得出的规范,所以很快,foobar2000就修改了RG的算法(又称RG2.0)为ITU-R BS.1770中的算法了。唯一的区别是,为了和原来的RG保持在相当的响度,又“擅自”把目标提升了一些,改成了-18 LUFS。注意这里的“相当的响度”,是基于对大量音乐分析得出的(另参见当年的各种对比),而并不是把原始的pink噪音拿来分析的;事实上,原始的pink噪音(-14 RMS dBFS版)算出来是 -15.36 LUFS,响于-18。(如果想自己测试要注意,我发现bs1770gain对单声道处理和fb2k不同,于是我手动复制了一份声道把它变成了双声道先)。民间也有很多对这个的实现,例如r128gain (后改名为 bs1770gain,毕竟讲道理,算法的部分是bs1770规定的,而不是r128)、libebur128(fb2k就是用这个实现修改而成)等。

另外,除了直接使用ReplayGain之外,fb2k论坛的版主之一kode54开发了一个DSP插件,foo_r129norm,可以即时地将音轨的音量规格化。也就是说,如果用了个这个插件,完全傻瓜化,根本不需要单独去给音轨跑RG啦。不过,考虑到转换到移动设备的需求,我暂时并没有用它。顺便,DSP的处理是在RG之后的,也就是说pre-amp的选项就没效了。你要想再在此基础上提升一个音量,得用EQ整体加个dB咯。

结语

本文断断续续写了2天,阅读了大量的资料。不过可以说是很满足的,基本中间遇到的问题都算是想通了。中间看过的帖,我尽量都用链接插入到文章中了,算是便于自己以后查找。