Поиск по сайту:

Создайте гитарный синтезатор: играйте музыкальные табулатуры на Python


Вы когда-нибудь хотели сочинять музыку без дорогостоящего оборудования и профессиональной студии? Возможно, вы раньше пробовали играть на музыкальном инструменте, но обнаружили, что ловкость рук требует слишком много усилий и времени. Если да, возможно, вас заинтересует использование возможностей Python для создания гитарного синтезатора. Выполнив несколько относительно простых шагов, вы сможете превратить свой компьютер в виртуальную гитару, способную играть любую песню.

В этом уроке вы:

  • Реализовать алгоритм синтеза щипковых струн Karplus-Strong.
  • Имитируйте различные типы струнных инструментов и их настройку.
  • Объедините несколько вибрирующих струн в полифонические аккорды.
  • Имитируйте реалистичную технику игры на гитаре и игры пальцами.
  • Используйте импульсные характеристики реальных инструментов, чтобы воспроизвести их уникальный тембр.
  • Чтение музыкальных нот из научной нотации и гитарных табулатур.

В любой момент вы можете загрузить полный исходный код гитарного синтезатора, а также образцы табулатур и другие ресурсы, которые вы будете использовать в этом руководстве. Они могут оказаться полезными, если вы хотите более подробно изучить код или начать работу. Чтобы скачать бонусные материалы прямо сейчас, перейдите по следующей ссылке:

Демо: гитарный синтезатор на Python

В этом пошаговом руководстве вы создадите синтезатор щипковых струнных инструментов на основе алгоритма Карплюса-Стронга на Python. Попутно вы создадите ансамбль виртуальных инструментов, в том числе акустическую, бас-гитару и электрогитару, а также банджо и гавайскую гитару. Затем вы создадите специальную программу чтения гитарных вкладок, чтобы можно было воспроизводить любимые песни.

К концу этого урока вы сможете синтезировать музыку из гитарных табулатур или, для краткости, гитарных табулатур, которые представляют собой упрощенную форму нотной записи, которая позволяет вам играть музыку без необходимости учиться читать стандартные ноты. Наконец, вы сохраните результат в файле MP3 для воспроизведения.

Ниже представлена короткая демонстрация работы синтезатора, воссоздающего культовые саундтреки из классических видеоигр, таких как Doom и Diablo. Нажмите кнопку воспроизведения, чтобы прослушать образец вывода:

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

Обзор проекта

Для вашего удобства проект, который вы собираетесь создать, вместе с его сторонними зависимостями будет управляться Poetry. Проект будет содержать два пакета Python с совершенно разными сферами ответственности:

  1. digitar: Для синтеза звука цифровой гитары.
  2. табулатура: для чтения и интерпретации гитарной табулатуры из файла.

Вы также разработаете и внедрите собственный формат данных для хранения гитарных табулатур на диске или в памяти. Это позволит вам воспроизводить музыку на основе достаточно стандартной табулатурной нотации, которую вы найдете в различных местах Интернета. Ваш проект также предоставит скрипт 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 для повышения точности. Вы можете создать экземпляры нового класса, предоставив секунды через его конструктор или вызвав метод класса, который ожидает миллисекунды и преобразует их в секунды, завернутые в соответствующий тип данных.

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, где конкретные классы, реализующие этот конкретный интерфейс, обеспечивают базовую логику.

В этом случае вы объявили специальный метод .__call__(), чтобы сделать экземпляры классов, соответствующих протоколу, вызываемыми. Ваш метод ожидает два аргумента:

  1. Количество аудиосэмплов для создания
  2. Количество выборок в секунду

Кроме того, генераторы пакетов предназначены для возврата массива 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.

Используя пакет стандартной библиотеки 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(), который принимает основную частоту вибрации, длительность и необязательный коэффициент демпфирования. в качестве аргументов. Если не указать значение коэффициента по умолчанию, сумма двух соседних выборок уменьшается вдвое с каждым циклом, что аналогично вычислению скользящего среднего. Он имитирует потерю энергии по мере затухания вибрации.

Пока что ваш метод определяет внутреннюю функцию, которая при вызове возвращает итератор-генератор. Результирующий объект-генератор выделяет и заполняет буфер, используя предоставленный пакетный генератор. Затем функция входит в бесконечный цикл 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, чтобы обеспечить высокую точность и избежать ненужных преобразований типов.

Вы почти завершили реализацию алгоритма синтеза Карплюса-Стронга, но в вашем коде есть две незначительные проблемы, которые необходимо устранить в первую очередь.

Удалите смещение постоянного тока и нормализуйте аудиосэмплы

В зависимости от начального импульса и коэффициента затухания вы можете получить значения, выходящие за пределы ожидаемого диапазона амплитуд, или значения могут отклоняться от нуля, создавая смещение постоянного тока. Это может привести к слышимым щелчкам или другим неприятным артефактам. Чтобы устранить эти потенциальные проблемы, вы устраните смещение, вычитая среднее значение сигнала, а затем нормализуете полученные выборки.

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 одинакового размера, содержащих амплитуды нескольких звуков. Затем метод возвращает поэлементную арифметическую сумму входных звуковых волн.

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

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

>>> 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

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

Вот тот же пример, который вы видели в предыдущем разделе. Однако теперь у вас есть задержка в сорок миллисекунд между отдельными нажатиями, и вы меняете продолжительность вибрации в зависимости от ее частоты:

>>> 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 заставляет вас мыслить в терминах высоты звука, полутона и октавы, что не самое удобное. Вы поместите презентацию в класс более высокого уровня внутри нового модуля с именем 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 справа, что также начинает следующую октаву, вы удвоите эту частоту.

Теперь вы можете расшифровать научную нотацию высоты тона. Например, 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.

Вот как вы можете определить аккорд из предыдущего раздела этого руководства, используя класс 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. Если вам интересно узнать больше о том, как играть именно эту песню, посмотрите гораздо более подробное видеоруководство на родственном канале Адриана.

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

  1. До мажор: нажмите на третий лад первой струны.
  2. Соль мажор: нажмите на второй лад первой струны, на третий лад второй струны и на второй лад третьей струны.
  3. Ля минор: нажмите на второй лад четвертой струны.
  4. Фа мажор: нажмите на первый лад второй струны и на второй лад четвертой струны.

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

  1. Ход вниз (медленный)
  2. Ход вниз (медленный)
  3. Ход вверх (медленный)
  4. Ход вверх (быстрый)
  5. Ход вниз (быстрый)
  6. Ход вверх (медленный)

Другими словами, вы начинаете с того, что кладете пальцы на гриф, чтобы сформировать нужный аккорд, а затем продолжаете поглаживать струны в заданном порядке. Когда вы достигнете конца этого паттерна, вы промываете его и повторяете, играя его еще раз для того же аккорда. После того, как вы дважды проиграете образец для определенного аккорда, вы переходите к следующему аккорду в последовательности.

Последующие штрихи в узоре располагаются примерно через такие интервалы времени в секундах:

           
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 с обновленными десятичными секундами.

@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 прямо сейчас. На снимке экрана ниже показаны первые четыре такта, а также аннотированы наиболее важные элементы гитарной вкладки:

Табулатура выше начинается с обозначений струн, соответствующих стандартной настройке гитары, тактового размера ⁴⁄₄ и темпа семидесяти пяти ударов в минуту. >. Каждый такт пронумерован и отделен от соседей вертикальной линией, чтобы помочь вам ориентироваться при чтении ноты.

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

Опираясь на эти знания, вы сможете интерпретировать предоставленную вкладку и вдохнуть в нее жизнь с помощью реализованного вами гитарного синтезатора. Первоначально вы жестко закодируете гитарную табулатуру 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)

Выделенные константы представляют собой единственные входные параметры, которые вы можете изменить, а остальные значения извлекаются из этих входных данных. Здесь вы группируете логически связанные значения в общих пространствах имен, определяя их как атрибуты класса. Соответствующие имена классов сообщают вам об их назначении.

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

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()

Вы повторно используете объект акустической гитары из предыдущих разделов, который применяет стандартную настройку, и определяете вспомогательную функцию для сохранения полученного звука в файле.

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

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. Они позволяют вам один раз определить значение и привязать его к глобальной переменной в документе. Имена переменных должны начинаться с символа амперсанда (&), и вы можете ссылаться на них, используя звездочку (*) вместо амперсанда. Если вы программировали на языке C, то это аналогично взятию адреса переменной и разыменованию указателя соответственно.

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

  1. &seven: Обозначает номера ладов, которые повторяются на протяжении такта.
  2. &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.
  • Имитация различных типов струнных инструментов и их настроек.
  • Объединение нескольких вибрирующих струн в полифонические аккорды
  • Имитация реалистичной техники игры на гитаре и игры пальцами.
  • Использованы импульсные характеристики реальных инструментов, чтобы воспроизвести их уникальный тембр.
  • Чтение музыкальных нот из научной нотации и гитарных табулатур.

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