Pull to refresh

Пишем Бетховена на Javascript или немного о MIDI.js

Reading time 7 min
Views 22K
Как сыграть ноты в браузере? Как сократить любое длиннейшее произведение до 107 отдельных нот (которые можно еще и закэшировать) и килобайта-другого текста? Немного музыкальной теории, js-библиотеки и MIDI под катом.

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

Для нетерпеливых — сразу можно ознакомиться с результатом (7 тактов лунной сонаты). Работает в последних Chrome и Firefox на Ubuntu 14.04. На мобильных устройствах работать, скорее всего, не будет.

Как же воспроизвести нотную запись в браузере? Первое, что приходит в голову — найти решение, реализующее основные функции. Поиск по гитхабу выдает midi.js. Решение удобное. Лицензия MIT. Примеры — работают. Берем!

$ git clone https://github.com/mudcube/MIDI.js.git

Получили копию на локальном окружении. В каталоге examples видим Basic.html. Скопируем туда же как Betchoven.html и будем изменять содержимое. Интересующие нас строки:

    var delay = 0; // play one note every quarter second
    var note = 50; // the MIDI note
    var velocity = 127; // how hard the note hits
    // play the note
    MIDI.setVolume(0, 127);
    MIDI.noteOn(0, note, velocity, delay);
    MIDI.noteOff(0, note, delay + 0.75);

Написать надо что-то относительно простое и медленное. Например, первую часть Лунной сонаты Бетховена (Соната для фортепиано № 14 до-диез минор, оп. 27, № 2, как подсказывает вики). Найдем ноты.

Пусть будут такие:

Распишу минимально необходимое для понимания этой нотной записи.

Небольшой экскурс в теорию


Ноты на пианино. На первой картинке нарисована раскладка пианино.


Разметка октав не совсем верная — первые две ноты слева — октава 0 в упрощенной нотации. С первой ноты До (обозначена как C) начинается октава 1.

Нажатие на клавишу пианино с такой раскладкой приведет к проигрыванию соответствующего звука:
от контроктавных Ля — Ля-диез (он же Си-бемоль) — Си
до До пятой октавы. Где целые ноты (До, Ре,..) — белые клавиши, полутона (с диезами и бемолями) — черные.
В зарубежной литературе часто применяется другая, упрощенная нотация, которой мы в итоге и будем пользоваться:
ноты обозначаются латинскими буквами С (До), D (Ре), E (Ми), F (Фа), G (Соль), A (Ля), B (Си). Октавы просто пронумерованы от 0 до 8. Соответственно, на пианино вы увидите обозначения нот
от A0 — A0♯ (B0♭) — B0 — С1
до С8. Черные клавиши могут быть не обозначены, как на картинке выше. На черных находятся промежуточные звуки (полутона) — ноты с диезами и бемолями.
Для каждой ноты и каждого полутона между ними, изображенных на данной раскладке есть соответствующий номер ноты в MIDI (от 21 до 108). Соотношение будет видно далее.
Над пианино видно два нотных стана (два раза по пять линий). Верхний — скрипичный, обозначает более высокие октавы, Нижний — басовый, более низкие. Загогулины в начале строк — знаки скрипичного и басового ключей, соответственно. Обратите внимание, что первая линия скрипичного ключа обозначает ноту Ми(E) первой(4) октавы, а первая басового — ноту Соль(G) большой(2).

Тональности

Следом за ключом на нотной записи видим 4 диеза. Так обозначается тональность. В данном случае она называется до-диез минор.

Для правильной игры в этой тональности нужно вместо нот, на линиях которых нарисованы диезы, играть диезы (следующая справа клавиша) этих нот. Диезы нарисованы на нотах C, D, F, G. Принципы построения тональностей расписывать не буду — сейчас они не так важны и информации в сети много. Желающие да загуглят.

Следовательно, если мы видим ноты C1, D1,..G7, мысленно меняем их на ближайшие справа C1♯, D1♯,… G7♯ и уже после ищем соответствующий номер в MIDI нумерации.

Знаки альтерации (♯, ♮, ♭)

Если эти знаки стоят не в начале строки, а где-то в «случайном» месте, то они временно, до конца текущего такта (такты разделяются вертикальной чертой) изменяют ноту следующим образом:
— Отменяется альтерация этой ноты в этой октаве, заданная тональностью. Например, нота С3♮ и все следующие за ней C3 до конца такта (ближайшей вертикальной черты) будут играться как С3;
-♯ повышает ноту на полтона. A3♯ и все следующие A3 до конца такта играются как A3♯;
-♭ понижает ноту на полтона. D3♭ и все следующие D3 будут играться как C3♯ (он же D3♭).

Вдумчивый читатель уже заметил, что в некоторых случаях «временные диез и бемоль» не имеют смысла. Например, в тональности лунной сонаты при нотах С, D, F, G можно ставить и не ставить диез. Ничего от этого не измениться. Да, для таких случаев есть дубль-диезы, но они — за пределами рассмотрения данной статьи.

Длительность

Нота в нотной записи может иметь полый или закрашенный кружок (головку), иметь вертикальную палку (штиль) и флажок. Это определяет относительную длительность звучания ноты. В нашем случае одна целая нота занимает весь такт, половинная — полтакта, и так далее. Привожу картинку для наглядности.



Еще один нюанс — точки. Точка сразу после ноты значит, что эта нота звучит полторы указанный длительности. Например, 1/8 с точкой звучит на протяжении 1/8 + 1/16 = 3/16 доли такта. Сразу оговорюсь для знатоков, Adagio в beats per minute я не переводил, а если бы и перевел, то что делать с этим 60-80 — не очень понятно. Поэтому длительность такта подобрана на слух.

Используя полученные знания, посчитаем ноты и переведем их в MIDI по следующей картинке:



С2 -> 37 (на полтона выше, потому что про тональность — до-диез минор)
C3 -> 49
G3 -> 56 и так далее.

Получим несколько топорную, но рабочую реализацию первого такта:

window.onload = function () {
	MIDI.loadPlugin({
		soundfontUrl: "./soundfont/",
		instrument: "acoustic_grand_piano",
		onprogress: function(state, progress) {
			console.log(state, progress);
		},
		onsuccess: function() {
  			play();
		}
	});
};

function play() {
	var delay = 0; // play one note every quarter second
	var velocity = 127; // how hard the note hits
	var gap = 0.6;
	var duration = 0.4;
	MIDI.setVolume(0, 80);
	// первый такт
	delay += gap;
	MIDI.noteOn(0, 49, velocity, delay);
	MIDI.noteOff(0, 49, delay + 4 * gap);
	MIDI.noteOn(0, 37, velocity, delay);
	MIDI.noteOff(0, 37, delay + 4 * gap);

	MIDI.noteOn(0, 56, velocity, delay);
	MIDI.noteOff(0, 56, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 61, velocity, delay);
	MIDI.noteOff(0, 61, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 64, velocity, delay);
	MIDI.noteOff(0, 64, delay + duration);

	delay += gap;
	MIDI.noteOn(0, 56, velocity, delay);
	MIDI.noteOff(0, 56, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 61, velocity, delay);
	MIDI.noteOff(0, 61, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 64, velocity, delay);
	MIDI.noteOff(0, 64, delay + duration);

	delay += gap;
	MIDI.noteOn(0, 56, velocity, delay);
	MIDI.noteOff(0, 56, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 61, velocity, delay);
	MIDI.noteOff(0, 61, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 64, velocity, delay);
	MIDI.noteOff(0, 64, delay + duration);

	delay += gap;
	MIDI.noteOn(0, 56, velocity, delay);
	MIDI.noteOff(0, 56, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 61, velocity, delay);
	MIDI.noteOff(0, 61, delay + duration);
	delay += gap;
	MIDI.noteOn(0, 64, velocity, delay);
	MIDI.noteOff(0, 64, delay + duration);
}

Это только начало, но нетренированный человек уже может притомится считать тональности и временные альтерации. Да и простыни кода получаются длинные. А это всего лишь первый такт. Конечно, нацеленный на ООП глаз сразу найдет мишени для рефакторинга. Вместе с тем, задачу высчитывания звука можно смело переложить на javascript.

Опишем тональность. Тональность — термин расплывчатый. Но в нашем конкретном примере тональностью будет просто необходимая альтерация нот в этой тональности. Как вы помните, в тональности до-диез минор мы видим ноты C1, D1,..G7, а подставляем на их место C1♯, D1♯,… G7♯. Я просто обозначил сдвиг для каждой из этих нот (+1 или просто 1). 4 — количество диезов. Бемоли бы обозначались как -4. Знатоки поймут, что в данном узком случае разницы между параллельными тональностями до-диез минор и ми-мажор для нашей задачи — нет. Одинаковые диезы при тех же нотах.

Код для статьи не должен быть загроможденным, поэтому нет JSDoc, не было проверок JSHint и прочего, но есть комментарии, переведенные на русский.

var keys = {
    4 : {
        C : 1,
        D : 1,
        F : 1,
        G : 1
    }
};

Теперь создадим объект для проигрывания:

var player = {
    // длительность такта
    barDuration : 8,
    // шкала времени
    timeline : 0,
    // не очень важный параметр
    velocity : 127,
    // укажем тональность
    key : keys[4],
    // временные изменения тональности по тексту
    tempAlts : {},
    // параметры - нота как строка, длительность, надо ли сдвигать координату времени
    play : function(noteString, duration, moveTime) {
        // подсчет ноты будет ниже
        var noteInt = this.calcNote(noteString);

        MIDI.noteOn(0, noteInt, this.velocity, this.timeline);
        // звучание ноты заканчивается через длительность такта * длительность ноты в такте
        MIDI.noteOff(0, noteInt, this.velocity, this.timeline + this.barDuration * duration);

        if (typeof moveTime !== 'undefined' && moveTime === true) {
            this.move(duration);
        }
    },
    move : function(duration) {
        this.timeline += this.barDuration * duration;
        // в конце каждого такта временные диезы, бемоли и бекары отменяются.
        if (this.timeline % this.barDuration === 0) {
            this.tempAlts = {};}
    },
};

Теперь релизуем подсчет ноты и еще немного улучшим код:

var player = {
    barDuration : 8,
    timeline : 0,
    velocity : 127,
    key : keys[4],
    tempAlts : {},
    play : function(noteString, duration, moveTime) {
        var noteInt = this.calcNote(noteString);

        MIDI.noteOn(0, noteInt, this.velocity, this.timeline);
        MIDI.noteOff(0, noteInt, this.velocity, this.timeline + this.barDuration * duration);

        if (typeof moveTime !== 'undefined' && moveTime === true) {
            this.move(duration);
        }
    },
    move : function(duration) {
        this.timeline += this.barDuration * duration;
        if (this.isEndOfBar()) {
            this.tempAlts = {};}
    },
    calcNote : function(noteString) {
        var note = noteString[0];
        var noteWithOctave = noteString.substring(0,2);
        // есть ли временные знаки альтерации при ноте
        var altering = this.getAltering(noteString);
        // установим временные диезы, бемоли, бекары
        if (altering) {
            this.setTempAltering(noteWithOctave, altering);
        }
        // если временных альтераций нет - возвращаем номер ноты в MIDI + сдвиг по тональности
        if (this.tempAlts[noteWithOctave] !== undefined) {
            return MIDI.keyToNote[noteWithOctave] + this.tempAlts[noteWithOctave];
        }
        // если временные альтерации есть - возвращаем номер ноты в MIDI + сдвиг по временной альтерации
        // тональность здесь не учавствует
        return MIDI.keyToNote[noteWithOctave] +
                (this.key[note] !== undefined ? this.key[note] : 0);
    },
    isEndOfBar : function() {
        return !!(this.timeline % this.barDuration === 0)
    },
    // получить знак альтерации при ноте или false
    getAltering : function(noteString) {
        var altering = noteString[2];
        return altering !== undefined ? altering : false;
    },
    setTempAltering : function(noteWithOctave, altering) {
        switch (altering) {
            // знак бемоля при ноте временно понижает ноту на 1 полутон и так далее 
            case 'b': this.tempAlts[noteWithOctave] = -1; break;
            // бекар обозначил как "%"
            case '%': this.tempAlts[noteWithOctave] = 0;  break;
            case '#': this.tempAlts[noteWithOctave] = 1;  break;
        }
    }
}

Ну и сама нотная запись:

    player.play('C2', 1);
    player.play('C1', 1);
    player.play('G3', 1/12, true);
    player.play('C4', 1/12, true);
    player.play('E4', 1/12, true);
    player.play('G3', 1/12, true);
    player.play('C4', 1/12, true);
    player.play('E4', 1/12, true);
    player.play('G3', 1/12, true);
    player.play('C4', 1/12, true);
    player.play('E4', 1/12, true);
    player.play('G3', 1/12, true);
    player.play('C4', 1/12, true);
    player.play('E4', 1/12, true);
    ...

Получившийся результат можно услышать тут и увидеть здесь. Сделал для примера 7 тактов из 19.

UPD Поправил про октавы, нотацию и длительность согласно комментарию lair
Tags:
Hubs:
+25
Comments 17
Comments Comments 17

Articles