[博客翻译]Vamp、Chordino和FFmpeg的吉他和弦卡拉OK(2022)


原文地址:https://dylanbeattie.net/2022/09/19/the-road-to-guitaraoke-part-1-vamp-chordino-imagesharp-ffmpeg.html


通往Guitaraoke之路,第一部分:Vamp、Chordino、ImageSharp和ffmpeg

Dylan Beattie 发布于2022年9月19日 • 永久链接
目前我的一个副业是在我当地的啤酒厂——位于Sydenham的非常棒的Ignition Brewery——每月举办一次卡拉OK之夜,但这可不是普通的卡拉OK。
guitaraoke-social-media-banner
除了唱歌,你还可以上台弹吉他或贝斯;我们提供乐器和设备,我还为所有伴奏曲目制作了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”插件:
image-20220919193238986
然后打开一个音频文件,高亮音频波形,进入分析 > Chordino: Chord Estimate……你会得到这个:
image-20220919202250234
这是我第一次感到“哇,这真的可能行得通”……插件很好地识别了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,它是一个命令行应用程序,可以读取、写入、转换和流式传输几乎所有曾经发明过的音频和视频格式。
所以,这是我的方法:

  1. 创建一个程序,读取和弦数据并将其转换为单个视频帧
  2. 将这些帧提供给ffmpeg以生成透明视频文件
  3. 将该视频文件合成到原始卡拉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(0, chordBarTop, playheadPosition, chordBarHeight))
      .Fill(options, white, new RectangularPolygon(playheadPosition - 2f, chordBarTop, 4, chordBarHeight))
    );
    using ImageVideoFrameWrapper<Rgba32> wrapper = new(image);
    yield return wrapper;
  }
}

这是Chord类:

class Chord {
  public float Time { get; set; }
  public string Name { get; set; }
  public float Duration { get; set; } = 0f;
  public string PrettyName => Name.Replace("b", "♭").Replace("#", "♯");
  public Chord(string line) {
    var tokens = line.Split(":");
    Time = float.Parse(tokens[0].Trim());
    Name = tokens[1].Trim();
  }
}

以及我用来将数据从ImageSharp传递到FFMPEG输入缓冲区的ImageVideoFrameWrapper<T>类:

using System.Runtime.CompilerServices;
using FFMpegCore.Pipes;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace ChordMaker; 
public class ImageVideoFrameWrapper<T> : IVideoFrame, IDisposable where T : unmanaged, IPixel<T> {
  public int Width => Source.Width;
  public int Height => Source.Height;
  public string Format => "rgba";
  public Image<T> Source { get; private set; }
  public ImageVideoFrameWrapper(Image<T> source) {
    Source = source ?? throw new ArgumentNullException(nameof(source));
  }
  public void Serialize(Stream stream) {
    byte[] pixelBytes = new byte[Source.Width * Source.Height * Unsafe.SizeOf<Rgba32>()];
    Source.CopyPixelDataTo(pixelBytes);
    stream.Write(pixelBytes, 0, pixelBytes.Length);
  }
  public async Task SerializeAsync(Stream stream, CancellationToken token) {
    var pixelBytes = new byte[Source.Width * Source.Height * Unsafe.SizeOf<Rgba32>()];
    Source.CopyPixelDataTo(pixelBytes);
    await stream.WriteAsync(pixelBytes, 0, pixelBytes.Length);
  }
  public void Dispose() {
    Source.Dispose();
  }
}

这将创建一个带有移动和弦的透明视频文件chords.webm。最后一步是将此视频合成到原始伴奏视频上,这是ffmpeg的另一个任务:

ffmpeg
 # 输入文件 #0:
 -i original.mp4
 # 输入文件 #1 的视频编解码器: libvpx-vp9
 -c:v libvpx-vp9 
 # 输入文件 #1
 -i chords.webm
 # 视频过滤器: 将视频 #1 缩放到 1280x720, 存储为 [z], 然后将 [z] 叠加到视频 #0 上
 -filter_complex "[1:v]scale=1280:720[z];[0:v][z]overlay" 
 # 输出视频编解码器: libx264
 -c:v libx264 
 # 输出视频比特率: 2500kbps
 -b:v 2500k 
 # 输出文件名
 composite.mp4

这将生成composite.mp4,这是原始伴奏视频与透明和弦叠加层合成在一起的视频。这也是一个我没有权限在线发布的4分钟视频,带有5.1环绕声混音,除非你使用正确的音频设备,否则听起来会很奇怪,所以为了所有在家跟随的朋友们,我再次通过ffmpeg处理它:

ffmpeg 
 # 输入文件: 
 -i .\composite.mp4 
 # 音频通道: 2
 -ac 2 
 # 音频过滤器: 将5.1渲染为2.0音频。
 # FL (前左) = 0.7 * FC (前中), + 0.70 * FL (前左) + 1.0 * BL (后左)
 # FR (前右) = 0.7 * FC (前中), + 0.70 * FR (前右) + 1.0 * BR (后右)
 -af "pan=stereo|FL=0.7*FC+0.70*FL+1.0*BL|FR=0.7*FC+0.70*FR+1.0*BR" 
 # 跳过开始时间到45秒 
 -ss 00:00:45 
 # 修剪视频长度为30秒
 -t 00:00:30 
 # 使用视频编解码器: libx264
 -c:v libx264 
 # 设置视频比特率为2500kbps
 -b:v 2500k 
 # 使用音频编解码器: aac
 -c:a aac 
 # 输出文件名
 guitaraoke-demo.mp4

这将生成一个30秒的剪辑,使用常规立体声,我已将其上传到YouTube,以便你可以看到结果:
这是一个相当有说服力的概念验证……但做一次,用一个剪辑,和能够批量生成50多首曲目以实际运行卡拉OK之夜之间有着天壤之别。所以下一步是添加节拍检测,这样我就可以量化和弦变化的时间,使其准确地落在节拍上。这不是必需的,但很好。
我还需要自动化各个步骤——视频 > WAV > 和弦 > 叠加 > 合成——这样我就可以在一堆曲目上运行这个东西,并一次性生成几十个视频。我怀疑有些曲目在渲染视频之前需要手动编辑和弦数据,所以我还需要找到一种方法来运行高速版本,可能是12fps,640x360,以检查结果,然后运行高质量的1280x720 60fps版本以生成最终视频。
如果你想亲眼看看它的效果,欢迎在每月的第三个星期六来Ignition Brewery and Taproom看看。如果你真的上台演奏点什么,那就更棒了。🎸🤘🍻

链接

你好,我是Dylan。

我用计算机、代码、喜剧、音乐和视频做有趣的事情,然后我环游世界,向人们讲述这些。我通过我的公司Ursatile提供软件培训和咨询。我是一名主题演讲者,我是微软MVP,我创造了Rockstar,一种最初是个玩笑但最终登上《Classic Rock》杂志的深奥编程语言,我还拥有互联网历史上最好的网址

阅读全文(20积分)