Click here to Skip to main content
13,627,789 members
Click here to Skip to main content
Add your own
alternative version

Stats

3.6K views
104 downloads
3 bookmarked
Posted 26 Mar 2018
Licenced CPOL

DryWetMIDI: Notes Quantization

, 26 Mar 2018
Rate this:
Please Sign up or sign in to vote.
Console application to quantize notes of a MIDI file

Introduction

The purpose of this article is to show how you can quantize notes of an MIDI file using DryWetMIDI – open-source library for managing MIDI files. We are going to write a console application that will perform this task.

In the previous article, an example of simple quantization was provided. But the application we are going to write now will be much more advanced, allowing, for example, to specify area near snapping point to randomize a note's start or end within; or to specify set of grid steps to perform groove quantization.

[^] top

Contents

  1. Usage
  2. Examples
  3. Implementation

[^] top

Usage

First of all, we should describe the usage of our program, i.e., the name of the program, its arguments and valid combinations of them. Every time a user calls the program in a wrong way, we will show the following help:

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.

where <...> means mandatory argument, [...] means optional argument and | means a choice (only one of arguments separated by | can be used at once).

So the program will be named as quantize. Let's discuss its arguments.

<input_path>

The path of a MIDI file to quantize notes of.

<grid>

Sequence of steps of the grid to quantize notes by. Each step should be separated from another one by comma. Steps will be parsed to instances of classes implementing ITimeSpan interface. Let's see valid strings that can be parsed to those instances.

MetricTimeSpan

Represents a step in terms of microseconds. Strings that can be parsed as an instance of the MetricTimeSpan have to be in the following format:

[Hours :] Minutes : Seconds [: Milliseconds]

Examples of valid strings:

"0:0:0:500" // 500 milliseconds
"0:4"       // 4 seconds
"8:20:30"   // 8 hours 20 minutes 30 seconds

Every component should be a nonnegative long number.

MusicalTimeSpan

Represents a step in terms of a fraction of the whole note. Strings that can be parsed as an instance of the MusicalTimeSpan have to be in the following format:

<Fraction | FractionMnemonic> [Tuplet | TupletMnemonic] [.+]

where:

Fraction should be in form of Numerator / Denominator where Numerator can be omitted if it equals 1.

FractionMnemonic can be one of the following strings: w (whole), h (half), q (quarter), e (eighth) or s (sixteenth).

Tuplet should be in form of [NotesCount : SpaceSize] which defines a tuplet with NotesCount notes in space of SpaceSize notes. For example, [3:2] means triplet.

TupletMnemonic can be one of the following strings: t (triplet) or d (duplet).

.+ means one or more dots.

Examples of valid strings:

"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"

BarBeatTimeSpan

Represents a step in terms of number of bars, beats and ticks. Strings that can be parsed as an instance of the BarBeatTimeSpan have to be in the following format:

Bars.Beats.Ticks

Examples of valid strings:

"0.1.8"  // 1 beat and 8 ticks
"10.0.0" // 10 bars
"0.0.5"  // 5 ticks

MidiTimeSpan

Represents a step in terms of either ticks (in case of ticks per quarter note time division of a MIDI file) or subdivisions of frame (in case of SMPTE time division). Strings that can be parsed as an instance of the MidiTimeSpan have to be in the following format:

TimeSpan

Examples of valid strings:

"300" // 300 ticks

MidiTimeSpan exists in the library for unification purposes and nearly useless for musicians.

-o <output_path>

The path of the output MIDI file. If this argument is not specified, an input file will be rewritten.

-s

This option instructs the program to quantize start times of notes. Start time is the default target of quantization routine. If -ke option is not specified, length of a note will not be changed.

-e

This option instructs the program to quantize end times of notes. If -ks option is not specified, length of a note will not be changed.

-ke

This option instructs the program to keep notes ends untouched in case of start times quantization. The length of a note can be changed in general case when this option is used.

-ks

This option instructs the program to keep notes starts untouched in case of end times quantization. The length of a note can be changed in general case when this option is used.

-t <tolerance>

Specifies the tolerance to randomize start or end time of a note within. In other words, it is the size of area around a nearest grid point where note's time should be placed. <tolerance> is any valid string that can be parsed to an instance of a class implementing the ITimeSpan (see description of the <grid> argument above to know what strings can be used for tolerance).

[^] top

Examples

Let's look at some examples of what our program can do. We will use input MIDI file with the following notes:

This file named input.mid is inside the archive files.zip attached to the article along with sources.zip archive with source code. As you can see, it is just a one bar with random notes. Now we are going to execute quantize program with different arguments and see the results.

We will start with a simple case – quantization of notes starts by grid of eighth step (<a href="#arg-start-time">-s</a> can be omitted since the start time of a note is the default target of quantization):

quantize input.mid 1/8 -s

Bottom bar shows the grid used to quantize notes. Gray rectangles show original notes so we can check the correctness of processing visually.

Now we will try to perform quantization by grid of variable step (aka groove quantization). q,e,e defines a grid where steps are 1/4, 1/8, 1/8, 1/4, 1/8, 1/8, ... so the pattern is [1/4, 1/8, 1/8]:

quantize input.mid q,e,e -s

Let's add more groove! [1/16, 1/8, 1/16, 1/4, triplet 1/8, triplet 1/8, triplet 1/8, dotted 1/8, 1/16] pattern will help us with this:

quantize input.mid s,e,s,q,et,et,et,e.,s

By default quantization of note's start time keeps note length untouched, so entire note is moved to another time. We can use -ke argument to keep ends of notes unchanged, so length can be changed during the quantization.

quantize input.mid q,e,e -s -ke

If we want to quantize end time of a note, -e argument should be used:

quantize input.mid q.,e -e

As with quantization of note's start time, we can keep opposite side of the note unchanged. Quantizing end time -ks argument should be used to keep start time untouched.

quantize input.mid q.,e -e -ks

Also, the tolerance can be specified with help of -t argument. Tolerance is the size of area around grid points where target (start or end time) can be randomly placed during quantization process. This is one of the approaches to "humanize" MIDI music. The following example shows quantization of start times to the grid with step of quarter length using tolerance of thirty-second length:

quantize input.mid q -t 1/32

[^] top

Implementation

It's time to take a look at the code of the program. The core class is Quantizer with one public method – 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 just holds arguments of the program:

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; }
}

We will not discuss how an instance of the QuantizerArguments is created since it is not a subject of the article. You can take a look at source code attached to the article if you are interested in it.

Now we are going to discuss implementation of QuantizeStart and QuantizeEnd methods of the Quantizer class.

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;
    });
}

ProcessNotes extension method from Melanchall.DryWetMidi.Smf.Interaction.NotesManagingUtilities allows to modify notes inside a MidiFile in an easy way. Algorithm of a note's start quantization is:

  1. create a grid to quantize by
  2. find a point of the grid nearest to a note's start
  3. get random time within the specified tolerance of the grid point found on previous step
  4. if note's end should be untouched (-ke option is specified), calculate new length of the note
  5. set new time of the note calculated on step 3

BuildGrid creates a grid to quantize by. Result grid is a collection of times represented as long values which is internal representation of time and length within a MIDI file. We are going through the specified steps of the grid and calling TimeConverter.ConvertFrom to convert ITimeSpan to a long value. Note that we need TempoMap object to perform such conversion since a MIDI file can contain tempo and time signature changes and thus they need to be taken into an account to turn, for example, seconds into ticks (long value). Code of the method is presented below:

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;
        }
    }
}

Since Time property of the Note class is already has long type, search of a grid's time nearest to a note's start is quite simple. We should just loop through the grid and take a point where difference between grid's time and note's time is smallest:

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;
}

To randomize time within the specified tolerance, we need to calculate minimum and maximum times which define boundaries of the allowable area. Then we should just take a random number between those times:

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 utilizes the same methods. But obviously changes end time of a note instead of the start one. Implementation of the method has nothing mystical:

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;
    });
}

And that's all! Our quantizer can now process files which allow to build automated scripts that can, for example, prepare MIDI files for further manipulations with another script or DAW.

Last thing that can be interested is how we get grid as IEnumerable<ITimeSpan> from the string representation passed to the program as an argument. It is super easy:

IEnumerable<ITimeSpan> grid = gridAsString.Split(new char[] { ',' },
                                                 StringSplitOptions.RemoveEmptyEntries)
                                          .Select(TimeSpanUtilities.Parse);

TimeSpanUtilities.Parse method takes a string and returns correct implementation of the ITimeSpan or throws error if the string has invalid format.

[^] top

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Maxim Dobroselsky
Software Developer
Russian Federation Russian Federation
My primary skills are C#, WPF and ArcObjects/ArcGIS Pro SDK. Currently I'm working on extensions for ArcGIS for Desktop and ArcGIS Pro.

Also I'm writing music which led me to starting the DryWetMIDI project on the GitHub. DryWetMIDI is an open source .NET library written in C# for managing MIDI files. The library is currently actively developing.

Also I actively help people on Code Review Stack Exchange to improve their C# code and have some answers on WPF related questions on Stack Overflow.

You may also be interested in...

Comments and Discussions

 
-- There are no messages in this forum --
Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web03 | 2.8.180712.1 | Last Updated 26 Mar 2018
Article Copyright 2018 by Maxim Dobroselsky
Everything else Copyright © CodeProject, 1999-2018
Layout: fixed | fluid