65.9K
CodeProject 正在变化。 阅读更多。
Home

DryWetMIDI:MIDI 文件的高级处理

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (21投票s)

2017年8月24日

CPOL

10分钟阅读

viewsIcon

63086

概述如何使用 DryWetMIDI 库对 MIDI 文件数据进行高级管理

简介

DryWetMIDI 是一个用于处理标准 MIDI 文件 (SMF) 的 .NET 库——读取、写入、创建和修改它们,还可以处理 MIDI 设备(这在 DryWetMIDI:处理 MIDI 设备 文章中有介绍)。在本文中,我们将讨论 MIDI 文件的处理。

尽管有许多 .NET 库提供 MIDI 文件解析功能,但 DryWetMIDI 具有一些使其与众不同的特色

  • 能够读取包含某些损坏的文件,例如遗漏了 Track End 事件
  • 能够精细调整读取和写入过程,例如,允许指定文本格式元事件(如 Lyric)中存储文本的 编码
  • 一套 **高级类**,允许以更易于理解的方式管理 MIDI 文件内容,例如 NoteMusicalTimeSpan

最后一点是该库最重要的部分,因为许多用户询问关于音符管理或将 MIDI 时间和长度转换为更易于人类理解的表示形式(如秒)的问题。为了解决这些问题,他们不得不一遍又一遍地编写相同的代码。DryWetMIDI 通过提供内置工具来执行上述任务,使他们不必重新发明轮子。

本文快速概述了 DryWetMIDI 提供的高级数据管理功能。它不是 API 参考。该库还具有与 MIDI 文件内容交互的低级层,但这并非本文的主题。

文章末尾有 示例,展示了如何使用 DryWetMIDI 提供的功能解决实际任务。所以,如果您想对该库有一个整体印象,可以立即跳转到它们。

[^] 返回顶部

背景

建议熟悉 SMF 格式,但如果您只打算使用 DryWetMIDI 的高级 API,则不一定需要。

[^] 返回顶部

目录

  1. 绝对时间
  2. 注释
  3. 和弦
  4. GetObjects
  5. 速度图
  6. 时间和长度表示
  7. 模式
  8. 工具
  9. 示例
  10. 链接
  11. 历史

[^] 返回顶部

绝对时间

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 OnNote 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 集合,其中元素是 NoteTimedEvent。对于代表 Note On/Note Off 事件的每个 TimedEvent 对,都将返回 Note。请参阅 GetObjectsUtilities 类的文档。

[^] 返回顶部

和弦

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

有一个类提供了同时获取不同类型对象的方法——GetObjectsUtilities。结合 RestsUtilities 类中的方法,您可以构建休止符以及上面描述的对象。

请参阅以下库文档文章以了解更多信息

[^] 返回顶部

速度图

速度图是 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 接口。要在不同表示形式之间转换时间跨度,应使用 TimeConverterLengthConverter 类。

// 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 值可能代表不同秒数,例如,取决于具有此长度的对象的时间。上述方法可以接受 longITimeSpan 作为时间。

TimedObjectUtilities 类中有一些有用的方法。该类包含实现 ITimedObject 接口的类型的扩展方法——TimedEventNoteChord。例如,您可以使用 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 接口的类型的扩展方法——NoteChord。例如,您可以使用 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,这意味着第一个时间跨度表示时间,第二个表示长度。当操作数类型不同时,此信息对于转换引擎是必需的。还有 TimeTimeLengthLength 模式。

您还可以从一个时间跨度中减去另一个时间跨度。

var timeSpan1 = new MetricTimeSpan(0, 10, 0);
var timeSpan2 = new MusicalTimeSpan(3, 8);
ITimeSpan result = timeSpan1.Subtract(timeSpan2, TimeSpanMode.TimeTime);

如果操作数类型相同,则结果时间跨度也将是该类型。但是,如果您对不同类型的时间跨度进行加法或减法运算,则结果时间跨度的类型将是 MathTimeSpan,它包含操作数以及操作(加法或减法)和模式。

要拉伸或收缩时间跨度,请使用 MultiplyDivide 方法。

ITimeSpan stretchedTimeSpan = new MetricTimeSpan(0, 0, 10).Multiply(2.5);
ITimeSpan shrinkedTimeSpan = new BarBeatTicksTimeSpan(0, 2).Divide(2);

TimeAsLengthAs 方法都有非泛型版本,其中所需结果类型应作为 TimeSpanType 类型的参数传递。TimeConverterLengthConverter 类中的泛型方法也有非泛型版本,它们也接受 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

[^] 返回顶部

历史

  • 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 日
    • 文章提交
© . All rights reserved.