Chidodj

Сегодня мы займёмся одной интересной затеей, которая пришла мне в голову, уже достаточно давно, когда я впервые увидел, как воспроизводят музыку на двигателях, в частности, играют Имперский марш из Звёздных войн, на приводах 3,5-дюймовых дискет, и не только, посылая с помощью микроконтроллера, высокочастотные сигналы на двигатель, издающий при этом звук.

Только, обычно, этот звук двигателей является отрицательным явлением, благодаря чему пользователям даже приходится устройство с этими двигателями (например, ЧПУ-станок или 3D принтер), ставить в другую комнату, чтобы они не докучали.

Мы же заставим этот звук служить нашим интересам, ублажая наши чресла наш слух. :-D

Посему: а сделаем ка, универсальный конвертер/генератор музыки, для игры на двигателях! Никто ведь не против? Нет? Ок, тогда поехали...:-D

Что, как и зачем

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

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

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

Среди них, я бы выделил два наиболее очевидных варианта:

  1. оцифровка какого-либо звука или музыки, с определённым битрейтом (этим параметром можно регулировать качество), и сохранение этой звуковой дорожки в микропроцессорное устройство, которое и будет управлять процессом проигрывания.

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

  2. воспроизведение музыки, в формате midi: для микроконтроллеров с малым объёмом памяти это способ видится одним из самых интересных: в его рамках, надо оперировать только ограниченным числом нот и последовательностью их воспроизведения, варьируя, кроме этого, продолжительностью их звучания, и промежутками между ними.

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

Как показали дальнейшие эксперименты, этот выбранный путь (в рамках обозначенных целей — воспроизведения простых звуков), оказался более чем верным: я пытался загружать разные треки в микроконтроллер, однако, несмотря на то, что некоторые треки были длиной в минуту и более, — они у меня никогда не занимали где-то более 15-18% памяти микроконтроллера! Неплохой результат! :-)

Кстати о микроконтроллере: в качестве объекта для загрузки, я выбрал микроконтроллер esp32 wroom32, которому был подключен драйвер двигателя, где, уже к нему, был подключен шаговый двигатель типоразмера Nema 17.

То есть, другими словами, я решил играть музыку на шаговом двигателе — продолжить, так сказать, «традиции олдов»  :-) 

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

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

Кстати, тут надо ещё отметить такой момент, а зачем это вообще может понадобиться кому-то (играть музыку с помощью двигателя)?

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

Например, как вам понравится такое: загрузить трек из какой-нибудь компьютерной игры, который будет воспроизводиться на двигателе ЧПУ станка или самодельного робота, после завершения работы — какой-нибудь «mission complete»  или «stage clear»-саундтрек, из компьютерной игры. 

Или, скажем, издание двигателем звуков, синхронно, с переходом юзера из одного меню дистанционного беспроводного пульта управления — в другой/переключение режимов и т.д. 

Или даже проигрывание грустной музыки, в стиле «game over» — если что-то пошло не так! :-D 

Так что тут возникает много интересных возможностей — ограниченных только вашей фантазией...

К тому же, такой подход (музыка на двигателях) очень редко применялся ранее в самодельных проектах (в основном, насколько я понимаю, в виду сложности и комплексности задачи, в которой необходимо разобраться, чтобы успешно создать такое решение) — ведь необходимо было понимать не только в программировании, но и хорошо знать теорию музыки и, понять, как это теперь всё соединить воедино... 

Поэтому, ранее, подобным баловались только senior-ы embedded-направления. 

Но, всё меняется — теперь, вы можете тоже попробовать;-)

Забегая вперёд, скажу, что, кажется, у меня получилось (но потребовало просто какого-то безумного количества итераций – более 100!) :-))

Но, сначала немного дополнительной полезной информации...

Краткая теория воспроизведения звука на двигателях

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

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

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

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

Опытным путём, чисто практически мной было выявлено, что наиболее громкое излучение звука достигается, если двигатель положен на бок, то есть, он упирается в твёрдую поверхность пластинами статора — в качестве такой жёсткой поверхности я использовал обычный письменный стол, на котором двигатель и лежал. 

Если же положить двигатель иным способом, например, коснуться ротором поверхности стола, или же, положить его на задний торец — звук становится очень тихим или почти исчезает. 

Кроме того, можно играть громкостью звука, чисто программным способом, используя частоту шагов, форму питающего тока (шаг, микрошаг) — сразу скажу, что эта часть (программное усиление громкости звука) у меня не особо оптимизирована, так что тут есть некоторые возможности для улучшений;-)

Что такое MIDI, совсем кратко

Musical Instrument Digital Interface (MIDI) предназначен для кодирования в цифровом формате звуковых событий, а не непосредственно самого звука — и этим он отличается от собственно звуковых форматов (mp3, wav и т.д.), кодируя звук в виде нот, их длительности, громкости, и инструментов, на которых они должны быть воспроизведены, — то есть, говоря другими словами, в рамках этого интерфейса оперируют инструкциями для создания музыки, а не непосредственно самой звуковой информацией. 

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

Midi-файл может содержать одну или несколько звуковых дорожек, каждая из которых, в свою очередь, содержит последовательность определённых событий, помеченных временной меткой: такие события могут быть представлены:

  • управляющими (смена громкости, включение/отключение эффектов и т.д.);

  • нотными (начало и конец воспроизведения ноты);

  • мета-событиями (как пример — изменение темпа воспроизведения).

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

Я здесь рассказал совсем кратко, применительно к конвертеру, о котором пойдёт речь ниже, вы же, при желании более глубоко ознакомиться с форматом midi, можете пройти вот по этой ссылке.

Браузерный обработчик-конвертер midi- звукового файла в программу под Arduino IDE, для загрузки в esp32 wroom32

Итак, что получилось в итоге?

Принцип действия

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

Далее, код анализирует файл, с целью определить, на какой дорожке, какие инструменты расположены. 

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

Например: для бас-гитары (низкие ноты), скрипки (высокие ноты), пианино (широкий диапазон — в зависимости от того, где расположена клавиша, может играть как высокие, так и низкие ноты). 

Кроме того, дополнительно анализируются косвенные признаки, например, если можно так сказать, «рисунок воспроизведения» — насколько часто и как ритмично повторяются ноты (так, к примеру, можно выявить барабаны), насколько продолжительно и в какой последовательности (например, быстрое воспроизведение, перебором нот по очереди — выявляет арфу).

Но, сразу нужно сказать, что всё описанное выше определение предположительное, и довольно примерное, поэтому, нельзя его назвать на 100% верным, и это делается просто «для справки пользователя» — чтобы хоть примерно понимать, какие дорожки, с какими инструментами стоит оставить звучащими, а какие стоит выключить (зачем это делать — об этом ещё будет ниже).

Далее, происходит фильтрация нот, согласно настройкам пользователя: включение/отключение фильтрации ударных, фильтрация за пределами частотного диапазона (можем заставить исполняться мелодию только в более басовом/среднем/высоком звучании), и за пределами границ временного диапазона (проще говоря — можно вырезать требуемый кусок, для загрузки в микроконтроллер; это полезная «фишка» , так как зачастую, требуется маленький, особо приглянувшейся фрагмент, а не вся мелодия). 

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

В ходе генерации, в частности, создаётся три массива (PROGMEM), для хранения частот нот, их продолжительности и пауз между ними.

В ходе экспериментов, для проигрывания музыки на шаговом двигателе, применялся самый простой драйвер двигателя (HG7881CP), так называемый «Н-мост» , которому были подключены четыре вывода шагового двигателя:

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

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

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

Интерфейс

Интерфейс программы выглядит следующим образом.

Вот так, до загрузки midi-трека:

А вот так — после загрузки трека (картинка ниже). Как мы видим, появилась некоторая информация о треке, в самом верху интерфейса, а также, ниже надписи "Фильтровать ударные (канал 10)" отобразились дорожки, которые были в этом треке. Слева от каждой дорожки есть галочка, нажимая/отжимая которую можно включать/отключать конкретную дорожку. Кроме того, в самом низу появился сгенерированный код для микроконтроллера, который можно скачать (нажав зелёную кнопку «Скачать код») или скопировать, нажав синюю кнопку «Копировать», в правом верхнем углу окна с кодом:

Включение/отключение дорожек

Как можно видеть, в верхней части программы находится общая краткая теория, чтобы, для тех, кто в первый раз сталкивается с midi-форматом, было несколько более понятно происходящее.

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

Однако, теория теорией, но, практическая жизнь вносит свои коррективы: та последовательность, как должны быть расположены инструменты, и как это показано на голубой вкладке — вовсе не факт, что так будет повторяться в реальной жизни! :-) 

Разработчик конкретного трека, может располагать их, как ему взбредёт в голову (и это вы ещё увидите, в результате своих собственных экспериментов) :-). 

Например, вы загружаете трек, и ожидаете, что на первом канале будет фортепиано — ан нет, например, неожиданно, вы видите, что первый канал, к примеру, оказывается вообще пустым и т.д. — то есть, в реальности, вы увидите всё что угодно.

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

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

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

Каким образом мы узнаём, на каком канале что в реальности расположено: в этот браузерный конвертер встроен автоматический анализатор, который при загрузке midi-трека, производит анализ дорожек, которые содержит этот трек, и, предположительно, отображает инструменты, обнаруженные в реальности.

Тут ещё нужна небольшая справка: а зачем вообще я сделал так, чтобы дорожки отображались в интерфейсе?

Не из-за праздного же интереса, чтобы просто узнать, как устроен конкретный midi-файл?

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

Поэтому, требуется опытным путём включать/отключать определенные дорожки, подобрав такое их сочетание, которое будет звучать узнаваемо (если вы хотите, воспроизвести какую-то известную мелодию, и, нужно, чтобы люди её узнали). 

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

То есть, нужно экспериментировать, а эта возможность с включением/отключением дорожек (нажимая на галочки слева от них) — просто одна из таких возможностей, для варьирования и экспериментов...

Урезание/расширение диапазона звучащих частот
(ползунки «Максимальная частота», «Минимальная частота» )

Вариант упрощения/усложнения трека, который может быть осуществлён, с применением способа выше, — включением/отключением отдельных каналов, можно рассматривать как более грубый вариант настройки. 

Если нужен более тонкий вариант, это можно осуществить ещё дополнительно с помощью, урезания/расширения диапазона частот — используя соответствующие ползунки (максимальная частота/минимальная частота). Эти изменения отображаются в коде, предназначенном для загрузки в микроконтроллер.

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

Темп

С помощью ползунка «темп» можно настраивать скорость воспроизведения трека: уменьшив его скорость до 50% от текущего, или увеличив на 200% от текущего. Эти изменения отображаются в коде, предназначенном для загрузки в микроконтроллер.

Громкость

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

Фильтрация ударных

Почти в самом низу интерфейса, над окном с кодом, можно видеть галочку «Автоматически фильтровать ударные (канал 10)»  — если её поставить, и если в конкретном midi-треке, есть ударные (ожидается, что они будут на 10 канале — но это вовсе не факт, как мы знаем :-D), то они будут отфильтрованы (убраны) — к слову, в аналогичных же целях, как можно видеть, в частотной фильтрации — минимальная частота установлена по умолчанию на 100 герц. 

Это также сделано с целью отфильтровать ударные, если они будут расположены на ином канале (не на десятом). 

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

А иной раз, наоборот, очень хорошо оставить ударные — басовито так «прёт», аж двигатель со стола норовит спрыгнуть…:-)))

Эти изменения отображаются в коде, предназначенном для загрузки в микроконтроллер.

В общем — жмите, отжимайте галочку, пробуйте, экспериментируйте и да пребудет с вами сила…

ВАЖНО – РЕШЕНИЕ ПРОБЛЕМ: как я и говорил выше, в реальных midi-треках творится всё, что угодно (да вы и сами это увидите), а парсер не умеет понимать «все ситуации на свете».

Поэтому: если вы нажали на «Воспроизвести» в предпрослушивании в браузере, секундомер пошёл, а звука нет - «поздравляю!» (в кавычках) — вам попался проблемный трек!

Что делать: нажать на кнопку «стоп», перемотать начало трека, с помощью ползунка «Начало фрагмента» и нажать кнопку «Воспроизвести». Проделать так, на разных участках — иногда бывает, что вначале трека идёт огромный кусок тишины.

С чем ещё сталкивался: несмотря на то, что плеер предпрослушивания в браузере подглючивает в виду описанных выше причин, я заметил, что распознавание трека и генерация кода для Arduino IDE всё равно работает корректно (возможно, что я не сталкивался с другими ситуациями просто — не исключаю).

Если вообще ничего не получается с этим треком: бросаем йего и вытираем скупую слезу — селяви :-)

Однако в реальности всё далеко не так плохо и многое работает вообще без каких то проблем или с минимальными проблемами (перемотать начало чуть подальше).

Ну и самое главное – код (копируем, вставляем в блокнот, сохраняем c расширением .html  и запускаем двойным кликом):

КОД КОНВЕРТОРА / ГЕНЕРАТОРА
<!--
  MIDI to Arduino/esp32 controlled Stepper Motor Music Converter
  Created with assistance from DeepSeek Chat AI
  https://deepseek.com
-->

<!DOCTYPE html>
<html>
<head>
  <title>Конвертер midi-файлов - в музыку для шагового двигателя</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
      line-height: 1.6;
    }
    .panel {
      background: #f5f5f5;
      padding: 20px;
      border-radius: 8px;
      margin-bottom: 20px;
    }
    button {
      padding: 10px 15px;
      background: #4CAF50;
      color: white;
      border: none;
      cursor: pointer;
      margin: 5px;
    }
    button:disabled {
      background: #cccccc;
    }
    #midiInfo {
      margin: 15px 0;
      min-height: 60px;
    }
    #codeOutput {
      width: 100%;
      height: 300px;
      font-family: monospace;
      position: relative;
    }
    .status {
      padding: 10px;
      margin: 10px 0;
      border-radius: 4px;
    }
    .status-info {
      background: #d4edda;
      color: #155724;
    }
    .status-error {
      background: #f8d7da;
      color: #721c24;
    }
    .slider-container {
      margin: 10px 0;
    }
    .slider-label {
      display: flex;
      justify-content: space-between;
      margin-bottom: 5px;
    }
    .copy-btn {
      position: absolute;
      top: 5px;
      right: 5px;
      padding: 5px 10px;
      background: #2196F3;
      color: white;
      border: none;
      border-radius: 3px;
      cursor: pointer;
    }
    .track-list {
      margin: 15px 0;
      padding: 10px;
      background: #fff;
      border-radius: 5px;
    }
    .track-item {
      margin: 8px 0;
      padding: 8px;
      background: #f9f9f9;
      border-radius: 3px;
      display: flex;
      align-items: center;
    }
    .track-item input {
      margin-right: 10px;
    }
    .warning {
      background: #fff3cd;
      color: #856404;
      padding: 15px;
      border-radius: 5px;
      margin: 15px 0;
      border-left: 4px solid #ffc107;
    }
    .theory {
      background: #e7f5fe;
      padding: 15px;
      border-radius: 5px;
      margin: 15px 0;
      font-size: 0.9em;
      border-left: 4px solid #2196F3;
    }
    .instrument-icon {
      margin-right: 8px;
      font-size: 1.2em;
    }
    .track-details {
      font-size: 0.85em;
      color: #666;
      margin-left: 5px;
    }
    .drums-label {
      color: #d32f2f;
      font-weight: bold;
      margin-left: 5px;
    }
    .audio-controls {
      margin: 15px 0;
      display: flex;
      gap: 10px;
      align-items: center;
    }
    .trim-controls {
      margin: 15px 0;
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 15px;
    }
    .trim-slider {
      display: flex;
      flex-direction: column;
      gap: 5px;
    }
    .time-display {
      font-family: monospace;
      margin-left: auto;
    }
  </style>
</head>
<body>
  <h2>Конвертер midi-файлов - в музыку для шагового двигателя</h2>
  
  <div class="panel">
    <input type="file" id="midiUpload" accept=".mid,.midi" />
    <div id="midiInfo">Загрузите MIDI-файл</div>
    
    <div class="warning">
      <strong>⚠ Важно!</strong> Некоторые MIDI-файлы могут содержать:
      <ul>
        <li>Нестандартные форматы дорожек</li>
        <li>События без нотных сообщений</li>
        <li>Альтернативные способы кодирования темпа</li>
      </ul>
    </div>

    <div class="theory">
      <strong>? Стандартное расположение инструментов (General MIDI):</strong>
      <ul>
        <li><strong>Канал 1:</strong> ? Фортепиано (основная мелодия)</li>
        <li><strong>Канал 2-5:</strong> ? Струнные / ? Духовые</li>
        <li><strong>Канал 6:</strong> ? Бас-гитара</li>
        <li><strong>Канал 7-8:</strong> ? Аккомпанемент</li>
        <li><strong>Канал 10:</strong> ? Ударные (стандарт)</li>
        <li><strong>Канал 11-16:</strong> ? Солирующие инструменты</li>
      </ul>
    </div>

    <div class="audio-controls">
      <button id="playBtn">▶️ Воспроизвести</button>
      <button id="stopBtn" disabled>⏹️ Стоп</button>
      <div class="time-display" id="timeDisplay">0:00 / 0:00</div>
    </div>

    <div class="trim-controls">
      <div class="trim-slider">
        <label for="trimStart">Начало фрагмента: <span id="trimStartValue">0:00</span></label>
        <input type="range" id="trimStart" min="0" max="100" value="0">
      </div>
      <div class="trim-slider">
        <label for="trimEnd">Конец фрагмента: <span id="trimEndValue">0:00</span></label>
        <input type="range" id="trimEnd" min="0" max="100" value="100">
      </div>
    </div>
    <div id="trimInfo" style="text-align: center; margin: 10px 0;">Фрагмент: 0:00 - 0:00</div>

    <div class="slider-container">
      <div class="slider-label">
        <span>Максимальная частота: <span id="maxFreqValue">2000</span> Гц</span>
      </div>
      <input type="range" id="maxFreq" min="100" max="5000" value="2000" step="10">
    </div>
    
    <div class="slider-container">
      <div class="slider-label">
        <span>Минимальная частота: <span id="minFreqValue">100</span> Гц</span>
      </div>
      <input type="range" id="minFreq" min="50" max="2000" value="100" step="10">
    </div>
    
    <div class="slider-container">
      <div class="slider-label">
        <span>Темп: <span id="tempoValue">100</span>%</span>
      </div>
      <input type="range" id="tempo" min="50" max="200" value="100" step="1">
    </div>
    
    <div class="slider-container">
      <div class="slider-label">
        <span>Громкость: <span id="volumeValue">100</span>%</span>
      </div>
      <input type="range" id="volume" min="0" max="100" value="100" step="1">
    </div>
    
    <div>
      <input type="checkbox" id="filterDrums" checked>
      <label for="filterDrums">Фильтровать ударные (канал 10)</label>
    </div>
    
    <div id="trackSelection" class="track-list" style="display: none;">
      <h4>Дорожки в файле:</h4>
    </div>
    
    <button id="downloadBtn" disabled>Скачать код</button>
    <div id="status" class="status status-info">Кликните по странице для активации звука</div>
  </div>

  <div style="position: relative;">
    <textarea id="codeOutput" readonly></textarea>
    <button id="copyBtn" class="copy-btn" disabled>Копировать</button>
  </div>

  <script>
    const elements = {
      midiUpload: document.getElementById('midiUpload'),
      midiInfo: document.getElementById('midiInfo'),
      downloadBtn: document.getElementById('downloadBtn'),
      copyBtn: document.getElementById('copyBtn'),
      status: document.getElementById('status'),
      codeOutput: document.getElementById('codeOutput'),
      filterDrums: document.getElementById('filterDrums'),
      minFreq: document.getElementById('minFreq'),
      maxFreq: document.getElementById('maxFreq'),
      tempo: document.getElementById('tempo'),
      volume: document.getElementById('volume'),
      minFreqValue: document.getElementById('minFreqValue'),
      maxFreqValue: document.getElementById('maxFreqValue'),
      tempoValue: document.getElementById('tempoValue'),
      volumeValue: document.getElementById('volumeValue'),
      trackSelection: document.getElementById('trackSelection'),
      playBtn: document.getElementById('playBtn'),
      stopBtn: document.getElementById('stopBtn'),
      timeDisplay: document.getElementById('timeDisplay'),
      trimStart: document.getElementById('trimStart'),
      trimEnd: document.getElementById('trimEnd'),
      trimStartValue: document.getElementById('trimStartValue'),
      trimEndValue: document.getElementById('trimEndValue'),
      trimInfo: document.getElementById('trimInfo')
    };

    let audioContext = null;
    let currentMidiData = null;
    let isPlaying = false;
    let playbackStartTime = 0;
    let totalDuration = 0;
    let trimStart = 0;
    let trimEnd = 100;
    let bpm = 120;
    let activeOscillators = new Set();
    let selectedStartTime = 0;
    let selectedEndTime = 0;

    function initAudio() {
      if (!audioContext) {
        audioContext = new (window.AudioContext || window.webkitAudioContext)();
        showStatus("Аудио активировано. Теперь можно загружать MIDI");
      }
    }

    function updateSliderValues() {
      elements.minFreqValue.textContent = elements.minFreq.value;
      elements.maxFreqValue.textContent = elements.maxFreq.value;
      elements.tempoValue.textContent = elements.tempo.value;
      elements.volumeValue.textContent = elements.volume.value;
      
      selectedStartTime = totalDuration * (parseInt(elements.trimStart.value) / 100);
      selectedEndTime = totalDuration * (parseInt(elements.trimEnd.value) / 100);
      
      elements.trimStartValue.textContent = formatTime(selectedStartTime);
      elements.trimEndValue.textContent = formatTime(selectedEndTime);
      
      trimStart = parseInt(elements.trimStart.value);
      trimEnd = parseInt(elements.trimEnd.value);
      elements.trimInfo.textContent = `Фрагмент: ${formatTime(selectedStartTime)} - ${formatTime(selectedEndTime)}`;
      
      // Обновляем таймер сразу при изменении ползунков
      if (!isPlaying) {
        elements.timeDisplay.textContent = `${formatTime(selectedStartTime)} / ${formatTime(selectedEndTime)}`;
      }
    }

    function formatTime(seconds) {
      const mins = Math.floor(seconds / 60);
      const secs = Math.floor(seconds % 60);
      return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
    }

    function playMidi() {
      if (!currentMidiData || isPlaying || !audioContext) return;
      
      if (audioContext.state === 'suspended') {
        audioContext.resume().then(() => {
          startPlayback();
        });
      } else {
        startPlayback();
      }
    }

    function startPlayback() {
      stopPlayback();
      
      isPlaying = true;
      elements.playBtn.disabled = true;
      elements.stopBtn.disabled = false;
      
      const startTime = audioContext.currentTime;
      playbackStartTime = startTime;
      
      const startTimePercent = trimStart / 100;
      const endTimePercent = trimEnd / 100;
      const fragmentDuration = selectedEndTime - selectedStartTime;
      
      elements.timeDisplay.textContent = `${formatTime(selectedStartTime)} / ${formatTime(selectedEndTime)}`;
      
      const selectedTracks = getSelectedTracks();
      const tempoFactor = 100 / elements.tempo.value;
      const volume = elements.volume.value / 100;
      
      const microsecondsPerTick = 60000000 / (bpm * currentMidiData.header.ticksPerBeat);
      
      selectedTracks.forEach(trackIndex => {
        const track = currentMidiData.tracks[trackIndex];
        
        track.notes.forEach(note => {
          if (elements.filterDrums.checked && note.channel === 9) return;
          
          const noteTime = note.time * microsecondsPerTick / 1000000;
          
          if (noteTime >= selectedStartTime && noteTime <= selectedEndTime) {
            const adjustedTime = (noteTime - selectedStartTime) * tempoFactor;
            const noteDuration = (currentMidiData.header.ticksPerBeat / 2) * microsecondsPerTick / 1000000 * tempoFactor;
            
            try {
              const oscillator = audioContext.createOscillator();
              const gainNode = audioContext.createGain();
              
              oscillator.type = 'sine';
              oscillator.frequency.value = midiToFrequency(note.midi);
              gainNode.gain.value = (note.velocity / 127) * volume;
              
              oscillator.connect(gainNode);
              gainNode.connect(audioContext.destination);
              
              oscillator.start(startTime + adjustedTime);
              oscillator.stop(startTime + adjustedTime + noteDuration);
              
              activeOscillators.add(oscillator);
              oscillator.onended = () => activeOscillators.delete(oscillator);
            } catch (e) {
              console.error("Ошибка создания осциллятора:", e);
            }
          }
        });
      });
      
      const updateTime = () => {
        if (!isPlaying || !audioContext) return;
        
        const currentPlayTime = audioContext.currentTime - playbackStartTime;
        const currentTrackTime = selectedStartTime + currentPlayTime;
        
        if (currentTrackTime <= selectedEndTime) {
          elements.timeDisplay.textContent = `${formatTime(currentTrackTime)} / ${formatTime(selectedEndTime)}`;
          requestAnimationFrame(updateTime);
        } else {
          stopPlayback();
        }
      };
      
      requestAnimationFrame(updateTime);
    }

    function stopPlayback() {
      isPlaying = false;
      elements.playBtn.disabled = false;
      elements.stopBtn.disabled = true;
      
      activeOscillators.forEach(osc => {
        try { 
          osc.stop();
          osc.disconnect();
        } catch (e) {
          console.error("Ошибка остановки осциллятора:", e);
        }
      });
      activeOscillators.clear();
      
      // Возвращаем таймер к началу выбранного фрагмента
      elements.timeDisplay.textContent = `${formatTime(selectedStartTime)} / ${formatTime(selectedEndTime)}`;
    }

    function getSelectedTracks() {
      const selectedTracks = [];
      const checkboxes = elements.trackSelection.querySelectorAll('input[type="checkbox"]');
      
      checkboxes.forEach((cb, index) => {
        if (cb.checked) selectedTracks.push(index);
      });
      
      return selectedTracks;
    }

    function analyzeTrack(track, index) {
      if (!track.notes || track.notes.length === 0) {
        return {
          instrument: "❓ Пустая дорожка",
          details: "Нет нот для анализа",
          isDrums: false,
          channel: null
        };
      }

      const channel = track.notes[0].channel + 1;
      const notes = track.notes.map(n => n.midi);
      const minNote = Math.min(...notes);
      const maxNote = Math.max(...notes);
      const noteRange = maxNote - minNote;
      const isDrums = (track.notes[0].channel === 9);
      
      let instrument = "? Инструмент";
      let details = `Канал ${channel}, ноты: ${minNote}-${maxNote}`;

      if (isDrums) {
        instrument = "? Ударные";
      } else if (minNote < 40 && noteRange < 12) {
        instrument = "? Бас";
      } else if (minNote > 60 && noteRange > 12) {
        instrument = "? Мелодия";
      } else if (noteRange > 24) {
        instrument = "? Аккомпанемент";
      } else if (channel === 10) {
        instrument = "? Ударные (канал 10)";
      }

      details = `Нот: ${track.notes.length}, ${details}`;

      return {
        instrument,
        details,
        isDrums,
        channel
      };
    }

    function createTrackSelection(tracks) {
      elements.trackSelection.innerHTML = '<h4>Дорожки в файле:</h4>';
      
      tracks.forEach((track, index) => {
        const analysis = analyzeTrack(track, index);
        const div = document.createElement('div');
        div.className = 'track-item';
        
        div.innerHTML = `
          <input type="checkbox" id="track-${index}" checked>
          <label for="track-${index}">
            <span class="instrument-icon">${analysis.instrument.split(' ')[0]}</span>
            <strong>Дорожка ${index + 1}:</strong> ${analysis.instrument}
            <span class="track-details">${analysis.details}</span>
            ${analysis.isDrums ? '<span class="drums-label">[ударные]</span>' : ''}
          </label>
        `;
        
        div.querySelector('input').addEventListener('change', handleChanges);
        elements.trackSelection.appendChild(div);
      });
      
      elements.trackSelection.style.display = 'block';
    }

    function parseMidi(arrayBuffer) {
      const dataView = new DataView(arrayBuffer);
      let pos = 0;
      
      if (dataView.getUint32(pos) !== 0x4D546864) throw new Error("Неверный MIDI-файл");
      pos += 8;
      
      const format = dataView.getUint16(pos);
      pos += 2;
      const numTracks = dataView.getUint16(pos);
      pos += 2;
      const ticksPerBeat = dataView.getUint16(pos);
      pos += 2;
      
      const tracks = [];
      let totalNotes = 0;
      let maxTime = 0;
      let tempo = 500000;
      let hasNotes = false;
      
      for (let i = 0; i < numTracks; i++) {
        if (dataView.getUint32(pos) !== 0x4D54726B) throw new Error("Ошибка формата дорожки");
        pos += 4;
        
        const trackLength = dataView.getUint32(pos);
        pos += 4;
        const trackEnd = pos + trackLength;
        
        const trackNotes = [];
        let currentTime = 0;
        let currentChannel = 0;
        let runningStatus = null;
        
        while (pos < trackEnd) {
          let deltaTime = 0;
          let byte;
          do {
            byte = dataView.getUint8(pos++);
            deltaTime = (deltaTime << 7) | (byte & 0x7F);
          } while (byte & 0x80);
          
          currentTime += deltaTime;
          let eventTypeByte = dataView.getUint8(pos);
          
          if ((eventTypeByte & 0x80) === 0) {
            if (runningStatus === null) {
              throw new Error("Неожиданный статус выполнения");
            }
            eventTypeByte = runningStatus;
          } else {
            runningStatus = eventTypeByte;
            pos++;
          }
          
          const eventType = eventTypeByte & 0xF0;
          
          if (eventType === 0x90) {
            const note = dataView.getUint8(pos++);
            const velocity = dataView.getUint8(pos++);
            currentChannel = eventTypeByte & 0x0F;
            
            if (velocity > 0) {
              trackNotes.push({
                midi: note,
                time: currentTime,
                channel: currentChannel,
                velocity: velocity
              });
              totalNotes++;
              hasNotes = true;
              
              if (currentTime > maxTime) maxTime = currentTime;
            }
          } else if (eventType === 0x80) {
            pos += 2;
          } else if (eventTypeByte === 0xFF && dataView.getUint8(pos) === 0x51) {
            pos++;
            const length = dataView.getUint8(pos++);
            tempo = 0;
            for (let j = 0; j < length; j++) {
              tempo = (tempo << 8) | dataView.getUint8(pos++);
            }
          } else {
            const eventLength = getMidiEventLength(eventTypeByte, dataView, pos);
            pos += eventLength;
          }
        }
        
        if (trackNotes.length > 0) {
          tracks.push({ notes: trackNotes });
        }
      }
      
      if (!hasNotes) {
        throw new Error("Файл не содержит нотных событий");
      }
      
      bpm = Math.round(60000000 / tempo);
      
      const microsecondsPerTick = tempo / ticksPerBeat;
      totalDuration = maxTime * microsecondsPerTick / 1000000;
      selectedStartTime = 0;
      selectedEndTime = totalDuration;
      
      return { 
        tracks, 
        header: { format, ticksPerBeat }, 
        totalNotes,
        duration: totalDuration
      };
    }
    
    function getMidiEventLength(eventType, dataView, pos) {
      const highNibble = eventType & 0xF0;
      if (highNibble === 0x80 || highNibble === 0x90 || highNibble === 0xA0 || 
          highNibble === 0xB0 || highNibble === 0xE0) return 2;
      else if (highNibble === 0xC0 || highNibble === 0xD0) return 1;
      else if (eventType === 0xFF) return 2 + dataView.getUint8(pos + 1);
      return 0;
    }

    function midiToFrequency(note) {
      const freq = 440 * Math.pow(2, (note - 69) / 12);
      return isFinite(freq) ? freq : 440;
    }

    function handleChanges() {
      updateSliderValues();
      if (currentMidiData) {
        processMidi(currentMidiData);
      }
    }

    function processMidi(midiData) {
      const filterDrums = elements.filterDrums.checked;
      const minFreq = parseInt(elements.minFreq.value);
      const maxFreq = parseInt(elements.maxFreq.value);
      const tempoFactor = 100 / elements.tempo.value;
      
      const selectedTracks = getSelectedTracks();
      
      const startPercent = trimStart / 100;
      const endPercent = trimEnd / 100;
      const startTime = midiData.duration * startPercent;
      const endTime = midiData.duration * endPercent;
      
      const allNotes = [];
      selectedTracks.forEach(trackIndex => {
        const track = midiData.tracks[trackIndex];
        
        track.notes.forEach(note => {
          if (filterDrums && note.channel === 9) return;
          
          const microsecondsPerTick = 60000000 / (bpm * midiData.header.ticksPerBeat);
          const noteTime = note.time * microsecondsPerTick / 1000000;
          
          if (noteTime < startTime || noteTime > endTime) return;
          
          const freq = midiToFrequency(note.midi);
          if (freq < minFreq || freq > maxFreq) return;
          
          const adjustedTime = (noteTime - startTime) * tempoFactor;
          const noteDuration = 100 * tempoFactor;
          
          allNotes.push({
            midi: note.midi,
            time: adjustedTime,
            duration: noteDuration,
            velocity: note.velocity
          });
        });
      });

      allNotes.sort((a, b) => a.time - b.time);
      const optimizedNotes = optimizeNotes(allNotes, minFreq, maxFreq);
      generateArduinoCode(optimizedNotes);
    }

    function optimizeNotes(notes, minFreq, maxFreq) {
      const optimized = [];
      let lastEndTime = 0;
      
      for (const note of notes) {
        const freq = midiToFrequency(note.midi);
        if (freq < minFreq || freq > maxFreq) continue;
        
        const startTime = Math.max(lastEndTime, note.time);
        const endTime = startTime + note.duration;
        
        optimized.push({
          freq: freq,
          duration: note.duration,
          startTime: startTime,
          endTime: endTime
        });
        
        lastEndTime = endTime;
      }
      
      return optimized;
    }

    function generateArduinoCode(notes) {
      if (notes.length === 0) {
          elements.codeOutput.value = "// Нет нот для воспроизведения";
          elements.downloadBtn.disabled = true;
          elements.copyBtn.disabled = true;
          return;
      }
      
      const tempoFactor = elements.tempo.value / 100;
      
      const freqs = notes.map(n => Math.round(n.freq)).filter(f => !isNaN(f));
      const durations = notes.map(n => Math.round(n.duration / tempoFactor));
      
      const delays = [];
      for (let i = 1; i < notes.length; i++) {
          delays.push(Math.max(1, Math.round((notes[i].startTime - notes[i-1].endTime) / tempoFactor)));
      }
      delays.push(50 / tempoFactor);
      
      elements.codeOutput.value = `#include <Arduino.h>

const uint8_t COIL_A1 = 12;
const uint8_t COIL_A2 = 14;
const uint8_t COIL_B1 = 27;
const uint8_t COIL_B2 = 26;

const uint16_t MIN_FREQ = ${elements.minFreq.value};
const uint16_t MAX_FREQ = ${elements.maxFreq.value};

const uint16_t melodyFreqs[] PROGMEM = {
    ${freqs.join(',\n    ')}
};

const uint16_t melodyDurations[] PROGMEM = {
    ${durations.join(',\n    ')}
};

const uint16_t melodyDelays[] PROGMEM = {
    ${delays.join(',\n    ')}
};

void activateCoil(uint8_t pin1, uint8_t pin2) {
  digitalWrite(pin1, HIGH);
  digitalWrite(pin2, LOW);
  delayMicroseconds(300);
  digitalWrite(pin1, LOW);
  digitalWrite(pin2, LOW);
}

void playNote(uint16_t freq, uint16_t dur) {
    if (freq < MIN_FREQ || freq > MAX_FREQ || dur < 5) {
        delay(dur);
        return;
    }
    
    uint32_t period = 1000000 / freq;
    uint32_t elapsed = 0;
    uint32_t durationMicros = dur * 1000L;
    
    while (elapsed < durationMicros) {
        uint32_t start = micros();
        
        activateCoil(COIL_A1, COIL_A2);
        delayMicroseconds(period/2 - 300);
        activateCoil(COIL_B1, COIL_B2);
        delayMicroseconds(period/2 - 300);
        
        elapsed += micros() - start;
    }
}

void setup() {
    pinMode(COIL_A1, OUTPUT);
    pinMode(COIL_A2, OUTPUT);
    pinMode(COIL_B1, OUTPUT);
    pinMode(COIL_B2, OUTPUT);
}

void loop() {
    for (uint16_t i = 0; i < ${notes.length}; i++) {
        uint16_t freq = pgm_read_word(&melodyFreqs[i]);
        uint16_t dur = pgm_read_word(&melodyDurations[i]);
        playNote(freq, dur);
        
        uint16_t del = pgm_read_word(&melodyDelays[i]);
        if (del > 0) delay(del);
    }
}`;

      elements.downloadBtn.disabled = false;
      elements.copyBtn.disabled = false;
    }

    function copyToClipboard() {
      elements.codeOutput.select();
      document.execCommand('copy');
      showStatus("Код скопирован в буфер обмена!");
      setTimeout(() => showStatus("Готово"), 2000);
    }

    function downloadCode() {
      const blob = new Blob([elements.codeOutput.value], { type: 'text/plain' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'motor_music.ino';
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }

    function showStatus(message, isError = false) {
      elements.status.textContent = message;
      elements.status.className = isError ? 'status status-error' : 'status status-info';
    }

    function init() {
      document.addEventListener('click', function initAudioOnClick() {
        initAudio();
        document.removeEventListener('click', initAudioOnClick);
      }, { once: true });

      elements.midiUpload.addEventListener('change', async function(e) {
        const file = e.target.files[0];
        if (!file) return;
        
        if (!audioContext) {
          showStatus("Ошибка: сначала кликните по странице для активации звука", true);
          return;
        }
        
        showStatus("Загрузка MIDI...");
        elements.downloadBtn.disabled = true;
        elements.copyBtn.disabled = true;
        elements.playBtn.disabled = true;
        elements.stopBtn.disabled = true;
        
        try {
          const arrayBuffer = await file.arrayBuffer();
          currentMidiData = parseMidi(arrayBuffer);
          
          elements.midiInfo.innerHTML = `
            <strong>${file.name}</strong><br>
            Дорожек: ${currentMidiData.tracks.length}<br>
            Нот: ${currentMidiData.totalNotes}<br>
            Длительность: ${formatTime(currentMidiData.duration)}<br>
            Темп: ${bpm} BPM
          `;
          
          createTrackSelection(currentMidiData.tracks);
          processMidi(currentMidiData);
          
          elements.playBtn.disabled = false;
          elements.timeDisplay.textContent = `${formatTime(selectedStartTime)} / ${formatTime(selectedEndTime)}`;
          showStatus("MIDI загружен. Нажмите 'Воспроизвести'");
        } catch (err) {
          console.error("Ошибка:", err);
          showStatus("Ошибка: " + err.message, true);
        }
      });

      elements.playBtn.addEventListener('click', playMidi);
      elements.stopBtn.addEventListener('click', stopPlayback);
      
      elements.minFreq.addEventListener('input', handleChanges);
      elements.maxFreq.addEventListener('input', handleChanges);
      elements.tempo.addEventListener('input', handleChanges);
      elements.volume.addEventListener('input', handleChanges);
      elements.filterDrums.addEventListener('change', handleChanges);
      elements.trimStart.addEventListener('input', handleChanges);
      elements.trimEnd.addEventListener('input', handleChanges);
      
      elements.copyBtn.addEventListener('click', copyToClipboard);
      elements.downloadBtn.addEventListener('click', downloadCode);
      
      updateSliderValues();
      showStatus("Кликните по странице для активации звука");
    }

    init();
  </script>
</body>
</html>

Итак, теперь, у вас есть инструмент, который позволяет достаточно легко внедрять музыку, в ваши любительские проекты — нужно только запустить генератор, загрузить туда midi-трек, скачать или скопировать сгенерированный код и использовать в своих проектах! 

Исходников, то бишь midi-файлов в сети можно найти великое множество.

Ну что, остаётся только сказать «а-аай, арриба» и достать из широких штанин свои маракасы с полки шаговый двигатель?!  :-)

Комментарии (2)


  1. Prohard
    14.08.2025 07:25

    А как с примерами звучания шагового двигателя?


    1. cnet Автор
      14.08.2025 07:25

      Вначале добавил в конец статьи - потом снёс :-D, дабы не нарушать права кое кого.

      Но в личку могу кинуть.

      UPD.Кинул в личку