Набираем в LilyPond с помощью midi-клавиатуры

в 14:43, , рубрики: lilypond, MIDI, метки: , ,

Я уже пару раз писал про lilypond, а теперь я купил midi-клавиатуру.

Многие нотные редакторы, в том числе Finale и Sibelius, имеют возможность набора нот с midi-клавиатуры аж двумя способами: или можно сыграть что-нибудь под метроном, и это будет немедленно записано нотами, либо можно вводить с оной только ноты, а ритм и всё прочее вводится обычным способом.

Я решил, что аналогичная возможность не помешала бы и для предпочитаемого мною lilypond'а. Так как возможность записать midi-файл, а потом преобразовать его с помощью midi2ly меня не устраивает — слишком много информации именно нотонаборного толка в midi-файле отражены быть не могут (мы об этом уже дискутировали) — я решил написать программу для того, чтобы нажатые клавиши и аккорды немедленно преобразовывались в необходимый формат.

Набор в lilypond с помощью команды relative позволяет не указывать октаву для каждой ноты, а указывать только направление смены октавы. Без таких указаний каждая следующая нота (в случае аккорда — звук, указанный первым) оказывается не дальше, чем на кварту. То есть фа после до будет набрано выше, даже если фа — диез, а до — бемоль (дважды увеличенная, но кварта, да).

Написание программы поставило передо мной сложности двух родов: собственно работа с midi-клавиатурой и преобразование полученных сигналов в нужный вид.

midi-dot-net: работа с MIDI

Первым делом я скачал библиотеку midi-dot-net. Она предоставляет возможность как ввода, так и вывода, но интересует нас сейчас ввод.

Список устройств, открытие и закрытие

Cписок доступных устройств ввода, я его запихнул в комбобокс.

using Midi;

// .......

private void LoadMidiDevices()
{
    foreach (InputDevice d in InputDevice.InstalledDevices)
    {
        DeviceList.Items.Add(d.Name);
    }
    DeviceList.SelectedIndex = 0;

    UpdateDevice();
}

Кроме имени устройства можно также узнать ManufacturerId, ProductId и всё это вместе в одном поле Spec.

Методы Open() и Close() открывают и закрывают устройство, состояние можно получить из поля IsOpen, StartReceiving(), StopReceiving(), IsReceiving, соответственно, отвечают за получение информации. Библиотека предоставляет удобную привязку событий в стиле C#.

Метод StartReceiving() опционально принимает объект типа Clock, предназначенный, в первую очередь, для отложенного MIDI-вывода. Если передать null, то таймстампы в событиях будут отсчитываться с момента вызова StartReceiving().

private void UpdateDevice()
{
    if (d != null)
    {
        if (d.IsReceiving)
            d.StopReceiving();
        if (d.IsOpen)
            d.Close();
    }

    d = InputDevice.InstalledDevices[DeviceList.SelectedIndex];
    if (!d.IsOpen)
        d.Open();

    if (d.IsReceiving)
        d.StopReceiving();

    d.StartReceiving(null);

    if (d != null)
    {
        d.NoteOn += new InputDevice.NoteOnHandler(this.NoteOn);
        d.NoteOff += new InputDevice.NoteOffHandler(this.NoteOff);
        d.ControlChange += new InputDevice.ControlChangeHandler(this.ControlChange);
    }
}

Не забываем закрыть устройство при выходе

private void SettingsWindow_FormClosing(object sender, FormClosingEventArgs e)
{
    d.StopReceiving();
    d.Close();
}
Обработка нажатий

Алгоритм работы я избрал таковым: при отпускании всех клавиш те из них, которые были нажаты дольше 50 мс передаются всем скопом в конвертер и отсылаются активному окну с помощью SendKeys. Это даёт возможность набирать и отдельные ноты, и аккорды.

Реализация проста до безобразия:

private List<Pitch> notes;
private Dictionary<Pitch, float> events;

//....

public void NoteOn(NoteOnMessage msg)
{
    lock (this)
    {
        events[msg.Pitch] = msg.Time;
    }
}

public void NoteOff(NoteOffMessage msg)
{
    lock (this)
    {
        if (events.ContainsKey(msg.Pitch))
        {
            if (msg.Time - events[msg.Pitch] > 0.05)
            {
                notes.Add(msg.Pitch);
            }
            events.Remove(msg.Pitch);

            if ((events.Count == 0) && (notes.Count > 0))
            {
                SendKeys.SendWait(" " + cons.Convert(notes));
                notes.Clear();
            }
        }
    }
}

Также я добавил возможность набора лиг (круглые скобки в синтаксисе lilypond'а) с помощью педали (а в моём случае — кнопки) sustain. Поскольку моя клавиатура присылает ControlChange по две штуки, я добавил дополнительную проверку.

bool sustain;

// .......

public void ControlChange(ControlChangeMessage msg)
{
    if (msg.Control == Midi.Control.SustainPedal)
    {
        if ((msg.Value > 64) && !sustain)
        {
            sustain = true;
            SendKeys.SendWait("{(}");
        }
        if ((msg.Value < 64) && sustain)
        {
            sustain = false;
            SendKeys.SendWait("{)}");
        }
    }
    return;
}

Также библиотека позволяет обрабатывать события ProgramChange и PitchBend.

Грызём орехи элементарной теории музыки

Вторая задача оказалась посложнее первой. Я добавил в окошко счётчик для указания количества знаков и переключатель мажор-минор и начал думать.

Первым делом я создал промежуточное звено между значением Midi.Pitch и выводом — класс ступени гаммы (поленившись посмотреть в словарь, назвал его сначала Grade, а надо было Degree). В зависимости от тональности мы будем преобразовать Midi.Pitch в Degree.
Массивы хранят настройки преобразования номера в хроматической (12-ступенной) гамме в ступень 7-ступенной гаммы и её альтерацию (повышение или понижение).

private static int[] majorScale = { 0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6 };
private static int[] majorAcc = { 0, -1, 0, -1, 0, 0, 1, 0, -1, 0, -1, 0 };
private static int[] minorScale = { 0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6 };
private static int[] minorAcc = { 0, -1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1 };

private Degree PitchToGrade(Pitch p)
{
    // с этой ноты начинается гамма
    int keybase = (keys * 7) % 12 - (isMajor?0:3);
    // поэтому в хроматической гамме от этой ноты наша будет
    int offset = ((int)p - keybase) % 12;
    // и смещение октавы по 7-ступенной системе
    int octave = (((int)p - keybase) / 12) * 7;
    int num, acc;

    if (offset < 0)
        offset += 12;

    if (isMajor)
    {
        num = majorScale[offset] + octave;
        acc = majorAcc[offset];
    }
    else
    {
        num = minorScale[offset] + octave;
        acc = minorAcc[offset];
    }

    return new Degree(num, acc);
} 

После этого начинается самое неприятное: выяснить, какая нота и с каким знаком будет обозначать ступень с этим номером и с этой альтерацией в данной тональности.

Метод класса Degree работает так. В переменную fs помещается то минимально необходимое количество знаков в тональности, которое нужно для того, чтобы эта ступень в этой тональности в неальтерированном виде обладала знаком альтерации. В переменной Number хранится номер ступени с учётом октавы, numMod — без учёта.

Магическим образом (прибавляя умноженное на четыре количество ключевых знаков и отнимая два в случае минора) номер ступени превращается в номер ноты в белоклавишной гамме, и, если переменная fs говорит о необходимости, добавляем «родную» альтерацию тональности.

private static String[] Naturals = { "c", "d", "e", "f", "g", "a", "h" };
private static String[] Sharps = { "cis", "dis", "eis", "fis", "gis", "ais", "his" };
private static String[] Flats = { "ces", "des", "es", "fes", "ges", "as", "b" };
private static String[] DoubleSharps = { "cisis", "disis", "eisis", "fisis", "gisis", "aisis", "bisis" };
private static String[] DoubleFlats = { "ceses", "deses", "eses", "feses", "geses", "ases", "beses" };

public String resolveIn(int keys, bool isMajor)
{
    int fAcc = Acc;
    int fs;
    int fNum;
    int numMod = Number % 7;

    fs = isMajor ? 6 : 3;

    fs = (fs - 2*numMod) % 7;

    if (fs <= 0)
        fs += 7;

    if (keys < 0)
        fs = 8 - fs;

    if (fs <= Math.Abs(keys))
        fAcc += keys / Math.Abs(keys);
            
    fNum = (numMod + keys*4 - (isMajor ? 0 : 2)) % 7;
            
    if (fNum < 0)
        fNum += 7;
                        
    switch (fAcc)
    {
        case -2: return DoubleFlats[fNum];
        case -1: return Flats[fNum];
        case 0: return Naturals[fNum];
        case 1: return Sharps[fNum];
        case 2: return DoubleSharps[fNum];
        default: return "";
    }
}

Остальное просто: необходимость добавления знаков изменения тональности вычисляется оператором вычитания ступеней, а последняя высота сохраняется в поле lastPitch:

// class Degree

public static String operator -(Degree a, Degree g)
{
    int o;

    o = a.Number - g.Number;
    o = (int)Math.Round((double)o / 7.0);

    if (o > 0)
        return new String(''', o);
    if (o < 0)
        return new String(',', -o);

    return "";
}



// class PitchConsumer

public String Convert(List<Pitch> pitches)
{
    Pitch localLast = lastPitch;
    String accum;

    if ((int)lastPitch == 0)
        lastPitch = pitches[0];

    pitches.Sort();
    if (pitches.Count == 1)
    {
        accum = PitchToString(pitches[0], lastPitch);
        lastPitch = pitches[0];
    }
    else
    {
        lastPitch = pitches[0];
        accum = "<";
        foreach (Pitch p in pitches)
        {
            if (accum.Length > 1)
                accum += " ";
            accum += PitchToString(p, localLast);
            localLast = p;
        }
        accum += ">";
    }
    return accum;
}

private String PitchToString(Pitch p, Pitch last)
{
    Degree g, glast;
    String note;

    g = PitchToGrade(p);
    glast = PitchToGrade(last);

    note = g.resolveIn(keys, isMajor);

    return note + (g - glast);
}

Выводы

Сложность подстерегала меня не в той области, где я был не уверен, а в том, что является предметом моей специальности. Зато я теперь лучше понимаю, как сложно бывает детям в музыкальной школе :-). И набирать ноты стало действительно быстрее.

https://github.com/m0003r/LilyInput

Автор: m03r


  1. deviskra:

    Программа Frescobaldi позволяет вводить Lilypond ноты с миди-клавиатуры, причем реализована возможность вводить отдельно ритм и прочее (штрихи, лиги, динамику) обычным способом, а потом набирать звуки на миди-клавиатуре (так, называемый Re-pitch mode). Пока возможность не добавлена в официальные сборки, поэтому качать и тестировать нужно отсюда https://github.com/deviskra/frescobaldi/tree/v2.x

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js