DryWetMIDI:MIDI 文件的高级处理






4.92/5 (21投票s)
概述如何使用 DryWetMIDI 库对 MIDI 文件数据进行高级管理
简介
DryWetMIDI 是一个用于处理标准 MIDI 文件 (SMF) 的 .NET 库——读取、写入、创建和修改它们,还可以处理 MIDI 设备(这在 DryWetMIDI:处理 MIDI 设备 文章中有介绍)。在本文中,我们将讨论 MIDI 文件的处理。
尽管有许多 .NET 库提供 MIDI 文件解析功能,但 DryWetMIDI
具有一些使其与众不同的特色
- 能够读取包含某些损坏的文件,例如遗漏了 Track End 事件
- 能够精细调整读取和写入过程,例如,允许指定文本格式元事件(如 Lyric)中存储文本的 编码
- 一套 **高级类**,允许以更易于理解的方式管理 MIDI 文件内容,例如
Note
或MusicalTimeSpan
最后一点是该库最重要的部分,因为许多用户询问关于音符管理或将 MIDI 时间和长度转换为更易于人类理解的表示形式(如秒)的问题。为了解决这些问题,他们不得不一遍又一遍地编写相同的代码。DryWetMIDI
通过提供内置工具来执行上述任务,使他们不必重新发明轮子。
本文快速概述了 DryWetMIDI
提供的高级数据管理功能。它不是 API 参考。该库还具有与 MIDI 文件内容交互的低级层,但这并非本文的主题。
文章末尾有 示例,展示了如何使用 DryWetMIDI
提供的功能解决实际任务。所以,如果您想对该库有一个整体印象,可以立即跳转到它们。
背景
建议熟悉 SMF 格式,但如果您只打算使用 DryWetMIDI
的高级 API,则不一定需要。
目录
绝对时间
MIDI 文件中的所有事件都有一个与之关联的 delta-time。Delta-time 是与前一个事件的偏移量。此偏移量的单位由文件的 time division 定义。根据 SMF 规范,有两种可能的时间分割方式
- 每四分音符的 tick 数 定义了四分音符持续的时间(99.999% 的 MIDI 文件都使用此时间分割方式,因此我们可以假定所有文件都有此设置)
- SMPTE 时间分割方式将时间定义为 SMPTE 帧的细分数以及指定的帧率
在实践中,通常比使用相对时间更方便地操作绝对时间。以下代码显示了如何使用 DryWetMIDI
通过事件的绝对时间进行管理
using Melanchall.DryWetMidi.Core;
using Melanchall.DryWetMidi.Interaction;
// Read a MIDI file
var midiFile = MidiFile.Read("Cool song.mid");
// Manage timed events of the first track chunk of the file
using (var objectsManager = midiFile.Chunks
.OfType<TrackChunk>()
.First()
.ManageTimedEvents())
{
// Get timed events ordered by time
var events = objectsManager.Objects;
// Set absolute time of the first Lyric event
var firstLyricEvent = events.FirstOrDefault(e => e.Event is LyricEvent);
if (firstLyricEvent != null)
firstLyricEvent.Time = 2000;
// Add new Pitch Bend event with absolute time = 3000
events.Add(new TimedEvent(new PitchBendEvent(8000), 3000));
}
退出 using
代码块后,管理音轨块中的所有事件将被 events
集合中的事件替换,并更新所有 delta-times。此外,您可以调用对象管理器中的 SaveChanges
方法来保存所有更改。如果您在多个方法之间使用管理器,则此方法尤其有用。
private TimedObjectsManager<TimedEvent> _timedEventsManager;
private void BeginManageEvents(TrackChunk trackChunk)
{
_timedEventsManager = trackChunk.ManageTimedEvents();
}
private void ShiftEvents(long shift)
{
foreach (TimedEvent timedEvent in _timedEventsManager.Objects)
{
timedEvent.Time += shift;
}
}
private void EndManageEvents()
{
_timedEventsManager.SaveChanges(); // or you can call Dispose
}
以下所有管理器都具有相同的保存逻辑。请阅读库文档的 对象管理器 文章。
此外,TimedEventsManagingUtilities
类包含一些有用的扩展方法。例如,您可以轻松地从文件中删除时间为 400 的所有 System Exclusive 事件
midiFile.RemoveTimedEvents(e => e.Event is SysExEvent && e.Time == 400);
或者您可以将所有事件的时间除以 2 来缩小 MIDI 文件
midiFile.ProcessTimedEvents(e => e.Time /= 2);
其他管理器也具有与定时事件类似的实用方法。值得一看的是存放这些扩展方法的类。此外,DryWetMIDI
提供的所有管理器都可以通过构造函数获得,而不是通过低级实体的实用方法。
音符
MIDI 文件使用 Note On 和 Note Off 事件对来表示音符。但人们通常希望在不干扰低级 MIDI 事件的情况下处理音符。
using (var objectsManager = midiFile.GetTrackChunks() // shortcut method for
// Chunks.OfType<TrackChunk>()
.First()
.ManageNotes())
{
// Get notes ordered by time
var notes = objectsManager.Objects;
// Get all C# notes
var cSharpNotes = notes.Where(n => n.NoteName == NoteName.CSharp);
// Reduce length of all C# notes by 100
foreach (var note in cSharpNotes)
{
note.Length -= 100;
}
// Add new note: C# of octave with number of 2
// Note: DryWetMIDI uses scientific pitch notation which means middle C is C4
notes.Add(new Note(NoteName.CSharp, 2)
{
Channel = (FourBitNumber)2,
Velocity = (SevenBitNumber)95
});
}
与定时事件一样,NotesManagingUtilities
类包含用于音符管理的实用工具。其中一个最有用的是 GetNotes
方法,该方法允许获取音轨块或整个 MIDI 文件中包含的所有音符。
IEnumerable<Note> notes = midiFile.GetNotes();
请注意,如果您对返回的音符进行任何更改,它们将不会被应用。所有音符操作都必须通过对象管理器进行,或者您可以使用 NotesManagingUtilities
中的 ProcessNotes
方法。例如,要将所有 F 音符向上移一个八度,您可以使用以下代码
f.ProcessNotes(n => n.NoteNumber += (SevenBitNumber)12,
n => n.NoteName == NoteName.F);
或者,您可以迭代 TimedEvent
集合并获取 ITimedObject
集合,其中元素是 Note
或 TimedEvent
。对于代表 Note On/Note Off 事件的每个 TimedEvent
对,都将返回 Note
。请参阅 Get
和弦
Chord
只是一个音符组。要处理和弦
using (var objectsManager = midiFile.GetTrackChunks()
.First()
.ManageChords())
{
// Get chords ordered by time
var chords = objectsManager.Objects;
// Get all chords that have C# note
var cSharpChords = chords.Where(c => c.Notes
.Any(n => n.NoteName == NoteName.CSharp));
}
与音符管理一样,也有用于和弦操作的实用方法。毫不奇怪,包含这些方法的类名为 ChordsManagingUtilities
。
GetObjects
有一个类提供了同时获取不同类型对象的方法——Get
请参阅以下库文档文章以了解更多信息
速度图
速度图是 MIDI 文件中所有速度和拍号更改的列表。使用 TempoMapManager
,您可以设置这些参数的新值(在指定时间)并获取当前的速度图。
using (TempoMapManager tempoMapManager = midiFile.ManageTempoMap())
{
// Get current tempo map
TempoMap tempoMap = tempoMapManager.TempoMap;
// Get time signature at 2000
TimeSignature timeSignature = tempoMap.TimeSignatureLine.AtTime(2000);
// Set new tempo (230,000 microseconds per quarter note) at the time of
// 20 seconds from the start of the file. See "Time representations"
// section below to learn about time classes
tempoMapManager.SetTempo(new MetricTimeSpan(0, 0, 20),
new Tempo(230000));
}
TempoMap
还包含一个 TimeDivision
实例,以便用于时间和长度转换。要获取 MIDI 文件的速度图,只需调用 TempoMapManagingUtilities
中的 GetTempoMap
扩展方法。
TempoMap tempoMap = midiFile.GetTempoMap();
此外,您还可以使用 ReplaceTempoMap
方法轻松地将 MIDI 文件的速度图替换为另一个。例如,要将文件的速度更改为 50 BPM,将拍号更改为 5/8,您可以编写以下代码
midiFile.ReplaceTempoMap(TempoMap.Create(Tempo.FromBeatsPerMinute(50),
new TimeSignature(5, 8)));
速度图是一个非常重要的对象,因为它允许执行时间和长度转换,正如您将在接下来的部分中看到的。
时间和长度表示
正如您可能注意到的,上面代码示例中的所有时间都以某些 long
值表示,其单位由文件的 time division 定义。在实践中,使用“易于理解”的表示形式(如秒或拍/节拍)要方便得多。事实上,时间与长度没有区别,因为 MIDI 文件中的时间只是一个始终从零开始的长度。因此,我们将使用 *time span*(时间跨度)一词来描述时间和长度。DryWetMIDI
提供以下类来表示时间跨度
MetricTimeSpan
用于以微秒为单位的时间跨度BarBeatTicksTimeSpan
用于以小节、拍和 tick 数为单位的时间跨度BarBeatFractionTimeSpan
用于以小节数和分数拍为单位的时间跨度MusicalTimeSpan
用于以全音符长度的比例表示的时间跨度MidiTimeSpan
用于统一目的,简单地存储以文件 time division 定义的单位表示的long
值。
所有时间跨度类都实现了 ITimeSpan
接口。要在不同表示形式之间转换时间跨度,应使用 TimeConverter
或 LengthConverter
类。
// Tempo map is needed in order to perform time span conversions
TempoMap tempoMap = midiFile.GetTempoMap();
// ====================================================================================
// Time conversion
// ------------------------------------------------------------------------------------
// You can use LengthConverter as well but with the TimeConverter
// you don't need to specify time
// where time span starts since it is always zero.
// ====================================================================================
// Some time in MIDI ticks (we assume time division of a MIDI file is
// "ticks per quarter note")
long ticks = 123;
// Convert ticks to metric time
MetricTimeSpan metricTime = TimeConverter.ConvertTo<MetricTimeSpan>(ticks, tempoMap);
// Convert ticks to musical time
MusicalTimeSpan musicalTimeFromTicks = TimeConverter.ConvertTo<MusicalTimeSpan>
(ticks, tempoMap);
// Convert metric time to musical time
MusicalTimeSpan musicalTimeFromMetric = TimeConverter.ConvertTo<MusicalTimeSpan>
(metricTime, tempoMap);
// Convert metric time to bar/beat time
BarBeatTicksTimeSpan barBeatTicksTimeFromMetric =
TimeConverter.ConvertTo<BarBeatTicksTimeSpan>(metricTime, tempoMap);
// Convert musical time back to ticks
long ticksFromMusical = TimeConverter.ConvertFrom(musicalTimeFromTicks, tempoMap);
// ======================================================================================
// Length conversion
// --------------------------------------------------------------------------------------
// Length conversion is the same as time conversion but you need to specify the time where
// a time span starts.
// ======================================================================================
// Convert ticks to metric length
MetricTimeSpan metricLength = LengthConverter.ConvertTo<MetricTimeSpan>
(ticks, time, tempoMap);
// Convert metric length to musical length using metric time
MusicalTimeSpan musicalLengthFromMetric =
LengthConverter.ConvertTo<MusicalTimeSpan>(metricLength,
metricTime,
tempoMap);
// Convert musical length back to ticks
long ticksFromMetricLength = LengthConverter.ConvertFrom(metricLength, time, tempoMap);
您可能注意到 LengthConverter
的方法接受一个时间。在一般情况下,MIDI 文件会有速度和拍号的变化。因此,相同的 long
值可能代表不同秒数,例如,取决于具有此长度的对象的时间。上述方法可以接受 long
或 ITimeSpan
作为时间。
TimedObjectUtilities
类中有一些有用的方法。该类包含实现 ITimedObject
接口的类型的扩展方法——TimedEvent
、Note
和 Chord
。例如,您可以使用 TimeAs
方法以小时、分钟、秒为单位获取定时事件的时间。
var metricTime = timedEvent.TimeAs<MetricTimeSpan>(tempoMap);
或者,您可以找到 MIDI 文件中所有开始于第 10 小节、第 4 拍的时间的音符。
TempoMap tempoMap = midiFile.GetTempoMap();
IEnumerable<Note> notes = midiFile.GetNotes()
.AtTime(new BarBeatTicksTimeSpan(10, 4), tempoMap);
此外,还有一个 LengthedObjectUtilities
类。该类包含实现 ILengthedObject
接口的类型的扩展方法——Note
和 Chord
。例如,您可以使用 LengthAs
方法获取音符的长度(以全音符的比例表示)。
var musicalLength = note.LengthAs<MusicalTimeSpan>(tempoMap);
或者,您可以获取 MIDI 文件中所有在文件开始后 30 秒精确结束的音符。
var tempoMap = midiFile.GetTempoMap();
var notesAt30sec = midiFile.GetNotes()
.EndAtTime(new MetricTimeSpan(0, 0, 30), tempoMap);
一些如何创建特定时间跨度类实例的示例
// 100,000 microseconds
var metricTimeSpan1 = new MetricTimeSpan(100000 /* microseconds */);
// 0 hours, 1 minute and 55 seconds
var metricTimeSpan2 = new MetricTimeSpan(0, 1, 55);
// Zero time
var metricTimeSpan3 = new MetricTimeSpan();
// 2 bars and 7 beats
var barBeatTicksTimeSpan = new BarBeatTicksTimeSpan(2, 7);
// Triplet eight
var musicalTimeSpan1 = MusicalTimeSpan.Eighth.Triplet();
// Five 5/17
var musicalTimeSpan2 = 5 * new MusicalTimeSpan(5, 17);
如果您想知道 MIDI 文件的长度(例如,以分钟和秒为单位),您可以使用以下代码。
var tempoMap = midiFile.GetTempoMap();
var midiFileDuration = midiFile.GetTimedEvents()
.LastOrDefault(e => e.Event is NoteOffEvent)
?.TimeAs<MetricTimeSpan>(tempoMap)
?? new MetricTimeSpan();
ITimeSpan
接口有几个方法可以对时间跨度执行算术运算。例如,要将度量长度添加到度量时间,您可以编写
var timeSpan1 = new MetricTimeSpan(0, 2, 20);
var timeSpan2 = new MetricTimeSpan(0, 0, 10);
ITimeSpan result = timeSpan1.Add(timeSpan2, TimeSpanMode.TimeLength);
您需要指定操作的 *模式*。在上面的示例中,使用了 TimeLength
,这意味着第一个时间跨度表示时间,第二个表示长度。当操作数类型不同时,此信息对于转换引擎是必需的。还有 TimeTime
和 LengthLength
模式。
您还可以从一个时间跨度中减去另一个时间跨度。
var timeSpan1 = new MetricTimeSpan(0, 10, 0);
var timeSpan2 = new MusicalTimeSpan(3, 8);
ITimeSpan result = timeSpan1.Subtract(timeSpan2, TimeSpanMode.TimeTime);
如果操作数类型相同,则结果时间跨度也将是该类型。但是,如果您对不同类型的时间跨度进行加法或减法运算,则结果时间跨度的类型将是 MathTimeSpan
,它包含操作数以及操作(加法或减法)和模式。
要拉伸或收缩时间跨度,请使用 Multiply
或 Divide
方法。
ITimeSpan stretchedTimeSpan = new MetricTimeSpan(0, 0, 10).Multiply(2.5);
ITimeSpan shrinkedTimeSpan = new BarBeatTicksTimeSpan(0, 2).Divide(2);
TimeAs
和 LengthAs
方法都有非泛型版本,其中所需结果类型应作为 TimeSpanType
类型的参数传递。TimeConverter
和 LengthConverter
类中的泛型方法也有非泛型版本,它们也接受 TimeSpanType
。
另请参阅 时间与长度 - 概述 文章。
乐句
为了方便创建 MIDI 文件,让您可以专注于音乐, there is the PatternBuilder
class. 该类提供了一个流畅的接口来构建可以导出到 MIDI 文件的音乐作品。以下是一个快速示例,展示了您可以使用生成器执行的操作
var patternBuilder = new PatternBuilder()
// Insert a pause of 5 seconds
.StepForward(new MetricTimeSpan(0, 0, 5))
// Insert an eighth C# note of the 4th octave
.Note(Octave.Get(4).CSharp, MusicalTimeSpan.Eighth)
// Set default note length to triplet eighth and default octave to 5
.SetNoteLength(MusicalTimeSpan.Eighth.Triplet())
.SetOctave(Octave.Get(5))
// Now we can add triplet eighth notes of the 5th octave in a simple way
.Note(NoteName.A)
.Note(NoteName.B)
.Note(NoteName.GSharp);
Build
方法将返回一个 Pattern
类的实例,其中包含在生成器上执行的所有操作。然后可以将 Pattern
导出到 MIDI 文件。
Pattern pattern = patternBuilder.Build();
// Export the pattern to a MIDI file using default tempo map (4/4, 120 BPM)
MidiFile midiFile = pattern.ToFile(TempoMap.Default);
这只是 PatternBuilder
功能的一小部分。它还有更多功能,包括指定音符力度、插入和弦、设置时间锚点、移动到特定时间以及重复之前的操作。因此,Pattern
是一种与 MIDI 绑定的音乐编程。请参阅文章末尾的 示例,其中展示了如何构建贝多芬的“月光奏鸣曲”的前四个小节。
工具
DryWetMIDI
中有几个类可以帮助解决复杂的任务,例如音符量化。让我们简要概述一下旨在完成这些任务的工具。
您可以在库文档中找到所有这些工具的 详细描述。
示例
让我们看看如何使用 DryWetMIDI
来处理一些实际任务。
音符合并
有时,在实际的 MIDI 文件中,音符可能会相互重叠。尽管 DryWetMIDI
已经为此目的提供了工具——Merger——但我们将尝试实现一个简单的版本。以下方法合并同一通道和音高的重叠音符。
public static void MergeNotes(MidiFile midiFile)
{
foreach (var trackChunk in midiFile.GetTrackChunks())
{
MergeNotes(trackChunk);
}
}
private static void MergeNotes(TrackChunk trackChunk)
{
using (var notesManager = trackChunk.ManageNotes())
{
var notes = notesManager.Objects;
// Create dictionary for storing currently merging notes of each channel (key)
// and each pitch (key of dictionary used as value for channel)
var currentNotes = new Dictionary<FourBitNumber,
Dictionary<SevenBitNumber, Note>>();
foreach (var note in notes.ToList())
{
var channel = note.Channel;
// Get currently merging notes of the channel
if (!currentNotes.TryGetValue(channel, out var currentNotesByNoteNumber))
currentNotes.Add(channel, currentNotesByNoteNumber =
new Dictionary<SevenBitNumber, Note>());
// Get the currently merging note
if (!currentNotesByNoteNumber.TryGetValue
(note.NoteNumber, out var currentNote))
{
currentNotesByNoteNumber.Add(note.NoteNumber, currentNote = note);
continue;
}
var currentEndTime = currentNote.Time + currentNote.Length;
// If time of the note is less than end of currently merging one,
// we should update length of currently merging note and delete the
// note from the notes collection
if (note.Time <= currentEndTime)
{
var endTime = Math.Max(note.Time + note.Length, currentEndTime);
currentNote.Length = endTime - currentNote.Time;
notes.Remove(note);
}
// If the note doesn't overlap currently merging one, the note become
// a currently merging note
else
currentNotesByNoteNumber[note.NoteNumber] = note;
}
}
}
如果我们采用如下的输入文件
并使用上述方法进行合并,我们将得到以下结果
MergeNotes(midiFile);
构建“月光奏鸣曲”
此示例展示了如何使用 Pattern
来构建音乐作品。首先,贝多芬的“月光奏鸣曲”的前四个小节将帮助我们完成这项工作。
using Melanchall.DryWetMidi.MusicTheory;
using Melanchall.DryWetMidi.Interaction;
using Melanchall.DryWetMidi.Composing;
public static Pattern BuildMoonlightSonata()
{
// Define a chord for bass part which is just an octave
var bassChord = new[] { Interval.Twelve };
// Build the composition
return new PatternBuilder()
// The length of all main theme's notes within four first bars is
// triplet eight so set it which will free us from necessity to specify
// the length of each note explicitly
.SetNoteLength(MusicalTimeSpan.Eighth.Triplet())
// Anchor current time (start of the pattern) to jump to it
// when we'll start to program bass part
.Anchor()
// We will add notes relative to G#3.
// Instead of Octave.Get(3).GSharp it is possible to use Note.Get(NoteName.GSharp, 3)
.SetRootNote(Octave.Get(3).GSharp)
// Add first three notes and repeat them seven times which will
// give us two bars of the main theme
// G#3
.Note(Interval.Zero) // +0 (G#3)
.Note(Interval.Five) // +5 (C#4)
.Note(Interval.Eight) // +8 (E4)
.Repeat(3, 7) // repeat three previous notes seven times
// Add notes of the next two bars
// G#3
.Note(Interval.One) // +1 (A3)
.Note(Interval.Five) // +5 (C#4)
.Note(Interval.Eight) // +8 (E4)
.Repeat(3, 1) // repeat three previous notes
.Note(Interval.One) // +1 (A3)
.Note(Interval.Six) // +6 (D4)
.Note(Interval.Ten) // +10 (F#4)
.Repeat(3, 1) // repeat three previous notes
// reaching the end of third bar
.Note(Interval.Zero) // +0 (G#3)
.Note(Interval.Four) // +4 (C4)
.Note(Interval.Ten) // +10 (F#4)
.Note(Interval.Zero) // +0 (G#3)
.Note(Interval.Five) // +5 (C#4)
.Note(Interval.Eight) // +8 (E4)
.Note(Interval.Zero) // +0 (G#3)
.Note(Interval.Five) // +5 (C#4)
.Note(Interval.Seven) // +7 (D#4)
.Note(-Interval.Two) // -2 (F#3)
.Note(Interval.Four) // +4 (C4)
.Note(Interval.Seven) // +7 (D#4)
// Now we will program bass part. To start adding notes from the
// beginning of the pattern we need to move to the anchor we set
// above
.MoveToFirstAnchor()
// First two chords have whole length
.SetNoteLength(MusicalTimeSpan.Whole)
// insert a chord relative to
.Chord(bassChord, Octave.Get(2).CSharp) // C#2 (C#2, C#3)
.Chord(bassChord, Octave.Get(1).B) // B1 (B1, B2)
// Remaining four chords has half length
.SetNoteLength(MusicalTimeSpan.Half)
.Chord(bassChord, Octave.Get(1).A) // A1 (A1, A2)
.Chord(bassChord, Octave.Get(1).FSharp) // F#1 (F#1, F#2)
.Chord(bassChord, Octave.Get(1).GSharp) // G#1 (G#1, G#2)
.Repeat() // repeat the previous chord
// Build a pattern that can be then saved to a MIDI file
.Build();
}
Links
- NuGet 包:https://nuget.net.cn/packages/Melanchall.DryWetMidi
- GitHub 仓库:https://github.com/melanchall/drywetmidi
- 文档:https://melanchall.github.io/drywetmidi
历史
- 2024 年 7 月 19 日
- 对代码示例和文本进行的小修复
- 2023 年 8 月 19 日
- 对代码示例进行的小修复
- 2022 年 6 月 9 日
- 文章已更新,以反映
DryWetMIDI
6.1.0 中引入的更改
- 文章已更新,以反映
- 2021 年 6 月 26 日
- 将 Wiki 链接替换为指向库文档的链接
- 2019 年 11 月 23 日
- 文章已更新,以反映
DryWetMIDI
5.0.0 中引入的重大更改
- 文章已更新,以反映
- 2019 年 2 月 2 日
- 文章已更新,以反映
DryWetMIDI
4.0.0 中引入的更改:新方法和类
- 文章已更新,以反映
- 2018 年 6 月 11 日
- 文章已更新,以反映
DryWetMIDI
3.0.0 中引入的更改:新方法和类
- 文章已更新,以反映
- 2017 年 11 月 7 日
- 文章已更新,以反映
DryWetMIDI
2.0.0 中引入的更改:时间类和长度类已通过ITimeSpan
进行了泛化
- 文章已更新,以反映
- 2017 年 8 月 24 日
- 文章提交