Создайте гитарный синтезатор: играйте музыкальные табулатуры на Python
Вы когда-нибудь хотели сочинять музыку без дорогостоящего оборудования и профессиональной студии? Возможно, вы раньше пробовали играть на музыкальном инструменте, но обнаружили, что ловкость рук требует слишком много усилий и времени. Если да, возможно, вас заинтересует использование возможностей Python для создания гитарного синтезатора. Выполнив несколько относительно простых шагов, вы сможете превратить свой компьютер в виртуальную гитару, способную играть любую песню.
В этом уроке вы:
- Реализовать алгоритм синтеза щипковых струн Karplus-Strong.
- Имитируйте различные типы струнных инструментов и их настройку.
- Объедините несколько вибрирующих струн в полифонические аккорды.
- Имитируйте реалистичную технику игры на гитаре и игры пальцами.
- Используйте импульсные характеристики реальных инструментов, чтобы воспроизвести их уникальный тембр.
- Чтение музыкальных нот из научной нотации и гитарных табулатур.
В любой момент вы можете загрузить полный исходный код гитарного синтезатора, а также образцы табулатур и другие ресурсы, которые вы будете использовать в этом руководстве. Они могут оказаться полезными, если вы хотите более подробно изучить код или начать работу. Чтобы скачать бонусные материалы прямо сейчас, перейдите по следующей ссылке:
Демо: гитарный синтезатор на Python
В этом пошаговом руководстве вы создадите синтезатор щипковых струнных инструментов на основе алгоритма Карплюса-Стронга на Python. Попутно вы создадите ансамбль виртуальных инструментов, в том числе акустическую, бас-гитару и электрогитару, а также банджо и гавайскую гитару. Затем вы создадите специальную программу чтения гитарных вкладок, чтобы можно было воспроизводить любимые песни.
К концу этого урока вы сможете синтезировать музыку из гитарных табулатур или, для краткости, гитарных табулатур, которые представляют собой упрощенную форму нотной записи, которая позволяет вам играть музыку без необходимости учиться читать стандартные ноты. Наконец, вы сохраните результат в файле MP3 для воспроизведения.
Ниже представлена короткая демонстрация работы синтезатора, воссоздающего культовые саундтреки из классических видеоигр, таких как Doom и Diablo. Нажмите кнопку воспроизведения, чтобы прослушать образец вывода:
Найдя понравившуюся гитарную табулатуру, вы можете подключить ее к гитарному синтезатору Python и оживить музыку. Например, веб-сайт Songsterr — это фантастический ресурс с широким выбором песен, из которых вы можете выбирать.
Обзор проекта
Для вашего удобства проект, который вы собираетесь создать, вместе с его сторонними зависимостями будет управляться Poetry. Проект будет содержать два пакета Python с совершенно разными сферами ответственности:
digitar
: Для синтеза звука цифровой гитары.табулатура
: для чтения и интерпретации гитарной табулатуры из файла.
Вы также разработаете и внедрите собственный формат данных для хранения гитарных табулатур на диске или в памяти. Это позволит вам воспроизводить музыку на основе достаточно стандартной табулатурной нотации, которую вы найдете в различных местах Интернета. Ваш проект также предоставит скрипт Python, который свяжет все вместе, что позволит вам интерпретировать вкладки с помощью одной команды прямо с вашего терминала.
Теперь вы можете углубиться в детали того, что вам понадобится для настройки среды разработки и начала кодирования.
Предварительные условия
Хотя вам не обязательно быть музыкантом, чтобы следовать этому руководству, базовое понимание музыкальных понятий, таких как ноты, полутона, октавы и аккорды, поможет вам быстрее усвоить информацию. Также было бы неплохо, если бы вы имели общее представление о том, как компьютеры представляют и обрабатывают цифровой звук с точки зрения частоты дискретизации, разрядности и форматов файлов, таких как WAV.
Но не волнуйтесь, если вы новичок в этих идеях! Вы будете проходить каждый шаг небольшими шагами с четкими объяснениями и примерами. Таким образом, даже если вы никогда раньше не занимались синтезом музыки, к концу этого урока у вас будет работающая цифровая гитара или цифровая.
Примечание. Вы можете изучить теорию музыки за полчаса, посмотрев отличное бесплатное видео Эндрю Хуанга.
Проект, который вы создадите, был протестирован на Python 3.12, но он должен нормально работать и в более ранних версиях Python, вплоть до Python 3.10. Если вам нужно быстро освежить знания, вот список полезных ресурсов, охватывающих наиболее важные функции языка, которыми вы воспользуетесь в своем путешествии по цифровой гитаре:
- Выражения присваивания
- Классы данных
- Перечисления
- Протоколы (статическая утиная типизация)
- Структурное сопоставление с образцом
- Введите подсказки
Помимо этого, вы будете использовать в своем проекте следующие сторонние пакеты Python:
- NumPy для упрощения и ускорения основного синтеза звука
- Педалборд для применения специальных эффектов, похожих на усилители электрогитары.
- Pydantic и PyYAML для анализа музыкальной табулатуры, представляющей движения пальцев на грифе гитары
Знакомство с ними определенно поможет, но вы также можете учиться по ходу дела и относиться к этому проекту как к возможности попрактиковаться и улучшить свои навыки Python.
Шаг 1: Настройте проект цифровой гитары
Первым шагом является подготовка среды разработки. Для начала вы создадите новый проект Python и установите необходимые сторонние библиотеки. Затем вы загрузите его в редактор, где продолжите писать необходимый код для вашего гитарного синтезатора.
Создайте новый проект и установите зависимости.
Существует множество способов создания проектов Python и управления ими. В этом уроке вы будете использовать Poetry как удобный инструмент для управления зависимостями. Если вы еще этого не сделали, установите Poetry — например, с помощью pipx — и начните новый проект, используя макет папки src/
, чтобы ваш код был организован:
$ poetry new --src --name digitar digital-guitar/
Created package digitar in digital-guitar
В результате появится структура папок, представленная ниже, которая включает файлы-заполнители с метаданными вашего проекта и исходным кодом, которые вы заполните позже:
digital-guitar/
│
├── src/
│ └── digitar/
│ └── __init__.py
│
├── tests/
│ └── __init__.py
│
├── pyproject.toml
└── README.md
Затем измените каталог на свой новый проект и добавьте несколько зависимостей, на которые вы будете полагаться позже:
$ cd digital-guitar/
$ poetry add numpy pedalboard pydantic pyyaml
После запуска этой команды Poetry создаст изолированную виртуальную среду в назначенном для вашего проекта месте и установит в нее перечисленные сторонние пакеты Python. Вы также должны увидеть новый файл poetry.lock
в корневой папке вашего проекта.
Теперь вы можете открыть папку digital-guitar/
в Python IDE или редакторе кода по вашему выбору. Если вы используете Visual Studio Code или PyCharm, обе программы обнаружат виртуальную среду, созданную Poetry. Последний также свяжет его с проектом, что позволит вам сразу получить доступ к установленным пакетам.
В VS Code вам может потребоваться вручную выбрать виртуальную среду, управляемую Poetry. Для этого откройте палитру команд, введите Python: Select Interpreter и выберите нужный интерпретатор. И наоборот, после открытия папки в PyCharm подтвердите запрос на настройку среды Poetry. Соответствующий интерпретатор Python появится в правом нижнем углу окна.
В качестве альтернативы, если вы заядлый пользователь Vim или Sublime Text, вы можете продолжать использовать Poetry в командной строке:
$ poetry install
$ poetry run play-tab demo/tabs/doom.yaml
Saved file /home/user/digital-guitar/doom.mp3
Первая команда установит ваш проект вместе с его зависимостями, определенными в файле pyproject.toml
. Вторая команда, которую вы реализуете позже, выполнит сценарий из связанной виртуальной среды, управляемой Poetry. Обратите внимание, что вы все равно будете использовать эти команды, независимо от того, какой редактор кода вы выберете.
Используйте неизменяемые типы данных в своем проекте
За некоторыми исключениями, в этом проекте вы будете определять неизменяемые типы данных почти исключительно. Неизменяемые объекты — это те, которые вы не можете изменить после их создания. Хотя на первый взгляд это может показаться ограничивающим фактором, на самом деле это дает массу преимуществ. Поэтому перед началом работы рекомендуется ознакомиться с концепцией неизменяемости и ее влиянием на поведение вашей программы.
Прежде всего, большинство неизменяемых объектов в Python являются хэшируемыми, что делает их действительными ключами словаря. Позже это станет важным для кэширования значений аргументов, чтобы избежать повторных вычислений. В конечном итоге это поможет вам сократить общее время, необходимое для синтеза звука.
Помимо этого, вы можете безопасно использовать неизменяемые объекты в качестве значений аргументов по умолчанию, не беспокоясь о непредвиденных побочных эффектах. Напротив, изменяемые аргументы по умолчанию — одна из наиболее распространенных ошибок в Python, которая может привести к неожиданным и трудно отслеживаемым ошибкам. Придерживаясь неизменяемых типов там, где это возможно, вы избавите себя от многих головных болей.
Кроме того, неизменяемые объекты можно рассматривать как простые значения, например целые числа или строки. Когда вы присваиваете неизменяемую переменную другой переменной, присваивание связывает обе ссылки с одним и тем же объектом в памяти. Но как только вы попытаетесь изменить состояние вашего неизменяемого объекта с помощью одной из этих переменных, вы создадите копию этого объекта, оставив исходную нетронутой. Таким образом, ваш код становится более предсказуемым и устойчивым.
Неизменяемые объекты также являются потокобезопасными и упрощают анализ вашего кода. Эти особенности делают их особенно подходящими для парадигмы функционального программирования, но вы также сможете воспользоваться их преимуществами и в объектно-ориентированной области.
Теперь пришло время применить эту теорию на практике, реализовав свой первый неизменяемый тип данных для этого проекта гитарного синтезатора.
Представляйте моменты времени, длительность и интервалы
Музыка — это эфемерная форма искусства, которую можно оценить только в течение короткого периода времени, когда ее играют или исполняют. Поскольку музыка по своей сути существует во времени, очень важно, чтобы вы могли правильно представлять моменты времени, длительность и интервалы, если вы хотите построить мощный синтезатор.
Тип данных Python float
недостаточно точен для музыкальной синхронизации из-за ошибок представления и округления, заложенных в стандарте IEEE 754. Если вам нужна большая точность, в Python рекомендуется заменять числа с плавающей запятой типом данных Decimal
или Fraction
. Однако непосредственное использование этих типов может оказаться затруднительным, и они не несут необходимой информации о задействованных единицах времени.
Чтобы избежать этих неприятностей, вы реализуете несколько пользовательских классов, начиная с универсального типа данных Time
. Создайте новый модуль Python с именем temporal
внутри вашего пакета digitar
и определите в нем следующий класс данных:
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self
type Numeric = int | float | Decimal | Fraction
@dataclass(frozen=True)
class Time:
seconds: Decimal
@classmethod
def from_milliseconds(cls, milliseconds: Numeric) -> Self:
return cls(Decimal(str(float(milliseconds))) / 1000)
Этот класс имеет только один атрибут, представляющий количество секунд в виде объекта Decimal
для повышения точности. Вы можете создать экземпляры нового класса, предоставив секунды через его конструктор или вызвав метод класса, который ожидает миллисекунды и преобразует их в секунды, завернутые в соответствующий тип данных.
Примечание. Начиная с Python 3.12, вам следует использовать оператор type
для создания явного псевдонима типа. В предыдущих выпусках Python вы можете вернуться к устаревшей аннотации TypeAlias
:
from typing import TypeAlias
Numeric: TypeAlias = int | float | Decimal | Fraction
В качестве альтернативы, если вы используете еще более раннюю версию Python, просто используйте простой оператор присваивания без каких-либо ключевых слов или аннотаций.
Из-за динамической природы Python конструктор по умолчанию, созданный интерпретатором для вашего класса данных, не будет применять подсказки типов, которыми вы аннотировали свои атрибуты. Другими словами, интерпретатор не будет проверять, относятся ли предоставленные значения к ожидаемым типам. Итак, в этом случае, если вы передадите целое число или число с плавающей запятой вместо объекта Decimal
, вы случайно создадите экземпляр с неверным типом атрибута.
К счастью, вы можете предотвратить эту проблему, реализовав в классе собственный метод инициализации, который заменит тот, который Python генерирует по умолчанию:
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self
type Numeric = int | float | Decimal | Fraction
@dataclass(frozen=True)
class Time:
seconds: Decimal
@classmethod
def from_milliseconds(cls, milliseconds: Numeric) -> Self:
return cls(Decimal(str(float(milliseconds))) / 1000)
def __init__(self, seconds: Numeric) -> None:
match seconds:
case int() | float():
object.__setattr__(self, "seconds", Decimal(str(seconds)))
case Decimal():
object.__setattr__(self, "seconds", seconds)
case Fraction():
object.__setattr__(
self, "seconds", Decimal(str(float(seconds)))
)
case _:
raise TypeError(
f"unsupported type '{type(seconds).__name__}'"
)
Вы используете сопоставление структурных шаблонов, чтобы определить тип аргумента, передаваемого вашему методу во время выполнения, и соответствующим образом разветвиться. Затем убедитесь, что атрибут экземпляра .секунды
всегда имеет значение объекта Decimal
, независимо от типа входных данных. Если вы передадите экземпляр Decimal
своему конструктору, вам больше нечего делать. В противном случае вы используете соответствующее преобразование или вызываете исключение, сигнализирующее о неправильном использовании конструктора.
Поскольку вы определили замороженный класс данных, который делает его экземпляры неизменяемыми, вы не можете установить значение атрибута напрямую или вызвать встроенную функцию setattr()
для существующего класса данных. объект. Это нарушит контракт неизменяемости. Если вам когда-нибудь понадобится принудительно изменить состояние экземпляра замороженного класса данных, вы можете прибегнуть к хаку, явно вызвав object.__setattr__()
, как в приведенном выше фрагменте кода.
Возможно, вы помните, что классы данных поддерживают специальный метод именно для такого рода настройки. Однако преимущество перезаписи метода инициализации по умолчанию вместо реализации .__post_init__()
заключается в том, что вы получаете полный контроль над процессом создания объекта. В результате объект может либо существовать и находиться в допустимом состоянии, либо не существовать вообще.
Примечание. Если создание недопустимого объекта практически невозможно, можно сказать, что ваш код соответствует философии отказоустойчивости.
Наконец, вы можете реализовать удобный метод, который вы будете использовать позже для перевода длительности в секундах в соответствующее количество аудиосэмплов:
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from typing import Self
type Numeric = int | float | Decimal | Fraction
type Hertz = int | float
@dataclass(frozen=True)
class Time:
# ...
def get_num_samples(self, sampling_rate: Hertz) -> int:
return round(self.seconds * round(sampling_rate))
Этот метод принимает в качестве аргумента частоту выборки в герцах (Гц), который представляет собой количество выборок в секунду. Умножение продолжительности в секундах на частоту дискретизации в герцах дает необходимое количество выборок, которое можно округлить, чтобы получить целое число.
Вот короткий сеанс Python REPL, демонстрирующий, как вы можете использовать преимущества своего нового класса данных:
>>> from digitar.temporal import Time
>>> Time(seconds=0.15)
Time(seconds=Decimal('0.15'))
>>> Time.from_milliseconds(2)
Time(seconds=Decimal('0.002'))
>>> _.get_num_samples(sampling_rate=44100)
88
Символ подчеркивания (_
) в REPL — это неявная переменная, содержащая значение последнего вычисленного выражения. В данном случае это относится к вашему экземпляру Time
, представляющему две миллисекунды.
Имея класс Time
, вы готовы перейти к следующему шагу. Вы погрузитесь в физику вибрирующей струны и увидите, как она издает звук.
Шаг 2. Смоделируйте акустическую волну вибрирующей струны
В конце концов, каждый звук, который вы слышите, — это локальное возмущение давления воздуха, создаваемое вибрирующим объектом. Будь то ваши голосовые связки, гитарная струна или громкоговоритель, эти вибрации толкают и притягивают молекулы воздуха вокруг них. Затем это движение распространяется по воздуху как акустическая волна, пока не достигает барабанной перепонки, которая вибрирует в ответ.
На этом этапе вы поближе познакомитесь с алгоритмом синтеза Karplus-Strong, который моделирует вибрацию натянутой струны. Затем вы реализуете его на Python с помощью NumPy и создадите свой первый синтетический звук, напоминающий звук перещипанной струны.
Познакомьтесь с алгоритмом Карплюса-Сильного
Алгоритм Karplus-Strong на удивление прост, учитывая сложные звуки, которые он может воспроизводить. Короче говоря, все начинается с заполнения очень короткого буфера пакетом случайного шума или другим сигналом, имеющим богатую энергию или множество частотных составляющих. Этот шум соответствует возбуждению реальной струны, которая первоначально колеблется в нескольких бессвязных движениях.
Эти, казалось бы, случайные вибрации постепенно становятся все более и более синусоидальными, с отчетливым синусоидальным периодом и частотой, которые вы воспринимаете как характерную высоту звука. Хотя амплитуды всех вибраций со временем ослабевают из-за диссипации энергии, вызванной внутренним трением и переносом энергии, определенная основная частота остается сильнее, чем большинство обертонов и гармоник, которые затухают быстрее.
Примечание. Физические свойства струны, включая ее длину и массу, отдают предпочтение определенным видам вибрации по сравнению с другими. Это то, что по своей сути определяет конкретную частоту, которая сохраняется и резонирует наиболее заметно.
В гитаре вы можете укоротить струну, прижав ее к одному из ладов грифа гитары, чтобы получить более высокий звук. И наоборот, некоторые струны толще и тяжелее других, вибрируют медленнее и приводят к более низкочастотным звукам.
Кроме того, бридж и порожок гитары являются опорными точками, которые отражают энергию, перемещающуюся вперед и назад по струне. Эти отражения мешают волнам, идущим в противоположных направлениях, способствуя образованию стоячих волн, которые являются основой музыкальных нот, которые вы слышите.
Посмотрите замедленную запись натянутой струны, чтобы увидеть этот феномен в действии.
Алгоритм Карплюса-Стронга применяет к сигналу фильтр нижних частот для имитации затухания более высоких частот с большей скоростью, чем основная частота. Он делает это путем вычисления скользящего среднего двух последовательных уровней амплитуды в буфере, эффективно действуя как простой сверточный фильтр. Он устраняет краткосрочные колебания, оставляя при этом долгосрочный тренд.
Кроме того, алгоритм передает усредненные значения обратно в буфер, чтобы усилить и продолжить вибрацию, хотя и с постепенной потерей энергии. Взгляните на диаграмму ниже, чтобы лучше понять, как работает этот цикл положительной обратной связи:
Генератор слева служит входными данными для алгоритма, обеспечивая первоначальный всплеск шума. Обычно это белый шум с равномерным распределением вероятностей, так что в среднем ни одна конкретная частота не выделяется по сравнению с другой. Аналогия аналогична белому свету, который содержит все частоты видимого спектра примерно одинаковой интенсивности.
Генератор отключается после заполнения кольцевого буфера, также известного как линия задержки, которая задерживает сигнал на определенное время перед подачей его обратно в контур. Сдвинутый по фазе сигнал из прошлого затем смешивается с текущим сигналом. Думайте об этом как об отражении волны, распространяющейся по струне в противоположном направлении.
Величина задержки определяет частоту вибрации виртуальной струны. Как и в случае с длиной струны гитары, более короткая задержка приводит к более высокому звучанию, а более длинная задержка — к более низкому звучанию. Вы можете рассчитать необходимый размер буфера (в пересчете на количество аудиосэмплов) по следующей формуле:
Чтобы получить количество выборок, D, умножьте период вибрации или обратную величину желаемой основной частоты, F0 по частоте дискретизации вашего сигнала, Fs. Проще говоря, разделите частоту дискретизации на основную частоту.
Затем задержанный сигнал проходит через фильтр нижних частот перед добавлением к следующей выборке из буфера. Вы можете реализовать как фильтр, так и сумматор, применив средневзвешенное значение к обеим выборкам, если сумма их весов равна единице или меньше. В противном случае вы будете усиливать сигнал, а не ослаблять его. Регулируя веса, вы можете контролировать затухание или затухание вибрации вашей виртуальной струны.
По мере того, как обработанный сигнал проходит через буфер, он теряет больше высокочастотного содержания и приобретает структуру, очень напоминающую звук натянутой струны. Благодаря петле обратной связи создается иллюзия вибрирующей струны, которая постепенно затухает.
Наконец, в дальнем правом углу диаграммы вы можете увидеть выход, который может быть динамиком или аудиофайлом, в который вы записываете полученные аудиосэмплы.
Когда вы строите графики сигналов и соответствующих им частотных спектров из последовательных циклов петли обратной связи, вы заметите следующую картину:
Верхний график показывает колебания амплитуды во времени. График чуть ниже отображает частотный состав сигнала в определенные моменты. Первоначально буфер заполняется случайными выборками, распределение частот которых примерно одинаково по всему спектру. С течением времени амплитуда сигнала уменьшается, а частота колебаний начинает концентрироваться в определенном спектральном диапазоне. Форма сигнала теперь напоминает синусоидальную волну.
Поскольку теперь вы понимаете принципы алгоритма Карплюса-Стронга, вы можете реализовать первый элемент диаграммы, показанной ранее.
Используйте случайные значения в качестве начального шума
Существует множество типов генераторов сигналов, которые вы можете использовать при синтезе звука. Некоторые из наиболее популярных из них включают периодические функции, такие как прямоугольная волна, треугольная волна и пилообразная волна. Однако в алгоритме синтеза Карплюса-Стронга наилучшие результаты вы получите с помощью апериодической функции, такой как случайный шум, благодаря ее богатому гармоническому содержанию, которое вы можете фильтровать с течением времени.
Шум бывает разных цветов, например, розовый или белый. Разница заключается в их спектральной плотности мощности на разных частотах. Например, в белом шуме каждая полоса частот имеет примерно одинаковую мощность. Таким образом, он идеально подходит для начального шумового всплеска, поскольку содержит широкий диапазон гармоник, которые можно формировать с помощью фильтра.
Чтобы иметь возможность экспериментировать с различными типами генераторов сигналов, вы определите собственный класс протокола в новом модуле Python с именем burst
:
from typing import Protocol
import numpy as np
from digitar.temporal import Hertz
class BurstGenerator(Protocol):
def __call__(self, num_samples: int, sampling_rate: Hertz) -> np.ndarray:
...
Цель класса протокола — указать желаемое поведение через сигнатуры методов без реализации этих методов. В Python вы обычно используете многоточие (...
), чтобы указать, что вы намеренно оставили тело метода неопределенным. Таким образом, класс протокола действует как интерфейс в Java, где конкретные классы, реализующие этот конкретный интерфейс, обеспечивают базовую логику.
Примечание. В Python вам понадобится средство проверки статического типа, например mypy, для обеспечения соблюдения протокола конкретными классами.
В этом случае вы объявили специальный метод .__call__()
, чтобы сделать экземпляры классов, соответствующих протоколу, вызываемыми. Ваш метод ожидает два аргумента:
- Количество аудиосэмплов для создания
- Количество выборок в секунду
Кроме того, генераторы пакетов предназначены для возврата массива NumPy с уровнями амплитуды, которые должны представлять собой числа с плавающей запятой, нормализованные на интервал между минус один и плюс один. Такая нормализация сделает последующую обработку звука более удобной.
Ваш первый конкретный класс-генератор будет генерировать белый шум, поскольку вы уже установили, что он наиболее подходит в данном контексте:
# ...
class WhiteNoise:
def __call__(self, num_samples: int, sampling_rate: Hertz) -> np.ndarray:
return np.random.uniform(-1.0, 1.0, num_samples)
Несмотря на то, что ваш новый класс не наследуется от BurstGenerator
, он по-прежнему соответствует протоколу, который вы определили ранее, предоставляя метод .__call__()
с правильной сигнатурой. Обратите внимание, что метод принимает частоту дискретизации в качестве второго аргумента, несмотря на то, что он не ссылается на нее где-либо в теле. Это необходимо для выполнения протокола.
Примечание. Вам необходимо знать частоту дискретизации для других типов генераторов пакетов, таких как периодические функции, упомянутые в начале этого раздела, которые вы, возможно, захотите реализовать в будущем.
Экземпляры вашего класса генератора WhiteNoise
теперь можно вызывать:
>>> from digitar.burst import WhiteNoise
>>> burst_generator = WhiteNoise()
>>> samples = burst_generator(num_samples=1_000_000, sampling_rate=44100)
>>> samples.min()
-0.9999988055552775
>>> samples.max()
0.999999948864092
>>> samples.mean()
-0.0001278112173601203
Полученные выборки ограничены диапазоном от -1 до 1, поскольку минимальное и максимальное значения очень близки к этим границам. Кроме того, среднее значение близко к нулю, поскольку в большом количестве выборок положительные и отрицательные амплитуды уравновешивают друг друга, подтверждая равномерное распределение значений.
Хорошо. Следующим важным компонентом схемы алгоритма Карплюса-Стронга является сама петля обратной связи. Сейчас вы разобьете его на более мелкие части.
Фильтрация более высоких частот с помощью петли обратной связи
Элегантный способ смоделировать цикл обратной связи в Python предполагает объединение функций генератора и отправку им значений. Вы также можете определить асинхронные функции и подключить их как совместные сопрограммы для достижения аналогичного эффекта. Однако в этом руководстве вы будете использовать гораздо более простую и немного более эффективную реализацию, основанную на итерации.
Создайте еще один модуль с именем synthesis
в вашем пакете Python и определите следующий заполнитель класса:
from dataclasses import dataclass
from digitar.burst import BurstGenerator, WhiteNoise
AUDIO_CD_SAMPLING_RATE = 44100
@dataclass(frozen=True)
class Synthesizer:
burst_generator: BurstGenerator = WhiteNoise()
sampling_rate: int = AUDIO_CD_SAMPLING_RATE
Этот класс замороженных данных состоит из двух дополнительных атрибутов, которые позволяют указать ожидаемую реализацию генератора пакетов и частоту дискретизации. Если вы пропустите эти параметры при создании нового экземпляра класса, вы будете полагаться на значения по умолчанию, которые используют генератор белого шума с частотой дискретизации 44,1 кГц, определенной как константа Python.
Примечание. Частота дискретизации 44,1 кГц является распространенным выбором для аудиоприложений, таких как аудио компакт-диски. Это означает, что каждую секунду записывается 44 100 образцов аудиоданных. Это соответствует частоте Найквиста чуть выше частот, слышимых человеческим ухом, что обеспечивает точное воспроизведение без искажений.
Используя пакет стандартной библиотеки itertools
, теперь вы можете реализовать бесконечный итератор, который будет циклически()
перебирать буфер аудиосэмплов. Следующий фрагмент кода отражает диаграмму Карплюса-Стронга, которую вы видели в предыдущем разделе:
from dataclasses import dataclass
from itertools import cycle
from typing import Iterator
import numpy as np
from digitar.burst import BurstGenerator, WhiteNoise
from digitar.temporal import Hertz, Time
AUDIO_CD_SAMPLING_RATE = 44100
@dataclass(frozen=True)
class Synthesizer:
burst_generator: BurstGenerator = WhiteNoise()
sampling_rate: int = AUDIO_CD_SAMPLING_RATE
def vibrate(
self, frequency: Hertz, duration: Time, damping: float = 0.5
) -> np.ndarray:
assert 0 < damping <= 0.5
def feedback_loop() -> Iterator[float]:
buffer = self.burst_generator(
num_samples=round(self.sampling_rate / frequency),
sampling_rate=self.sampling_rate
)
for i in cycle(range(buffer.size)):
yield (current_sample := buffer[i])
next_sample = buffer[(i + 1) % buffer.size]
buffer[i] = (current_sample + next_sample) * damping
Вы определяете метод .vibrate()
, который принимает основную частоту вибрации, длительность и необязательный коэффициент демпфирования. в качестве аргументов. Если не указать значение коэффициента по умолчанию, сумма двух соседних выборок уменьшается вдвое с каждым циклом, что аналогично вычислению скользящего среднего. Он имитирует потерю энергии по мере затухания вибрации.
Примечание. Чтобы предотвратить передачу неправильного коэффициента демпфирования, из-за которого алгоритм будет усиливать сэмплы вместо их демпфирования, вы поместили оператор assert
с соответствующим условием в начале начало вашего метода. Он будет отклонять значения, выходящие за пределы ожидаемого диапазона, хотя вам следует полагаться на такие утверждения только во время разработки. Есть более эффективные способы реализовать инварианты в вашем коде, которые вы будете использовать позже.
Пока что ваш метод определяет внутреннюю функцию, которая при вызове возвращает итератор-генератор. Результирующий объект-генератор выделяет и заполняет буфер, используя предоставленный пакетный генератор. Затем функция входит в бесконечный цикл for
, который продолжает бесконечно выдавать значения из буфера в циклическом режиме, поскольку у него нет условий остановки.
Вы используете оператор Вальжа (:=
) для одновременного получения и перехвата текущего значения амплитуды в каждом цикле. На следующей итерации вы вычисляете среднее значение двух соседних значений, чтобы имитировать эффект демпфирования. Оператор по модулю (%
) гарантирует, что индекс перейдет к началу буфера, как только он достигнет конца, создавая эффект циклического буфера.
Чтобы использовать конечное количество выборок, определяемое параметром duration
, вы можете обернуть свой feedback_loop()
вызовом функции fromiter()
NumPy. :
# ...
@dataclass(frozen=True)
class Synthesizer:
# ...
def vibrate(
self, frequency: Hertz, duration: Time, damping: float = 0.5
) -> np.ndarray:
assert 0 < damping <= 0.5
def feedback_loop() -> Iterator[float]:
buffer = self.burst_generator(
num_samples=round(self.sampling_rate / frequency),
sampling_rate=self.sampling_rate
)
for i in cycle(range(buffer.size)):
yield (current_sample := buffer[i])
next_sample = buffer[(i + 1) % buffer.size]
buffer[i] = (current_sample + next_sample) * damping
return np.fromiter(
feedback_loop(),
np.float64,
duration.get_num_samples(self.sampling_rate),
)
Поскольку параметр duration
является экземпляром класса данных Time
, который вы определили ранее, вы можете преобразовать количество секунд в соответствующее количество аудиосэмплов, вызвав <.get_num_samples(). Просто не забудьте указать правильную частоту дискретизации. Вам также следует указать float64
в качестве типа данных для элементов вашего массива NumPy, чтобы обеспечить высокую точность и избежать ненужных преобразований типов.
Примечание. Вы хотите использовать преимущества числового типа высокой точности во время обработки звука. Однако результирующие аудиосэмплы обычно кодируются более эффективным с точки зрения использования памяти типом, например 16-битным целым числом со знаком.
Вы почти завершили реализацию алгоритма синтеза Карплюса-Стронга, но в вашем коде есть две незначительные проблемы, которые необходимо устранить в первую очередь.
Удалите смещение постоянного тока и нормализуйте аудиосэмплы
В зависимости от начального импульса и коэффициента затухания вы можете получить значения, выходящие за пределы ожидаемого диапазона амплитуд, или значения могут отклоняться от нуля, создавая смещение постоянного тока. Это может привести к слышимым щелчкам или другим неприятным артефактам. Чтобы устранить эти потенциальные проблемы, вы устраните смещение, вычитая среднее значение сигнала, а затем нормализуете полученные выборки.
NumPy не предоставляет встроенных функций для этих задач, но создать свои собственные не так уж и сложно. Начните с создания в вашем пакете нового модуля с именем processing
со следующими двумя функциями:
import numpy as np
def remove_dc(samples: np.ndarray) -> np.ndarray:
return samples - samples.mean()
def normalize(samples: np.ndarray) -> np.ndarray:
return samples / np.abs(samples).max()
Обе функции используют возможности векторизации NumPy. Первый вычитает среднее значение из каждого элемента, а второй делит все выборки на максимальное абсолютное значение во входном массиве.
Теперь вы можете импортировать и вызывать вспомогательные функции в синтезаторе перед возвратом массива вычисленных аудиосэмплов:
from dataclasses import dataclass
from itertools import cycle
from typing import Iterator
import numpy as np
from digitar.burst import BurstGenerator, WhiteNoise
from digitar.processing import normalize, remove_dc
from digitar.temporal import Hertz, Time
AUDIO_CD_SAMPLING_RATE = 44100
@dataclass(frozen=True)
class Synthesizer:
# ...
def vibrate(
self, frequency: Hertz, duration: Time, damping: float = 0.5
) -> np.ndarray:
# ...
return normalize(
remove_dc(
np.fromiter(
feedback_loop(),
np.float64,
duration.get_num_samples(self.sampling_rate),
)
)
)
Хотя порядок может не иметь существенного значения, перед выполнением нормализации принято удалять смещение постоянного тока. Удаление компонента постоянного тока гарантирует, что ваш сигнал будет сосредоточен вокруг нуля. В противном случае у него все еще может быть постоянная составляющая, которая может повлиять на общий масштаб нормализации.
Большой! Вы только что реализовали алгоритм синтеза Карплюса-Стронга на Python. Почему бы не проверить это и не услышать результаты?
Перебирайте струну, чтобы получить монофонические звуки.
Строго говоря, ваш синтезатор возвращает массив NumPy нормализованных уровней амплитуды вместо аудиосэмплов, непосредственно соответствующих цифровому звуку. В то же время вы можете выбирать из нескольких форматов данных, схем сжатия и кодировок, чтобы определить, как хранить и передавать ваши аудиоданные.
Например, линейная импульсно-кодовая модуляция (LPCM) — это стандартное кодирование несжатых файлов WAV, в котором для представления аудиосэмплов обычно используются 16-битные целые числа со знаком. Другие форматы, такие как MP3, используют алгоритмы сжатия с потерями, которые уменьшают размер файла за счет удаления информации, менее воспринимаемой человеческим ухом. Эти форматы могут предлагать постоянный или переменный битрейт в зависимости от желаемого качества и размера файла.
Чтобы не увязнуть в технических тонкостях, вы воспользуетесь библиотекой Spotify Pedalboard, которая может обрабатывать за вас эти низкоуровневые детали. Вы предоставите нормализованные уровни амплитуды от вашего синтезатора, а Pedalboard закодирует их соответствующим образом в зависимости от предпочитаемого вами формата данных:
>>> from pedalboard.io import AudioFile
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time
>>> frequencies = [261.63, 293.66, 329.63, 349.23, 392, 440, 493.88, 523.25]
>>> duration = Time(seconds=0.5)
>>> damping = 0.495
>>> synthesizer = Synthesizer()
>>> with AudioFile("monophonic.mp3", "w", synthesizer.sampling_rate) as file:
... for frequency in frequencies:
... file.write(synthesizer.vibrate(frequency, duration, damping))
В этом случае вы сохраняете синтезированные звуки в виде файла MP3, используя параметры библиотеки по умолчанию. Приведенный выше фрагмент кода создает файл MP3 с моноканалом, частотой дискретизации 44,1 кГц и постоянным битрейтом 320 килобит в секунду< — наивысшее качество, поддерживаемое этим форматом. Не забудьте запустить код из виртуальной среды вашего проекта, чтобы получить доступ к необходимым модулям.
Чтобы подтвердить некоторые из этих свойств звука, вы можете открыть файл для чтения и проверить некоторые его атрибуты:
>>> with AudioFile("monophonic.mp3") as file:
... print(f"{file.num_channels = }")
... print(f"{file.samplerate = }")
... print(f"{file.file_dtype = }")
...
file.num_channels = 1
file.samplerate = 44100
file.file_dtype = 'float32'
Поскольку файлы MP3 сжаты, вы не можете рассчитать их битрейт по этим параметрам. Фактический битрейт хранится в заголовке файла вместе с другими метаданными, которые вы можете проверить с помощью внешней программы, такой как MediaInfo:
$ mediainfo monophonic.mp3
General
Complete name : monophonic.mp3
Format : MPEG Audio
File size : 159 KiB
Duration : 4 s 48 ms
Overall bit rate mode : Constant
Overall bit rate : 320 kb/s
Writing library : LAME3.100
(...)
Сгенерированный файл содержит серию музыкальных тонов на основе заданных вами частот. Каждый тон длится полсекунды, в результате чего получается мелодия, развивающаяся по нотам do-re-mi-fa-sol-la-ti-do. Эти тона представляют собой ноты сольфеджио, часто используемые для обучения музыкальной гамме. Ниже показано, как они выглядят в виде сигнала. Вы можете нажать кнопку воспроизведения, чтобы прослушать:
Обратите внимание, что каждый тон резко прекращается, прежде чем появляется возможность полностью затихнуть. Вы можете поэкспериментировать с большей или меньшей длительностью и настроить параметр демпфирование. Но, как бы вы ни старались, вы сможете воспроизводить только монофонические звуки без возможности наложения нескольких нот.
В следующем разделе вы узнаете, как синтезировать более сложные звуки, становясь на шаг ближе к имитации полноценной гитары.
Шаг 3. Имитация игры на нескольких гитарных струнах
На этом этапе вы можете создавать аудиофайлы, состоящие из монофонических звуков. Это означает, что как только начинает воспроизводиться следующий звук, предыдущий прекращается, в результате чего воспроизводится серия дискретных тонов. Это нормально для старых мелодий мобильного телефона или саундтреков к ретро-видеоиграм. Однако, когда гитарист играет на нескольких струнах одновременно, он создает аккорд с нотами, которые резонируют вместе.
В этом разделе вы настроите свой класс синтезатора для создания полифонических звуков, позволяя отдельным нотам перекрываться и мешать друг другу.
Смешайте несколько нот в полифонический звук
Чтобы одновременно сыграть несколько нот, вы можете микшировать соответствующие акустические волны. Идите дальше и определите еще один метод в вашем классе синтезатора, который будет отвечать за наложение сэмплов из нескольких звуков друг на друга:
from dataclasses import dataclass
from itertools import cycle
from typing import Iterator, Sequence
# ...
@dataclass(frozen=True)
class Synthesizer:
# ...
def overlay(self, sounds: Sequence[np.ndarray]) -> np.ndarray:
return np.sum(sounds, axis=0)
Этот метод использует для микширования последовательность массивов NumPy одинакового размера, содержащих амплитуды нескольких звуков. Затем метод возвращает поэлементную арифметическую сумму входных звуковых волн.
Примечание. Хотя обычно вам следует сохранять гибкость, позволяя вашим функциям и методам принимать любые итерации в качестве входных аргументов, в этом случае NumPy ожидает, что итерация будет иметь фиксированный размер.
Предполагая, что вы уже устранили смещение постоянного тока из отдельных звуков, которые хотите микшировать, вам больше не нужно об этом беспокоиться. Кроме того, вы не хотите нормализовать наложенные звуки на этом этапе, поскольку их количество может сильно различаться в пределах одной песни. Если сделать это сейчас, это может привести к нестабильному уровню громкости, из-за чего некоторые музыкальные аккорды будут едва слышны. Вместо этого вы должны применить нормализацию перед записью всей песни в файл.
Предположим, вы хотите сымитировать, как исполнитель одновременно перебирает все струны гитары. Вот как это можно сделать, используя новый метод:
>>> from pedalboard.io import AudioFile
>>> from digitar.processing import normalize
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time
>>> frequencies = [329.63, 246.94, 196.00, 146.83, 110.00, 82.41]
>>> duration = Time(seconds=3.5)
>>> damping = 0.499
>>> synthesizer = Synthesizer()
>>> sounds = [
... synthesizer.vibrate(frequency, duration, damping)
... for frequency in frequencies
... ]
>>> with AudioFile("polyphonic.mp3", "w", synthesizer.sampling_rate) as file:
... file.write(normalize(synthesizer.overlay(sounds)))
Вы определяете частоты, соответствующие стандартной настройке шестиструнной гитары, и устанавливаете длительность отдельной ноты в три с половиной секунды. Кроме того, вы настраиваете коэффициент демпфирования на немного большее значение, чем раньше, чтобы заставить его вибрировать дольше. Затем вы синтезируете звук каждой строки в виде списка и объединяете их с помощью метода .overlay()
.
Это будет результирующая форма аудиофайла, который вы создадите после запуска кода, указанного выше:
Это, несомненно, улучшение по сравнению с монофонической версией. Однако синтезированный файл все равно звучит немного искусственно при воспроизведении. Это потому, что на настоящей гитаре струны никогда не перебираются точно в один и тот же момент. Между перебором каждой струны всегда есть небольшая задержка. Возникающие в результате волновые взаимодействия создают сложные резонансы, добавляя звучанию богатства и аутентичности.
Далее вы введете регулируемую задержку между последующими ударами, чтобы придать полифоническому звуку более реалистичный вид. В результате вы сможете определить направление удара!
Отрегулируйте скорость хода, чтобы контролировать ритм.
Когда вы быстро ударяете по струнам гитары, задержка между последовательными ударами становится относительно короткой, что делает общий звук громким и резким. И наоборот, задержка увеличивается по мере того, как вы перебираете струны медленнее и мягче. Вы можете довести эту технику до крайности, сыграв арпеджио или сломанный аккорд, играя ноты одну за другой, а не одновременно.
Теперь измените свой метод .overlay()
так, чтобы он принимал дополнительный параметр delay
, представляющий интервал времени между каждым штрихом:
# ...
@dataclass(frozen=True)
class Synthesizer:
# ...
def overlay(
self, sounds: Sequence[np.ndarray], delay: Time
) -> np.ndarray:
num_delay_samples = delay.get_num_samples(self.sampling_rate)
num_samples = max(
i * num_delay_samples + sound.size
for i, sound in enumerate(sounds)
)
samples = np.zeros(num_samples, dtype=np.float64)
for i, sound in enumerate(sounds):
offset = i * num_delay_samples
samples[offset : offset + sound.size] += sound
return samples
На основе текущей частоты дискретизации вашего синтезатора вы конвертируете задержку в секундах в соответствующее количество сэмплов. Затем вы определяете общее количество выборок, которые необходимо выделить для результирующего массива, который инициализируется нулями. Наконец, вы перебираете звуки, добавляя их в массив сэмплов с соответствующим смещением.
Примечание. Эта модифицированная версия .overlay()
позволяет смешивать звуки переменной длины, массивы которых больше не обязательно должны быть одинакового размера. Это может быть особенно удобно, если вы хотите, чтобы одна или несколько струн звучали дольше остальных, создавая интересную текстуру.
Вот тот же пример, который вы видели в предыдущем разделе. Однако теперь у вас есть задержка в сорок миллисекунд между отдельными нажатиями, и вы меняете продолжительность вибрации в зависимости от ее частоты:
>>> from pedalboard.io import AudioFile
>>> from digitar.processing import normalize
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time
>>> frequencies = [329.63, 246.94, 196.00, 146.83, 110.00, 82.41]
>>> delay = Time.from_milliseconds(40)
>>> damping = 0.499
>>> synthesizer = Synthesizer()
>>> sounds = [
... synthesizer.vibrate(frequency, Time(3.5 + 0.25 * i), damping)
... for i, frequency in enumerate(frequencies)
... ]
>>> with AudioFile("arpeggio.mp3", "w", synthesizer.sampling_rate) as file:
... file.write(normalize(synthesizer.overlay(sounds, delay)))
Ноты с более низкой частотой будут иметь немного большую продолжительность, чем их более высокочастотные аналоги. Это имитирует инерцию настоящих струн, которые имеют тенденцию вибрировать дольше, если они толще или длиннее.
Ниже приведена соответствующая форма сигнала, которая имеет больше вариаций и сложности:
Если вы внимательно посмотрите на эту форму волны, то увидите отдельные пики в начале, указывающие, где начинаются последующие ноты. Они расположены на одинаковом расстоянии друг от друга, что определяется параметром задержки.
Изменяя задержку, вы можете регулировать скорость ударов, чтобы создать более быстрый и динамичный ритм или более медленный и мягкий звук. Вы будете использовать этот параметр, чтобы повысить выразительность вашего виртуального инструмента и имитировать музыкальные фразы, которые естественно может использовать гитарист.
Теперь, когда у вас есть контроль над длительностью каждой ноты в аккорде, вы можете экспериментировать дальше, изменяя порядок их воспроизведения.
Измените направление игры, чтобы изменить тембр
Гитаристы часто меняют не только скорость, но и направление игры во время игры. Чередуя удары вниз и вверх, они могут подчеркнуть разные струны и изменить тембр одного и того же аккорда. Удары вниз, как правило, звучат более мощно и обычно громче, потому что медиатор (или ваш палец) сначала ударяет по нижним, более толстым струнам. И наоборот, удары вверх часто выделяют более высокие и тонкие струны, создавая более легкий звук.
Вы можете выразить как скорость, так и направление игры с помощью пользовательских типов данных. Создайте модуль Python с именем stroke
в вашем пакете digitar
и определите в нем эти два класса:
import enum
from dataclasses import dataclass
from typing import Self
from digitar.temporal import Time
class Direction(enum.Enum):
DOWN = enum.auto()
UP = enum.auto()
@dataclass(frozen=True)
class Velocity:
direction: Direction
delay: Time
@classmethod
def down(cls, delay: Time) -> Self:
return cls(Direction.DOWN, delay)
@classmethod
def up(cls, delay: Time) -> Self:
return cls(Direction.UP, delay)
Первый класс — это перечисление Python, которое присваивает уникальные значения взаимоисключающим направлениям штрихов, которых имеется два. Следующий класс, Velocity
, использует это перечисление в качестве своего члена, объединяя его с задержкой или интервалом между последующими выборками.
Вы можете быстро создавать экземпляры объектов для представления гитарных ударов, вызывая удобные методы класса Velocity
:
>>> from digitar.stroke import Direction, Velocity
>>> from digitar.temporal import Time
>>> slow = Time.from_milliseconds(40)
>>> fast = Time.from_milliseconds(20)
>>> Velocity.down(slow)
Velocity(direction=<Direction.DOWN: 1>, delay=Time(seconds=Decimal('0.04')))
>>> Velocity.up(fast)
Velocity(direction=<Direction.UP: 2>, delay=Time(seconds=Decimal('0.02')))
Первый гребок медленный и направлен вниз, второй – более быстрый и направлен вверх. Вы будете использовать эти новые типы данных в проекте для управления музыкальными ощущениями вашей цифровой гитары.
Но в дикой природе существует множество видов гитар. У некоторых струн меньше, у других больше или меньше, а некоторым нужен электронный усилитель. Кроме того, вы можете настроить каждый инструмент на разные ноты. Итак, прежде чем вы сможете правильно воспользоваться скоростью удара, вам необходимо создать виртуальный инструмент и научиться с ним обращаться.
Шаг 4. Играйте музыкальные ноты на виртуальной гитаре
На этом этапе вы можете создавать как монофонические, так и полифонические звуки на основе определенных частот с помощью вашей цифровой гитары. На этом этапе вы смоделируете взаимосвязь между этими частотами и музыкальными нотами, которым они соответствуют. Кроме того, вы смоделируете настройку гитарных струн и взаимодействие с грифом, чтобы создать реалистичный опыт игры.
Нажмите на вибрирующую струну, чтобы изменить ее высоту
Большинство гитар имеют от четырех до двенадцати струн, каждая из которых способна воспроизводить различную высоту звука. Когда вы дергаете открытую струну, не касаясь грифа гитары, струна начинает вибрировать на своей основной частоте. Однако, как только вы прижимаете струну к одной из металлических полосок или ладов вдоль накладки грифа, вы эффективно укорачиваете струну, меняя частоту ее вибрации при перещипывании.
Примечание. Чем короче струна, тем выше частота вибрации или звука, которую вы слышите. И наоборот, чем длиннее струна, тем ниже тон. Именно поэтому у контрабаса такой длинный гриф, а у гавайской гитары – более короткий.
Каждый лад гитары представляет собой увеличение высоты звука на один полутон или полтона хроматической гаммы — стандартной шкалы в западной музыке. Хроматическая гамма делит каждую октаву, или набор из восьми музыкальных нот, на двенадцать равноотстоящих друг от друга полутонов с соотношением между ними корня двенадцатой степени из двух. Когда вы дойдете до двенадцатого полутона, вы удвоите частоту ноты, обозначающей начало октавы.
Расстояния между соседними ладами в ладовом инструменте следуют тому же принципу, отражая логарифмический характер увеличения частоты на каждом шаге. Двигаясь по грифу и нажимая на последовательные лады, вы заметите, что высота струны постепенно увеличивается, на один полутон за раз.
На типичной шестиструнной гитаре обычно имеется около двадцати или более ладов, что составляет более ста тонов! Однако если учесть дубликаты из-за перекрытия октав, фактическое количество отличительных тонов уменьшается. На самом деле вы можете сыграть около четырех октав музыкальных нот, что меньше пятидесяти уникальных тонов. С другой стороны, виртуальная гитара, которую вы собираетесь собрать, не имеет таких ограничений!
В Python вы можете реализовать регулировку высоты тона на основе полутонов следующим образом:
from dataclasses import dataclass
from typing import Self
from digitar.temporal import Hertz
@dataclass(frozen=True)
class Pitch:
frequency: Hertz
def adjust(self, num_semitones: int) -> Self:
return Pitch(self.frequency * 2 ** (num_semitones / 12))
Создав новую высоту звука, вы можете изменить соответствующую основную частоту, вызвав .adjust()
с нужным количеством полутонов. Положительное количество полутонов увеличит частоту, отрицательное число уменьшит ее, а ноль сохранит ее. Обратите внимание, что вы используете оператор возведения в степень Python (**
) для вычисления корня двенадцатой степени из двух, на котором основана формула.
Чтобы убедиться, что ваш код работает должным образом, вы можете запустить следующий тест:
>>> from digitar.pitch import Pitch
>>> pitch = Pitch(frequency=110.0)
>>> semitones = [-12, 12, 24] + list(range(12))
>>> for num_semitones in sorted(semitones):
... print(f"{num_semitones:>3}: {pitch.adjust(num_semitones)}")
...
-12: Pitch(frequency=55.0)
0: Pitch(frequency=110.0)
1: Pitch(frequency=116.54094037952248)
2: Pitch(frequency=123.47082531403103)
3: Pitch(frequency=130.8127826502993)
4: Pitch(frequency=138.59131548843604)
5: Pitch(frequency=146.8323839587038)
6: Pitch(frequency=155.56349186104046)
7: Pitch(frequency=164.81377845643496)
8: Pitch(frequency=174.61411571650194)
9: Pitch(frequency=184.9972113558172)
10: Pitch(frequency=195.99771799087463)
11: Pitch(frequency=207.65234878997256)
12: Pitch(frequency=220.0)
24: Pitch(frequency=440.0)
Вы начинаете с определения высоты звука, создаваемого струной, вибрирующей с частотой 110 Гц, что соответствует ноте ля во второй октаве. Затем вы перебираете список чисел полутонов, чтобы соответствующим образом настроить высоту звука.
В зависимости от того, является ли данное число отрицательным или положительным, регулировка частоты ровно на двенадцать полутонов (одну октаву) либо уменьшает вдвое, либо удваивает исходную частоту этой высоты. Все, что находится между ними, устанавливает частоту соответствующего полутона в пределах этой октавы.
Примечание. Во всех случаях вы получаете новый экземпляр класса Pitch
, который является неизменяемым.
Возможность регулировать частоту полезна, но класс Pitch
заставляет вас мыслить в терминах высоты звука, полутона и октавы, что не самое удобное. Вы поместите презентацию в класс более высокого уровня внутри нового модуля с именем instrument
:
from dataclasses import dataclass
from digitar.pitch import Pitch
@dataclass(frozen=True)
class VibratingString:
pitch: Pitch
def press_fret(self, fret_number: int | None = None) -> Pitch:
if fret_number is None:
return self.pitch
return self.pitch.adjust(fret_number)
Чтобы имитировать выдергивание открытой строки, передайте None
или оставьте параметр fret_number
при вызове метода .press_fret()
. Сделав это, вы вернете неизмененную высоту струны. Альтернативно вы можете передать ноль в качестве номера лада.
И вот как вы можете взаимодействовать с вашим новым классом:
>>> from digitar.instrument import VibratingString
>>> from digitar.pitch import Pitch
>>> a2_string = VibratingString(Pitch(frequency=110))
>>> a2_string.pitch
Pitch(frequency=110)
>>> a2_string.press_fret(None)
Pitch(frequency=110)
>>> a2_string.press_fret(0)
Pitch(frequency=110.0)
>>> a2_string.press_fret(1)
Pitch(frequency=116.54094037952248)
>>> a2_string.press_fret(12)
Pitch(frequency=220.0)
Теперь вы можете обрабатывать высоту звука и гитарные струны независимо, что позволяет при желании назначить разную высоту одной и той же струне. Такое сопоставление высоты звука с открытыми струнами известно как настройка гитары в музыке. Системы настройки требуют от вас понимания определенных обозначений музыкальных нот, о которых вы узнаете в следующем разделе.
Чтение музыкальных нот из научной нотации высоты тона
В научной нотации высоты тона каждая музыкальная нота отображается в виде буквы, за которой следует необязательный символ, например диез (♯) или бемоль (♭), обозначающий случайные звуки, а также номер октавы. Символ диез повышает высоту ноты на полтона, а символ бемоли понижает ее на полутон. Если вы опустите номер октавы, то неявно предполагается ноль.
В этом обозначении семь букв, где C обозначает границы каждой октавы:
Semitone | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Sharp | C♯0 | D♯0 | F♯0 | G♯0 | A♯0 | ||||||||
Tone | C0 | D0 | E0 | F0 | G0 | A0 | B0 | C1 | |||||
Flat | D♭0 | E♭0 | G♭0 | A♭0 | B♭0 |
В данном случае вы смотрите на первую октаву, состоящую из восьми нот: C0, D0, E0, F 0, G0, A0, B0 и C1. Система начинается с C0 или просто C, что составляет примерно 16,3516 Гц. Когда вы дойдете до C1 справа, что также начинает следующую октаву, вы удвоите эту частоту.
Примечание. Хотя большинство букв отличаются друг от друга на целый тон, не все из них имеют острые или плоские эквиваленты. Буквы E и F, а также B и C отстоят друг от друга на полтона, поэтому нет E♯ или F♭. Это произошло по историческим причинам из-за технических ограничений ранних фортепианных клавиатур:
Когда вы накладываете ноты в научной нотации на клавиатуру фортепиано, вы заметите, что случайные знаки соответствуют пяти черным клавишам. И наоборот, естественные тона соответствуют семи белым клавишам в каждой октаве, которые повторяются в обоих направлениях на клавиатуре.
Теперь вы можете расшифровать научную нотацию высоты тона. Например, A4 обозначает музыкальную ноту A в четвертой октаве с частотой 440 Гц, которая является эталоном концертной высоты. Точно так же C♯4 представляет ноту до-диез в четвертой октаве, расположенную на один полутон выше средней до на стандартной фортепианной клавиатуре.
В Python вы можете использовать регулярные выражения для программного перевода этих обозначений в числовые значения. Добавьте следующий метод класса в класс Pitch
в модуле pitch
:
import re
from dataclasses import dataclass
from typing import Self
from digitar.temporal import Hertz
@dataclass(frozen=True)
class Pitch:
frequency: Hertz
@classmethod
def from_scientific_notation(cls, notation: str) -> Self:
if match := re.fullmatch(r"([A-G]#?)(-?\d+)?", notation):
note = match.group(1)
octave = int(match.group(2) or 0)
semitones = "C C# D D# E F F# G G# A A# B".split()
index = octave * 12 + semitones.index(note) - 57
return cls(frequency=440.0 * 2 ** (index / 12))
else:
raise ValueError(
f"Invalid scientific pitch notation: {notation}"
)
def adjust(self, num_semitones: int) -> Self:
return Pitch(self.frequency * 2 ** (num_semitones / 12))
Этот метод вычисляет частоту данной ноты на основе ее расстояния в полутонах от A4. Обратите внимание, что это упрощенная реализация, которая учитывает только резкие ноты. Если вам нужно изобразить бемольную ноту, вы можете переписать ее через эквивалентную ей диезную ноту, при условии, что она существует. Например, B♭ — то же самое, что A♯.
Вот пример использования вашего нового метода класса:
>>> from digitar.pitch import Pitch
>>> for note in "C", "C0", "A#", "C#4", "A4":
... print(f"{note:>3}", Pitch.from_scientific_notation(note))
...
C Pitch(frequency=16.351597831287414)
C0 Pitch(frequency=16.351597831287414)
A# Pitch(frequency=29.13523509488062)
C#4 Pitch(frequency=277.1826309768721)
A4 Pitch(frequency=440.0)
Как видите, код принимает и интерпретирует несколько вариантов научной нотации высоты тона. Отлично! Теперь вы готовы настроить свою цифровую гитару.
Выполните настройку струн виртуальной гитары
В реальном мире музыканты регулируют натяжение гитарных струн, затягивая или ослабляя соответствующие колки, чтобы добиться идеально настроенного звука. Это позволяет им назначать струнам своего инструмента разные наборы музыкальных нот или высоты тона. Иногда они повторно используют одну и ту же высоту звука для двух или более струн, чтобы создать более полный звук.
В зависимости от количества струн на гитаре вы по-разному распределяете музыкальные ноты. Помимо стандартной настройки, которая представляет собой наиболее типичный выбор нот для данного инструмента, вы можете применить несколько альтернативных строев гитары, даже если в вашем распоряжении одинаковое количество струн.
Традиционная настройка шестиструнной гитары, от самой тонкой струны (самый высокий звук) до самой толстой (самый низкий звук), выглядит следующим образом:
String | Note | Frequency |
---|---|---|
1st | E4 | 329.63 Hz |
2nd | B3 | 246.94 Hz |
3rd | G3 | 196.00 Hz |
4th | D3 | 146.83 Hz |
5th | A2 | 110.00 Hz |
6th | E2 | 82.41 Hz |
Если вы правша, вы обычно используете правую руку, чтобы перебирать струны рядом со звуковым отверстием, в то время как левая рука берет ноты на грифе. В этой ориентации первая струна (E4) располагается ближе всего к низу, а шестая струна (E2) – ближе всего к верху.
Строения гитары принято обозначать в порядке возрастания частот. Например, стандартная настройка гитары обычно представлена как: E2-A2-D. 3-G3-B3- Е4. В то же время некоторые гитарные табы следуют нумерации струн, указанной в таблице выше, что меняет этот порядок. Таким образом, верхняя строка в табулатуре шестиструнной гитары обычно представляет первую струну (E4), а нижняя — шестую струну (E2).
Чтобы избежать путаницы, вы должны соблюдать оба соглашения. Добавьте следующий класс в свой модуль instrument
, чтобы можно было представить настройку строки:
from dataclasses import dataclass
from typing import Self
from digitar.pitch import Pitch
# ...
@dataclass(frozen=True)
class StringTuning:
strings: tuple[VibratingString, ...]
@classmethod
def from_notes(cls, *notes: str) -> Self:
return cls(
tuple(
VibratingString(Pitch.from_scientific_notation(note))
for note in reversed(notes)
)
)
Объект этого класса содержит кортеж экземпляров VibratingString
, отсортированных по номеру строки в порядке возрастания. Другими словами, первый элемент кортежа соответствует первой строке (E4), а последний элемент — шестой строке (E2). Обратите внимание, что количество струн может быть меньше или больше шести, если вам нужно представить другие типы струнных инструментов, например банджо, у которого всего пять струн.
На практике вы создадите новые экземпляры класса StringTuning
, вызвав метод класса .from_notes()
и передав переменное количество музыкальных нот в научной нотации. При этом вы должны следовать порядку настройки струн, начиная с самой низкой высоты. Это связано с тем, что метод меняет местами входные ноты, чтобы они соответствовали типичному расположению струн на гитарной табулатуре.
Вот как вы можете использовать класс StringTuning
для представления различных систем настройки для разных щипковых инструментов:
>>> from digitar.instrument import StringTuning
>>> StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4")
StringTuning(
strings=(
VibratingString(pitch=Pitch(frequency=329.6275569128699)),
VibratingString(pitch=Pitch(frequency=246.94165062806206)),
VibratingString(pitch=Pitch(frequency=195.99771799087463)),
VibratingString(pitch=Pitch(frequency=146.8323839587038)),
VibratingString(pitch=Pitch(frequency=110.0)),
VibratingString(pitch=Pitch(frequency=82.4068892282175)),
)
)
>>> StringTuning.from_notes("E1", "A1", "D2", "G2")
StringTuning(
strings=(
VibratingString(pitch=Pitch(frequency=97.99885899543733)),
VibratingString(pitch=Pitch(frequency=73.41619197935188)),
VibratingString(pitch=Pitch(frequency=55.0)),
VibratingString(pitch=Pitch(frequency=41.20344461410875)),
)
)
Первый объект представляет собой стандартную настройку шестиструнной гитары, а второй — настройку четырехструнной бас-гитары. Вы можете использовать тот же подход для моделирования настройки других струнных инструментов, предоставляя соответствующие ноты для каждой струны.
Благодаря этому можно добиться эффекта пережевывания гитары пальцами для взятия определенного аккорда:
>>> tuning = StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4")
>>> frets = (None, None, 2, None, 0, None)
>>> for string, fret_number in zip(tuning.strings, frets):
... if fret_number is not None:
... string.press_fret(fret_number)
...
Pitch(frequency=220.0)
Pitch(frequency=110.0)
В этом случае вы используете стандартную настройку гитары. Затем вы имитируете нажатие второго лада на третьей струне (G3) и оставление пятой струны (A2)< открыть, играя ими обоими. Вы не перемещаете и не трогаете оставшиеся строки, как указано в кортеже None
. Функция zip()
объединяет строки и соответствующие номера ладов в пары, которые вы перебираете.
Примечание. Количество ладов должно соответствовать количеству гитарных струн. В противном случае вы рискуете получить ошибки индексации или неточное представление играемого аккорда.
Третья струна настроена на ноту G3 или 196 Гц. Но, поскольку вы нажимаете ее на втором ладу, вы увеличиваете ее высоту на два полутона, в результате чего получается частота 220 Гц. Пятая струна настроена на частоту A2 или 110 Гц, которую вы играете открыто или без раздражения. Смешав обе частоты, вы получите аккорд, состоящий из нот A3 и A2, которые находятся на расстоянии одной октавы.
Примечание. Такое сочетание музыкальных нот создает унисон или идеальный октавный интервал, гармонически созвучный и приятный для слуха.
Далее вы создадите собственный тип данных для более удобного представления музыкальных аккордов.
Представление аккордов на ладовом инструменте
Ранее вы определили простой кортеж для выражения номеров ладов в конкретном аккорде. Вы можете быть немного более явным, расширив класс tuple
и ограничив типы значений, которые разрешены в нем:
from typing import Self
class Chord(tuple[int | None, ...]):
@classmethod
def from_numbers(cls, *numbers: int | None) -> Self:
return cls(numbers)
С помощью подсказок типов вы заявляете, что ваш кортеж должен содержать только целые числа, представляющие номера ладов, или пустые значения (None
), указывающие на открытую строку. Вы также предоставляете метод класса .from_numbers()
, позволяющий создать экземпляр Chord
путем прямой передачи номеров ладов. Этот метод принимает переменное количество аргументов, каждый из которых может быть целым числом или None
.
Примечание. Кортежи Python неизменяемы, поэтому вам не нужно предпринимать дополнительные действия для достижения первоначальных целей проектирования.
Вот как вы можете определить аккорд из предыдущего раздела этого руководства, используя класс Chord
:
>>> from digitar.chord import Chord
>>> Chord.from_numbers(None, None, 2, None, 0, None)
(None, None, 2, None, 0, None)
>>> Chord([None, None, 2, None, 0, None])
(None, None, 2, None, 0, None)
Когда вы создаете экземпляр Chord
с помощью метода класса, вы передаете номера ладов в качестве аргументов. Вы также можете создать экземпляр класса, передав конструктору итерируемый объект значений, например список. Однако, как правило, более явно использовать метод .from_numbers()
.
Подводя итог, отметим самые важные моменты, которые следует запомнить:
- Позиция значения в кортеже определяет номер строки, поэтому первый элемент соответствует самому высокому тону.
- Пустое значение (
None
) означает, что вы не вообще извлекаете строку. - Ноль представляет собой открытую струну, которую вы перебираете, не нажимая ни на один лад.
- Остальные целые числа соответствуют номерам ладов на грифе гитары, на которые вы нажимаете.
Это также рисунки пальцев на гитарных табулатурах, которые вы будете использовать позже в этом уроке. Теперь пришло время определить еще один пользовательский тип данных, с помощью которого вы будете представлять в коде различные виды щипковых инструментов.
Смоделируйте любой щипковый струнный инструмент
Когда вы думаете об основных свойствах, влияющих на звучание щипкового струнного инструмента, это количество струн, их настройка и материал, из которого они сделаны. Последний аспект, хотя и не единственный, влияет на то, как долго струна будет поддерживать вибрацию и на степень демпфирования энергии.
Вы можете удобно выразить эти атрибуты, определив класс данных в вашем модуле instrument
:
from dataclasses import dataclass
from typing import Self
from digitar.pitch import Pitch
from digitar.temporal import Time
# ...
@dataclass(frozen=True)
class PluckedStringInstrument:
tuning: StringTuning
vibration: Time
damping: float = 0.5
def __post_init__(self) -> None:
if not (0 < self.damping <= 0.5):
raise ValueError(
"string damping must be in the range of (0, 0.5]"
)
Настройка струн определяет, сколько струн у инструмента и каковы их основные частоты вибрации. Для простоты все струны инструмента будут иметь одинаковое время вибрации и коэффициент демпфирования, который по умолчанию равен половине. Если вы хотите переопределить их индивидуально для каждой строки, вам придется настроить код самостоятельно.
Метод .__post_init__()
проверяет, находится ли демпфирование в допустимом диапазоне значений.
Вы можете определить удобное свойство в своем классе, чтобы быстро узнать количество струн в инструменте, не обращаясь к объекту настройки:
from dataclasses import dataclass
from functools import cached_property
from typing import Self
from digitar.pitch import Pitch
from digitar.temporal import Time
# ...
@dataclass(frozen=True)
class PluckedStringInstrument:
# ...
@cached_property
def num_strings(self) -> int:
return len(self.tuning.strings)
Это кэшированное свойство для более эффективного доступа. После первого доступа к такому свойству Python запоминает вычисленное значение, поэтому при последующих обращениях оно не будет пересчитываться, поскольку значение не меняется в течение жизни объекта.
Далее вы можете добавить методы, которые будут брать экземпляр Chord
, который вы создали ранее, и превращать его в кортеж тонов, который вы позже сможете использовать для синтеза полифонического звука:
from dataclasses import dataclass
from functools import cache, cached_property
from typing import Self
from digitar.chord import Chord
from digitar.pitch import Pitch
from digitar.temporal import Time
# ...
@dataclass(frozen=True)
class PluckedStringInstrument:
# ...
@cache
def downstroke(self, chord: Chord) -> tuple[Pitch, ...]:
return tuple(reversed(self.upstroke(chord)))
@cache
def upstroke(self, chord: Chord) -> tuple[Pitch, ...]:
if len(chord) != self.num_strings:
raise ValueError(
"chord and instrument must have the same string count"
)
return tuple(
string.press_fret(fret_number)
for string, fret_number in zip(self.tuning.strings, chord)
if fret_number is not None
)
Поскольку порядок номеров ладов в аккорде соответствует порядку гитарных струн (снизу вверх), поглаживание аккорда имитирует ход вверх. Ваш метод .upstroke()
использует выражение-генератор с условным выражением, которое выглядит почти идентично циклу, который вы видели ранее, когда выполняли настройку строки. Метод .downstroke()
делегирует выполнение .upstroke()
, перехватывает результирующий кортеж объектов Pitch
и обращает его.
Поскольку большинство аккордов повторяются снова и снова по одинаковой схеме в одной песне, вам не нужно каждый раз вычислять каждый из них. Вместо этого вы аннотируете оба метода декоратором @cache
, чтобы избежать избыточных вычислений. Сохраняя вычисленные кортежи, Python вернет кэшированный результат, когда те же входные данные будут повторяться.
Теперь вы можете моделировать различные типы щипковых струнных инструментов, чтобы воспроизвести их уникальные акустические характеристики. Вот несколько примеров стандартной настройки каждого инструмента:
>>> from digitar.instrument import PluckedStringInstrument, StringTuning
>>> from digitar.temporal import Time
>>> acoustic_guitar = PluckedStringInstrument(
... tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
... vibration=Time(seconds=10),
... damping=0.498,
... )
>>> bass_guitar = PluckedStringInstrument(
... tuning=StringTuning.from_notes("E1", "A1", "D2", "G2"),
... vibration=Time(seconds=10),
... damping=0.4965,
... )
>>> electric_guitar = PluckedStringInstrument(
... tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
... vibration=Time(seconds=0.09),
... damping=0.475,
... )
>>> banjo = PluckedStringInstrument(
... tuning=StringTuning.from_notes("G4", "D3", "G3", "B3", "D4"),
... vibration=Time(seconds=2.5),
... damping=0.4965,
... )
>>> ukulele = PluckedStringInstrument(
... tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
... vibration=Time(seconds=5.0),
... damping=0.498,
... )
На данный момент это всего лишь абстрактные контейнеры для логически связанных данных. Прежде чем вы сможете в полной мере воспользоваться преимуществами этих виртуальных инструментов и по-настоящему услышать их, вам необходимо интегрировать их в свой синтезатор Karplus-Strong, что вы и сделаете дальше.
Объедините синтезатор с инструментом
Вы хотите настроить свой синтезатор на щипковый струнный инструмент, чтобы можно было синтезировать звуки, характерные для этого конкретного инструмента. Откройте модуль synthesis
в своем проекте Python и добавьте поле instrument
в класс Synthesizer
:
from dataclasses import dataclass
from itertools import cycle
from typing import Sequence
import numpy as np
from digitar.burst import BurstGenerator, WhiteNoise
from digitar.instrument import PluckedStringInstrument
from digitar.processing import normalize, remove_dc
from digitar.temporal import Hertz, Time
AUDIO_CD_SAMPLING_RATE = 44100
@dataclass(frozen=True)
class Synthesizer:
instrument: PluckedStringInstrument
burst_generator: BurstGenerator = WhiteNoise()
sampling_rate: int = AUDIO_CD_SAMPLING_RATE
# ...
Используя свойства, определенные в классе PluckedStringInstrument
, синтезатор может генерировать звуки, имитирующие тембр и выразительность щипкового струнного инструмента, например акустической гитары или банджо.
Теперь, когда в вашем синтезаторе есть инструмент, вы можете использовать его настроенные струны, чтобы сыграть аккорд с заданными скоростью и направлением. :
# ...
from digitar.burst import BurstGenerator, WhiteNoise
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument
from digitar.processing import normalize, remove_dc
from digitar.stroke import Direction, Velocity
from digitar.temporal import Hertz, Time
AUDIO_CD_SAMPLING_RATE = 44100
@dataclass(frozen=True)
class Synthesizer:
instrument: PluckedStringInstrument
burst_generator: BurstGenerator = WhiteNoise()
sampling_rate: int = AUDIO_CD_SAMPLING_RATE
def strum_strings(
self, chord: Chord, velocity: Velocity, vibration: Time | None = None
) -> np.ndarray:
if vibration is None:
vibration = self.instrument.vibration
if velocity.direction is Direction.UP:
stroke = self.instrument.upstroke
else:
stroke = self.instrument.downstroke
sounds = tuple(
self.vibrate(pitch.frequency, vibration, self.instrument.damping)
for pitch in stroke(chord)
)
return self.overlay(sounds, velocity.delay)
# ...
Ваш новый метод .strum_strings()
ожидает как минимум экземпляры Chord
и Velocity
. При желании вы можете передать продолжительность вибрации, но если вы этого не сделаете, метод вернется к продолжительности инструмента по умолчанию. В зависимости от желаемого направления штриха он синтезирует высоту звука в порядке возрастания или убывания струн. Наконец, он накладывает на них необходимую задержку или арпеджио.
Примечание. Вы можете ввести два отдельных метода: один для перебора отдельной струны, а другой для игры на нескольких струнах одновременно. Однако удобнее рассматривать оба случая единообразно, особенно когда вам приходится иметь дело с сотнями аккордов в гитарной табулатуре.
Поскольку .strum_strings()
стал единственной частью общедоступного интерфейса вашего класса, вы можете указать, что два других метода: vibrate()
и overlay( )
предназначены только для внутреннего использования. Распространенным соглашением в Python для обозначения закрытых методов является добавление к их именам одного подчеркивания (_
):
# ...
@dataclass(frozen=True)
class Synthesizer:
# ...
def strum_strings(...) -> np.ndarray:
# ...
sounds = tuple(
self._vibrate(pitch.frequency, vibration, self.instrument.damping)
for pitch in stroke(chord)
)
return self._overlay(sounds, velocity.delay)
def _vibrate(...) -> np.ndarray:
# ...
def _overlay(...) -> np.ndarray:
# ...
Теперь понятно, что ._vibrate()
и ._overlay()
— это детали реализации, которые могут измениться без предварительного уведомления, поэтому вам не следует обращаться к ним из внешней области.
Ваш синтезатор почти готов, но ему не хватает одной важной детали. Если бы вам пришлось синтезировать целое музыкальное произведение, такое как оригинальный саундтрек Diablo, то более девяноста процентов времени синтеза было бы потрачено на избыточные вычисления. Это потому, что большинство песен состоят из повторяющихся узоров и мотивов. Именно эти повторяющиеся последовательности аккордов создают узнаваемый ритм.
Чтобы сократить общее время синтеза с минут до секунд, вы можете включить кэширование промежуточных результатов. В идеале вы хотели бы украсить все методы вашего класса Synthesizer
декоратором @cache
, чтобы вычислять их один раз для каждого уникального списка аргументов. Однако для кэширования требуется, чтобы все аргументы метода были хешируемыми.
Хотя вы старательно использовали неизменяемые объекты, которые также являются хешируемыми, массивы NumPy — нет. Поэтому вы не можете кэшировать результаты вашего метода ._overlay()
, который принимает в качестве аргумента последовательность массивов. Вместо этого вы можете кэшировать два других метода, которые полагаются только на неизменяемые объекты:
from dataclasses import dataclass
from functools import cache
from itertools import cycle
from typing import Sequence
# ...
@dataclass(frozen=True)
class Synthesizer:
# ...
@cache
def strum_strings(...) -> np.ndarray:
# ...
@cache
def _vibrate(...) -> np.ndarray:
# ...
def _overlay(...) -> np.ndarray:
# ...
Благодаря этому небольшому изменению вы, по сути, меняете память на скорость. Пока на вашем компьютере достаточно памяти, это займет лишь часть времени, которое потребовалось бы в противном случае. Поскольку результаты сохраняются и извлекаются, они не будут пересчитываться каждый раз, когда их запрашивают.
Примечание. Кэширование лучше всего работает с так называемыми чистыми функциями, выходные данные которых могут полностью определяться входными данными. Если ваша функция вызывает побочные эффекты или зависит от глобального состояния, вам лучше обойтись без кэширования.
Как насчет того, чтобы сыграть несколько аккордов на каком-нибудь из ваших инструментов? Ниже приведен короткий фрагмент кода, который воспроизводит удар вниз по всем открытым струнам трех различных щипковых инструментов, определенных вами ранее:
>>> from pedalboard.io import AudioFile
>>> from digitar.chord import Chord
>>> from digitar.instrument import PluckedStringInstrument, StringTuning
>>> from digitar.stroke import Direction, Velocity
>>> from digitar.synthesis import Synthesizer
>>> from digitar.temporal import Time
>>> instruments = {
... "acoustic_guitar": PluckedStringInstrument(
... tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
... vibration=Time(seconds=10),
... damping=0.498,
... ),
... "banjo": PluckedStringInstrument(
... tuning=StringTuning.from_notes("G4", "D3", "G3", "B3", "D4"),
... vibration=Time(seconds=2.5),
... damping=0.4965,
... ),
... "ukulele": PluckedStringInstrument(
... tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
... vibration=Time(seconds=5.0),
... damping=0.498,
... ),
... }
>>> for name, instrument in instruments.items():
... synthesizer = Synthesizer(instrument)
... amplitudes = synthesizer.strum_strings(
... Chord([0] * instrument.num_strings),
... Velocity(Direction.DOWN, Time.from_milliseconds(40))
... )
... with AudioFile(f"{name}.mp3", "w", synthesizer.sampling_rate) as file:
... file.write(amplitudes)
Этот код перебирает словарь пар ключ-значение, состоящий из имени инструмента и соответствующего экземпляра PluckedStringInstrument
. Воспроизведя приведенные ниже аудиофайлы, вы узнаете характерный тембр каждого инструмента:
Хорошо. У вас есть все воедино, и вы готовы играть настоящую музыку на своей виртуальной гитаре!
Шаг 5. Сочиняйте мелодии, используя образцы игры на ударных инструментах.
На этом этапе вы можете синтезировать отдельные ноты и аккорды, которые будут звучать так, как будто вы сыграли их на реальном инструменте. Кроме того, вы можете имитировать различные типы щипковых струнных инструментов и настраивать их по своему вкусу. В этой части урока вы будете составлять более сложные мелодии из этих строительных блоков.
Выделите звуковую дорожку для вашего инструмента
Музыка состоит из аккордов и нот, расположенных на линейной временной шкале, которые специально расположены так, чтобы создать ритм и мелодию. Одна песня часто содержит более одной аудиодорожки, соответствующей различным инструментам, например соло-гитаре, бас-гитаре и ударным, а также вокалу.
В этом проекте вы представите звуковую дорожку с помощью своего первого класса mutable, чтобы можно было постепенно добавлять и микшировать звуки в хронологическом порядке. Определите новый модуль с именем track
со следующим классом AudioTrack
:
import numpy as np
from digitar.temporal import Hertz, Time
class AudioTrack:
def __init__(self, sampling_rate: Hertz) -> None:
self.sampling_rate = int(sampling_rate)
self.samples = np.array([], dtype=np.float64)
def __len__(self) -> int:
return self.samples.size
@property
def duration(self) -> Time:
return Time(seconds=len(self) / self.sampling_rate)
def add(self, samples: np.ndarray) -> None:
self.samples = np.append(self.samples, samples)
Аудиодорожка содержит последовательность аудиосэмплов, точнее, уровней амплитуды, которые вы будете кодировать как сэмплы с выбранным форматом данных. Однако для простоты вы будете называть их образцами.
Чтобы создать новый экземпляр класса AudioTrack
, вам необходимо указать желаемую частоту дискретизации или частоту в герцах. Это позволит вам рассчитать текущую продолжительность трека в секундах, а также добавлять новые сэмплы с определенным смещением по времени. На данный момент вы можете добавлять сэмплы только в самый конец существующего трека без возможности наложения их раньше или вставки позже.
Вы исправите это сейчас, реализовав в своем классе еще один метод:
# ...
class AudioTrack:
# ...
def add_at(self, instant: Time, samples: np.ndarray) -> None:
samples_offset = round(instant.seconds * self.sampling_rate)
if samples_offset == len(self):
self.add(samples)
elif samples_offset > len(self):
self.add(np.zeros(samples_offset - len(self)))
self.add(samples)
else:
end = samples_offset + len(samples)
if end > len(self):
self.add(np.zeros(end - len(self)))
self.samples[samples_offset:end] += samples
Этот метод .add_at()
принимает в качестве аргумента момент времени в дополнение к последовательности добавляемых выборок. На основе частоты дискретизации дорожки он вычисляет смещение в виде количества аудиосэмплов. Если смещение соответствует текущей длине звуковой дорожки, метод добавляет сэмплы посредством делегирования методу .add()
.
В противном случае логика становится немного сложнее:
- Пробел. Если смещение превышает текущую длину дорожки, метод заполняет пробел нулями перед добавлением новых выборок, как и раньше.
- Полное перекрытие. Если смещение находится где-то посередине дорожки и новые сэмплы могут поместиться в него, то метод накладывает новые семплы поверх существующих в правильном положении.
- Частичное перекрытие. Если смещение находится где-то в середине дорожки, но новые сэмплы выходят за пределы ее текущего конца, метод смешивает перекрывающуюся часть и добавляет оставшиеся сэмплы, выходящие за пределы текущей длины дорожки. .
Новый метод позволяет точно размещать звуки на звуковой дорожке. Но вам все равно нужно следить за ходом времени на временной шкале. Чтобы помочь вам в этом, вы создадите еще один пользовательский тип данных.
Отслеживайте развитие музыки на временной шкале
Время может двигаться только вперед, поэтому вы смоделируете временную шкалу как еще один изменяемый класс со специальным методом для продвижения к текущему моменту. Откройте модуль temporal
и добавьте следующее определение класса данных:
# ...
@dataclass
class Timeline:
instant: Time = Time(seconds=0)
def __rshift__(self, seconds: Numeric | Time) -> Self:
self.instant += seconds
return self
Если вы не указали иное, временная шкала по умолчанию начинается с нуля секунд. Благодаря неизменности объектов Time
вы можете использовать один из них в качестве значения по умолчанию для атрибута instant
.
Метод .__rshift__()
обеспечивает реализацию оператора побитового сдвига вправо (>>
) для вашего класса. В данном случае это нестандартная реализация, не имеющая ничего общего с операциями над битами. Вместо этого он перемещает временную шкалу на заданное количество секунд или на другой объект Time
. Метод обновляет текущий экземпляр Timeline
и возвращает себя, что позволяет сразу же объединить методы в цепочку или оценить сдвинутую временную шкалу.
Обратите внимание, что сдвиг временной шкалы добавляет либо числовое значение, например объект Decimal
, либо экземпляр Time
к другому экземпляру Time
. Это добавление не будет работать «из коробки», поскольку Python не умеет добавлять два объекта пользовательских типов данных с помощью оператора «плюс» (+
). К счастью, вы можете указать ему, как обрабатывать такое добавление, реализовав метод .__add__()
в вашем классе Time
:
# ...
@dataclass(frozen=True)
class Time:
# ...
def __add__(self, seconds: Numeric | Self) -> Self:
match seconds:
case Time() as time:
return Time(self.seconds + time.seconds)
case int() | Decimal():
return Time(self.seconds + seconds)
case float():
return Time(self.seconds + Decimal(str(seconds)))
case Fraction():
return Time(Fraction.from_decimal(self.seconds) + seconds)
case _:
raise TypeError(f"can't add '{type(seconds).__name__}'")
def get_num_samples(self, sampling_rate: Hertz) -> int:
return round(self.seconds * round(sampling_rate))
# ...
Когда вы предоставляете объект Time
в качестве аргумента для .__add__()
, метод вычисляет сумму десятичных секунд в обоих случаях и возвращает новое значение Time.
с полученными секундами. С другой стороны, если аргумент имеет один из ожидаемых числовых типов, метод сначала преобразует его соответствующим образом. В случае неподдерживаемого типа метод вызывает исключение с сообщением об ошибке.
Просмотрите следующие примеры, чтобы понять, как можно использовать класс Timeline
:
>>> from digitar.temporal import Time, Timeline
>>> Timeline()
Timeline(instant=Time(seconds=Decimal('0')))
>>> Timeline(instant=Time.from_milliseconds(100))
Timeline(instant=Time(seconds=Decimal('0.1')))
>>> Timeline() >> 0.1 >> 0.3 >> 0.5
Timeline(instant=Time(seconds=Decimal('0.9')))
>>> from digitar.temporal import Time, Timeline
>>> timeline = Timeline()
>>> for offset in 0.1, 0.3, 0.5:
... timeline >> offset
...
Timeline(instant=Time(seconds=Decimal('0.1')))
Timeline(instant=Time(seconds=Decimal('0.4')))
Timeline(instant=Time(seconds=Decimal('0.9')))
>>> timeline.instant.seconds
Decimal('0.9')
Эти примеры демонстрируют различные способы использования переопределенного побитового оператора для перемещения во времени. В частности, вы можете объединить несколько приращений времени в одно выражение для кумулятивного продвижения по временной шкале. Временная шкала является постоянной, поэтому любые предыдущие изменения сохраняются в ней, что позволяет вам запросить текущий момент.
Имея звуковую дорожку и временную шкалу, вы наконец сможете сочинить свою первую мелодию. Вы готовы повеселиться?
Повторение аккордов через определенные промежутки времени
Для начала вы сыграете припев из хита Джейсона Мраза «I’m Yours» на виртуальной гавайской гитаре. Следующий пример основан на прекрасном объяснении, щедро предоставленном Адрианом с канала Learn And Play на YouTube. Если вам интересно узнать больше о том, как играть именно эту песню, посмотрите гораздо более подробное видеоруководство на родственном канале Адриана.
Примечание. Упомянутый видеоурок был записан на польском языке, который является родным языком Адриана. К сожалению, для этого видео нет субтитров на английском языке, поэтому вам может потребоваться использовать сторонние инструменты для автоматической транскрипции и перевода, чтобы понять урок.
Припев песни состоит из четырех аккордов в следующей последовательности с соответствующими аппликатурами для гавайской гитары:
- До мажор: нажмите на третий лад первой струны.
- Соль мажор: нажмите на второй лад первой струны, на третий лад второй струны и на второй лад третьей струны.
- Ля минор: нажмите на второй лад четвертой струны.
- Фа мажор: нажмите на первый лад второй струны и на второй лад четвертой струны.
Кроме того, каждый аккорд следует играть в соответствии с схемой игры, изображенной ниже, повторяя ее дважды:
- Ход вниз (медленный)
- Ход вниз (медленный)
- Ход вверх (медленный)
- Ход вверх (быстрый)
- Ход вниз (быстрый)
- Ход вверх (медленный)
Другими словами, вы начинаете с того, что кладете пальцы на гриф, чтобы сформировать нужный аккорд, а затем продолжаете поглаживать струны в заданном порядке. Когда вы достигнете конца этого паттерна, вы промываете его и повторяете, играя его еще раз для того же аккорда. После того, как вы дважды проиграете образец для определенного аккорда, вы переходите к следующему аккорду в последовательности.
Последующие штрихи в узоре располагаются примерно через такие интервалы времени в секундах:
0.65s | 0.45s | 0.75s | 0.2s | 0.4s | 0.25s |
Хотя конкретные смещения аккордов были оценены на слух, их вполне достаточно для этого упражнения. Вы будете использовать их для распределения синтезированных аккордов по аудиодорожке с помощью временной шкалы.
Объединив все это, вы можете создать скрипт Python с именем play_chorus.py
, который воспроизводит образец игры припева песни. Чтобы сохранить порядок, рассмотрите возможность создания новой подпапки в корневой папке вашего проекта, где вы будете хранить такие сценарии. Например, вы можете дать ему имя demo/
:
from itertools import cycle
from typing import Iterator
from digitar.chord import Chord
from digitar.stroke import Velocity
from digitar.temporal import Time
def strumming_pattern() -> Iterator[tuple[float, Chord, Velocity]]:
chords = (
Chord.from_numbers(0, 0, 0, 3),
Chord.from_numbers(0, 2, 3, 2),
Chord.from_numbers(2, 0, 0, 0),
Chord.from_numbers(2, 0, 1, 0),
)
fast = Time.from_milliseconds(10)
slow = Time.from_milliseconds(25)
strokes = [
Velocity.down(slow),
Velocity.down(slow),
Velocity.up(slow),
Velocity.up(fast),
Velocity.down(fast),
Velocity.up(slow),
]
interval = cycle([0.65, 0.45, 0.75, 0.2, 0.4, 0.25])
for chord in chords:
for _ in range(2): # Repeat each chord twice
for stroke in strokes:
yield next(interval), chord, stroke
Приведенная выше функция strumming_pattern()
возвращает итератор триплетов, состоящий из временного интервала в секундах, экземпляра Chord
и объекта Velocity
, который описывает инсульт. Интервал — это смещение следующего аккорда на временной шкале относительно текущего аккорда.
На каждом аккорде указаны номера ладов, на которых следует нажимать на соответствующие струны. Помните, что строки отсчитываются с правой стороны, поэтому последний элемент кортежа аккорда представляет первую строку.
Всего существует четыре типа инсультов. И ход вверх, и ход вниз бывают двух видов: медленные и быстрые, которые различаются величиной задержки между последовательными нажатиями. Вы чередуете эти движения, чтобы имитировать ожидаемый ритм.
Далее вы можете определить виртуальную гавайскую гитару и подключить ее к синтезатору:
from itertools import cycle
from typing import Iterator
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time
def main() -> None:
ukulele = PluckedStringInstrument(
tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
vibration=Time(seconds=5.0),
damping=0.498,
)
synthesizer = Synthesizer(ukulele)
# ...
if __name__ == "__main__":
main()
Следуя идиоме Python name-main, вы определяете функцию main()
как точку входа в свой скрипт и вызываете ее в конце файла. Затем вы повторно используете определение PluckedStringInstrument
, которое вы видели в предыдущем разделе и которое определяет стандартную настройку гавайской гитары.
Следующий шаг — синтезировать отдельные аккорды — в зависимости от того, как вы играете по виртуальным струнам — и добавить их на звуковую дорожку в нужный момент:
from itertools import cycle
from typing import Iterator
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time, Timeline
from digitar.track import AudioTrack
def main() -> None:
ukulele = PluckedStringInstrument(
tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
vibration=Time(seconds=5.0),
damping=0.498,
)
synthesizer = Synthesizer(ukulele)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = Timeline()
for interval, chord, stroke in strumming_pattern():
audio_samples = synthesizer.strum_strings(chord, stroke)
audio_track.add_at(timeline.instant, audio_samples)
timeline >> interval
# ...
На основе частоты дискретизации синтезатора вы создаете звуковую дорожку и временную шкалу, которая начинается с нуля секунд. Затем вы перебираете образец игры, синтезируете следующий звук гавайской гитары и добавляете его к звуковой дорожке в текущий момент. Наконец, вы перемещаете временную шкалу, используя предоставленное смещение.
Теперь вы можете сохранить амплитуды, сохраненные в вашей звуковой дорожке, в файл, не забывая нормализовать их, чтобы избежать обрезки и других искажений:
from itertools import cycle
from typing import Iterator
from pedalboard.io import AudioFile
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import Time, Timeline
from digitar.track import AudioTrack
def main() -> None:
ukulele = PluckedStringInstrument(
tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
vibration=Time(seconds=5.0),
damping=0.498,
)
synthesizer = Synthesizer(ukulele)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = Timeline()
for interval, chord, stroke in strumming_pattern():
audio_samples = synthesizer.strum_strings(chord, stroke)
audio_track.add_at(timeline.instant, audio_samples)
timeline >> interval
with AudioFile("chorus.mp3", "w", audio_track.sampling_rate) as file:
file.write(normalize(audio_track.samples))
# ...
Запустив этот скрипт, вы получите аудиофайл с именем chorus.mp3
, в котором записана схема игры и аккорды песни:
Похвалите себя заслуженно! Вы только что сделали синтезатор для щипковых струнных инструментов. Он работает прилично хорошо, но требует, чтобы вы вручную планировали отдельные ноты на временной шкале, чтобы они соответствовали ритму. Это может быть подвержено ошибкам и неуклюже. Кроме того, вы не можете изменить темп песни или количество ударов в минуту.
Далее вы примените более систематический подход к расположению музыкальных нот и аккордов на временной шкале.
Разделите временную шкалу на доли тактов
Музыка вращается вокруг времени, которое играет центральную роль в ритме, темпе и продолжительности отдельных нот композиции. На протяжении всей истории композиторы считали удобным делить временную шкалу на сегменты, известные как такты или такты, обычно содержащие одинаковое количество долей.
Вы можете думать о доле как об основной единице времени в музыкальной композиции. Это устойчивый пульс, определяющий ритм. Ритм обычно остается постоянным на протяжении всей песни, и вы можете интуитивно распознать его, постукивая по нему ногами или хлопая в ладоши. Музыканты иногда намеренно считают доли вслух или в уме, чтобы сохранить ритм своего исполнения.
Каждому такту соответствует тактовый размер, состоящий из двух чисел, расположенных вертикально. Верхнее число указывает количество долей в такте, а нижнее число обозначает дробное значение ноты, которое представляет длину одной доли относительно всей ноты. Например, в размере ⁴⁄₄ (4 × ¼) имеется четыре доли на такт, а длительность доли равна четвертной ноте или ¼- часть всей заметки.
Примечание. Тактовый размер ⁴⁄₄ настолько распространен в западной музыке, что его часто называют «общим тактом». Скорее всего, вы встречали это во многих песнях.
По историческим причинам и для удобства исполнителя значение ноты в тактовом размере почти всегда представляет собой степень двойки, что позволяет легко подразделять доли. Если вы хотите сыграть ноту между основными долями вашего такта, а не на такте, вы можете увеличить разрешение, используя меньшие значения нот. Однако они должны следовать двоичному ряду:
Note | Value | Power of Two |
---|---|---|
Whole | 1 | 20 |
Half | ½ | 2-1 |
Quarter | ¼ | 2-2 |
Eighth | ⅛ | 2-3 |
Sixteenth | ¹⁄₁₆ | 2-4 |
Thirty-Second | ¹⁄₃₂ | 2-5 |
На практике ноты короче одной шестнадцатой используются редко. Вы также можете объединить несколько стандартных значений нот, чтобы сформировать еще более сложные ноты с точками. Например, ¼ + ⅛ + ¹⁄₁₆ дает вам ⁷⁄₁₆, что может помочь вам создавать сложные ритмы.
Когда вы представляете свои ноты, используя относительные величины вместо абсолютных, вы можете легко контролировать темп или темп всей композиции. Зная взаимоотношения между отдельными нотами, вы сможете определить, как долго их играть.
Допустим, у вас есть музыкальное произведение в общее время. Если вы установите темп на семьдесят пять ударов в минуту (BPM), то каждая доля, которая в этом размере является четвертной нотой, будет длиться 0,8 секунды. Четыре доли, составляющие один такт, будут длиться 3,2 секунды. Вы можете умножить это на общее количество тактов в композиции, чтобы найти ее продолжительность.
Чтобы точно представить длительность дробных нот в секундах, вы реализуете еще один специальный метод в своем классе Time
:
# ...
@dataclass(frozen=True)
class Time:
# ...
def __mul__(self, seconds: Numeric) -> Self:
match seconds:
case int() | Decimal():
return Time(self.seconds * seconds)
case float():
return Time(self.seconds * Decimal(str(seconds)))
case Fraction():
return Time(Fraction.from_decimal(self.seconds) * seconds)
case _:
raise TypeError(f"can't multiply by '{type(seconds).__name__}'")
# ...
# ...
Метод .__mul__()
позволяет перегрузить оператор умножения (*
) в вашем классе. В этом случае умножение экземпляра Time
на числовое значение возвращает новый объект Time
с обновленными десятичными секундами.
Примечание. Оба метода .__add__()
и .__mul__()
вызываются только тогда, когда левый операнд является вашим. Экземпляр Time
. Если вы также хотите поддерживать их правые версии, реализуйте аналоги .__radd__()
и .__rmul__()
посредством делегирования:
@dataclass(frozen=True)
class Time:
# ...
def __radd__(self, seconds: Numeric | Self) -> Self:
return self + seconds
def __rmul__(self, seconds: Numeric) -> Self:
return self * seconds
Оба метода меняют порядок операндов. Они позволят вам использовать перегруженные операторы независимо от того, находится ли экземпляр Time
слева или справа от оператора.
Благодаря поддержке типа данных Fraction
в вашем методе умножения вы можете элегантно выражать длительность музыкальных нот и тактов:
>>> from fractions import Fraction
>>> from digitar.temporal import Time
>>> beats_per_minute = 75
>>> beats_per_measure = 4
>>> note_value = Fraction(1, 4)
>>> beat = Time(seconds=60 / beats_per_minute)
>>> measure = beat * beats_per_measure
>>> beat
Time(seconds=Decimal('0.8'))
>>> measure
Time(seconds=Decimal('3.2'))
>>> whole_note = beat * note_value.denominator
>>> half_note = whole_note * Fraction(1, 2)
>>> quarter_note = whole_note * Fraction(1, 4)
>>> three_sixteenth_note = whole_note * (Fraction(1, 8) + Fraction(1, 16))
>>> three_sixteenth_note
Time(seconds=Decimal('0.6'))
Этот фрагмент кода демонстрирует, как можно точно рассчитать длительность различных музыкальных нот в секундах. Он начинается с указания темпа (75 ударов в минуту) и размера ⁴⁄₄. Вы используете эту информацию, чтобы получить продолжительность одной доли и одного такта в секундах. На основе длины доли и значения ноты вы затем определяете длительность всей ноты и ее частей.
Ваш существующий класс Timeline
понимает только секунды, когда дело доходит до отслеживания хода времени. В следующем разделе вы расширите его, включив в него поддержку музыкальных тактов, к которым вы сможете быстро перейти.
Внедрение временной шкалы отслеживания показателей
Когда вы читаете нотную запись, например гитарную табулатуру, вам необходимо расположить ноты на временной шкале, используя относительные смещения в пределах текущего такта, чтобы обеспечить точное время и ритм. Вы увидели, как определить продолжительность заметки и разместить ее на временной шкале. Однако у вас нет возможности найти границы меры и перейти к следующей мере, если текущая еще не полностью заполнена.
Идем дальше и определяем еще один изменяемый класс данных, который расширяет базовый класс Timeline
двумя дополнительными полями: .measure
и .last_measure_ended_at
:
from dataclasses import dataclass, field
# ...
@dataclass
class MeasuredTimeline(Timeline):
measure: Time = Time(seconds=0)
last_measure_ended_at: Time = field(init=False, repr=False)
Если вы наследуете от другого класса данных, который имеет хотя бы одно поле со значением по умолчанию, вы должны также объявить значения по умолчанию в своем подклассе. Это связано с тем, что поля, отличные от полей по умолчанию, не могут следовать за полями по умолчанию, даже если они определены в суперклассе. Таким образом, чтобы удовлетворить синтаксическим требованиям, вы указываете ноль секунд в качестве значения по умолчанию для поля .measure
, даже если вы обычно указываете собственное значение во время создания объекта.
Примечание. Иногда вам может потребоваться обновить размер показателя временной шкалы. Тактовый размер может меняться несколько раз в пределах одной и той же песни, хотя и относительно редко.
В то время как первый атрибут указывает продолжительность текущей меры, второй атрибут отслеживает, когда закончилась последняя мера. Поскольку его значение зависит от полей .instant
и .measure
временной шкалы, вы должны инициализировать его вручную в .__post_init__()
:
# ...
@dataclass
class MeasuredTimeline(Timeline):
measure: Time = Time(seconds=0)
last_measure_ended_at: Time = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.measure.seconds > 0 and self.instant.seconds > 0:
periods = self.instant.seconds // self.measure.seconds
self.last_measure_ended_at = Time(periods * self.measure.seconds)
else:
self.last_measure_ended_at = Time(seconds=0)
Если указан размер меры и текущая позиция на временной шкале больше нуля секунд, вы вычисляете количество прошедших полных мер и соответствующим образом устанавливаете .last_measure_ended_at
. В противном случае вы оставляете значение по умолчанию, равное нулю секунд.
Вы можете продолжать использовать оператор побитового сдвига вправо (>>
), как и раньше, для продвижения атрибута .instant
временной шкалы. Однако вы также можете перейти к следующему такту в любой момент, даже если вы все еще находитесь в середине другого такта. Для этого вы можете реализовать метод .__next__()
в своем классе следующим образом:
# ...
@dataclass
class MeasuredTimeline(Timeline):
# ...
def __next__(self) -> Self:
if self.measure.seconds <= 0:
raise ValueError("measure duration must be positive")
self.last_measure_ended_at += self.measure
self.instant = self.last_measure_ended_at
return self
Прежде чем попытаться обновить другие поля, убедитесь, что продолжительность текущей меры в секундах положительна. В этом случае вы добавляете длительность к атрибуту .last_measure_ended_at
, отмечая конец текущей меры. Затем вы устанавливаете атрибут .instant
временной шкалы на это новое значение, чтобы перейти к началу следующего такта. Наконец, вы возвращаете объект MeasuredTimeline
, чтобы обеспечить цепочку методов и операторов.
После того, как вы создадите экземпляр класса с ненулевым размером меры, вы можете начать переключаться между мерами:
>>> from digitar.temporal import MeasuredTimeline, Time
>>> timeline = MeasuredTimeline(measure=Time(seconds=3.2))
>>> timeline.instant
Time(seconds=Decimal('0'))
>>> (timeline >> Time(0.6) >> Time(0.8)).instant
Time(seconds=Decimal('1.4'))
>>> next(timeline).instant
Time(seconds=Decimal('3.2'))
>>> timeline.measure = Time(seconds=2.0)
>>> next(timeline).instant
Time(seconds=Decimal('5.2'))
Если вы не указали иное, объект MeasuredTimeline
начинается с нуля секунд, как и обычная временная шкала. Вы можете использовать побитовый оператор сдвига вправо, как обычно. Кроме того, вызвав встроенную функцию next()
, вы можете пропустить оставшуюся часть текущей меры и перейти к началу следующей меры. Когда вы решите изменить размер меры, это будет отражено в последующих вызовах next()
.
Теперь, когда вы знакомы с музыкальными размерами, долями и дробными нотами, вы готовы синтезировать композицию на основе настоящей гитарной табулатуры.
Научитесь читать табулатуры на гитаре
Как упоминалось ранее, гитарная табулатура, часто называемая гитарная табулатура, представляет собой упрощенную форму нотной записи, предназначенную для начинающих музыкантов и любителей, которые могут чувствовать себя менее комфортно с традиционными нотами. В то же время профессиональные музыканты не уклоняются от использования гитарных табулатур из-за их удобства для обучения и обмена идеями.
Поскольку это обозначение специально разработано для струнных инструментов, на гитаре табулатура содержит горизонтальные линии, обозначающие струны, с цифрами над ними, указывающими, какие лады нужно нажимать. В зависимости от типа инструмента количество линий может варьироваться, но для типичной гитары их будет шесть.
Порядок гитарных струн на табулатуре не стандартизирован, поэтому всегда ищите метки, на которых указаны номера струн или буквы, соответствующие их настройке.
Вы можете найти бесплатные табы для гитары в Интернете. Как упоминалось в начале этого руководства, Songsterr — это управляемый сообществом веб-сайт, на котором размещено более миллиона вкладок. Скорее всего, там вы найдете табы ваших любимых мелодий. В рамках этого примера вы воссоздадите культовый саундтрек из игры Diablo.
Примечание. Табулатуры гитары содержат четкие инструкции о том, где располагать пальцы на грифе, но обычно не передают длительность нот. Вместо этого вы уже должны знать песню достаточно хорошо, чтобы понимать ее ритм. Однако с помощью Songsterr вы можете использовать встроенный проигрыватель, чтобы прослушивать аккорды последовательно. Кроме того, на сайте представлена ритм-нотация, концептуально похожая на ноты.
Взгляните на тему «Тристрам» в игре от Мэтта Уэльмена на Songsterr прямо сейчас. На снимке экрана ниже показаны первые четыре такта, а также аннотированы наиболее важные элементы гитарной вкладки:
Табулатура выше начинается с обозначений струн, соответствующих стандартной настройке гитары, тактового размера ⁴⁄₄ и темпа семидесяти пяти ударов в минуту. >. Каждый такт пронумерован и отделен от соседей вертикальной линией, чтобы помочь вам ориентироваться при чтении ноты.
Цифры, выделенные жирным шрифтом на горизонтальных линиях, обозначают лады, на которых следует нажимать на соответствующие струны, чтобы услышать нужный аккорд. Наконец, символы под каждым тактом обозначают дробную длительность нот и пауз (пауз) относительно всей ноты.
Примечание. Посетите официальную справочную страницу Songsterr, чтобы получить дополнительную информацию о том, как читать вкладки на их веб-сайте.
Опираясь на эти знания, вы сможете интерпретировать предоставленную вкладку и вдохнуть в нее жизнь с помощью реализованного вами гитарного синтезатора. Первоначально вы жестко закодируете гитарную табулатуру Diablo в скрипте Python, используя программный подход.
Воспроизведение табулатуры Diablo программно
Создайте новый скрипт под названием play_diablo.py
в папке demo/
со следующим содержимым:
from fractions import Fraction
from digitar.temporal import Time
BEATS_PER_MINUTE = 75
BEATS_PER_MEASURE = 4
NOTE_VALUE = Fraction(1, 4)
class MeasureTiming:
BEAT = Time(seconds=60 / BEATS_PER_MINUTE)
MEASURE = BEAT * BEATS_PER_MEASURE
class Note:
WHOLE = MeasureTiming.BEAT * NOTE_VALUE.denominator
SEVEN_SIXTEENTH = WHOLE * Fraction(7, 16)
FIVE_SIXTEENTH = WHOLE * Fraction(5, 16)
THREE_SIXTEENTH = WHOLE * Fraction(3, 16)
ONE_EIGHTH = WHOLE * Fraction(1, 8)
ONE_SIXTEENTH = WHOLE * Fraction(1, 16)
ONE_THIRTY_SECOND = WHOLE * Fraction(1, 32)
class StrummingSpeed:
SLOW = Time.from_milliseconds(40)
FAST = Time.from_milliseconds(20)
SUPER_FAST = Time.from_milliseconds(5)
Выделенные константы представляют собой единственные входные параметры, которые вы можете изменить, а остальные значения извлекаются из этих входных данных. Здесь вы группируете логически связанные значения в общих пространствах имен, определяя их как атрибуты класса. Соответствующие имена классов сообщают вам об их назначении.
Примечание. Ваш непосредственный инстинкт может подсказать вам использовать тип данных Python Enum
для группировки констант. Но в этом случае вас интересуют базовые значения, а не члены перечисления, которые их обертывают. Более того, с помощью Enum
вы не сможете основывать отдельные значения заметки на заметке WHOLE
, которую вы вычисляете динамически. Наконец, BEAT
и MEASURE
не являются взаимоисключающими вариантами.
Затем определите свою виртуальную гитару, подключите ее к синтезатору и подготовьте звуковую дорожку вместе с временной шкалой, которая учитывает размеры табуляции:
from fractions import Fraction
from pedalboard.io import AudioFile
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
# ...
def main() -> None:
acoustic_guitar = PluckedStringInstrument(
tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
vibration=Time(seconds=10),
damping=0.498,
)
synthesizer = Synthesizer(acoustic_guitar)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = MeasuredTimeline(measure=MeasureTiming.MEASURE)
save(audio_track, "diablo.mp3")
def save(audio_track: AudioTrack, filename: str) -> None:
with AudioFile(filename, "w", audio_track.sampling_rate) as file:
file.write(normalize(audio_track.samples))
print(f"\nSaved file {filename!r}")
if __name__ == "__main__":
main()
Вы повторно используете объект акустической гитары из предыдущих разделов, который применяет стандартную настройку, и определяете вспомогательную функцию для сохранения полученного звука в файле.
Примечание. Будьте осторожны при передаче размера меры в экземпляр MeasuredTimeline
. Если вы передадите его как позиционный аргумент, а не как аргумент ключевого слова, вы инициализируете поле .instant
вместо предполагаемого поля .measure
.
На временную шкалу вы поместите синтезированные звуки, описываемые текущим моментом, номерами ладов, которые нужно нажать, и скоростью удара, которые вы можете смоделировать как неизменяемый класс данных:
from dataclasses import dataclass
from fractions import Fraction
from pedalboard.io import AudioFile
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
# ...
@dataclass(frozen=True)
class Stroke:
instant: Time
chord: Chord
velocity: Velocity
# ...
Объекты класса Stroke
представляют собой именно то, что вы видите на вкладке гитары, предоставленной Songsterr. Теперь вы можете преобразовать каждый такт в последовательность штрихов для цикла:
# ...
def main() -> None:
acoustic_guitar = PluckedStringInstrument(
tuning=StringTuning.from_notes("E2", "A2", "D3", "G3", "B3", "E4"),
vibration=Time(seconds=10),
damping=0.498,
)
synthesizer = Synthesizer(acoustic_guitar)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = MeasuredTimeline(measure=MeasureTiming.MEASURE)
for measure in measures(timeline):
for stroke in measure:
audio_track.add_at(
stroke.instant,
synthesizer.strum_strings(stroke.chord, stroke.velocity),
)
save(audio_track, "diablo.mp3")
def measures(timeline: MeasuredTimeline) -> tuple[tuple[Stroke, ...], ...]:
return (
measure_01(timeline),
measure_02(timeline),
)
# ...
Сначала вы перебираете последовательность показателей, возвращаемую функцией measures()
, которую вы вызываете с временной шкалой в качестве аргумента. Затем вы перебираете каждый штрих в текущем такте, синтезируете соответствующий аккорд и добавляете его в трек в нужный момент.
В настоящее время ваша гитарная вкладка содержит два такта, каждый из которых вычисляется в отдельной функции, которую вы можете определить сейчас:
# ...
def measure_01(timeline: MeasuredTimeline) -> tuple[Stroke, ...]:
return (
Stroke(
timeline.instant,
Chord.from_numbers(0, 0, 2, 2, 0, None),
Velocity.down(StrummingSpeed.SLOW),
),
Stroke(
(timeline >> Note.THREE_SIXTEENTH).instant,
Chord.from_numbers(None, 0, 2, None, None, None),
Velocity.up(StrummingSpeed.FAST),
),
Stroke(
(timeline >> Note.ONE_EIGHTH).instant,
Chord.from_numbers(0, 0, 2, 2, 0, None),
Velocity.down(StrummingSpeed.SLOW),
),
)
def measure_02(timeline: MeasuredTimeline) -> tuple[Stroke, ...]:
return (
Stroke(
next(timeline).instant,
Chord.from_numbers(0, 4, 2, 1, 0, None),
Velocity.down(StrummingSpeed.SLOW),
),
Stroke(
(timeline >> Note.THREE_SIXTEENTH).instant,
Chord.from_numbers(None, None, 2, None, None, None),
Velocity.down(StrummingSpeed.SUPER_FAST),
),
Stroke(
(timeline >> Note.ONE_EIGHTH).instant,
Chord.from_numbers(0, 4, 2, 1, 0, None),
Velocity.down(StrummingSpeed.SLOW),
),
Stroke(
(timeline >> Note.SEVEN_SIXTEENTH).instant,
Chord.from_numbers(7, None, None, None, None, None),
Velocity.down(StrummingSpeed.SUPER_FAST),
),
)
# ...
Полная гитарная табулатура Diablo содержит семьдесят восемь тактов и общее количество тысячи ударов. Для краткости приведенный выше фрагмент кода показывает только первые два показателя, которых должно быть достаточно, чтобы распознать известную тему. Хотя для примера этого будет достаточно, не стесняйтесь реализовывать последующие меры на основе вкладки Songsterr.
Как вариант, вы можете скопировать окончательный исходный код остальных функций из бонусных материалов. Чтобы получить их, нажмите на ссылку ниже:
Помните, что полный сценарий play_diablo.py
содержит несколько тысяч строк кода Python! Поэтому, возможно, вам будет удобнее продолжить работу над этим минимально жизнеспособным прототипом.
Обратите внимание, что каждый удар, за исключением самого первого, сдвигает временную шкалу на часть всей ноты, чтобы отразить длительность предыдущего аккорда. Это обеспечивает правильное расстояние между соседними хордами. Кроме того, выделенная строка перемещает временную шкалу к началу следующего такта на вкладке.
Всего для саундтрека Diablo вам понадобится шесть уникальных дробных нот. Зная длительность всей ноты в секундах, вы можете быстро определить длительность остальных нот:
Note | Seconds | Fraction |
---|---|---|
Whole | 3.2s | 1 |
Seven-sixteenth | 1.4s | ⁷⁄₁₆ = (¹⁄₁₆ + ⅛ + ¼) |
Five-sixteenth | 1.0s | ⁵⁄₁₆ = (¹⁄₁₆ + ¼) |
Three-sixteenth | 0.6s | ³⁄₁₆ = (¹⁄₁₆ + ⅛) |
One-eighth | 0.4s | ⅛ |
One-sixteenth | 0.2s | ¹⁄₁₆ |
One-thirty-second | 0.1s | ¹⁄₃₂ |
Если предположить, что вся нота имеет ту же продолжительность, что и весь такт, 3,2 секунды, то нота в одну тридцать секунд будет равна 0,1 секунды и так далее. Разделение длительности всей ноты позволяет с большой точностью собрать воедино ритм звуковой дорожки.
Разве не было бы здорово создать универсальный проигрыватель, который мог бы читать и синтезировать любую гитарную табулатуру, а не только эту конкретную? В конце концов вы добьетесь этого, но перед этим вам придется усовершенствовать синтез, чтобы он звучал еще более аутентично.
Шаг 6. Примените специальные эффекты для большей реалистичности
На этом этапе ваш гитарный синтезатор довольно хорошо имитирует настоящий инструмент, но звучит все равно немного резко и искусственно. Существует множество способов улучшить тембр виртуальной гитары, но в этом разделе вы ограничитесь специальными эффектами, предоставляемыми библиотекой Pedalboard. Он позволяет объединять несколько эффектов вместе, как настоящий гитарный педалборд, управляемый ногой.
Усильте басы и добавьте эффект реверберации
Настоящая гитара имеет звуковой ящик, который производит насыщенный и яркий звук на низких частотах. Чтобы имитировать это на вашей виртуальной гитаре и усилить басы, вы можете использовать аудиоэквалайзер (EQ). Кроме того, добавив эффект реверберации, вы имитируете естественное эхо и затухание, возникающее в физическом пространстве, придавая звуку большую глубину и реалистичность.
Хотя Pedalboard не имеет специального эквалайзера, вы можете комбинировать различные аудиоплагины для достижения желаемого эффекта. Измените сценарий play_diablo.py
, применив реверберацию, фильтр низких частот и усиление к синтезированной звуковой дорожке:
from dataclasses import dataclass
from fractions import Fraction
import numpy as np
from pedalboard import Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
# ...
def save(audio_track, filename):
with AudioFile(filename, "w", audio_track.sampling_rate) as file:
file.write(normalize(apply_effects(audio_track)))
print(f"\nSaved file {filename!r}")
def apply_effects(audio_track: AudioTrack) -> np.ndarray:
effects = Pedalboard([
Reverb(),
LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
Gain(gain_db=6),
])
return effects(audio_track.samples, audio_track.sampling_rate)
if __name__ == "__main__":
main()
Сначала вы импортируете соответствующие плагины из библиотеки и используете их для создания виртуального педалборда. После сборки вы вызываете его на звуковой дорожке и нормализуете полученные семплы перед сохранением их в файл.
Для реверберации используются настройки по умолчанию, а для полочного фильтра нижних частот установлена частота среза 440 Гц, усиление 10 дБ и добротность 1. Усиление настроено на увеличение громкости на 6 дБ. Вы можете поэкспериментировать с различными значениями параметров, чтобы настроить звук по своему вкусу или лучше соответствовать определенному музыкальному жанру.
Когда вы снова запустите сценарий и воспроизведете полученный аудиофайл, вы должны услышать более естественный звук. Ваша цифровая гитара начинает напоминать тон акустической гитары. Однако есть один конкретный эффект, который может иметь реальное значение, и сейчас вы собираетесь его изучить.
Примените фильтр реверберации свертки с IR
Идея сверточной реверберации заключается в имитации реверберации физического пространства с помощью фильтра, использующего импульсную характеристику (IR). Импульсный отклик — это запись акустических характеристик реального места, например концертного зала, церкви или небольшой комнаты. Обычно это короткий звук, похожий на хлопок или хлопок воздушного шара, который отражает реакцию пространства на полный спектр частот.
С помощью этого особого типа реверберации вы можете, например, записать вокал в студии и применить атмосферу величественного собора при постобработке. У вас создастся впечатление, что выступление действительно было записано именно в этом месте. Посетите библиотеку Open AIR, где собрана коллекция высококачественных импульсных откликов из разных мест по всему миру. Вы можете послушать и сравнить версии до и после. Разница заметна!
В контексте гитар импульсные характеристики могут помочь вам имитировать звук различных гитарных усилителей или даже моделировать звук определенных инструментов, таких как банджо или гавайская гитара. Фильтр свертывает необработанный или сухой сигнал с импульсной характеристикой, эффективно впечатывая акустические характеристики исходного инструмента в звук. Это создает очень реалистичный эффект, добавляя глубину и характер вашей цифровой гитаре.
Снова откройте скрипт play_diablo.py
и вставьте сверточный фильтр с путем к файлу импульсной характеристики акустической гитары:
from dataclasses import dataclass
from fractions import Fraction
import numpy as np
from pedalboard import Convolution, Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile
# ...
def apply_effects(audio_track: AudioTrack) -> np.ndarray:
effects = Pedalboard([
Reverb(),
Convolution(impulse_response_filename="ir/acoustic.wav", mix=0.95),
LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
Gain(gain_db=6),
])
return effects(audio_track.samples, audio_track.sampling_rate)
if __name__ == "__main__":
main()
В Интернете можно найти множество бесплатных импульсных откликов для гитар. Однако найти качественный вариант может оказаться непростой задачей. Файлы импульсных характеристик, используемые в этом руководстве, взяты из следующих источников:
- Acoustic Guitar
Tay816 M251 SB1.wav
- Bass Guitar
Святой Грааль C800 SB1.wav
- Electric Guitar
Rocksta Reactions Mesa Traditional D6 D 0 -18 -36.wav
- Banjo
IR_5_string_banjo_dazzo_IR44k
- Ukulele
AtlasV2.wav
Вы можете перейти по ссылкам выше, чтобы найти соответствующие образцы пакетов. Либо загрузите вспомогательные материалы к этому руководству, которые включают в себя отдельные файлы импульсных характеристик с удобными названиями. Загрузив эти файлы, поместите их в подпапку ir/
, где вы храните демонстрационные сценарии:
digital-guitar/
│
├── demo/
│ ├── ir/
│ │ ├── acoustic.wav
│ │ ├── banjo.wav
│ │ ├── bass.wav
│ │ ├── electric.wav
│ │ └── ukulele.wav
│ │
│ ├── play_chorus.py
│ └── play_diablo.py
│
└── (...)
Теперь вы можете обновить другой скрипт, play_chorus.py
, применив аналогичные эффекты и используя соответствующую импульсную характеристику для улучшения синтезированного звука:
from itertools import cycle
from typing import Iterator
from pedalboard import Convolution, Gain, LowShelfFilter, Pedalboard, Reverb
from pedalboard.io import AudioFile
# ...
def main() -> None:
ukulele = PluckedStringInstrument(
tuning=StringTuning.from_notes("A4", "E4", "C4", "G4"),
vibration=Time(seconds=5.0),
damping=0.498,
)
synthesizer = Synthesizer(ukulele)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = Timeline()
for interval, chord, stroke in strumming_pattern():
audio_samples = synthesizer.strum_strings(chord, stroke)
audio_track.add_at(timeline.instant, audio_samples)
timeline >> interval
effects = Pedalboard(
[
Reverb(),
Convolution(impulse_response_filename="ir/ukulele.wav", mix=0.95),
LowShelfFilter(cutoff_frequency_hz=440, gain_db=10, q=1),
Gain(gain_db=15),
]
)
samples = effects(audio_track.samples, audio_track.sampling_rate)
with AudioFile("chorus.mp3", "w", audio_track.sampling_rate) as file:
file.write(normalize(samples))
# ...
Опять же, вы можете поиграть с этими параметрами или даже попробовать разные плагины из библиотеки Pedalboard.
Хорошо. Итак, вы смоделировали акустическую гитару и гавайскую гитару. Как насчет того, чтобы на этот раз сыграть на электрогитаре или бас-гитаре? Как вы вскоре увидите, моделирование этих инструментов в основном сводится к выбору правильных эффектов из библиотеки плагинов и настройке настройки струн и времени вибрации. Чтобы избежать дублирования и повторения кода, с этого момента вы будете хранить аккорды в отдельном файле.
Шаг 7. Загрузите гитарную табулатуру из файла
Существует множество форматов данных гитарных табулатур: от простой вкладки ASCII до более сложных двоичных форматов, таких как Power Tab или Guitar Pro. Для чтения некоторых из них требуется специализированное или проприетарное программное обеспечение. В этом разделе вы создадите свой собственный формат файла, который будет отражать наиболее важные функции вкладок, размещенных на веб-сайте Songsterr. В конце концов, вы довершите это с помощью специального проигрывателя табулатур, чтобы вы могли слышать музыку!
Создайте формат файла для ваших гитарных табулатур
Прежде чем писать одну строку кода, сделайте шаг назад и подумайте, как вы хотите использовать новый формат гитарных табулатур. В частности, какую информацию вы хотите разместить на вкладке и как вы планируете ее представить?
Ниже приведены предлагаемые цели дизайна для вашего пользовательского формата, которые должны быть следующими:
- Удобочитаемый. Формат должен быть удобочитаемым для людей, чтобы вы могли редактировать вкладки в обычном текстовом редакторе.
- Интуитивно понятный. Вам нужен формат, имеющий знакомый синтаксис и легкий процесс обучения, чтобы вы как можно быстрее почувствовали себя как дома.
- Кратко. В большинстве песен повсюду повторяются одни и те же аккорды и узоры, поэтому формат должен эффективно их представлять, чтобы избежать ненужного многословия.
- Иерархический. Формат должен иметь иерархическую структуру, обеспечивающую удобную десериализацию в словарь Python.
- Мульти-дорожка. Файл с одной вкладкой должен позволять вам хранить одну или несколько дорожек, соответствующих виртуальным инструментам, и микшировать их в различных пропорциях.
Если принять во внимание эти требования, то XML, JSON и YAML станут лучшими кандидатами на роль базового формата данных, на основе которого вы можете строить. Все они основаны на тексте, широко известны и имеют иерархическую структуру, позволяющую помещать в них несколько треков. Тем не менее, только YAML отвечает всем требованиям, так как с двумя другими форматами избежать повторения будет непросто.
YAML также является хорошим выбором, поскольку он поддерживает привязки и псевдонимы, которые позволяют повторно использовать повторяющиеся элементы без необходимости их переписывания. Это поможет вам сэкономить много набора текста, особенно в контексте гитарных табулатур!
Взгляните на фрагмент вымышленной гитарной табулатуры ниже, который демонстрирует некоторые особенности вашего формата:
title: Hello, World! # Optional
artist: John Doe # Optional
tracks:
acoustic: # Arbitrary name
url: https://www.songsterr.com/hello # Optional
weight: 0.8 # Optional (defaults to 1.0)
instrument:
tuning: [E2, A2, D3, G3, B3, E4]
vibration: 5.5
damping: 0.498 # Optional (defaults to 0.5)
effects: # Optional
- Reverb
- Convolution:
impulse_response_filename: acoustic.wav
mix: 0.95
tablature:
beats_per_minute: 75
measures:
- time_signature: 4/4
notes: # Optional (can be empty measure)
- frets: [0, 0, 2, 2, 0, ~]
offset: 1/8 # Optional (defaults to zero)
upstroke: true # Optional (defaults to false)
arpeggio: 0.04 # Optional (defaults to 0.005)
vibration: 3.5 # Optional (overrides instrument's defaults)
- time_signature: 4/4
- time_signature: 4/4
notes: &loop
- frets: &seven [~, ~, ~, ~, 7, ~]
- frets: *seven
offset: 1/4
- frets: *seven
offset: 1/4
- frets: *seven
offset: 1/4
- time_signature: 4/4
notes: *loop
# ...
electric:
# ...
ukulele:
# ...
Многие атрибуты являются совершенно необязательными, и большинство из них имеют разумные значения по умолчанию, в том числе:
- Вес. Дорожки на вашей вкладке будут смешаны с весом, равным единице, если вы явно не запросите другой вес.
- Демпфирование. Если вы не укажете демпфирование инструмента, по умолчанию оно будет равно 0,5, что представляет собой простое среднее значение.
- Примечания. Вы можете пропустить ноты, чтобы обозначить пустой такт, что иногда имеет смысл, когда вы хотите синхронизировать несколько инструментов.
- Смещение. Если вы не укажете смещение, соответствующая нота или аккорд будет помещена в любую текущую позицию на временной шкале. Обычно вы опускаете смещение первой ноты в такте, если оно не встречается в такте.
- Upstroke: большинство штрихов направлено вниз, поэтому вам следует устанавливать этот атрибут только в том случае, если вы хотите, чтобы аккорд воспроизводился вверх.
- Арпеджио: Скорость удара или задержка между отдельными ударами в аккорде по умолчанию составляет пять миллисекунд, что довольно быстро.
- Вибрация. Вам нужно установить вибрацию ноты только в том случае, если вы хотите переопределить вибрацию струны по умолчанию, определенную в соответствующем инструменте.
Дополнительные эффекты инструмента представляют собой плагины Pedalboard. Вы можете связать их в определенном порядке для достижения желаемого результата или вообще пропустить их. Каждый эффект должен быть либо именем класса плагина, либо отображением имени класса на соответствующие аргументы конструктора. Вы можете ознакомиться с документацией Pedalboard для получения более подробной информации о том, как настроить эти эффекты.
У каждого трека есть своя табулатура, состоящая из темпа, выраженного как количество ударов в секунду, и списка тактов. В свою очередь, каждый такт содержит размер и список нот или аккордов. Для отдельной ноты должно быть определено как минимум номер лада, на который нужно нажать, поскольку остальные атрибуты являются необязательными. Однако в большинстве экземпляров заметки также указывается смещение в виде доли всей заметки.
Примечание. В YAML вы можете использовать символ тильды (~
), чтобы обозначить отсутствие значения, что более компактно, чем запись null
. Плюс, когда вы щурите глаза, тильда напоминает тире из формата табуляции ASCII.
Якоря и псевдонимы — две наиболее мощные функции YAML. Они позволяют вам один раз определить значение и привязать его к глобальной переменной в документе. Имена переменных должны начинаться с символа амперсанда (&
), и вы можете ссылаться на них, используя звездочку (*
) вместо амперсанда. Если вы программировали на языке C, то это аналогично взятию адреса переменной и разыменованию указателя соответственно.
В приведенном выше примере вы объявляете две глобальные переменные или привязки YAML:
&seven
: Обозначает номера ладов, которые повторяются на протяжении такта.&loop
: Захватывает саму меру, что позволяет использовать один и тот же цикл много раз в композиции.
Это не только экономит место и сокращает набор текста, но и делает документ более удобным в сопровождении. Если вы хотите изменить последовательность, то вам нужно обновить ее только в одном месте, и изменение будет отражено везде, где вы использовали псевдоним.
Увидев образец гитарной табулатуры в формате файла на основе YAML, теперь вы можете загрузить его в Python. Вы сделаете это с помощью библиотеки Pydantic.
Определите модели Pydantic для загрузки из YAML
Создайте родственный пакет с именем tablature
рядом с digitar
, который вы создали ранее. В результате у вас должна получиться следующая структура папок:
digital-guitar/
│
├── demo/
│ ├── ir/
│ │ └── (...)
│ │
│ ├── play_chorus.py
│ └── play_diablo.py
│
├── src/
│ ├── digitar/
│ │ ├── __init__.py
│ │ ├── burst.py
│ │ ├── chord.py
│ │ ├── instrument.py
│ │ ├── pitch.py
│ │ ├── processing.py
│ │ ├── stroke.py
│ │ ├── synthesis.py
│ │ ├── temporal.py
│ │ └── track.py
│ │
│ └── tablature/
│ └── __init__.py
│
├── tests/
│ └── __init__.py
│
├── pyproject.toml
└── README.md
Теперь создайте модуль Python с именем models
и поместите его в новый пакет. Этот модуль будет содержать классы модели Pydantic для вашего формата данных на основе YAML. Начните с моделирования корневого элемента документа, который вы назовете Song
:
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import BaseModel
class Song(BaseModel):
title: Optional[str] = None
artist: Optional[str] = None
tracks: dict[str, Track]
@classmethod
def from_file(cls, path: str | Path) -> Self:
with Path(path).open(encoding="utf-8") as file:
return cls(**yaml.safe_load(file))
Корневой элемент документа имеет два необязательных атрибута: .title
и .artist
, а также обязательный словарь .tracks
. Последний сопоставляет произвольные имена дорожек с экземплярами Track
, которые вы реализуете чуть позже. Класс также предоставляет метод для загрузки документов YAML из файла, указанного либо строкой, либо экземпляром Path
, и десериализации их в объект модели.
Поскольку Python читает исходный код сверху вниз, вам необходимо определить Track
перед вашей моделью Song
, которая от этого зависит:
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import BaseModel, HttpUrl, NonNegativeFloat, model_validator
class Track(BaseModel):
url: Optional[HttpUrl] = None
weight: Optional[NonNegativeFloat] = 1.0
instrument: Instrument
tablature: Tablature
@model_validator(mode="after")
def check_frets(self) -> Self:
num_strings = len(self.instrument.tuning)
for measure in self.tablature.measures:
for notes in measure.notes:
if len(notes.frets) != num_strings:
raise ValueError("Incorrect number of frets")
return self
class Song(BaseModel):
# ...
Экземпляр Track
состоит из пары необязательных атрибутов .url
и .weight
и пары обязательных атрибутов .instrument.
и .tablature
. Вес представляет собой относительную громкость трека в финальном миксе. Декорированный метод .check_frets()
проверяет, соответствует ли количество ладов в каждом такте количеству струн в инструменте.
Модель Instrument
отражает ваш тип digitar.PluckedStringInstrument
, дополняя его цепочкой плагинов Pedalboard:
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
confloat, conlist, constr, model_validator)
DEFAULT_STRING_DAMPING: float = 0.5
class Instrument(BaseModel):
tuning: conlist(constr(pattern=r"([A-G]#?)(-?\d+)?"), min_length=1)
vibration: PositiveFloat
damping: Optional[confloat(ge=0, le=0.5)] = DEFAULT_STRING_DAMPING
effects: Optional[tuple[str | dict, ...]] = tuple()
class Track(BaseModel):
# ...
class Song(BaseModel):
# ...
Атрибут .tuning
представляет собой список по крайней мере из одного элемента, ограниченного строковым типом данных, соответствующим регулярному выражению музыкальной ноты в научной нотации высоты тона. .vibration
указывает, как долго в секундах струны инструмента должны вибрировать по умолчанию. При необходимости вы можете переопределить это значение для каждого хода. .damping
— это значение с плавающей запятой, ограниченное указанным интервалом и по умолчанию равное значению, хранящемуся в константе.
Ваша следующая модель, Tablature
, состоит всего из двух атрибутов:
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
PositiveInt, confloat, conlist, constr, model_validator)
DEFAULT_STRING_DAMPING: float = 0.5
class Tablature(BaseModel):
beats_per_minute: PositiveInt
measures: tuple[Measure, ...]
class Instrument(BaseModel):
# ...
class Track(BaseModel):
# ...
class Song(BaseModel):
# ...
И .beats_per_mine
, и .measures
являются обязательными. Первый атрибут – это положительное целое число, обозначающее темп песни в ударах в минуту. Второй атрибут — это кортеж, содержащий один или несколько объектов Measure
, которые вы можете реализовать сейчас:
from fractions import Fraction
from functools import cached_property
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, PositiveFloat,
PositiveInt, confloat, conlist, constr, model_validator)
DEFAULT_STRING_DAMPING: float = 0.5
class Measure(BaseModel):
time_signature: constr(pattern=r"\d+/\d+")
notes: Optional[tuple[Note, ...]] = tuple()
@cached_property
def beats_per_measure(self) -> int:
return int(self.time_signature.split("/")[0])
@cached_property
def note_value(self) -> Fraction:
return Fraction(1, int(self.time_signature.split("/")[1]))
class Tablature(BaseModel):
# ...
class Instrument(BaseModel):
# ...
class Track(BaseModel):
# ...
class Song(BaseModel):
# ...
Для каждой Меры
разрешено указывать свой собственный .time_signature
с дробной записью, например 4/4
. Кортеж .notes
не является обязательным, поскольку мера может быть пустой. Два кэшированных свойства извлекают количество долей в такте и значение ноты из тактового размера.
Наконец, вы можете записать свою последнюю модель, представляющую ноту или аккорд, для игры на виртуальной гитаре:
from fractions import Fraction
from functools import cached_property
from pathlib import Path
from typing import Optional, Self
import yaml
from pydantic import (BaseModel, HttpUrl, NonNegativeFloat, NonNegativeInt,
PositiveFloat, PositiveInt, confloat, conlist, constr,
model_validator)
DEFAULT_STRING_DAMPING: float = 0.5
DEFAULT_ARPEGGIO_SECONDS: float = 0.005
class Note(BaseModel):
frets: conlist(NonNegativeInt | None, min_length=1)
offset: Optional[constr(pattern=r"\d+/\d+")] = "0/1"
upstroke: Optional[bool] = False
arpeggio: Optional[NonNegativeFloat] = DEFAULT_ARPEGGIO_SECONDS
vibration: Optional[PositiveFloat] = None
class Measure(BaseModel):
# ...
class Tablature(BaseModel):
# ...
class Instrument(BaseModel):
# ...
class Track(BaseModel):
# ...
class Song(BaseModel):
# ...
Эта модель имеет только один обязательный атрибут, .frets
, который представляет собой список номеров ладов, ограниченный либо None
, либо неотрицательными целочисленными элементами. .offset
примечания должно быть указано как часть всей примечания, например 1/8
. В противном случае оно по умолчанию равно нулю. Остальные атрибуты включают .upstroke
, .arpeggio
и .vibration
, которые описывают, как воспроизводить штрих.
С помощью этих моделей вы можете загрузить примеры гитарных табулатур, представленные во вспомогательных материалах. Например, один из включенных файлов YAML основан на вкладке Songsterr для песни Foggy Mountain Breakdown Эрла Скраггса с банджо, акустической гитарой и бас-гитарой:
>>> from tablature.models import Song
>>> song = Song.from_file("demo/tabs/foggy-mountain-breakdown.yaml")
>>> sorted(song.tracks)
['acoustic', 'banjo', 'bass']
>>> banjo = song.tracks["banjo"].instrument
>>> banjo.tuning
['G4', 'D3', 'G3', 'B3', 'D4']
>>> banjo_tab = song.tracks["banjo"].tablature
>>> banjo_tab.measures[-1].notes
(
Note(
frets=[None, None, 0, None, None],
offset='0/1',
upstroke=False,
arpeggio=0.005,
vibration=None
),
Note(
frets=[0, None, None, None, 0],
offset='1/2',
upstroke=False,
arpeggio=0.005,
vibration=None
)
)
Вы читаете файл YAML с гитарной табулатурой и десериализуете его в иерархию моделей Pydantic. Затем вы получаете доступ к треку, связанному с табулатурой банджо, и отображаете ноты в последнем такте.
Далее вы создадите проигрыватель, который сможет брать эти модели, переводить их в область вашей цифровой гитары и воспроизводить синтезированный аудиофайл. Готовы ли вы принять вызов?
Реализация средства чтения гитарных табулатур
Определите раздел сценариев в файле pyproject.toml
с точкой входа в ваш проект Python, который вы позже запустите из командной строки:
# ...
[tool.poetry.scripts]
play-tab = "tablature.player:main"
Здесь определяется команда play-tab
, указывающая на новый модуль с именем player
в пакете tablature
. Теперь вы можете создать этот модуль, реализовав в нем следующие несколько функций:
from argparse import ArgumentParser, Namespace
from pathlib import Path
from tablature import models
SAMPLING_RATE = 44100
def main() -> None:
play(parse_args())
def parse_args() -> Namespace:
parser = ArgumentParser()
parser.add_argument("path", type=Path, help="tablature file (.yaml)")
parser.add_argument("-o", "--output", type=Path, default=None)
return parser.parse_args()
def play(args: Namespace) -> None:
song = models.Song.from_file(args.path)
Функция main()
— это то, что Poetry будет вызывать, когда вы вызываете poetry run play-tab
в своем терминале. Эта функция анализирует аргументы командной строки с помощью argparse
и передает их функции play()
, которая загружает песню из указанного файла YAML через вашу модель Pydantic.
Вы должны указать путь к гитарной табулатуре в качестве позиционного аргумента, а также можете указать путь к выходному аудиофайлу в качестве опции. Если вы этого не сделаете, результирующий файл будет иметь то же базовое имя, что и ваш входной файл.
Загрузив вкладку в Python, вы можете интерпретировать ее, синтезируя отдельные треки:
from argparse import ArgumentParser, Namespace
from pathlib import Path
import numpy as np
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
from tablature import models
# ...
def play(args: Namespace) -> None:
song = models.Song.from_file(args.path)
tracks = [
track.weight * synthesize(track)
for track in song.tracks.values()
]
def synthesize(track: models.Track) -> np.ndarray:
synthesizer = Synthesizer(
instrument=PluckedStringInstrument(
tuning=StringTuning.from_notes(*track.instrument.tuning),
damping=track.instrument.damping,
vibration=Time(track.instrument.vibration),
),
sampling_rate=SAMPLING_RATE,
)
audio_track = AudioTrack(synthesizer.sampling_rate)
timeline = MeasuredTimeline()
read(track.tablature, synthesizer, audio_track, timeline)
return apply_effects(audio_track, track.instrument)
Вы используете понимание списка для синтеза каждой дорожки и умножения полученного массива сэмплов NumPy на вес дорожки.
Функция synthesize()
создает объект синтезатора на основе определения инструмента в треке. Затем он читает соответствующую табулатуру, размещая примечания на временной шкале. Наконец, он применяет специальные эффекты с помощью Pedalboard перед возвратом аудиосэмплов вызывающему абоненту.
Функция read()
автоматизирует действия, которые вы ранее выполняли вручную, когда программно играли на вкладке Diablo:
from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path
import numpy as np
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
from tablature import models
# ...
def read(
tablature: models.Tablature,
synthesizer: Synthesizer,
audio_track: AudioTrack,
timeline: MeasuredTimeline,
) -> None:
beat = Time(seconds=60 / tablature.beats_per_minute)
for measure in tablature.measures:
timeline.measure = beat * measure.beats_per_measure
whole_note = beat * measure.note_value.denominator
for note in measure.notes:
stroke = Velocity.up if note.upstroke else Velocity.down
audio_track.add_at(
(timeline >> (whole_note * Fraction(note.offset))).instant,
synthesizer.strum_strings(
chord=Chord(note.frets),
velocity=stroke(delay=Time(note.arpeggio)),
vibration=(
Time(note.vibration) if note.vibration else None
),
),
)
next(timeline)
Он начинается с определения длительности удара в секундах. На основании этого функция вычисляет длительность текущего такта и целой ноты на вкладке. Затем он перебирает каждую ноту в такте, синтезирует соответствующий аккорд и добавляет его на звуковую дорожку в расчетное время. После каждой итерации функция вызывает next()
на временной шкале, чтобы перейти к следующему показателю.
Следующие три функции работают совместно для импорта и применения нужных плагинов из библиотеки Pedalboard на основе объявлений в файле YAML:
from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path
import numpy as np
import pedalboard
# ...
def apply_effects(
audio_track: AudioTrack, instrument: models.Instrument
) -> np.ndarray:
effects = pedalboard.Pedalboard(get_plugins(instrument))
return effects(audio_track.samples, audio_track.sampling_rate)
def get_plugins(instrument: models.Instrument) -> list[pedalboard.Plugin]:
return [get_plugin(effect) for effect in instrument.effects]
def get_plugin(effect: str | dict) -> pedalboard.Plugin:
match effect:
case str() as class_name:
return getattr(pedalboard, class_name)()
case dict() as plugin_dict if len(plugin_dict) == 1:
class_name, params = list(plugin_dict.items())[0]
return getattr(pedalboard, class_name)(**params)
Первая функция применяет эффекты, связанные с конкретным инструментом, к звуковой дорожке. Он создает объект Pedalboard
из плагинов, полученных из инструмента трека. Последняя функция возвращает экземпляр плагина на основе его имени и при необходимости инициализирует его с параметрами, указанными в документе табулатуры.
Теперь вы можете микшировать синтезированные треки и сохранять их в файл. Для этого вам нужно изменить функцию play()
:
from argparse import ArgumentParser, Namespace
from fractions import Fraction
from pathlib import Path
import numpy as np
import pedalboard
from digitar.chord import Chord
from digitar.instrument import PluckedStringInstrument, StringTuning
from digitar.processing import normalize
from digitar.stroke import Velocity
from digitar.synthesis import Synthesizer
from digitar.temporal import MeasuredTimeline, Time
from digitar.track import AudioTrack
from pedalboard.io import AudioFile
from tablature import models
# ...
def play(args: Namespace) -> None:
song = models.Song.from_file(args.path)
samples = normalize(
np.sum(
pad_to_longest(
[
track.weight * synthesize(track)
for track in song.tracks.values()
]
),
axis=0,
)
)
save(
samples,
args.output or Path.cwd() / args.path.with_suffix(".mp3").name,
)
def pad_to_longest(tracks: list[np.ndarray]) -> list[np.ndarray]:
max_length = max(array.size for array in tracks)
return [
np.pad(array, (0, max_length - array.size)) for array in tracks
]
def save(samples: np.ndarray, path: Path) -> None:
with AudioFile(str(path), "w", SAMPLING_RATE) as file:
file.write(samples)
print(f"Saved file {path.absolute()}")
# ...
Поскольку отдельные дорожки могут различаться по длине, вы дополняете их, чтобы убедиться, что все они имеют одинаковую длину, прежде чем добавлять их амплитуды с помощью np.sum()
и нормализовать их значения. Наконец, вы сохраняете аудиосэмплы в файл, вызывая функцию save()
.
Однако чтобы гарантировать, что относительные пути в вашем документе YAML будут работать должным образом, вам следует временно изменить текущий рабочий каталог скрипта:
import os
from argparse import ArgumentParser, Namespace
from contextlib import contextmanager
from fractions import Fraction
from pathlib import Path
# ...
def play(args: Namespace) -> None:
song = models.Song.from_file(args.path)
with chdir(args.path.parent):
samples = normalize(
np.sum(
pad_to_longest(
[
track.weight * synthesize(track)
for track in song.tracks.values()
]
),
axis=0,
)
)
save(
samples,
args.output or Path.cwd() / args.path.with_suffix(".mp3").name,
)
@contextmanager
def chdir(directory: Path) -> None:
current_dir = os.getcwd()
os.chdir(directory)
try:
yield
finally:
os.chdir(current_dir)
# ...
Вы указываете контекстный менеджер на основе функций, который вызываете с помощью оператора with
, чтобы установить в качестве рабочего каталога родительскую папку файла YAML. Без него вы не сможете найти и загрузить файлы импульсных характеристик для плагина свертки Pedalboard.
Хорошо. Ниже описано, как использовать скрипт play-tab
в терминале. Не забудьте переустановить проект Poetry, чтобы точка входа, определенная в pyproject.toml
, вступила в силу:
$ poetry install
$ poetry run play-tab demo/tabs/foggy-mountain-breakdown.yaml -o foggy.mp3
Saved file /home/user/digital-guitar/foggy.mp3
Если вы опустите опцию имени выходного файла (-o
), результирующий файл будет использовать то же имя, что и входной файл, но с расширением .mp3
.
Вот как будет звучать сэмпл табулатуры, состоящий из трех инструментальных треков, когда вы пропустите его через синтезатор:
Отличная работа! Если вы зашли так далеко, то спасибо вам за вашу решимость и настойчивость. Надеемся, это было веселое и полезное путешествие, которое помогло вам узнать что-то новое.
Заключение
Поздравляем с завершением этого сложного проекта! Вы успешно реализовали алгоритм синтеза щипковых струн и программу чтения гитарных табулатур, чтобы играть реалистичную музыку на Python. И попутно вы получили значительное представление об основополагающей теории музыки. Возможно, вы даже почувствовали вдохновение взять в руки настоящую гитару и начать играть. Кто знает?
В этом уроке вы:
- Реализован алгоритм синтеза щипковых струн Karplus-Strong.
- Имитация различных типов струнных инструментов и их настроек.
- Объединение нескольких вибрирующих струн в полифонические аккорды
- Имитация реалистичной техники игры на гитаре и игры пальцами.
- Использованы импульсные характеристики реальных инструментов, чтобы воспроизвести их уникальный тембр.
- Чтение музыкальных нот из научной нотации и гитарных табулатур.
Полный исходный код этого проекта, включая снимки отдельных шагов, образцы табулатур и файлы импульсных характеристик, вы найдете во вспомогательных материалах. Чтобы получить их, воспользуйтесь ссылкой ниже: