CP勾指幕后
之前一直被催更,听到了无数好奇为什么完整版要做这么久的声音。虽然完整版出了之后其实也就没人关心这个了,但既然答应了就还是写一下吧。
文中出现的所有代码和推导均不保证正确性,请确保完全理解代码后再使用,因此造成的一切后果自负。
完整版面临的挑战
因为C酱只唱了一段,所以剩下的部分全部得靠虚拟cp来唱。听起来是个很简单的活,有手就能做。但是有些作为听众不需要考虑的东西,作为创作者却不得不仔细谨慎地审视:
虚拟C酱需要直面C酱本人的歌声带来的对比。我必须做到在C酱的声音和虚拟C酱的声音衔接时,能够以假乱真到不认真听会听不出来区别。否则,整首曲子的连贯性与完整性都会因此大受打击。这一点在初版中做得不够好,因此需要进一步改进。
C酱本人的歌声为咬字方式提供了一个强有力的参考。相比之下,其他AI翻唱并不需要关心咬字是否相似,只需要声线相似即可。但在这里我不得不使用大量的翻唱作为推理源,选出其中最像C酱咬字风格的片段拼凑出全曲,以保证C酱和虚拟C酱交接时不那么令人不适。进一步推广这个要求,我期望从任意C酱本人唱歌的时间点跳转到任意虚拟C酱唱歌的时间点,或是反过来时,都应该不容易被察觉到有巨大区别。
完整版应当和初版一样,在结尾时让人感到意犹未尽。考虑到结尾时几乎只剩下人声,这进一步提高了对模型表现力的要求。
完整版的水准必须配得上初版给所有人带来的期待,以及面对必然的与初版的对比;无论如何都必须确保这不是一个让人感到“狗尾续貂”或者“可惜”的完整版。
综上,这对虚拟C酱的声线重建能力提出了严苛的挑战。她的声线必须足够接近C酱本人,并且有较强的重建能力,以便能够在翻唱源质量欠佳时依然提供足够好的人声,减少对本就稀少的唱功过关的人声翻唱源的需求。
当然也有其他不使用虚拟C酱的解决方案:
扔掉C酱本人的歌声片段,全部替换为虚拟C酱,可以降低对高相似度的咬字的期待。然而那段歌声毕竟是整个项目的起点,初版也已经用过真人的声音,所以没有任何这么做的理由。此外比起AI我当然更希望能用上真人的声音。不如说要是能听到人声本家的话,我死而无憾
也曾有人问我为什么不直接问C酱能不能唱剩下的部分。很可惜虽然AI的声线像,但是和真人比的区别还是很大,尤其是咬字上的特征,分辨能力好的话一耳朵便知。在AI相关的创作上,听得越多越能明白和真人的距离有多远。在这种前提下,C酱要是在知道干声用途后还愿意提供干声,想必被人发现后会有炎上吧。这不仅与她一直以来的做法相悖,而且为了二创素材打扰本人,我认为也是不合适的。
虽然朋友圈的观众大多都有着和主播们同样的温柔,但是不能因为善意而降低要求。确实做得不好也没关系,但谁不希望做得真的好呢。
提升数据集质量
回归正题。众所周知,语音数据量越大,干声质量越高,模型质量就越好。和坐拥10小时杂谈回切糕的Pi不一样,C酱玩的游戏大多是有配音、BGM和大量交互音效的精良大作,因此即便是单人游戏也很难仅通过RMS+阈值作为依据只切出她说话的片段,更不用说很多时候她都在联动。此外她也很少杂谈,即便杂谈也总是开着有vocal的jpop作bgm,这使得获取高质量数据集的难度进一步上升。最后,因为她疑似在10月和11月间换了一次麦克风,中高频的收音效果和贴脸感有较大差别,所以能够使用的数据范围再次缩小。
在训练过程很难做出差异的情况下,数据集的质量是至关重要的。因此绝大部分时间都应该花在数据的筛选和预处理上。一般来说,预处理可以分为如下几步:
数据源。我用的是yt-dlp,可以批量下载m4a。如果有多个目标视频源,可以自己写个小脚本跑。
分离人声。这一步通常使用UVR5,我用的是其中的demucs v4 htdemucs_ft模型。本地3070Ti跑还挺慢的,据说外网有UVR colab notebook,可以使用更好的GPU快速分离。效果最好的似乎是Kim_Vocal_1,但比demucs还慢。此外,去除其他人声可用UVR-MDX-NET Karaoke,可用于去除vocal和声,增加可用的推理源。我没有试过能不能去除杂谈回背景中的vocal,但同理,可以一试。
去除噪音。游戏音效很难去除干净,另外UVR有时也清不干净BGM。这一步我使用了DeepFilterNet。值得注意的是官方的实现没有针对超长音频数据的优化,所以我自己写了一个不会爆显存也不会爆内存的,可以使用GPU加速的分块enhance函数。
自动切片。因为我在第一次处理数据时还没有傻瓜式一键切片器,所以用的也是自己写的。
可选的过滤器。我在这一步上浪费了太多时间。我会简短地描述我做过的努力,为什么失败,以及未来的期望方向。
因为目前还没有很好的过滤系统,UVR和DeepFilterNet的过滤能力也有限,因此第一步过滤其实是在数据源的选取上。如果输入的数据本身就干净,那么其实第二和第三步都不需要。只可惜对于游戏主播来说能满足这种条件的数据实在是太过稀有。
起初我的期望是,滤出15小时以上的说话素材,以便从零训练出具有强重建能力的DiffSVC模型。然而过滤器的误伤率实在太高,153小时的素材仅切出3小时的语音数据,其中还有不少无声部分。
数据这么少,那就应该使用预训练模型,然而我一直试着拒绝它。一方面,DiffSVC的虚拟Pi展现了10小时干净数据的潜力。另一方面,我希望尽可能地减少音色泄露的风险。此外从so-vits 2.0开始,C酱就一直是不得不用预训练模型才能炼出合格效果的那个,我的执念希望她也可以从零炼出不亚于虚拟Pi水准的模型来。
然而数据量和难度终于还是将我击败了,最终投降使用了预训练模型,真香。
分块降噪器
首先是在UVR5分离人声之后用于降噪的分块enhance。
如果直接用DeepFilterNet提供的binary的话,不仅没法用GPU加速,对输入数据的格式要求也过于严格(只支持16位48k采样率的wav)。使用它的python库可以更好地实现预处理,例如读取各种格式的音频数据,并使用GPU加速。
值得一提的是,不留headroom的话deepfilternet会产生很恐怖的失真,尤其是没有经过true peak limit还被mp3重新有损压缩过的数据。听起来像clip,但其实是直接输出幅值1。如果使用python调用enhance,就可以灵活地调整输入信号的峰值peak。以下是代码:
切片器
正如上面提到的那样,SVC模型要求我们把数据切成5-15秒的片段。人工切费时费力,因此当然是交给机器做。
在干净的干声输入上,例如Pi的切糕杂谈回,可以直接根据RMS来判断voice activity。因此,最初的思路就是求出RMS曲线后,根据阈值切出小块语音来。
然而,RMS的精度是采样级的,如果直接使用阈值的结果,得到的覆盖率会惊人地低:C酱开始认真玩游戏之后,说话就相对较少,而且都是短句。在这种情况下,不经过合并的区间,几乎很少有可以满足5-15秒限制的片段。例如在AV860286073_part1上,结果如下:

覆盖率仅有6%,至少有80%的数据被浪费了。C酱的数据源本身就少,这样的浪费是不可接受的。
一种贪心的更优解是,维护一个栈。顺序扫描所有说话区间,并总是尽可能地合并当前栈顶与当前说话区间。当当前栈顶无法与当前区间合并又不符合条件时,删除栈顶区间。为了进一步控制切片平均RMS,我引入了动态gap的概念,即根据栈顶区间长度来计算栈顶区间与当前区间间隔silence的长度上限。
这是一种相当启发式的方法。即便如此也足以获得比不合并好得多的覆盖率。不过,在说话较少的数据上,如AV860286073_part1,覆盖率依然不够好:

于是又实现了基于dp的最大clip覆盖率的合并方法。
首先用符号定义给定的参数。定义输入信号为,采样率为
。所有从RMS+阈值生成的clip区间的集合为
,第i个clip为
,
表示
的左边界与右边界。对应地,我们能求出对偶的silence区域形成的集合
,使得该集合及其任意元素
满足以下条件:
另外规定。现在问题可转化成,求出一个长度为
的01 bit mask,其中第
位代表是否选取
作为音频的切分点。最朴素的解法自然是
枚举,然而对于我们需要处理的,长度动辄半小时起步的数据,这种复杂度肯定是不行的。
定义为,在选择
作为分割点的情况下,集合
中最多能有多少clip被合法覆盖。注意此处的合法覆盖指,对于任意
,
。其中
是
左侧第一个被选中的切分点,
是
右侧第一个被选中的切分点。lower和upper为so-vits / diff-svc要求的音频片段长度,此处为
。
这个子问题是递归的,并且(0-indexed) 就是全局的最优答案。它的状态转移方程为:
容易发现在第一项中满足条件的j的区间是有限且连续的。因此可以简单地用二分查找满足条件的最大j,然后逆序for循环+break来节省循环开销。
第二项涉及的范围更大,不优化的话哪怕是平均复杂度也会退化到。观察到dp单调递增,因此可以贪心地选
。容易证明替换后一项后,dp依然是单调递增的,并且答案依然是最优的。
和其他最优化dp问题一样,只需要在状态转移时记录前一状态,就可以在对应生成的边反向的树上反向追溯最优解。这样就得到了最大覆盖率的切片方案。
虽然说应该是最优解,但我不知道怎么严格证明。欢迎知道怎么证的读者在评论区告诉我!
当然上述方程只是最优化覆盖的切片数,而不是最优化覆盖clip的总时长。好在只需要简单地修改转移方程的第一项即可更改最优化目标,这可以通过clip对应的duration的前缀和数组快速实现。最终我也使用了总时长最优化来实现代码,如下所示:
这次继续在AV860286073_part1上测试,但是同时对比三种方案的覆盖率:

对于说话更密集的数据,例如AV566314551_part1,三种不同合并方案的覆盖率和silence比例也大致不变:

然而切出来的所有数据里有50%都是silence,如果它们被SVC系统认为是需要拟合的部分,会影响系统的性能。在引入非线性artifact和拟合silence之间,我选择了去除silence。所以我又写了remove_silence函数:
结果加上这个之后,dynamic gap也能经常做到100%覆盖率了,dp除了保证能提供最大化覆盖率同时最小化silence比例的最优解之外,好像也没什么优势了。

呃,结果搞半天可能根本就不需要dp啊。
Bonus: 如何替换勾指起誓封面的字体颜色
没有PS或者其他专业图像处理工具,只有paint.net,选区替换颜色效果太差,重新找字体也不现实。现在我刚完成了整首曲子的混母,很想快点投稿让其他喜欢cp的朋友们也听到,但是就是差个漂亮的有cp代表色的封面,很急很急很急很急,怎么办呢?
答案是PIL!
原理就是将目标范围内的颜色的hue调整到目标hue和saturation。最终就能得到小图效果相当不错的cp标志色的封面咯!不过要是放大看的话,边缘还是不太行。

失败的过滤器
前面都是简单的部分,如果目标清晰的话只需要一晚上就能写完。拖了那么久才更新,是因为大约有一个多月的时间都浪费在实现并提升过滤器的准确率上,最后发现再怎么过滤都不如直接用人工筛选过的干净数据源切块后丢进预训练模型效果来得好。
下面是我挣扎着写了一个月的过滤器的结构:

这个系统太过trivial,相比端到端又太过于复杂,以至于理所当然般地没什么好结果。训练数据只有5分钟更是雪上加霜。
系统会使用不同的预训练模型,在音频切片后计算说话人向量。对于训练数据,所有的说话人向量会被用于训练novelty detector model(见上图红色部分),这个模型会在之后被用来计算评估集上的分离准确率。以下是使用的所有说话人向量与模型:
dvector (yistLin/dvector), vggish, VI-Speaker, ECAPA-TDNN (speechbrain)
Isolation Forest, Local Outlier Factor, Gaussian Mixture, One Class SVM, 以及受花儿不哭老师启发而实现的取embedding在L2空间内的平均值+余弦距离的打分器
训练集是长达5分钟的人工筛选的高质量C酱干声。eval集含有157条正样例,124条负样例。
我本以为能从各种各样没能被demucs消除干净的干扰中提取出高质量的干声,尽可能提升数据量,结果到头来似乎还不如写个网站让舰长群的朋友们帮忙一起筛选。越想越觉得不该一个人钻牛角尖。
具体的代码太长不贴了,反正效果一团糟。整个管线跑下来,能得到模型的分类结果的阈值、f1和准确率:
可视化分类结果后可以看到类似这样的输出:

根据最高的准确率/f0位置,可以简单地定义得到基于阈值的分类器。
将过滤器,分类器与切片器结合,可以得到类似这样的结果:

看起来很不错。然而,生成的切片中容易出现切片首尾存在其他人声的情况。由于打分是基于平均的相似度,因此先切片再分类的做法容易引入脏数据,降低数据集质量。因此,我又实现了先计算相似度曲线,后根据曲线+阈值确定说话区间的做法。在688399338_part2上结果如下:

C酱说话的声音比游戏中经过电平处理,峰值均匀统一的信号要响不少,因此仅通过输入波形也能分辨出C酱说话的部分。然而容易发现,游戏内配音的部分也有较高的打分,因而说明分离度较差。
此外,这个系统在干净的数据上表现出倾向于给“密集说话”片段打高分的倾向,而不关心说话人是否准确。也许是因为block size为3秒,导致时域分辨率太低。但是说话人向量大多是在较长的语音片段上训练的,编码器在更小的block size上给出的向量的准确率未知,因此也不能贸然通过更小的blocksize和hopsize提升分辨率。
一种准确率远高于基于speaker embedding方法的分离方式是,注意到在C酱换麦克风之前的数据中,其环境底噪在55Hz附近有一个明显的正弦波分量;并且由于麦克风自动根据输入电平大小mute/unmute输出,因此很容易通过高阶带通滤波器+sosfiltfilt+阈值来得到精确的说话区间。然而因为我不使用旧麦克风的数据,因此这样的观察没什么用。
展望
如果要继续的话,应该从哪些地方改进呢?
在sovits/diffsvc之外,最新的工作,像是VALL-E这样有zero-shot TTS能力的模型,要是也能用于SVC就好了。但这也只是没看过论文的外行小白的揣测罢了。可能看完abstract我就会立刻后悔打出这行字此外其社区实现还在积极开发中,没有可用的预模型,所以短期内可能还没什么盼头。
预训练模型很好,但是在数据量较小的时候,DiffSVC还是会逐渐收敛到很糊的人声上。也许基于diffusion方法的语音生成已经有了新的改良,只是还没有像sovits/diffsvc这样可用?毕竟DiffSinger是21年的工作,如今已经过了两三年了。我想应该多看看相关论文,要是真有的话,说不定早就有更好的模型呢?此外DiffSVC在质量较差的推理源上受到的影响似乎比sovits严重的多,要是有相关理论分析并且加以改善就好了。
一个比较简单的改进是把DiffSVC的内容编码向量从Soft Hubert换成ContentVec,这个应该有点基础改个半小时源码就能跑起来。但是它的性能究竟是不是真的更好,还是要看论文和实验才知道。另外也没有基于ContentVec的预训练模型,改出来也不能用。FishDiffusion似乎已经完成了这一点改良,但我还没试过效果。
除了模型,预处理上能做的事情还有很多,毕竟上面的分离系统的性能只能用一坨来形容。
一种可能的方式是使用target speaker extraction的各种SOTA系统进一步过滤数据集。例如今年1月底,speech separation的一篇新SOTA提出了一种全新的能够将分离信号的理论上限SNR提升3dB的方法。对于TSE任务,类似的方案也许也能获得很好的效果,例如用SpEx+替换架构图中的B模块。代价就是得自己动手实现(......)不过既然预训练模型都可用,也许只要花几个月就能写出来。
除此之外,也许也可以自己训练一份DeepFilterNet的参数,但是参杂更多游戏音效在内。只是这么做似乎只是在为demucs擦屁股而已。这么想的话,最后这个分类器可以认为是更廉价的擦屁股纸巾,然而我却在提升廉价纸巾的韧度和易用性上浪费了一个多月,哈哈。
关于咬字的问题,毕竟内容编码器是对所有语音通用的,但为了极致的“听起来就是本人”的效果却不得不一直和这种不可控因素虚空较劲,很打击创作的热情。所以下一步应该会试试DiffSinger,看了一下数据集的处理还挺简单的,应该很快就能出效果。毕竟是基于拼音标注的,想必也能学到不同音素的独特发声方式,进一步逼近真人吧。
当然说不定效果最好的是用钞能力拜托C酱录一些干声素材然后训练。只是那么做有一种自己输了的感觉。
毕竟我不是机器学习从业者,总觉得自己的思路完全没打开。要追求前沿的极限效果,还是得多看论文多动手才行啊。
总结
有时候会想,要是没有浪费这一个月钻牛角尖的话,是不是就能赶上情人节了呢?
其实给cp勾指做完整版不会再有多少额外的数据提升。但我还是做了。理由很简单,和你们一样,我也想要听到。
但是做完之后,反而因为听了太多太久已经没有再听的欲望。那努力了这么久,究竟是图什么呢。
看到观众在弹幕里联想闪闪发光才惊觉原来歌词也这么适合这他俩。作为创作者的我因为时时刻刻关注咬字声线时值音高融合度这些更底层的音频细节,早就没有那种他俩真的一起在唱歌的幻觉了。
真羡慕观众。