DryWetMIDI: 音符量化





5.00/5 (5投票s)
用于量化 MIDI 文件音符的控制台应用程序
简介
本文旨在演示如何使用 DryWetMIDI(一个用于管理 MIDI 文件的开源库)来量化 MIDI 文件的音符。我们将编写一个控制台应用程序来执行此任务。
在 上一篇文章 中,我们提供了一个简单的量化示例。但是,我们现在要编写的应用程序将更加高级,例如,允许指定吸附点附近的区域以随机化音符的开始或结束时间;或者指定一组网格步长以执行凹凸量化(groove quantization)。
目录
用法
首先,我们应该描述程序的用法,即程序名称、其参数以及它们的有效组合。每当用户以错误的方式调用程序时,我们都会显示以下帮助信息
USAGE:
quantize <input_path> <grid> [-o <output_path>]
[-s | -e]
[-ke | -ks]
[-t <tolerance>]
Parameters:
<input_path> Input MIDI file path.
<grid> Steps of grid to quantize by.
-o <output_path> Output file path.
-s Quantize starts of notes.
-e Quantize ends of notes.
-ke Keep ends of notes.
-ks Keep starts of notes.
-t <tolerance> Tolerance to randomize note's start or end.
其中 <...>
表示必需参数,[...]
表示可选参数,|
表示选择(一次只能使用由 |
分隔的参数之一)。
所以程序将被命名为 **quantize**。让我们讨论它的参数。
<input_path>
要量化音符的 MIDI 文件的路径。
<grid>
用于量化音符的网格步长序列。每个步长之间应以逗号分隔。步长将被解析为实现 ITimeSpan
接口的类的实例。让我们看看可以解析为这些实例的有效 string
。
MetricTimeSpan
以微秒为单位表示一个步长。可以解析为 MetricTimeSpan
实例的 String
必须遵循以下格式
[小时 :] 分钟 : 秒 [: 毫秒]
有效 string
的示例
"0:0:0:500" // 500 milliseconds
"0:4" // 4 seconds
"8:20:30" // 8 hours 20 minutes 30 seconds
每个组件都必须是一个非负的 long
数字。
MusicalTimeSpan
以全音符的比例表示一个步长。可以解析为 MusicalTimeSpan
实例的 String
必须遵循以下格式
<分数 | 分数助记符> [连音符 | 连音符助记符] [.+]
其中
分数 应为 分子 / 分母 的形式,其中 分子 如果等于 1,则可以省略。
分数助记符 可以是以下字符串之一:w (whole,全音符), h (half,二分音符), q (quarter,四分音符), e (eighth,八分音符) 或 s (sixteenth,十六分音符)。
连音符 应为 [音符数量 : 占用空间数量] 的形式,它定义了一个连音符,其中 音符数量 的音符占据 占用空间数量 音符的空间。例如,[3:2] 表示三连音(triplet)。
连音符助记符 可以是以下字符串之一:t (triplet,三连音) 或 d (duplet,二连音)。
.+ 表示一个或多个点。
有效 string
的示例
"5/8" // 5/8
"q" // quarter
"e.." // double dotted eighth
"/4t" // triplet 1/4
"wd." // single dotted duplet whole
"1/9[3:5]" // tuplet 1/9 where tuplet is "3 notes in space of 5"
BarBeatTicksTimeSpan
以小节、拍和 tick 的数量表示一个步长。可以解析为 BarBeatTicksTimeSpan
实例的 String
必须遵循以下格式
小节.拍.Tick
有效 string
的示例
"0.1.8" // 1 beat and 8 ticks
"10.0.0" // 10 bars
"0.0.5" // 5 ticks
BarBeatFractionTimeSpan
以小节和分数拍的数量表示一个步长。可以解析为 BarBeatFractionTimeSpan
实例的 String
必须遵循以下格式
小节_拍整数部分.小节分数部分
有效 string
的示例
"0_0.0" // zero time span
"1_0.0" // 1 bar
"0_10.5" // 10.5 beats
"100_20.2" // 100 bars and 20.2 beats
MidiTimeSpan
以 tick(如果是 MIDI 文件的 每四分音符 tick 时间分割)或帧的细分(如果是 SMPTE 时间分割)表示一个步长。可以解析为 MidiTimeSpan
实例的 String
必须遵循以下格式
TimeSpan
有效 string
的示例
"300" // 300 ticks
MidiTimeSpan
在库中是为了统一目的而存在的,对音乐家来说几乎没有用处。
-o <output_path>
输出 MIDI 文件的路径。如果未指定此参数,则输入文件将被覆盖。
-s
此选项指示程序量化音符的开始时间。开始时间是量化例程的默认目标。如果未指定 -ke
选项,音符的长度将不会改变。
-e
此选项指示程序量化音符的结束时间。如果未指定 -ks
选项,音符的长度将不会改变。
-ke
当量化开始时间时,此选项指示程序保持音符的结束时间不变。在这种情况下,音符的长度可能会改变。
-ks
当量化结束时间时,此选项指示程序保持音符的开始时间不变。在这种情况下,音符的长度可能会改变。
-t <tolerance>
指定要在此范围内随机化音符开始或结束时间的容差。换句话说,它是最近的网格点周围的区域大小,音符的时间应该放置在该区域内。<tolerance>
是任何可以解析为实现 ITimeSpan
的类实例的有效 string
(有关可用于容差的 string
,请参见上面 <grid>
参数的说明)。
示例
让我们看看我们的程序可以做什么的一些例子。我们将使用具有以下音符的输入 MIDI 文件
此文件名为 input.mid,位于随文章一起提供的 files.zip 归档文件中,以及包含源代码的 sources.zip 归档文件。如您所见,它只是一个包含随机音符的小节。现在我们将执行 quantize 程序并使用不同的参数,然后查看结果。
我们将从一个简单的案例开始——使用八分音符步长的网格量化音符开始时间(由于音符的开始时间是量化过程的默认目标,可以省略 -s)
quantize input.mid 1/8 -s
底部的条形图显示了用于量化音符的网格。灰色矩形显示原始音符,因此我们可以直观地检查处理的正确性。
现在我们将尝试执行变步长网格量化(也称为 凹凸量化)。q,e,e 定义了一个网格,其步长为 1/4、1/8、1/8、1/4、1/8、1/8、...,因此模式是 **[1/4, 1/8, 1/8]**
quantize input.mid q,e,e -s
让我们增加凹凸感!**[1/16, 1/8, 1/16, 1/4, 三连音 1/8, 三连音 1/8, 三连音 1/8, 附点八分音符 1/8, 1/16]** 模式将有助于我们实现这一点。
quantize input.mid s,e,s,q,et,et,et,e.,s
默认情况下,量化音符的开始时间会保持音符长度不变,因此整个音符会被移动到另一个时间。我们可以使用 -ke 参数来保持音符的结束时间不变,因此在量化过程中音符的长度可能会改变。
quantize input.mid q,e,e -s -ke
如果我们想量化音符的结束时间,应该使用 -e 参数。
quantize input.mid q.,e -e
与量化音符的开始时间一样,我们可以保持音符的另一端不变。量化结束时间时,应使用 -ks 参数来保持开始时间不变。
quantize input.mid q.,e -e -ks
此外,可以使用 -t 参数指定容差。容差是网格点周围的区域大小,在此区域内,目标(开始或结束时间)可以在量化过程中随机放置。这是“人性化” MIDI 音乐的方法之一。下面的示例显示了使用三十二分音符长度的容差将开始时间量化到四分音符长度的网格。
quantize input.mid q -t 1/32
实现
现在是时候看看程序的代码了。核心类是 Quantizer
,它有一个 public
方法 – Quantize
。
internal static class Quantizer
{
public static void Quantize(QuantizerArguments arguments)
{
var midiFile = MidiFile.Read(arguments.InputPath);
switch (arguments.Target)
{
case QuantizationTarget.Start:
QuantizeStart(midiFile, arguments.Grid,
arguments.Tolerance, arguments.KeepEnd);
break;
case QuantizationTarget.End:
QuantizeEnd(midiFile, arguments.Grid,
arguments.Tolerance, arguments.KeepStart);
break;
}
midiFile.Write(arguments.OutputPath, true);
}
}
QuantizerArguments
仅保存程序的参数。
internal sealed class QuantizerArguments
{
public string InputPath { get; }
public string OutputPath { get; }
public IEnumerable<ITimeSpan> Grid { get; }
public QuantizationTarget Target { get; }
public bool KeepEnd { get; }
public bool KeepStart { get; }
public ITimeSpan Tolerance { get; }
}
我们不讨论 QuantizerArguments
实例的创建方式,因为它不是本文的主题。如果您有兴趣,可以查看随文章一起提供的源代码。
现在我们将讨论 Quantizer
类中 QuantizeStart
和 QuantizeEnd
方法的实现。
private static void QuantizeStart(MidiFile midiFile,
IEnumerable<ITimeSpan> gridSteps,
ITimeSpan tolerance,
bool keepEnd)
{
if (midiFile == null)
throw new ArgumentNullException(nameof(midiFile));
if (gridSteps == null)
throw new ArgumentNullException(nameof(gridSteps));
// Tempo map should be obtained in order to perform time/length conversions
var tempoMap = midiFile.GetTempoMap();
// Build the grid to quantize notes to
var grid = BuildGrid(midiFile, gridSteps, tempoMap).ToList();
if (!grid.Any())
return;
var random = new Random();
// Perform quantization
midiFile.ProcessNotes(n =>
{
var startTime = n.Time;
var endTime = startTime + n.Length;
// Find nearest grid point to snap the note to
var newStartTime = FindNearestTime(grid, startTime);
// Adjust the note's time according to the specified tolerance
newStartTime = RandomizeTime(newStartTime, tolerance, random, tempoMap);
// If end time should be untouched, correct length of the note
if (keepEnd)
n.Length = Math.Max(0, endTime - newStartTime);
// Set new note's time
n.Time = newStartTime;
});
}
来自 Melanchall.DryWetMidi.Interaction.NotesManagingUtilities
的 ProcessNotes
扩展方法允许以简单的方式修改 MidiFile
中的音符。音符开始时间量化的算法是:
- 创建一个用于量化的网格
- 找到最接近音符开始时间的网格点
- 在找到的网格点的指定容差范围内获取随机时间
- 如果应保持音符结束时间不变(指定了
-ke
选项),则计算音符的新长度 - 设置在步骤 3 中计算的新音符时间
BuildGrid
创建一个用于量化的网格。结果网格是表示为 long
值的时间的集合,这是 MIDI 文件中时间和长度的内部表示。我们遍历网格指定的步长,并调用 TimeConverter.ConvertFrom
将 ITimeSpan
转换为 long
值。请注意,我们需要 TempoMap
对象来进行这种转换,因为 MIDI 文件可能包含速度和拍号的变化,因此需要将它们考虑在内,例如将秒转换为 tick(long
值)。下面显示了该方法的代码。
private static IEnumerable<long> BuildGrid(MidiFile midiFile,
IEnumerable<ITimeSpan> gridSteps,
TempoMap tempoMap)
{
var lastNote = midiFile.GetNotes().LastOrDefault();
if (lastNote == null)
yield break;
var time = 0L;
var lastNoteTime = lastNote.Time;
while (true)
{
foreach (var step in gridSteps)
{
if (time > lastNoteTime)
yield break;
time = TimeConverter.ConvertFrom(((MidiTimeSpan)time).Add
(step, TimeSpanMode.TimeLength),
tempoMap);
yield return time;
}
}
}
由于 Note
类的 Time
属性已经是 long
类型,因此搜索最接近音符开始时间的网格时间非常简单。我们只需遍历网格并取一个点,其中网格时间与音符时间之间的差值最小。
private static long FindNearestTime(IEnumerable<long> grid, long time)
{
var difference = long.MaxValue;
var nearestTime = 0L;
foreach (var gridTime in grid)
{
var timeDelta = Math.Abs(time - gridTime);
if (timeDelta >= difference)
break;
difference = timeDelta;
nearestTime = gridTime;
}
return nearestTime;
}
为了在指定的容差范围内随机化时间,我们需要计算定义允许区域边界的最小和最大时间。然后我们只需在该时间之间取一个随机数。
private static long RandomizeTime
(long time, ITimeSpan tolerance, Random random, TempoMap tempoMap)
{
if (tolerance == null)
return time;
// Calculate maximum time of the area to randomize time within
var minTime = CalculateBoundaryTime
(time, tolerance, MathOperation.Subtract, tempoMap);
// Calculate minimum time of the area to randomize time within
var maxTime = CalculateBoundaryTime(time, tolerance, MathOperation.Add, tempoMap);
return GetRandomTime(minTime - 1, maxTime, random) + 1;
}
private static long CalculateBoundaryTime(long time,
ITimeSpan tolerance,
MathOperation operation,
TempoMap tempoMap)
{
ITimeSpan boundaryTime = (MidiTimeSpan)time;
switch (operation)
{
// Upper boundary (maximum time of the area to randomize time within)
case MathOperation.Add:
boundaryTime = boundaryTime.Add(tolerance, TimeSpanMode.TimeLength);
break;
// Lower boundary (minimum time of the area to randomize time within)
case MathOperation.Subtract:
boundaryTime = boundaryTime.Subtract(tolerance, TimeSpanMode.TimeLength);
break;
}
// Return calculated time converted to long, or 0 if the time is negative
return Math.Max(0, TimeConverter.ConvertFrom(boundaryTime, tempoMap));
}
private static long GetRandomTime(long minTime, long maxTime, Random random)
{
var difference = (int)Math.Abs(maxTime - minTime);
return minTime + random.Next(difference);
}
QuantizeEnd
利用了相同的方法。但显然它修改的是音符的结束时间而不是开始时间。该方法的实现没有任何神秘之处。
private static void QuantizeEnd(MidiFile midiFile,
IEnumerable<ITimeSpan> gridSteps,
ITimeSpan tolerance,
bool keepStart)
{
if (midiFile == null)
throw new ArgumentNullException(nameof(midiFile));
if (gridSteps == null)
throw new ArgumentNullException(nameof(gridSteps));
// Tempo map should be obtained in order to perform time/length conversions
var tempoMap = midiFile.GetTempoMap();
// Build the grid to quantize notes to
var grid = BuildGrid(midiFile, gridSteps, tempoMap).ToList();
if (!grid.Any())
return;
var random = new Random();
// Perform quantization
midiFile.ProcessNotes(n =>
{
var startTime = n.Time;
var endTime = startTime + n.Length;
// Find nearest grid point to snap the note to
var newEndTime = FindNearestTime(grid, endTime);
// Adjust the note's end time according to the specified tolerance
newEndTime = RandomizeTime(newEndTime, tolerance, random, tempoMap);
// If start time should be untouched, correct length of the note
if (keepStart)
n.Length = Math.Max(0, newEndTime - startTime);
// Set new note's time
n.Time = newEndTime - n.Length;
});
}
就这样!我们的量化器现在可以处理文件了,这使得可以构建自动化脚本,这些脚本可以例如为进一步使用另一个脚本或 DAW 进行处理准备 MIDI 文件。
最后可能感兴趣的是,我们如何从作为参数传递给程序的 string
表示形式中获取 IEnumerable<ITimeSpan>
类型的网格。这非常简单。
IEnumerable<ITimeSpan> grid = gridAsString.Split(new char[] { ',' },
StringSplitOptions.RemoveEmptyEntries)
.Select(TimeSpanUtilities.Parse);
TimeSpanUtilities.Parse
方法接受一个 string
并返回正确的 ITimeSpan
实现,如果 string
格式无效,则会抛出错误。
Links
- NuGet 包:https://nuget.net.cn/packages/Melanchall.DryWetMidi
- GitHub 存储库:https://github.com/melanchall/drywetmidi
- 文档:https://melanchall.github.io/drywetmidi
历史
- 2021 年 10 月 20 日
- 修复了用于解析
BarBeatFractionTimeSpan
的格式字符串
- 修复了用于解析
- 2019 年 11 月 23 日
- 文章已更新,以反映
DryWetMIDI
5.0.0 中引入的更改
- 文章已更新,以反映
- 2018 年 3 月 26 日
- 文章提交