Как я MIDI-клавиатуру писал

в 8:26, , рубрики: .net, MIDI, гитара, музыка, Программирование, Синтезатор, метки: , , , , , ,

Не так давно я загорелся идеей написать свою MIDI-клавиатуру. Позже к ней был прикручен гитарный гриф, она научилась распознавать аккорды и воспроизводить мелодии. Собственно об этом и будет этот пост.
Если вам интересно как программно воспроизводить звук через MIDI-синтезаторы, алгоритм распознавания гитарных аккордов, или же вы просто любите играть на гитаре или клавишных инструментах прошу под кат.

Раз, раз-два-три…

Перед тем как писать клавиатуру нужно как-то научиться воспроизводить звук. Первое что приходит на ум использовать встроенный в систему синтезатор. Он и на каждом устройстве есть, и устанавливать ничего не нужно. В общем работает из коробки.
Программу я решил писать на C#. Поискав в гугле, я узнал, что .NET сам по себе не умеет работать c MIDI, но есть WinAPI функции для этого. Последующий поиск в итоге привел меня к библиотеке NAudio. С помощью неё мы и будем воспроизводить звуки.

Для воспроизведения какой либо ноты необходимо отправить определенное сообщение на MidiOut, с указанием канала воспроизведения, ноты и силы нажатия.
Так, например, можно воспроизвести ноту ля 3-ей октавы:

midiOut.Send( MidiMessage.StartNote( 57, 127, 0 ).RawData ) //id звука, сила нажатия (0-127), номер канала;

Но не все так просто, воспроизведение ноты нужно остановить, иначе она так и будет звучать.

midiOut.Send( MidiMessage.StopNote( 57, 0, 0 ).RawData );

Перед воспроизведением нужно открыть нужное нам MIDI устройство. Делается это простым созданием объекта MidiOut. В конструктор передается номер устройства, т.к. их может быть несколько.
Узнать их количество можно считав статическое свойство MidiOut.NumberOfDevices, а получить сведения об этом устройстве методом MidiOut.DeviceInfo, передав ему идентификатор синтезатора.

Помните ту странную цифру 57? Это идентификатор ноты. Нумерация начинается с 0, каждое следующее значение это увеличение тональности на полутон.
Зависимость воспроизводимой ноты от ID можно увидеть на таблице:

image

Ознакомившись со всей этой информации я написал класс для упрощения работы с NAudio:

Скрытый текст

internal struct Note
{
    public Note( byte oct, Tones t )
    {
        octave = oct;
        tone = t;

        id = 12 + octave * 12 + (int)tone;
    }

    public byte octave;
    public Tones tone;
    public int id;
}

public enum Tones
{
    A = 9, Ad = 10, B = 11, C = 0, Cd = 1,
    D = 2, Dd = 3, E = 4, F = 5, Fd = 6, G = 7, Gd = 8
}

class AudioSintezator : IDisposable
{
    public int PlayTone( byte octave, Tones tone )
    {
        // 12 полутонов в октаве, начинаем считать с 0-й октавы (есть еще и -1-ая)
        int note = 12 + octave * 12 + (int)tone;

        if( !playingTones.Contains( note ) )
        {
            // воспроизводим ноту с макс. силой нажатия на канале 0
            midiOut.Send( MidiMessage.StartNote( note, 127, 0 ).RawData );
            playingTones.Add( note );
        }

        return note;
    }

    public void StopPlaying( int id )
    {
        if( playingTones.Contains( id ) )
        {
			// Останавливаем воспроизведение ноты
            midiOut.Send( MidiMessage.StopNote( id, 0, 0 ).RawData );
            playingTones.Remove( id );
        }
    }


    MidiOut midiOut = new MidiOut( 0 );
    List<int> playingTones = new List<int>();

    public void Dispose()
    {
        midiOut.Close();
        midiOut.Dispose();
    }
}

А так же сделал пробный вариант MIDI-клавиатуры

image

Секс, наркотики и рок-н-ролл

Следующий этап это создание гитарного грифа и привязки нот к струнам и ладам.
Гитарный гриф устроен очень просто. Каждая струна в открытом положении издает звук с определенной тональностью. Эта же струна, зажатая на определенном ладу, издает звук на определенное количество полутонов выше.
Если открытая струна издает звук E4, то она же зажатая на втором ладу издаст звук F#4, а на 12-ом E5.
Алгоритм прост: берем тональность открытой струны и увеличиваем её на определенное количество полутонов.

Для упрощения себе жизни я написал класс:

Скрытый текст


class Guitar
{
    public Guitar( params Note[] tune )
    {
        for( int i = 0; i < 6; ++i )
        {
            strs.Add( new GuitarString( tune[i] ) );
        }
    }

    public List<Tuple<byte, byte>> GetFretsForNote( Note note )
    {
        // 1-е значение номер струны (от 0 до 5), 2-е - номер лада ( 0 - открытая струна)
        List<Tuple<byte, byte>> result = new List<Tuple<byte, byte>>();

        byte currentString = 0;
        foreach( var str in strs )
        {
            var fret = str.GetFretForNote( note );

            if( fret != -1 ) // Если на этой струне можно сыграть заданную ноту
            {
                result.Add( new Tuple<byte, byte>( currentString, (byte)fret ) );
            }

            ++currentString;
        }

        return result;
    }

    public Note GetNote( byte str, byte fret )
    {
        return strs[str].GetNoteForFret( fret );
    }

    public void SetTuning( params Note[] tune ) // звучание открытых струн
    {
        for( int i = 0; i < 6; ++i )
        {
            strs[i].SetTune( tune[i] );
        }
    }

    List<GuitarString> strs = new List<GuitarString>();
}

class GuitarString
{
    public GuitarString( Note note )
    {
        this.open = note;
    }

    public void SetTune( Note note )
    {
        this.open = note;
    }

    public Note GetNoteForFret( byte fret )
    {
        return open + fret;
    }

    public int GetFretForNote( Note note )
    {
        int fret = -1; // -1 означает, что нельзя сыграть ноту на этой струне

        if( open <= note )
        {
            int octDiff = note.octave - open.octave;
            int noteDiff = note.tone - open.tone;

            fret = octDiff * 12 + noteDiff;
        }

        return fret;
    }

    Note open;
}

Вот что у меня получилось на этом этапе:

image

Трезвучия, септаккорды и прочие радости музыки

Этот этап тоже не особо сложен за исключением одного момента, который будет описан чуть позже.
Немного теории: аккорд это набор звуков определенных тональностей. Ноты эти взяты не абы как — они расположены с определенными интервалами. Интервалы измеряются в полутонах.
Аккорд ля минор, например, имеет интервалы 3,4, т.е. представляет последовательность нот: A, C, E (ля, до, ми)
Больше я рассказать про это не смогу, т.к. сам дилетант в музыке, консерваторий не оканчивал. И боюсь наговорить много чего лишнего и далекого от истины. Больше можно узнать на википедии.

Вернемся к программе.
Алгоритм распознавания следующий:

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

На этом можно бы было и закончить, если бы не одно но: гитарные аккорды не так просто устроены, как хотелось бы. Они содержат в себе множество звуков в определенной гаммы. Например гитарный ля минор содержит уже 5 звуков, хоть это и те же ля-до-ми.

Это усложняет распознавание:

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

Как вы уже, наверное, догадались я так же создал класс для упрощения работы с аккордами:

Скрытый текст



static class Chords
{
    public static ChordType[] chordTypes = new ChordType[]{
        new ChordType("мажорное трезвучие", "", 4,3),
        new ChordType("минорное трезвучие", "m", 3,4),
        new ChordType("увеличенное трезвучие", "5+", 4,4),
        new ChordType("уменьшенное трезвучие", "m-5", 3,3),
        new ChordType("большой мажорный септаккорд", "maj7", 4,3,4),
        new ChordType("большой минорный септаккорд", "m+7", 3,4,4),
        new ChordType("доминантсептаккорд", "7", 4,3,3),
        new ChordType("малый минорный септаккорд", "m7", 3,4,3),
        new ChordType("полуувеличенный септаккорд", "maj5+", 4,4,3),
        new ChordType("полууменьшенный септаккорд", "m7-5", 3,3,4),
        new ChordType("уменьшенный септаккорд", "dim", 3,3,3),
        new ChordType("трезвучие с задержанием (IV)", "sus2", 2,5),
        new ChordType("трезвучие с задержанием (II)", "sus4", 5,2),
        new ChordType("секстмажор","6", 4,3,2),
        new ChordType("секстминор", "m6", 3,4,2),
        new ChordType("большой нонмажор", "9", 4,3,3,4),
        new ChordType("большой нонминор", "m9", 3,4,3,4),
        new ChordType("малый нонмажор", "-9", 4,3,3,3),
        new ChordType("малый нонминор", "m-9", 3,4,3,3),
        new ChordType("нота",""),
        new ChordType("малая секунда", " - М2", 1),
        new ChordType("большая секунда", " - Б2", 2),
        new ChordType("малая терция", " - М3", 3),
        new ChordType("большая терция", " - Б3", 4),
        new ChordType("чистая кварта", " - Ч4", 5),
        new ChordType("увеличенная кварта", " - УВ4", 6),
        new ChordType("чистая квинта", " - Ч5", 7),
        new ChordType("малая секста", " - М6", 8),
        new ChordType("большая секста", " - Б6", 9),
        new ChordType("малая септима", " - М7", 10),
        new ChordType("большая септима", " - Б7", 11),
        new ChordType("октава", " - О", 12),
        new ChordType("малая нона", " - М9", 13),
        new ChordType("большая нона", " - Б9", 14) 
};

    public static string[] chordsBases = new string[] {
        "A","A#","B","C","C#","D","D#","E",
        "F","F#","G","G#"
    };

    public static string[] chordMods = new string[] {
        "","m","5+","m-5","maj7","m+7","7",
        "m7","maj5+","m7-5","dim","sus2","sus4",
        "6","m6","9","m9","-9","m-9"
    };

    private static int GetChordType( List<Note> tmp )
    {
        int[] intervals = new int[tmp.Count - 1];
        for( int i = 0; i < tmp.Count - 1; ++i )
        {
            intervals[i] = tmp[i] - tmp[i + 1];
        }

        int type = 0;
        foreach( var chordType in Chords.chordTypes )
        {
            if( Utils.CompareArrays( intervals, chordType.intervals ) )
                break;
            ++type;
        }
        return type;
    }

    public static void GetChord( List<Note> chordNotes, out Note BaseNote, out ChordType type )
    {
        List<Note> notes = PrepareNotes( chordNotes ); // Подготовка нот к распознаванию
        int typeIndex = GetChordType( notes ); // Попытка распознать аккорд

        if( typeIndex < chordTypes.Length ) //Если нашли
        {
            BaseNote = notes[0];
            type = chordTypes[typeIndex];
        }
        else
        {
            bool unknown = true;
            var possibleChord = new List<Note>( notes );

            // Осуществляем полный перебор
            foreach( List<Note> perm in Utils.GeneratePermutation( possibleChord ) )
            {
                // Убираем промежутки между нотами ( > 12 полутонов )
                for( int k = 1; k < perm.Count; ++k )
                {
                    if( perm[k].tone > perm[k - 1].tone )
                    {
                        perm[k] = new Note( perm[k - 1].octave, perm[k].tone );
                    }
                    else
                    {
                        perm[k] = new Note( (byte)(perm[k - 1].octave + 1), perm[k].tone );
                    }
                }

                typeIndex = GetChordType( possibleChord );

                if( typeIndex < Chords.chordTypes.Length )
                {
                    unknown = false;
                    break; // Мы нашли что нужно, выходим
                }
            }

            if( unknown )
            {
                throw new Exception( "неизвестный аккорд" );
            }
            else
            {
                BaseNote = possibleChord[0];
                type = chordTypes[typeIndex];
            }
        }
    }

    private static List<Note> PrepareNotes( List<Note> notes )
    {
        List<Note> tmp = new List<Note>();

        bool finded = false;
        for( int i = 0; i < notes.Count; ++i )
        {
            finded = false;
            var note = notes[i];
            for( int j = 0; j < tmp.Count; ++j ) //Ищем похожие тона в списке
            {
                if( note.tone == tmp[j].tone )
                {
                    finded = true;
                    break;
                }
            }

            if( !finded ) //Если такой тон еще не встречался
            {
                tmp.Add( note );
            }
        }

        // Если все ноты одинаковые, но разные по тональности
        if( tmp.Count == 1 && notes.Count > 1 ) 
            return notes;

        // "пододвигаем" ноты
        byte lowest = tmp[0].octave;
        var lowesTone = tmp[0].tone;
        for( int i = 0; i < tmp.Count; ++i )
        {
            if( tmp[i].octave > lowest )
            {
                if( Utils.CountOfTones( tmp[i].tone, notes ) > 1 )
                {
                    if( tmp[i].tone > lowesTone )
                    {
                        tmp[i] = new Note( lowest, tmp[i].tone );
                    }
                    else
                    {
                        tmp[i] = new Note( (byte)(lowest + 1), tmp[i].tone );
                    }
                }
            }
        }

        tmp = tmp.OrderBy( x => x.id ).ToList();
        return tmp;
    }
}

Финальный результат:

image

Полные исходные коды проекта доступны на GitHub.
Так-же хочу выразить благодарность товарищу Sadler, автору поста «Ovation. Таблица аккордов своими руками с помощью JS и HTML5», у которого я и подсмотрел таблицу аккордов. Это мне сэкономило чуточку времени.

Автор: A1ex


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


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