通往Guitaraoke之路,第一部分:Vamp、Chordino、ImageSharp和ffmpeg
Dylan Beattie 发布于2022年9月19日 • 永久链接
目前我的一个副业是在我当地的啤酒厂——位于Sydenham的非常棒的Ignition Brewery——每月举办一次卡拉OK之夜,但这可不是普通的卡拉OK。
除了唱歌,你还可以上台弹吉他或贝斯;我们提供乐器和设备,我还为所有伴奏曲目制作了5声道混音,这样如果有人想现场演奏,我们可以淡出特定的乐器。
我在8月举办了第一次Guitaraoke之夜,效果非常好(尽管遇到了热浪和火车罢工!)——但到目前为止,我收到的最多反馈是“如果能在视频上看到吉他和弦就好了”。嗯……确实如此。毕竟,卡拉OK的整个意义在于歌手不需要知道歌词……如果我们真的要好好做Guitaraoke,演奏者也不应该需要知道歌曲。
所以,这就是需求:在当晚的屏幕上显示吉他和弦。过去几周,我一直在研究实现这一目标的各种方法——从在Premiere中手动编辑视频(这太耗时了),到构建某种自定义视频播放器,从文本文件中读取和弦图表并将其叠加到视频上(事实证明构建视频播放器很难),再到涉及OBS的复杂系统。
不过,今天我取得了一些进展。以下是我的实现过程。
Vamp和Chordino
Vamp是“一个用于从音频数据中提取描述性信息的音频处理插件系统”。直到几天前,我才听说过Vamp,不得不说,它并不是我见过的最用户友好的平台——很多插件的“文档”是作者发表的科学论文,描述了插件的功能。(真的……)
注意:我花了几天时间尝试在我的M1 Macbook Pro上运行这些东西,但效果有限。Arm64二进制主机无法加载x64二进制插件,很多操作都涉及到让程序A加载库B,而库B又需要库C。不过,Windows 10上运行得很顺利——我猜Intel Mac和Linux也会一样容易。
为了尝试并看看效果如何,我安装了Vamp插件包,然后使用Audacity作为宿主应用程序来试用这些插件。安装插件包后,点击分析,添加/移除插件……并启用“Chordino: Chord Estimate”插件:
然后打开一个音频文件,高亮音频波形,进入分析 > Chordino: Chord Estimate……你会得到这个:
这是我第一次感到“哇,这真的可能行得通”……插件很好地识别了Shania Twain的《Man! I Feel Like A Woman》的和弦,而且还提取了和弦变化的确切时间。这将会很有用。
下一步是将这些数据导出为我可以实际使用的格式。原来Vamp开发者SDK包含一个简单的命令行宿主程序,所以我下载了一份。
要使用命令行宿主程序,我必须将输入文件转换为WAV音频(如果你安装了ffmpeg库,Audacity可以很乐意地分析MP3音频和MP4视频文件)。经过一些试错,我找到了正确的语法;我需要的命令是:
D:\projects> VampSimpleHost.exe nnls-chroma:chordino man-i-feel-like-a-woman.wav
VampSimpleHost.exe: Running...
Reading file: "man-i-feel-like-a-woman.wav", writing to standard output
Running plugin: "chordino"...
Using block size = 16384, step size = 2048
Plugin accepts 1 -> 1 channel(s)
Sound file has 2 (will mix/augment if necessary)
Output is: "simplechord"
0.185759637: N
0.464399093: Bmaj7
3.854512471: Bb
5.061950113: Gm
5.619229025: Bb
20.944399093: Eb7
22.244716553: Bb
28.653424036: Eb7
29.907301587: Bb
31.950657596: Gm
32.554376417: Bb
34.411972789: Ab
34.690612245: Bb
38.220045351: Ab
38.545124716: Bb
...
简单。这是时间戳(以秒为单位,小数形式)和和弦(“N”表示“无和弦”)。
好了。稍后,我们可以将其连接到某种自动化处理管道中。今天,我所做的是将其重定向到chords.txt
,然后进入下一部分:将其转换为视频。
将和弦数据转换为视频
有很多方法可以将数据转换为视频,但有两件事要记住。一——它总是归结为渲染单个帧然后将它们拼接在一起。二——如果ffmpeg做不到,你就不需要它。如果你以前没有使用过ffmpeg,它是一个命令行应用程序,可以读取、写入、转换和流式传输几乎所有曾经发明过的音频和视频格式。
所以,这是我的方法:
- 创建一个程序,读取和弦数据并将其转换为单个视频帧
- 将这些帧提供给ffmpeg以生成透明视频文件
- 将该视频文件合成到原始卡拉OK曲目的顶部(有点像在电影上显示字幕轨道)。
使用ImageSharp和FFMpegCore在.NET中渲染视频
我在这里使用.NET是因为我熟悉它,也喜欢它。为了在每个帧上绘制和弦名称,我使用了ImageSharp;每个帧然后被包装在一个ImageVideoFrameWrapper
中,这是我编写的一个类,用于将数据从ImageSharp传递到ffmpeg。然后我使用一个名为FFMpegCore的.NET库直接从内存中读取这些帧并将其渲染到输出视频流中。注意,我在这里使用了.webm
格式,并指定libvpx-vp9
为视频编解码器——这是因为我希望输出视频支持alpha透明度。
以下是程序代码:
using ChordMaker;
using FFMpegCore;
using FFMpegCore.Arguments;
using FFMpegCore.Pipes;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
const int FPS = 60; // 帧率
const float SPF = 1f / FPS; // 每帧秒数
var chords = File.ReadAllLines("chords.txt").Select(line => new Chord(line)).ToList();
for (var i = 1; i < chords.Count; i++) {
chords[i-1].Duration = chords[i].Time - chords[i-1].Time;
}
var frameCount = (int)((chords.Max(pair => pair.Time) + 5) * FPS);
const int width = 1920;
const int height = 1080;
const int speed = width/6;
var frames = CreateFramesSD(frameCount, width, height, chords);
var videoFramesSource = new RawVideoPipeSource(frames) { FrameRate = FPS };
FFMpegArguments
.FromPipeInput(videoFramesSource)
.OutputToFile("chords.webm", overwrite: true, options => options
.WithVideoCodec("libvpx-vp9"))
.ProcessSynchronously();
Console.WriteLine("完成");
static IEnumerable<IVideoFrame> CreateFramesSD(int count, int width, int height, List<Chord> chords) {
var family = new FontCollection().Add(System.IO.Path.Combine("fonts", "ttf", "FreeSansBold.ttf"));
DrawingOptions options = new() {
GraphicsOptions = new() {
ColorBlendingMode = PixelColorBlendingMode.Normal
}
};
var playheadPosition = width / 8f;
var chordBarHeight = height / 8f;
var chordBarTop = chordBarHeight * 6.5f;
var font = family.CreateFont(chordBarHeight * 0.6f);
var chordTextTop = chordBarTop + chordBarHeight * 0.2f;
var transBlack = Brushes.Solid(Color.FromRgba(0, 0, 0, 200));
var transRed = Brushes.Solid(Color.FromRgba(255, 0, 0, 127));
var white = Brushes.Solid(Color.White);
for (var frame = 0; frame < count; frame++) {
Console.WriteLine();
Console.Write($"帧 {frame}/{count}: ");
var time = frame * SPF;
using Image<Rgba32> image = new(width, height, Color.Transparent);
image.Mutate(x => x
.Fill(options, transBlack, new RectangularPolygon(0, chordBarTop, width, chordBarHeight))
);
var lastChord = String.Empty;
foreach (var chord in chords) {
if (chord.Name == "N") continue; // 无和弦
if (chord.Name == lastChord) continue; // 无变化
if (chord.Name.Length > 4) continue; // 忽略像Bm7b5这样的和弦,Chordino有时会返回这些
if (chord.Duration < 0.5f) continue; // 跳过非常非常短的和弦
lastChord = chord.Name;
var offset = playheadPosition + speed * (chord.Time - time);
if (offset < -playheadPosition || offset > width) continue;
var point = new PointF(offset, chordTextTop);
Console.Write($"{chord.Name} ");
image.Mutate(x => x.DrawText(chord.PrettyName, font, Color.White, point));
}
image.Mutate(x => x
.Fill(options, transRed, new RectangularPolygon