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